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 =googletoday; addingoutlooklater just extends the enum. (finalizeis not provider-scoped — the provider is derived from thestate, mirroring auth's/auth/oauth/finalize.) - Reads are merged across providers, each item tagged with
provider(see DTOs). Onelist-calendarsreturns 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
| OperationID | Method + Path | RBAC | Notes |
|---|---|---|---|
connect-calendar | POST /calendar/{provider}/connect | — (auth) | OAuth start → consent redirect; {provider} = google today |
finalize-calendar-connection | POST /calendar/finalize | — (auth) | code + PKCE verifier → stores the refresh token (provider from state) |
disconnect-calendar | POST /calendar/{provider}/disconnect | — (auth) | removes that provider's connection |
update-calendar-status | POST /calendar/{provider}/status/update | — (auth) | enable/disable that provider's calendar feature |
list-calendars | POST /calendar/calendars/list | — (auth) | merged — calendars across all connected providers (each tagged provider) |
list-calendar-events | POST /calendar/events/list | — (auth) | batch by {provider, calendarId} + time range |
get-calendar-events | POST /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.
| Field | Outlook (Graph) | Normalization | |
|---|---|---|---|
| start/end time | dateTime RFC3339 with offset | dateTime naive-local, no offset + zone | combine Graph's local time + zone → RFC3339 instant; request Prefer: outlook.timezone |
| timezone id | IANA | Windows name by default ("Pacific Standard Time") | force IANA via Prefer: outlook.timezone; else map via CLDR windowsZones |
| all-day | start.date present (no dateTime); end exclusive | isAllDay: true, midnight+zone | emit date-only start/end + allDay:true; keep end exclusive |
| recurrence | string[] RFC5545 (RRULE/EXDATE/RDATE) | structured {pattern, range} | Graph→RRULE (reliable); EXDATE/RDATE have no Graph rule form (exceptions are separate events) |
| attendee status | responseStatus (4 values) | status.response (6 values) | collapse tentativelyAccepted→tentative, notResponded/none→needsAction, organizer→accepted |
| visibility | visibility (default/public/private/confidential) | sensitivity (normal/personal/private/confidential) | normal→default; personal/private/confidential→private; no public from Outlook |
| event id | stable | changes on move unless immutable id requested | Prefer: IdType="ImmutableId" |
| calendar color | backgroundColor hex | color named-enum + hexColor (may be empty) | resolve to hex; fallback palette when Outlook hexColor is empty |
| description | HTML (undeclared) | body{contentType,content} + bodyPreview | normalize to plain text (Graph bodyPreview; strip Google HTML) |
| location | location string | location{displayName,…} + locations[] | flatten to displayName |
Module / backend notes
- Wire paths drop
/google/;{provider}is a path-param selector (enumgoogletoday). The module may staycalendar/googleinternally for now; aprovider-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/ListCalendarsRequestall note "fetch from DB") — the refresh token is read server-side from the stored connection, never sent by the client. connect/finalizereuse the sameAuthDatashape as auth OAuth start.
Verify at implementation
attendees[].statusandvisibilityare normalized wire enums (C6) — the FE already defines both (AttendeeStatus,CalendarEventVisibility). Hold the line: never let raw provider values leak through.recurrenceshape change: the FE currently types it as a singlestring; the contract isstring[](RFC5545). UpdateCalendarEventSchema+CalendarEventItem.tsx(readsmasterEvent.recurrence) — small.- Confirm the Outlook adapter requests
Prefer: outlook.timezone="<IANA>"andPrefer: IdType="ImmutableId"before mapping (the two highest-risk Graph defaults). - Confirm
finalize/update-status/disconnectneed no response body beyond 204 (vs returning updatedintegrations).