feat: subscribable iCal calendar feed (#107 Phase A; Phase B dropped) #188

Merged
claude-bot merged 2 commits from feat/107-calendar-feed into main 2026-07-17 21:37:27 +00:00
Contributor

Implements Phase A of #107. Phase B (Google Calendar OAuth) is intentionally dropped — see the investigation below; the owner opted to replace it with emailed calendar invites (separate follow-up). Fourth and last of the v3.6.0 (Scheduling) milestone.

Phase A — the feed

Calendar export was one-shot per session (GET /sessions/{id}/calendar.ics) and never reflected reschedules/cancellations. Now each user gets a subscribable personal feed.

  • GET /calendar/feed/{token}.ics — unauthenticated (token-based), rate-limited, text/calendar. Lists the user's sessions across all their campaigns (90-day trailing window).
  • users.calendar_feed_token — opaque per-user token (mirrors analytics_share_token); regenerating revokes the old URL. Managed via GET/POST/DELETE /api/me/calendar-feed.
  • app/services/calendar_service.py — a shared multi-VEVENT builder: stable UID per session, UTC DTSTART/DTEND (end_time or +4h), SEQUENCE/LAST-MODIFIED so clients refresh on change, and STATUS:CANCELLED for cancelled sessions (subscribers remove them). The existing per-session download now reuses this builder.
  • sessions.updated_at — added (it didn't exist; server_default=now(), onupdate=now()), which drives SEQUENCE.
  • Frontend: src/api/calendar.js + a "Calendar subscription" card on Profile (feed URL + copy, generate/regenerate/revoke).

Acceptance criteria (Phase A)

  • Subscribing shows all confirmed sessions; the feed needs no session cookie (token only).
  • Rescheduling bumps SEQUENCE/LAST-MODIFIED so clients update.
  • Cancelling emits STATUS:CANCELLED.
  • Regenerating the token 404s the old URL.

Phase B — investigation & decision (dropped)

Requested by the issue's checklist. Findings:

  • The feed already gives Apple/Thunderbird 5–15 min refresh (configurable) and Outlook ~3h; only Google Calendar is slow (8–24h, not configurable).
  • Google push would need, per self-hosted instance, its own Google Cloud project + OAuth client, and the calendar.events sensitive scope requires either Google verification (video demo, ~10-day review) or the "unverified app" warning + a 100-user lifetime cap that can't be reset.

Conclusion: Google OAuth push is poor cost/benefit for a self-hosted tool — heavy per-instance setup + verification friction to fix only Google's refresh lag. Dropped. The owner chose emailed iCalendar invites (METHOD:REQUEST/CANCEL on the existing confirmation/cancellation emails) as the near-instant, provider-agnostic, no-OAuth replacement — filed as a follow-up.

Sources: Google sensitive-scope verification, unverified apps / 100-user cap, ICS refresh rates.

Verification

Backend (dev-host harness): migration applies from base and downgrade -1 → upgrade head round-trips (both new columns drop/recreate); 430 passed (9 new); ruff clean.
Frontend (node:20): lint clean, 169 tests (20 new), build succeeds.

Deploy note: migration c0d1e2f3a4b5 runs with the v3.6.0 deploy (backup-first), after #98's and #104's.

🤖 Generated with Claude Code

Implements **Phase A** of #107. **Phase B (Google Calendar OAuth) is intentionally dropped** — see the investigation below; the owner opted to replace it with emailed calendar invites (separate follow-up). Fourth and last of the v3.6.0 (Scheduling) milestone. ## Phase A — the feed Calendar export was one-shot per session (`GET /sessions/{id}/calendar.ics`) and never reflected reschedules/cancellations. Now each user gets a **subscribable personal feed**. - **`GET /calendar/feed/{token}.ics`** — unauthenticated (token-based), rate-limited, `text/calendar`. Lists the user's sessions across all their campaigns (90-day trailing window). - **`users.calendar_feed_token`** — opaque per-user token (mirrors `analytics_share_token`); regenerating **revokes the old URL**. Managed via `GET/POST/DELETE /api/me/calendar-feed`. - **`app/services/calendar_service.py`** — a shared multi-VEVENT builder: stable `UID` per session, UTC `DTSTART`/`DTEND` (`end_time` or +4h), `SEQUENCE`/`LAST-MODIFIED` so clients refresh on change, and `STATUS:CANCELLED` for cancelled sessions (subscribers remove them). The existing per-session download now reuses this builder. - **`sessions.updated_at`** — added (it didn't exist; `server_default=now()`, `onupdate=now()`), which drives `SEQUENCE`. - **Frontend**: `src/api/calendar.js` + a "Calendar subscription" card on Profile (feed URL + copy, generate/regenerate/revoke). ### Acceptance criteria (Phase A) - [x] Subscribing shows all confirmed sessions; the feed needs no session cookie (token only). - [x] Rescheduling bumps `SEQUENCE`/`LAST-MODIFIED` so clients update. - [x] Cancelling emits `STATUS:CANCELLED`. - [x] Regenerating the token 404s the old URL. ## Phase B — investigation & decision (dropped) Requested by the issue's checklist. Findings: - The feed already gives **Apple/Thunderbird 5–15 min** refresh (configurable) and **Outlook ~3h**; only **Google Calendar is slow (8–24h, not configurable)**. - Google push would need, **per self-hosted instance**, its own Google Cloud project + OAuth client, and the `calendar.events` **sensitive scope** requires either Google verification (video demo, ~10-day review) or the "unverified app" warning + a **100-user lifetime cap that can't be reset**. **Conclusion:** Google OAuth push is poor cost/benefit for a self-hosted tool — heavy per-instance setup + verification friction to fix only Google's refresh lag. Dropped. The owner chose **emailed iCalendar invites** (`METHOD:REQUEST`/`CANCEL` on the existing confirmation/cancellation emails) as the near-instant, provider-agnostic, no-OAuth replacement — filed as a follow-up. Sources: [Google sensitive-scope verification](https://developers.google.com/identity/protocols/oauth2/production-readiness/sensitive-scope-verification), [unverified apps / 100-user cap](https://support.google.com/cloud/answer/7454865?hl=en), [ICS refresh rates](https://www.usecarly.com/blog/google-calendar-ics-refresh-rate/). ## Verification Backend (dev-host harness): migration applies from base **and** `downgrade -1 → upgrade head` round-trips (both new columns drop/recreate); **430 passed** (9 new); ruff clean. Frontend (node:20): lint clean, **169 tests** (20 new), build succeeds. **Deploy note:** migration `c0d1e2f3a4b5` runs with the v3.6.0 deploy (backup-first), after #98's and #104's. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Calendar export was one-shot per-session (GET /sessions/{id}/calendar.ics) and
never updated on reschedule/cancel. Add a subscribable per-user feed so a group
can subscribe once in Google/Apple/Outlook and see all their confirmed sessions,
kept current.

Backend (migration c0d1e2f3a4b5, down_revision b9c0d1e2f3a4):
- users.calendar_feed_token (nullable, unique) — an opaque per-user token,
  mirroring the campaign analytics_share_token pattern; regenerating it revokes
  the old URL.
- sessions.updated_at (server_default now, onupdate now) — did not exist before;
  drives the feed's SEQUENCE/LAST-MODIFIED so calendar clients refresh on change.
- app/services/calendar_service.py: a shared multi-VEVENT ICS builder. Stable
  UID per session, DTSTART/DTEND in UTC (end_time or +4h), SEQUENCE =
  int(updated_at.timestamp()), STATUS:CANCELLED for cancelled sessions (so
  subscribers remove them). Covers all campaigns the user belongs to, within a
  90-day trailing window. The per-session download now reuses this builder.
- GET /calendar/feed/{token}.ics — unauthenticated, rate-limited, text/calendar.
- GET/POST/DELETE /api/me/calendar-feed — view / generate-or-rotate / revoke.

Frontend: src/api/calendar.js + a "Calendar subscription" card on Profile
(feed URL + copy, generate/regenerate/revoke).

Verified via the dev-host harness: migration applies from base and downgrade -1
+ upgrade head round-trips (both new columns drop/recreate); backend 430 passed
(9 new), ruff clean; frontend lint clean, 169 tests (20 new), build succeeds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
docs: changelog for calendar feed (#107 Phase A)
All checks were successful
CI / Backend lint (ruff) (pull_request) Successful in 58s
CI / Frontend tests, audit, and build (pull_request) Successful in 1m54s
CI / Bot tests and audit (pull_request) Successful in 2m13s
CI / Docker image build (pull_request) Successful in 2m55s
CI / Backend migration, tests, and audit (pull_request) Successful in 4m5s
7b124d9dae
claude-bot deleted branch feat/107-calendar-feed 2026-07-17 21:37:27 +00:00
Sign in to join this conversation.
No description provided.