Skip to main content

Resource: Calendar

Tag: Calendar · Module: calendar/google

Google Calendar integration: connect via OAuth, then read the user's calendars + events. The batch read endpoints are the canonical case for POST-with-body (they take arrays — see C1/RPC rationale).

Grounded: calendar/google/models/types.go, calendar/google/service/*.


Provider is a first-class dimension (Outlook is a known requirement)

Today the FE contract is hard-Google (getGoogleCalendars, disconnectGoogleCalendar, …). The wire drops that: DTOs are provider-agnostic and paths are /calendar/*, never /calendar/google/* (a hardcoded per-provider segment is the anti-pattern — the same lesson as auth's continue-with-google/-microsoft).

But Outlook is coming, so provider is modeled as an explicit dimension now, in the shape that makes Outlook a purely additive change (no breaking reshape later):

  • Provider is a path selector on the connection-lifecycle ops (connect/disconnect/status/update), exactly like auth's /auth/oauth/{provider}/start — it's a routing selector (distinct IdP / OAuth config / token), so per C1 it goes in the path, not the body. {provider} enum = google today; adding outlook later just extends the enum. (finalize is not provider-scoped — the provider is derived from the state, mirroring auth's /auth/oauth/finalize.)
  • Reads are merged across providers, each item tagged with provider (see DTOs). One list-calendars returns every connected provider's calendars; the app shows a unified calendar and can filter by provider.
  • Connection state (User.integrations) is already provider-keyed, so a second provider is additive there.

No Outlook endpoints are built now — only the shape is made provider-ready.


Operations

OperationIDMethod + PathRBACNotes
connect-calendarPOST /calendar/{provider}/connect— (auth)OAuth start → consent redirect; {provider} = google today
finalize-calendar-connectionPOST /calendar/finalize— (auth)code + PKCE verifier → stores the refresh token (provider from state)
disconnect-calendarPOST /calendar/{provider}/disconnect— (auth)removes that provider's connection
update-calendar-statusPOST /calendar/{provider}/status/update— (auth)enable/disable that provider's calendar feature
list-calendarsPOST /calendar/calendars/list— (auth)merged — calendars across all connected providers (each tagged provider)
list-calendar-eventsPOST /calendar/events/list— (auth)batch by {provider, calendarId} + time range
get-calendar-eventsPOST /calendar/events/get— (auth)batch by {provider, calendarId, eventId}

Mirrors the auth OAuth shape: {provider}/connect (start) + finalize (code exchange, provider from state). The standalone status-get is dropped — connection/enabled state is read from User.integrations (see below); {provider}/status/update writes it. connect/disconnect/status/update are provider-scoped (path selector); reads are merged (provider as a data field).


Connection state lives on User.integrations

Two distinct facts: connected (a refresh token exists) and enabled (the user's feature toggle). Both are read from the User DTO — update its integrations shape (see user.md):

"integrations": { "googleCalendar": { "connected": true, "enabled": true } } // was a bare bool

update-calendar-status sets enabled; connect/disconnect flip connected. No separate status-get op. Provider-keyed and independent: a second provider is additive — integrations.outlookCalendar {connected, enabled} is added when Outlook ships; each provider's connected/enabled is tracked separately (so "Google on, Outlook off" is representable). No reshape.


DTOs

connect-calendar / finalize-calendar-connection

// connect — `{provider}` in path (google today) — Request
{ "state": "string", "codeChallenge": "string" } // PKCE
// connect — Response (consent redirect; same AuthData as auth oauth/start)
{ "authData": { "authMethod": "sso", "redirectUrl": "string", "state": "string", "challenge": "string" } }

// finalize — Request
{ "code": "string", "codeVerifier": "string" }
// finalize — Response: 204 (updates User.integrations.googleCalendar.connected = true)

disconnect-calendar / update-calendar-status

// disconnect — empty body → 204
// update-calendar-status — Request
{ "enabled": true }
// Response — 204 (reflected in User.integrations.googleCalendar.enabled)

list-calendars

// Request body — empty {}
// Response — C4 uniform envelope; merged across providers (full set in one page; page totals trivial)
{ "data": [ { "id": "string", "provider": "google", "name": "string", "color": "string" } ], // CalendarDTO: name=Google summary/Outlook name; color=hex "#rrggbb" (used directly as a CSS color) — backend always supplies one (Outlook named-color→hex, fallback when empty)
"page": { "limit": 100, "offset": 0, "total": 7 } }

list-calendar-events (batch — partial-tolerant, C11)

// Request — one entry per calendar+range; provider qualifies the calendarId (which API/token to use)
{ "calendars": [ { "provider": "google", "calendarId": "string", "timeMin": "RFC3339", "timeMax": "RFC3339" } ] } // timeMax >= timeMin
// Response — C11 batch envelope; one calendar failing doesn't fail the batch (correlation key = calendarId)
{ "results": [ { "key": "string", "ok": true, "data": { "events": [ /* Event */ ] }, "error": null } ] }
// on failure: { "key": "string", "ok": false, "data": null, "error": { /* Problem Details, C7 */ } }

get-calendar-events (batch — partial-tolerant, C11)

// Request — provider qualifies each lookup
{ "events": [ { "provider": "google", "calendarId": "string", "eventId": "string" } ] }
// Response — C11 batch envelope (correlation key = eventId)
{ "results": [ { "key": "string", "ok": true, "data": { /* Event */ }, "error": null } ] }
// on failure: { "key": "string", "ok": false, "data": null, "error": { /* Problem Details, C7 */ } }

