feat(calendar): iCal subscription feed for RSVP'd events - #16
Open
elijahchancey wants to merge 2 commits into
Open
feat(calendar): iCal subscription feed for RSVP'd events#16elijahchancey wants to merge 2 commits into
elijahchancey wants to merge 2 commits into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_countcode already uses againstHASURA_URL. The feed resolves the token → citizen → linked emails (across merged accounts) → Hasura → spec-compliant iCalendar.Endpoints (mounted at
/calendar)GET/POST/calendar/subscriptionPOST/calendar/subscription/regenerateDELETE/calendar/subscriptionGET/calendar/{token}/rsvp.icstext/calendar)Feed URL shape:
https://<backend>/calendar/{token}/rsvp.ics(also surfaced aswebcal://…).Implementation notes
CalendarSubscriptionmodel (one revocable token per citizen) + Alembic migration chained off the current headc3d4e5f6a7b8. Mirrors the existingAttendeeTicketApiKeytoken pattern (secrets.token_urlsafe(32)).uv.lockchurn). EmitsUID,DTSTAMP,DTSTART/DTEND(UTC),SUMMARY,LOCATION,DESCRIPTION,CATEGORIES,STATUS, plusREFRESH-INTERVAL/X-PUBLISHED-TTLrefresh hints. Correct 75-octet line folding (UTF-8 aware) and TEXT escaping.VEVENT(noRRULEneeded).Sample output
(08:00
America/Los_Angeles→15:00Z; comma/semicolon/newline escaped; long lines folded.)Testing
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).icalendar, dev-only — not added to project deps).upgrade()/downgrade()verified against SQLite (correct columns, unique indexes oncitizen_id/token, FK tohumans).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