Skip to content

0011 — Community Calendar ​

Status: Accepted (2026-06-18 — owner accepted during ADR review)

Date: 2026-06-17

ADO work item: AB#3088

Deciders: Kristopher Turner (platform owner)


Context ​

The Community Calendar is identified in the platform strategy as the primary engagement driver for Heritage Virginia members (Epic AB#3075; Feature AB#3088). Members need a single, authoritative place to see church events, RSVP, track attendance, and coordinate across ministries and small groups — from Sunday services and Wednesday nights to youth activities, homeschool co-op days, and community service events.

Several platform constraints bear directly on calendar design:

  • Closed community (ADR 0007). All data is member-only. Event visibility must respect the approved-member boundary and be further scoped by role and ministry/small-group membership. A guest or unapproved-signup account must not see internal events.
  • Five-role RBAC, server-side (ADR 0006). Which events a member sees, whether they can RSVP, whether they can manage events or record attendance — all of these are server-enforced role decisions. The client renders what the API returns; it never makes authorization decisions of record.
  • API-first / headless (ADR 0008). Business logic — including all recurrence expansion, visibility filtering, RSVP writes, and attendance recording — lives in apps/api. Web and mobile are thin clients that consume api-client. No surface talks to the database directly.
  • Azure SQL Serverless + low/no-cost constraint (ADR 0004). Calendar data (events, RSVPs, attendance, recurrence definitions) lives in Azure SQL. Schema must be compatible with the account/family domain (Users, FamilyGroups) already owned by the platform.
  • Recurrence is a hard problem at scale. iCalendar RRULE (RFC 5545) is the industry-standard representation for repeating events. Expanding rules client-side introduces inconsistencies across web, iOS, and Android (timezone handling, edge cases for monthly/yearly rules, DST transitions). Server-side expansion eliminates that class of bug entirely and keeps clients simple.

Without an ADR, feature teams may independently choose recurrence representations, implement visibility filtering in the client, or design calendar storage incompatible with the RBAC and family-group models — all of which have happened in similar projects and require costly rewrites. This ADR records the decisions that prevent those outcomes.

This is a planning and architecture decision. It introduces no code.

Decision ​

We will build the Community Calendar as a feature slice in apps/api/src/features/calendar/ that stores events, RSVPs, attendance, and recurrence rules in Azure SQL; expands recurring events server-side using the iCalendar RRULE standard (RFC 5545); enforces visibility scoping and write authorization server-side using the five-role RBAC model and ministry/small-group membership; and surfaces the calendar on web and mobile identically through packages/api-client — with month, week, day, list, and agenda views rendered by the client from concrete occurrence payloads returned by the API.

Data model (logical) ​

text
Events
  id, title, description, location
  starts_at (UTC), ends_at (UTC), all_day
  organizer_user_id  → Users.id
  ministry_id        → Ministries.id (nullable — general events have no ministry scope)
  visibility         → enum: all_members | role_scoped | ministry_members | small_group_members
  is_cancelled

EventRecurrences
  id, event_id       → Events.id (the master event row)
  rrule              -- RFC 5545 RRULE string (e.g. FREQ=WEEKLY;BYDAY=SU;COUNT=52)
  exception_dates    -- comma-separated ISO-8601 dates for cancelled occurrences
  -- expansion to concrete dates performed by the API layer, never the client

EventRsvps
  id, event_id, occurrence_date (for recurring), user_id → Users.id
  status             → enum: attending | not_attending | maybe
  guest_count        -- additional family members / guests the RSVP covers
  created_at, updated_at

EventAttendance
  id, event_id, occurrence_date, user_id → Users.id
  checked_in_at, checked_in_by_user_id → Users.id
  method             → enum: self_checkin | staff_checkin | kiosk

API surface (illustrative, not exhaustive) ​

text
GET  /api/calendar/events
     ?from=ISO8601&to=ISO8601&view=month|week|day|list|agenda
     → concrete occurrences expanded for the window; filtered by caller's visibility scope

GET  /api/calendar/events/:id
POST /api/calendar/events               (Minister, Staff, Admin)
PUT  /api/calendar/events/:id           (event organizer, Staff, Admin)
DEL  /api/calendar/events/:id           (Staff, Admin)

POST /api/calendar/events/:id/rsvp      (any approved member)
PUT  /api/calendar/events/:id/rsvp      (update own RSVP)

POST /api/calendar/events/:id/checkin   (any member — self check-in, if feature flag on)
POST /api/calendar/events/:id/attendance (Staff, Admin — record attendance for others)

GET  /api/calendar/events/:id/attendance (Staff, Admin)

GET  /api/calendar/feed/:token/events.ics  (read-only ICS subscription feed; token-authenticated, RBAC-filtered)
GET  /api/calendar/events/:id/download.ics (single-event .ics download; standard session auth)

Recurrence expansion rules ​

  1. The EventRecurrences.rrule column stores the raw RFC 5545 RRULE string for the event.
  2. The API expands the rule to concrete { starts_at, ends_at } tuples within the requested date window before returning the payload. No raw RRULE reaches the client.
  3. Exception dates (cancelled single occurrences) are filtered out during expansion.
  4. Modifications to a single occurrence of a recurring series create a separate Events row linked by a parent_event_id reference (the "this and following" / "only this" edit model from RFC 5545).
  5. All timestamps stored and returned as UTC; the client formats them in the device locale.

Visibility scoping (server-side enforcement) ​

Visibility valueWho sees the event
all_membersAny approved member (the default for most events)
role_scopedMembers with a minimum role (e.g. Staff+)
ministry_membersMembers assigned to the event's ministry_id
small_group_membersMembers of a specific small group linked at query

The API computes the intersection of the caller's roles and group memberships (from the platform RBAC layer, ADR 0006) and filters the event list before returning it. The client receives only events it is permitted to see; no client-side filtering of server-returned data is required or trusted.

Calendar sync via iCalendar (ICS) subscription feed ​

Members may subscribe to the community calendar from any standards-compliant personal calendar application (Apple Calendar / iCloud, Google Calendar, Microsoft Outlook, and any other webcal client) using the iCalendar standard (RFC 5545). No per-provider API integration is required; ICS is universal.

Two sync primitives are provided:

  1. Tokenized per-member ICS subscription feed. Each approved member is issued a unique, opaque subscription token (stored server-side, revocable). The feed URL takes the form:

    text
    GET /api/calendar/feed/:token/events.ics

    The token resolves to the member's identity server-side. The response is a valid text/calendar document containing only the events that member may see, computed using the same visibility-scoping logic as the authenticated REST API (see Visibility scoping above). The personal calendar application polls this URL on its own schedule (typically every few hours); the feed always reflects the current server state. The client subscribes using a webcal:// URI or by pasting the HTTPS URL directly — both are standard entry points for Apple Calendar, Google Calendar, and Outlook.

  2. Single-event .ics download ("Add to calendar"). Any event detail view exposes a button that downloads a single-event .ics file for that occurrence. This is a one-time snapshot; it does not auto-refresh. It is appropriate for members who do not want a standing subscription but wish to add one specific event to their calendar.

Authorization and RBAC. The subscription feed endpoint is not authenticated by the platform's standard bearer-token mechanism — calendar applications do not send session tokens. Instead, the opaque subscription token in the URL path is the credential. The API resolves the token to a member record, then applies the identical five-role RBAC filter used by GET /api/calendar/events (ADR 0006). Events the member is not permitted to see are excluded from the feed; the feed never returns events for other members. Tokens are issued only to approved members; a pending or rejected account has no token. Tokens are revocable (member self-service or Admin action) and do not share the member's password or session credentials.

Scope explicitly excluded. Two-way sync — writing calendar changes from a personal calendar application back to the platform — is out of scope. The feed is read-only in both directions: the platform writes to the feed; personal calendar apps read from it. No CalDAV endpoint is provided. No write-back from Apple Calendar, Google Calendar, or Outlook is accepted or planned. This keeps the data model simple and eliminates an entire class of conflict-resolution and authorization complexity.

Alternatives considered ​

OptionProsConsWhy not chosen
Native calendar feature in apps/api (chosen)Full RBAC + closed-community control; consistent behavior across web and mobile via api-client; single source of truth for recurrence expansionRequires building recurrence expansion logic in the API; front-loaded effort— chosen
Tokenized ICS subscription feed + single-event .ics download (chosen)Universal standard; no per-provider API integration; personal calendars auto-refresh; RBAC filtering applied server-side using existing ADR 0006 logic; read-only keeps model simpleMembers must subscribe once via a URL; subscription token must be managed (issuance, revocation)— chosen for calendar sync capability
Embed Google Calendar or a similar third-party calendar widgetNear-zero build effort for the calendar UIEvents would be stored on Google's infrastructure; RBAC visibility cannot be enforced — the embedded widget exposes events to Google's data model, not the platform's; member-approval boundary cannot be maintained; no RSVP or attendance integrationRejected — breaks the closed-community model (ADR 0007), server-side RBAC (ADR 0006), and API-first (ADR 0008)
Two-way sync (CalDAV / write-back from personal calendar apps)Members could edit church events from their personal calendarRequires CalDAV server implementation; write authorization across RBAC roles is non-trivial; conflict resolution between platform edits and personal-calendar edits is complex; personal calendar apps vary in their CalDAV compliance; risk of accidental event deletion or modification by membersRejected — complexity is disproportionate to benefit; the platform is the authority for community events; members should not write to it from external tools
Per-provider API integration (Google Calendar API, Apple EventKit, Microsoft Graph Calendar)Deep integration with each platform's native calendar experienceRequires separate OAuth flows, credentials, and maintenance for each provider; scope of consent for calendar write access raises privacy concerns; fragile against provider API changes; no improvement over ICS for read-only useRejected — ICS is universal and provider-agnostic; per-provider APIs add cost and maintenance with no benefit for a read-only feed
Calendar SaaS (e.g. Calendly, Eventbrite embedded)Rich UI out of the boxSubscription cost violates ADR 0004 cost constraint; member PII and event data sent to a third-party service; no integration with the platform's RBAC or family-group modelRejected — cost + data exposure + no RBAC integration
Client-side recurrence expansion (clients interpret RRULE directly)Removes server expansion logicEach surface must independently implement RFC 5545; timezone and DST handling diverges; exception-date logic must be replicated three times (web, iOS, Android); historically error-proneRejected — inconsistency across surfaces violates ADR 0008 (logic in the API, not clients)
Flat event rows only, no recurrence modelSimplest possible schemaStaff must create every individual occurrence manually; recurring events (Sunday service, Wednesday night) would require 52+ rows/year; operationally untenableRejected — recurrence is a core requirement for a weekly-worship community

Consequences ​

Positive ​

  • Members get a single, authoritative calendar with consistent data across web and mobile; one API response drives all views (month, week, day, list, agenda) by client-side date grouping of the returned occurrence list.
  • RSVP and attendance data are linked to platform Users and FamilyGroups, enabling analytics (who comes, how often) without any additional data pipeline.
  • Visibility scoping at the event level means ministry events, staff-only planning items, and community-wide announcements coexist in one calendar without information leakage.
  • The iCalendar RRULE standard is well-understood and has mature parsing libraries for Node.js (e.g. rrule); the API expansion pattern does not require custom date math.
  • Calendar sync via ICS subscription is a natural extension of the same event data — no schema change to the Events or RBAC model is required. Members get auto-refreshing personal calendar integration with Apple Calendar, Google Calendar, and Outlook without the platform needing to integrate with any provider API.
  • Two-way sync is explicitly excluded, which eliminates an entire class of conflict-resolution complexity and keeps the platform the single authority for community event data.

Negative / trade-offs ​

  • The API must include an RRULE expansion step in every calendar query; for large date windows with high-frequency recurrences the expansion is CPU-bound. Mitigation: bound the maximum window clients may request (e.g. 90 days); cache expanded occurrence lists where feasible in the Azure Functions execution context.
  • The "this occurrence only" and "this and following" edit models for recurring events add schema complexity (parent_event_id, exception dates). Mitigation: this is inherent to RFC 5545; the schema above follows the standard model and the complexity is well-documented.
  • Check-in / attendance recording requires a device on-site (kiosk or staff phone). Mitigation: the self-check-in path (member taps "check in" on their own device) reduces staff burden and is the default mode.
  • The ICS subscription feed is not protected by the platform's standard bearer-token auth; the subscription token in the URL is the sole credential. Token management (issuance, storage, revocation) must be implemented correctly. Mitigation: tokens are opaque, long, randomly generated, and stored hashed server-side; the token table is linked to the member record and purged on account deactivation or member request; no token is reused across members.
  • Personal calendar applications control their own polling frequency; feed changes (event cancellation, time change) are not instantaneous on subscribers' devices. Mitigation: this is inherent to the ICS subscription model and is widely understood by users; in-app notifications (platform notification service) cover time-sensitive changes independently of the ICS feed.

Risks ​

  • Timezone edge cases. RFC 5545 RRULE expansion across DST transitions and around month/year boundaries is a known source of off-by-one errors. Mitigation: use an established RRULE library (Node.js rrule package is the de facto standard); store all times as UTC; write targeted integration tests for DST transition dates before shipping recurrence.
  • Visibility misconfiguration. If an event is accidentally set to all_members when it should be ministry_members, internal information is exposed to the full congregation. Mitigation: the event create/edit UI defaults to all_members only for general events; minister/staff event creation forms prompt explicitly for visibility scope; audit log records every visibility change (platform audit log, ADR 0005).
  • RSVP spam / guest inflation. A member could inflate guest counts artificially, skewing headcount planning. Mitigation: guest count is capped server-side (configurable per event); the feature is Staff-reviewable via the attendance endpoint.
  • ICS subscription token exposure. A member's subscription URL, if shared or leaked, grants read access to that member's visible events. Mitigation: tokens are long and opaque (not guessable); members can revoke and regenerate their token at any time from account settings; Admins can revoke tokens for any member; the token is transmitted only over HTTPS; the token conveys read access only to the events the member is already authorized to see — it does not escalate privilege.
  • Scope creep into the platform core. Calendar logic could be promoted into the platform core (e.g. a "events" notification service) inappropriately. Mitigation: the calendar is a feature slice; it calls the platform notification transport service but does not own it (ADR 0008 rule: features extend, never fork, the platform).

References ​

Heritage Community Hub — Internal. Access restricted via Cloudflare Access + Entra ID.