Event (was EventDTO)

{
"id": "string", // provider event id; backend uses Outlook's IMMUTABLE id (Prefer: IdType="ImmutableId") — Graph ids otherwise change on move; Google ids already stable
"provider": "google", // google today; outlook additive — tags the event's source
"calendarId": "string",
"title": "string", // Google `summary` / Outlook `subject`
"allDay": false, // discriminates start/end below
"start": "RFC3339 | YYYY-MM-DD", // C3 — RFC3339 instant when allDay=false; date-only when allDay=true
"startTimezone": "string", // IANA (e.g. "Europe/Rome"); Outlook Windows zone names normalized → IANA at the boundary (request Graph with `Prefer: outlook.timezone`)
"end": "RFC3339 | YYYY-MM-DD", // all-day end is exclusive (Google convention) — normalized consistently
"endTimezone": "string", // IANA
"location?": "string", // C8 — Outlook's structured location → flattened to displayName
"description?": "string", // plain text — Outlook body(html|text)→text; Google HTML stripped/sanitized server-side
"htmlLink?": "string", // deep link to the event (Google `htmlLink` / Outlook `webLink`)
"visibility?": "default | public | private | confidential", // C6 — app masks details when `private`. Outlook `sensitivity`→ normal→default, personal/private/confidential→private (Outlook has no `public`)
"recurrence?": ["string"], // RFC5545 lines (RRULE/EXDATE/RDATE), master events only; Outlook's structured pattern/range converted → RRULE at the boundary
"recurringEventId?": "string", // pointer to the series master (Google `recurringEventId` / Outlook `seriesMasterId`)
"organizer?": { "email": "string", "name?": "string" }, // C8 — `email` may be a non-RFC address (Google resource calendars, e.g. "…group.v.calendar.google.com") — do NOT validate as email
"attendees": [ { "email": "string", "name?": "string", "status": "accepted | declined | tentative | needsAction" } ]
// attendee.status (C6 enum): Outlook maps tentativelyAccepted→tentative, notResponded/none→needsAction, organizer→accepted
}

Google ↔ Outlook normalization (the fields that aren't a clean rename)

The DTOs above are the normalized wire shape; these are the boundary transforms the backend owns so the client never sees a provider difference. Clean renames (title, htmlLink, recurringEventId) are omitted.

FieldGoogleOutlook (Graph)Normalization
start/end timedateTime RFC3339 with offsetdateTime naive-local, no offset + zonecombine Graph's local time + zone → RFC3339 instant; request Prefer: outlook.timezone
timezone idIANAWindows name by default ("Pacific Standard Time")force IANA via Prefer: outlook.timezone; else map via CLDR windowsZones
all-daystart.date present (no dateTime); end exclusiveisAllDay: true, midnight+zoneemit date-only start/end + allDay:true; keep end exclusive
recurrencestring[] RFC5545 (RRULE/EXDATE/RDATE)structured {pattern, range}Graph→RRULE (reliable); EXDATE/RDATE have no Graph rule form (exceptions are separate events)
attendee statusresponseStatus (4 values)status.response (6 values)collapse tentativelyAccepted→tentative, notResponded/none→needsAction, organizer→accepted
visibilityvisibility (default/public/private/confidential)sensitivity (normal/personal/private/confidential)normal→default; personal/private/confidential→private; no public from Outlook
event idstablechanges on move unless immutable id requestedPrefer: IdType="ImmutableId"
calendar colorbackgroundColor hexcolor named-enum + hexColor (may be empty)resolve to hex; fallback palette when Outlook hexColor is empty
descriptionHTML (undeclared)body{contentType,content} + bodyPreviewnormalize to plain text (Graph bodyPreview; strip Google HTML)
locationlocation stringlocation{displayName,…} + locations[]flatten to displayName

Module / backend notes

  • Wire paths drop /google/; {provider} is a path-param selector (enum google today). The module may stay calendar/google internally for now; a provider-dispatch boundary (per-provider OAuth client + event mapper behind one service) is the natural extension point when Outlook lands — no wire change.
  • Reads are merged: the service fans out across the user's connected providers and tags each calendar/event with provider; the event-batch handlers route per entry by {provider}.
  • Remove the dead refreshToken-in-request TODOs (BatchListEventsRequest/BatchGetEventsRequest/ ListCalendarsRequest all note "fetch from DB") — the refresh token is read server-side from the stored connection, never sent by the client.
  • connect/finalize reuse the same AuthData shape as auth OAuth start.

Verify at implementation

  • attendees[].status and visibility are normalized wire enums (C6) — the FE already defines both (AttendeeStatus, CalendarEventVisibility). Hold the line: never let raw provider values leak through.
  • recurrence shape change: the FE currently types it as a single string; the contract is string[] (RFC5545). Update CalendarEventSchema + CalendarEventItem.tsx (reads masterEvent.recurrence) — small.
  • Confirm the Outlook adapter requests Prefer: outlook.timezone="<IANA>" and Prefer: IdType="ImmutableId" before mapping (the two highest-risk Graph defaults).
  • Confirm finalize/update-status/disconnect need no response body beyond 204 (vs returning updated integrations).