Skip to content

feat(calendar): iCal subscription feed for RSVP'd events - #16

Open
elijahchancey wants to merge 2 commits into
p2p-lanes:mainfrom
elijahchancey:feat/ical-rsvp-subscription
Open

feat(calendar): iCal subscription feed for RSVP'd events#16
elijahchancey wants to merge 2 commits into
p2p-lanes:mainfrom
elijahchancey:feat/ical-rsvp-subscription

Conversation

@elijahchancey

Copy link
Copy Markdown

What & why

Adds calendar subscription URLs so a user can paste a link into Apple/Google/Outlook Calendar and have their RSVP'd events auto-sync. Calendar apps fetch subscription URLs unauthenticated (no cookies / interactive OAuth), so this uses a per-user, revocable subscription token embedded in the URL.

Individual events (talks, sessions, activities) are not stored in this backend — they live in the external Social Layer (Hasura GraphQL). "RSVP'd" means the citizen is a participant, which is exactly the filter the profile/_get_events_count code already uses against HASURA_URL. The feed resolves the token → citizen → linked emails (across merged accounts) → Hasura → spec-compliant iCalendar.

Scope: this is the RSVP'd-events feed only. An "all events" feed was intentionally deferred (it needs a popup→Social-Layer-group mapping that doesn't exist yet).

Endpoints (mounted at /calendar)

Method Path Auth Purpose
GET/POST /calendar/subscription JWT Get-or-create the citizen's token + feed URLs
POST /calendar/subscription/regenerate JWT Rotate the token (old URL stops working)
DELETE /calendar/subscription JWT Revoke the token
GET /calendar/{token}/rsvp.ics token-in-URL The iCalendar feed (text/calendar)

Feed URL shape: https://<backend>/calendar/{token}/rsvp.ics (also surfaced as webcal://…).

Implementation notes

  • New CalendarSubscription model (one revocable token per citizen) + Alembic migration chained off the current head c3d4e5f6a7b8. Mirrors the existing AttendeeTicketApiKey token pattern (secrets.token_urlsafe(32)).
  • Hand-rolled RFC 5545 serializer — deliberately no new runtime dependency (avoids uv.lock churn). Emits UID, DTSTAMP, DTSTART/DTEND (UTC), SUMMARY, LOCATION, DESCRIPTION, CATEGORIES, STATUS, plus REFRESH-INTERVAL/X-PUBLISHED-TTL refresh hints. Correct 75-octet line folding (UTF-8 aware) and TEXT escaping.
  • Recurring events: the Social Layer returns one row per occurrence, so each RSVP'd occurrence becomes its own VEVENT (no RRULE needed).
  • Resilient fetch: a field-fallback GraphQL query (rich projection → minimal projection) and total error-swallowing so the endpoint always returns a valid calendar (empty rather than 500) if the Social Layer is unreachable.

Sample output

BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//EdgeOS//Calendar Subscription//EN
CALSCALE:GREGORIAN
METHOD:PUBLISH
NAME:Edge — My RSVP'd Events
REFRESH-INTERVAL;VALUE=DURATION:PT1H
X-PUBLISHED-TTL:PT1H
BEGIN:VEVENT
UID:qigong-w1@sociallayer.edgeos
DTSTAMP:20260603T185830Z
DTSTART:20260603T150000Z
DTEND:20260603T154500Z
SUMMARY:Qi Gong
STATUS:CONFIRMED
LOCATION:The Hub - Wellness Space - 405 Healdsburg Ave
DESCRIPTION:A moving meditation rooted in Traditional Chinese Medicine.\n…
CATEGORIES:Exercise,wellness,movement
END:VEVENT
END:VCALENDAR

(08:00 America/Los_Angeles15:00Z; comma/semicolon/newline escaped; long lines folded.)

Testing

  • 19 new tests (tests/test_calendar.py): token lifecycle (create/idempotent/regenerate/revoke/auth-required/per-citizen isolation), the feed (valid token, invalid token → 404, empty calendar, linked-email resolution), the Social Layer fetch (no-emails, no-HASURA_URL, field-fallback, network-error swallowing), and the serializer (escaping, folding ≤75 octets, CRLF, timezone conversion, missing-end default, skip-no-start).
  • Output validated against a real iCalendar parser (icalendar, dev-only — not added to project deps).
  • Full existing suite still green: 150 passed.
  • Migration upgrade()/downgrade() verified against SQLite (correct columns, unique indexes on citizen_id/token, FK to humans).

Frontend

The companion Citizen-Portal PR (surfacing these URLs on the profile page with copy / webcal:// subscribe / regenerate) depends on this and will reference it.

🤖 Generated with Claude Code

Adds a per-citizen, revocable calendar subscription token and an
unauthenticated iCalendar (.ics) feed of the events a citizen has RSVP'd
to. Calendar apps (Apple/Google/Outlook) poll subscription URLs without
cookies or interactive login, so the bearer secret lives in the URL.

Individual events live in the external Social Layer (Hasura GraphQL), not
in this backend — the feed resolves the token to a citizen, gathers their
linked emails (across merged accounts, same as the profile), and queries
Hasura for events they participate in, transforming them into spec-
compliant VCALENDAR/VEVENT output (UTC times, escaping, 75-octet line
folding, refresh hints). Recurring events are returned by the Social
Layer as one row per occurrence, so each becomes its own VEVENT.

Endpoints (mounted at /calendar):
- GET/POST  /calendar/subscription            get-or-create token + URLs (JWT)
- POST      /calendar/subscription/regenerate rotate token (JWT)
- DELETE    /calendar/subscription            revoke token (JWT)
- GET       /calendar/{token}/rsvp.ics        the feed (token-authed)

- New CalendarSubscription model + Alembic migration (chained off head).
- Hand-rolled RFC 5545 serializer (no new runtime dependency / lockfile
  churn); Social Layer fetch degrades gracefully (field-fallback query,
  never raises -> always a valid calendar).
- 19 tests covering token lifecycle, the feed, the Social Layer fetch,
  and the serializer (incl. a parser-validated spec-compliance check).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…il case

- Feed responses now send `Cache-Control: private, no-store` instead of
  `public, max-age=3600`. The URL token is a per-user bearer secret, so
  shared caches/CDNs must not store the response — otherwise a revoked or
  regenerated token's old response could still be served, defeating
  revocation. Poll cadence is already conveyed by the in-body
  REFRESH-INTERVAL / X-PUBLISHED-TTL.
- get_linked_emails no longer lowercases emails. The Social Layer `_in`
  filter matches profile.email exactly, and the existing event-count path
  passes stored emails through unchanged; lowercasing could miss RSVPs for
  mixed-case addresses. Now dedupes case-insensitively while preserving the
  stored form.
- Added tests: feed is not publicly cacheable; linked emails keep case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant