diff --git a/AGENTS.md b/AGENTS.md index a758d22806..a5e69293b3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,6 +58,7 @@ Validation defaults: - Handoffs: `.agents/handoffs/SCHEMA.md` - Error autofix Routine: `docs/CI-CD/error-autofix-routine.md` - Agent-ready issues: `.github/ISSUE_TEMPLATE/3-agent-task.yml` +- Temporary attendee-support work pack (delete when done): `wip/attendee-support/README.md` The `wip/restructure/` pack is deleted after WP-01–07. WP-08 was cancelled: discovery was not the bottleneck (failures were CI, review, evidence, and diff --git a/README.md b/README.md index f20e4a97fb..dbe58cc4bf 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Cool things you can do with in Compass - Find the perfect slot for an event with your keyboard: `SHIFT` + `↑` `↓` `←` `→` - Do everything from the cmd palette - Google Calendar sync +- Add/remove event attendees and RSVP to invites, with optional Google-contact suggestions Things you can't do in Compass (yet): diff --git a/bun.lock b/bun.lock index c46f055cac..67310ca28e 100644 --- a/bun.lock +++ b/bun.lock @@ -111,6 +111,7 @@ "dependencies": { "@compass/core": "1.0.0", "@googleapis/calendar": "^14.1.0", + "@googleapis/people": "^8.0.0", "express": "^4.17.1", "express-rate-limit": "^7.5.0", "google-auth-library": "^10.6.2", @@ -309,6 +310,8 @@ "@googleapis/calendar": ["@googleapis/calendar@14.2.0", "", { "dependencies": { "googleapis-common": "^8.0.0" } }, "sha512-oo6bikN3dl7n43vvknpPzJlCD8FZbs82h4bZLfDAx1wWRUkIusg6++RFQvOKF46rNXsUePqyVwnFx+8oOOyK1A=="], + "@googleapis/people": ["@googleapis/people@8.0.0", "", { "dependencies": { "googleapis-common": "^8.0.0" } }, "sha512-YGdkT345sCCGhPYbwaM9D9lBsuWqzLs2EIba1DU0lpKPjNTrpT5amWCA0V781pgBtmhuygGZA1pz6MdUyzwQuw=="], + "@inquirer/external-editor": ["@inquirer/external-editor@1.0.3", "", { "dependencies": { "chardet": "^2.1.1", "iconv-lite": "^0.7.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA=="], "@jest/expect-utils": ["@jest/expect-utils@29.7.0", "", { "dependencies": { "jest-get-type": "^29.6.3" } }, "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA=="], diff --git a/docs/README.md b/docs/README.md index 3485caf2a2..b450652d0f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,6 +12,7 @@ Internal documentation for engineers and agents working in the Compass repo. - Auth or session behavior: [Frontend Runtime Flow](./frontend/frontend-runtime-flow.md), [Password Auth Flow](./features/password-auth-flow.md), [Google Sync And SSE Flow](./features/google-sync-and-sse-flow.md) - Event shape or recurrence behavior: [Event Domain Model](./architecture/event-domain-model.md), [Common Change Recipes](./development/common-change-recipes.md#change-recurring-event-behavior) +- Attendees, contact suggestions, or RSVP: [Attendees, Contacts, And RSVP](./features/attendees.md), [Feature File Map](./development/feature-file-map.md#attendees-contacts-and-rsvp) - Event caching, reads, or optimistic writes: [Event Caching](./frontend/event-caching.md) - Dragging/resizing events on the week grid: [Week Drag Interaction](./frontend/week-drag-interaction.md) - Keyboard shortcuts, event jump, or pointer suppression: [Shortcuts](./acceptance/shortcuts.md), [Contextual Pointer Guidance](./frontend/contextual-pointer-guidance.md), [Feature File Map](./development/feature-file-map.md#keyboard-shortcuts) diff --git a/docs/development/feature-file-map.md b/docs/development/feature-file-map.md index f33e2df50c..56228ef551 100644 --- a/docs/development/feature-file-map.md +++ b/docs/development/feature-file-map.md @@ -34,6 +34,23 @@ Use this document to find the first files to inspect for common Compass changes. - Backend event routes: `packages/backend/src/event/event.routes.config.ts` - Backend event controller/service: `packages/backend/src/event/controllers/event.controller.ts`, `packages/backend/src/event/services/event.service.ts` +## Attendees, Contacts, And RSVP + +Full flow diagram, invitation-intent semantics, merge/replay rules, contacts +consent flow, and named warts: [Attendees, Contacts, And +RSVP](../features/attendees.md). + +- Attendee/RSVP write contracts: `packages/core/src/types/event-command.contracts.ts`, `packages/core/src/types/event-attendance.contracts.ts` +- Guest-list editor: `packages/web/src/views/Forms/EventForm/AttendeeField/AttendeeField.tsx` +- Save-time invitation prompt: `packages/web/src/views/Forms/EventForm/SendInvitationsDialog.tsx`, `packages/web/src/views/Forms/hooks/useSaveEventForm.ts` +- RSVP control and scope dialog: `packages/web/src/views/Forms/EventForm/RsvpControl.tsx`, `packages/web/src/views/Forms/EventForm/RsvpScopeDialog.tsx` +- Contact suggestions hook: `packages/web/src/views/Forms/EventForm/AttendeeField/useContactSuggestions.ts` +- Backend translation (create/update/delete/rsvp commands, `ATTENDEES_UNSUPPORTED`): `packages/backend/src/common/services/sync-service/event-command.translation.ts`, `packages/backend/src/event/controllers/event.controller.ts` +- Backend contacts proxy: `packages/backend/src/contacts/controllers/contacts.controller.ts` +- Sync attendee merge and RSVP execution: `packages/sync/src/domain/merge-update-content.ts`, `packages/sync/src/domain/provider-command.service.ts` +- Sync Google writer/people adapters: `packages/sync/src/providers/google/google-event-writer.adapter.ts`, `packages/sync/src/providers/google/google-people.adapter.ts` +- E2e coverage: `e2e/attendees/` + ## Day / Week Views - Day view route and content: `packages/web/src/views/Day/view` diff --git a/docs/features/attendees.md b/docs/features/attendees.md new file mode 100644 index 0000000000..066bebdb34 --- /dev/null +++ b/docs/features/attendees.md @@ -0,0 +1,329 @@ +# Attendees, Contacts, And RSVP + +Compass supports adding/removing guests on events you organize on a +**writable Google calendar**, an optional Google-contacts suggestion picker +for that guest field, and RSVP (accepted/declined/tentative) to events +you're invited to — per occurrence or for a whole series. Compass never +sends email itself; Google emails guests via `sendUpdates` when asked to. + +Non-organizer guest-list editing (`guestsCanModify`) is out of scope for v1: +invited users get the RSVP control only, never the guest editor. Local +(non-Google) calendars reject attendee writes with a typed error. + +## High-Level Flow + +```mermaid +flowchart LR + subgraph Web["packages/web"] + AF[AttendeeField] + RC[RsvpControl] + SF[useSaveEventForm] + EM[useEventMutations] + end + subgraph Backend["packages/backend"] + EC[event.controller] + ECT[event-command.translation] + CC[contacts.controller] + end + subgraph Sync["packages/sync"] + CMD["POST /internal/commands"] + PCS[provider-command.service] + GEW[google-event-writer.adapter] + GPA[google-people.adapter] + end + Google[(Google Calendar / People API)] + + AF -->|guest chips| SF + SF -->|invitation prompt if guests changed| EM + RC -->|rsvp mutation| EM + EM -->|PUT /api/event/:id, POST /api/event/:id/rsvp| EC + EC --> ECT + ECT -->|create/update/delete/rsvp command| CMD + CMD --> PCS + PCS -->|mergeAttendees against freshly fetched state| GEW + GEW -->|insert/patch, sendUpdates| Google + AF -.->|GET /api/contacts/suggestions| CC + CC -.->|GET /internal/contacts/suggestions| GPA + GPA -.-> Google +``` + +Sync owns every line of Google-specific code (writer adapter, people +adapter, scopes); the backend only translates browser JSON into sync +commands and proxies contacts reads. See +[Google Sync And SSE Flow](./google-sync-and-sse-flow.md) for the general +sync/SSE architecture this feature reuses (incoming RSVP changes from other +attendees, and Compass's own optimistic answer settling, both arrive back +over the same `eventsChanged` SSE signal). + +## Editing The Guest List + +Source: `packages/core/src/types/event-command.contracts.ts` +(`EditableContentSchema.attendees`), +`packages/web/src/views/Forms/EventForm/AttendeeField/AttendeeField.tsx`, +`packages/web/src/views/Forms/hooks/useSaveEventForm.ts`. + +- The browser write input's `content.attendees` field is **tri-state by + presence**: omitted means "not editing guests" (provider membership flows + through untouched — today's default behavior, byte-identical to pre- + attendee-support payloads); present (including `[]`) means "replace + membership with exactly this set." Entries are `{email, displayName}` — + no `responseStatus` ever rides the write side. +- The editor (`AttendeeField`) only renders for the organizer, on a writable + Google calendar, on a single event or a series base — never on one + occurrence of a series (guest edits have no per-occurrence semantics; see + `packages/web/src/views/Forms/EventForm/EventForm.attendees.test.tsx`). + A recurring series-base edit that changed the guest set narrows + `RecurrenceScopeDialog` to "All Events" only + (`RecurrenceScopeDialog.test.tsx`). +- Typing an invalid string never creates a chip — `AttendeeField`'s + `isValidAttendeeEmail` gate rejects it inline ("Enter a valid email + address") and nothing reaches `onChange` + (`e2e/attendees/attendee-editor.spec.ts` "an invalid email never becomes a + chip"; unit-level in `AttendeeField.test.tsx`). +- A touched-but-unchanged guest list (add then remove) normalizes back to + "not editing guests" before the save, so it round-trips byte-identically + and never shows the invitation prompt + (`useSaveEventForm.attendees.test.tsx`). + +### Invitation-intent semantics (save-time prompt) + +Source: `packages/core/src/types/event-command.contracts.ts` +(`InvitationIntentValueSchema`), +`packages/web/src/views/Forms/EventForm/SendInvitationsDialog.tsx`. + +When (and only when) a save actually changed the guest set, +`useSaveEventForm` shows "Send invitation emails?" **before** submitting +anything. Send (the default, focused on open) maps to `invitation: "all"`; +"Don't send" maps to `"none"`; dismissing (Escape/backdrop/Cancel) aborts +the save entirely — the draft stays open, untouched. An unchanged guest +list sends no `attendees` key and no `invitation` key at all, so it is +wire-identical to a pre-attendee-support save +(`e2e/attendees/attendee-editor.spec.ts`, all three save scenarios assert +the exact wire body; `useSaveEventForm.attendees.test.tsx`). + +`invitation` also threads through create and delete +(`event-command.translation.ts`); a delete's cancellation-email choice uses +the same three-value vocabulary. Google sends every email itself via +`sendUpdates` — Compass has no email infrastructure of its own. + +### Merge-by-email (sync) and replay rules + +Source: `packages/sync/src/domain/merge-update-content.ts` (`mergeAttendees`), +`packages/sync/src/domain/provider-command.service.ts`. + +Sync commands carry `attendeesEdit: "replace" | "preserve"` (default +`"preserve"` — the backward-compat guarantee: every existing caller and +stored command stays valid). On `"replace"`, `mergeAttendees(intended, +providerCurrent)` merges the browser's *intended* email set against +**freshly fetched provider state** (never sync's own stored record — a +concurrent RSVP between syncs would otherwise be clobbered): + +- an email present in both keeps the provider's current `responseStatus` + and `displayName`; +- a new email enters as `needsAction`; +- a dropped email is removed. + +Google's patch endpoint replaces the **whole** `attendees` array, so this +merge is the only thing standing between an edit and silently uninviting +everyone — `mergeAttendees` is pure and table-tested in isolation +(`merge-update-content.test.ts`, 9 cases + a purity assertion). A +`"preserve"` command never touches `attendees` in the write body at all — +proven byte-identical against the pre-attendee-support insert/patch shape +(`google-event-writer.adapter.test.ts` "omits the attendees key when the +write does not intend a guest edit"). + +**Replay**: sync's replay check (`matchesIntendedEdit`) always ignores +`responseStatus` drift (RSVP changes must never block replay of an +unrelated edit); with `attendeesEdit: "replace"` it additionally compares +by **email set only** — a resubmitted identical command is a no-op even if +some other attendee's RSVP changed in between +(`provider-command.service.db.test.ts`). + +### The organizer guard, and the create-time backstop + +A guest replacement requires the calendar's account to be the event's +organizer (or a Compass-created event with no organizer yet — treated as +organized). The guard fails typed and closed before any provider call. +On **create**, guests only ever deliver to a writable Google calendar; the +web belt-drops a guest edit targeting anything else +(`useSaveEventForm.ts`), and the backend/sync stack backstops the same +rule server-side with the typed `ATTENDEES_UNSUPPORTED` error (403, +non-retryable) — see +`packages/backend/src/event/event.error.ts` and +`event-command.translation.test.ts`. + +## Contacts Suggestions (Optional Consent) + +Source: `packages/core/src/types/contact.contracts.ts`, +`packages/sync/src/providers/google/google-people.adapter.ts`, +`packages/sync/src/server/contacts.routes.ts`, +`packages/backend/src/contacts/controllers/contacts.controller.ts`, +`packages/web/src/views/Forms/EventForm/AttendeeField/useContactSuggestions.ts`. + +Contacts (`contacts.readonly` + `contacts.other.readonly`) are Google +*sensitive* scopes requested on the onboarding consent screen but **never +required** — a user who leaves them unchecked completes sign-in normally +(explicit tests on both backend and web pin this: "signs up successfully +when the optional contacts scopes are not granted" / +"completes sign-in when the optional contacts scopes are not granted"; +`e2e/oauth/google-auth-callback.spec.ts` covers both the granted and +denied paths end-to-end). `GOOGLE_AUTH_SCOPES_REQUIRED` (web), the +backend's required-scope validation, sync's base `GOOGLE_SCOPES`, and the +e2e spec's own `REQUIRED_SCOPES` constant never include the contacts +scopes — every WP that touched this area added a literal-pinned test +asserting the list is unchanged. + +Flow: + +1. Per-connection `grantedScopes` (already the mechanism incremental + Google auth uses) derive a `suggestContacts` capability + (`google-capabilities.ts`), surfaced on `GoogleSyncConnectionSummary` / + `selectCanSuggestContacts` in the web's `user-metadata.store.ts`. +2. `AttendeeField` gets a real `suggestionSource` only when the capability + is true; otherwise it falls back to a plain email-chip input with no + network calls (`emptySuggestionSource`), and a non-nagging + "Enable contact suggestions" nudge can render in the listbox footer + (`EnableContactSuggestionsNudge.tsx`, frequency rule pinned in + `contact-nudge.gate.test.ts`: at most once per session, dismissal + persisted forever). +3. Typing debounces 250ms (`CONTACT_SUGGESTION_DEBOUNCE_MS`) and requires + ≥2 characters (`CONTACT_SUGGESTION_QUERY_MIN_LENGTH`) before querying + `GET /api/contacts/suggestions?q=` → backend `contacts.controller.ts` + → sync `GET /internal/contacts/suggestions` (principal-scoped) → + `google-people.adapter.ts` → the People API, ranked and merged. Every + layer degrades a failure to a typed empty result (backend: `[]` under a + 200, never a 4xx/5xx bubbled to the UI) so a sync outage never fires an + error toast per keystroke. `e2e/attendees/contact-suggestions.spec.ts` + exercises the full min-length + debounce + pick-a-suggestion path + against a stubbed suggestions endpoint. +4. Privacy: neither the query string nor any suggestion/contact content is + ever logged, on either the sync or backend side — both emit only static + log lines built from content-free error facts + (`kind`/`status`/`correlationId`); pinned literally in + `contacts.controller.test.ts` and proven via the safety canary (below). + +## RSVP Semantics + +Source: `packages/core/src/types/event-command.contracts.ts` +(`RsvpEventInputSchema`), +`packages/sync/src/domain/provider-command.service.ts` +(`executeProviderRsvp`), +`packages/web/src/views/Forms/EventForm/RsvpControl.tsx`, +`packages/web/src/views/Forms/EventForm/RsvpScopeDialog.tsx`. + +- `RsvpControl` renders a "Going? / Maybe / Decline" `radiogroup` whenever + the connected account's email matches an attendee entry on the event + (case-insensitive, organizer included) — on any calendar the account can + read, including a viewer-access (reader) calendar, since answering an + invitation is not a calendar write + (`EventForm.rsvp.test.tsx`). +- **Self-entry rewrite, never a full replace.** The provider write rewrites + only the caller's own attendee entry — `executeProviderRsvp` fetches the + target's current provider state fresh, finds the self entry by the + connection's account email, and patches back the *whole* list with just + that one entry's `responseStatus` changed and `sendUpdates: "none"` + (answering an invitation never emails anyone). There is deliberately no + organizer guard — the organizer RSVPing to their own event is allowed, + because Google lists the organizer as an attendee of their own event. +- **Per-occurrence vs. series.** The web posts to + `POST /api/event/:id/rsvp`; a single event answers immediately with + `scope: "single"` and no dialog. An **occurrence** of a series + (`eventId::recurrenceId` composite id) opens + `RsvpScopeDialog` — "This Event" / "All Events" **only**, defaulting to + "This Event" — because an RSVP has no `thisAndFollowing` semantics; sync + has no code path that can ever mint one + (`e2e/attendees/rsvp.spec.ts` asserts exactly two dialog radios and + no "following" text anywhere). A **series base** (no single occurrence to + answer) skips the dialog entirely and always answers `scope: "all"` — + offering a per-occurrence choice on a base id would be a lie. Server-side, + `scope: "single"` on a composite id resolves through the writer's own + `fetchInstanceAt` (never a hand-built instance id) and patches the + resolved instance while the series master's stored guest list and + version stay untouched; `scope: "all"` patches the master directly, + never resolving an instance + (`provider-command.service.db.test.ts` "instance-vs-master targeting"). +- **Optimistic UI + settle.** The web repaints only the self entry + immediately and rolls back on a `503`; the provider-confirmed list + settles through the same SSE-backed invalidation path + (`eventsChanged`) that carries in other attendees' RSVP changes + (`useEventMutations.rsvp.test.tsx`). +- **Route contract.** `POST /api/event/:id/rsvp` answers `204 No Content` + (the sync command outcome carries no event content); `responseStatus: + "needsAction"` is rejected `400` at the route (you cannot RSVP back to + unanswered). Idempotency key hashes event + status + scope (+ decoded + recurrenceId) — replaying the same answer is a no-op; changing the + answer, scope, or target mints a new key. +- A transiently-failed RSVP command is swept and retried like any other + sync command (`"rsvp"` is in `RETRYABLE_KINDS`, + `stale-command-retry.service.db.test.ts`). + +## Named Warts (Accepted For v1) + +These are deliberate, documented trade-offs — not bugs to silently fix +later without revisiting the product decision. + +1. **Fetch→patch race.** Every attendee/RSVP write fetches current provider + state, computes a merge, then patches unconditionally + (`expectedVersion: null` for RSVP; no If-Match retry loop for either + path). A provider-side change landing inside that fetch→patch window can + be overwritten. This is intentional: RSVP drift in particular must never + block a write, and etag/If-Match retry loops are explicitly out of scope + for the whole pack. +2. **Alias-email self-match.** The RSVP self entry is matched by the + connected account's own email, case-insensitively. A Google account + alias that doesn't match the connection's stored email will not be + recognized as "self," and `RsvpControl` will not render for that + attendee entry. +3. **Organizer auto-added on create.** Google auto-adds the organizer as an + `accepted` attendee when an event is created, so a post-create readback + can differ from the exact set the client sent. The normalizer handles + this on read; never compare a create request body to the readback and + expect an exact match. +4. **Non-organizer guest editing (`guestsCanModify`) is unsupported.** + Invited non-organizers get the RSVP control only. This is a documented + v1 scope cut, not a wart, but is listed here because it is the single + biggest gap between what Google Calendar itself allows and what Compass + exposes. + +## Safety + +`packages/sync/src/safety/safety-canary.ts` forbids attendee/contact JSON +shapes in sync logs, SSE payloads, and error causes; this is asserted +end-to-end for guest-list writes, RSVP writes, and contact suggestion +failures across the sync and backend safety-canary suites (see the +Evidence sections of WP-02, WP-05, WP-07 in +`wip/attendee-support/` while that pack still exists, or the sync/backend +`*safety*` test files directly after it is deleted). + +## E2E Coverage + +`e2e/attendees/` (Playwright, chromium): `attendee-editor.spec.ts`, +`contact-suggestions.spec.ts`, `rsvp.spec.ts`, plus the shared +`attendee-harness.ts`. The harness runs the same anonymous local-mode e2e +web server as every other Playwright suite in this repo (there is no real +backend on port 3000), so it simulates a signed-in Google state exactly +like `e2e/oauth/` and `e2e/calendars/calendar-experience.spec.ts` do: +`__COMPASS_E2E_TEST__` engages the app's e2e seams, remembered auth +(`compass.auth` in `localStorage`) steers the event repository onto the +remote API, `window.__COMPASS_E2E_HOOKS__.setAuthenticated(true)` flips +`useCalendarsQuery`'s session gate (a **separate** gate from the +repository-source flag — both must be set, or the calendar list never +leaves the synthesized anonymous-local calendar and no fixture event ever +appears on the grid), and every `/api/**` call is stubbed with captured +request bodies for payload assertions. Buttons inside floating +`OverlayPanel` dialogs (the Send/Don't send prompt, the RSVP/recurrence +scope dialogs) are clicked via a DOM `element.click()` dispatch +(`dispatchClick`) rather than Playwright's built-in `.click()`, because +the panel's re-render between Playwright's actionability check and the +actual pointer dispatch otherwise swallows the click with no error and no +network call — the same class of issue already documented for the Save +button in `e2e/utils/event-test-utils.ts`. + +This closes the coverage gap WP-08 flagged: its accessibility sweep runs +the same anonymous local-mode harness, where no Google invitation state +can exist, so `RsvpControl`/`AttendeeField` never mount there and are +axe-swept via RTL semantics instead (`RsvpControl.test.tsx`, +`AttendeeField.test.tsx`). The `e2e/attendees/` specs are the first (and +so far only) place these two components mount and are driven under a +genuinely signed-in, real-DOM, real-browser condition — not merely +type-checked or RTL-rendered. diff --git a/e2e/attendees/attendee-editor.spec.ts b/e2e/attendees/attendee-editor.spec.ts new file mode 100644 index 0000000000..21c34a5b7f --- /dev/null +++ b/e2e/attendees/attendee-editor.spec.ts @@ -0,0 +1,146 @@ +import { expect, test } from "@playwright/test"; +import { + ACCOUNT_EMAIL, + buildEventFixture, + clickSave, + dispatchClick, + getGuestCombobox, + openEventForm, + prepareSignedInGooglePage, +} from "./attendee-harness"; + +// Attendee editor end-to-end (WP-04/WP-09): adding guests on an organized, +// writable-Google event, the save-time "Send invitation emails?" prompt, and +// the exact replace payload (guest set + invitation intent) on the wire. + +const organizedEvent = () => + buildEventFixture({ + id: "evt-guests-1", + title: "Design Review", + attendees: [ + { email: ACCOUNT_EMAIL, displayName: null, responseStatus: "accepted" }, + { + email: "bob@example.com", + displayName: "Bob B", + responseStatus: "accepted", + }, + ], + }); + +test("adding a guest prompts to send invitations and puts the replaced guest set on the wire", async ({ + page, +}) => { + const captured = await prepareSignedInGooglePage(page, { + events: [organizedEvent()], + }); + + await openEventForm(page, "Design Review"); + + const combobox = getGuestCombobox(page); + await combobox.fill("dana@example.com"); + await page.keyboard.press("Enter"); + // The chip is on screen before anything reaches the wire. + await expect( + page.getByRole("button", { name: "Remove dana@example.com" }), + ).toBeVisible(); + + await clickSave(page); + + // Guest set changed -> the Send prompt appears BEFORE any mutation. + await expect(page.getByText("Send invitation emails?")).toBeVisible(); + expect(captured.replaceRequests).toHaveLength(0); + + await dispatchClick(page.getByRole("button", { name: "Send", exact: true })); + + await expect.poll(() => captured.replaceRequests.length).toBe(1); + const { eventId, body } = captured.replaceRequests[0]; + expect(eventId).toBe("evt-guests-1"); + const content = body.content as Record; + // Write-input shape: membership only, no responseStatus on any entry. + expect(content.attendees).toEqual([ + { email: ACCOUNT_EMAIL, displayName: null }, + { email: "bob@example.com", displayName: "Bob B" }, + { email: "dana@example.com", displayName: null }, + ]); + // "Send" -> Google emails the affected guests. + expect(body.invitation).toBe("all"); + + // The form closes after the save is submitted. + await expect(page.getByRole("form").getByPlaceholder("Title")).toBeHidden(); +}); + +test("choosing Don't send saves the guest edit with invitation none", async ({ + page, +}) => { + const captured = await prepareSignedInGooglePage(page, { + events: [organizedEvent()], + }); + + await openEventForm(page, "Design Review"); + + const combobox = getGuestCombobox(page); + await combobox.fill("erin@example.com"); + await page.keyboard.press("Enter"); + await expect( + page.getByRole("button", { name: "Remove erin@example.com" }), + ).toBeVisible(); + + await clickSave(page); + await expect(page.getByText("Send invitation emails?")).toBeVisible(); + await dispatchClick(page.getByRole("button", { name: "Don't send" })); + + await expect.poll(() => captured.replaceRequests.length).toBe(1); + const { body } = captured.replaceRequests[0]; + const content = body.content as Record; + expect(content.attendees).toEqual([ + { email: ACCOUNT_EMAIL, displayName: null }, + { email: "bob@example.com", displayName: "Bob B" }, + { email: "erin@example.com", displayName: null }, + ]); + expect(body.invitation).toBe("none"); +}); + +test("a save that never touched the guest list sends no attendees key and shows no prompt", async ({ + page, +}) => { + const captured = await prepareSignedInGooglePage(page, { + events: [organizedEvent()], + }); + + await openEventForm(page, "Design Review"); + + await page + .getByRole("form") + .getByPlaceholder("Title") + .fill("Design Review (moved)"); + await clickSave(page); + + await expect.poll(() => captured.replaceRequests.length).toBe(1); + // No prompt appeared: the save went straight to the wire... + await expect(page.getByText("Send invitation emails?")).toBeHidden(); + const { body } = captured.replaceRequests[0]; + const content = body.content as Record; + // ...and the body carries neither an attendees key ("not editing guests" — + // provider membership flows through untouched) nor an invitation key. + expect("attendees" in content).toBe(false); + expect("invitation" in body).toBe(false); +}); + +test("an invalid email never becomes a chip", async ({ page }) => { + const captured = await prepareSignedInGooglePage(page, { + events: [organizedEvent()], + }); + + await openEventForm(page, "Design Review"); + + const combobox = getGuestCombobox(page); + await combobox.fill("not-an-email"); + await page.keyboard.press("Enter"); + + // Inline rejection in the listbox; no chip, nothing on the wire. + await expect(page.getByText("Enter a valid email address")).toBeVisible(); + await expect( + page.getByRole("button", { name: "Remove not-an-email" }), + ).toHaveCount(0); + expect(captured.replaceRequests).toHaveLength(0); +}); diff --git a/e2e/attendees/attendee-harness.ts b/e2e/attendees/attendee-harness.ts new file mode 100644 index 0000000000..de9dc47742 --- /dev/null +++ b/e2e/attendees/attendee-harness.ts @@ -0,0 +1,399 @@ +import { expect, type Page } from "@playwright/test"; + +/** + * Signed-in Google-calendar harness for the attendee e2e specs. + * + * The Playwright web server runs the anonymous local-mode app (no backend on + * port 3000), so these specs simulate the signed-in state the same way + * e2e/oauth does: `__COMPASS_E2E_TEST__` engages the app's sanctioned e2e + * seams (SessionProvider skips real SuperTokens checks; the user-metadata + * store exposes its bridge), remembered auth (`compass.auth`) flips event + * reads/writes onto the remote repository, and every `/api/**` request is + * stubbed here — nothing real is contacted and every write body is captured + * for payload assertions. + */ + +export const ACCOUNT_EMAIL = "user@example.com"; +/** ObjectId-shaped (CalendarIdSchema) id for the stubbed Google calendar. */ +export const GOOGLE_CALENDAR_ID = "64b7f0a1c2d3e4f5a6b7c8d9"; + +const googleCalendar = { + id: GOOGLE_CALENDAR_ID, + name: "Work", + description: "", + timeZone: "Etc/UTC", + foregroundColor: "#ffffff", + backgroundColor: "#4285f4", + provider: "google", + access: "owner", + capabilities: { + canReadAvailability: true, + canReadDetails: true, + canWrite: true, + canManage: true, + canWatchEvents: true, + }, + isPrimary: true, + isVisible: true, + isActive: true, + accountEmail: ACCOUNT_EMAIL, +}; + +/** A GoogleSyncConnectionSummary shape for the stubbed GET /api/user/metadata. */ +const connectionSummary = ( + accountEmail: string, + canSuggestContacts: boolean, +) => ({ + id: "e2e-connection-1", + state: "healthy", + stateReason: null, + lastSyncedAt: null, + lastHealthyAt: null, + accountEmail, + connectionState: "HEALTHY", + canSuggestContacts, +}); + +export interface AttendeeFixture { + email: string; + displayName: string | null; + responseStatus: "needsAction" | "accepted" | "declined" | "tentative"; +} + +export interface EventFixture { + id: string; + calendarId: string; + content: { + kind: "details"; + title: string; + description: string; + location: string | null; + organizer: { email: string; displayName: string | null } | null; + attendees: AttendeeFixture[]; + }; + schedule: { kind: "timed"; start: string; end: string; timeZone: string }; + recurrence: + | { kind: "single" } + | { kind: "series"; rules: string[] } + | { kind: "occurrence"; seriesId: string }; + createdAt: string; + updatedAt: string | null; +} + +/** + * A one-hour timed event starting at the top of the current hour, so it is + * always inside the week the grid opens on (and near the scroll position). + */ +export const buildEventFixture = (options: { + id: string; + title: string; + attendees?: AttendeeFixture[]; + organizer?: { email: string; displayName: string | null } | null; + recurrence?: EventFixture["recurrence"]; +}): EventFixture => { + const start = new Date(); + start.setMinutes(0, 0, 0); + const end = new Date(start.getTime() + 60 * 60 * 1000); + + return { + id: options.id, + calendarId: GOOGLE_CALENDAR_ID, + content: { + kind: "details", + title: options.title, + description: "", + location: null, + organizer: + options.organizer === undefined + ? { email: ACCOUNT_EMAIL, displayName: null } + : options.organizer, + attendees: options.attendees ?? [], + }, + schedule: { + kind: "timed", + start: start.toISOString(), + end: end.toISOString(), + timeZone: "Etc/UTC", + }, + recurrence: options.recurrence ?? { kind: "single" }, + createdAt: new Date(start.getTime() - 24 * 60 * 60 * 1000).toISOString(), + updatedAt: null, + }; +}; + +export interface CapturedApiRequests { + /** PUT /api/event/:id bodies, in order, with the decoded event id. */ + replaceRequests: Array<{ eventId: string; body: Record }>; + /** POST /api/event/:id/rsvp bodies, in order, with the decoded event id. */ + rsvpRequests: Array<{ eventId: string; body: Record }>; + /** `q` values of GET /api/contacts/suggestions calls, in order. */ + suggestionQueries: string[]; +} + +export interface SignedInPageOptions { + events: EventFixture[]; + /** Payload for the stubbed GET /api/contacts/suggestions. */ + suggestions?: Array<{ email: string; displayName: string | null }>; + /** + * When true, the user-metadata store is seeded (via the e2e bridge) with a + * healthy connection that granted the contacts scopes, so the attendee + * field queries the stubbed suggestions endpoint. + */ + canSuggestContacts?: boolean; +} + +export const prepareSignedInGooglePage = async ( + page: Page, + options: SignedInPageOptions, +): Promise => { + const captured: CapturedApiRequests = { + replaceRequests: [], + rsvpRequests: [], + suggestionQueries: [], + }; + const suggestions = options.suggestions ?? []; + + await page.addInitScript((accountEmail) => { + ( + window as Window & { __COMPASS_E2E_TEST__?: boolean } + ).__COMPASS_E2E_TEST__ = true; + // Remembered auth: the app prefers the remote event repository once the + // user has ever authenticated, which is what routes reads/writes through + // the stubbed /api/** below. + localStorage.setItem( + "compass.auth", + JSON.stringify({ hasAuthenticated: true, lastKnownEmail: accountEmail }), + ); + }, ACCOUNT_EMAIL); + + const json = (body: unknown) => ({ + status: 200, + contentType: "application/json", + body: JSON.stringify(body), + }); + + await page.route("**/api/**", async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const path = url.pathname; + + if (path.endsWith("/api/calendars")) { + return route.fulfill(json({ calendars: [googleCalendar] })); + } + + if (path.endsWith("/api/event") && request.method() === "GET") { + return route.fulfill(json({ events: options.events })); + } + + const replaceMatch = /\/api\/event\/([^/]+)$/.exec(path); + if (replaceMatch && request.method() === "PUT") { + const eventId = decodeURIComponent(replaceMatch[1]); + const body = request.postDataJSON() as Record; + captured.replaceRequests.push({ eventId, body }); + // Settle the stubbed store the way the real pipeline would: retained + // guests keep their provider responseStatus, new guests enter as + // needsAction. The next list refetch (invalidation) then confirms the + // optimistic paint instead of reverting it. + const stored = options.events.find( + (candidate) => candidate.id === eventId, + ); + const content = body.content as + | { + title?: string; + attendees?: Array>; + } + | undefined; + if (stored && content) { + if (typeof content.title === "string") { + stored.content.title = content.title; + } + if (Array.isArray(content.attendees)) { + const previous = stored.content.attendees; + stored.content.attendees = content.attendees.map( + ({ email, displayName }) => ({ + email, + displayName, + responseStatus: + previous.find( + (entry) => entry.email.toLowerCase() === email.toLowerCase(), + )?.responseStatus ?? "needsAction", + }), + ); + } + } + return route.fulfill(json({ event: stored ?? options.events[0] })); + } + + const rsvpMatch = /\/api\/event\/([^/]+)\/rsvp$/.exec(path); + if (rsvpMatch && request.method() === "POST") { + const eventId = decodeURIComponent(rsvpMatch[1]); + const body = request.postDataJSON() as Record; + captured.rsvpRequests.push({ eventId, body }); + // Self-entry rewrite, like sync: only the account's own attendee entry + // changes, so the invalidation refetch confirms the optimistic answer. + const stored = options.events.find( + (candidate) => candidate.id === eventId, + ); + if (stored && typeof body.responseStatus === "string") { + stored.content.attendees = stored.content.attendees.map((entry) => + entry.email.toLowerCase() === ACCOUNT_EMAIL + ? { + ...entry, + responseStatus: + body.responseStatus as AttendeeFixture["responseStatus"], + } + : entry, + ); + } + return route.fulfill({ status: 204, body: "" }); + } + + if (path.endsWith("/api/contacts/suggestions")) { + captured.suggestionQueries.push(url.searchParams.get("q") ?? ""); + return route.fulfill(json({ suggestions })); + } + + if (path.endsWith("/api/user/metadata")) { + // Must reflect canSuggestContacts on every fetch, not just the first: + // refreshUserMetadata() re-fetches on its own (session events, google + // sync refresh, periodic invalidation), and each response overwrites + // the zustand store — a one-time bridge injection before this route + // even existed would get clobbered back to false by the next fetch. + return route.fulfill( + json({ + google: { + connectionState: "HEALTHY", + connections: options.canSuggestContacts + ? [connectionSummary(ACCOUNT_EMAIL, true)] + : [], + }, + }), + ); + } + + if (path.endsWith("/api/config")) { + return route.fulfill(json({ google: { isConfigured: true } })); + } + + return route.fulfill(json({})); + }); + + await page.goto("/week", { waitUntil: "domcontentloaded" }); + // The header's one

button doubles as the view switcher — the shared + // signal that the calendar shell finished booting. + await expect( + page.getByRole("heading", { level: 1 }).getByRole("button"), + ).toBeVisible({ timeout: 15000 }); + + // `__COMPASS_E2E_TEST__` makes SessionProvider skip the real SuperTokens + // check, so `useSession().authenticated` starts (and stays) false — it is + // NOT derived from the remembered `compass.auth` flag above (that flag + // only steers event.repository.source.store's local-vs-remote choice). + // useCalendarsQuery gates its data source on `authenticated` + // (calendarsQueryOptions), so without this the app queries GET /api/event + // with the synthesized anonymous local-calendar id and none of this + // harness's fixture events (attached to GOOGLE_CALENDAR_ID) ever appear on + // the grid. Same fix as e2e/calendars/calendar-experience.spec.ts. + await page.waitForFunction( + () => + ( + window as Window & { + __COMPASS_E2E_HOOKS__?: { setAuthenticated: (v: boolean) => void }; + } + ).__COMPASS_E2E_HOOKS__ !== undefined, + ); + await page.evaluate(() => { + ( + window as Window & { + __COMPASS_E2E_HOOKS__?: { setAuthenticated: (v: boolean) => void }; + } + ).__COMPASS_E2E_HOOKS__?.setAuthenticated(true); + }); + + if (options.canSuggestContacts) { + // The stubbed GET /api/user/metadata above already answers with the + // capability set on every fetch (including refetches), so the initial + // paint only needs a nudge: force one metadata refetch through the + // e2e store bridge rather than waiting out staleTime. + await page.waitForFunction(() => { + const bridge = ( + window as Window & { + __COMPASS_E2E_STORE__?: { userMetadata?: unknown }; + } + ).__COMPASS_E2E_STORE__; + return Boolean(bridge?.userMetadata); + }); + await page.evaluate( + (metadata) => { + const bridge = ( + window as Window & { + __COMPASS_E2E_STORE__?: { + userMetadata?: { set: (metadata: unknown) => void }; + }; + } + ).__COMPASS_E2E_STORE__; + bridge?.userMetadata?.set(metadata); + }, + { + google: { + connectionState: "HEALTHY", + connections: [connectionSummary(ACCOUNT_EMAIL, true)], + }, + }, + ); + } + + return captured; +}; + +/** + * Dispatches a real DOM click instead of Playwright's `.click()`. Buttons + * inside OverlayPanel (the Send/Don't send invitation dialog, + * RecurrenceScopeDialog's scope choices) sit in floating UI that re-renders + * between Playwright's pointer-actionability check and the actual dispatch, + * so a normal `.click()` silently lands on nothing and no handler runs — no + * error, no request, no state change. Mirrors the Save-button workaround in + * e2e/utils/event-test-utils.ts. + */ +export const dispatchClick = async ( + locator: import("@playwright/test").Locator, +) => { + // Some targets (the sr-only radio inputs behind RsvpControl/ + // RecurrenceScopeDialog labels) are intentionally invisible, so wait for + // DOM attachment rather than requiring toBeVisible. + await locator.waitFor({ state: "attached", timeout: 10000 }); + await locator.evaluate((el) => { + (el as HTMLElement).click(); + }); +}; + +/** Opens the event's form by focusing its grid card and pressing Enter. */ +export const openEventForm = async (page: Page, title: string) => { + const eventButton = page.getByRole("button", { name: title }).last(); + await eventButton.waitFor({ state: "visible", timeout: 10000 }); + await eventButton.focus(); + await page.keyboard.press("Enter"); + await expect(page.getByRole("form").getByPlaceholder("Title")).toHaveValue( + title, + { timeout: 10000 }, + ); +}; + +/** The event form's guest combobox (AttendeeField). */ +export const getGuestCombobox = (page: Page) => + page.getByRole("combobox", { name: "Guests" }); + +/** + * Submits the open event form via its Save button. Mirrors + * e2e/utils/event-test-utils.ts: the button sits in floating UI that can + * re-render during pointer actionability checks, so dispatch a DOM click. + */ +export const clickSave = async (page: Page) => { + const saveButton = page + .getByRole("form") + .getByRole("button", { name: "Save" }); + await expect(saveButton).toBeVisible({ timeout: 10000 }); + await saveButton.evaluate((el) => { + (el as HTMLElement).click(); + }); +}; diff --git a/e2e/attendees/contact-suggestions.spec.ts b/e2e/attendees/contact-suggestions.spec.ts new file mode 100644 index 0000000000..81df2de125 --- /dev/null +++ b/e2e/attendees/contact-suggestions.spec.ts @@ -0,0 +1,67 @@ +import { expect, test } from "@playwright/test"; +import { + ACCOUNT_EMAIL, + buildEventFixture, + dispatchClick, + getGuestCombobox, + openEventForm, + prepareSignedInGooglePage, +} from "./attendee-harness"; + +// Contact-suggestion picker end-to-end (WP-05/06/WP-09) against a stubbed +// suggestions endpoint: the ≥2-char minimum keeps single keystrokes off the +// wire, the 250ms debounce collapses fast typing to one query, and a picked +// suggestion becomes a guest chip carrying its display name. + +const organizedEvent = () => + buildEventFixture({ + id: "evt-suggest-1", + title: "Planning Sync", + attendees: [ + { email: ACCOUNT_EMAIL, displayName: null, responseStatus: "accepted" }, + ], + }); + +test("suggests contacts from the stubbed endpoint after the min-length and debounce gates, and picking one adds a chip", async ({ + page, +}) => { + const captured = await prepareSignedInGooglePage(page, { + events: [organizedEvent()], + canSuggestContacts: true, + suggestions: [ + { email: "alan@example.com", displayName: "Alan Partridge" }, + { email: "alice@example.com", displayName: "Alice A" }, + ], + }); + + await openEventForm(page, "Planning Sync"); + + const combobox = getGuestCombobox(page); + await combobox.click(); + // One character: below CONTACT_SUGGESTION_QUERY_MIN_LENGTH (2). Wait out + // the 250ms debounce window (poll, not a bare sleep) and assert that no + // request left the browser. + await combobox.fill("a"); + const typedAt = Date.now(); + await expect + .poll(() => + Date.now() - typedAt > 600 ? captured.suggestionQueries.length : -1, + ) + .toBe(0); + + // Second character crosses the minimum: exactly one debounced query for + // the final text goes out, and the stubbed page renders as options. + await combobox.pressSequentially("l"); + await expect(page.getByText("Alan Partridge")).toBeVisible(); + await expect(page.getByText("Alice A")).toBeVisible(); + expect(captured.suggestionQueries).toEqual(["al"]); + + await dispatchClick(page.getByText("Alan Partridge")); + + // The picked suggestion is now a chip (labelled by display name) and the + // input reset closed the listbox. + await expect( + page.getByRole("button", { name: "Remove Alan Partridge" }), + ).toBeVisible(); + await expect(page.getByRole("listbox")).toHaveCount(0); +}); diff --git a/e2e/attendees/rsvp.spec.ts b/e2e/attendees/rsvp.spec.ts new file mode 100644 index 0000000000..da9e28d345 --- /dev/null +++ b/e2e/attendees/rsvp.spec.ts @@ -0,0 +1,157 @@ +import { expect, test } from "@playwright/test"; +import { + ACCOUNT_EMAIL, + buildEventFixture, + dispatchClick, + openEventForm, + prepareSignedInGooglePage, +} from "./attendee-harness"; + +// RSVP control end-to-end (WP-07/08/WP-09): the Going / Maybe / Decline +// radiogroup on an event the account is invited to, the per-occurrence +// "This Event" / "All Events" scope dialog, and the exact POST +// /api/event/:id/rsvp payloads — including the composite occurrence id on +// the URL. + +const RECURRENCE_ID = "2026-08-26T10:00:00.000Z"; +const SERIES_ID = "evt-series-1"; +const OCCURRENCE_ID = `${SERIES_ID}::${RECURRENCE_ID}`; + +const invitedSingleEvent = () => + buildEventFixture({ + id: "evt-invite-1", + title: "Team Offsite", + organizer: { email: "boss@example.com", displayName: "The Boss" }, + attendees: [ + { + email: "boss@example.com", + displayName: null, + responseStatus: "accepted", + }, + { + email: ACCOUNT_EMAIL, + displayName: null, + responseStatus: "needsAction", + }, + ], + }); + +const invitedOccurrence = () => + buildEventFixture({ + id: OCCURRENCE_ID, + title: "Weekly Standup", + organizer: { email: "boss@example.com", displayName: "The Boss" }, + recurrence: { kind: "occurrence", seriesId: SERIES_ID }, + attendees: [ + { + email: "boss@example.com", + displayName: null, + responseStatus: "accepted", + }, + { email: ACCOUNT_EMAIL, displayName: null, responseStatus: "tentative" }, + ], + }); + +const getRsvpGroup = (page: import("@playwright/test").Page) => + page.getByRole("radiogroup", { name: "Going?" }); + +test("answering a single event posts immediately with scope single and no dialog", async ({ + page, +}) => { + const captured = await prepareSignedInGooglePage(page, { + events: [invitedSingleEvent()], + }); + + await openEventForm(page, "Team Offsite"); + + const group = getRsvpGroup(page); + await expect(group).toBeVisible(); + // Unanswered (needsAction): no radio is checked yet. + await expect(group.getByRole("radio", { name: "Going" })).not.toBeChecked(); + await expect(group.getByRole("radio", { name: "Maybe" })).not.toBeChecked(); + await expect(group.getByRole("radio", { name: "Decline" })).not.toBeChecked(); + + await dispatchClick(group.getByRole("radio", { name: "Maybe" })); + + // Single event: no scope dialog, the answer goes straight to the wire. + await expect(page.getByText("Respond for")).toHaveCount(0); + await expect.poll(() => captured.rsvpRequests.length).toBe(1); + expect(captured.rsvpRequests[0]).toEqual({ + eventId: "evt-invite-1", + body: { responseStatus: "tentative", scope: "single" }, + }); + + // The optimistic self-entry rewrite paints the answer immediately. + await expect(group.getByRole("radio", { name: "Maybe" })).toBeChecked(); +}); + +test("answering an occurrence offers This Event / All Events (never this-and-following) and posts the occurrence id on scope single", async ({ + page, +}) => { + const captured = await prepareSignedInGooglePage(page, { + events: [invitedOccurrence()], + }); + + await openEventForm(page, "Weekly Standup"); + + const group = getRsvpGroup(page); + await expect(group).toBeVisible(); + await expect(group.getByRole("radio", { name: "Maybe" })).toBeChecked(); + + await dispatchClick(group.getByRole("radio", { name: "Decline" })); + + // The scope dialog opens BEFORE anything reaches the wire. + const dialog = page.getByRole("radiogroup", { name: "Respond for" }); + await expect(dialog).toBeVisible(); + expect(captured.rsvpRequests).toHaveLength(0); + + // Exactly two choices; an RSVP has no this-and-following semantics. + await expect(dialog.getByRole("radio")).toHaveCount(2); + await expect(dialog.getByRole("radio", { name: "This Event" })).toBeChecked(); + await expect( + dialog.getByRole("radio", { name: "All Events" }), + ).not.toBeChecked(); + await expect(page.getByText(/following/i)).toHaveCount(0); + + await dispatchClick(page.getByRole("button", { name: "Ok" })); + + // Scope "single" answers just this occurrence: the composite + // eventId::recurrenceId rides the URL so the backend addresses exactly it. + await expect.poll(() => captured.rsvpRequests.length).toBe(1); + expect(captured.rsvpRequests[0]).toEqual({ + eventId: OCCURRENCE_ID, + body: { responseStatus: "declined", scope: "single" }, + }); + await expect(dialog).toHaveCount(0); +}); + +test("choosing All Events posts scope all, and cancelling sends nothing", async ({ + page, +}) => { + const captured = await prepareSignedInGooglePage(page, { + events: [invitedOccurrence()], + }); + + await openEventForm(page, "Weekly Standup"); + const group = getRsvpGroup(page); + + // Cancel first: the dialog closes and nothing was posted. + await dispatchClick(group.getByRole("radio", { name: "Going" })); + const dialog = page.getByRole("radiogroup", { name: "Respond for" }); + await expect(dialog).toBeVisible(); + await dispatchClick(page.getByRole("button", { name: "Cancel" })); + await expect(dialog).toHaveCount(0); + expect(captured.rsvpRequests).toHaveLength(0); + + // Then answer for the whole series. + await dispatchClick(group.getByRole("radio", { name: "Going" })); + await expect(dialog).toBeVisible(); + await dispatchClick(dialog.getByRole("radio", { name: "All Events" })); + await dispatchClick(page.getByRole("button", { name: "Ok" })); + + await expect.poll(() => captured.rsvpRequests.length).toBe(1); + expect(captured.rsvpRequests[0]).toEqual({ + eventId: OCCURRENCE_ID, + body: { responseStatus: "accepted", scope: "all" }, + }); +}); diff --git a/e2e/oauth/google-auth-callback.spec.ts b/e2e/oauth/google-auth-callback.spec.ts index 4fe74a4c44..e89fdba413 100644 --- a/e2e/oauth/google-auth-callback.spec.ts +++ b/e2e/oauth/google-auth-callback.spec.ts @@ -11,13 +11,30 @@ const REQUIRED_SCOPES = [ const getIntentStorageKey = (state: string) => `${INTENT_STORAGE_PREFIX}.${state}`; -const getCallbackUrl = (state: string) => +// Optional contacts scopes (WP-05/WP-06): requested at consent, but sign-in +// must complete whether or not the user grants them. NEVER move these into +// REQUIRED_SCOPES above — that list is pinned to the callback verification +// and requiring contacts would brick sign-in for everyone who declines. +const OPTIONAL_CONTACTS_SCOPES = [ + "https://www.googleapis.com/auth/contacts.readonly", + "https://www.googleapis.com/auth/contacts.other.readonly", +]; + +const getCallbackUrl = (state: string, grantedScopes = REQUIRED_SCOPES) => `${CALLBACK_PATH}?state=${encodeURIComponent( state, - )}&code=auth-code&scope=${encodeURIComponent(REQUIRED_SCOPES.join(" "))}`; + )}&code=auth-code&scope=${encodeURIComponent(grantedScopes.join(" "))}`; + +type MetadataPayload = Record; -const prepareGoogleAuthCallbackPage = async (page: Page) => { +const prepareGoogleAuthCallbackPage = async ( + page: Page, + options: { metadata?: MetadataPayload } = {}, +) => { const loginOrSignupRequests: unknown[] = []; + const metadata = options.metadata ?? { + google: { connectionState: "HEALTHY" }, + }; await page.addInitScript(() => { ( @@ -47,7 +64,7 @@ const prepareGoogleAuthCallbackPage = async (page: Page) => { return route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify({ google: { connectionState: "HEALTHY" } }), + body: JSON.stringify(metadata), }); } @@ -102,3 +119,118 @@ test("finishes a saved Google sign-in callback", async ({ page }) => { ), ).toBeNull(); }); + +// WP-06: the optional contacts grant rides the SAME sign-in callback. Either +// outcome — granted or denied — must land the user signed in on /week with a +// healthy connection; only the suggestContacts capability differs. + +const seedSignInIntent = async (page: Page, state: string) => { + await page.goto("/week"); + await page.evaluate( + ({ key, value }) => { + sessionStorage.setItem(key, JSON.stringify(value)); + }, + { + key: getIntentStorageKey(state), + value: { + intent: "signIn", + returnPath: "/week", + createdAt: Date.now(), + }, + }, + ); +}; + +const connectionSummary = (canSuggestContacts: boolean) => ({ + id: "e2e-connection-1", + state: "healthy", + stateReason: null, + lastSyncedAt: null, + lastHealthyAt: null, + accountEmail: "user@example.com", + connectionState: "HEALTHY", + canSuggestContacts, +}); + +const readStoredGoogleMetadata = (page: Page) => + page.evaluate(() => { + const bridge = ( + window as Window & { + __COMPASS_E2E_STORE__?: { + userMetadata?: { getState: () => { current: unknown } }; + }; + } + ).__COMPASS_E2E_STORE__; + const current = bridge?.userMetadata?.getState().current as + | { + google?: { + connectionState?: string; + connections?: Array<{ canSuggestContacts?: boolean }>; + }; + } + | null + | undefined; + return current?.google ?? null; + }); + +test("finishes sign-in with the optional contacts scopes granted and surfaces the capability", async ({ + page, +}) => { + const state = "sign-in-contacts-granted"; + const apiMocks = await prepareGoogleAuthCallbackPage(page, { + metadata: { + google: { + connectionState: "HEALTHY", + connections: [connectionSummary(true)], + }, + }, + }); + + await seedSignInIntent(page, state); + await page.goto( + getCallbackUrl(state, [...REQUIRED_SCOPES, ...OPTIONAL_CONTACTS_SCOPES]), + ); + + await expect(page).toHaveURL(/\/week$/); + expect(apiMocks.loginOrSignupRequests).toHaveLength(1); + + await expect + .poll(async () => (await readStoredGoogleMetadata(page))?.connectionState) + .toBe("HEALTHY"); + const google = await readStoredGoogleMetadata(page); + expect(google?.connections?.[0]?.canSuggestContacts).toBe(true); +}); + +test("finishes sign-in when the contacts scopes are denied: connection healthy, capability false", async ({ + page, +}) => { + const state = "sign-in-contacts-denied"; + const apiMocks = await prepareGoogleAuthCallbackPage(page, { + metadata: { + google: { + connectionState: "HEALTHY", + connections: [connectionSummary(false)], + }, + }, + }); + + await seedSignInIntent(page, state); + // The callback grants ONLY the required scopes — the contacts boxes were + // left unchecked. This must complete exactly like a full grant (no + // missing-scopes failure, no insufficientScopes / reconnect state). + await page.goto(getCallbackUrl(state, REQUIRED_SCOPES)); + + await expect(page).toHaveURL(/\/week$/); + expect(apiMocks.loginOrSignupRequests).toHaveLength(1); + await expect( + page.getByText( + "Compass needs all the requested permissions to sync your calendar. Please allow them and try again.", + ), + ).not.toBeVisible(); + + await expect + .poll(async () => (await readStoredGoogleMetadata(page))?.connectionState) + .toBe("HEALTHY"); + const google = await readStoredGoogleMetadata(page); + expect(google?.connections?.[0]?.canSuggestContacts).toBe(false); +}); diff --git a/packages/backend/src/auth/services/google/google.auth.scopes.test.ts b/packages/backend/src/auth/services/google/google.auth.scopes.test.ts new file mode 100644 index 0000000000..cf89ac6f48 --- /dev/null +++ b/packages/backend/src/auth/services/google/google.auth.scopes.test.ts @@ -0,0 +1,39 @@ +import { + GOOGLE_AUTH_SCOPES, + GOOGLE_AUTH_SCOPES_OPTIONAL, + GOOGLE_AUTH_SCOPES_REQUESTED, +} from "@backend/auth/services/google/google.auth.scopes"; +import { describe, expect, it } from "bun:test"; + +describe("google auth scopes", () => { + // GOOGLE_AUTH_SCOPES is the REQUIRED list: sign-in validation + // (grantedGoogleScopes) fails without every scope in it, so a contacts + // scope here would brick sign-in for everyone who declines it. The literal + // pin is the regression guard for WP-05's core promise: the required list + // did not change. + it("keeps the required list unchanged — no contacts scope, ever", () => { + expect(GOOGLE_AUTH_SCOPES).toEqual([ + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/calendar.readonly", + "https://www.googleapis.com/auth/calendar.events", + ]); + for (const scope of GOOGLE_AUTH_SCOPES) { + expect(scope).not.toContain("contacts"); + } + }); + + it("requests the contacts scopes only as optional additions", () => { + expect(GOOGLE_AUTH_SCOPES_OPTIONAL).toEqual([ + "https://www.googleapis.com/auth/contacts.readonly", + "https://www.googleapis.com/auth/contacts.other.readonly", + ]); + expect(GOOGLE_AUTH_SCOPES_REQUESTED).toEqual([ + ...GOOGLE_AUTH_SCOPES, + ...GOOGLE_AUTH_SCOPES_OPTIONAL, + ]); + // No optional scope may leak into the required list. + for (const scope of GOOGLE_AUTH_SCOPES_OPTIONAL) { + expect(GOOGLE_AUTH_SCOPES).not.toContain(scope); + } + }); +}); diff --git a/packages/backend/src/auth/services/google/google.auth.scopes.ts b/packages/backend/src/auth/services/google/google.auth.scopes.ts index b1dc4f3f73..2d61ba843d 100644 --- a/packages/backend/src/auth/services/google/google.auth.scopes.ts +++ b/packages/backend/src/auth/services/google/google.auth.scopes.ts @@ -1,8 +1,31 @@ // Keep the scopes configured for SuperTokens and the scopes persisted in Sync // together. The browser independently verifies that Google granted this set // before it sends its authorization code to Compass. +// +// This is the REQUIRED list: sign-in fails without every scope in it. It must +// stay in lockstep with the web's GOOGLE_AUTH_SCOPES_REQUIRED and the e2e +// REQUIRED_SCOPES list, and it must NEVER gain a contacts scope — contacts are +// optional (see GOOGLE_AUTH_SCOPES_OPTIONAL below), and requiring one would +// brick sign-in for every user who declines it. export const GOOGLE_AUTH_SCOPES: string[] = [ "https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/calendar.readonly", "https://www.googleapis.com/auth/calendar.events", ]; + +// Optional scopes the consent screen ASKS for but sign-in never requires. The +// user can leave them unchecked and proceed; whatever Google actually granted +// is persisted per connection and drives capabilities (e.g. suggestContacts) +// downstream. Approved as optional sensitive scopes 2026-08-25. +export const GOOGLE_AUTH_SCOPES_OPTIONAL: string[] = [ + "https://www.googleapis.com/auth/contacts.readonly", + "https://www.googleapis.com/auth/contacts.other.readonly", +]; + +// What the sign-in flow REQUESTS from Google: every required scope plus the +// optional ones. Only ever used to build the consent request — required-scope +// validation stays on GOOGLE_AUTH_SCOPES alone. +export const GOOGLE_AUTH_SCOPES_REQUESTED: string[] = [ + ...GOOGLE_AUTH_SCOPES, + ...GOOGLE_AUTH_SCOPES_OPTIONAL, +]; diff --git a/packages/backend/src/auth/services/google/google.auth.service.test.ts b/packages/backend/src/auth/services/google/google.auth.service.test.ts index 7d94d11119..6171799f71 100644 --- a/packages/backend/src/auth/services/google/google.auth.service.test.ts +++ b/packages/backend/src/auth/services/google/google.auth.service.test.ts @@ -1,7 +1,10 @@ import { faker } from "@faker-js/faker"; import { type Credentials, type TokenPayload } from "google-auth-library"; import { restoreFileMocks } from "@backend/__tests__/helpers/mock.setup"; -import { GOOGLE_AUTH_SCOPES } from "@backend/auth/services/google/google.auth.scopes"; +import { + GOOGLE_AUTH_SCOPES, + GOOGLE_AUTH_SCOPES_REQUESTED, +} from "@backend/auth/services/google/google.auth.scopes"; import { type AuthDecision, type GoogleSignInSuccess, @@ -194,6 +197,37 @@ describe("handleGoogleAuth", () => { expect(adoptCalls).toHaveLength(0); }); + it("signs up successfully when the optional contacts scopes are not granted", async () => { + // The consent screen REQUESTS the contacts scopes + // (GOOGLE_AUTH_SCOPES_REQUESTED), but the user can uncheck them: the + // grant then carries exactly the required scopes and sign-in must + // succeed — required-scope validation checks GOOGLE_AUTH_SCOPES only. + const providerUser = makeProviderUser(); + const withoutContacts = GOOGLE_AUTH_SCOPES_REQUESTED.filter( + (scope) => !scope.includes("contacts"), + ); + expect(withoutContacts).toEqual(GOOGLE_AUTH_SCOPES); + const success: GoogleSignInSuccess = { + providerUser, + oAuthTokens: { + access_token: faker.internet.jwt(), + refresh_token: faker.string.uuid(), + scope: withoutContacts.join(" "), + }, + createdNewRecipeUser: true, + recipeUserId: faker.database.mongodbObjectId(), + loginMethodsLength: 1, + }; + mockDetermineGoogleAuthMode.mockResolvedValue( + makeDecision({ authMode: "SIGNUP" }), + ); + + await googleAuthService.handleGoogleAuth(success); + + expect(googleAuthService.googleSignup).toHaveBeenCalledTimes(1); + expect(adoptCalls).toHaveLength(1); + }); + it("does not create a user when an existing session would otherwise SIGNUP", async () => { const success: GoogleSignInSuccess = { providerUser: makeProviderUser(), diff --git a/packages/backend/src/common/middleware/supertokens.middleware.ts b/packages/backend/src/common/middleware/supertokens.middleware.ts index 022de008fa..12836ebdd1 100644 --- a/packages/backend/src/common/middleware/supertokens.middleware.ts +++ b/packages/backend/src/common/middleware/supertokens.middleware.ts @@ -12,7 +12,7 @@ import UserMetadata from "supertokens-node/recipe/usermetadata"; import { APP_NAME } from "@core/constants/core.constants"; import { BaseError } from "@core/errors/errors.base"; import { Status } from "@core/errors/status.codes"; -import { GOOGLE_AUTH_SCOPES } from "@backend/auth/services/google/google.auth.scopes"; +import { GOOGLE_AUTH_SCOPES_REQUESTED } from "@backend/auth/services/google/google.auth.scopes"; import { CONFIG } from "@backend/common/constants/config.constants"; import { isGoogleConfigured } from "@backend/common/constants/config.util"; import { @@ -35,7 +35,9 @@ const createGoogleProvider = ( clientType: "web", clientId, clientSecret, - scope: GOOGLE_AUTH_SCOPES, + // Requested (required + optional contacts) — the REQUIRED validation + // lives in google.auth.service.ts against GOOGLE_AUTH_SCOPES only. + scope: GOOGLE_AUTH_SCOPES_REQUESTED, }, ], }, diff --git a/packages/backend/src/common/services/sync-service/connection-state.translation.test.ts b/packages/backend/src/common/services/sync-service/connection-state.translation.test.ts index 4b67fb978a..8cbd1cb2c9 100644 --- a/packages/backend/src/common/services/sync-service/connection-state.translation.test.ts +++ b/packages/backend/src/common/services/sync-service/connection-state.translation.test.ts @@ -108,6 +108,24 @@ describe("toGoogleSyncConnectionSummary", () => { // This connection's own state, so the browser can render one account's // status without knowing sync's vocabulary. delayed maps to ATTENTION. connectionState: "ATTENTION", + // No capabilities on the record — no contacts grant to surface. + canSuggestContacts: false, }); }); + + it("surfaces suggestContacts as canSuggestContacts", () => { + const granted = connection("healthy", null, { + capabilities: ["readEvents", "writeEvents", "suggestContacts"], + }); + expect(toGoogleSyncConnectionSummary(granted).canSuggestContacts).toBe( + true, + ); + + const notGranted = connection("healthy", null, { + capabilities: ["readEvents", "writeEvents"], + }); + expect(toGoogleSyncConnectionSummary(notGranted).canSuggestContacts).toBe( + false, + ); + }); }); diff --git a/packages/backend/src/common/services/sync-service/connection-state.translation.ts b/packages/backend/src/common/services/sync-service/connection-state.translation.ts index 219dfc1110..918ed1e4cb 100644 --- a/packages/backend/src/common/services/sync-service/connection-state.translation.ts +++ b/packages/backend/src/common/services/sync-service/connection-state.translation.ts @@ -108,5 +108,10 @@ export function toGoogleSyncConnectionSummary( connection.state, connection.stateReason, ), + // Sync derives this capability from the OPTIONAL contacts scopes the + // account actually granted; false is an ordinary state (the grant is + // never required) that the browser uses to offer the enable-contacts + // nudge instead of live suggestions. + canSuggestContacts: connection.capabilities.includes("suggestContacts"), }; } diff --git a/packages/backend/src/common/services/sync-service/event-command.translation.test.ts b/packages/backend/src/common/services/sync-service/event-command.translation.test.ts index 4381a2ba1c..9b3110c5fe 100644 --- a/packages/backend/src/common/services/sync-service/event-command.translation.test.ts +++ b/packages/backend/src/common/services/sync-service/event-command.translation.test.ts @@ -6,6 +6,7 @@ import { toCreateSubmitRequest, toDeleteSubmitRequest, toReplaceSubmitRequests, + toRsvpSubmitRequest, toSyncContent, } from "./event-command.translation"; import { composeOccurrenceId } from "./occurrence-id"; @@ -75,6 +76,49 @@ describe("toSyncContent", () => { toSyncContent({ title: "Standup", description: "Daily", location: "" }), ).not.toHaveProperty("color"); }); + + it("maps intended attendees in with the needsAction placeholder", () => { + expect( + toSyncContent({ + title: "Standup", + description: "Daily", + location: "", + attendees: [ + { email: "ada@example.com", displayName: "Ada" }, + { email: "grace@example.com", displayName: null }, + ], + }), + ).toEqual({ + title: "Standup", + description: "Daily", + location: "", + organizer: null, + attendees: [ + { + email: "ada@example.com", + displayName: "Ada", + responseStatus: "needsAction", + }, + { + email: "grace@example.com", + displayName: null, + responseStatus: "needsAction", + }, + ], + conference: null, + }); + }); + + it("keeps an explicit empty guest list as [] (remove everyone)", () => { + expect( + toSyncContent({ + title: "Standup", + description: "Daily", + location: "", + attendees: [], + }).attendees, + ).toEqual([]); + }); }); describe("resolveCommandTarget", () => { @@ -206,6 +250,70 @@ describe("toCreateSubmitRequest", () => { expect(request.restore).toBeUndefined(); }); + + it("threads attendees and invitation into a schema-valid create command", () => { + const { request, responseEvent } = toCreateSubmitRequest({ + id: objectId(), + calendarId: objectId(), + content: { + kind: "details", + title: "Kickoff", + description: "", + location: "", + attendees: [{ email: "ada@example.com", displayName: "Ada" }], + }, + schedule: timedSchedule, + recurrence: { kind: "single" }, + invitation: "all", + }); + + expect(() => CommandSubmitRequestSchema.parse(request)).not.toThrow(); + expect(request.input).toMatchObject({ + kind: "create", + invitation: "all", + attendeesEdit: "replace", + }); + if (request.input.kind !== "create") return; + expect(request.input.content.attendees).toEqual([ + { + email: "ada@example.com", + displayName: "Ada", + responseStatus: "needsAction", + }, + ]); + // The optimistic response event carries the intended guests so the + // browser cache stays coherent until the provider-sourced read arrives. + expect(() => EventSchema.parse(responseEvent)).not.toThrow(); + expect( + responseEvent.content.kind === "details" && + responseEvent.content.attendees, + ).toEqual([ + { + email: "ada@example.com", + displayName: "Ada", + responseStatus: "needsAction", + }, + ]); + }); + + it("keeps a legacy create input on preserve with no notification", () => { + const { request, responseEvent } = toCreateSubmitRequest({ + id: objectId(), + calendarId: objectId(), + content: { kind: "details", title: "X", description: "", location: "" }, + schedule: timedSchedule, + recurrence: { kind: "single" }, + }); + + expect(request.input).toMatchObject({ + kind: "create", + invitation: "none", + attendeesEdit: "preserve", + }); + if (request.input.kind !== "create") return; + expect(request.input.content.attendees).toEqual([]); + expect(responseEvent.content).not.toHaveProperty("attendees"); + }); }); describe("toReplaceSubmitRequests", () => { @@ -282,6 +390,196 @@ describe("toReplaceSubmitRequests", () => { expect(restored[0]?.idempotencyKey).toBe(plain[0]?.idempotencyKey); }); + it("threads attendees and invitation into a schema-valid update command", () => { + const eventId = objectId(); + const { requests, responseEvent } = toReplaceSubmitRequests(eventId, { + content: { + kind: "details", + title: "Kickoff", + description: "", + location: "", + attendees: [ + { email: "ada@example.com", displayName: "Ada" }, + { email: "grace@example.com", displayName: null }, + ], + }, + schedule: timedSchedule, + recurrence: { kind: "preserve" }, + scope: "this", + invitation: "all", + }); + + expect(requests).toHaveLength(1); + const request = requests[0]; + expect(() => CommandSubmitRequestSchema.parse(request)).not.toThrow(); + expect(request?.input).toMatchObject({ + kind: "update", + invitation: "all", + attendeesEdit: "replace", + }); + if (request?.input.kind !== "update") return; + expect(request.input.content.attendees).toEqual([ + { + email: "ada@example.com", + displayName: "Ada", + responseStatus: "needsAction", + }, + { + email: "grace@example.com", + displayName: null, + responseStatus: "needsAction", + }, + ]); + expect(() => EventSchema.parse(responseEvent)).not.toThrow(); + expect( + responseEvent.content.kind === "details" && + responseEvent.content.attendees, + ).toEqual([ + { + email: "ada@example.com", + displayName: "Ada", + responseStatus: "needsAction", + }, + { + email: "grace@example.com", + displayName: null, + responseStatus: "needsAction", + }, + ]); + }); + + it("marks an explicit empty guest list as replace, not preserve", () => { + const { requests } = toReplaceSubmitRequests(objectId(), { + content: { + kind: "details", + title: "Solo", + description: "", + location: "", + attendees: [], + }, + schedule: timedSchedule, + recurrence: { kind: "preserve" }, + scope: "this", + }); + + expect(requests[0]?.input).toMatchObject({ + kind: "update", + attendeesEdit: "replace", + }); + if (requests[0]?.input.kind !== "update") return; + expect(requests[0].input.content.attendees).toEqual([]); + }); + + // Snapshot regression for the pack's backward-compat guarantee: a payload + // that predates attendee support must build the same submit request it + // always did (invitation none, attendeesEdit preserve, [] attendee pad). + it("builds a byte-identical submit request for a legacy replace payload", () => { + const eventId = "64b7f7f7f7f7f7f7f7f7f7f7"; + const { requests } = toReplaceSubmitRequests(eventId, { + content: { + kind: "details", + title: "Standup", + description: "Daily", + location: "Room A", + }, + schedule: { + kind: "timed", + start: "2026-07-14T09:00:00.000Z", + end: "2026-07-14T10:00:00.000Z", + timeZone: "UTC", + }, + recurrence: { kind: "preserve" }, + scope: "this", + }); + + expect(requests).toEqual([ + { + idempotencyKey: "update:0b7c2048556d01da12ae81970f090b767bc6a6bc", + eventId, + expectedVersion: null, + input: { + kind: "update", + invitation: "none", + attendeesEdit: "preserve", + content: { + title: "Standup", + description: "Daily", + location: "Room A", + organizer: null, + attendees: [], + conference: null, + }, + schedule: { + kind: "timed", + start: "2026-07-14T09:00:00.000Z", + end: "2026-07-14T10:00:00.000Z", + timeZone: "UTC", + }, + recurrence: { kind: "preserve" }, + scope: "all", + recurrenceId: null, + }, + }, + ] as never); + }); + + // The mandatory key-stability lock: the literal below was computed from the + // PRE-attendee translator for this exact payload. If it ever changes, a + // deployed retry of an in-flight legacy edit would mint a NEW command + // instead of replaying the original — double-applying the write. The hash + // covers the browser content AS RECEIVED, so absent attendees/invitation + // serialize exactly as they did before those fields existed. + it("keeps the legacy update idempotency key stable across the attendee rollout", () => { + const legacyInput = { + content: { + kind: "details" as const, + title: "Standup", + description: "Daily", + location: "Room A", + }, + schedule: { + kind: "timed" as const, + start: "2026-07-14T09:00:00.000Z", + end: "2026-07-14T10:00:00.000Z", + timeZone: "UTC", + }, + recurrence: { kind: "preserve" as const }, + scope: "this" as const, + }; + const { requests } = toReplaceSubmitRequests( + "64b7f7f7f7f7f7f7f7f7f7f7", + legacyInput, + ); + + expect(requests[0]?.idempotencyKey).toBe( + "update:0b7c2048556d01da12ae81970f090b767bc6a6bc", + ); + // invitation is per-submission delivery intent, deliberately outside the + // hash (like restore): the same edit resubmitted with a different email + // choice must reach the same command record. + const { requests: withInvitation } = toReplaceSubmitRequests( + "64b7f7f7f7f7f7f7f7f7f7f7", + { ...legacyInput, invitation: "all" }, + ); + expect(withInvitation[0]?.idempotencyKey).toBe( + "update:0b7c2048556d01da12ae81970f090b767bc6a6bc", + ); + // A guest-list edit rides inside content, so it mints a distinct key. + const { requests: withGuests } = toReplaceSubmitRequests( + "64b7f7f7f7f7f7f7f7f7f7f7", + { + ...legacyInput, + content: { + ...legacyInput.content, + attendees: [{ email: "ada@example.com", displayName: null }], + }, + }, + ); + expect(withGuests[0]?.idempotencyKey).not.toBe( + "update:0b7c2048556d01da12ae81970f090b767bc6a6bc", + ); + }); + it("appends a move command when calendarId is present", () => { const eventId = objectId(); const calendarId = objectId(); @@ -329,4 +627,142 @@ describe("toDeleteSubmitRequest", () => { expect(second.idempotencyKey).toBe(first.idempotencyKey); }); + + // Guest cancellation emails: the user's save-time choice flows onto the + // delete command instead of the old hardcoded "none". + it("threads invitation through a delete without changing its identity key", () => { + const eventId = "64b7f7f7f7f7f7f7f7f7f7f7"; + const request = toDeleteSubmitRequest(eventId, { + scope: "all", + invitation: "all", + }); + + expect(() => CommandSubmitRequestSchema.parse(request)).not.toThrow(); + if (request.input.kind !== "delete") return; + expect(request.input.invitation).toBe("all"); + // Identity-only key, pinned from the pre-attendee translator: a legacy + // delete AND one carrying an invitation both map to the same command, so + // a timed-out delete retried with either shape never double-submits. + expect(request.idempotencyKey).toBe( + "delete:b65cb27825e51d116acdcb198b1f11786dd971c4", + ); + const legacy = toDeleteSubmitRequest(eventId, { scope: "all" }); + expect(legacy.idempotencyKey).toBe( + "delete:b65cb27825e51d116acdcb198b1f11786dd971c4", + ); + if (legacy.input.kind !== "delete") return; + expect(legacy.input.invitation).toBe("none"); + }); +}); + +describe("toRsvpSubmitRequest", () => { + // Occurrence-scope payload proof (WP-08): browser scope "single" on a + // composite occurrence id addresses exactly that occurrence — sync scope + // "this" with the decoded recurrenceId, never the whole series. + it("addresses one occurrence for scope single on a composite id", () => { + const eventId = objectId(); + const recurrenceId = "2026-07-21T15:00:00.000Z"; + const id = composeOccurrenceId({ eventId, recurrenceId }); + const request = toRsvpSubmitRequest(id, { + responseStatus: "declined", + scope: "single", + }); + + expect(() => CommandSubmitRequestSchema.parse(request)).not.toThrow(); + expect(request.eventId).toBe(eventId); + expect(request.expectedVersion).toBeNull(); + expect(request.input).toEqual({ + kind: "rsvp", + responseStatus: "declined", + scope: "this", + recurrenceId, + }); + }); + + it("targets the series master for scope all on a composite id", () => { + const eventId = objectId(); + const id = composeOccurrenceId({ + eventId, + recurrenceId: "2026-07-21T15:00:00.000Z", + }); + const request = toRsvpSubmitRequest(id, { + responseStatus: "accepted", + scope: "all", + }); + + expect(() => CommandSubmitRequestSchema.parse(request)).not.toThrow(); + expect(request.eventId).toBe(eventId); + expect(request.input).toEqual({ + kind: "rsvp", + responseStatus: "accepted", + scope: "all", + recurrenceId: null, + }); + }); + + // A non-recurring event has no occurrence to address: scope "single" on a + // plain id answers the event itself, coerced to sync's coherent + // scope-"all" + null recurrenceId exactly like update/delete. Never + // "thisAndFollowing" — sync refuses that typed for rsvp. + it("coerces scope single on a plain id to the event itself (scope all, null recurrenceId)", () => { + const eventId = objectId(); + const request = toRsvpSubmitRequest(eventId, { + responseStatus: "tentative", + scope: "single", + }); + + expect(request.eventId).toBe(eventId); + expect(request.input).toEqual({ + kind: "rsvp", + responseStatus: "tentative", + scope: "all", + recurrenceId: null, + }); + }); + + // Idempotency key = event + status + scope: the same answer replays the + // same command; a different answer, target, or scope mints a new one. + it("derives the idempotency key from event + status + scope", () => { + const eventId = objectId(); + const id = composeOccurrenceId({ + eventId, + recurrenceId: "2026-07-21T15:00:00.000Z", + }); + const accept = { responseStatus: "accepted", scope: "single" } as const; + + const first = toRsvpSubmitRequest(id, accept); + const replay = toRsvpSubmitRequest(id, accept); + expect(replay.idempotencyKey).toBe(first.idempotencyKey); + expect(first.idempotencyKey).toStartWith("rsvp:"); + + const changedAnswer = toRsvpSubmitRequest(id, { + responseStatus: "declined", + scope: "single", + }); + expect(changedAnswer.idempotencyKey).not.toBe(first.idempotencyKey); + + const changedScope = toRsvpSubmitRequest(id, { + responseStatus: "accepted", + scope: "all", + }); + expect(changedScope.idempotencyKey).not.toBe(first.idempotencyKey); + + const otherEvent = toRsvpSubmitRequest( + composeOccurrenceId({ + eventId: objectId(), + recurrenceId: "2026-07-21T15:00:00.000Z", + }), + accept, + ); + expect(otherEvent.idempotencyKey).not.toBe(first.idempotencyKey); + }); + + it("throws INVALID_OCCURRENCE_ID on a malformed composite id rather than widening to the series", () => { + expect(() => + toRsvpSubmitRequest(`${objectId()}::not-a-date`, { + responseStatus: "accepted", + scope: "single", + }), + ).toThrow(/looks like an occurrence reference/); + }); }); diff --git a/packages/backend/src/common/services/sync-service/event-command.translation.ts b/packages/backend/src/common/services/sync-service/event-command.translation.ts index 49a589d85c..27290ed46b 100644 --- a/packages/backend/src/common/services/sync-service/event-command.translation.ts +++ b/packages/backend/src/common/services/sync-service/event-command.translation.ts @@ -7,6 +7,10 @@ import { EventIdSchema, } from "@core/types/domain-primitives"; import { type Event, EventSchema } from "@core/types/event.contracts"; +import { + type Attendee, + type AttendeeInput, +} from "@core/types/event-attendance.contracts"; import { type EventColorSlot, withColor, @@ -16,6 +20,7 @@ import { type DeleteEventInput, type RecurrenceScope, type ReplaceEventInput, + type RsvpEventInput, } from "@core/types/event-command.contracts"; import { type CommandSubmitRequest, @@ -35,29 +40,74 @@ import { createHash } from "node:crypto"; // we still need a contract-valid calendarId to round-trip the parse. const UNKNOWN_CALENDAR_ID = CalendarIdSchema.parse("000000000000000000000000"); -// Expand browser details-content into sync's fuller content shape. Browser -// edits never touch organizer/attendees/conference — pad with nulls so the -// strict SyncEventContent schema accepts the wire payload. On create those -// nulls are correct (new event). On update, sync's apply path merges -// title/description/location onto the existing record (mergeUpdateContent) -// so a rename cannot wipe provider-sourced attendees/conference. Optional -// color is forwarded when the browser sets one; omitted color leaves merge -// to keep whatever Sync already stores. -export const toSyncContent = (content: { +// The browser names guests but never sets anyone's RSVP (AttendeeInputSchema +// has no responseStatus), so every intended attendee enters the command — and +// the optimistic response event — as the provider's "no answer yet" state. +// Sync's merge-by-email keeps a retained guest's real provider responseStatus; +// this placeholder only survives for genuinely new emails. +const toIntendedAttendees = (attendees: readonly AttendeeInput[]): Attendee[] => + attendees.map(({ email, displayName }) => ({ + email, + displayName, + responseStatus: "needsAction" as const, + })); + +// Browser content that may carry a guest-list edit. Omitted attendees means +// "not editing guests" (today's behavior); present, including [], means +// "replace membership with exactly this set" — see EditableContentSchema. +interface BrowserEditableContent { title: string; description: string; location: string; color?: EventColorSlot | null; -}): SyncEventContent => ({ + attendees?: readonly AttendeeInput[]; +} + +// Expand browser details-content into sync's fuller content shape. Browser +// edits never touch organizer/conference — pad with nulls so the strict +// SyncEventContent schema accepts the wire payload. On create those nulls are +// correct (new event). On update, sync's apply path merges +// title/description/location onto the existing record (mergeUpdateContent) +// so a rename cannot wipe provider-sourced attendees/conference. Attendees: +// an intended guest list maps in with the needsAction placeholder (paired +// with attendeesEdit "replace" via toAttendeesEdit below); omitted keeps +// today's [] pad, which sync ignores under attendeesEdit "preserve". Optional +// color is forwarded when the browser sets one; omitted color leaves merge +// to keep whatever Sync already stores. +export const toSyncContent = ( + content: BrowserEditableContent, +): SyncEventContent => ({ title: content.title, description: content.description, location: content.location, organizer: null, - attendees: [], + attendees: + content.attendees === undefined + ? [] + : toIntendedAttendees(content.attendees), conference: null, ...withColor(content.color), }); +// Whether this write replaces guest membership. Derived from presence, not +// emptiness: [] is a deliberate "remove everyone" and must still replace. +const toAttendeesEdit = ( + content: BrowserEditableContent, +): "replace" | "preserve" => + content.attendees === undefined ? "preserve" : "replace"; + +// Synthesized response events echo the intended guest list (needsAction +// placeholders) so the browser's optimistic cache stays coherent until the +// provider-sourced read arrives. Content without a guest edit passes through +// untouched — the input attendee shape has no responseStatus, so it would not +// parse as the read-side AttendeeSchema. +const toResponseContent = ( + content: C, +): C | (Omit & { attendees: Attendee[] }) => + content.attendees === undefined + ? content + : { ...content, attendees: toIntendedAttendees(content.attendees) }; + export interface CommandTarget { eventId: EventId; scope: RecurrenceScope; @@ -142,7 +192,10 @@ export const toCreateSubmitRequest = ( kind: "create", calendarId: input.calendarId, clientEventId, - invitation: "none", + // The user's save-time choice of whether the provider emails attendees; + // absent means the pre-attendee default of notifying no one. + invitation: input.invitation ?? "none", + attendeesEdit: toAttendeesEdit(input.content), content: toSyncContent(input.content), schedule: input.schedule, recurrence: input.recurrence, @@ -154,7 +207,7 @@ export const toCreateSubmitRequest = ( const responseEvent = EventSchema.parse({ id: eventId, calendarId: input.calendarId, - content: input.content, + content: toResponseContent(input.content), schedule: input.schedule, recurrence: input.recurrence, createdAt: now, @@ -175,6 +228,13 @@ export const toReplaceSubmitRequests = ( const target = resolveCommandTarget(id, input.scope); const updateRequest = CommandSubmitRequestSchema.parse({ + // Hashes the browser payload AS RECEIVED: a legacy payload (no attendees, + // no invitation) serializes byte-identically to before those fields + // existed, so its key — and therefore retry/replay identity across a + // deploy — is unchanged (pinned by the key-stability test). A guest-list + // edit rides inside `content`, so it naturally mints a distinct key. + // `invitation` stays out of the hash like `restore` does: it is + // per-submission delivery intent, not a different edit. idempotencyKey: hashedIdempotencyKey("update", { eventId: target.eventId, scope: target.scope, @@ -187,7 +247,8 @@ export const toReplaceSubmitRequests = ( expectedVersion: null, input: { kind: "update", - invitation: "none", + invitation: input.invitation ?? "none", + attendeesEdit: toAttendeesEdit(input.content), content: toSyncContent(input.content), schedule: input.schedule, recurrence: input.recurrence, @@ -250,7 +311,51 @@ export const toDeleteSubmitRequest = ( expectedVersion: null, input: { kind: "delete", - invitation: "none", + // Guest cancellation emails: the provider notifies attendees of the + // deletion when the user chose to. Deliberately outside the identity- + // only idempotency key above — it is delivery intent, not a different + // delete. + invitation: input.invitation ?? "none", + scope: target.scope, + recurrenceId: target.recurrenceId, + }, + }); +}; + +// RSVP → one rsvp command. The browser's scope vocabulary is "single" | "all" +// (RsvpEventInputSchema): "single" answers exactly the addressed event — one +// occurrence when the URL id is a composite occurrence id, or the event itself +// for a plain id (resolveCommandTarget coerces that to sync's scope "all" + +// null recurrenceId, exactly as update/delete address a non-recurring event) — +// and "all" answers the whole series (composite ids drop their recurrenceId). +// "thisAndFollowing" is deliberately unreachable: sync refuses it typed for +// rsvp, so no translation may ever mint it. +// +// The idempotency key is derived from event + status + scope (target identity +// plus the answer): repeating the same answer replays the same command, while +// changing the answer — or the scope — mints a distinct command. Like +// delete's key, it is deliberately nonce-free so a timed-out POST retried by +// the client maps back to the original command instead of double-submitting. +export const toRsvpSubmitRequest = ( + id: string, + input: RsvpEventInput, +): CommandSubmitRequest => { + const target = resolveCommandTarget( + id, + input.scope === "all" ? "all" : "this", + ); + return CommandSubmitRequestSchema.parse({ + idempotencyKey: hashedIdempotencyKey("rsvp", { + eventId: target.eventId, + scope: target.scope, + recurrenceId: target.recurrenceId, + responseStatus: input.responseStatus, + }), + eventId: target.eventId, + expectedVersion: null, + input: { + kind: "rsvp", + responseStatus: input.responseStatus, scope: target.scope, recurrenceId: target.recurrenceId, }, @@ -286,7 +391,7 @@ const synthesizeReplaceEvent = ( return EventSchema.parse({ id: responseId, calendarId: input.calendarId ?? UNKNOWN_CALENDAR_ID, - content: input.content, + content: toResponseContent(input.content), schedule: input.schedule, recurrence, createdAt: new Date().toISOString(), diff --git a/packages/backend/src/common/services/sync-service/sync-service.client.test.ts b/packages/backend/src/common/services/sync-service/sync-service.client.test.ts index 161fcc920d..5a08ffc521 100644 --- a/packages/backend/src/common/services/sync-service/sync-service.client.test.ts +++ b/packages/backend/src/common/services/sync-service/sync-service.client.test.ts @@ -22,6 +22,7 @@ import { EVENTS_FULL_PATH, FOREGROUND_REFRESH_PATH, } from "@sync/server/connection.routes"; +import { CONTACTS_SUGGESTIONS_PATH } from "@sync/server/contacts.routes"; import { PRINCIPAL_PATH } from "@sync/server/principal.routes"; import { SyncServiceClient, @@ -310,6 +311,69 @@ describe("SyncServiceClient", () => { expect(verdict.context.principalId).toBe(who.principalId); }); + it("fetches contact suggestions with a signed GET the real Sync verifier accepts", async () => { + const who = principal(); + const { fn, calls } = fakeFetch(async () => ({ + status: 200, + json: async () => ({ + suggestions: [{ email: "ada@example.com", displayName: "Ada" }], + }), + })); + + const result = await client(fn).getContactSuggestions(who, "ad a"); + + if (!result.ok) throw new Error(`expected ok, got ${result.error.kind}`); + expect(result.value.suggestions).toEqual([ + { email: "ada@example.com", displayName: "Ada" }, + ]); + + const sent = calls[0]; + // Path contract with the Sync route module, query URL-encoded. + expect(sent?.url).toBe(`${BASE_URL}${CONTACTS_SUGGESTIONS_PATH}?q=ad+a`); + expect(sent?.method).toBe("GET"); + + const verdict = verifyInternalRequest({ + secret: SECRET, + headers: sent?.headers ?? {}, + now: NOW, + }); + if (!verdict.ok) throw new Error(`verify failed: ${verdict.reason}`); + expect(verdict.context.tenantId).toBe(who.tenantId); + expect(verdict.context.principalId).toBe(who.principalId); + }); + + it("rejects a contact-suggestions body that does not match the contract", async () => { + const { fn } = fakeFetch(async () => ({ + status: 200, + json: async () => ({ + // People-shaped extras must never ride through the strict contract. + suggestions: [ + { + email: "ada@example.com", + displayName: "Ada", + phoneNumbers: ["+1"], + }, + ], + }), + })); + + const result = await client(fn).getContactSuggestions(principal(), "ada"); + if (result.ok) throw new Error("expected invalidResponse"); + expect(result.error.kind).toBe("invalidResponse"); + }); + + it("maps a 403 contacts refusal to unexpectedStatus with the status attached", async () => { + const { fn } = fakeFetch(async () => ({ + status: 403, + json: async () => ({ error: "contacts_not_granted" }), + })); + + const result = await client(fn).getContactSuggestions(principal(), "ada"); + if (result.ok) throw new Error("expected a failure"); + expect(result.error.kind).toBe("unexpectedStatus"); + expect(result.error.status).toBe(403); + }); + it("polls the change feed from now and with a resume cursor", async () => { const who = principal(); const cursor = objectId(); diff --git a/packages/backend/src/common/services/sync-service/sync-service.client.ts b/packages/backend/src/common/services/sync-service/sync-service.client.ts index 53d13cecfc..df974563bb 100644 --- a/packages/backend/src/common/services/sync-service/sync-service.client.ts +++ b/packages/backend/src/common/services/sync-service/sync-service.client.ts @@ -1,5 +1,9 @@ import { type z } from "zod/v4"; import { encryptInternalCredential } from "@core/security/internal-credential-envelope"; +import { + type ContactSuggestionsResponse, + ContactSuggestionsResponseSchema, +} from "@core/types/contact.contracts"; import { type BusyAvailabilityRequest, type BusyAvailabilityResponse, @@ -59,6 +63,7 @@ const ADOPT_GOOGLE_AUTHORIZATION_PATH = const EVENTS_FULL_PATH = "/internal/events/full"; const COMMANDS_PATH = "/internal/commands"; const PRINCIPAL_PATH = "/internal/principal"; +const CONTACTS_SUGGESTIONS_PATH = "/internal/contacts/suggestions"; const DEFAULT_TIMEOUT_MS = 5_000; // Provider create/update/delete run inline inside POST /internal/commands. @@ -374,6 +379,29 @@ export class SyncServiceClient { }); } + // Ranked contact suggestions for the attendee type-ahead, scoped to the + // signed principal. A provider-touching read: sync queries the People API + // per request (contacts are never cached) and answers 403 when no + // connection granted a contacts scope, so callers should treat any failure + // as "no suggestions" — the lookup is a convenience, never load-bearing. + // Neither the query nor the response content belongs in any log. + getContactSuggestions( + principal: SyncPrincipal, + query: string, + correlationId?: string, + ): Promise> { + const params = new URLSearchParams(); + params.set("q", query); + return this.#request({ + method: "GET", + path: CONTACTS_SUGGESTIONS_PATH, + query: params, + principal, + schema: ContactSuggestionsResponseSchema, + correlationId, + }); + } + // Resumable content-free invalidation page for the signed principal. Pass // `null` to resume from now (empty page + watermark). A stale/unknown cursor // returns `{ kind: "resyncRequired" }` rather than a partial replay. diff --git a/packages/backend/src/contacts/contacts.routes.config.ts b/packages/backend/src/contacts/contacts.routes.config.ts new file mode 100644 index 0000000000..a9c33476ad --- /dev/null +++ b/packages/backend/src/contacts/contacts.routes.config.ts @@ -0,0 +1,25 @@ +import type express from "express"; +import { verifySession } from "@backend/auth/session/session.middleware"; +import { CommonRoutesConfig } from "@backend/common/common.routes.config"; +import contactsController from "./controllers/contacts.controller"; + +/** + * Contacts Routes Configuration (WP-06). One authenticated read: the browser + * proxy for sync's contact-suggestion lookup. Middleware parity with event + * reads — `verifySession()` only; reads are not billing-gated (billing guards + * writes, see event.controller's assertBillingAllowsWrites). + */ +export class ContactsRoutes extends CommonRoutesConfig { + constructor(app: express.Application) { + super(app, "ContactsRoutes"); + } + + configureRoutes(): express.Application { + this.app + .route(`/api/contacts/suggestions`) + .all(verifySession()) + .get(contactsController.suggestions); + + return this.app; + } +} diff --git a/packages/backend/src/contacts/controllers/contacts.controller.test.ts b/packages/backend/src/contacts/controllers/contacts.controller.test.ts new file mode 100644 index 0000000000..0d1cc2a850 --- /dev/null +++ b/packages/backend/src/contacts/controllers/contacts.controller.test.ts @@ -0,0 +1,205 @@ +import { type Response } from "express"; +import { type SessionRequest } from "supertokens-node/framework/express"; +import { Status } from "@core/errors/status.codes"; +import { CONTACT_SUGGESTION_QUERY_MAX_LENGTH } from "@core/types/contact.contracts"; +import * as syncServiceFactory from "@backend/common/services/sync-service/sync-service.factory"; +import contactsController, { + contactSuggestionsFailureLogLine, +} from "./contacts.controller"; +import { afterEach, describe, expect, it, mock, spyOn } from "bun:test"; + +const sessionReq = (userId: string, query: Record = {}) => + ({ + session: { getUserId: () => userId }, + query, + params: {}, + body: {}, + }) as unknown as SessionRequest; + +const jsonRes = () => { + const json = mock(); + const res = { + status: mock().mockReturnThis(), + json, + } as unknown as Response; + return { res, json }; +}; + +const mockClient = ( + getContactSuggestions: ReturnType, +): ReturnType => + spyOn(syncServiceFactory, "getSyncServiceClient").mockReturnValue({ + getContactSuggestions, + } as unknown as ReturnType); + +afterEach(() => { + mock.restore(); +}); + +describe("ContactsController suggestions", () => { + const userId = "507f1f77bcf86cd799439011"; + + it("proxies sync's ranked suggestions through unchanged", async () => { + const suggestions = [ + { email: "ada@example.com", displayName: "Ada Lovelace" }, + { email: "al@example.com", displayName: null }, + ]; + const getContactSuggestions = mock(() => + Promise.resolve({ + ok: true, + value: { suggestions }, + correlationId: "corr-1", + }), + ); + mockClient(getContactSuggestions); + + const { res, json } = jsonRes(); + await contactsController.suggestions( + sessionReq(userId, { q: "a" + "d" }), + res, + ); + + expect(res.status).toHaveBeenCalledWith(Status.OK); + expect(json).toHaveBeenCalledWith({ suggestions }); + // Principal-scoped and query-forwarding. + expect(getContactSuggestions.mock.calls[0]?.[1]).toBe("ad"); + }); + + it("answers a sub-minimum query with a typed empty 200 and no sync call", async () => { + const getContactSuggestions = mock(); + mockClient(getContactSuggestions); + + const { res, json } = jsonRes(); + await contactsController.suggestions(sessionReq(userId, { q: " a " }), res); + + expect(getContactSuggestions).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(Status.OK); + expect(json).toHaveBeenCalledWith({ suggestions: [] }); + }); + + it("degrades to a typed empty 200 when sync is down (no error-toast storm)", async () => { + const getContactSuggestions = mock(() => + Promise.resolve({ + ok: false, + error: { kind: "unavailable", status: 503, correlationId: "corr-2" }, + }), + ); + mockClient(getContactSuggestions); + + const { res, json } = jsonRes(); + await contactsController.suggestions(sessionReq(userId, { q: "ada" }), res); + + expect(res.status).toHaveBeenCalledWith(Status.OK); + expect(json).toHaveBeenCalledWith({ suggestions: [] }); + }); + + it("degrades a capability refusal (403) to the same typed empty 200", async () => { + const getContactSuggestions = mock(() => + Promise.resolve({ + ok: false, + error: { + kind: "unexpectedStatus", + status: 403, + correlationId: "corr-3", + }, + }), + ); + mockClient(getContactSuggestions); + + const { res, json } = jsonRes(); + await contactsController.suggestions(sessionReq(userId, { q: "ada" }), res); + + expect(res.status).toHaveBeenCalledWith(Status.OK); + expect(json).toHaveBeenCalledWith({ suggestions: [] }); + }); + + it("rejects a missing or non-string query with 400, without calling sync", async () => { + const getContactSuggestions = mock(); + mockClient(getContactSuggestions); + + const { res: missingRes, json: missingJson } = jsonRes(); + await contactsController.suggestions(sessionReq(userId, {}), missingRes); + expect(missingRes.status).toHaveBeenCalledWith(Status.BAD_REQUEST); + expect(missingJson).toHaveBeenCalledWith({ error: "invalid_query" }); + + const { res: arrayRes } = jsonRes(); + await contactsController.suggestions( + sessionReq(userId, { q: ["a", "b"] }), + arrayRes, + ); + expect(arrayRes.status).toHaveBeenCalledWith(Status.BAD_REQUEST); + + expect(getContactSuggestions).not.toHaveBeenCalled(); + }); + + it("rejects an over-length query with 400, without calling sync", async () => { + const getContactSuggestions = mock(); + mockClient(getContactSuggestions); + + const { res } = jsonRes(); + await contactsController.suggestions( + sessionReq(userId, { + q: "a".repeat(CONTACT_SUGGESTION_QUERY_MAX_LENGTH + 1), + }), + res, + ); + + expect(res.status).toHaveBeenCalledWith(Status.BAD_REQUEST); + expect(getContactSuggestions).not.toHaveBeenCalled(); + }); + + it("answers 401 for a sessionless request", async () => { + const getContactSuggestions = mock(); + mockClient(getContactSuggestions); + + const { res } = jsonRes(); + await contactsController.suggestions( + { query: { q: "ada" } } as unknown as SessionRequest, + res, + ); + + expect(res.status).toHaveBeenCalledWith(Status.UNAUTHORIZED); + expect(getContactSuggestions).not.toHaveBeenCalled(); + }); + + it("degrades a timeout to the typed empty 200 too", async () => { + const getContactSuggestions = mock(() => + Promise.resolve({ + ok: false, + error: { kind: "timeout", correlationId: "corr-4" }, + }), + ); + mockClient(getContactSuggestions); + + const { res, json } = jsonRes(); + await contactsController.suggestions( + sessionReq(userId, { q: "secret person" }), + res, + ); + + expect(res.status).toHaveBeenCalledWith(Status.OK); + expect(json).toHaveBeenCalledWith({ suggestions: [] }); + }); + + it("builds its only log line from content-free error facts (no query, no contacts)", () => { + // The controller's single log emission goes through this exported + // builder, whose input type (SyncClientError) physically cannot carry + // the query or a suggestion. Pin the rendered shape so a future edit + // cannot quietly template extra data in. + expect( + contactSuggestionsFailureLogLine({ + kind: "unavailable", + status: 503, + correlationId: "corr-9", + }), + ).toBe( + "Contact suggestions unavailable (unavailable 503) [correlationId=corr-9]", + ); + expect( + contactSuggestionsFailureLogLine({ + kind: "timeout", + correlationId: "corr-10", + }), + ).toBe("Contact suggestions unavailable (timeout) [correlationId=corr-10]"); + }); +}); diff --git a/packages/backend/src/contacts/controllers/contacts.controller.ts b/packages/backend/src/contacts/controllers/contacts.controller.ts new file mode 100644 index 0000000000..7f6d187e1c --- /dev/null +++ b/packages/backend/src/contacts/controllers/contacts.controller.ts @@ -0,0 +1,96 @@ +import { type Response } from "express"; +import { type SessionRequest } from "supertokens-node/framework/express"; +import { Status } from "@core/errors/status.codes"; +import { Logger } from "@core/logger/winston.logger"; +import { + CONTACT_SUGGESTION_QUERY_MAX_LENGTH, + CONTACT_SUGGESTION_QUERY_MIN_LENGTH, + type ContactSuggestionsResponse, +} from "@core/types/contact.contracts"; +import { zObjectId } from "@core/types/type.utils"; +import { toSyncPrincipal } from "@backend/common/services/sync-service/sync-principal"; +import { logLevelForSyncClientError } from "@backend/common/services/sync-service/sync-proxy-error"; +import { type SyncClientError } from "@backend/common/services/sync-service/sync-service.client"; +import { getSyncServiceClient } from "@backend/common/services/sync-service/sync-service.factory"; + +const logger = Logger("app:contacts.controller"); + +const EMPTY_SUGGESTIONS: ContactSuggestionsResponse = { suggestions: [] }; + +// The ONLY log line this controller emits, built from the sync client's +// content-free error facts alone — never from the query or a response body. +// Exported so a test pins that property against future edits. +export function contactSuggestionsFailureLogLine( + error: SyncClientError, +): string { + return ( + `Contact suggestions unavailable (${error.kind}` + + `${error.status !== undefined ? ` ${error.status}` : ""}) ` + + `[correlationId=${error.correlationId}]` + ); +} + +/** + * Browser proxy for sync's contact-suggestion lookup (attendee type-ahead). + * + * Suggestions are a convenience, never load-bearing: EVERY sync-side failure + * — sync down, passive mode, capability revoked mid-session (403), rate + * limit — degrades to a typed empty 200 so the attendee field silently falls + * back to raw email entry instead of firing an error toast per keystroke. + * Only a malformed browser request is a 400. + * + * Privacy: neither the query nor any suggestion content is ever logged — + * log lines are static text plus the sync client's content-free error facts + * (kind/status/correlationId). + */ +class ContactsController { + suggestions = async (req: SessionRequest, res: Response) => { + const userId = req.session?.getUserId(); + const parsedUserId = zObjectId.safeParse(userId); + if (!parsedUserId.success) { + res.status(Status.UNAUTHORIZED).json({ error: "unauthorized" }); + return; + } + + const rawQuery = req.query["q"]; + if ( + typeof rawQuery !== "string" || + rawQuery.length > CONTACT_SUGGESTION_QUERY_MAX_LENGTH + ) { + res.status(Status.BAD_REQUEST).json({ error: "invalid_query" }); + return; + } + + // Below the minimum the answer is empty by contract (sync would answer + // the same) — skip the round-trip entirely. + const query = rawQuery.trim(); + if (query.length < CONTACT_SUGGESTION_QUERY_MIN_LENGTH) { + res.status(Status.OK).json(EMPTY_SUGGESTIONS); + return; + } + + const client = getSyncServiceClient(); + const result = await client.getContactSuggestions( + toSyncPrincipal(parsedUserId.data.toString()), + query, + ); + + if (result.ok) { + res.status(Status.OK).json(result.value); + return; + } + + // Content-free by construction: SyncClientError carries only + // kind/status/correlationId — never the query or a response body. A 403 + // (no contacts grant — metadata raced a revocation) is an ordinary state, + // not a defect, so it stays at warn rather than error-tracking level. + const level = + result.error.status === Status.FORBIDDEN + ? "warn" + : logLevelForSyncClientError(result.error.kind); + logger[level](contactSuggestionsFailureLogLine(result.error)); + res.status(Status.OK).json(EMPTY_SUGGESTIONS); + }; +} + +export default new ContactsController(); diff --git a/packages/backend/src/event/controllers/event.controller.test.ts b/packages/backend/src/event/controllers/event.controller.test.ts index 43f10b7a8c..da93804c30 100644 --- a/packages/backend/src/event/controllers/event.controller.test.ts +++ b/packages/backend/src/event/controllers/event.controller.test.ts @@ -4,6 +4,7 @@ import { type SessionRequest } from "supertokens-node/framework/express"; import { Status } from "@core/errors/status.codes"; import calendarService from "@backend/calendar/services/calendar.service"; import { CONFIG } from "@backend/common/constants/config.constants"; +import { composeOccurrenceId } from "@backend/common/services/sync-service/occurrence-id"; import * as syncServiceFactory from "@backend/common/services/sync-service/sync-service.factory"; import { eventMutationError, @@ -337,6 +338,306 @@ describe("EventController", () => { expect(json).toHaveBeenCalled(); }); + it("rejects attendees on a calendar sync does not list as writable Google, before any submit", async () => { + // A create targeting the Compass local calendar (never in sync's list) + // or any unknown id: the guest list has nowhere to be delivered, so this + // must be a typed 4xx with NO command submitted. + const submitCommand = mock(); + spyOn(syncServiceFactory, "getSyncServiceClient").mockReturnValue({ + listCalendars: mock(() => + Promise.resolve({ + ok: true as const, + value: { + calendars: [ + { + id: objectId(), // a different (writable) calendar + capabilities: { canWriteEvents: true }, + }, + ], + }, + }), + ), + submitCommand, + } as never); + + const { res, json } = jsonRes(); + const body = sampleCreateBody(); + await eventController.create( + sessionReq(objectId(), { + body: { + ...body, + content: { + ...body.content, + attendees: [{ email: "ada@example.com", displayName: null }], + }, + }, + }), + res, + ); + + expect((res.status as ReturnType).mock.calls[0]?.[0]).toBe( + Status.FORBIDDEN, + ); + expect(json).toHaveBeenCalledWith({ + code: "ATTENDEES_UNSUPPORTED", + message: + "Guests can only be added to events on a writable Google calendar", + retryable: false, + }); + expect(submitCommand).not.toHaveBeenCalled(); + }); + + it("rejects a replace carrying attendees when the user has no writable Google calendar", async () => { + const submitCommand = mock(); + spyOn(syncServiceFactory, "getSyncServiceClient").mockReturnValue({ + listCalendars: mock(() => + Promise.resolve({ + ok: true as const, + value: { + calendars: [ + // Read-only Google calendar: listed, but not writable. + { id: objectId(), capabilities: { canWriteEvents: false } }, + ], + }, + }), + ), + submitCommand, + } as never); + + const { res, json } = jsonRes(); + await eventController.replace( + sessionReq(objectId(), { + params: { id: objectId() }, + body: { + content: { + kind: "details", + title: "Kickoff", + description: "", + location: "", + attendees: [{ email: "ada@example.com", displayName: null }], + }, + schedule: { + kind: "timed", + start: "2026-07-14T12:00:00.000Z", + end: "2026-07-14T13:00:00.000Z", + timeZone: "UTC", + }, + recurrence: { kind: "preserve" }, + scope: "this", + invitation: "all", + }, + }), + res, + ); + + expect((res.status as ReturnType).mock.calls[0]?.[0]).toBe( + Status.FORBIDDEN, + ); + expect(json).toHaveBeenCalledWith( + expect.objectContaining({ code: "ATTENDEES_UNSUPPORTED" }), + ); + expect(submitCommand).not.toHaveBeenCalled(); + }); + + it("submits attendees + invitation for a writable Google calendar and echoes the guests optimistically", async () => { + const calendarId = objectId(); + const submitCommand = mock(() => + Promise.resolve({ + ok: true as const, + value: { + command: { + outcome: { + state: "confirmed" as const, + providerEventId: "prov-1", + providerVersion: "v1", + }, + }, + }, + }), + ); + spyOn(syncServiceFactory, "getSyncServiceClient").mockReturnValue({ + listCalendars: mock(() => + Promise.resolve({ + ok: true as const, + value: { + calendars: [ + { id: calendarId, capabilities: { canWriteEvents: true } }, + ], + }, + }), + ), + submitCommand, + } as never); + + const { res, json } = jsonRes(); + const body = sampleCreateBody(); + await eventController.create( + sessionReq(objectId(), { + body: { + ...body, + calendarId, + content: { + ...body.content, + attendees: [{ email: "ada@example.com", displayName: "Ada" }], + }, + invitation: "all", + }, + }), + res, + ); + + expect(res.status).toHaveBeenCalledWith(Status.OK); + const request = ( + submitCommand.mock.calls[0] as never as [unknown, { input: unknown }] + )[1]; + expect(request.input).toMatchObject({ + kind: "create", + invitation: "all", + attendeesEdit: "replace", + }); + // The optimistic response event carries the intended guests. + const responseBody = ( + json.mock.calls[0] as never as [ + { event: { content: { attendees?: unknown } } }, + ] + )[0]; + expect(responseBody.event.content.attendees).toEqual([ + { + email: "ada@example.com", + displayName: "Ada", + responseStatus: "needsAction", + }, + ]); + }); + + it("rejects a malformed attendee as 400 INVALID_INPUT, not a 500", async () => { + const { res, json } = jsonRes(); + const body = sampleCreateBody(); + await eventController.create( + sessionReq(objectId(), { + body: { + ...body, + content: { + ...body.content, + // Empty email fails AttendeeInputSchema before anything runs. + attendees: [{ email: "", displayName: null }], + }, + }, + }), + res, + ); + + expect(res.status).toHaveBeenCalledWith(Status.BAD_REQUEST); + expect(json).toHaveBeenCalledWith( + expect.objectContaining({ code: "INVALID_INPUT", retryable: false }), + ); + }); + + it("keeps the retryable SYNC_UNAVAILABLE behavior when the new fields are present", async () => { + const calendarId = objectId(); + spyOn(syncServiceFactory, "getSyncServiceClient").mockReturnValue({ + listCalendars: mock(() => + Promise.resolve({ + ok: true as const, + value: { + calendars: [ + { id: calendarId, capabilities: { canWriteEvents: true } }, + ], + }, + }), + ), + submitCommand: mock(() => + Promise.resolve({ + ok: false as const, + error: { kind: "unavailable" as const, correlationId: "corr-sub" }, + }), + ), + } as never); + + const { res, json } = jsonRes(); + const body = sampleCreateBody(); + await eventController.create( + sessionReq(objectId(), { + body: { + ...body, + calendarId, + content: { + ...body.content, + attendees: [{ email: "ada@example.com", displayName: null }], + }, + invitation: "all", + }, + }), + res, + ); + + expect((res.status as ReturnType).mock.calls[0]?.[0]).toBe( + Status.SERVICE_UNAVAILABLE, + ); + expect(json).toHaveBeenCalledWith( + expect.objectContaining({ code: "SYNC_UNAVAILABLE", retryable: true }), + ); + }); + + it("threads the invitation query param onto a delete (guest cancellation emails)", async () => { + const submitCommand = mock(() => + Promise.resolve({ + ok: true as const, + value: { + command: { + outcome: { + state: "confirmed" as const, + providerEventId: null, + providerVersion: null, + }, + }, + }, + }), + ); + spyOn(syncServiceFactory, "getSyncServiceClient").mockReturnValue({ + submitCommand, + } as never); + + const { res } = jsonRes(); + await eventController.delete( + sessionReq(objectId(), { + params: { id: objectId() }, + query: { scope: "all", invitation: "all" }, + }), + res, + ); + + expect(res.status).toHaveBeenCalledWith(Status.NO_CONTENT); + const request = ( + submitCommand.mock.calls[0] as never as [unknown, { input: unknown }] + )[1]; + expect(request.input).toMatchObject({ kind: "delete", invitation: "all" }); + }); + + it("rejects an invalid invitation query param on delete as 400 INVALID_INPUT", async () => { + const submitCommand = mock(); + spyOn(syncServiceFactory, "getSyncServiceClient").mockReturnValue({ + submitCommand, + } as never); + + const { res, json } = jsonRes(); + await eventController.delete( + sessionReq(objectId(), { + params: { id: objectId() }, + query: { scope: "all", invitation: "everyone" }, + }), + res, + ); + + expect((res.status as ReturnType).mock.calls[0]?.[0]).toBe( + Status.BAD_REQUEST, + ); + expect(json).toHaveBeenCalledWith( + expect.objectContaining({ code: "INVALID_INPUT" }), + ); + expect(submitCommand).not.toHaveBeenCalled(); + }); + it("maps authorizationRevoked to 410 GOOGLE_REVOKED (not retryable)", async () => { mockSyncCommandFailure("authorizationRevoked"); const { res, json } = await createViaSync(); @@ -410,6 +711,161 @@ describe("EventController", () => { }); }); + // WP-08: POST /api/event/:id/rsvp. No writable-calendar gate — an RSVP is + // not a calendar write, so it must succeed without ANY calendar lookup + // (viewer-access calendars included). + it("submits an rsvp command and answers 204 without any calendar-writability lookup", async () => { + const submitCommand = mock(() => + Promise.resolve({ + ok: true as const, + value: { + command: { + outcome: { + state: "confirmed" as const, + providerEventId: "prov-1", + providerVersion: "v2", + }, + }, + }, + }), + ); + // Deliberately NO listCalendars on the stub: if the handler consulted + // any writable-calendar gate, this test would throw on the missing + // method instead of answering 204. + spyOn(syncServiceFactory, "getSyncServiceClient").mockReturnValue({ + submitCommand, + } as never); + + const { res } = jsonRes(); + const eventId = objectId(); + await eventController.rsvp( + sessionReq(objectId(), { + params: { id: eventId }, + body: { responseStatus: "accepted", scope: "single" }, + }), + res, + ); + + expect(res.status).toHaveBeenCalledWith(Status.NO_CONTENT); + expect(submitCommand).toHaveBeenCalledTimes(1); + const request = ( + submitCommand.mock.calls[0] as never as [ + unknown, + { eventId: string; input: unknown; idempotencyKey: string }, + ] + )[1]; + expect(request.eventId).toBe(eventId); + expect(request.idempotencyKey).toStartWith("rsvp:"); + // Plain id + scope "single" answers the event itself (coerced target). + expect(request.input).toEqual({ + kind: "rsvp", + responseStatus: "accepted", + scope: "all", + recurrenceId: null, + }); + }); + + it("posts the decoded occurrence target for a scope-single rsvp on a composite id", async () => { + const submitCommand = mock(() => + Promise.resolve({ + ok: true as const, + value: { + command: { + outcome: { + state: "confirmed" as const, + providerEventId: "prov-inst-1", + providerVersion: "v3", + }, + }, + }, + }), + ); + spyOn(syncServiceFactory, "getSyncServiceClient").mockReturnValue({ + submitCommand, + } as never); + + const { res } = jsonRes(); + const seriesId = objectId(); + const recurrenceId = "2026-07-21T15:00:00.000Z"; + await eventController.rsvp( + sessionReq(objectId(), { + params: { + id: composeOccurrenceId({ eventId: seriesId, recurrenceId }), + }, + body: { responseStatus: "declined", scope: "single" }, + }), + res, + ); + + expect(res.status).toHaveBeenCalledWith(Status.NO_CONTENT); + const request = ( + submitCommand.mock.calls[0] as never as [ + unknown, + { eventId: string; input: unknown }, + ] + )[1]; + // The series id + the occurrence's recurrenceId — never the whole series. + expect(request.eventId).toBe(seriesId); + expect(request.input).toEqual({ + kind: "rsvp", + responseStatus: "declined", + scope: "this", + recurrenceId, + }); + }); + + it("rejects an rsvp of needsAction as 400 INVALID_INPUT with no sync call", async () => { + const submitCommand = mock(); + spyOn(syncServiceFactory, "getSyncServiceClient").mockReturnValue({ + submitCommand, + } as never); + + const { res, json } = jsonRes(); + await eventController.rsvp( + sessionReq(objectId(), { + params: { id: objectId() }, + // A user answers, they don't un-answer: needsAction is never a + // choosable response. + body: { responseStatus: "needsAction", scope: "single" }, + }), + res, + ); + + expect((res.status as ReturnType).mock.calls[0]?.[0]).toBe( + Status.BAD_REQUEST, + ); + expect(json).toHaveBeenCalledWith( + expect.objectContaining({ code: "INVALID_INPUT", retryable: false }), + ); + expect(submitCommand).not.toHaveBeenCalled(); + }); + + it("maps a typed rsvp refusal (unsupportedCapability) to 403 UNSUPPORTED_OPERATION", async () => { + // E.g. the caller is not in the event's attendee list, or the connection + // cannot be verified — sync fails the command typed; never a retryable + // 502. + mockSyncCommandFailure("unsupportedCapability"); + + const { res, json } = jsonRes(); + await eventController.rsvp( + sessionReq(objectId(), { + params: { id: objectId() }, + body: { responseStatus: "tentative", scope: "all" }, + }), + res, + ); + + expect((res.status as ReturnType).mock.calls[0]?.[0]).toBe( + Status.FORBIDDEN, + ); + expect(json).toHaveBeenCalledWith( + expect.objectContaining({ + code: "UNSUPPORTED_OPERATION", + retryable: false, + }), + ); + }); + it("rejects delete-all when the session user does not match :userId", async () => { const deleteSpy = spyOn( (await import("@backend/event/services/event.service")).default, diff --git a/packages/backend/src/event/controllers/event.controller.ts b/packages/backend/src/event/controllers/event.controller.ts index d0ae38e0e4..9d626c9678 100644 --- a/packages/backend/src/event/controllers/event.controller.ts +++ b/packages/backend/src/event/controllers/event.controller.ts @@ -11,6 +11,8 @@ import { EventListQuerySchema, type ReplaceEventInput, ReplaceEventInputSchema, + type RsvpEventInput, + RsvpEventInputSchema, } from "@core/types/event-command.contracts"; import { type CommandSubmitRequest, @@ -28,6 +30,7 @@ import { toCreateSubmitRequest, toDeleteSubmitRequest, toReplaceSubmitRequests, + toRsvpSubmitRequest, } from "@backend/common/services/sync-service/event-command.translation"; import { syncEventInstanceToBrowser } from "@backend/common/services/sync-service/event-list.translation"; import { toSyncPrincipal } from "@backend/common/services/sync-service/sync-principal"; @@ -211,6 +214,48 @@ const readAllFromSync = async (userId: string, query: EventListQuery) => { return instances.map(syncEventInstanceToBrowser); }; +// Gate for guest-list edits (`content.attendees` present): only a writable +// Google calendar can deliver a guest list, so anything else — the Compass +// local calendar, a read-only Google calendar, an unknown id — is a typed +// refusal BEFORE any command is submitted. Reuses the same sync calendar +// lookup the read path performs (active calendars only; the local calendar is +// never in sync's list, so it fails the membership check by construction). +// +// `calendarId` is the create path's exact target. A replace carries no +// calendarId (cross-calendar moves are rejected before this), so the check +// degrades to "the principal has at least one writable Google calendar": +// the browser only offers the editor on the event's own writable Google +// calendar (WP-04) and sync's organizer guard refuses per-event misuse, so +// this coarser backstop is about local-only/read-only accounts, not routing. +const assertAttendeesSupported = async ( + client: SyncServiceClient, + userId: string, + calendarId?: string, +) => { + const result = await client.listCalendars(toSyncPrincipal(userId), { + activeOnly: true, + }); + if (!result.ok) { + throw eventMutationError( + "PROVIDER_FAILURE", + `Failed to list calendars from sync (${result.error.kind})`, + result.error, + ); + } + + const supported = result.value.calendars.some( + (calendar) => + calendar.capabilities.canWriteEvents && + (calendarId === undefined || calendar.id === calendarId), + ); + if (!supported) { + throw eventMutationError( + "ATTENDEES_UNSUPPORTED", + "Guests can only be added to events on a writable Google calendar", + ); + } +}; + const mapSyncFailure = (reason: SyncCommandFailureReason) => { switch (reason) { case "readOnlyCalendar": @@ -286,6 +331,9 @@ const submitCommandOrThrow = async ( const createFromSync = async (userId: string, input: CreateEventInput) => { const client = getSyncServiceClient(); + if (input.content.attendees !== undefined) { + await assertAttendeesSupported(client, userId, input.calendarId); + } const { request, responseEvent } = toCreateSubmitRequest(input); await submitCommandOrThrow(client, userId, request); return responseEvent; @@ -310,6 +358,9 @@ const replaceFromSync = async ( } const client = getSyncServiceClient(); + if (input.content.attendees !== undefined) { + await assertAttendeesSupported(client, userId); + } const { requests, responseEvent } = toReplaceSubmitRequests(eventId, input); for (const request of requests) { await submitCommandOrThrow(client, userId, request); @@ -317,6 +368,22 @@ const replaceFromSync = async ( return responseEvent; }; +// RSVP: answer an invitation on the addressed event (or its whole series). +// Deliberately NO assertAttendeesSupported gate: an RSVP rewrites only the +// caller's own attendee entry and is legitimate on a viewer-access (read-only) +// calendar — it is not a calendar write. Sync's own guards (self must be in +// the attendee list, connection resolvable) are the enforcement; they answer +// with a typed unsupportedCapability that maps to UNSUPPORTED_OPERATION here. +const rsvpFromSync = async ( + userId: string, + eventId: string, + input: RsvpEventInput, +) => { + const client = getSyncServiceClient(); + const request = toRsvpSubmitRequest(eventId, input); + await submitCommandOrThrow(client, userId, request); +}; + const deleteFromSync = async ( userId: string, eventId: string, @@ -369,6 +436,31 @@ class EventController { } }; + // POST /api/event/:id/rsvp — auth/billing/maintenance parity with the + // other event writes above, minus any calendar-writability gate (finish + // line 5: RSVP is allowed on viewer-access calendars). The sync command + // result carries no event content and the sync client has no event-by-id + // lookup, so the response is 204: the web is optimistic and settles the + // provider-confirmed list via SSE, the same way it discards the + // synthesized bodies of create/replace. + rsvp = async (req: SessionRequest, res: Response) => { + try { + assertCloudMutationsAllowed(); + const userId = req.session?.getUserId() as string; + await assertBillingAllowsWrites(userId); + const eventId = req.params["id"] as string; + // Strict parse: an invalid responseStatus (incl. "needsAction" — a user + // answers, they don't un-answer) or scope is a 400 INVALID_INPUT with + // no sync call. + const input = RsvpEventInputSchema.parse(req.body); + await rsvpFromSync(userId, eventId, input); + + res.status(Status.NO_CONTENT).send(); + } catch (e) { + send(res, e); + } + }; + delete = async (req: SessionRequest, res: Response) => { try { assertCloudMutationsAllowed(); @@ -376,8 +468,15 @@ class EventController { await assertBillingAllowsWrites(userId); const eventId = req.params["id"] as string; const scopeParam = req.query["scope"]; + // DELETE has no body, so the save-time invitation choice (whether + // Google emails attendees the cancellation) rides the query string. + // Absent keeps the pre-attendee default of notifying no one. + const invitationParam = req.query["invitation"]; const input = DeleteEventInputSchema.parse({ scope: typeof scopeParam === "string" ? scopeParam : "this", + ...(typeof invitationParam === "string" + ? { invitation: invitationParam } + : {}), }); await deleteFromSync(userId, eventId, input); diff --git a/packages/backend/src/event/event.error.ts b/packages/backend/src/event/event.error.ts index 2014c68ee1..9c8f155021 100644 --- a/packages/backend/src/event/event.error.ts +++ b/packages/backend/src/event/event.error.ts @@ -33,6 +33,9 @@ const STATUS_BY_CODE: Record = { // event (e.g. deleting one occurrence of a Google birthday event) — not a // provider outage, so never the retryable 502 it used to surface as. UNSUPPORTED_OPERATION: Status.FORBIDDEN, + // 403 like the capability refusals above: guests can only be written to a + // writable Google calendar, and retrying cannot change that. + ATTENDEES_UNSUPPORTED: Status.FORBIDDEN, }; const RETRYABLE_BY_CODE: Record = { @@ -51,6 +54,7 @@ const RETRYABLE_BY_CODE: Record = { INVALID_INPUT: false, BILLING_REQUIRED: false, UNSUPPORTED_OPERATION: false, + ATTENDEES_UNSUPPORTED: false, }; export class EventMutationException extends BaseError { diff --git a/packages/backend/src/event/event.routes.config.ts b/packages/backend/src/event/event.routes.config.ts index 6fd20bee1e..a4513319cb 100644 --- a/packages/backend/src/event/event.routes.config.ts +++ b/packages/backend/src/event/event.routes.config.ts @@ -32,6 +32,15 @@ export class EventRoutes extends CommonRoutesConfig { .put(eventController.replace) .delete(eventController.delete); + // RSVP to an invitation (WP-08). Same session gate as the other event + // writes; billing/maintenance parity lives in the controller, and there + // is deliberately no writable-calendar gate — answering an invitation is + // allowed on viewer-access calendars. + this.app + .route(`/api/event/:id/rsvp`) + .all(verifySession()) + .post(eventController.rsvp); + return this.app; } } diff --git a/packages/backend/src/servers/express/express.server.ts b/packages/backend/src/servers/express/express.server.ts index e2b899ac6c..c16ce0dc81 100644 --- a/packages/backend/src/servers/express/express.server.ts +++ b/packages/backend/src/servers/express/express.server.ts @@ -17,6 +17,7 @@ import { supertokensCors, } from "@backend/common/middleware/supertokens.middleware"; import { ConfigRoutes } from "@backend/config/config.routes.config"; +import { ContactsRoutes } from "@backend/contacts/contacts.routes.config"; import { EventRoutes } from "@backend/event/event.routes.config"; import { HealthRoutes } from "@backend/health/health.routes.config"; import { EventsRoutes } from "@backend/servers/sse/events-stream.routes.config"; @@ -54,6 +55,7 @@ export const initExpressServer = () => { routes.push(new EventRoutes(app)); routes.push(new EventsRoutes(app)); routes.push(new CalendarRoutes(app)); + routes.push(new ContactsRoutes(app)); app.use(supertokensErrorHandler()); // Keep this after routes diff --git a/packages/core/src/types/contact.contracts.test.ts b/packages/core/src/types/contact.contracts.test.ts new file mode 100644 index 0000000000..8e52f75e4b --- /dev/null +++ b/packages/core/src/types/contact.contracts.test.ts @@ -0,0 +1,93 @@ +import { + CONTACT_SUGGESTION_MAX_RESULTS, + CONTACT_SUGGESTION_QUERY_MAX_LENGTH, + CONTACT_SUGGESTION_QUERY_MIN_LENGTH, + ContactSuggestionSchema, + ContactSuggestionsResponseSchema, +} from "@core/types/contact.contracts"; +import { describe, expect, it } from "bun:test"; + +describe("ContactSuggestionSchema", () => { + it("accepts an email with a display name", () => { + expect( + ContactSuggestionSchema.safeParse({ + email: "alice@example.com", + displayName: "Alice Doe", + }).success, + ).toBe(true); + }); + + it("accepts a null display name (other contacts often have none)", () => { + expect( + ContactSuggestionSchema.safeParse({ + email: "alice@example.com", + displayName: null, + }).success, + ).toBe(true); + }); + + it("requires the email", () => { + expect( + ContactSuggestionSchema.safeParse({ email: "", displayName: null }) + .success, + ).toBe(false); + expect( + ContactSuggestionSchema.safeParse({ displayName: "Alice" }).success, + ).toBe(false); + }); + + it("rejects People-API fields riding along — the wire carries email + name ONLY", () => { + for (const extra of [ + { photos: [{ url: "https://example.com/a.png" }] }, + { phoneNumbers: [{ value: "555-0100" }] }, + { emailAddresses: [{ value: "alice@example.com" }] }, + { metadata: { primary: true } }, + { responseStatus: "accepted" }, + ]) { + expect( + ContactSuggestionSchema.safeParse({ + email: "alice@example.com", + displayName: null, + ...extra, + }).success, + ).toBe(false); + } + }); +}); + +describe("ContactSuggestionsResponseSchema", () => { + it("accepts an empty list (the under-min-length response)", () => { + expect( + ContactSuggestionsResponseSchema.safeParse({ suggestions: [] }).success, + ).toBe(true); + }); + + it("caps the page at the maximum", () => { + const suggestion = { email: "a@example.com", displayName: null }; + expect( + ContactSuggestionsResponseSchema.safeParse({ + suggestions: Array.from( + { length: CONTACT_SUGGESTION_MAX_RESULTS }, + () => suggestion, + ), + }).success, + ).toBe(true); + expect( + ContactSuggestionsResponseSchema.safeParse({ + suggestions: Array.from( + { length: CONTACT_SUGGESTION_MAX_RESULTS + 1 }, + () => suggestion, + ), + }).success, + ).toBe(false); + }); + + it("keeps the query bounds sane", () => { + // The min-length gate is what keeps one-character queries from hitting + // the People API at all; the max bounds a malformed request. + expect(CONTACT_SUGGESTION_QUERY_MIN_LENGTH).toBe(2); + expect(CONTACT_SUGGESTION_QUERY_MAX_LENGTH).toBeGreaterThan( + CONTACT_SUGGESTION_QUERY_MIN_LENGTH, + ); + }); +}); diff --git a/packages/core/src/types/contact.contracts.ts b/packages/core/src/types/contact.contracts.ts new file mode 100644 index 0000000000..23198fa715 --- /dev/null +++ b/packages/core/src/types/contact.contracts.ts @@ -0,0 +1,35 @@ +import { z } from "zod/v4"; + +// Contact-suggestion vocabulary for the attendee field's type-ahead. The wire +// shape deliberately carries ONLY what that field renders — an email to invite +// and a display name — never photos, phone numbers, metadata, or any other +// People API field. Everything else Google returns is dropped at the adapter +// boundary, and the strict objects here reject a People-shaped payload that +// tries to ride through. + +// Queries shorter than this return an empty result WITHOUT a provider call: +// one character matches half an address book, burns People API quota, and the +// UI cannot rank it usefully. +export const CONTACT_SUGGESTION_QUERY_MIN_LENGTH = 2; +// Generous bound for a typed prefix; anything longer is a malformed request, +// not a search. +export const CONTACT_SUGGESTION_QUERY_MAX_LENGTH = 256; +// Upper bound on a suggestion page. The People search endpoints cap their own +// pages at 30; a type-ahead needs far fewer. +export const CONTACT_SUGGESTION_MAX_RESULTS = 10; + +export const ContactSuggestionSchema = z.strictObject({ + email: z.string().trim().min(1).max(320), + displayName: z.string().trim().min(1).max(256).nullable(), +}); +export type ContactSuggestion = z.infer; + +export const ContactSuggestionsResponseSchema = z.strictObject({ + suggestions: z + .array(ContactSuggestionSchema) + .max(CONTACT_SUGGESTION_MAX_RESULTS) + .readonly(), +}); +export type ContactSuggestionsResponse = z.infer< + typeof ContactSuggestionsResponseSchema +>; diff --git a/packages/core/src/types/event-attendance.contracts.test.ts b/packages/core/src/types/event-attendance.contracts.test.ts new file mode 100644 index 0000000000..7de27f75a0 --- /dev/null +++ b/packages/core/src/types/event-attendance.contracts.test.ts @@ -0,0 +1,104 @@ +import { + AttendeeInputSchema, + RsvpResponseStatusSchema, + uniqueAttendeeEmails, +} from "@core/types/event-attendance.contracts"; + +describe("Event attendance contracts", () => { + describe("AttendeeInputSchema", () => { + const base = { email: "ada@example.com", displayName: "Ada Lovelace" }; + + it("accepts an email with a display name", () => { + expect(AttendeeInputSchema.safeParse(base).success).toBe(true); + }); + + it("accepts a null display name", () => { + const input = { ...base, displayName: null }; + + expect(AttendeeInputSchema.safeParse(input).success).toBe(true); + }); + + it("trims surrounding whitespace", () => { + const input = { email: " ada@example.com ", displayName: " Ada " }; + const parsed = AttendeeInputSchema.parse(input); + + expect(parsed).toStrictEqual({ + email: "ada@example.com", + displayName: "Ada", + }); + }); + + it("rejects an empty email", () => { + const input = { ...base, email: " " }; + + expect(AttendeeInputSchema.safeParse(input).success).toBe(false); + }); + + it("rejects an email over 320 characters", () => { + const input = { ...base, email: `${"a".repeat(320)}@example.com` }; + + expect(AttendeeInputSchema.safeParse(input).success).toBe(false); + }); + + it("rejects a responseStatus key — callers never set another person's RSVP", () => { + const input = { ...base, responseStatus: "accepted" }; + + expect(AttendeeInputSchema.safeParse(input).success).toBe(false); + }); + + it("rejects unknown keys", () => { + const input = { ...base, optional: true }; + + expect(AttendeeInputSchema.safeParse(input).success).toBe(false); + }); + }); + + describe("uniqueAttendeeEmails", () => { + it("accepts an empty list", () => { + expect(uniqueAttendeeEmails([])).toBe(true); + }); + + it("accepts distinct emails", () => { + const attendees = [ + { email: "ada@example.com" }, + { email: "grace@example.com" }, + ]; + + expect(uniqueAttendeeEmails(attendees)).toBe(true); + }); + + it("rejects exact duplicates", () => { + const attendees = [ + { email: "ada@example.com" }, + { email: "ada@example.com" }, + ]; + + expect(uniqueAttendeeEmails(attendees)).toBe(false); + }); + + it("rejects duplicates differing only by case", () => { + const attendees = [ + { email: "ada@example.com" }, + { email: "Ada@Example.com" }, + ]; + + expect(uniqueAttendeeEmails(attendees)).toBe(false); + }); + }); + + describe("RsvpResponseStatusSchema", () => { + it.each([ + "accepted", + "declined", + "tentative", + ] as const)("accepts %s", (status) => { + expect(RsvpResponseStatusSchema.safeParse(status).success).toBe(true); + }); + + it("rejects needsAction — a user answers, they don't un-answer", () => { + expect(RsvpResponseStatusSchema.safeParse("needsAction").success).toBe( + false, + ); + }); + }); +}); diff --git a/packages/core/src/types/event-attendance.contracts.ts b/packages/core/src/types/event-attendance.contracts.ts index d84dd7bb0b..2b51e8c569 100644 --- a/packages/core/src/types/event-attendance.contracts.ts +++ b/packages/core/src/types/event-attendance.contracts.ts @@ -1,9 +1,11 @@ import { z } from "zod/v4"; -// Provider-sourced, read-only event fields shared by the canonical Sync -// contract and the app-facing Event contract. Standalone (no dependency on -// either event.contracts.ts or sync/event.contracts.ts) so both can import -// it without a circular import, mirroring event-color.contracts.ts. +// Attendance vocabulary shared by the canonical Sync contract and the +// app-facing Event contract: provider-sourced read shapes plus the write-input +// shapes for editing the guest list and answering an invitation. Standalone +// (no dependency on either event.contracts.ts or sync/event.contracts.ts) so +// both can import it without a circular import, mirroring +// event-color.contracts.ts. export const OrganizerSchema = z.strictObject({ email: z.string().trim().min(1).max(320), @@ -28,6 +30,33 @@ export const AttendeeSchema = z.strictObject({ }); export type Attendee = z.infer; +// Write-input shape for one guest-list entry. Callers name attendees but never +// set another person's RSVP, so responseStatus is deliberately absent — the +// strict object rejects it. RSVP state is provider-owned and arrives via the +// read path (AttendeeSchema). +export const AttendeeInputSchema = z.strictObject({ + email: z.string().trim().min(1).max(320), + displayName: z.string().trim().min(1).max(256).nullable(), +}); +export type AttendeeInput = z.infer; + +// Array refine helper: guest membership is keyed by email and providers treat +// emails case-insensitively, so one email may appear at most once per list +// regardless of case. +export const uniqueAttendeeEmails = ( + attendees: ReadonlyArray>, +): boolean => + new Set(attendees.map(({ email }) => email.toLowerCase())).size === + attendees.length; + +// The statuses a user may set on their own attendance. `needsAction` is the +// provider's "no answer yet" state — readable but never choosable, so RSVP +// inputs exclude it. +export const RsvpResponseStatusSchema = AttendeeResponseStatusSchema.exclude([ + "needsAction", +]); +export type RsvpResponseStatus = z.infer; + export const ConferenceSchema = z.strictObject({ url: z.url(), label: z.string().trim().min(1).max(256).nullable(), diff --git a/packages/core/src/types/event-command.contracts.test.ts b/packages/core/src/types/event-command.contracts.test.ts index a569e82648..6eb4ec5eaa 100644 --- a/packages/core/src/types/event-command.contracts.test.ts +++ b/packages/core/src/types/event-command.contracts.test.ts @@ -7,6 +7,7 @@ import { EventListResponseSchema, EventMutationErrorSchema, ReplaceEventInputSchema, + RsvpEventInputSchema, } from "@core/types/event-command.contracts"; const calendarId = () => faker.database.mongodbObjectId(); @@ -70,6 +71,74 @@ describe("Event Command Contracts", () => { expect(CreateEventInputSchema.safeParse(input).success).toBe(false); }); + + it("parses a legacy payload without attendees or invitation to an identical output", () => { + const legacy = base(); + const parsed = CreateEventInputSchema.parse(legacy); + + expect(parsed).toStrictEqual(legacy); + }); + + it("accepts content attendees alongside an invitation intent", () => { + const input = { + ...base(), + content: { + ...content, + attendees: [ + { email: "ada@example.com", displayName: "Ada" }, + { email: "grace@example.com", displayName: null }, + ], + }, + invitation: "all", + }; + + expect(CreateEventInputSchema.safeParse(input).success).toBe(true); + }); + + it("accepts an empty attendee list — replace membership with none", () => { + const input = { ...base(), content: { ...content, attendees: [] } }; + + expect(CreateEventInputSchema.safeParse(input).success).toBe(true); + }); + + it("rejects duplicate attendee emails differing only by case", () => { + const input = { + ...base(), + content: { + ...content, + attendees: [ + { email: "ada@example.com", displayName: null }, + { email: "Ada@Example.com", displayName: null }, + ], + }, + }; + + expect(CreateEventInputSchema.safeParse(input).success).toBe(false); + }); + + it("rejects an attendee carrying a responseStatus", () => { + const input = { + ...base(), + content: { + ...content, + attendees: [ + { + email: "ada@example.com", + displayName: null, + responseStatus: "accepted", + }, + ], + }, + }; + + expect(CreateEventInputSchema.safeParse(input).success).toBe(false); + }); + + it("rejects an unrecognized invitation intent", () => { + const input = { ...base(), invitation: "everyone" }; + + expect(CreateEventInputSchema.safeParse(input).success).toBe(false); + }); }); describe("ReplaceEventInputSchema", () => { @@ -112,6 +181,42 @@ describe("Event Command Contracts", () => { expect(ReplaceEventInputSchema.safeParse(input).success).toBe(false); }); + + it("parses a legacy payload without attendees or invitation to an identical output", () => { + const legacy = base(); + const parsed = ReplaceEventInputSchema.parse(legacy); + + expect(parsed).toStrictEqual(legacy); + }); + + it("accepts a replace with two attendees and invitation all", () => { + const input = base({ + content: { + ...content, + attendees: [ + { email: "ada@example.com", displayName: "Ada" }, + { email: "grace@example.com", displayName: null }, + ], + }, + invitation: "all", + }); + + expect(ReplaceEventInputSchema.safeParse(input).success).toBe(true); + }); + + it("rejects duplicate attendee emails", () => { + const input = base({ + content: { + ...content, + attendees: [ + { email: "ada@example.com", displayName: null }, + { email: "ada@example.com", displayName: null }, + ], + }, + }); + + expect(ReplaceEventInputSchema.safeParse(input).success).toBe(false); + }); }); describe("DeleteEventInputSchema", () => { @@ -135,6 +240,54 @@ describe("Event Command Contracts", () => { expect(result.success).toBe(false); }); + + it("accepts a cancellation invitation intent", () => { + const input = { scope: "all", invitation: "all" }; + + expect(DeleteEventInputSchema.safeParse(input).success).toBe(true); + }); + + it("parses a legacy payload without an invitation to an identical output", () => { + const legacy = { scope: "all" }; + + expect(DeleteEventInputSchema.parse(legacy)).toStrictEqual(legacy); + }); + }); + + describe("RsvpEventInputSchema", () => { + it.each([ + ["accepted", "single"], + ["declined", "all"], + ["tentative", "single"], + ] as const)("accepts responseStatus %s with scope %s", (responseStatus, scope) => { + const input = { responseStatus, scope }; + + expect(RsvpEventInputSchema.safeParse(input).success).toBe(true); + }); + + it("rejects needsAction — un-answering is not an RSVP", () => { + const input = { responseStatus: "needsAction", scope: "single" }; + + expect(RsvpEventInputSchema.safeParse(input).success).toBe(false); + }); + + it("rejects a missing scope", () => { + const input = { responseStatus: "accepted" }; + + expect(RsvpEventInputSchema.safeParse(input).success).toBe(false); + }); + + it("rejects a recurrence-edit scope value", () => { + const input = { responseStatus: "accepted", scope: "thisAndFollowing" }; + + expect(RsvpEventInputSchema.safeParse(input).success).toBe(false); + }); + + it("rejects unknown keys", () => { + const input = { responseStatus: "accepted", scope: "single", extra: 1 }; + + expect(RsvpEventInputSchema.safeParse(input).success).toBe(false); + }); }); describe("EventListQuerySchema", () => { diff --git a/packages/core/src/types/event-command.contracts.ts b/packages/core/src/types/event-command.contracts.ts index c71b5b61bc..e31d3e77d1 100644 --- a/packages/core/src/types/event-command.contracts.ts +++ b/packages/core/src/types/event-command.contracts.ts @@ -11,8 +11,24 @@ import { EventScheduleSchema, EventSchema, } from "@core/types/event.contracts"; +import { + AttendeeInputSchema, + RsvpResponseStatusSchema, + uniqueAttendeeEmails, +} from "@core/types/event-attendance.contracts"; import { OptionalNullableEventColorSchema } from "@core/types/event-color.contracts"; +// Whether saving (or deleting) this event should have the provider email +// attendees about it. Same vocabulary as sync's InvitationIntentSchema, which +// derives from this, but undefaulted: on the browser API the field is truly +// optional, so a legacy payload parses to an identical output and the backend +// keeps deciding what "absent" means (today: none). +export const InvitationIntentValueSchema = z.enum([ + "all", + "externalOnly", + "none", +]); + const EditableContentSchema = z.strictObject({ kind: z.literal("details"), title: z.string(), @@ -24,6 +40,18 @@ const EditableContentSchema = z.strictObject({ // Null clears a previously set color on replace; omit leaves sync color // alone when the client did not touch it. color: OptionalNullableEventColorSchema, + // Guest-list edit signal: omitted means "not editing guests" (provider + // attendees flow through untouched — today's behavior); present, including + // [], means "replace membership with exactly this set". One entry per email, + // case-insensitive. Readonly like the read-side lists so a replayed + // Event["content"] stays structurally assignable to this input. + attendees: z + .array(AttendeeInputSchema) + .readonly() + .refine(uniqueAttendeeEmails, { + message: "Attendee emails must be unique", + }) + .optional(), }); export const RecurrenceScopeSchema = z.enum([ @@ -55,6 +83,10 @@ export const CreateEventInputSchema = z.strictObject({ // command instead of treating the resubmission as a no-op replay. Never // set by a normal create or by offline-promotion retries. restore: z.literal(true).optional(), + // Whether the provider should email invitations for this save. Omitted means + // the backend's default (today: none). Only meaningful when the guest set + // changed. + invitation: InvitationIntentValueSchema.optional(), }); export type CreateEventInput = z.infer; @@ -72,14 +104,30 @@ export const ReplaceEventInputSchema = z.strictObject({ // replayed edits and redo-of-edit (both hit the same colliding-key replay // problem on the update side). restore: z.literal(true).optional(), + // Same save-time invitation choice as CreateEventInputSchema.invitation. + invitation: InvitationIntentValueSchema.optional(), }); export type ReplaceEventInput = z.infer; export const DeleteEventInputSchema = z.strictObject({ scope: RecurrenceScopeSchema, + // Whether the provider should email attendees the cancellation. Omitted + // means the backend's default (today: none). + invitation: InvitationIntentValueSchema.optional(), }); export type DeleteEventInput = z.infer; +// RSVP to an event the user is invited to. The target rides on the request +// URL's event id — a composite occurrence id (`eventId::recurrenceId`) +// addresses one occurrence — and scope says whether the response covers just +// that occurrence ("single") or the whole series ("all"). For a non-recurring +// event the scope is always "single". +export const RsvpEventInputSchema = z.strictObject({ + responseStatus: RsvpResponseStatusSchema, + scope: z.enum(["single", "all"]), +}); +export type RsvpEventInput = z.infer; + export const EventListQuerySchema = z .strictObject({ kind: z.literal("range"), @@ -167,6 +215,12 @@ export const EventMutationErrorCodeSchema = z.enum([ // The request body failed contract validation (e.g. an unrecognized key on // a strict schema). Always a client-side mistake, never retryable. "INVALID_INPUT", + // The write carried `content.attendees` but the target calendar cannot + // deliver a guest list — it is not a writable Google calendar (local/ + // anonymous calendars, read-only Google calendars). Rejected before any + // sync submission; never retryable. The web hides the attendee editor in + // these cases, so this is the backstop for hand-crafted requests. + "ATTENDEES_UNSUPPORTED", // Cloud event writes require a trial or paid subscription. 403, not 401 // (SuperTokens retries 401 after refresh) and not 402 (Status has no // PAYMENT_REQUIRED). Never retryable. diff --git a/packages/core/src/types/sync/command.contracts.test.ts b/packages/core/src/types/sync/command.contracts.test.ts index e24237afb8..a40f261877 100644 --- a/packages/core/src/types/sync/command.contracts.test.ts +++ b/packages/core/src/types/sync/command.contracts.test.ts @@ -47,6 +47,13 @@ const updateInput = (scope: string = "this") => ({ const moveInput = () => ({ kind: "move", calendarId: objectId() }); +const rsvpInput = (scope: string = "this") => ({ + kind: "rsvp", + responseStatus: "accepted", + scope, + recurrenceId: recurrenceIdFor(scope), +}); + const deleteInput = (scope: string = "this") => ({ kind: "delete", scope, @@ -182,6 +189,91 @@ describe("Sync command contracts", () => { SyncCommandInputSchema.safeParse({ kind: "archive" }).success, ).toBe(false); }); + + it.each([ + "create", + "update", + ] as const)("defaults a legacy %s input without attendeesEdit to preserve", (kind) => { + const legacy = kind === "create" ? createInput() : updateInput(); + const parsed = SyncCommandInputSchema.safeParse(legacy); + expect(parsed.success && parsed.data.kind === kind).toBe(true); + if ( + parsed.success && + (parsed.data.kind === "create" || parsed.data.kind === "update") + ) { + expect(parsed.data.attendeesEdit).toBe("preserve"); + } + }); + + it("round-trips an explicit attendeesEdit replace on an update", () => { + const parsed = SyncCommandInputSchema.safeParse({ + ...updateInput(), + attendeesEdit: "replace", + }); + expect(parsed.success && parsed.data.kind === "update").toBe(true); + if (parsed.success && parsed.data.kind === "update") { + expect(parsed.data.attendeesEdit).toBe("replace"); + } + }); + + it("rejects an unrecognized attendeesEdit value", () => { + const input = { ...createInput(), attendeesEdit: "merge" }; + expect(SyncCommandInputSchema.safeParse(input).success).toBe(false); + }); + + it("rejects attendeesEdit on move and delete inputs", () => { + for (const input of [moveInput(), deleteInput()]) { + expect( + SyncCommandInputSchema.safeParse({ + ...input, + attendeesEdit: "replace", + }).success, + ).toBe(false); + } + }); + + it.each([ + "this", + "thisAndFollowing", + "all", + ] as const)("accepts an rsvp input with scope %s", (scope) => { + expect(SyncCommandInputSchema.safeParse(rsvpInput(scope)).success).toBe( + true, + ); + }); + + it.each([ + "accepted", + "declined", + "tentative", + ] as const)("accepts an rsvp answering %s", (responseStatus) => { + const input = { ...rsvpInput(), responseStatus }; + expect(SyncCommandInputSchema.safeParse(input).success).toBe(true); + }); + + it("rejects an rsvp answering needsAction", () => { + const input = { ...rsvpInput(), responseStatus: "needsAction" }; + expect(SyncCommandInputSchema.safeParse(input).success).toBe(false); + }); + + it("defaults an rsvp's recurrenceId to null when absent", () => { + const { recurrenceId: _omit, ...withoutTarget } = rsvpInput("all"); + const parsed = SyncCommandInputSchema.safeParse(withoutTarget); + expect(parsed.success && parsed.data.kind === "rsvp").toBe(true); + if (parsed.success && parsed.data.kind === "rsvp") { + expect(parsed.data.recurrenceId).toBeNull(); + } + }); + + it("rejects an rsvp input carrying content — rsvp is not an update", () => { + const input = { ...rsvpInput(), content: baseContent }; + expect(SyncCommandInputSchema.safeParse(input).success).toBe(false); + }); + + it("rejects an rsvp input carrying an invitation — rsvp never emails", () => { + const input = { ...rsvpInput(), invitation: "all" }; + expect(SyncCommandInputSchema.safeParse(input).success).toBe(false); + }); }); describe("SyncCommandOutcomeSchema", () => { @@ -327,5 +419,55 @@ describe("Sync command contracts", () => { const command = baseCommand({ input: deleteInput("this") }); expect(SyncCommandSchema.safeParse(command).success).toBe(true); }); + + it("accepts an rsvp command targeting one occurrence", () => { + const command = baseCommand({ input: rsvpInput("this") }); + expect(SyncCommandSchema.safeParse(command).success).toBe(true); + }); + + it("accepts a series-scoped rsvp command", () => { + const command = baseCommand({ input: rsvpInput("all") }); + expect(SyncCommandSchema.safeParse(command).success).toBe(true); + }); + + it("rejects a this-scope rsvp command that omits a recurrenceId", () => { + const command = baseCommand({ + input: { ...rsvpInput("this"), recurrenceId: null }, + }); + expect(SyncCommandSchema.safeParse(command).success).toBe(false); + }); + + it("rejects an all-scope rsvp command that carries a recurrenceId", () => { + const command = baseCommand({ + input: { + ...rsvpInput("all"), + recurrenceId: "2026-07-14T09:00:00-06:00", + }, + }); + expect(SyncCommandSchema.safeParse(command).success).toBe(false); + }); + + it("round-trips an update carrying attendeesEdit replace through JSON unchanged", () => { + const command = baseCommand({ + input: { ...updateInput(), attendeesEdit: "replace" }, + expectedVersion: "etag-1", + outcome: confirmedLinkedOutcome, + }); + const parsed = SyncCommandSchema.parse(command); + expect(parsed.input).toMatchObject({ attendeesEdit: "replace" }); + expect( + SyncCommandSchema.parse(JSON.parse(JSON.stringify(parsed))), + ).toEqual(parsed); + }); + + it("parses a legacy update command JSON without attendeesEdit with preserve", () => { + // Fixture mirrors a stored pre-attendees command record: no + // attendeesEdit anywhere. It must parse, and only gain the default. + const legacy = JSON.parse( + JSON.stringify(baseCommand({ input: updateInput() })), + ); + const parsed = SyncCommandSchema.parse(legacy); + expect(parsed.input).toMatchObject({ attendeesEdit: "preserve" }); + }); }); }); diff --git a/packages/core/src/types/sync/command.contracts.ts b/packages/core/src/types/sync/command.contracts.ts index 20c8319ece..4d6fda86c4 100644 --- a/packages/core/src/types/sync/command.contracts.ts +++ b/packages/core/src/types/sync/command.contracts.ts @@ -4,7 +4,9 @@ import { EditableRecurrenceSchema, EventScheduleSchema, } from "@core/types/event.contracts"; +import { RsvpResponseStatusSchema } from "@core/types/event-attendance.contracts"; import { + InvitationIntentValueSchema, RecurrenceEditSchema, RecurrenceScopeSchema, } from "@core/types/event-command.contracts"; @@ -31,11 +33,17 @@ import { // is the user's (made in the UI when they created the event), carried on the // command so Sync honors it rather than deciding. Defaults to notifying no one // — the safe choice for a create with no attendees or no provider target. -export const InvitationIntentSchema = z - .enum(["all", "externalOnly", "none"]) - .default("none"); +export const InvitationIntentSchema = + InvitationIntentValueSchema.default("none"); export type InvitationIntent = z.infer; +// Whether this write replaces the event's guest membership with the command +// content's attendee set ("replace"), or leaves whatever the provider +// currently has untouched ("preserve" — today's behavior). The default is the +// backward-compat guarantee: every stored command and legacy caller parses +// unchanged and keeps behaving exactly as before. +const AttendeesEditSchema = z.enum(["replace", "preserve"]).default("preserve"); + // create has no calendar to move within and no prior scope to preserve, so // its recurrence input reuses the existing single/series edit shape. // clientEventId carries the stable device-event identity when an anonymous @@ -46,6 +54,7 @@ const CreateCommandInputSchema = z.strictObject({ calendarId: SyncEventCalendarIdSchema, clientEventId: ClientEventIdSchema.nullable().default(null), invitation: InvitationIntentSchema, + attendeesEdit: AttendeesEditSchema, content: SyncEventContentSchema, schedule: EventScheduleSchema, recurrence: EditableRecurrenceSchema, @@ -58,6 +67,7 @@ const CreateCommandInputSchema = z.strictObject({ const UpdateCommandInputSchema = z.strictObject({ kind: z.literal("update"), invitation: InvitationIntentSchema, + attendeesEdit: AttendeesEditSchema, content: SyncEventContentSchema, schedule: EventScheduleSchema, recurrence: RecurrenceEditSchema, @@ -84,11 +94,28 @@ const DeleteCommandInputSchema = z.strictObject({ recurrenceId: DateTimeSchema.nullable().default(null), }); +// rsvp rewrites only the caller's own attendee entry (matched by the +// connection's account email), so it is its own kind rather than an overloaded +// update: it carries no content and never emails anyone (executed with +// sendUpdates "none"). `needsAction` is unrepresentable by design — a user +// answers, they don't un-answer. Targeting reuses update's scope/recurrenceId +// fields so composite occurrence ids (`eventId::recurrenceId`) resolve exactly +// the way they do for update and delete. +const RsvpCommandInputSchema = z.strictObject({ + kind: z.literal("rsvp"), + responseStatus: RsvpResponseStatusSchema, + scope: RecurrenceScopeSchema, + // Which occurrence a this/thisAndFollowing scope targets (see update above). + // Null for scope "all" and for a single event. + recurrenceId: DateTimeSchema.nullable().default(null), +}); + export const SyncCommandInputSchema = z.discriminatedUnion("kind", [ CreateCommandInputSchema, UpdateCommandInputSchema, MoveCommandInputSchema, DeleteCommandInputSchema, + RsvpCommandInputSchema, ]); export type SyncCommandInput = z.infer; @@ -96,8 +123,16 @@ export type SyncCommandInput = z.infer; // recurrenceId; scope "all" targets the whole series, so it must not. Enforced // on the request and command envelopes rather than the input union so the union // stays a clean discriminated union (a refined member can't discriminate). +// rsvp opts in: it addresses occurrences with the same scope/recurrenceId +// fields, so it obeys the same coherence rule. const recurrenceTargetIsCoherent = (input: SyncCommandInput): boolean => { - if (input.kind !== "update" && input.kind !== "delete") return true; + if ( + input.kind !== "update" && + input.kind !== "delete" && + input.kind !== "rsvp" + ) { + return true; + } return (input.scope === "all") === (input.recurrenceId === null); }; const RECURRENCE_TARGET_MESSAGE = diff --git a/packages/core/src/types/sync/connection.contracts.test.ts b/packages/core/src/types/sync/connection.contracts.test.ts index 8e563e57a5..b868c2a4bd 100644 --- a/packages/core/src/types/sync/connection.contracts.test.ts +++ b/packages/core/src/types/sync/connection.contracts.test.ts @@ -2,6 +2,8 @@ import { faker } from "@faker-js/faker"; import { CalendarAccessRoleSchema, CalendarListQuerySchema, + ConnectionBeginFeaturesSchema, + ConnectionBeginRequestSchema, ConnectionListResponseSchema, ConnectionStateSchema, GoogleConnectionAdoptionRequestSchema, @@ -168,6 +170,48 @@ describe("Sync connection contracts", () => { }); }); + describe("ConnectionBeginFeaturesSchema", () => { + it("accepts the contacts feature and an empty list", () => { + expect( + ConnectionBeginFeaturesSchema.safeParse(["contacts"]).success, + ).toBe(true); + expect(ConnectionBeginFeaturesSchema.safeParse([]).success).toBe(true); + }); + + it("rejects unknown features and non-array shapes", () => { + expect( + ConnectionBeginFeaturesSchema.safeParse(["telepathy"]).success, + ).toBe(false); + expect(ConnectionBeginFeaturesSchema.safeParse("contacts").success).toBe( + false, + ); + }); + }); + + describe("ConnectionBeginRequestSchema", () => { + it("keeps a legacy body (no features) parsing byte-identically", () => { + expect(ConnectionBeginRequestSchema.parse({})).toEqual({}); + }); + + it("accepts optional features alongside an optional connectionId", () => { + expect( + ConnectionBeginRequestSchema.parse({ features: ["contacts"] }), + ).toEqual({ features: ["contacts"] }); + const withBoth = ConnectionBeginRequestSchema.safeParse({ + connectionId: "507f1f77bcf86cd799439011", + features: ["contacts"], + }); + expect(withBoth.success).toBe(true); + }); + + it("rejects unknown features in the begin body", () => { + expect( + ConnectionBeginRequestSchema.safeParse({ features: ["telepathy"] }) + .success, + ).toBe(false); + }); + }); + describe("ProviderAccountFactsSchema", () => { it("accepts null display fields", () => { const facts = { diff --git a/packages/core/src/types/sync/connection.contracts.ts b/packages/core/src/types/sync/connection.contracts.ts index 6740801789..c4d10dc5ed 100644 --- a/packages/core/src/types/sync/connection.contracts.ts +++ b/packages/core/src/types/sync/connection.contracts.ts @@ -50,6 +50,22 @@ export const ProviderAccountFactsSchema = z.strictObject({ }); export type ProviderAccountFacts = z.infer; +// Optional feature groups a connect/reconnect consent flow may additionally +// request. Each feature maps to provider scopes the user can decline without +// affecting the connection's calendar capabilities — "contacts" asks for the +// Google contacts scopes that back attendee suggestions. Never a required +// scope: leaving one unchecked is a normal outcome, not an error. +export const ConnectionFeatureSchema = z.enum(["contacts"]); +export type ConnectionFeature = z.infer; + +export const ConnectionBeginFeaturesSchema = z + .array(ConnectionFeatureSchema) + .max(8) + .readonly(); +export type ConnectionBeginFeatures = z.infer< + typeof ConnectionBeginFeaturesSchema +>; + // Opaque authenticated-encryption envelope for credentials sent between the // Compass API and Sync services. The browser never sees this transport type. export const EncryptedCredentialEnvelopeSchema = z.strictObject({ @@ -171,10 +187,14 @@ export type ConnectionListResponse = z.infer< // Start an OAuth authorization flow for the caller's principal. An optional // connectionId means reconnect (rebind consent to that existing connection); -// omit it for a fresh connection. Principal scope always comes from the -// authenticated context, never the body. +// omit it for a fresh connection. Optional `features` widen the consent +// request with that feature's OPTIONAL scopes (e.g. "contacts" for attendee +// suggestions); absent keeps the request — and the consent URL sync mints — +// byte-identical to before features existed. Principal scope always comes +// from the authenticated context, never the body. export const ConnectionBeginRequestSchema = z.strictObject({ connectionId: ConnectionIdSchema.optional(), + features: ConnectionBeginFeaturesSchema.optional(), }); export type ConnectionBeginRequest = z.infer< typeof ConnectionBeginRequestSchema diff --git a/packages/core/src/types/sync/identity.contracts.test.ts b/packages/core/src/types/sync/identity.contracts.test.ts index 95ed35f184..a01b6ec743 100644 --- a/packages/core/src/types/sync/identity.contracts.test.ts +++ b/packages/core/src/types/sync/identity.contracts.test.ts @@ -133,6 +133,7 @@ describe("Sync identity contracts", () => { "inviteAttendees", "changeNotifications", "incrementalChanges", + "suggestContacts", ] as const)("accepts %s", (capability) => { expect(ProviderCapabilitySchema.safeParse(capability).success).toBe(true); }); diff --git a/packages/core/src/types/sync/identity.contracts.ts b/packages/core/src/types/sync/identity.contracts.ts index 0cf55390c7..044b8172b9 100644 --- a/packages/core/src/types/sync/identity.contracts.ts +++ b/packages/core/src/types/sync/identity.contracts.ts @@ -80,6 +80,11 @@ export const ProviderCapabilitySchema = z.enum([ "inviteAttendees", "changeNotifications", "incrementalChanges", + // The connection may serve attendee type-ahead suggestions from the + // provider's contacts. Derived from the OPTIONAL contacts scopes (either one + // suffices — partial grants are normal); its absence is an ordinary state, + // never an error. + "suggestContacts", ]); export type ProviderCapability = z.infer; diff --git a/packages/core/src/types/user.types.ts b/packages/core/src/types/user.types.ts index 3d67c01678..44aadd29f4 100644 --- a/packages/core/src/types/user.types.ts +++ b/packages/core/src/types/user.types.ts @@ -97,6 +97,12 @@ export type GoogleSyncConnectionSummary = { // sync state/reason. The browser renders per-account status and reconnect // from it directly, so sync's state vocabulary stays on the server. connectionState: GoogleConnectionState; + // True when this connection granted a Google contacts scope (sync's + // `suggestContacts` capability), so the attendee field can offer live + // contact suggestions. False is an ordinary state — contacts are an + // OPTIONAL grant — and gates the "enable contact suggestions" nudge, + // never an error surface. + canSuggestContacts: boolean; }; // Intersection (not extends): SuperTokens JSONObject's string index signature diff --git a/packages/sync/package.json b/packages/sync/package.json index 3ff12bc13b..f7a0491b1a 100644 --- a/packages/sync/package.json +++ b/packages/sync/package.json @@ -7,6 +7,7 @@ "dependencies": { "@compass/core": "1.0.0", "@googleapis/calendar": "^14.1.0", + "@googleapis/people": "^8.0.0", "express": "^4.17.1", "express-rate-limit": "^7.5.0", "google-auth-library": "^10.6.2", diff --git a/packages/sync/src/app.ts b/packages/sync/src/app.ts index fc50e70c68..67dc87b8ec 100644 --- a/packages/sync/src/app.ts +++ b/packages/sync/src/app.ts @@ -39,7 +39,9 @@ import { GoogleCalendarAdapter } from "@sync/providers/google/google-calendar.ad import { GoogleEventReaderAdapter } from "@sync/providers/google/google-event-reader.adapter"; import { GoogleEventWriter } from "@sync/providers/google/google-event-writer.adapter"; import { GoogleNotificationAdapter } from "@sync/providers/google/google-notifications.adapter"; +import { GooglePeopleAdapter } from "@sync/providers/google/google-people.adapter"; import { type ProviderAuthAdapter } from "@sync/providers/provider-auth.port"; +import { type ContactsPort } from "@sync/providers/provider-contacts.port"; import { type ProviderEventWriter } from "@sync/providers/provider-event-writer.port"; import { redactedCause } from "@sync/safety/redact-error"; import { NOTIFICATIONS_PATH } from "@sync/server/notification.routes"; @@ -88,6 +90,9 @@ export function createSyncService( // Override the provider event writer (tests inject a fake); production // builds it from config. writer?: ProviderEventWriter; + // Override the provider contacts port (tests inject a fake); production + // builds it from config. + contacts?: ContactsPort; } = {}, ): SyncService { const identity = buildServiceIdentity({ @@ -127,6 +132,9 @@ export function createSyncService( // The event writer is likewise db-free and gated on provider config; // the command routes use it for provider-targeted creates. writer: deps.writer ?? buildEventWriter(config), + // The contacts port is likewise db-free and gated on provider config; + // the contacts routes use it for attendee suggestions. + contacts: deps.contacts ?? buildContactsPort(config), // The OAuth CSRF state is signed with a key derived from the service // secret (domain-separated from internal-auth signing); the callback // resolves against the public base URL. @@ -181,6 +189,16 @@ function buildEventWriter(config: SyncConfig): ProviderEventWriter | undefined { return new GoogleEventWriter(); } +// Build the provider contacts port when the provider is configured. Gated on +// the same credentials as the auth adapter: a passive/unconfigured deployment +// returns undefined and the suggestions route refuses. +function buildContactsPort(config: SyncConfig): ContactsPort | undefined { + if (!config.GOOGLE_CLIENT_ID || !config.GOOGLE_CLIENT_SECRET) { + return undefined; + } + return new GooglePeopleAdapter(); +} + function closeHttpServer(httpServer: Server): Promise { if (!httpServer.listening) return Promise.resolve(); return new Promise((resolve, reject) => @@ -719,6 +737,7 @@ function buildSchedulers( calendars: repos.calendars, occurrences: repos.eventOccurrences, resources: repos.syncResources, + connections: repos.connections, markers: repos.deletionMarkers, execution: config.EXECUTION, provider: { diff --git a/packages/sync/src/domain/cloud-command.service.db.test.ts b/packages/sync/src/domain/cloud-command.service.db.test.ts index 17555ab919..6b8fabc5b3 100644 --- a/packages/sync/src/domain/cloud-command.service.db.test.ts +++ b/packages/sync/src/domain/cloud-command.service.db.test.ts @@ -20,6 +20,7 @@ import { type ProviderPatchInput, type ProviderWriteResult, } from "@sync/providers/provider-event-writer.port"; +import { findSafetyCanaryHit } from "@sync/safety/safety-canary"; import { SYNC_COLLECTIONS } from "@sync/storage/collections"; import { type CommandSubmit } from "@sync/storage/contracts/command.contracts"; import { type EventRecord } from "@sync/storage/contracts/event.contracts"; @@ -645,6 +646,235 @@ describe("submitCloudCommand provider dispatch", () => { ).toBe(true); }); + // --- rsvp routing (WP-07) -------------------------------------------------- + + const rsvpFor = ( + tenantId: TenantId, + principalId: PrincipalId, + eventId: EventId, + scope = "all", + recurrenceId: string | null = null, + ): CommandSubmit => ({ + tenantId, + principalId, + idempotencyKey: `idem-${objectId()}` as IdempotencyKey, + eventId, + input: { + kind: "rsvp", + responseStatus: "declined", + scope, + recurrenceId, + } as unknown as SyncCommandInput, + expectedVersion: null, + }); + + const selfConnection = { + findById: async () => ({ account: { email: "self@example.com" } }), + }; + + it("routes a provider-linked rsvp to the provider executor when active", async () => { + const tenantId = objectId() as TenantId; + const principalId = objectId() as PrincipalId; + const calendar = await seedProviderCalendar(tenantId, principalId); + const eventId = objectId() as EventId; + await seedEvent(tenantId, principalId, eventId, { + calendarId: calendar._id, + connectionId: calendar.connectionId as never, + providerEventId: "g-evt-1" as never, + providerVersion: "etag-1" as never, + deliveryState: "confirmed", + content: { + title: "Existing", + description: "", + location: null, + organizer: { email: "organizer@example.com", displayName: null }, + attendees: [ + { + email: "self@example.com", + displayName: null, + responseStatus: "accepted", + }, + ], + conference: null, + }, + }); + const writer = new FakeWriter(); + writer.fetched = { + ...(writer.fetched as ProviderEvent), + content: { + ...(writer.fetched as ProviderEvent).content, + attendees: [ + { + email: "self@example.com", + displayName: null, + responseStatus: "accepted", + }, + ], + }, + }; + + const { command } = await submitCloudCommand( + { + commands, + events, + calendars, + occurrences, + resources, + connections: selfConnection, + markers, + execution: "active", + provider: provider(writer), + }, + rsvpFor(tenantId, principalId, eventId), + now, + ); + + expect(command.outcome.state).toBe("confirmed"); + expect(writer.patchCalls).toHaveLength(1); + expect(writer.patchCalls[0].attendees).toEqual([ + { + email: "self@example.com", + displayName: null, + responseStatus: "declined", + }, + ]); + const stored = await events.findById(tenantId, principalId, eventId); + expect(stored?.content.attendees).toEqual([ + { + email: "self@example.com", + displayName: null, + responseStatus: "declined", + }, + ]); + }); + + it("fails an rsvp against a cloud-only event typed, with no provider call", async () => { + // Cloud-only events with attendees are not expected in v1 — there is no + // provider invitation to answer and Compass sends no email — so the + // command fails typed (unsupportedCapability) instead of being stranded + // pending or guessed at locally. + const tenantId = objectId() as TenantId; + const principalId = objectId() as PrincipalId; + const eventId = objectId() as EventId; + await seedEvent(tenantId, principalId, eventId, { + content: { + title: "Existing", + description: "", + location: null, + organizer: null, + attendees: [ + { + email: "self@example.com", + displayName: null, + responseStatus: "needsAction", + }, + ], + conference: null, + }, + }); + const writer = new FakeWriter(); + + const { command } = await submitCloudCommand( + { + commands, + events, + calendars, + occurrences, + resources, + connections: selfConnection, + markers, + execution: "active", + provider: provider(writer), + }, + rsvpFor(tenantId, principalId, eventId), + now, + ); + + expect(command.outcome).toEqual({ + state: "failed", + failureReason: "unsupportedCapability", + }); + expect(writer.patchCalls).toHaveLength(0); + // No attendee JSON on the failure surface the route logs from. + expect(findSafetyCanaryHit(command.outcome)).toBeNull(); + }); + + it("fails an rsvp with scope thisAndFollowing typed", async () => { + // An RSVP answers "this event" or "all events"; from-here-on has no v1 + // semantics and the browser cannot send it. + const tenantId = objectId() as TenantId; + const principalId = objectId() as PrincipalId; + const calendar = await seedProviderCalendar(tenantId, principalId); + const eventId = objectId() as EventId; + await seedEvent(tenantId, principalId, eventId, { + calendarId: calendar._id, + connectionId: calendar.connectionId as never, + providerEventId: "g-series-1" as never, + providerVersion: "etag-1" as never, + deliveryState: "confirmed", + recurrence: { kind: "seriesMaster", rules: ["RRULE:FREQ=WEEKLY"] }, + }); + const writer = new FakeWriter(); + + const { command } = await submitCloudCommand( + { + commands, + events, + calendars, + occurrences, + resources, + connections: selfConnection, + markers, + execution: "active", + provider: provider(writer), + }, + rsvpFor( + tenantId, + principalId, + eventId, + "thisAndFollowing", + "2026-07-21T15:00:00.000Z", + ), + now, + ); + + expect(command.outcome).toEqual({ + state: "failed", + failureReason: "unsupportedCapability", + }); + expect(writer.patchCalls).toHaveLength(0); + }); + + it("fails an rsvp against a missing event as a version conflict", async () => { + // The target vanished since the browser last saw it — an honest + // conflict, mirroring the update path. + const tenantId = objectId() as TenantId; + const principalId = objectId() as PrincipalId; + const writer = new FakeWriter(); + + const { command } = await submitCloudCommand( + { + commands, + events, + calendars, + occurrences, + resources, + connections: selfConnection, + markers, + execution: "active", + provider: provider(writer), + }, + rsvpFor(tenantId, principalId, objectId() as EventId), + now, + ); + + expect(command.outcome).toEqual({ + state: "failed", + failureReason: "versionConflict", + }); + expect(writer.patchCalls).toHaveLength(0); + }); + const updateSeriesFor = ( tenantId: TenantId, principalId: PrincipalId, @@ -1906,3 +2136,247 @@ describe("submitCloudCommand provider dispatch", () => { }); }); }); + +// WP-02: attendeesEdit "replace" on cloud-only records — stored membership +// merges against the stored list (no provider copy exists), creates normalize +// every guest to needsAction, and per-occurrence/split replaces are refused +// typed. "preserve"/legacy stays byte-identical (covered by every pre-existing +// test in this file, none of which set attendeesEdit). +describe("cloud-only attendeesEdit replace", () => { + let mongo: SyncMongoService; + let commands: CommandRepository; + let events: EventRepository; + let occurrences: EventOccurrenceRepository; + let resources: SyncResourceRepository; + let calendars: ProviderCalendarRepository; + let markers: DeletionMarkerRepository; + + const now = () => new Date("2026-07-10T00:00:00.000Z"); + + beforeEach(() => { + mongo = storage.mongo(); + commands = new CommandRepository(mongo.db); + events = new EventRepository(mongo.db); + occurrences = new EventOccurrenceRepository(mongo.db, mongo.client); + resources = new SyncResourceRepository(mongo.db); + calendars = new ProviderCalendarRepository(mongo.db); + markers = new DeletionMarkerRepository(mongo.db); + }); + + const deps = () => ({ + commands, + events, + calendars, + occurrences, + resources, + markers, + execution: "active" as const, + }); + + const attendee = ( + email: string, + responseStatus = "needsAction", + displayName: string | null = null, + ) => ({ email, displayName, responseStatus }); + + const contentWith = (title: string, attendees: unknown[] = []) => ({ + title, + description: "", + location: null, + organizer: null, + attendees, + conference: null, + }); + + const schedule = { + kind: "timed", + start: "2026-07-14T09:00:00-06:00", + end: "2026-07-14T10:00:00-06:00", + timeZone: "America/Denver", + }; + + const seedCloudEvent = ( + tenantId: TenantId, + principalId: PrincipalId, + eventId: EventId, + overrides: Partial = {}, + ) => + events.put({ + _id: eventId, + tenantId, + principalId, + origin: "compass", + calendarId: objectId(), + clientEventId: null, + connectionId: null, + providerEventId: null, + providerVersion: null, + providerUpdatedAt: null, + deliveryState: null, + providerMetadata: null, + content: contentWith("Existing", [ + attendee("kept@example.com", "accepted", "Kept"), + attendee("dropped@example.com", "tentative"), + ]), + schedule, + recurrence: { kind: "single" }, + lifecycleState: "active", + generation: 0, + createdAt: now(), + updatedAt: now(), + confirmedAt: now(), + ...overrides, + } as EventRecord); + + const updateFor = ( + tenantId: TenantId, + principalId: PrincipalId, + eventId: EventId, + opts: { + attendees: unknown[]; + scope?: string; + recurrenceId?: string | null; + }, + ): CommandSubmit => ({ + tenantId, + principalId, + idempotencyKey: `idem-${objectId()}` as IdempotencyKey, + eventId, + input: { + kind: "update", + invitation: "none", + attendeesEdit: "replace", + content: contentWith("Existing", opts.attendees), + schedule, + recurrence: { kind: "preserve" }, + scope: opts.scope ?? "all", + recurrenceId: opts.recurrenceId ?? null, + } as unknown as SyncCommandInput, + expectedVersion: null, + }); + + it("stores the merged membership on a cloud-only update", async () => { + const tenantId = objectId() as TenantId; + const principalId = objectId() as PrincipalId; + const eventId = objectId() as EventId; + await seedCloudEvent(tenantId, principalId, eventId); + + const { command } = await submitCloudCommand( + deps(), + updateFor(tenantId, principalId, eventId, { + attendees: [ + attendee("kept@example.com"), + attendee("new@example.com", "needsAction", "New"), + ], + }), + now, + ); + + expect(command.outcome.state).toBe("confirmed"); + const stored = await events.findById(tenantId, principalId, eventId); + // Retained guests keep their stored status/name, new guests enter as + // needsAction, dropped guests are removed. + expect(stored?.content.attendees).toEqual([ + attendee("kept@example.com", "accepted", "Kept"), + attendee("new@example.com", "needsAction", "New"), + ] as never); + }); + + it("normalizes every guest to needsAction on a cloud-only create", async () => { + const tenantId = objectId() as TenantId; + const principalId = objectId() as PrincipalId; + const eventId = objectId() as EventId; + + const { command } = await submitCloudCommand( + deps(), + { + tenantId, + principalId, + idempotencyKey: `idem-${objectId()}` as IdempotencyKey, + eventId, + input: { + kind: "create", + calendarId: objectId(), + invitation: "none", + attendeesEdit: "replace", + content: contentWith("Kickoff", [ + attendee("a@example.com", "accepted", "Aye"), + attendee("b@example.com"), + ]), + schedule, + recurrence: { kind: "single" }, + } as unknown as SyncCommandInput, + expectedVersion: null, + }, + now, + ); + + expect(command.outcome.state).toBe("confirmed"); + const stored = await events.findById(tenantId, principalId, eventId); + expect(stored?.content.attendees).toEqual([ + attendee("a@example.com", "needsAction", "Aye"), + attendee("b@example.com", "needsAction"), + ] as never); + }); + + it("refuses a replace on a cloud occurrence edit (this scope), with a content-free failure", async () => { + const tenantId = objectId() as TenantId; + const principalId = objectId() as PrincipalId; + const eventId = objectId() as EventId; + await seedCloudEvent(tenantId, principalId, eventId, { + recurrence: { kind: "seriesMaster", rules: ["RRULE:FREQ=WEEKLY"] }, + }); + + const { command } = await submitCloudCommand( + deps(), + updateFor(tenantId, principalId, eventId, { + attendees: [attendee("a@example.com")], + scope: "this", + recurrenceId: "2026-07-21T15:00:00.000Z", + }), + now, + ); + + expect(command.outcome).toEqual({ + state: "failed", + failureReason: "unsupportedCapability", + }); + // No exception override was created for the refused edit. + expect( + await events.findSeriesExceptions(tenantId, principalId, eventId), + ).toHaveLength(0); + // The failure surface (what the command route logs and the SSE notices + // derive from) carries no attendee JSON or event content. + expect(findSafetyCanaryHit(command.outcome)).toBeNull(); + }); + + it("refuses a replace on a cloud thisAndFollowing split", async () => { + const tenantId = objectId() as TenantId; + const principalId = objectId() as PrincipalId; + const eventId = objectId() as EventId; + await seedCloudEvent(tenantId, principalId, eventId, { + recurrence: { kind: "seriesMaster", rules: ["RRULE:FREQ=WEEKLY"] }, + }); + + const { command } = await submitCloudCommand( + deps(), + updateFor(tenantId, principalId, eventId, { + attendees: [attendee("a@example.com")], + scope: "thisAndFollowing", + recurrenceId: "2026-07-21T15:00:00.000Z", + }), + now, + ); + + expect(command.outcome).toEqual({ + state: "failed", + failureReason: "unsupportedCapability", + }); + // The master's rules were not truncated by the refused split. + const stored = await events.findById(tenantId, principalId, eventId); + expect(stored?.recurrence).toEqual({ + kind: "seriesMaster", + rules: ["RRULE:FREQ=WEEKLY"], + } as never); + }); +}); diff --git a/packages/sync/src/domain/cloud-command.service.ts b/packages/sync/src/domain/cloud-command.service.ts index 71abe3938b..54de0e861e 100644 --- a/packages/sync/src/domain/cloud-command.service.ts +++ b/packages/sync/src/domain/cloud-command.service.ts @@ -6,6 +6,7 @@ import { type SyncExecutionMode } from "@sync/config/sync.config"; import { type CredentialCustody } from "@sync/credentials/credential-custody.service"; import { terminalReplayIsStale } from "@sync/domain/command-replay"; import { + mergeAttendees, mergeUpdateContent, omitNullColor, } from "@sync/domain/merge-update-content"; @@ -20,10 +21,12 @@ import { executeProviderDelete, executeProviderOccurrenceDelete, executeProviderOccurrenceUpdate, + executeProviderRsvp, executeProviderSeriesFollowingDelete, executeProviderSeriesFollowingUpdate, executeProviderSeriesUpdate, executeProviderUpdate, + type ProviderConnectionLookup, type ProviderDeleteDeps, } from "@sync/domain/provider-command.service"; import { reprojectOccurrences } from "@sync/domain/reproject"; @@ -75,6 +78,10 @@ export interface CloudCommandDeps { // Which generation reads serve per calendar, so a provider-linked create // projects where reads will look for it. resources: SyncResourceRepository; + // Connection facts for the provider executors' attendee organizer guard — + // a guest-list replace is only valid for the connection account that + // organizes the event. + connections: ProviderConnectionLookup; // The deletion-marker store, for the tombstone a provider delete leaves. markers: DeletionMarkerRepository; execution: SyncExecutionMode; @@ -152,8 +159,12 @@ export async function submitCloudCommand( changed: inserted || final.outcome.state !== initialOutcomeState, }); - // update/delete apply to an existing event; move is not handled yet. - if (command.input.kind === "update" || command.input.kind === "delete") { + // update/delete/rsvp apply to an existing event; move is not handled yet. + if ( + command.input.kind === "update" || + command.input.kind === "delete" || + command.input.kind === "rsvp" + ) { return finish(await applyCloudMutation(deps, command, now)); } // A "move" command has no executor anywhere (no local apply, no provider @@ -176,8 +187,8 @@ export async function submitCloudCommand( return finish(await applyCloudCreateOrProvider(deps, command, now)); } -// Re-run an already-submitted, still-nonterminal create/update/delete command -// through the same routing the original request used (dedupe by +// Re-run an already-submitted, still-nonterminal create/update/delete/rsvp +// command through the same routing the original request used (dedupe by // idempotencyKey does not apply here — the command already exists). For the // stale-command retry sweep: a transient provider failure mid-execute leaves // the command exactly as it was (see provider-command.service.ts's per-kind @@ -234,6 +245,7 @@ async function applyCloudCreateOrProvider( events: deps.events, occurrences: deps.occurrences, resources: deps.resources, + connections: deps.connections, writer: deps.provider.writer, custody: deps.provider.custody, }, @@ -272,11 +284,12 @@ async function applyCloudCreate( return confirmCloud(deps, command); } -// Apply a cloud-only update or delete to an existing event. Only single, -// unlinked events are handled here: a provider-linked event needs the provider -// mutation path, and a recurring series needs scope handling — both land in -// later slices, so those commands are left pending. Delete is idempotent (an -// already-absent event confirms), so a retry after a crash converges. +// Apply a cloud-only update, delete, or rsvp to an existing event. Only +// single, unlinked events are handled here: a provider-linked event needs the +// provider mutation path, and a recurring series needs scope handling — both +// land in later slices, so those commands are left pending. Delete is +// idempotent (an already-absent event confirms), so a retry after a crash +// converges. async function applyCloudMutation( deps: CloudCommandDeps, command: CommandRecord, @@ -288,6 +301,43 @@ async function applyCloudMutation( command.eventId, ); + // rsvp: rewrite the caller's own attendee entry at the provider. Only a + // provider-linked event has a real invitation to answer in v1 — a + // cloud-only (unlinked/local) event's attendee list has no provider + // counterpart and Compass sends no email — so anything else is refused + // typed (unsupportedCapability), never left pending and never guessed at. + if (command.input.kind === "rsvp") { + // The target vanished since the browser last saw it — an honest + // conflict, mirroring the update path below. + if (!existing) return failCloud(deps, command, "versionConflict"); + if (existing.connectionId === null) { + return failCloud(deps, command, "unsupportedCapability"); + } + // A bare exception addressed directly by its own id: unreachable from + // the browser (see the matching update/delete comments below), but not + // a coherent state to strand pending either. + if ( + existing.recurrence.kind !== "single" && + existing.recurrence.kind !== "seriesMaster" + ) { + return failCloud(deps, command, "permanentProviderError"); + } + // An RSVP answers "this event" (scope "this") or the whole series + // (scope "all"); "from here on" has no v1 semantics — the browser + // cannot send it — so a command carrying it is refused typed rather + // than silently widened to one of the real scopes. + if (command.input.scope === "thisAndFollowing") { + return failCloud(deps, command, "unsupportedCapability"); + } + return dispatchProviderMutation( + deps, + command, + existing, + now, + executeProviderRsvp, + ); + } + if (command.input.kind === "delete") { // Absence is the desired end state, so a delete of an already-gone (or // never-created) event is confirmed rather than left hanging. @@ -481,6 +531,7 @@ async function dispatchProviderMutation( events: deps.events, occurrences: deps.occurrences, resources: deps.resources, + connections: deps.connections, writer: deps.provider.writer, custody: deps.provider.custody, markers: deps.markers, @@ -613,6 +664,12 @@ async function updateCloudOccurrence( if (command.input.kind !== "update" || command.input.recurrenceId === null) { return command; } + // Guest-list editing is whole-event/whole-series only in v1, matching the + // provider executors: refuse a per-occurrence replace typed rather than + // silently dropping the intent. + if (command.input.attendeesEdit === "replace") { + return failCloud(deps, command, "unsupportedCapability"); + } const exception = await deps.events.upsertException( master, command.input.recurrenceId, @@ -696,6 +753,13 @@ async function updateCloudSeriesFollowing( return updateCloudSeries(deps, command, master, now); } + // Same v1 rule as the provider split path: a guest-list replace has no + // defined semantics on a thisAndFollowing split, so refuse typed rather + // than silently preserving. + if (command.input.attendeesEdit === "replace") { + return failCloud(deps, command, "unsupportedCapability"); + } + await deleteFollowingExceptions(deps, command, master._id, splitAt); const truncated: EventRecord = { ...master, @@ -805,9 +869,25 @@ function applyCloudUpdate( throw new Error("applyCloudUpdate requires an update command"); } const { input } = command; + const merged = mergeUpdateContent(existing.content, input.content); + // A cloud-only event has no provider copy to fetch, so an attendeesEdit + // "replace" merges the intended membership against the STORED list — the + // closest "current" state — keeping any recorded status for retained + // guests and entering new ones as needsAction. "preserve"/legacy commands + // keep today's byte-identical merge (attendees untouched). + const content = + input.attendeesEdit === "replace" + ? { + ...merged, + attendees: mergeAttendees( + input.content.attendees, + existing.content.attendees, + ), + } + : merged; return { ...existing, - content: mergeUpdateContent(existing.content, input.content), + content, schedule: input.schedule, recurrence: input.recurrence.kind === "preserve" @@ -828,6 +908,17 @@ function buildCloudEventRecord(command: CommandRecord, now: Date): EventRecord { throw new Error("buildCloudEventRecord requires a create command"); } const { input } = command; + // A create with intended guests stores them normalized through the same + // merge the provider path uses — against an empty list, since nothing + // exists yet — so every guest enters as needsAction rather than trusting + // the command's own responseStatus values. + const content = + input.attendeesEdit === "replace" + ? { + ...input.content, + attendees: mergeAttendees(input.content.attendees, []), + } + : input.content; return { _id: command.eventId, tenantId: command.tenantId, @@ -843,7 +934,7 @@ function buildCloudEventRecord(command: CommandRecord, now: Date): EventRecord { providerUpdatedAt: null, deliveryState: null, providerMetadata: null, - content: omitNullColor(input.content), + content: omitNullColor(content), schedule: input.schedule, recurrence: toStoredRecurrence(input.recurrence), lifecycleState: "active", diff --git a/packages/sync/src/domain/merge-update-content.test.ts b/packages/sync/src/domain/merge-update-content.test.ts index db2085832b..80dcd8ace2 100644 --- a/packages/sync/src/domain/merge-update-content.test.ts +++ b/packages/sync/src/domain/merge-update-content.test.ts @@ -1,4 +1,9 @@ -import { mergeUpdateContent, omitNullColor } from "./merge-update-content"; +import { type Attendee } from "@core/types/event-attendance.contracts"; +import { + mergeAttendees, + mergeUpdateContent, + omitNullColor, +} from "./merge-update-content"; import { describe, expect, it } from "bun:test"; describe("mergeUpdateContent", () => { @@ -234,6 +239,136 @@ describe("mergeUpdateContent", () => { }); }); +describe("mergeAttendees", () => { + const attendee = ( + email: string, + responseStatus: Attendee["responseStatus"] = "needsAction", + displayName: string | null = null, + ): Attendee => ({ email, displayName, responseStatus }); + + // Exhaustive table over the merge rules: retained entries keep the + // provider's status/displayName in provider order, new entries append as + // needsAction in intent order, dropped emails are removed, and email + // matching is case-insensitive. + const cases: Array<{ + name: string; + intended: Array>; + provider: Attendee[]; + expected: Attendee[]; + }> = [ + { + name: "keeps a retained guest's provider responseStatus and displayName", + intended: [{ email: "a@x.com", displayName: "Renamed" }], + provider: [attendee("a@x.com", "accepted", "Provider Name")], + expected: [attendee("a@x.com", "accepted", "Provider Name")], + }, + { + name: "adds a new guest as needsAction", + intended: [ + { email: "a@x.com", displayName: null }, + { email: "b@x.com", displayName: "B" }, + ], + provider: [attendee("a@x.com", "declined")], + expected: [ + attendee("a@x.com", "declined"), + attendee("b@x.com", "needsAction", "B"), + ], + }, + { + name: "removes a dropped guest", + intended: [{ email: "a@x.com", displayName: null }], + provider: [ + attendee("a@x.com", "accepted"), + attendee("b@x.com", "tentative"), + ], + expected: [attendee("a@x.com", "accepted")], + }, + { + name: "matches emails case-insensitively in both directions", + intended: [ + { email: "Alice@X.com", displayName: null }, + { email: "bob@x.com", displayName: null }, + ], + provider: [ + attendee("alice@x.com", "accepted"), + attendee("BOB@x.com", "declined"), + ], + expected: [ + attendee("alice@x.com", "accepted"), + attendee("BOB@x.com", "declined"), + ], + }, + { + name: "preserves provider order for retained guests and appends new ones in intent order", + intended: [ + { email: "new1@x.com", displayName: null }, + { email: "c@x.com", displayName: null }, + { email: "a@x.com", displayName: null }, + { email: "new2@x.com", displayName: null }, + ], + provider: [ + attendee("a@x.com", "accepted"), + attendee("b@x.com", "tentative"), + attendee("c@x.com", "declined"), + ], + expected: [ + attendee("a@x.com", "accepted"), + attendee("c@x.com", "declined"), + attendee("new1@x.com"), + attendee("new2@x.com"), + ], + }, + { + name: "an empty intent removes everyone", + intended: [], + provider: [attendee("a@x.com", "accepted")], + expected: [], + }, + { + name: "merging into an empty provider list makes every guest needsAction", + intended: [ + { email: "a@x.com", displayName: "A" }, + { email: "b@x.com", displayName: null }, + ], + provider: [], + expected: [attendee("a@x.com", "needsAction", "A"), attendee("b@x.com")], + }, + { + name: "an unchanged membership returns the provider list verbatim", + intended: [ + { email: "b@x.com", displayName: null }, + { email: "a@x.com", displayName: null }, + ], + provider: [ + attendee("a@x.com", "accepted", "A"), + attendee("b@x.com", "declined"), + ], + expected: [ + attendee("a@x.com", "accepted", "A"), + attendee("b@x.com", "declined"), + ], + }, + { + name: "both lists empty stays empty", + intended: [], + provider: [], + expected: [], + }, + ]; + + it.each(cases)("$name", ({ intended, provider, expected }) => { + expect(mergeAttendees(intended, provider)).toEqual(expected); + }); + + it("does not mutate its inputs", () => { + const intended = [{ email: "a@x.com", displayName: null }]; + const provider = [attendee("b@x.com", "accepted")]; + mergeAttendees(intended, provider); + expect(intended).toEqual([{ email: "a@x.com", displayName: null }]); + expect(provider).toEqual([attendee("b@x.com", "accepted")]); + }); +}); + describe("omitNullColor", () => { it("drops null color before persist", () => { expect( diff --git a/packages/sync/src/domain/merge-update-content.ts b/packages/sync/src/domain/merge-update-content.ts index 2d19fed9b3..a3e6b8d2f8 100644 --- a/packages/sync/src/domain/merge-update-content.ts +++ b/packages/sync/src/domain/merge-update-content.ts @@ -1,3 +1,4 @@ +import { type Attendee } from "@core/types/event-attendance.contracts"; import { type SyncEventContent } from "@core/types/sync/event.contracts"; // Browser edits only title + description + location (+ optional color). An @@ -45,6 +46,43 @@ export function mergeUpdateContent( return kept; } +// Merge an intended guest membership (an attendeesEdit "replace" command's +// attendee set) against the attendee list the provider currently holds. +// Membership is keyed by email, case-insensitively — providers treat addresses +// that differ only in case as the same guest: +// - retained emails keep the provider's entry verbatim: responseStatus and +// displayName are provider-owned facts a Compass guest edit must not +// reset, so a concurrent RSVP between syncs survives; +// - new emails enter as needsAction — a caller never sets another person's +// RSVP; +// - emails absent from the intent are removed. +// Provider order is preserved for retained entries and new entries append in +// intent order. Pure on purpose: a Google patch replaces the WHOLE attendees +// array, so a merge bug silently uninvites people — this must stay +// table-testable in isolation. +export function mergeAttendees( + intended: ReadonlyArray>, + providerCurrent: readonly Attendee[], +): readonly Attendee[] { + const intendedEmails = new Set( + intended.map(({ email }) => email.toLowerCase()), + ); + const currentEmails = new Set( + providerCurrent.map(({ email }) => email.toLowerCase()), + ); + const retained = providerCurrent.filter(({ email }) => + intendedEmails.has(email.toLowerCase()), + ); + const added = intended + .filter(({ email }) => !currentEmails.has(email.toLowerCase())) + .map(({ email, displayName }) => ({ + email, + displayName, + responseStatus: "needsAction" as const, + })); + return [...retained, ...added]; +} + // Null is a write-command "clear" signal. Stored/read rows omit the field; // persisting null fails SyncEventInstance validation on list. export function omitNullColor(content: SyncEventContent): SyncEventContent { diff --git a/packages/sync/src/domain/provider-command.service.db.test.ts b/packages/sync/src/domain/provider-command.service.db.test.ts index 0b9752dc57..f88fe5e47f 100644 --- a/packages/sync/src/domain/provider-command.service.db.test.ts +++ b/packages/sync/src/domain/provider-command.service.db.test.ts @@ -1,4 +1,5 @@ import { faker } from "@faker-js/faker"; +import { type Attendee } from "@core/types/event-attendance.contracts"; import { type RecurrenceEdit } from "@core/types/event-command.contracts"; import { type SyncCommandInput } from "@core/types/sync/command.contracts"; import { @@ -16,10 +17,12 @@ import { executeProviderDelete, executeProviderOccurrenceDelete, executeProviderOccurrenceUpdate, + executeProviderRsvp, executeProviderSeriesFollowingDelete, executeProviderSeriesFollowingUpdate, executeProviderSeriesUpdate, executeProviderUpdate, + type ProviderConnectionLookup, } from "@sync/domain/provider-command.service"; import { type AccessTokenSource } from "@sync/domain/provider-write-ladder"; import { reprojectOccurrences } from "@sync/domain/reproject"; @@ -39,6 +42,7 @@ import { ProviderWriteError, type ProviderWriteResult, } from "@sync/providers/provider-event-writer.port"; +import { findSafetyCanaryHit } from "@sync/safety/safety-canary"; import { SYNC_COLLECTIONS } from "@sync/storage/collections"; import { type EventRecord } from "@sync/storage/contracts/event.contracts"; import { type ProviderCalendarRecord } from "@sync/storage/contracts/provider-calendar.contracts"; @@ -2901,3 +2905,1401 @@ describe("provider-linked recurring scopes (this / thisAndFollowing)", () => { }); }); }); + +// WP-02: attendeesEdit "replace" — merge-by-email against freshly fetched +// provider state, organizer guard, replay by email set, and byte-identical +// "preserve"/legacy behavior. +describe("attendeesEdit replace", () => { + let mongo: SyncMongoService; + let commands: CommandRepository; + let events: EventRepository; + let occurrences: EventOccurrenceRepository; + let resources: SyncResourceRepository; + let calendars: ProviderCalendarRepository; + + const now = () => new Date("2026-07-10T00:00:00.000Z"); + const OWNER = "owner@example.com"; + + const schedule = { + kind: "timed" as const, + start: "2026-07-14T09:00:00-06:00", + end: "2026-07-14T10:00:00-06:00", + timeZone: "America/Denver", + }; + + const attendee = ( + email: string, + responseStatus: Attendee["responseStatus"] = "needsAction", + displayName: string | null = null, + ): Attendee => ({ email, displayName, responseStatus }); + + const contentWith = ( + title: string, + opts: { + organizer?: { email: string; displayName: string | null } | null; + attendees?: Attendee[]; + } = {}, + ) => ({ + title, + description: "", + location: null, + organizer: opts.organizer ?? null, + attendees: opts.attendees ?? [], + conference: null, + }); + + const connectionsWith = (email: string | null): ProviderConnectionLookup => ({ + findById: async () => ({ account: { email } }), + }); + const missingConnection: ProviderConnectionLookup = { + findById: async () => null, + }; + + const deps = ( + writer: ProviderEventWriter, + connections: ProviderConnectionLookup, + ) => ({ + commands, + events, + occurrences, + resources, + connections, + writer, + custody: tokenSource(), + }); + + beforeEach(() => { + mongo = storage.mongo(); + commands = new CommandRepository(mongo.db); + events = new EventRepository(mongo.db); + occurrences = new EventOccurrenceRepository(mongo.db, mongo.client); + resources = new SyncResourceRepository(mongo.db); + calendars = new ProviderCalendarRepository(mongo.db); + }); + + const seedCalendar = async ( + tenantId: TenantId, + principalId: PrincipalId, + connectionId: ConnectionId, + ) => + calendars.upsertByProviderCalendar({ + tenantId, + principalId, + connectionId, + providerCalendarId: "primary@google.com", + displayName: "Google", + color: null, + active: true, + primary: true, + accessRole: "owner", + capabilities: { + canReadEvents: true, + canWriteEvents: true, + canReadBusy: true, + canInviteAttendees: true, + }, + }); + + // Seed a provider-linked single event, optionally with a stored organizer + // and (stale) stored attendee list. + const seedLinked = async (opts: { + organizer?: { email: string; displayName: string | null } | null; + storedAttendees?: Attendee[]; + recurrence?: { kind: "seriesMaster"; rules: string[] }; + }) => { + const tenantId = objectId() as TenantId; + const principalId = objectId() as PrincipalId; + const connectionId = objectId() as ConnectionId; + const calendar = await seedCalendar(tenantId, principalId, connectionId); + const eventId = objectId() as EventId; + await events.put({ + _id: eventId, + tenantId, + principalId, + origin: "compass", + calendarId: calendar._id, + clientEventId: null, + connectionId, + providerEventId: "g-evt-1" as never, + providerVersion: "etag-1" as never, + providerUpdatedAt: null, + deliveryState: "confirmed", + providerMetadata: null, + content: contentWith("Old", { + organizer: opts.organizer, + attendees: opts.storedAttendees, + }), + schedule, + recurrence: opts.recurrence ?? { kind: "single" }, + lifecycleState: "active", + generation: 0, + createdAt: now(), + updatedAt: now(), + confirmedAt: now(), + } as never); + const event = await events.findById(tenantId, principalId, eventId); + if (!event) throw new Error("seed failed to read back the event"); + return { tenantId, principalId, calendar, event }; + }; + + const replaceCommand = async ( + event: EventRecord, + opts: { + title?: string; + attendees: Attendee[]; + attendeesEdit?: "replace" | "preserve"; + scope?: string; + recurrenceId?: string | null; + recurrence?: unknown; + }, + ) => + ( + await commands.submit({ + tenantId: event.tenantId, + principalId: event.principalId, + idempotencyKey: `idem-${objectId()}` as IdempotencyKey, + eventId: event._id, + input: { + kind: "update", + invitation: "all", + attendeesEdit: opts.attendeesEdit ?? "replace", + content: contentWith(opts.title ?? "Old", { + attendees: opts.attendees, + }), + schedule, + recurrence: opts.recurrence ?? { kind: "preserve" }, + scope: opts.scope ?? "all", + recurrenceId: opts.recurrenceId ?? null, + } as unknown as SyncCommandInput, + expectedVersion: "etag-1" as never, + }) + ).record; + + const providerSingle = ( + title: string, + version: string, + attendees: Attendee[], + ): ProviderEvent => ({ + kind: "event", + providerEventId: "g-evt-1", + providerVersion: version, + providerUpdatedAt: null, + content: contentWith(title, { + organizer: { email: OWNER, displayName: null }, + attendees, + }), + schedule, + busy: true, + recurrence: { kind: "single" }, + }); + + it("merges the intent against freshly fetched provider state and patches the full set", async () => { + // Acceptance "Normal": add one attendee to an event with three existing. + // The stored record is STALE (everyone needsAction); the provider copy has + // newer RSVPs that must survive the replace. + const { tenantId, principalId, calendar, event } = await seedLinked({ + organizer: { email: OWNER, displayName: null }, + storedAttendees: [ + attendee("a@example.com"), + attendee("b@example.com"), + attendee("c@example.com"), + ], + }); + const command = await replaceCommand(event, { + attendees: [ + attendee("a@example.com"), + attendee("b@example.com"), + attendee("c@example.com"), + attendee("d@example.com", "needsAction", "Dee"), + ], + }); + const writer = new FakeUpdateWriter(); + writer.fetched = providerSingle("Old", "etag-1", [ + attendee("a@example.com", "accepted"), + attendee("b@example.com", "needsAction"), + attendee("c@example.com", "declined", "Cee"), + ]); + + const result = await executeProviderUpdate( + // Case-insensitive: the connection reports the account email cased + // differently than the stored organizer. + deps(writer, connectionsWith("Owner@Example.COM")), + command, + event, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + const expectedMerged = [ + attendee("a@example.com", "accepted"), + attendee("b@example.com", "needsAction"), + attendee("c@example.com", "declined", "Cee"), + attendee("d@example.com", "needsAction", "Dee"), + ]; + expect(writer.patchCalls).toHaveLength(1); + expect(writer.patchCalls[0].attendees).toEqual(expectedMerged); + expect(writer.patchCalls[0].invitation).toBe("all"); + // The merged membership lands on the sync record at confirm, so reads + // reflect it before the next Google round-trip. + const stored = await events.findById(tenantId, principalId, event._id); + expect(stored?.content.attendees).toEqual(expectedMerged); + }); + + it("replaces with an empty set to remove every guest", async () => { + const { tenantId, principalId, calendar, event } = await seedLinked({ + organizer: { email: OWNER, displayName: null }, + storedAttendees: [attendee("a@example.com", "accepted")], + }); + const command = await replaceCommand(event, { attendees: [] }); + const writer = new FakeUpdateWriter(); + writer.fetched = providerSingle("Old", "etag-1", [ + attendee("a@example.com", "accepted"), + ]); + + const result = await executeProviderUpdate( + deps(writer, connectionsWith(OWNER)), + command, + event, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + expect(writer.patchCalls[0].attendees).toEqual([]); + const stored = await events.findById(tenantId, principalId, event._id); + expect(stored?.content.attendees).toEqual([]); + }); + + it("fails a non-organizer replace typed, before any provider call", async () => { + const { calendar, event } = await seedLinked({ + organizer: { email: "someone-else@example.com", displayName: null }, + storedAttendees: [attendee("a@example.com", "accepted")], + }); + const command = await replaceCommand(event, { + attendees: [attendee("a@example.com"), attendee("b@example.com")], + }); + const writer = new FakeUpdateWriter(); + + const result = await executeProviderUpdate( + deps(writer, connectionsWith(OWNER)), + command, + event, + calendar, + now, + ); + + expect(result.outcome).toEqual({ + state: "failed", + failureReason: "unsupportedCapability", + }); + // No provider call of any kind — not even the replay-detection fetch. + expect(writer.fetchCalls).toHaveLength(0); + expect(writer.patchCalls).toHaveLength(0); + // The failure surface the command route logs from (and the SSE notices + // derive from) carries no attendee JSON or event content. + expect(findSafetyCanaryHit(result.outcome)).toBeNull(); + expect( + findSafetyCanaryHit( + `Command ${result._id} (${result.input.kind} ${result.eventId}) failed: ${ + result.outcome.state === "failed" && result.outcome.failureReason + }`, + ), + ).toBeNull(); + }); + + it("fails closed when the connection cannot be resolved", async () => { + const { calendar, event } = await seedLinked({ + organizer: { email: OWNER, displayName: null }, + }); + const command = await replaceCommand(event, { + attendees: [attendee("a@example.com")], + }); + const writer = new FakeUpdateWriter(); + + const result = await executeProviderUpdate( + deps(writer, missingConnection), + command, + event, + calendar, + now, + ); + + expect(result.outcome).toEqual({ + state: "failed", + failureReason: "unsupportedCapability", + }); + expect(writer.fetchCalls).toHaveLength(0); + }); + + it("allows a replace when no organizer is stored yet", async () => { + // A Compass-created event that has never had guests carries no organizer; + // the connection's own account organizes it. + const { calendar, event } = await seedLinked({ organizer: null }); + const command = await replaceCommand(event, { + attendees: [attendee("a@example.com")], + }); + const writer = new FakeUpdateWriter(); + writer.fetched = providerSingle("Old", "etag-1", []); + + const result = await executeProviderUpdate( + deps(writer, connectionsWith(OWNER)), + command, + event, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + expect(writer.patchCalls[0].attendees).toEqual([attendee("a@example.com")]); + }); + + it("confirms a landed attendee-only edit on replay — email sets, order- and status-insensitive", async () => { + const { calendar, event } = await seedLinked({ + organizer: { email: OWNER, displayName: null }, + storedAttendees: [attendee("a@example.com")], + }); + const command = await replaceCommand(event, { + attendees: [attendee("a@example.com"), attendee("b@example.com")], + }); + const writer = new FakeUpdateWriter(); + // The prior attempt landed; since then the provider reordered the list + // and one guest RSVP'd. Same membership => replay, never a second write. + writer.fetched = providerSingle("Old", "etag-7", [ + attendee("b@example.com", "accepted"), + attendee("A@Example.com", "declined"), + ]); + + const result = await executeProviderUpdate( + deps(writer, connectionsWith(OWNER)), + command, + event, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + expect(writer.patchCalls).toHaveLength(0); + expect( + result.outcome.state === "confirmed" && result.outcome.providerVersion, + ).toBe("etag-7"); + }); + + it("still patches when the landed membership differs from the intent", async () => { + const { calendar, event } = await seedLinked({ + organizer: { email: OWNER, displayName: null }, + }); + const command = await replaceCommand(event, { + attendees: [attendee("a@example.com"), attendee("b@example.com")], + }); + const writer = new FakeUpdateWriter(); + writer.fetched = providerSingle("Old", "etag-1", [ + attendee("a@example.com", "accepted"), + attendee("c@example.com", "accepted"), + ]); + + const result = await executeProviderUpdate( + deps(writer, connectionsWith(OWNER)), + command, + event, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + expect(writer.patchCalls).toHaveLength(1); + expect(writer.patchCalls[0].attendees).toEqual([ + attendee("a@example.com", "accepted"), + attendee("b@example.com"), + ]); + }); + + it("keeps a preserve command byte-identical: no attendees on the patch, stored list untouched", async () => { + const storedAttendees = [attendee("kept@example.com", "accepted")]; + const { tenantId, principalId, calendar, event } = await seedLinked({ + organizer: { email: OWNER, displayName: null }, + storedAttendees, + }); + // The browser echoes full content on legacy updates — attendees included — + // but "preserve" must not turn that into a guest write. + const command = await replaceCommand(event, { + title: "Renamed", + attendees: [attendee("stray@example.com")], + attendeesEdit: "preserve", + }); + const writer = new FakeUpdateWriter(); + writer.fetched = providerSingle("Old", "etag-1", [ + attendee("kept@example.com", "accepted"), + attendee("provider-only@example.com", "tentative"), + ]); + + const result = await executeProviderUpdate( + deps(writer, connectionsWith(OWNER)), + command, + event, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + expect(writer.patchCalls).toHaveLength(1); + expect(writer.patchCalls[0]).not.toHaveProperty("attendees"); + expect(writer.patchCalls[0].content.attendees).toEqual(storedAttendees); + // The stored record keeps its own attendee list (mergeUpdateContent), not + // the command's echoed one. + const stored = await events.findById(tenantId, principalId, event._id); + expect(stored?.content.attendees).toEqual(storedAttendees); + expect(stored?.content.title).toBe("Renamed"); + }); + + it("leaves a replace pending on a transient fetch failure, with no patch", async () => { + // Acceptance "Tool failure": the provider fetch fails transiently. + const { calendar, event } = await seedLinked({ + organizer: { email: OWNER, displayName: null }, + }); + const command = await replaceCommand(event, { + attendees: [attendee("a@example.com")], + }); + const writer = new FakeUpdateWriter(); + writer.fetchError = new ProviderWriteError("transient", "blip"); + + const result = await executeProviderUpdate( + deps(writer, connectionsWith(OWNER)), + command, + event, + calendar, + now, + ); + + expect(result.outcome.state).toBe("pending"); + expect(writer.patchCalls).toHaveLength(0); + }); + + it("merges and patches the guest list on a series edit-all", async () => { + const weekly4 = ["RRULE:FREQ=WEEKLY;COUNT=4"]; + const { tenantId, principalId, calendar, event } = await seedLinked({ + organizer: { email: OWNER, displayName: null }, + storedAttendees: [attendee("a@example.com")], + recurrence: { kind: "seriesMaster", rules: weekly4 }, + }); + const command = await replaceCommand(event, { + attendees: [attendee("a@example.com"), attendee("b@example.com")], + }); + const writer = new FakeUpdateWriter(); + writer.fetched = { + ...providerSingle("Old", "etag-1", [ + attendee("a@example.com", "accepted"), + ]), + recurrence: { kind: "seriesMaster", rules: weekly4 }, + }; + + const result = await executeProviderSeriesUpdate( + deps(writer, connectionsWith(OWNER)), + command, + event, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + expect(writer.patchCalls).toHaveLength(1); + expect(writer.patchCalls[0].attendees).toEqual([ + attendee("a@example.com", "accepted"), + attendee("b@example.com"), + ]); + const stored = await events.findById(tenantId, principalId, event._id); + expect(stored?.content.attendees).toEqual([ + attendee("a@example.com", "accepted"), + attendee("b@example.com"), + ]); + }); + + it("fails a non-organizer series edit-all replace before any provider call", async () => { + const { calendar, event } = await seedLinked({ + organizer: { email: "someone-else@example.com", displayName: null }, + recurrence: { + kind: "seriesMaster", + rules: ["RRULE:FREQ=WEEKLY;COUNT=4"], + }, + }); + const command = await replaceCommand(event, { + attendees: [attendee("a@example.com")], + }); + const writer = new FakeUpdateWriter(); + + const result = await executeProviderSeriesUpdate( + deps(writer, connectionsWith(OWNER)), + command, + event, + calendar, + now, + ); + + expect(result.outcome).toEqual({ + state: "failed", + failureReason: "unsupportedCapability", + }); + expect(writer.fetchCalls).toHaveLength(0); + expect(writer.patchCalls).toHaveLength(0); + }); + + it("refuses a replace on a scope-this occurrence edit (whole-event only in v1)", async () => { + const { calendar, event } = await seedLinked({ + organizer: { email: OWNER, displayName: null }, + recurrence: { + kind: "seriesMaster", + rules: ["RRULE:FREQ=WEEKLY;COUNT=4"], + }, + }); + const command = await replaceCommand(event, { + attendees: [attendee("a@example.com")], + scope: "this", + recurrenceId: "2026-07-21T15:00:00.000Z", + }); + const writer = new FakeUpdateWriter(); + + const result = await executeProviderOccurrenceUpdate( + deps(writer, connectionsWith(OWNER)), + command, + event, + calendar, + now, + ); + + expect(result.outcome).toEqual({ + state: "failed", + failureReason: "unsupportedCapability", + }); + expect(writer.patchCalls).toHaveLength(0); + }); + + it("refuses a replace on a thisAndFollowing split", async () => { + const { tenantId, principalId, calendar, event } = await seedLinked({ + organizer: { email: OWNER, displayName: null }, + recurrence: { + kind: "seriesMaster", + rules: ["RRULE:FREQ=WEEKLY;COUNT=4"], + }, + }); + const command = await replaceCommand(event, { + attendees: [attendee("a@example.com")], + scope: "thisAndFollowing", + recurrenceId: "2026-07-21T15:00:00.000Z", + }); + const writer = new FakeUpdateWriter(); + + const result = await executeProviderSeriesFollowingUpdate( + deps(writer, connectionsWith(OWNER)), + command, + event, + calendar, + now, + ); + + expect(result.outcome).toEqual({ + state: "failed", + failureReason: "unsupportedCapability", + }); + expect(writer.patchCalls).toHaveLength(0); + // Refused before the split touched anything: the master's rules are + // untruncated. + const stored = await events.findById(tenantId, principalId, event._id); + expect(stored?.recurrence).toEqual({ + kind: "seriesMaster", + rules: ["RRULE:FREQ=WEEKLY;COUNT=4"], + }); + }); + + it("emits every intended guest as needsAction on create and stores them", async () => { + const tenantId = objectId() as TenantId; + const principalId = objectId() as PrincipalId; + const connectionId = objectId() as ConnectionId; + const calendar = await seedCalendar(tenantId, principalId, connectionId); + const { record: command } = await commands.submit({ + tenantId, + principalId, + idempotencyKey: `idem-${objectId()}` as IdempotencyKey, + eventId: objectId() as EventId, + input: { + kind: "create", + calendarId: calendar._id, + invitation: "all", + attendeesEdit: "replace", + // The command may carry stray statuses; a create normalizes every + // guest to needsAction (nobody has answered a brand-new invitation). + content: contentWith("Kickoff", { + attendees: [ + attendee("a@example.com", "accepted", "Aye"), + attendee("b@example.com"), + ], + }), + schedule, + recurrence: { kind: "single" }, + } as unknown as SyncCommandInput, + expectedVersion: null, + }); + const writer = new FakeWriter(); + + const result = await executeProviderCreate( + deps(writer, connectionsWith(OWNER)), + command, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + const expected = [ + attendee("a@example.com", "needsAction", "Aye"), + attendee("b@example.com"), + ]; + expect(writer.calls).toHaveLength(1); + expect(writer.calls[0].attendees).toEqual(expected); + expect(writer.calls[0].invitation).toBe("all"); + const stored = await events.findById( + tenantId, + principalId, + command.eventId, + ); + expect(stored?.content.attendees).toEqual(expected); + }); + + it("keeps a legacy create byte-identical: no attendees on the provider write", async () => { + const tenantId = objectId() as TenantId; + const principalId = objectId() as PrincipalId; + const connectionId = objectId() as ConnectionId; + const calendar = await seedCalendar(tenantId, principalId, connectionId); + const { record: command } = await commands.submit({ + tenantId, + principalId, + idempotencyKey: `idem-${objectId()}` as IdempotencyKey, + eventId: objectId() as EventId, + // No attendeesEdit: the schema defaults it to "preserve" (legacy). + input: { + kind: "create", + calendarId: calendar._id, + invitation: "none", + content: contentWith("Plain"), + schedule, + recurrence: { kind: "single" }, + } as unknown as SyncCommandInput, + expectedVersion: null, + }); + const writer = new FakeWriter(); + + const result = await executeProviderCreate( + deps(writer, connectionsWith(OWNER)), + command, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + expect(command.input.kind === "create" && command.input.attendeesEdit).toBe( + "preserve", + ); + expect(writer.calls[0]).not.toHaveProperty("attendees"); + }); +}); + +// WP-07: rsvp command execution — rewrite ONLY the self attendee entry +// (matched case-insensitively by the connection's account email) against +// freshly fetched provider state, patch the full merged list with +// sendUpdates "none", target the master for scope "all" and the resolved +// Google instance for scope "this", replay without a second write, and fail +// typed (unsupportedCapability) on every guard. +describe("executeProviderRsvp", () => { + let mongo: SyncMongoService; + let commands: CommandRepository; + let events: EventRepository; + let occurrences: EventOccurrenceRepository; + let resources: SyncResourceRepository; + let calendars: ProviderCalendarRepository; + + const now = () => new Date("2026-07-10T00:00:00.000Z"); + const SELF = "self@example.com"; + + const schedule = { + kind: "timed" as const, + start: "2026-07-14T09:00:00-06:00", + end: "2026-07-14T10:00:00-06:00", + timeZone: "America/Denver", + }; + const weekly3 = ["RRULE:FREQ=WEEKLY;COUNT=3"]; + const SECOND_START_UTC = "2026-07-21T15:00:00.000Z"; + + const attendee = ( + email: string, + responseStatus: Attendee["responseStatus"] = "needsAction", + displayName: string | null = null, + ): Attendee => ({ email, displayName, responseStatus }); + + const contentWith = ( + title: string, + opts: { + organizer?: { email: string; displayName: string | null } | null; + attendees?: Attendee[]; + color?: string; + } = {}, + ) => ({ + title, + description: "", + location: null, + organizer: opts.organizer ?? null, + attendees: opts.attendees ?? [], + conference: null, + ...(opts.color ? { color: opts.color } : {}), + }); + + const connectionsWith = (email: string | null): ProviderConnectionLookup => ({ + findById: async () => ({ account: { email } }), + }); + const missingConnection: ProviderConnectionLookup = { + findById: async () => null, + }; + + const deps = ( + writer: ProviderEventWriter, + connections: ProviderConnectionLookup, + ) => ({ + commands, + events, + occurrences, + resources, + connections, + writer, + custody: tokenSource(), + }); + + beforeEach(() => { + mongo = storage.mongo(); + commands = new CommandRepository(mongo.db); + events = new EventRepository(mongo.db); + occurrences = new EventOccurrenceRepository(mongo.db, mongo.client); + resources = new SyncResourceRepository(mongo.db); + calendars = new ProviderCalendarRepository(mongo.db); + }); + + const seedCalendar = async ( + tenantId: TenantId, + principalId: PrincipalId, + connectionId: ConnectionId, + ) => + calendars.upsertByProviderCalendar({ + tenantId, + principalId, + connectionId, + providerCalendarId: "primary@google.com", + displayName: "Google", + color: null, + active: true, + primary: true, + accessRole: "owner", + capabilities: { + canReadEvents: true, + canWriteEvents: true, + canReadBusy: true, + canInviteAttendees: true, + }, + }); + + // Seed a provider-linked event the account is invited to (stored guest + // list includes SELF by default). + const seedLinked = async ( + opts: { + organizer?: { email: string; displayName: string | null } | null; + storedAttendees?: Attendee[]; + recurrence?: { kind: "seriesMaster"; rules: string[] }; + } = {}, + ) => { + const tenantId = objectId() as TenantId; + const principalId = objectId() as PrincipalId; + const connectionId = objectId() as ConnectionId; + const calendar = await seedCalendar(tenantId, principalId, connectionId); + const eventId = objectId() as EventId; + await events.put({ + _id: eventId, + tenantId, + principalId, + origin: "compass", + calendarId: calendar._id, + clientEventId: null, + connectionId, + providerEventId: "g-evt-1" as never, + providerVersion: "etag-1" as never, + providerUpdatedAt: null, + deliveryState: "confirmed", + providerMetadata: null, + content: contentWith("Invited", { + organizer: opts.organizer ?? { + email: "organizer@example.com", + displayName: null, + }, + attendees: opts.storedAttendees ?? [ + attendee("organizer@example.com", "accepted"), + attendee(SELF, "accepted"), + ], + }), + schedule, + recurrence: opts.recurrence ?? { kind: "single" }, + lifecycleState: "active", + generation: 0, + createdAt: now(), + updatedAt: now(), + confirmedAt: now(), + } as never); + const event = await events.findById(tenantId, principalId, eventId); + if (!event) throw new Error("seed failed to read back the event"); + return { tenantId, principalId, calendar, event }; + }; + + const rsvpCommand = async ( + event: EventRecord, + opts: { + responseStatus?: "accepted" | "declined" | "tentative"; + scope?: string; + recurrenceId?: string | null; + } = {}, + ) => + ( + await commands.submit({ + tenantId: event.tenantId, + principalId: event.principalId, + idempotencyKey: `idem-${objectId()}` as IdempotencyKey, + eventId: event._id, + input: { + kind: "rsvp", + responseStatus: opts.responseStatus ?? "declined", + scope: opts.scope ?? "all", + recurrenceId: opts.recurrenceId ?? null, + } as unknown as SyncCommandInput, + expectedVersion: null, + }) + ).record; + + const providerSingle = ( + version: string, + attendees: Attendee[], + opts: { color?: string } = {}, + ): ProviderEvent => ({ + kind: "event", + providerEventId: "g-evt-1", + providerVersion: version, + providerUpdatedAt: null, + content: contentWith("Invited", { + organizer: { email: "organizer@example.com", displayName: null }, + attendees, + color: opts.color, + }) as ProviderEvent["content"], + schedule, + busy: true, + recurrence: { kind: "single" }, + }); + + it("rewrites only the self entry (case-insensitive) and patches the full list with sendUpdates none", async () => { + // Acceptance "Normal": accepted → declined on a single event. The + // account email is cased differently than the provider's entry, and the + // provider list carries fresher sibling RSVPs than the stored copy. + const { tenantId, principalId, calendar, event } = await seedLinked(); + const command = await rsvpCommand(event, { responseStatus: "declined" }); + const writer = new FakeUpdateWriter(); + const fetchedList = [ + attendee("organizer@example.com", "accepted", "Org"), + attendee("Self@Example.COM", "accepted"), + attendee("other@example.com", "tentative", "Oth"), + ]; + writer.fetched = providerSingle("etag-1", fetchedList, { color: "coral" }); + + const result = await executeProviderRsvp( + deps(writer, connectionsWith(SELF)), + command, + event, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + expect(writer.patchCalls).toHaveLength(1); + const patch = writer.patchCalls[0]; + // The full merged list rides the attendee body emission: only the self + // entry's responseStatus changed; every other entry — and the self + // entry's own email casing and displayName — is byte-identical to the + // freshly fetched provider state. + expect(patch.attendees).toEqual([ + attendee("organizer@example.com", "accepted", "Org"), + attendee("Self@Example.COM", "declined"), + attendee("other@example.com", "tentative", "Oth"), + ]); + expect(patch.attendees?.[0]).toEqual(fetchedList[0] as Attendee); + expect(patch.attendees?.[2]).toEqual(fetchedList[2] as Attendee); + // Never emails the guest list, and never conditions on a version: a + // concurrent sibling RSVP must not block this one. + expect(patch.invitation).toBe("none"); + expect(patch.expectedVersion).toBeNull(); + expect(patch.providerEventId).toBe("g-evt-1"); + // The echoed body carries the fetched content minus color/colorHex, so + // the patch cannot touch Google's color or label state. + expect(patch.content.title).toBe("Invited"); + expect(patch.content).not.toHaveProperty("color"); + expect(patch.content).not.toHaveProperty("colorHex"); + + // The answer lands on the stored record before the next Google + // round-trip. + const stored = await events.findById(tenantId, principalId, event._id); + expect(stored?.content.attendees).toEqual([ + attendee("organizer@example.com", "accepted", "Org"), + attendee("Self@Example.COM", "declined"), + attendee("other@example.com", "tentative", "Oth"), + ]); + expect(stored?.providerVersion).toBe("etag-2"); + expect( + result.outcome.state === "confirmed" && result.outcome.providerVersion, + ).toBe("etag-2"); + }); + + it("confirms a replay without a second write when the provider already holds the answer", async () => { + const { calendar, event } = await seedLinked(); + const command = await rsvpCommand(event, { responseStatus: "tentative" }); + const writer = new FakeUpdateWriter(); + // The prior attempt landed (or the user answered from another client). + writer.fetched = providerSingle("etag-7", [ + attendee("organizer@example.com", "accepted"), + attendee(SELF, "tentative"), + ]); + + const result = await executeProviderRsvp( + deps(writer, connectionsWith(SELF)), + command, + event, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + expect(writer.patchCalls).toHaveLength(0); + expect( + result.outcome.state === "confirmed" && result.outcome.providerVersion, + ).toBe("etag-7"); + }); + + it("allows the organizer to RSVP their own event", async () => { + // Finish line 4: no organizer guard here — Google lists the organizer as + // an attendee of their own event, and answering it is theirs to do. + const { calendar, event } = await seedLinked({ + organizer: { email: SELF, displayName: null }, + storedAttendees: [ + attendee(SELF, "accepted"), + attendee("guest@example.com", "needsAction"), + ], + }); + const command = await rsvpCommand(event, { responseStatus: "tentative" }); + const writer = new FakeUpdateWriter(); + writer.fetched = providerSingle("etag-1", [ + attendee(SELF, "accepted"), + attendee("guest@example.com", "needsAction"), + ]); + + const result = await executeProviderRsvp( + deps(writer, connectionsWith(SELF)), + command, + event, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + expect(writer.patchCalls[0].attendees).toEqual([ + attendee(SELF, "tentative"), + attendee("guest@example.com", "needsAction"), + ]); + }); + + it("fails typed when the account is not in the stored guest list, with no provider call", async () => { + // Acceptance "Policy": self not an attendee → unsupportedCapability + // BEFORE any provider call, and no attendee JSON anywhere the route + // logs from. + const { calendar, event } = await seedLinked({ + storedAttendees: [ + attendee("organizer@example.com", "accepted"), + attendee("someone-else@example.com", "needsAction"), + ], + }); + const command = await rsvpCommand(event); + const writer = new FakeUpdateWriter(); + + const result = await executeProviderRsvp( + deps(writer, connectionsWith(SELF)), + command, + event, + calendar, + now, + ); + + expect(result.outcome).toEqual({ + state: "failed", + failureReason: "unsupportedCapability", + }); + expect(writer.fetchCalls).toHaveLength(0); + expect(writer.patchCalls).toHaveLength(0); + expect(findSafetyCanaryHit(result.outcome)).toBeNull(); + expect( + findSafetyCanaryHit( + `Command ${result._id} (${result.input.kind} ${result.eventId}) failed: ${ + result.outcome.state === "failed" && result.outcome.failureReason + }`, + ), + ).toBeNull(); + }); + + it("fails closed when the connection cannot be resolved", async () => { + const { calendar, event } = await seedLinked(); + const command = await rsvpCommand(event); + const writer = new FakeUpdateWriter(); + + const result = await executeProviderRsvp( + deps(writer, missingConnection), + command, + event, + calendar, + now, + ); + + expect(result.outcome).toEqual({ + state: "failed", + failureReason: "unsupportedCapability", + }); + expect(writer.fetchCalls).toHaveLength(0); + }); + + it("fails closed when the connection has no account email", async () => { + const { calendar, event } = await seedLinked(); + const command = await rsvpCommand(event); + const writer = new FakeUpdateWriter(); + + const result = await executeProviderRsvp( + deps(writer, connectionsWith(null)), + command, + event, + calendar, + now, + ); + + expect(result.outcome).toEqual({ + state: "failed", + failureReason: "unsupportedCapability", + }); + expect(writer.fetchCalls).toHaveLength(0); + }); + + it("fails typed when the provider no longer lists the account, without writing", async () => { + // The stored list still has SELF, but the fetched state does not + // (uninvited provider-side since the last pull): same typed refusal, + // discovered after the fetch — never a write. + const { calendar, event } = await seedLinked(); + const command = await rsvpCommand(event); + const writer = new FakeUpdateWriter(); + writer.fetched = providerSingle("etag-3", [ + attendee("organizer@example.com", "accepted"), + ]); + + const result = await executeProviderRsvp( + deps(writer, connectionsWith(SELF)), + command, + event, + calendar, + now, + ); + + expect(result.outcome).toEqual({ + state: "failed", + failureReason: "unsupportedCapability", + }); + expect(writer.fetchCalls).toHaveLength(1); + expect(writer.patchCalls).toHaveLength(0); + }); + + it("leaves the command pending on a transient fetch failure, with no patch", async () => { + // Acceptance "Tool failure": fetch 5xx → the command stays retryable. + const { calendar, event } = await seedLinked(); + const command = await rsvpCommand(event); + const writer = new FakeUpdateWriter(); + writer.fetchError = new ProviderWriteError("transient", "blip"); + + const result = await executeProviderRsvp( + deps(writer, connectionsWith(SELF)), + command, + event, + calendar, + now, + ); + + expect(result.outcome.state).toBe("pending"); + expect(writer.patchCalls).toHaveLength(0); + }); + + it("fails permanently when nothing live exists to answer", async () => { + const { calendar, event } = await seedLinked(); + const command = await rsvpCommand(event); + const writer = new FakeUpdateWriter(); + writer.fetched = null; + + const result = await executeProviderRsvp( + deps(writer, connectionsWith(SELF)), + command, + event, + calendar, + now, + ); + + expect(result.outcome).toEqual({ + state: "failed", + failureReason: "permanentProviderError", + }); + expect(writer.patchCalls).toHaveLength(0); + }); + + // --- instance-vs-master targeting ----------------------------------------- + + const providerInstance = (attendees: Attendee[]): ProviderEvent => ({ + kind: "event", + providerEventId: "g-inst-1", + providerVersion: "etag-inst-1", + providerUpdatedAt: null, + content: contentWith("Invited", { + organizer: { email: "organizer@example.com", displayName: null }, + attendees, + }) as ProviderEvent["content"], + schedule: { + kind: "timed", + start: "2026-07-21T09:00:00-06:00", + end: "2026-07-21T10:00:00-06:00", + timeZone: "America/Denver", + }, + busy: true, + recurrence: { + kind: "instance", + seriesProviderId: "g-evt-1", + recurrenceId: SECOND_START_UTC, + }, + }); + + it("patches the resolved Google instance on a scope-this rsvp, leaving the master untouched", async () => { + // Acceptance "Normal": declined on ONE occurrence leaves the master and + // sibling occurrences untouched. The instance id comes from the writer's + // own fetchInstanceAt resolution — never hand-built. + const { tenantId, principalId, calendar, event } = await seedLinked({ + recurrence: { kind: "seriesMaster", rules: weekly3 }, + }); + const command = await rsvpCommand(event, { + responseStatus: "declined", + scope: "this", + recurrenceId: SECOND_START_UTC, + }); + const writer = new FakeRecurringWriter(); + writer.fetchInstanceResult = providerInstance([ + attendee("organizer@example.com", "accepted"), + attendee(SELF, "accepted"), + ]); + writer.patchResult = { + providerEventId: "g-inst-1", + providerVersion: "etag-inst-2", + }; + + const result = await executeProviderRsvp( + deps(writer, connectionsWith(SELF)), + command, + event, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + // The occurrence was resolved off the series via fetchInstanceAt, by the + // master's provider id and the occurrence's original start. + expect(writer.fetchInstanceCalls).toHaveLength(1); + expect(writer.fetchInstanceCalls[0]).toMatchObject({ + calendarId: calendar.providerCalendarId, + seriesProviderEventId: "g-evt-1", + originalStartAt: SECOND_START_UTC, + scheduleKind: "timed", + }); + // The master itself was never fetched and never patched: the single + // patch targets the RESOLVED instance id, with no recurrence key. + expect(writer.fetchEventCalls).toHaveLength(0); + expect(writer.patchCalls).toHaveLength(1); + expect(writer.patchCalls[0].providerEventId).toBe("g-inst-1"); + expect(writer.patchCalls[0].recurrence).toEqual({ kind: "instance" }); + expect(writer.patchCalls[0].invitation).toBe("none"); + expect(writer.patchCalls[0].attendees).toEqual([ + attendee("organizer@example.com", "accepted"), + attendee(SELF, "declined"), + ]); + + // Locally: the master's stored guest list is untouched; the answer lives + // on the instance's exception record, carrying the instance's own + // provider identity. + const master = await events.findById(tenantId, principalId, event._id); + expect(master?.content.attendees).toEqual([ + attendee("organizer@example.com", "accepted"), + attendee(SELF, "accepted"), + ]); + expect(master?.providerVersion).toBe("etag-1"); + const exceptions = await events.findSeriesExceptions( + tenantId, + principalId, + event._id, + ); + expect(exceptions).toHaveLength(1); + expect(exceptions[0]?.providerEventId).toBe("g-inst-1"); + expect(exceptions[0]?.providerVersion).toBe("etag-inst-2"); + expect(exceptions[0]?.content.attendees).toEqual([ + attendee("organizer@example.com", "accepted"), + attendee(SELF, "declined"), + ]); + + // Sibling occurrences are untouched: the master still projects 07-14 and + // 07-28, and the answered instant projects from the exception. + const masterRows = await mongo.db + .collection(SYNC_COLLECTIONS.eventOccurrences) + .find({ eventId: event._id }) + .toArray(); + expect( + masterRows.map((row) => (row["startAt"] as Date).toISOString()).sort(), + ).toEqual(["2026-07-14T15:00:00.000Z", "2026-07-28T15:00:00.000Z"]); + const exceptionRows = await mongo.db + .collection(SYNC_COLLECTIONS.eventOccurrences) + .find({ eventId: exceptions[0]?._id }) + .toArray(); + expect( + exceptionRows.map((row) => (row["startAt"] as Date).toISOString()), + ).toEqual([SECOND_START_UTC]); + }); + + it("patches the series master on a scope-all rsvp, never resolving an instance", async () => { + // The other half of the targeting proof: "all events" answers on the + // master itself. + const { tenantId, principalId, calendar, event } = await seedLinked({ + recurrence: { kind: "seriesMaster", rules: weekly3 }, + }); + const command = await rsvpCommand(event, { responseStatus: "declined" }); + const writer = new FakeRecurringWriter(); + writer.fetchEventResult = { + ...providerSingle("etag-1", [ + attendee("organizer@example.com", "accepted"), + attendee(SELF, "accepted"), + ]), + recurrence: { kind: "seriesMaster", rules: weekly3 }, + }; + writer.patchResult = { + providerEventId: "g-evt-1", + providerVersion: "etag-2", + }; + + const result = await executeProviderRsvp( + deps(writer, connectionsWith(SELF)), + command, + event, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + expect(writer.fetchInstanceCalls).toHaveLength(0); + expect(writer.fetchEventCalls).toHaveLength(1); + expect(writer.patchCalls).toHaveLength(1); + expect(writer.patchCalls[0].providerEventId).toBe("g-evt-1"); + // The master's own current rules are re-written unchanged + // (self-describing), mirroring how a "preserve" series edit writes. + expect(writer.patchCalls[0].recurrence).toEqual({ + kind: "series", + rules: weekly3, + }); + expect(writer.patchCalls[0].attendees).toEqual([ + attendee("organizer@example.com", "accepted"), + attendee(SELF, "declined"), + ]); + const master = await events.findById(tenantId, principalId, event._id); + expect(master?.content.attendees).toEqual([ + attendee("organizer@example.com", "accepted"), + attendee(SELF, "declined"), + ]); + }); + + it("keeps a scope-all rsvp from resurrecting a cancelled occurrence", async () => { + // The commit reprojects through reprojectMaster, so a previously deleted + // occurrence's instant stays excluded. + const { calendar, event } = await seedLinked({ + recurrence: { kind: "seriesMaster", rules: weekly3 }, + }); + await events.upsertException( + event, + SECOND_START_UTC as never, + { + content: event.content, + schedule, + cancelled: true, + providerIdentity: null, + }, + now(), + ); + const command = await rsvpCommand(event, { responseStatus: "declined" }); + const writer = new FakeRecurringWriter(); + writer.fetchEventResult = { + ...providerSingle("etag-1", [attendee(SELF, "accepted")]), + recurrence: { kind: "seriesMaster", rules: weekly3 }, + }; + + const result = await executeProviderRsvp( + deps(writer, connectionsWith(SELF)), + command, + event, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + const masterRows = await mongo.db + .collection(SYNC_COLLECTIONS.eventOccurrences) + .find({ eventId: event._id }) + .toArray(); + expect( + masterRows.map((row) => (row["startAt"] as Date).toISOString()).sort(), + ).toEqual(["2026-07-14T15:00:00.000Z", "2026-07-28T15:00:00.000Z"]); + }); + + it("confirms a scope-this replay without a second write when the instance already holds the answer", async () => { + const { tenantId, principalId, calendar, event } = await seedLinked({ + recurrence: { kind: "seriesMaster", rules: weekly3 }, + }); + const command = await rsvpCommand(event, { + responseStatus: "declined", + scope: "this", + recurrenceId: SECOND_START_UTC, + }); + const writer = new FakeRecurringWriter(); + writer.fetchInstanceResult = providerInstance([attendee(SELF, "declined")]); + + const result = await executeProviderRsvp( + deps(writer, connectionsWith(SELF)), + command, + event, + calendar, + now, + ); + + expect(result.outcome.state).toBe("confirmed"); + expect(writer.patchCalls).toHaveLength(0); + expect( + result.outcome.state === "confirmed" && result.outcome.providerVersion, + ).toBe("etag-inst-1"); + // The already-landed answer still converges locally onto the exception. + const exceptions = await events.findSeriesExceptions( + tenantId, + principalId, + event._id, + ); + expect(exceptions).toHaveLength(1); + expect(exceptions[0]?.content.attendees).toEqual([ + attendee(SELF, "declined"), + ]); + }); +}); diff --git a/packages/sync/src/domain/provider-command.service.ts b/packages/sync/src/domain/provider-command.service.ts index d051f5bd08..83fcc2883e 100644 --- a/packages/sync/src/domain/provider-command.service.ts +++ b/packages/sync/src/domain/provider-command.service.ts @@ -3,6 +3,7 @@ import { type EditableRecurrence, type EventSchedule, } from "@core/types/event.contracts"; +import { type Attendee } from "@core/types/event-attendance.contracts"; import { type RecurrenceEdit } from "@core/types/event-command.contracts"; import { type SyncCommandFailureReason } from "@core/types/sync/command.contracts"; import { @@ -12,9 +13,12 @@ import { } from "@core/types/sync/event.contracts"; import { type ConnectionId, + type PrincipalId, type ProviderEventId, + type TenantId, } from "@core/types/sync/identity.contracts"; import { + mergeAttendees, mergeUpdateContent, omitNullColor, } from "@sync/domain/merge-update-content"; @@ -53,6 +57,18 @@ import { type EventRepository } from "@sync/storage/repositories/event.repositor import { type EventOccurrenceRepository } from "@sync/storage/repositories/event-occurrence.repository"; import { type SyncResourceRepository } from "@sync/storage/repositories/sync-resource.repository"; +// The single connection fact the attendee organizer guard needs, narrowed +// from ProviderConnectionRepository so tests can fake it without a database. +export interface ProviderConnectionLookup { + findById( + tenantId: TenantId, + principalId: PrincipalId, + id: ConnectionId, + ): Promise<{ + readonly account: { readonly email: string | null }; + } | null>; +} + export interface ProviderMutationDeps { commands: CommandRepository; events: EventRepository; @@ -62,6 +78,9 @@ export interface ProviderMutationDeps { // Reads serve a calendar's active generation, so a create has to ask which // generation that is rather than assume the calendar has never been repaired. resources: SyncResourceRepository; + // Which account the write acts as — a guest-list replace is only valid on + // an event that account organizes (see organizerGuardFailure). + connections: ProviderConnectionLookup; writer: ProviderEventWriter; custody: AccessTokenSource; } @@ -107,6 +126,16 @@ export async function executeProviderCreate( } const { accessToken } = tokenResult; + // A create with intended guests (attendeesEdit "replace") merges against an + // EMPTY provider list — the event does not exist yet — so every intended + // guest enters as needsAction. The organizer is not synthesized: Google + // adds the creating account as an accepted organizer-attendee itself, so a + // create body must never be compared to its readback. + const intendedAttendees = + input.attendeesEdit === "replace" + ? mergeAttendees(input.content.attendees, []) + : undefined; + // Transient failures are safe to retry — the deterministic id keeps the // eventual retry idempotent. Every other reason is terminal and maps // straight to a command failure class. @@ -119,6 +148,7 @@ export async function executeProviderCreate( schedule: input.schedule, recurrence: toProviderWriteRecurrence(input.recurrence), invitation: input.invitation, + ...(intendedAttendees ? { attendees: intendedAttendees } : {}), }), ); if (!writeResult.ok) { @@ -149,6 +179,7 @@ export async function executeProviderCreate( result, now(), generations.get(input.calendarId) ?? 0, + intendedAttendees, ); await deps.events.put(record); await reprojectOccurrences(deps.occurrences, record, now); @@ -186,6 +217,41 @@ async function failCommand( return failed ?? command; } +// Gate for a guest-list replace: only the organizer's copy of an event +// supports rewriting the attendee array, and v1 rejects non-organizer guest +// editing outright (`guestsCanModify` is a documented follow-up). The STORED +// organizer is compared case-insensitively against the connection's account +// email so a non-organizer replace fails typed (unsupportedCapability) BEFORE +// any provider call, fetch included. A null stored organizer passes — it +// means no organizer has ever been read back (e.g. a Compass-created event +// with no guests yet), so the connection's own account organizes it. +// Unverifiable states (missing connection row, or a connection without an +// account email while an organizer exists) fail closed with the same typed +// reason rather than guessing. Returns the failed command, or null when the +// replace may proceed. +async function organizerGuardFailure( + deps: ProviderMutationDeps, + command: CommandRecord, + event: EventRecord, + connectionId: ConnectionId, +): Promise { + const organizerEmail = event.content.organizer?.email; + if (organizerEmail === undefined) return null; + const connection = await deps.connections.findById( + command.tenantId, + command.principalId, + connectionId, + ); + const accountEmail = connection?.account.email; + if ( + accountEmail != null && + accountEmail.toLowerCase() === organizerEmail.toLowerCase() + ) { + return null; + } + return failCommand(deps, command, "unsupportedCapability", connectionId); +} + // After a failed override-align patch, continue when the instance is already // gone (matching deleteEvent's 404-OK). Return a command to stop on; null means // local cleanup may proceed. The master write may already have landed, so a @@ -236,11 +302,18 @@ function buildLinkedEventRecord( result: ProviderWriteResult, now: Date, generation: number, + // The guest list the create actually wrote (merged, all needsAction), so + // the stored record reflects it before the next Google round-trip; absent + // for "preserve"/legacy creates, which store the command content verbatim. + intendedAttendees?: readonly Attendee[], ): EventRecord { if (command.input.kind !== "create") { throw new Error("buildLinkedEventRecord requires a create command"); } const { input } = command; + const content = intendedAttendees + ? { ...input.content, attendees: intendedAttendees } + : input.content; return { _id: command.eventId, tenantId: command.tenantId, @@ -255,7 +328,7 @@ function buildLinkedEventRecord( providerUpdatedAt: null, deliveryState: "confirmed", providerMetadata: result.icalUid ? { iCalUID: result.icalUid } : null, - content: omitNullColor(input.content), + content: omitNullColor(content), schedule: input.schedule, recurrence: input.recurrence.kind === "series" @@ -316,6 +389,18 @@ export async function executeProviderUpdate( const providerEventId = event.providerEventId; const connectionId = event.connectionId; + // A guest-list replace is only supported for the organizer; a non-organizer + // replace fails typed before any provider call. + if (input.attendeesEdit === "replace") { + const guardFailure = await organizerGuardFailure( + deps, + command, + event, + connectionId, + ); + if (guardFailure) return guardFailure; + } + const tokenResult = await resolveAccessToken(deps.custody, connectionId); if (!tokenResult.ok) { if (tokenResult.stop.kind === "pending") return command; @@ -347,7 +432,20 @@ export async function executeProviderUpdate( } // Merge so a title/description edit cannot wipe provider-sourced attendees. - const content = mergeUpdateContent(event.content, input.content); + let content = mergeUpdateContent(event.content, input.content); + // A "replace" merges the intended membership against the FRESHLY FETCHED + // provider list (current.content), never sync's stored record: the Google + // patch replaces the whole attendees array, and merging against a stale + // stored copy would clobber a concurrent RSVP made between syncs. The + // merged list also lands on the local record at commit, so reads reflect + // the edit before the next provider round-trip. + const intendedAttendees = + input.attendeesEdit === "replace" + ? mergeAttendees(input.content.attendees, current.content.attendees) + : undefined; + if (intendedAttendees) { + content = { ...content, attendees: intendedAttendees }; + } // Almost always "single" (event.recurrence.kind is single here, so // "preserve" resolves to single via intendedSeriesRecurrence's own // fallback) — except a single→series conversion, which writes real rules. @@ -356,7 +454,13 @@ export async function executeProviderUpdate( // Replay: the provider already holds this edit, so confirm at its version // rather than writing again. if ( - matchesIntendedEdit(current, content, input.schedule, intendedRecurrence) + matchesIntendedEdit( + current, + content, + input.schedule, + intendedRecurrence, + intendedAttendees, + ) ) { return commitProviderUpdate( deps, @@ -376,6 +480,7 @@ export async function executeProviderUpdate( schedule: input.schedule, recurrence: intendedRecurrence, invitation: input.invitation, + ...(intendedAttendees ? { attendees: intendedAttendees } : {}), }), ); if (!patchResult.ok) { @@ -474,6 +579,18 @@ export async function executeProviderSeriesUpdate( const providerEventId = master.providerEventId; const intendedRecurrence = intendedSeriesRecurrence(input.recurrence, master); + // Same organizer gate as the single-event path: a non-organizer guest-list + // replace fails typed before any provider call. + if (input.attendeesEdit === "replace") { + const guardFailure = await organizerGuardFailure( + deps, + command, + master, + connectionId, + ); + if (guardFailure) return guardFailure; + } + const tokenResult = await resolveAccessToken(deps.custody, connectionId); if (!tokenResult.ok) { if (tokenResult.stop.kind === "pending") return command; @@ -502,18 +619,34 @@ export async function executeProviderSeriesUpdate( return failCommand(deps, command, "permanentProviderError", connectionId); } - const content = mergeUpdateContent(master.content, input.content); + let content = mergeUpdateContent(master.content, input.content); + // Guest membership merges against the freshly fetched master, mirroring the + // single-event path (see executeProviderUpdate). + const intendedAttendees = + input.attendeesEdit === "replace" + ? mergeAttendees(input.content.attendees, current.content.attendees) + : undefined; + if (intendedAttendees) { + content = { ...content, attendees: intendedAttendees }; + } // Replay: the provider already holds this series edit (rules included), so // confirm at its version rather than writing again. if ( - matchesIntendedEdit(current, content, input.schedule, intendedRecurrence) + matchesIntendedEdit( + current, + content, + input.schedule, + intendedRecurrence, + intendedAttendees, + ) ) { return commitProviderSeriesUpdate( deps, command, master, content, + intendedAttendees, current.providerVersion, now, { @@ -532,6 +665,7 @@ export async function executeProviderSeriesUpdate( schedule: input.schedule, recurrence: intendedRecurrence, invitation: input.invitation, + ...(intendedAttendees ? { attendees: intendedAttendees } : {}), }), ); if (!patchResult.ok) { @@ -545,6 +679,7 @@ export async function executeProviderSeriesUpdate( command, master, content, + intendedAttendees, result.providerVersion, now, { @@ -581,6 +716,10 @@ async function commitProviderSeriesUpdate( command: CommandRecord, master: EventRecord, content: SyncEventContent, + // Present when this edit-all replaced the guest list: the override-align + // patches carry the same merged membership, so a reverted override does not + // keep a stale guest list Google would otherwise leave on it. + intendedAttendees: readonly Attendee[] | undefined, providerVersion: string, now: () => Date, provider: { @@ -635,6 +774,7 @@ async function commitProviderSeriesUpdate( ), recurrence: { kind: "instance" }, invitation: input.invitation, + ...(intendedAttendees ? { attendees: intendedAttendees } : {}), }); }); if (!alignResult.ok) { @@ -754,6 +894,14 @@ export async function executeProviderOccurrenceUpdate( const connectionId = master.connectionId; const seriesProviderEventId = master.providerEventId; + // Guest-list editing is whole-event/whole-series only in v1: a replace on a + // "this" scope has no defined per-occurrence semantics yet, so refuse typed + // rather than silently preserving — dropped intent would read as a + // successful guest edit that never happened. + if (input.attendeesEdit === "replace") { + return failCommand(deps, command, "unsupportedCapability", connectionId); + } + const tokenResult = await resolveAccessToken(deps.custody, connectionId); if (!tokenResult.ok) { if (tokenResult.stop.kind === "pending") return command; @@ -1200,6 +1348,14 @@ export async function executeProviderSeriesFollowingUpdate( return executeProviderSeriesUpdate(deps, command, master, calendar, now); } + // Same v1 rule as the occurrence path: a guest-list replace has no defined + // semantics on a thisAndFollowing split (which guest list would the + // truncated original keep?), so refuse typed rather than silently + // preserving. + if (input.attendeesEdit === "replace") { + return failCommand(deps, command, "unsupportedCapability", connectionId); + } + const tokenResult = await resolveAccessToken(deps.custody, connectionId); if (!tokenResult.ok) { if (tokenResult.stop.kind === "pending") return command; @@ -1353,6 +1509,310 @@ export async function executeProviderSeriesFollowingUpdate( return confirmed ?? command; } +// --------------------------------------------------------------------------- +// RSVP. +// +// An RSVP is not a content edit: it rewrites exactly ONE attendee entry — the +// connection account's own, matched case-insensitively by email — and leaves +// every other entry byte-identical to the freshly fetched provider state. +// Because a Google patch replaces the WHOLE attendees array, the write sends +// the full merged list, and sendUpdates is always "none": answering an +// invitation must never email the guest list. +// --------------------------------------------------------------------------- + +// The caller's own attendee entry, matched case-insensitively by the +// connection's account email. Alias emails not matching is a named wart. +function findSelfAttendee( + attendees: readonly Attendee[], + accountEmail: string, +): Attendee | undefined { + const email = accountEmail.toLowerCase(); + return attendees.find((attendee) => attendee.email.toLowerCase() === email); +} + +// Execute a Compass-initiated rsvp command against the owning provider. +// +// Targeting: scope "all" (and any single event) addresses the event itself — +// for a recurring series that is the SERIES MASTER, so the answer covers +// every occurrence. Scope "this" addresses ONE occurrence: the Google +// instance is resolved via the writer's fetchInstanceAt (the same +// occurrence-id decode update/delete use — an instance id is never +// hand-built here) and IT is patched, leaving the master and every sibling +// instance untouched. +// +// Replay safety: the freshly fetched self entry already holding the intended +// status means a prior attempt landed (or the user answered from another +// client) — confirm at the current provider version without a second write. +// The patch itself is UNCONDITIONAL (no If-Match): any other guest's +// concurrent RSVP bumps the provider version, and RSVP drift must never +// block an RSVP. The fetch→patch window this leaves open is the pack's +// named clobber-window wart, same as attendeesEdit "replace". +// +// Guards, all typed unsupportedCapability: an unverifiable connection +// (missing row / no account email) and a stored attendee list without the +// self entry both fail closed BEFORE any provider call; a fetched list +// without the self entry (removed provider-side since the last pull) fails +// after the fetch, without a write. There is deliberately NO organizer +// guard: the organizer RSVPing their own event is allowed — Google lists +// the organizer as an attendee of their own event. +export async function executeProviderRsvp( + deps: ProviderMutationDeps, + command: CommandRecord, + event: EventRecord, + calendar: ProviderCalendarRecord, + now: () => Date, +): Promise { + if (command.input.kind !== "rsvp") { + throw new Error("executeProviderRsvp requires an rsvp command"); + } + if (!event.connectionId || !event.providerEventId) { + throw new Error("executeProviderRsvp requires a linked event"); + } + const { input } = command; + const connectionId = event.connectionId; + const seriesProviderEventId = event.providerEventId; + + const connection = await deps.connections.findById( + command.tenantId, + command.principalId, + connectionId, + ); + const accountEmail = connection?.account.email ?? null; + if (accountEmail === null) { + return failCommand(deps, command, "unsupportedCapability", connectionId); + } + if (!findSelfAttendee(event.content.attendees, accountEmail)) { + return failCommand(deps, command, "unsupportedCapability", connectionId); + } + + const tokenResult = await resolveAccessToken(deps.custody, connectionId); + if (!tokenResult.ok) { + if (tokenResult.stop.kind === "pending") return command; + return failCommand(deps, command, tokenResult.stop.reason, connectionId); + } + const { accessToken } = tokenResult; + + const perOccurrence = + input.scope === "this" && + input.recurrenceId !== null && + event.recurrence.kind === "seriesMaster"; + + // Fetch the target's current provider state: the master (or single event) + // itself, or the ONE resolved instance for a per-occurrence answer. + const fetchResult = await runProviderWrite(() => + perOccurrence + ? deps.writer.fetchInstanceAt({ + accessToken, + calendarId: calendar.providerCalendarId, + seriesProviderEventId, + originalStartAt: input.recurrenceId as DateTime, + scheduleKind: event.schedule.kind, + }) + : deps.writer.fetchEvent({ + accessToken, + calendarId: calendar.providerCalendarId, + providerEventId: seriesProviderEventId, + }), + ); + if (!fetchResult.ok) { + if (fetchResult.stop.kind === "pending") return command; + return failCommand(deps, command, fetchResult.stop.reason, connectionId); + } + const current = + fetchResult.value?.kind === "event" ? fetchResult.value : null; + // Nothing live to answer: the event (or that one instance) no longer + // exists as a content event at the provider. + if (!current) { + return failCommand(deps, command, "permanentProviderError", connectionId); + } + + const selfIndex = current.content.attendees.findIndex( + (attendee) => attendee.email.toLowerCase() === accountEmail.toLowerCase(), + ); + // Removed from the guest list provider-side since the last pull: there is + // no self entry to rewrite (same typed refusal as the stored-list guard, + // just discovered one step later — after the fetch, before any write). + if (selfIndex === -1) { + return failCommand(deps, command, "unsupportedCapability", connectionId); + } + const currentSelf = current.content.attendees[selfIndex] as Attendee; + const alreadyAnswered = currentSelf.responseStatus === input.responseStatus; + + const intendedAttendees = alreadyAnswered + ? current.content.attendees + : current.content.attendees.map((attendee, index) => + index === selfIndex + ? { ...attendee, responseStatus: input.responseStatus } + : attendee, + ); + + let providerVersion = current.providerVersion; + if (!alreadyAnswered) { + const patchResult = await runProviderWrite(() => + deps.writer.patchEvent({ + accessToken, + calendarId: calendar.providerCalendarId, + providerEventId: current.providerEventId, + // Unconditional on purpose: another guest's concurrent RSVP bumps + // the provider version, and RSVP drift must never block an RSVP. + expectedVersion: null, + content: rsvpEchoContent(current.content), + schedule: current.schedule, + recurrence: rsvpWriteRecurrence(current.recurrence), + // Answering an invitation never emails anyone. + invitation: "none", + attendees: intendedAttendees, + }), + ); + if (!patchResult.ok) { + if (patchResult.stop.kind === "pending") return command; + return failCommand(deps, command, patchResult.stop.reason, connectionId); + } + providerVersion = patchResult.value.providerVersion; + } + + if (perOccurrence) { + return commitProviderOccurrenceRsvp( + deps, + command, + event, + input.recurrenceId as DateTime, + { ...current.content, attendees: intendedAttendees }, + current.schedule, + current.providerEventId, + providerVersion, + now, + ); + } + return commitProviderRsvp( + deps, + command, + event, + intendedAttendees, + providerVersion, + now, + ); +} + +// The non-attendee body an rsvp patch sends. The write port requires a full +// body, so the freshly fetched provider state is echoed back — re-writing +// the provider's own current values is self-describing (mirroring how a +// "preserve" series edit re-writes the current rules) and the only field +// that actually changes is the self entry riding the separate `attendees` +// input. color/colorHex are STRIPPED rather than echoed: colorHex is +// read-only, and a slot color in the body would trigger the writer's +// label-clearing pre-patch (an extra provider round-trip that could clear a +// label Compass never meant to touch) — an omitted color leaves Google's +// color state entirely alone under patch merge-by-key semantics. +function rsvpEchoContent(content: SyncEventContent): SyncEventContent { + const { color: _color, colorHex: _colorHex, ...rest } = content; + return rest; +} + +// The recurrence an rsvp patch re-writes, mapped from the fetched read: a +// series master re-writes its own current rules (harmless, self-describing); +// a resolved instance must OMIT the recurrence key entirely (Google rejects +// one on an instance — see ProviderWriteRecurrence); a single event stays +// single. +function rsvpWriteRecurrence( + current: ProviderEvent["recurrence"], +): ProviderWriteRecurrence { + if (current.kind === "seriesMaster") { + return { kind: "series", rules: current.rules }; + } + if (current.kind === "instance") return { kind: "instance" }; + return { kind: "single" }; +} + +// Commit a confirmed whole-event (or whole-series) rsvp locally: rewrite the +// stored record's attendee list — only that; pulls own every other field — +// bump the provider version, and reproject through reprojectMaster so a +// series' exception instants stay excluded (a cancelled occurrence must not +// be resurrected by an RSVP). A miss from replaceExisting means the event +// vanished mid-flight — leave the command pending rather than confirm +// against a gone event. +async function commitProviderRsvp( + deps: ProviderMutationDeps, + command: CommandRecord, + event: EventRecord, + attendees: readonly Attendee[], + providerVersion: string, + now: () => Date, +): Promise { + const updated: EventRecord = { + ...event, + content: { ...event.content, attendees }, + providerVersion: providerVersion as ProviderEventVersion, + providerUpdatedAt: null, + deliveryState: "confirmed", + updatedAt: now(), + }; + const applied = await deps.events.replaceExisting(updated); + if (!applied) return command; + await reprojectMaster(deps, command, updated, now); + + const confirmed = await deps.commands.updateOutcome( + command.tenantId, + command.principalId, + command._id, + { + state: "confirmed", + providerEventId: event.providerEventId as ProviderEventId, + providerVersion: providerVersion as ProviderEventVersion, + }, + command.attemptCount, + ); + return confirmed ?? command; +} + +// Commit a confirmed per-occurrence rsvp locally: upsert the exception +// carrying the INSTANCE's own provider identity and its fetched content with +// the rewritten self entry (what a pull of that instance would store), +// reproject the master to exclude that instant, then project the exception's +// own occurrence — the same local-commit shape a scope-"this" edit uses, so +// the next backend read reflects the answer before Google round-trips. +async function commitProviderOccurrenceRsvp( + deps: ProviderMutationDeps, + command: CommandRecord, + master: EventRecord, + recurrenceId: DateTime, + content: SyncEventContent, + schedule: EventSchedule, + providerEventId: string, + providerVersion: string, + now: () => Date, +): Promise { + const exception = await deps.events.upsertException( + master, + recurrenceId, + { + content, + schedule, + cancelled: false, + providerIdentity: { + providerEventId: providerEventId as ProviderEventId, + providerVersion: providerVersion as ProviderEventVersion, + }, + }, + now(), + ); + await reprojectMaster(deps, command, master, now); + await reprojectOccurrences(deps.occurrences, exception, now); + + const confirmed = await deps.commands.updateOutcome( + command.tenantId, + command.principalId, + command._id, + { + state: "confirmed", + providerEventId: providerEventId as ProviderEventId, + providerVersion: providerVersion as ProviderEventVersion, + }, + command.attemptCount, + ); + return confirmed ?? command; +} + // The provider recurrence a series edit-all writes. "series" sets new rules; // "single" removes recurrence (converting the series to one event); "preserve" // re-writes the master's current rules unchanged (harmless, keeps the write @@ -1389,21 +1849,28 @@ function storedSeriesRecurrence( // Whether the provider's current event already carries this command's intended // edit — the signal that a prior attempt landed and this is a safe replay. // Compares ONLY the fields a patch actually writes (title, description, -// location, color, schedule, recurrence). organizer/attendees/conference are -// read-reflected, not written by the provider adapter, so they drift -// independently (e.g. an attendee RSVPs) — comparing them would turn a landed -// edit into a false miss, then a stale-version patch, then a spurious -// versionConflict on a write that already succeeded. Recurrence IS written (a -// series edit-all changes the rules), so it must be compared: a rules-only edit -// leaves content and schedule identical, and without this a false replay would -// confirm the command without ever writing the new rules. Used only to detect a -// replay, so a false negative on the compared fields is still safe (it falls -// through to the conditional patch). +// location, color, schedule, recurrence — and the guest membership, but only +// when the command intends a guest-list replace). organizer/conference are +// read-reflected, never written by the provider adapter, so they drift +// independently — comparing them would turn a landed edit into a false miss, +// then a stale-version patch, then a spurious versionConflict on a write that +// already succeeded. Attendees are the same by default (an attendee RSVPs +// whenever they like), so they stay out of the comparison for every +// "preserve"/legacy command; a "replace" command DOES write them, so its +// replay check compares membership — as email sets, order-insensitive and +// responseStatus-ignored, because RSVP drift must never block replay (see +// attendeesMatchIntent). Recurrence IS written (a series edit-all changes the +// rules), so it must be compared: a rules-only edit leaves content and +// schedule identical, and without this a false replay would confirm the +// command without ever writing the new rules. Used only to detect a replay, +// so a false negative on the compared fields is still safe (it falls through +// to the conditional patch). function matchesIntendedEdit( current: ProviderEvent, content: SyncEventContent, schedule: EventSchedule, recurrence: ProviderWriteRecurrence, + intendedAttendees?: readonly Attendee[], ): boolean { // Null on the command means "no color"; treat it like an absent color on // the provider read so a clear that already landed counts as a replay. @@ -1414,7 +1881,31 @@ function matchesIntendedEdit( current.content.location === content.location && current.content.color === intendedColor && deepEqual(current.schedule, schedule) && - recurrenceMatches(current.recurrence, recurrence) + recurrenceMatches(current.recurrence, recurrence) && + attendeesMatchIntent(current.content.attendees, intendedAttendees) + ); +} + +// Membership comparison for the replay check, entered ONLY when the command +// intends a guest-list replace (undefined = attendees are not part of this +// write; always a match). Email sets, case-insensitive, order-insensitive, +// responseStatus-ignored: a guest RSVPing (or Google reordering the list) +// between a landed patch and its retry must still read as a replay — RSVP +// drift never blocks replay. +function attendeesMatchIntent( + current: readonly Attendee[], + intended: readonly Attendee[] | undefined, +): boolean { + if (intended === undefined) return true; + const currentEmails = new Set( + current.map(({ email }) => email.toLowerCase()), + ); + const intendedEmails = new Set( + intended.map(({ email }) => email.toLowerCase()), + ); + return ( + currentEmails.size === intendedEmails.size && + [...intendedEmails].every((email) => currentEmails.has(email)) ); } diff --git a/packages/sync/src/domain/stale-command-retry.service.db.test.ts b/packages/sync/src/domain/stale-command-retry.service.db.test.ts index f4d60fb2df..b17d8d5548 100644 --- a/packages/sync/src/domain/stale-command-retry.service.db.test.ts +++ b/packages/sync/src/domain/stale-command-retry.service.db.test.ts @@ -237,6 +237,120 @@ describe("retryStaleCommands", () => { expect(await events.findById(tenantId, principalId, event._id)).toBeNull(); }); + it("finishes an rsvp that failed transiently on the first attempt", async () => { + // WP-07: an rsvp runs inline from the HTTP request like update/delete — + // a transient provider blip leaves it pending, and only this sweep ever + // revisits it, so "rsvp" must be in RETRYABLE_KINDS. + const tenantId = objectId() as TenantId; + const principalId = objectId() as PrincipalId; + const connectionId = objectId() as ConnectionId; + const calendar = await seedProviderCalendar(calendars, { + tenantId, + principalId, + connectionId, + }); + const eventId = objectId() as EventId; + const self = { + email: "self@example.com", + displayName: null, + responseStatus: "accepted" as const, + }; + await events.put({ + _id: eventId, + tenantId, + principalId, + origin: "compass", + calendarId: calendar._id, + clientEventId: null, + connectionId, + providerEventId: "g-evt-1" as never, + providerVersion: "etag-1" as never, + providerUpdatedAt: null, + deliveryState: "confirmed", + providerMetadata: null, + content: { + title: "Invited", + description: "", + location: null, + organizer: { email: "organizer@example.com", displayName: null }, + attendees: [self], + conference: null, + }, + schedule, + recurrence: { kind: "single" }, + lifecycleState: "active", + generation: 0, + createdAt: now(), + updatedAt: now(), + confirmedAt: now(), + } as never); + const { record: command } = await commands.submit({ + tenantId, + principalId, + idempotencyKey: `idem-${objectId()}` as IdempotencyKey, + eventId, + input: { + kind: "rsvp", + responseStatus: "declined", + scope: "all", + recurrenceId: null, + } as never, + expectedVersion: null, + }); + expect(command.outcome.state).toBe("pending"); + const patchCalls: unknown[] = []; + const writer: ProviderEventWriter = { + createEvent: async () => { + throw new Error("unused"); + }, + deleteEvent: async () => { + throw new Error("unused"); + }, + patchEvent: async (input) => { + patchCalls.push(input); + return { providerEventId: "g-evt-1", providerVersion: "etag-2" }; + }, + fetchEvent: async () => ({ + kind: "event", + providerEventId: "g-evt-1", + providerVersion: "etag-1", + providerUpdatedAt: null, + content: { + title: "Invited", + description: "", + location: null, + organizer: { email: "organizer@example.com", displayName: null }, + attendees: [self], + conference: null, + }, + schedule, + busy: true, + recurrence: { kind: "single" }, + }), + fetchInstanceAt: async () => null, + }; + + const result = await retryStaleCommands( + { + ...baseDeps(writer), + connections: { + findById: async () => ({ account: { email: self.email } }), + }, + }, + before(), + now, + ); + + expect(result).toEqual({ attempted: 1, stillStale: 0 }); + expect(patchCalls).toHaveLength(1); + const stored = await commands.findById(tenantId, principalId, command._id); + expect(stored?.outcome.state).toBe("confirmed"); + const event = await events.findById(tenantId, principalId, eventId); + expect(event?.content.attendees).toEqual([ + { ...self, responseStatus: "declined" }, + ]); + }); + it("finishes a create that failed transiently on the first attempt", async () => { const { tenantId, principalId, eventId, command } = await seedStuckCreate(); expect(command.outcome.state).toBe("pending"); diff --git a/packages/sync/src/domain/stale-command-retry.service.ts b/packages/sync/src/domain/stale-command-retry.service.ts index 4ca822133c..0302feba8e 100644 --- a/packages/sync/src/domain/stale-command-retry.service.ts +++ b/packages/sync/src/domain/stale-command-retry.service.ts @@ -32,6 +32,7 @@ const RETRYABLE_KINDS: readonly SyncCommandInput["kind"][] = [ "create", "update", "delete", + "rsvp", ]; // The self-heal sweep for commands stuck nonterminal (pending/applying/ diff --git a/packages/sync/src/providers/google/google-auth.adapter.test.ts b/packages/sync/src/providers/google/google-auth.adapter.test.ts index e17b256e50..6ec96d49d3 100644 --- a/packages/sync/src/providers/google/google-auth.adapter.test.ts +++ b/packages/sync/src/providers/google/google-auth.adapter.test.ts @@ -1,5 +1,8 @@ import { type Credentials, type TokenPayload } from "google-auth-library"; -import { GOOGLE_SCOPES } from "@sync/providers/google/google.scopes"; +import { + CONTACTS_FEATURE_SCOPES, + GOOGLE_SCOPES, +} from "@sync/providers/google/google.scopes"; import { GoogleAuthAdapter, type GoogleOAuthClient, @@ -139,6 +142,44 @@ describe("GoogleAuthAdapter", () => { expect(client.authUrlOptions[0].prompt).toBe("select_account consent"); expect(client.authUrlOptions[0].access_type).toBe("offline"); }); + + it("appends optional feature scopes after the base scopes", () => { + const client = new FakeGoogleClient(); + const { adapter } = adapterWith(client); + + adapter.buildAuthorizationUrl({ + state: "opaque-state", + redirectUri: "https://staging.example.com/sync/google", + extraScopes: CONTACTS_FEATURE_SCOPES, + }); + + expect(client.authUrlOptions[0].scope).toEqual([ + ...GOOGLE_SCOPES, + "https://www.googleapis.com/auth/contacts.readonly", + "https://www.googleapis.com/auth/contacts.other.readonly", + ]); + }); + + it("builds a byte-identical consent URL when no extra scopes are asked for", () => { + const client = new FakeGoogleClient(); + const { adapter } = adapterWith(client); + + const withoutField = adapter.buildAuthorizationUrl({ + state: "opaque-state", + redirectUri: "https://staging.example.com/sync/google", + }); + const emptyList = adapter.buildAuthorizationUrl({ + state: "opaque-state", + redirectUri: "https://staging.example.com/sync/google", + extraScopes: [], + }); + + // The optional-scope rollout must not change what a plain connect asks + // Google for — same URL, base scopes only. + expect(emptyList).toBe(withoutField); + expect(client.authUrlOptions[0].scope).toEqual([...GOOGLE_SCOPES]); + expect(client.authUrlOptions[1].scope).toEqual([...GOOGLE_SCOPES]); + }); }); describe("exchangeAuthorizationCode", () => { diff --git a/packages/sync/src/providers/google/google-auth.adapter.ts b/packages/sync/src/providers/google/google-auth.adapter.ts index 3df35399b0..b72a9734e7 100644 --- a/packages/sync/src/providers/google/google-auth.adapter.ts +++ b/packages/sync/src/providers/google/google-auth.adapter.ts @@ -65,6 +65,7 @@ export class GoogleAuthAdapter implements ProviderAuthAdapter { state: string; redirectUri: string; selectAccount?: boolean; + extraScopes?: readonly string[]; }): string { return this.#makeClient(input.redirectUri).generateAuthUrl({ // offline + consent guarantees a refresh token even on re-authorization, @@ -75,7 +76,9 @@ export class GoogleAuthAdapter implements ProviderAuthAdapter { access_type: "offline", prompt: input.selectAccount ? "select_account consent" : "consent", include_granted_scopes: true, - scope: [...GOOGLE_SCOPES], + // Base scopes always; optional feature scopes (e.g. contacts) only when + // the caller asked for them, so a plain connect URL stays byte-identical. + scope: [...GOOGLE_SCOPES, ...(input.extraScopes ?? [])], state: input.state, }); } diff --git a/packages/sync/src/providers/google/google-capabilities.test.ts b/packages/sync/src/providers/google/google-capabilities.test.ts index 5c0128d70e..93a501699a 100644 --- a/packages/sync/src/providers/google/google-capabilities.test.ts +++ b/packages/sync/src/providers/google/google-capabilities.test.ts @@ -3,6 +3,9 @@ import { googleCapabilitiesFromScopes } from "@sync/providers/google/google-capa const EMAIL = "https://www.googleapis.com/auth/userinfo.email"; const READONLY = "https://www.googleapis.com/auth/calendar.readonly"; const EVENTS = "https://www.googleapis.com/auth/calendar.events"; +const CONTACTS = "https://www.googleapis.com/auth/contacts.readonly"; +const CONTACTS_OTHER = + "https://www.googleapis.com/auth/contacts.other.readonly"; describe("googleCapabilitiesFromScopes", () => { it("grants full read+write capabilities for the events scope", () => { @@ -43,4 +46,31 @@ describe("googleCapabilitiesFromScopes", () => { expect(new Set(caps).size).toBe(caps.length); expect(caps).toContain("writeEvents"); }); + + // Contacts scopes are optional and independently declinable, so EITHER one + // is enough for the capability — partial grants are a normal outcome of the + // consent screen, not an error. + it.each([ + [CONTACTS], + [CONTACTS_OTHER], + [CONTACTS, CONTACTS_OTHER], + ])("grants suggestContacts from a contacts grant (%#)", (...scopes) => { + expect(googleCapabilitiesFromScopes([EMAIL, EVENTS, ...scopes])).toContain( + "suggestContacts", + ); + }); + + it("does not grant suggestContacts without a contacts scope", () => { + expect( + googleCapabilitiesFromScopes([EMAIL, READONLY, EVENTS]), + ).not.toContain("suggestContacts"); + }); + + it("grants suggestContacts independently of the calendar scopes", () => { + // A degenerate grant with contacts but no calendar still reports the + // contacts capability; connect-time gating on readEvents is separate. + expect(googleCapabilitiesFromScopes([CONTACTS])).toEqual([ + "suggestContacts", + ]); + }); }); diff --git a/packages/sync/src/providers/google/google-capabilities.ts b/packages/sync/src/providers/google/google-capabilities.ts index 796203e85d..ab6a6c0582 100644 --- a/packages/sync/src/providers/google/google-capabilities.ts +++ b/packages/sync/src/providers/google/google-capabilities.ts @@ -2,6 +2,8 @@ import { type ProviderCapability } from "@core/types/sync/identity.contracts"; import { GOOGLE_SCOPE_CALENDAR_EVENTS as CALENDAR_EVENTS, GOOGLE_SCOPE_CALENDAR_READONLY as CALENDAR_READONLY, + GOOGLE_SCOPE_CONTACTS_OTHER_READONLY as CONTACTS_OTHER_READONLY, + GOOGLE_SCOPE_CONTACTS_READONLY as CONTACTS_READONLY, } from "@sync/providers/google/google.scopes"; // Derive connection capabilities from the scopes Google actually granted (which @@ -27,6 +29,12 @@ export function googleCapabilitiesFromScopes( if (granted.has(CALENDAR_EVENTS)) { capabilities.push("writeEvents", "inviteAttendees"); } + // Contacts scopes are optional and independently declinable; either one is + // enough to serve suggestions from the surface it covers (the adapter only + // queries the People surfaces the granted scopes actually allow). + if (granted.has(CONTACTS_READONLY) || granted.has(CONTACTS_OTHER_READONLY)) { + capabilities.push("suggestContacts"); + } return capabilities; } diff --git a/packages/sync/src/providers/google/google-event-writer.adapter.test.ts b/packages/sync/src/providers/google/google-event-writer.adapter.test.ts index ad11e8877c..87f7b2f403 100644 --- a/packages/sync/src/providers/google/google-event-writer.adapter.test.ts +++ b/packages/sync/src/providers/google/google-event-writer.adapter.test.ts @@ -252,6 +252,126 @@ describe("GoogleEventWriter", () => { ); }); + it("emits intended attendees on create, omitting a null displayName", async () => { + const api = new FakeEventsApi(); + const { writer } = writerWith(api); + + await writer.createEvent({ + ...baseCreate, + invitation: "all", + attendees: [ + { + email: "kept@example.com", + displayName: "Kept", + responseStatus: "accepted", + }, + { + email: "new@example.com", + displayName: null, + responseStatus: "needsAction", + }, + ], + }); + + expect(api.calls.insert[0].sendUpdates).toBe("all"); + expect(api.calls.insert[0].requestBody.attendees).toEqual([ + { + email: "kept@example.com", + displayName: "Kept", + responseStatus: "accepted", + }, + { email: "new@example.com", responseStatus: "needsAction" }, + ]); + }); + + it("replaces the whole attendees array on a patch that intends a guest edit", async () => { + const api = new FakeEventsApi(); + const { writer } = writerWith(api); + + await writer.patchEvent({ + ...basePatch, + invitation: "externalOnly", + attendees: [ + { + email: "solo@example.com", + displayName: null, + responseStatus: "tentative", + }, + ], + }); + + // The exact body, sendUpdates included: retained statuses echo back, and + // no other attendee-adjacent key (organizer, conferenceData) appears. + expect(api.calls.patch[0].sendUpdates).toBe("externalOnly"); + expect(api.calls.patch[0].requestBody).toEqual({ + summary: "Title", + description: "Desc", + location: null, + start: { + date: null, + dateTime: "2025-01-15T09:00:00-05:00", + timeZone: "America/New_York", + }, + end: { + date: null, + dateTime: "2025-01-15T10:00:00-05:00", + timeZone: "America/New_York", + }, + recurrence: null, + attendees: [{ email: "solo@example.com", responseStatus: "tentative" }], + }); + }); + + it("sends an explicit empty attendees array to remove every guest", async () => { + const api = new FakeEventsApi(); + const { writer } = writerWith(api); + + await writer.patchEvent({ ...basePatch, attendees: [] }); + + expect(api.calls.patch[0].requestBody.attendees).toEqual([]); + }); + + it("omits the attendees key when the write does not intend a guest edit", async () => { + // The preserve/legacy regression: the body is byte-identical to before + // attendee writes existed, so patch merge-by-key leaves Google's own + // guest list untouched. content.attendees never leaks into the body. + const api = new FakeEventsApi(); + const { writer } = writerWith(api); + + await writer.createEvent({ + ...baseCreate, + content: content({ + attendees: [ + { + email: "read-reflected@example.com", + displayName: null, + responseStatus: "accepted", + }, + ], + } as Partial), + }); + await writer.patchEvent(basePatch); + + expect(api.calls.insert[0].requestBody).toEqual({ + id: "abc12deadbeef00000000000", + summary: "Title", + description: "Desc", + location: null, + start: { + date: null, + dateTime: "2025-01-15T09:00:00-05:00", + timeZone: "America/New_York", + }, + end: { + date: null, + dateTime: "2025-01-15T10:00:00-05:00", + timeZone: "America/New_York", + }, + recurrence: null, + }); + expect(api.calls.patch[0].requestBody).not.toHaveProperty("attendees"); + }); + it("conditions a patch on the expected version via If-Match", async () => { const api = new FakeEventsApi(); const { writer } = writerWith(api); diff --git a/packages/sync/src/providers/google/google-event-writer.adapter.ts b/packages/sync/src/providers/google/google-event-writer.adapter.ts index addc208930..460f9739ea 100644 --- a/packages/sync/src/providers/google/google-event-writer.adapter.ts +++ b/packages/sync/src/providers/google/google-event-writer.adapter.ts @@ -1,6 +1,7 @@ import { calendar, type calendar_v3 } from "@googleapis/calendar"; import { OAuth2Client } from "google-auth-library"; import { type EventSchedule } from "@core/types/event.contracts"; +import { type Attendee } from "@core/types/event-attendance.contracts"; import { type gCalendar, type gSchema$Event, @@ -149,7 +150,12 @@ export class GoogleEventWriter implements ProviderEventWriter { const api = this.#makeApi(input.accessToken); const requestBody: gSchema$Event = { id: input.providerEventId, - ...toGoogleBody(input.content, input.schedule, input.recurrence), + ...toGoogleBody( + input.content, + input.schedule, + input.recurrence, + input.attendees, + ), }; try { @@ -205,6 +211,7 @@ export class GoogleEventWriter implements ProviderEventWriter { input.content, input.schedule, input.recurrence, + input.attendees, ), sendUpdates: toSendUpdates(input.invitation), ifMatch, @@ -299,17 +306,23 @@ function toResult(event: gSchema$Event): ProviderWriteResult { // other schedule kind's keys behind makes Google reject a start holding both a // date and a dateTime. // -// organizer, attendees, and conference are deliberately NOT written. Compass is -// not authoritative for a provider event's guest list (organizer is fixed by -// the provider at creation, and attendee/conference management is a separate -// concern), so those are read-reflected only. Patch's merge-by-key semantics -// leave the provider's own values untouched, which is the intended behavior. -// sendUpdates still notifies existing attendees of the title/time changes we do -// write. +// organizer and conference are deliberately NOT written. Compass is not +// authoritative for them (the organizer is fixed by the provider at creation, +// and conference management is a separate concern), so those are +// read-reflected only. Attendees are written ONLY when the write intends a +// guest-list edit — `attendees` present, produced upstream from an +// attendeesEdit "replace" command by merging against freshly fetched provider +// state — because a Google write replaces the WHOLE attendees array. When +// `attendees` is absent (every "preserve"/legacy command), the key is omitted +// and patch's merge-by-key semantics leave the provider's own guest list +// untouched, exactly as before. content.attendees itself is read-reflected +// state and never reaches the body. sendUpdates still notifies existing +// attendees of the changes we do write. function toGoogleBody( content: SyncEventContent, schedule: EventSchedule, recurrence: ProviderWriteRecurrence, + attendees?: readonly Attendee[], ): gSchema$Event { return { summary: content.title, @@ -318,6 +331,25 @@ function toGoogleBody( ...googleColorIdFields(content.color), ...scheduleFields(schedule), ...recurrenceField(recurrence), + ...attendeesField(attendees), + }; +} + +// Present (even empty) replaces Google's whole attendees array; absent omits +// the key so merge-by-key leaves the provider's list alone. Retained entries +// carry the provider's own responseStatus back, so a guest-list write never +// resets anyone's RSVP; displayName is omitted when unknown rather than sent +// as null. +function attendeesField( + attendees: readonly Attendee[] | undefined, +): Partial> { + if (!attendees) return {}; + return { + attendees: attendees.map(({ email, displayName, responseStatus }) => ({ + email, + responseStatus, + ...(displayName === null ? {} : { displayName }), + })), }; } diff --git a/packages/sync/src/providers/google/google-people.adapter.test.ts b/packages/sync/src/providers/google/google-people.adapter.test.ts new file mode 100644 index 0000000000..b4e8153cf1 --- /dev/null +++ b/packages/sync/src/providers/google/google-people.adapter.test.ts @@ -0,0 +1,318 @@ +import { + GooglePeopleAdapter, + type GooglePeopleApi, + type GooglePeopleSearchPage, +} from "@sync/providers/google/google-people.adapter"; +import { ContactsSearchError } from "@sync/providers/provider-contacts.port"; +import { findSafetyCanaryHit } from "@sync/safety/safety-canary"; +import { describe, expect, it } from "bun:test"; + +const page = ( + ...people: Array<{ + emails: Array<{ value: string; primary?: boolean }>; + name?: string; + }> +): GooglePeopleSearchPage => ({ + results: people.map((person) => ({ + person: { + names: person.name === undefined ? [] : [{ displayName: person.name }], + emailAddresses: person.emails.map(({ value, primary }) => ({ + value, + metadata: primary === undefined ? null : { primary }, + })), + }, + })), +}); + +// Scripted People API: records which surfaces were queried and with what, +// then serves fixed pages (or throws a scripted error). +class FakePeopleApi implements GooglePeopleApi { + contactsCalls: Array<{ query: string; pageSize: number; readMask: string }> = + []; + otherCalls: Array<{ query: string; pageSize: number; readMask: string }> = []; + contactsPage: GooglePeopleSearchPage = { results: [] }; + otherPage: GooglePeopleSearchPage = { results: [] }; + error?: unknown; + + async searchContacts(params: { + query: string; + pageSize: number; + readMask: string; + }): Promise { + this.contactsCalls.push(params); + if (this.error) throw this.error; + return this.contactsPage; + } + + async searchOtherContacts(params: { + query: string; + pageSize: number; + readMask: string; + }): Promise { + this.otherCalls.push(params); + if (this.error) throw this.error; + return this.otherPage; + } +} + +const adapterWith = (api: FakePeopleApi) => { + const tokens: string[] = []; + const adapter = new GooglePeopleAdapter((accessToken) => { + tokens.push(accessToken); + return api; + }); + return { adapter, tokens }; +}; + +const bothSources = { contacts: true, otherContacts: true }; + +describe("GooglePeopleAdapter", () => { + it("queries both surfaces when both scopes were granted and merges the results", async () => { + const api = new FakePeopleApi(); + api.contactsPage = page({ + emails: [{ value: "alice@example.com" }], + name: "Alice Doe", + }); + api.otherPage = page({ emails: [{ value: "albert@example.com" }] }); + const { adapter, tokens } = adapterWith(api); + + const suggestions = await adapter.searchContacts({ + accessToken: "short-lived-token", + query: "al", + sources: bothSources, + }); + + expect(tokens).toEqual(["short-lived-token"]); + expect(api.contactsCalls).toHaveLength(1); + expect(api.otherCalls).toHaveLength(1); + // Only the fields the suggestion needs are requested from Google. + expect(api.contactsCalls[0]).toEqual({ + query: "al", + pageSize: 10, + readMask: "names,emailAddresses", + }); + expect(suggestions).toEqual([ + { email: "alice@example.com", displayName: "Alice Doe" }, + { email: "albert@example.com", displayName: null }, + ]); + }); + + it("queries only the surface the granted scope allows", async () => { + const api = new FakePeopleApi(); + api.otherPage = page({ emails: [{ value: "other@example.com" }] }); + const { adapter } = adapterWith(api); + + const suggestions = await adapter.searchContacts({ + accessToken: "token", + query: "ot", + sources: { contacts: false, otherContacts: true }, + }); + + // contacts.readonly was not granted: people.searchContacts is never hit. + expect(api.contactsCalls).toHaveLength(0); + expect(api.otherCalls).toHaveLength(1); + expect(suggestions).toEqual([ + { email: "other@example.com", displayName: null }, + ]); + }); + + it("makes no call at all when no source is allowed", async () => { + const api = new FakePeopleApi(); + const { adapter, tokens } = adapterWith(api); + + const suggestions = await adapter.searchContacts({ + accessToken: "token", + query: "al", + sources: { contacts: false, otherContacts: false }, + }); + + expect(suggestions).toEqual([]); + expect(tokens).toEqual([]); + expect(api.contactsCalls).toHaveLength(0); + expect(api.otherCalls).toHaveLength(0); + }); + + it("ranks prefix matches first and de-duplicates by email across surfaces", async () => { + const api = new FakePeopleApi(); + api.contactsPage = page( + { emails: [{ value: "zoe@example.com" }], name: "Zoe Alberts" }, + { emails: [{ value: "team@example.com" }], name: "Team" }, + { emails: [{ value: "al@example.com" }], name: "Al" }, + ); + api.otherPage = page( + // Duplicate of a saved contact (different case): dropped. + { emails: [{ value: "AL@example.com" }] }, + { emails: [{ value: "alfred@example.com" }] }, + ); + const { adapter } = adapterWith(api); + + const suggestions = await adapter.searchContacts({ + accessToken: "token", + query: "al", + sources: bothSources, + }); + + expect(suggestions).toEqual([ + // Prefix matches (email or name) first, saved contacts before other. + { email: "al@example.com", displayName: "Al" }, + { email: "alfred@example.com", displayName: null }, + // Name "Zoe Alberts" only CONTAINS the query. + { email: "zoe@example.com", displayName: "Zoe Alberts" }, + // No match text at all ranks last (the provider matched it its own way). + { email: "team@example.com", displayName: "Team" }, + ]); + }); + + it("prefers the primary email and name, and drops unusable entries", async () => { + const api = new FakePeopleApi(); + api.contactsPage = { + results: [ + { + person: { + names: [ + { displayName: "Secondary Name" }, + { displayName: "Primary Name", metadata: { primary: true } }, + ], + emailAddresses: [ + { value: "secondary@example.com" }, + { value: "primary@example.com", metadata: { primary: true } }, + { value: " " }, + ], + }, + }, + { person: { names: [], emailAddresses: [] } }, + { person: null }, + ], + }; + const { adapter } = adapterWith(api); + + const suggestions = await adapter.searchContacts({ + accessToken: "token", + query: "pr", + sources: { contacts: true, otherContacts: false }, + }); + + expect(suggestions).toEqual([ + { email: "primary@example.com", displayName: "Primary Name" }, + { email: "secondary@example.com", displayName: "Primary Name" }, + ]); + }); + + it("caps the merged list at the contract's maximum", async () => { + const api = new FakePeopleApi(); + api.contactsPage = page( + ...Array.from({ length: 9 }, (_, i) => ({ + emails: [{ value: `saved-${i}@example.com` }], + })), + ); + api.otherPage = page( + ...Array.from({ length: 9 }, (_, i) => ({ + emails: [{ value: `other-${i}@example.com` }], + })), + ); + const { adapter } = adapterWith(api); + + const suggestions = await adapter.searchContacts({ + accessToken: "token", + query: "example", + sources: bothSources, + }); + + expect(suggestions).toHaveLength(10); + }); + + it("maps a 429 to a typed retryable rateLimited error", async () => { + const api = new FakePeopleApi(); + api.error = Object.assign(new Error("Quota exceeded"), { + response: { status: 429 }, + }); + const { adapter } = adapterWith(api); + + const promise = adapter.searchContacts({ + accessToken: "token", + query: "al", + sources: bothSources, + }); + + await expect(promise).rejects.toBeInstanceOf(ContactsSearchError); + await expect(promise).rejects.toMatchObject({ reason: "rateLimited" }); + }); + + it("maps a 403 quota reason to rateLimited and a plain 403 to unauthorized", async () => { + const quota = new FakePeopleApi(); + quota.error = Object.assign(new Error("Rate limited"), { + response: { + status: 403, + data: { error: { errors: [{ reason: "rateLimitExceeded" }] } }, + }, + }); + const denied = new FakePeopleApi(); + denied.error = Object.assign(new Error("Insufficient scopes"), { + response: { status: 403 }, + }); + + await expect( + adapterWith(quota).adapter.searchContacts({ + accessToken: "token", + query: "al", + sources: bothSources, + }), + ).rejects.toMatchObject({ reason: "rateLimited" }); + await expect( + adapterWith(denied).adapter.searchContacts({ + accessToken: "token", + query: "al", + sources: bothSources, + }), + ).rejects.toMatchObject({ reason: "unauthorized" }); + }); + + it("never carries contact data or the token in a search error's cause chain", async () => { + const api = new FakePeopleApi(); + // A gaxios-shaped failure whose request/response carry contact data and + // the bearer token — none of it may survive into the thrown error. + api.error = Object.assign(new Error("Internal error"), { + response: { + status: 500, + data: { + results: [ + { + person: { + emailAddresses: [{ value: "leak@example.com" }], + names: [{ displayName: "Leaky Person" }], + }, + }, + ], + }, + }, + config: { + url: "https://people.googleapis.com/v1/people:searchContacts?query=leak", + headers: { Authorization: "Bearer secret-token" }, + }, + }); + const { adapter } = adapterWith(api); + + let thrown: unknown; + try { + await adapter.searchContacts({ + accessToken: "token", + query: "leak", + sources: bothSources, + }); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(ContactsSearchError); + const error = thrown as ContactsSearchError; + expect(error.reason).toBe("searchFailed"); + const serialized = { + message: error.message, + cause: error.cause instanceof Error ? error.cause.message : error.cause, + stackedCause: error.cause, + }; + expect(findSafetyCanaryHit(serialized)).toBeNull(); + expect(JSON.stringify(serialized)).not.toContain("leak@example.com"); + expect(JSON.stringify(serialized)).not.toContain("secret-token"); + }); +}); diff --git a/packages/sync/src/providers/google/google-people.adapter.ts b/packages/sync/src/providers/google/google-people.adapter.ts new file mode 100644 index 0000000000..dbcea98338 --- /dev/null +++ b/packages/sync/src/providers/google/google-people.adapter.ts @@ -0,0 +1,242 @@ +import { people } from "@googleapis/people"; +import { OAuth2Client } from "google-auth-library"; +import { + CONTACT_SUGGESTION_MAX_RESULTS, + type ContactSuggestion, +} from "@core/types/contact.contracts"; +import { + googleErrorReasons, + googleFailureCause, + googleStatus, +} from "@sync/providers/google/google-error"; +import { GOOGLE_REQUEST_TIMEOUT_MS } from "@sync/providers/google/google-http.constants"; +import { + type ContactsPort, + ContactsSearchError, + type ContactsSearchInput, +} from "@sync/providers/provider-contacts.port"; + +// One People search match, narrowed to the two fields suggestions are allowed +// to carry. Everything else a Person holds (photos, phones, metadata) is +// dropped at this boundary and never enters Sync. +export interface GooglePeoplePersonMatch { + readonly person?: { + readonly names?: ReadonlyArray<{ + readonly displayName?: string | null; + readonly metadata?: { readonly primary?: boolean | null } | null; + }> | null; + readonly emailAddresses?: ReadonlyArray<{ + readonly value?: string | null; + readonly metadata?: { readonly primary?: boolean | null } | null; + }> | null; + } | null; +} + +export interface GooglePeopleSearchPage { + readonly results: readonly GooglePeoplePersonMatch[]; +} + +// The two People calls the adapter makes, one per contacts surface. Depending +// on this narrow interface (not the concrete googleapis client) lets tests +// supply scripted results without a network round-trip or module mocking. +export interface GooglePeopleApi { + // Saved contacts (`people.searchContacts`, requires contacts.readonly). + searchContacts(params: { + query: string; + pageSize: number; + readMask: string; + }): Promise; + // Interacted-with addresses (`otherContacts.search`, requires + // contacts.other.readonly). + searchOtherContacts(params: { + query: string; + pageSize: number; + readMask: string; + }): Promise; +} + +// Built per-request from a short-lived access token minted by credential +// custody; the token is set as the OAuth client's credential, never logged. +// Mirrors GoogleOAuthClientFactory / GoogleEventListApiFactory. +export type GooglePeopleApiFactory = (accessToken: string) => GooglePeopleApi; + +// Only the fields the suggestion shape uses — asking for more would pull +// contact data Sync has no business holding. +const PEOPLE_READ_MASK = "names,emailAddresses"; + +const defaultApiFactory: GooglePeopleApiFactory = (accessToken) => { + const auth = new OAuth2Client(); + auth.setCredentials({ access_token: accessToken }); + const client = people({ + version: "v1", + auth, + timeout: GOOGLE_REQUEST_TIMEOUT_MS, + }); + return { + async searchContacts({ query, pageSize, readMask }) { + const { data } = await client.people.searchContacts({ + query, + pageSize, + readMask, + }); + return { results: data.results ?? [] }; + }, + async searchOtherContacts({ query, pageSize, readMask }) { + const { data } = await client.otherContacts.search({ + query, + pageSize, + readMask, + }); + return { results: data.results ?? [] }; + }, + }; +}; + +// Google implementation of the contacts port. Queries ONLY the People surfaces +// the caller's granted scopes allow, merges both result sets, ranks them by +// how directly they match the typed prefix, and de-duplicates by email. The +// output carries email + displayName and nothing else. +export class GooglePeopleAdapter implements ContactsPort { + #makeApi: GooglePeopleApiFactory; + + constructor(makeApi: GooglePeopleApiFactory = defaultApiFactory) { + this.#makeApi = makeApi; + } + + async searchContacts( + input: ContactsSearchInput, + ): Promise { + // Nothing granted, nothing to query — don't even build a client. + if (!input.sources.contacts && !input.sources.otherContacts) return []; + + const api = this.#makeApi(input.accessToken); + const params = { + query: input.query, + pageSize: CONTACT_SUGGESTION_MAX_RESULTS, + readMask: PEOPLE_READ_MASK, + }; + + // Saved contacts first: they are the stronger signal, and rankSuggestions + // preserves this order among equal ranks. + const calls: Array> = []; + if (input.sources.contacts) calls.push(api.searchContacts(params)); + if (input.sources.otherContacts) + calls.push(api.searchOtherContacts(params)); + + let pages: GooglePeopleSearchPage[]; + try { + pages = await Promise.all(calls); + } catch (error) { + throw toContactsSearchError(error); + } + + const candidates = pages.flatMap((page) => + page.results.flatMap((match) => toSuggestions(match)), + ); + return rankSuggestions(candidates, input.query).slice( + 0, + CONTACT_SUGGESTION_MAX_RESULTS, + ); + } +} + +// Map one matched person to suggestion candidates — one per usable email +// address, primary first, each carrying the person's primary display name. +// Anything unusable (no email, over the contract's bounds) is dropped, not +// erred: a malformed contact must not break the whole suggestion list. +function toSuggestions(match: GooglePeoplePersonMatch): ContactSuggestion[] { + const person = match.person; + if (!person) return []; + + const names = person.names ?? []; + const rawName = ( + names.find((name) => name.metadata?.primary)?.displayName ?? + names[0]?.displayName ?? + "" + ).trim(); + const displayName = + rawName.length > 0 && rawName.length <= 256 ? rawName : null; + + const addresses = [...(person.emailAddresses ?? [])].sort( + (a, b) => + Number(b.metadata?.primary ?? false) - + Number(a.metadata?.primary ?? false), + ); + const suggestions: ContactSuggestion[] = []; + for (const address of addresses) { + const email = (address.value ?? "").trim(); + if (email.length === 0 || email.length > 320) continue; + suggestions.push({ email, displayName }); + } + return suggestions; +} + +// Rank by how directly the suggestion matches the typed prefix: email or name +// prefix match first, then substring match, then the provider's own relevance +// order. The sort is stable, so equal ranks keep source order (saved contacts +// ahead of other contacts). De-duplicates by case-insensitive email, keeping +// the best-ranked (first) occurrence. +function rankSuggestions( + candidates: readonly ContactSuggestion[], + query: string, +): ContactSuggestion[] { + const needle = query.trim().toLowerCase(); + const rank = (suggestion: ContactSuggestion): number => { + const email = suggestion.email.toLowerCase(); + const name = suggestion.displayName?.toLowerCase() ?? ""; + if (email.startsWith(needle) || name.startsWith(needle)) return 0; + if (email.includes(needle) || name.includes(needle)) return 1; + return 2; + }; + + const ranked = candidates + .map((suggestion, index) => ({ suggestion, index, rank: rank(suggestion) })) + .sort((a, b) => a.rank - b.rank || a.index - b.index); + + const seen = new Set(); + const unique: ContactSuggestion[] = []; + for (const { suggestion } of ranked) { + const key = suggestion.email.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + unique.push(suggestion); + } + return unique; +} + +// Google's quota refusals worth backing off on, as opposed to backendError / +// internalError (transient server faults, classified searchFailed below). +const RATE_LIMIT_REASONS = [ + "rateLimitExceeded", + "userRateLimitExceeded", + "quotaExceeded", + "dailyLimitExceeded", +]; + +// Classify a failed People call. The cause keeps only response-derived triage +// facts (HTTP status, Google's machine-readable reason) — never the request, +// the query, or any contact content. +function toContactsSearchError(error: unknown): ContactsSearchError { + const status = googleStatus(error); + const reasons = googleErrorReasons(error); + if ( + status === 429 || + reasons.some((reason) => RATE_LIMIT_REASONS.includes(reason)) + ) { + return new ContactsSearchError( + "rateLimited", + "Google throttled the contact search", + { cause: googleFailureCause(error) }, + ); + } + if (status === 401 || status === 403) { + return new ContactsSearchError( + "unauthorized", + "Google refused the contact search credential or scope", + { cause: googleFailureCause(error) }, + ); + } + return new ContactsSearchError("searchFailed", "Contact search failed", { + cause: googleFailureCause(error), + }); +} diff --git a/packages/sync/src/providers/google/google.scopes.test.ts b/packages/sync/src/providers/google/google.scopes.test.ts new file mode 100644 index 0000000000..b39f6bbacc --- /dev/null +++ b/packages/sync/src/providers/google/google.scopes.test.ts @@ -0,0 +1,32 @@ +import { + CONTACTS_FEATURE_SCOPES, + GOOGLE_SCOPE_CONTACTS_OTHER_READONLY, + GOOGLE_SCOPE_CONTACTS_READONLY, + GOOGLE_SCOPES, +} from "@sync/providers/google/google.scopes"; +import { describe, expect, it } from "bun:test"; + +describe("google scopes", () => { + // The base list is what EVERY connect flow requests and what the required + // sign-in lists mirror. The literal pin is the regression guard for WP-05's + // core promise: adding optional contacts scopes changed nothing here. + it("keeps the base GOOGLE_SCOPES list unchanged", () => { + expect(GOOGLE_SCOPES).toEqual([ + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/calendar.readonly", + "https://www.googleapis.com/auth/calendar.events", + ]); + }); + + it("keeps the contacts scopes OUT of the base list — they are optional", () => { + expect(GOOGLE_SCOPES).not.toContain(GOOGLE_SCOPE_CONTACTS_READONLY); + expect(GOOGLE_SCOPES).not.toContain(GOOGLE_SCOPE_CONTACTS_OTHER_READONLY); + }); + + it("bundles both contacts scopes into the contacts feature", () => { + expect(CONTACTS_FEATURE_SCOPES).toEqual([ + "https://www.googleapis.com/auth/contacts.readonly", + "https://www.googleapis.com/auth/contacts.other.readonly", + ]); + }); +}); diff --git a/packages/sync/src/providers/google/google.scopes.ts b/packages/sync/src/providers/google/google.scopes.ts index 86f3bc8abb..e6a89a5812 100644 --- a/packages/sync/src/providers/google/google.scopes.ts +++ b/packages/sync/src/providers/google/google.scopes.ts @@ -7,12 +7,33 @@ export const GOOGLE_SCOPE_CALENDAR_READONLY = "https://www.googleapis.com/auth/calendar.readonly"; export const GOOGLE_SCOPE_CALENDAR_EVENTS = "https://www.googleapis.com/auth/calendar.events"; +// The user's saved contacts (People API people.searchContacts). +export const GOOGLE_SCOPE_CONTACTS_READONLY = + "https://www.googleapis.com/auth/contacts.readonly"; +// "Other contacts" — addresses the user has interacted with but never saved +// (People API otherContacts.search). +export const GOOGLE_SCOPE_CONTACTS_OTHER_READONLY = + "https://www.googleapis.com/auth/contacts.other.readonly"; // Scopes Sync requests from Google. These mirror the Compass API's existing // Google integration so a user's granted consent covers both. Email identifies // the account; the calendar scopes cover read and event read/write. +// +// This is the BASE list every connect flow requests. The contacts scopes are +// deliberately NOT here: they are optional feature scopes (below), added to a +// consent URL only when the caller asks for the feature, and never required +// for a connection to work. export const GOOGLE_SCOPES: readonly string[] = [ GOOGLE_SCOPE_USERINFO_EMAIL, GOOGLE_SCOPE_CALENDAR_READONLY, GOOGLE_SCOPE_CALENDAR_EVENTS, ]; + +// Optional scopes backing attendee contact suggestions. Requested only when a +// connect/reconnect begins with the "contacts" feature; the user can decline +// either or both (partial grants are normal) and the connection still works — +// capabilities are derived from what was actually granted. +export const CONTACTS_FEATURE_SCOPES: readonly string[] = [ + GOOGLE_SCOPE_CONTACTS_READONLY, + GOOGLE_SCOPE_CONTACTS_OTHER_READONLY, +]; diff --git a/packages/sync/src/providers/provider-auth.port.ts b/packages/sync/src/providers/provider-auth.port.ts index 1213c44a50..4e822c5bd7 100644 --- a/packages/sync/src/providers/provider-auth.port.ts +++ b/packages/sync/src/providers/provider-auth.port.ts @@ -40,6 +40,11 @@ export interface ProviderAuthAdapter { // with one signed-in session silently re-authorizes that same account, so // the user never gets to pick the account they meant to add. readonly selectAccount?: boolean; + // Optional feature scopes to request ON TOP of the adapter's base scopes + // (e.g. the contacts scopes behind attendee suggestions). Absent or empty + // leaves the consent request byte-identical to a plain connect; the user + // may decline any of them and the flow still completes. + readonly extraScopes?: readonly string[]; }): string; // Exchange an authorization code for durable credentials and account diff --git a/packages/sync/src/providers/provider-contacts.port.ts b/packages/sync/src/providers/provider-contacts.port.ts new file mode 100644 index 0000000000..1eee1e8152 --- /dev/null +++ b/packages/sync/src/providers/provider-contacts.port.ts @@ -0,0 +1,47 @@ +import { type ContactSuggestion } from "@core/types/contact.contracts"; +import { ProviderError } from "@sync/providers/provider-error"; + +// Which provider contact surfaces the caller's granted scopes allow. The route +// derives this from the connection credential's granted scopes so the adapter +// never queries an API the user did not consent to — a partial grant (one of +// the two contacts scopes) is a normal state, not an error. +export interface ContactsSearchSources { + // Saved contacts (Google: People API `people.searchContacts`, + // `contacts.readonly`). + readonly contacts: boolean; + // Interacted-with-but-never-saved addresses (Google: People API + // `otherContacts.search`, `contacts.other.readonly`). + readonly otherContacts: boolean; +} + +export interface ContactsSearchInput { + // Short-lived access token minted by credential custody; set on the request + // client and never logged. + readonly accessToken: string; + // The typed prefix to match. The caller enforces the minimum length; the + // adapter passes it through verbatim. + readonly query: string; + readonly sources: ContactsSearchSources; +} + +// A provider-neutral, read-only contact-suggestion port. Deliberately narrow: +// one search that returns ranked {email, displayName} pairs and nothing else — +// no listing, no profile detail, no write surface. Sync owns all provider code, +// so this port is how the suggestions route reaches the People API without +// knowing it exists. +export interface ContactsPort { + searchContacts(input: ContactsSearchInput): Promise; +} + +// Why a contact search failed. Callers map these to transport responses; none +// of them ever carries contact content in its message or cause. +export type ContactsSearchErrorReason = + // The provider throttled the call (429 / quota reasons). Retryable: the + // caller should back off and try again, not fail the connection. + | "rateLimited" + // The provider rejected the credential or the scope at call time. + | "unauthorized" + // Transient or unclassified failure (5xx, network, malformed response). + | "searchFailed"; + +export class ContactsSearchError extends ProviderError {} diff --git a/packages/sync/src/providers/provider-event-writer.port.ts b/packages/sync/src/providers/provider-event-writer.port.ts index bb603c79f5..e559c1cf2f 100644 --- a/packages/sync/src/providers/provider-event-writer.port.ts +++ b/packages/sync/src/providers/provider-event-writer.port.ts @@ -1,4 +1,5 @@ import { type EventSchedule } from "@core/types/event.contracts"; +import { type Attendee } from "@core/types/event-attendance.contracts"; import { type SyncEventContent } from "@core/types/sync/event.contracts"; import { ProviderError } from "@sync/providers/provider-error"; import { type ProviderEventRead } from "@sync/providers/provider-event.port"; @@ -28,6 +29,14 @@ interface ProviderWriteBody { readonly schedule: EventSchedule; readonly recurrence: ProviderWriteRecurrence; readonly invitation: InvitationIntent; + // The guest membership this write sets, already merged by the caller + // against freshly fetched provider state (see mergeAttendees). Present — + // including an empty list — replaces the provider's whole attendee array; + // absent leaves the provider's guest list untouched (the behavior every + // attendeesEdit "preserve" / legacy command keeps). content.attendees is + // read-reflected state and is never written; only this field reaches the + // provider. + readonly attendees?: readonly Attendee[]; } export interface ProviderCreateInput extends ProviderWriteBody { diff --git a/packages/sync/src/safety/redact-error.test.ts b/packages/sync/src/safety/redact-error.test.ts index b845ece68d..aa849be8b7 100644 --- a/packages/sync/src/safety/redact-error.test.ts +++ b/packages/sync/src/safety/redact-error.test.ts @@ -51,6 +51,25 @@ describe("safety canaries", () => { ).toMatch(/^eventContent:/); }); + it("detects People-API-shaped contact data (WP-05)", () => { + // A person payload serialized into a log or error cause is a contact leak. + expect( + findSafetyCanaryHit({ + person: { emailAddresses: [{ value: "a@example.com" }] }, + }), + ).toMatch(/^eventContent:/); + // So is a suggestion list. + expect( + findSafetyCanaryHit({ + suggestions: [{ email: "a@example.com", displayName: "A" }], + }), + ).toMatch(/^eventContent:/); + // But an empty suggestions list (the under-min-length response) and a + // plain count are shape-only — no contact content to protect. + assertNoSafetyCanary({ suggestions: [] }); + assertNoSafetyCanary({ suggestionCount: 3 }); + }); + it("allows id-only invalidation envelopes", () => { assertNoSafetyCanary({ kind: "calendar", diff --git a/packages/sync/src/safety/safety-canary.ts b/packages/sync/src/safety/safety-canary.ts index 7cffd61bc9..0cee8efeea 100644 --- a/packages/sync/src/safety/safety-canary.ts +++ b/packages/sync/src/safety/safety-canary.ts @@ -17,6 +17,10 @@ const EVENT_CONTENT_PATTERNS: readonly RegExp[] = [ /"attendees"\s*:\s*\[/i, /"conferenceData"\s*:/i, /"hangoutLink"\s*:/i, + // People API shapes (WP-05 contact suggestions). A person payload or a + // suggestion list serialized into a log/error is a contact-data leak. + /"emailAddresses"\s*:/i, + /"suggestions"\s*:\s*\[\s*\{/i, ]; export type SafetyCanaryKind = "secret" | "eventContent"; diff --git a/packages/sync/src/server/command.routes.db.test.ts b/packages/sync/src/server/command.routes.db.test.ts index fe8e265da9..237b6514c9 100644 --- a/packages/sync/src/server/command.routes.db.test.ts +++ b/packages/sync/src/server/command.routes.db.test.ts @@ -9,10 +9,22 @@ import { setupSyncStorage } from "@sync/__tests__/helpers/storage"; import { createSyncService, type SyncService } from "@sync/app"; import { signInternalRequest } from "@sync/auth/internal-auth"; import { type SyncConfig } from "@sync/config/sync.config"; +import { + type ProviderAuthAdapter, + type RefreshedCredential, +} from "@sync/providers/provider-auth.port"; +import { type ProviderEvent } from "@sync/providers/provider-event.port"; +import { + type ProviderEventWriter, + type ProviderPatchInput, + type ProviderWriteResult, +} from "@sync/providers/provider-event-writer.port"; import { COMMANDS_PATH } from "@sync/server/command.routes"; import { CommandRepository } from "@sync/storage/repositories/command.repository"; +import { CredentialRepository } from "@sync/storage/repositories/credential.repository"; import { EventRepository } from "@sync/storage/repositories/event.repository"; import { ProviderCalendarRepository } from "@sync/storage/repositories/provider-calendar.repository"; +import { ProviderConnectionRepository } from "@sync/storage/repositories/provider-connection.repository"; import { type SyncMongoService } from "@sync/storage/sync-mongo.service"; import { type AddressInfo } from "node:net"; @@ -562,6 +574,217 @@ describe("POST /internal/commands", () => { expect(res.status).toBe(400); }); + it("confirms a provider-linked rsvp end-to-end and appends the change-feed invalidations", async () => { + // WP-07: the full inline path — signed request → rsvp dispatch → self + // entry rewrite at the (fake) provider → confirm → invalidation outbox + // rows, which are what the Compass API's SSE eventsChanged derives from. + const tenantId = objectId(); + const principalId = objectId(); + const connections = new ProviderConnectionRepository(mongo.db); + const connection = await connections.upsertByProviderAccount({ + tenantId: tenantId as TenantId, + principalId: principalId as PrincipalId, + provider: "google", + account: { + providerAccountId: objectId(), + email: "self@example.com", + displayName: null, + }, + capabilities: ["readEvents"], + state: "healthy", + stateReason: null, + }); + const calendars = new ProviderCalendarRepository(mongo.db); + const calendar = await calendars.upsertByProviderCalendar({ + tenantId: tenantId as TenantId, + principalId: principalId as PrincipalId, + connectionId: connection._id, + providerCalendarId: "primary@google.com", + displayName: "Google", + color: null, + active: true, + primary: true, + accessRole: "editor", + capabilities: { + canReadEvents: true, + canWriteEvents: true, + canReadBusy: true, + canInviteAttendees: true, + }, + }); + const credentials = new CredentialRepository(mongo.db); + await credentials.store({ + connectionId: connection._id, + provider: "google", + refreshToken: "stored-refresh-token", + scopes: ["https://www.googleapis.com/auth/calendar.events"], + }); + const events = new EventRepository(mongo.db); + const eventId = objectId(); + const self = { + email: "self@example.com", + displayName: null, + responseStatus: "needsAction" as const, + }; + const schedule = { + kind: "timed" as const, + start: "2026-07-14T09:00:00-06:00", + end: "2026-07-14T10:00:00-06:00", + timeZone: "America/Denver", + }; + const content = { + title: "Invited", + description: "", + location: null, + organizer: { email: "organizer@example.com", displayName: null }, + attendees: [self], + conference: null, + }; + await events.put({ + _id: eventId, + tenantId, + principalId, + origin: "compass", + calendarId: calendar._id, + clientEventId: null, + connectionId: connection._id, + providerEventId: "g-evt-1", + providerVersion: "etag-1", + providerUpdatedAt: null, + deliveryState: "confirmed", + providerMetadata: null, + content, + schedule, + recurrence: { kind: "single" }, + lifecycleState: "active", + generation: 0, + createdAt: new Date(), + updatedAt: new Date(), + confirmedAt: new Date(), + } as never); + + const patchCalls: ProviderPatchInput[] = []; + const writer: ProviderEventWriter = { + createEvent: async () => { + throw new Error("unused"); + }, + deleteEvent: async () => { + throw new Error("unused"); + }, + patchEvent: async ( + input: ProviderPatchInput, + ): Promise => { + patchCalls.push(input); + return { providerEventId: "g-evt-1", providerVersion: "etag-2" }; + }, + fetchEvent: async (): Promise => ({ + kind: "event", + providerEventId: "g-evt-1", + providerVersion: "etag-1", + providerUpdatedAt: null, + content, + schedule, + busy: true, + recurrence: { kind: "single" }, + }), + fetchInstanceAt: async () => null, + }; + const authAdapter: ProviderAuthAdapter = { + buildAuthorizationUrl: () => { + throw new Error("unused"); + }, + exchangeAuthorizationCode: async () => { + throw new Error("unused"); + }, + refreshAccessToken: async (): Promise => ({ + accessToken: "fresh-access-token", + expiresAt: new Date("2099-01-01T00:00:00Z"), + grantedScopes: [], + }), + revoke: async () => {}, + }; + service = createSyncService(testConfig({ EXECUTION: "active" }), { + mongo, + writer, + authAdapter, + }); + await new Promise((resolve) => service.httpServer.listen(0, resolve)); + const { port } = service.httpServer.address() as AddressInfo; + base = `http://127.0.0.1:${port}`; + + const res = await submit(tenantId, principalId, { + idempotencyKey: `idem-${objectId()}`, + eventId, + input: { + kind: "rsvp", + responseStatus: "accepted", + scope: "all", + recurrenceId: null, + }, + expectedVersion: null, + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { + command: { outcome: { state: string } }; + }; + expect(body.command.outcome.state).toBe("confirmed"); + // The single patch rewrote only the self entry, silently. + expect(patchCalls).toHaveLength(1); + expect(patchCalls[0]?.invitation).toBe("none"); + expect(patchCalls[0]?.attendees).toEqual([ + { ...self, responseStatus: "accepted" }, + ]); + // The stored record reflects the answer before any Google round-trip. + const stored = await events.findById( + tenantId as TenantId, + principalId as never, + eventId as never, + ); + expect(stored?.content.attendees).toEqual([ + { ...self, responseStatus: "accepted" }, + ]); + // The change-feed outbox got both notices — the source of the Compass + // API's SSE eventsChanged for this calendar. + const invalidations = await mongo.db + .collection("invalidations") + .find({ tenantId, principalId }) + .toArray(); + expect( + invalidations.some( + (row) => + row["invalidation"]?.["kind"] === "event" && + row["invalidation"]?.["eventId"] === eventId && + row["invalidation"]?.["calendarId"] === calendar._id, + ), + ).toBe(true); + expect( + invalidations.some((row) => row["invalidation"]?.["kind"] === "command"), + ).toBe(true); + }); + + it("rejects an rsvp to needsAction (unrepresentable by contract)", async () => { + // WP-07 acceptance "Incomplete input": a user answers an invitation, + // they don't un-answer it — RsvpResponseStatusSchema excludes + // needsAction, so the request never becomes a command. + await startService(); + + const res = await submit(objectId(), objectId(), { + idempotencyKey: `idem-${objectId()}`, + eventId: objectId(), + input: { + kind: "rsvp", + responseStatus: "needsAction", + scope: "all", + recurrenceId: null, + }, + expectedVersion: null, + }); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "invalid_command" }); + }); + it("rejects an unsigned request", async () => { await startService(); diff --git a/packages/sync/src/server/command.routes.ts b/packages/sync/src/server/command.routes.ts index 333c0f95bf..32d52a5f20 100644 --- a/packages/sync/src/server/command.routes.ts +++ b/packages/sync/src/server/command.routes.ts @@ -106,6 +106,7 @@ export function registerCommandRoutes( calendars: repos.calendars, occurrences: repos.eventOccurrences, resources: repos.syncResources, + connections: repos.connections, markers: repos.deletionMarkers, execution: deps.execution, provider, diff --git a/packages/sync/src/server/connection.routes.db.test.ts b/packages/sync/src/server/connection.routes.db.test.ts index 525d3ead39..7f38055725 100644 --- a/packages/sync/src/server/connection.routes.db.test.ts +++ b/packages/sync/src/server/connection.routes.db.test.ts @@ -85,11 +85,13 @@ class FakeAuthAdapter implements ProviderAuthAdapter { state: string; redirectUri: string; selectAccount?: boolean; + extraScopes?: readonly string[]; }> = []; buildAuthorizationUrl(input: { state: string; redirectUri: string; selectAccount?: boolean; + extraScopes?: readonly string[]; }): string { this.authorizations.push(input); return `https://consent.example.com/?state=${input.state}`; @@ -608,6 +610,52 @@ describe("POST /internal/connections/begin", () => { expect(res.status).toBe(400); }); + it("asks for both contacts scopes when begin carries the contacts feature", async () => { + const tenantId = objectId(); + const principalId = objectId(); + await startService(activeConfig(), adapter); + + const res = await begin(tenantId, principalId, { + features: ["contacts"], + }); + + expect(res.status).toBe(200); + expect(adapter.authorizations[0].extraScopes).toEqual([ + "https://www.googleapis.com/auth/contacts.readonly", + "https://www.googleapis.com/auth/contacts.other.readonly", + ]); + }); + + it("keeps a plain begin's adapter input identical to before features existed", async () => { + const tenantId = objectId(); + const principalId = objectId(); + await startService(activeConfig(), adapter); + + // No features field, and an explicitly empty one — both must produce the + // pre-WP-05 adapter input: no extraScopes key at all, so the consent URL + // is byte-identical to a plain connect. + await begin(tenantId, principalId); + await begin(tenantId, principalId, { features: [] }); + + for (const authorization of adapter.authorizations) { + expect("extraScopes" in authorization).toBe(false); + } + }); + + it("rejects an unknown feature", async () => { + const tenantId = objectId(); + const principalId = objectId(); + await startService(activeConfig(), adapter); + + const res = await begin(tenantId, principalId, { + features: ["telepathy"], + }); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "invalid_features" }); + expect(adapter.authorizations).toHaveLength(0); + }); + it("refuses to begin in passive mode", async () => { const tenantId = objectId(); const principalId = objectId(); @@ -702,6 +750,54 @@ describe("GET /sync/google", () => { expect(stored?.refreshToken).toBe("granted-refresh-token"); }); + it("derives suggestContacts when the callback's grant includes a contacts scope", async () => { + const tenantId = objectId(); + const principalId = objectId(); + // A partial contacts grant (only other-contacts) still counts. + adapter.exchangeResult = { + ...adapter.exchangeResult, + grantedScopes: [ + ...adapter.exchangeResult.grantedScopes, + "https://www.googleapis.com/auth/contacts.other.readonly", + ], + }; + await startService(activeConfig(), adapter); + + const res = await hitCallback( + `code=auth-code&state=${encodeURIComponent(validState(tenantId, principalId))}`, + ); + + expect(statusOf(res)).toBe("connected"); + const [linked] = await connections.listByPrincipal( + tenantId as TenantId, + principalId as PrincipalId, + ); + expect(linked.capabilities).toContain("suggestContacts"); + // The granted scopes ride the credential for the suggestions route. + const stored = await credentials.findByConnection(linked._id); + expect(stored?.scopes).toContain( + "https://www.googleapis.com/auth/contacts.other.readonly", + ); + }); + + it("derives no suggestContacts when contacts scopes were left unchecked", async () => { + const tenantId = objectId(); + const principalId = objectId(); + await startService(activeConfig(), adapter); + + const res = await hitCallback( + `code=auth-code&state=${encodeURIComponent(validState(tenantId, principalId))}`, + ); + + // Declining the optional scopes is a NORMAL connect, not an error. + expect(statusOf(res)).toBe("connected"); + const [linked] = await connections.listByPrincipal( + tenantId as TenantId, + principalId as PrincipalId, + ); + expect(linked.capabilities).not.toContain("suggestContacts"); + }); + it("enqueues calendar-list discovery to bootstrap the new connection", async () => { const tenantId = objectId(); const principalId = objectId(); diff --git a/packages/sync/src/server/connection.routes.ts b/packages/sync/src/server/connection.routes.ts index 28a03186c3..ae60d7b5e7 100644 --- a/packages/sync/src/server/connection.routes.ts +++ b/packages/sync/src/server/connection.routes.ts @@ -8,6 +8,7 @@ import { BusyAvailabilityResponseSchema, } from "@core/types/sync/availability.contracts"; import { + ConnectionBeginFeaturesSchema, type ConnectionListResponse, ConnectionRefreshResponseSchema, ForegroundRefreshRequestSchema, @@ -48,8 +49,10 @@ import { HORIZON_PAST_MONTHS, } from "@sync/domain/horizon"; import { signOAuthState, verifyOAuthState } from "@sync/oauth/oauth-state"; +import { CONTACTS_FEATURE_SCOPES } from "@sync/providers/google/google.scopes"; import { googleCapabilitiesFromScopes } from "@sync/providers/google/google-capabilities"; import { type ProviderAuthAdapter } from "@sync/providers/provider-auth.port"; +import { type ContactsPort } from "@sync/providers/provider-contacts.port"; import { type ProviderEventWriter } from "@sync/providers/provider-event-writer.port"; import { redactedCause } from "@sync/safety/redact-error"; import { @@ -112,6 +115,10 @@ export interface ConnectionApiDeps { // Not used by the connection routes themselves; carried here because this is // the shared bag the command routes are wired from. writer?: ProviderEventWriter; + // The provider contacts port, present only when the provider is configured. + // Not used by the connection routes themselves; carried here because this is + // the shared bag the contacts routes are wired from. + contacts?: ContactsPort; // Secret the OAuth CSRF state is signed with, and the public base URL the // provider callback resolves against. stateSecret: string; @@ -530,6 +537,24 @@ export function registerConnectionRoutes( connectionId = parsed.data; } + // Optional feature groups widen the consent request with their OPTIONAL + // scopes (e.g. contacts for attendee suggestions). Absent features keep + // the consent URL byte-identical to a plain connect; the user may still + // decline any feature scope on the consent screen and the flow completes + // (the callback derives capabilities from what was actually granted). + let extraScopes: string[] = []; + const rawFeatures = (req.body as { features?: unknown })?.features; + if (rawFeatures !== undefined && rawFeatures !== null) { + const parsed = ConnectionBeginFeaturesSchema.safeParse(rawFeatures); + if (!parsed.success) { + res.status(Status.BAD_REQUEST).json({ error: "invalid_features" }); + return; + } + if (parsed.data.includes("contacts")) { + extraScopes = [...CONTACTS_FEATURE_SCOPES]; + } + } + // A fresh connect by a principal that already has connections is an // add-account: show the provider's account chooser so the user can pick // a different account instead of silently re-authorizing the connected @@ -561,6 +586,10 @@ export function registerConnectionRoutes( state, redirectUri: `${deps.callbackBaseUrl}${OAUTH_CALLBACK_PATH}`, selectAccount, + // Undefined (not an empty array) when no feature was asked for, so a + // plain begin's adapter input — and therefore its consent URL — stays + // byte-identical to before features existed. + ...(extraScopes.length > 0 ? { extraScopes } : {}), }); res.status(Status.OK).json({ authorizationUrl }); }, diff --git a/packages/sync/src/server/contacts.routes.db.test.ts b/packages/sync/src/server/contacts.routes.db.test.ts new file mode 100644 index 0000000000..56358873fd --- /dev/null +++ b/packages/sync/src/server/contacts.routes.db.test.ts @@ -0,0 +1,353 @@ +import { faker } from "@faker-js/faker"; +import { NodeEnv } from "@core/constants/core.constants"; +import { type ContactSuggestion } from "@core/types/contact.contracts"; +import { + type PrincipalId, + type TenantId, +} from "@core/types/sync/identity.contracts"; +import { setupSyncStorage } from "@sync/__tests__/helpers/storage"; +import { createSyncService, type SyncService } from "@sync/app"; +import { signInternalRequest } from "@sync/auth/internal-auth"; +import { type SyncConfig } from "@sync/config/sync.config"; +import { + GOOGLE_SCOPE_CALENDAR_EVENTS, + GOOGLE_SCOPE_CONTACTS_OTHER_READONLY, + GOOGLE_SCOPE_CONTACTS_READONLY, +} from "@sync/providers/google/google.scopes"; +import { + type ProviderAuthAdapter, + type ProviderAuthorization, + type RefreshedCredential, +} from "@sync/providers/provider-auth.port"; +import { + type ContactsPort, + ContactsSearchError, + type ContactsSearchInput, +} from "@sync/providers/provider-contacts.port"; +import { CONTACTS_SUGGESTIONS_PATH } from "@sync/server/contacts.routes"; +import { CredentialRepository } from "@sync/storage/repositories/credential.repository"; +import { ProviderConnectionRepository } from "@sync/storage/repositories/provider-connection.repository"; +import { type SyncMongoService } from "@sync/storage/sync-mongo.service"; +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { type AddressInfo } from "node:net"; + +const uri = process.env["SYNC_MONGO_URI"] as string; +const storage = setupSyncStorage(import.meta.url); +const objectId = () => faker.database.mongodbObjectId(); +const SECRET = "internal-secret"; + +const testConfig = (overrides: Partial = {}): SyncConfig => + ({ + NODE_ENV: NodeEnv.Test, + PORT: 0, + MONGO_URI: uri, + INTERNAL_AUTH_TOKEN: SECRET, + CALLBACK_BASE_URL: "http://localhost:3010", + EXECUTION: "active", + MAX_CONCURRENCY: 4, + ...overrides, + }) as SyncConfig; + +// Custody needs only refreshAccessToken here: the stored credential has no +// cached access token, so every suggestion request mints one. +class FakeAuthAdapter implements ProviderAuthAdapter { + minted = 0; + buildAuthorizationUrl(): string { + throw new Error("unused"); + } + exchangeAuthorizationCode(): Promise { + throw new Error("unused"); + } + async refreshAccessToken(): Promise { + this.minted += 1; + return { + accessToken: "minted-access-token", + expiresAt: new Date(Date.now() + 3_600_000), + grantedScopes: [], + }; + } + async revoke(): Promise {} +} + +class FakeContactsPort implements ContactsPort { + calls: ContactsSearchInput[] = []; + result: ContactSuggestion[] = []; + error?: unknown; + async searchContacts( + input: ContactsSearchInput, + ): Promise { + this.calls.push(input); + if (this.error) throw this.error; + return this.result; + } +} + +const signedHeaders = ( + tenantId: string, + principalId: string, +): Record => { + const timestamp = Date.now(); + return { + "x-sync-tenant": tenantId, + "x-sync-principal": principalId, + "x-sync-timestamp": String(timestamp), + "x-sync-signature": signInternalRequest(SECRET, { + timestamp, + tenantId, + principalId, + }), + }; +}; + +describe("GET /internal/contacts/suggestions", () => { + let mongo: SyncMongoService; + let connections: ProviderConnectionRepository; + let credentials: CredentialRepository; + let service: SyncService; + let base: string; + let authAdapter: FakeAuthAdapter; + let contacts: FakeContactsPort; + + const startService = async (config: SyncConfig = testConfig()) => { + service = createSyncService(config, { mongo, authAdapter, contacts }); + await new Promise((resolve) => service.httpServer.listen(0, resolve)); + const { port } = service.httpServer.address() as AddressInfo; + base = `http://127.0.0.1:${port}`; + }; + + const seedContactsConnection = async ( + tenantId: string, + principalId: string, + scopes: string[], + ) => { + const connection = await connections.upsertByProviderAccount({ + tenantId: tenantId as TenantId, + principalId: principalId as PrincipalId, + provider: "google", + account: { + providerAccountId: objectId(), + email: "me@example.com", + displayName: null, + }, + capabilities: ["readEvents", "suggestContacts"], + state: "healthy", + stateReason: null, + }); + await credentials.store({ + connectionId: connection._id, + provider: "google", + refreshToken: "stored-refresh-token", + scopes, + }); + return connection; + }; + + const suggest = (tenantId: string, principalId: string, q?: string) => + fetch( + `${base}${CONTACTS_SUGGESTIONS_PATH}${ + q === undefined ? "" : `?q=${encodeURIComponent(q)}` + }`, + { headers: signedHeaders(tenantId, principalId) }, + ); + + beforeEach(() => { + mongo = storage.mongo(); + connections = new ProviderConnectionRepository(mongo.db); + credentials = new CredentialRepository(mongo.db); + authAdapter = new FakeAuthAdapter(); + contacts = new FakeContactsPort(); + }); + + afterEach(async () => { + await service?.stop(); + }); + + it("returns suggestions from the port, scoped by the credential's granted contacts scopes", async () => { + const tenantId = objectId(); + const principalId = objectId(); + await seedContactsConnection(tenantId, principalId, [ + GOOGLE_SCOPE_CALENDAR_EVENTS, + GOOGLE_SCOPE_CONTACTS_READONLY, + GOOGLE_SCOPE_CONTACTS_OTHER_READONLY, + ]); + contacts.result = [ + { email: "alice@example.com", displayName: "Alice Doe" }, + { email: "albert@example.com", displayName: null }, + ]; + await startService(); + + const res = await suggest(tenantId, principalId, "al"); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + suggestions: [ + { email: "alice@example.com", displayName: "Alice Doe" }, + { email: "albert@example.com", displayName: null }, + ], + }); + expect(contacts.calls).toEqual([ + { + accessToken: "minted-access-token", + query: "al", + sources: { contacts: true, otherContacts: true }, + }, + ]); + }); + + it("passes only the granted surface to the port on a partial grant", async () => { + const tenantId = objectId(); + const principalId = objectId(); + await seedContactsConnection(tenantId, principalId, [ + GOOGLE_SCOPE_CALENDAR_EVENTS, + GOOGLE_SCOPE_CONTACTS_OTHER_READONLY, + ]); + await startService(); + + const res = await suggest(tenantId, principalId, "al"); + + expect(res.status).toBe(200); + expect(contacts.calls).toEqual([ + { + accessToken: "minted-access-token", + query: "al", + sources: { contacts: false, otherContacts: true }, + }, + ]); + }); + + it("returns an empty 200 for a sub-minimum query without touching the provider", async () => { + const tenantId = objectId(); + const principalId = objectId(); + await seedContactsConnection(tenantId, principalId, [ + GOOGLE_SCOPE_CONTACTS_READONLY, + ]); + await startService(); + + // One character — and a padded one character, which must trim first. + for (const q of ["a", " a "]) { + const res = await suggest(tenantId, principalId, q); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ suggestions: [] }); + } + expect(contacts.calls).toHaveLength(0); + // No access token was minted either — the provider was never involved. + expect(authAdapter.minted).toBe(0); + }); + + it("refuses typed when no connection has the contacts capability", async () => { + const tenantId = objectId(); + const principalId = objectId(); + // A connection WITHOUT the contacts grant (calendar-only capabilities). + await connections.upsertByProviderAccount({ + tenantId: tenantId as TenantId, + principalId: principalId as PrincipalId, + provider: "google", + account: { + providerAccountId: objectId(), + email: "me@example.com", + displayName: null, + }, + capabilities: ["readEvents", "writeEvents"], + state: "healthy", + stateReason: null, + }); + await startService(); + + const res = await suggest(tenantId, principalId, "al"); + + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ error: "contacts_not_granted" }); + expect(contacts.calls).toHaveLength(0); + }); + + it("never serves another principal's contacts grant", async () => { + const tenantId = objectId(); + const owner = objectId(); + const stranger = objectId(); + await seedContactsConnection(tenantId, owner, [ + GOOGLE_SCOPE_CONTACTS_READONLY, + ]); + await startService(); + + const res = await suggest(tenantId, stranger, "al"); + + expect(res.status).toBe(403); + expect(contacts.calls).toHaveLength(0); + }); + + it("maps a provider rate-limit to a typed retryable 429", async () => { + const tenantId = objectId(); + const principalId = objectId(); + await seedContactsConnection(tenantId, principalId, [ + GOOGLE_SCOPE_CONTACTS_READONLY, + ]); + contacts.error = new ContactsSearchError( + "rateLimited", + "Google throttled the contact search", + ); + await startService(); + + const res = await suggest(tenantId, principalId, "al"); + + expect(res.status).toBe(429); + expect(await res.json()).toEqual({ + error: "rate_limited", + retryable: true, + }); + }); + + it("maps other provider search failures to a typed retryable 503", async () => { + const tenantId = objectId(); + const principalId = objectId(); + await seedContactsConnection(tenantId, principalId, [ + GOOGLE_SCOPE_CONTACTS_READONLY, + ]); + contacts.error = new ContactsSearchError("searchFailed", "Search failed"); + await startService(); + + const res = await suggest(tenantId, principalId, "al"); + + expect(res.status).toBe(503); + expect(await res.json()).toEqual({ + error: "contacts_unavailable", + retryable: true, + }); + }); + + it("rejects a missing or oversized query", async () => { + const tenantId = objectId(); + const principalId = objectId(); + await seedContactsConnection(tenantId, principalId, [ + GOOGLE_SCOPE_CONTACTS_READONLY, + ]); + await startService(); + + const missing = await suggest(tenantId, principalId); + expect(missing.status).toBe(400); + const oversized = await suggest(tenantId, principalId, "a".repeat(257)); + expect(oversized.status).toBe(400); + expect(contacts.calls).toHaveLength(0); + }); + + it("refuses in passive mode", async () => { + const tenantId = objectId(); + const principalId = objectId(); + await seedContactsConnection(tenantId, principalId, [ + GOOGLE_SCOPE_CONTACTS_READONLY, + ]); + await startService(testConfig({ EXECUTION: "passive" })); + + const res = await suggest(tenantId, principalId, "al"); + + expect(res.status).toBe(409); + expect(await res.json()).toEqual({ error: "provider_work_disabled" }); + }); + + it("rejects an unsigned request", async () => { + await startService(); + + const res = await fetch(`${base}${CONTACTS_SUGGESTIONS_PATH}?q=al`); + + expect(res.status).toBe(401); + }); +}); diff --git a/packages/sync/src/server/contacts.routes.ts b/packages/sync/src/server/contacts.routes.ts new file mode 100644 index 0000000000..321b439485 --- /dev/null +++ b/packages/sync/src/server/contacts.routes.ts @@ -0,0 +1,217 @@ +import { type Express, type RequestHandler } from "express"; +import { Status } from "@core/errors/status.codes"; +import { Logger } from "@core/logger/winston.logger"; +import { + CONTACT_SUGGESTION_MAX_RESULTS, + CONTACT_SUGGESTION_QUERY_MAX_LENGTH, + CONTACT_SUGGESTION_QUERY_MIN_LENGTH, + type ContactSuggestion, + ContactSuggestionsResponseSchema, +} from "@core/types/contact.contracts"; +import { type SyncExecutionMode } from "@sync/config/sync.config"; +import { CredentialCustody } from "@sync/credentials/credential-custody.service"; +import { + GOOGLE_SCOPE_CONTACTS_OTHER_READONLY, + GOOGLE_SCOPE_CONTACTS_READONLY, +} from "@sync/providers/google/google.scopes"; +import { + type ProviderAuthAdapter, + ProviderAuthError, +} from "@sync/providers/provider-auth.port"; +import { + type ContactsPort, + ContactsSearchError, +} from "@sync/providers/provider-contacts.port"; +import { redactedCause } from "@sync/safety/redact-error"; +import { + ensureConnected, + internalRateLimit, + requireAuth, + respondInternalError, +} from "@sync/server/internal-http"; +import { type ProviderConnectionRecord } from "@sync/storage/contracts/provider-connection.contracts"; +import { type CredentialRepository } from "@sync/storage/repositories/credential.repository"; +import { type SyncMongoService } from "@sync/storage/sync-mongo.service"; +import { syncRepositories } from "@sync/storage/sync-repositories"; + +const logger = Logger("sync:contacts.routes"); + +export const CONTACTS_SUGGESTIONS_PATH = "/internal/contacts/suggestions"; + +export interface ContactsApiDeps { + authMiddleware: RequestHandler; + mongo: SyncMongoService; + // Suggestions call the provider, so a passive deployment refuses. + execution: SyncExecutionMode; + // Backs per-request credential custody; absent means no provider work. + authAdapter?: ProviderAuthAdapter; + // The provider contacts port, present only when the provider is configured. + contacts?: ContactsPort; +} + +// Internal, authenticated attendee-suggestion lookup. Principal-scoped: the +// tenant/principal come from the signed auth context, and only connections +// that actually granted a contacts scope are queried. The response carries +// {email, displayName} pairs and NOTHING else from the People API; neither the +// query nor any contact content is ever logged. +export function registerContactsRoutes( + app: Express, + deps: ContactsApiDeps, +): void { + app.get( + CONTACTS_SUGGESTIONS_PATH, + internalRateLimit, + deps.authMiddleware, + async (req, res) => { + const auth = requireAuth(req, res); + if (!auth) return; + if (!ensureConnected(deps.mongo, res)) return; + // Suggestions always touch the provider (contacts are never cached), so + // a passive or unconfigured service refuses like begin does. + if (deps.execution === "passive" || !deps.authAdapter || !deps.contacts) { + res.status(Status.CONFLICT).json({ error: "provider_work_disabled" }); + return; + } + + const rawQuery = req.query["q"]; + if ( + typeof rawQuery !== "string" || + rawQuery.length > CONTACT_SUGGESTION_QUERY_MAX_LENGTH + ) { + res.status(Status.BAD_REQUEST).json({ error: "invalid_query" }); + return; + } + const query = rawQuery.trim(); + + try { + const repos = syncRepositories(deps.mongo); + const connections = await repos.connections.listByPrincipal( + auth.tenantId, + auth.principalId, + ); + // The capability is the contract: no contacts grant on any connection + // is a typed refusal (the browser hides/offers the feature from it), + // never a 500. + const capable = connections.filter((connection) => + connection.capabilities.includes("suggestContacts"), + ); + if (capable.length === 0) { + res.status(Status.FORBIDDEN).json({ error: "contacts_not_granted" }); + return; + } + + // A sub-minimum prefix matches half an address book: empty result, + // no provider call, no quota burned. + if (query.length < CONTACT_SUGGESTION_QUERY_MIN_LENGTH) { + res + .status(Status.OK) + .json(ContactSuggestionsResponseSchema.parse({ suggestions: [] })); + return; + } + + const custody = new CredentialCustody( + repos.credentials, + deps.authAdapter, + ); + const collected: ContactSuggestion[] = []; + for (const connection of capable) { + const suggestions = await searchConnection( + deps.contacts, + custody, + repos.credentials, + connection, + query, + ); + collected.push(...suggestions); + } + + res.status(Status.OK).json( + ContactSuggestionsResponseSchema.parse({ + suggestions: dedupeByEmail(collected).slice( + 0, + CONTACT_SUGGESTION_MAX_RESULTS, + ), + }), + ); + } catch (error) { + if (error instanceof ContactsSearchError) { + // Typed provider failures map to typed transport responses. The + // log line is static and the cause is response-fact-only — neither + // ever carries the query or contact content. + if (error.reason === "rateLimited") { + logger.warn("Contact search throttled by the provider"); + res.status(Status.TOO_MANY_REQUESTS).json({ + error: "rate_limited", + retryable: true, + }); + return; + } + logger.warn("Contact search failed", redactedCause(error)); + res + .status(Status.SERVICE_UNAVAILABLE) + .json({ error: "contacts_unavailable", retryable: true }); + return; + } + if (error instanceof ProviderAuthError) { + // The connection's credential could not mint a token right now + // (refresh failure, revoked grant). Suggestions are a convenience: + // report unavailable and let connection-state repair handle the + // credential, rather than failing with a 500. + logger.warn( + "Contact suggestions could not mint an access token", + redactedCause(error), + ); + res + .status(Status.SERVICE_UNAVAILABLE) + .json({ error: "contacts_unavailable", retryable: true }); + return; + } + logger.error( + "Failed to serve contact suggestions", + redactedCause(error), + ); + respondInternalError(res); + } + }, + ); +} + +// Query one connection's granted People surfaces. The credential record is +// consulted ONLY for its granted scopes (which surfaces the user consented +// to); a connection without a stored credential (e.g. just disconnected) +// contributes nothing rather than failing the request. +async function searchConnection( + contacts: ContactsPort, + custody: CredentialCustody, + credentials: CredentialRepository, + connection: ProviderConnectionRecord, + query: string, +): Promise { + const credential = await credentials.findByConnection(connection._id); + if (!credential) return []; + const granted = new Set(credential.scopes); + const sources = { + contacts: granted.has(GOOGLE_SCOPE_CONTACTS_READONLY), + otherContacts: granted.has(GOOGLE_SCOPE_CONTACTS_OTHER_READONLY), + }; + if (!sources.contacts && !sources.otherContacts) return []; + + const accessToken = await custody.getValidAccessToken(connection._id); + return contacts.searchContacts({ accessToken, query, sources }); +} + +// Across connections the same address can appear twice; keep the first +// occurrence (connection listing order, each list already ranked). +function dedupeByEmail( + suggestions: readonly ContactSuggestion[], +): ContactSuggestion[] { + const seen = new Set(); + const unique: ContactSuggestion[] = []; + for (const suggestion of suggestions) { + const key = suggestion.email.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + unique.push(suggestion); + } + return unique; +} diff --git a/packages/sync/src/server/sync.server.ts b/packages/sync/src/server/sync.server.ts index 19402e14cb..0ff8e179a4 100644 --- a/packages/sync/src/server/sync.server.ts +++ b/packages/sync/src/server/sync.server.ts @@ -6,6 +6,7 @@ import { type ConnectionApiDeps, registerConnectionRoutes, } from "@sync/server/connection.routes"; +import { registerContactsRoutes } from "@sync/server/contacts.routes"; import { registerDiagnosticRoutes } from "@sync/server/diagnostic.routes"; import { registerHealthRoutes } from "@sync/server/health.routes"; import { registerNotificationRoutes } from "@sync/server/notification.routes"; @@ -46,6 +47,15 @@ export function buildSyncApp(deps: { authAdapter: deps.connectionApi.authAdapter, now: deps.connectionApi.now, }); + // Attendee contact suggestions from the provider's People surfaces, gated + // on the optional per-connection contacts grant (WP-05). + registerContactsRoutes(app, { + authMiddleware: deps.connectionApi.authMiddleware, + mongo: deps.connectionApi.mongo, + execution: deps.connectionApi.execution, + authAdapter: deps.connectionApi.authAdapter, + contacts: deps.connectionApi.contacts, + }); // Resumable invalidation outbox for Compass API → browser SSE (S40). registerChangeFeedRoutes(app, { authMiddleware: deps.connectionApi.authMiddleware, diff --git a/packages/web/src/__tests__/utils/factories/calendar.factory.ts b/packages/web/src/__tests__/utils/factories/calendar.factory.ts index 0c7419257c..5b2663816a 100644 --- a/packages/web/src/__tests__/utils/factories/calendar.factory.ts +++ b/packages/web/src/__tests__/utils/factories/calendar.factory.ts @@ -44,6 +44,7 @@ export function createMockConnection( lastHealthyAt: null, accountEmail, connectionState: "HEALTHY", + canSuggestContacts: false, ...overrides, }; } diff --git a/packages/web/src/api/api.types.ts b/packages/web/src/api/api.types.ts index 2e929d9c1b..e2e8738098 100644 --- a/packages/web/src/api/api.types.ts +++ b/packages/web/src/api/api.types.ts @@ -13,6 +13,9 @@ export interface ApiRequestConfig { body?: unknown; headers?: HeadersInit; method?: string; + // Abort the underlying fetch (e.g. a type-ahead query cancelled on + // unmount). Aborting rejects the request promise with an AbortError. + signal?: AbortSignal; skipSessionRecovery?: boolean; url?: string; } @@ -27,7 +30,7 @@ export interface ApiResponse { export type ApiMethodConfig = Pick< ApiRequestConfig, - "headers" | "skipSessionRecovery" + "headers" | "signal" | "skipSessionRecovery" >; export type SignoutStatus = diff --git a/packages/web/src/api/base/base.api.ts b/packages/web/src/api/base/base.api.ts index f31155c089..0c7c954f67 100644 --- a/packages/web/src/api/base/base.api.ts +++ b/packages/web/src/api/base/base.api.ts @@ -26,6 +26,7 @@ const request = async ( body, headers: config.headers, method, + signal: config.signal, skipSessionRecovery: config.skipSessionRecovery, url, } satisfies ApiRequestConfig; @@ -47,6 +48,7 @@ const request = async ( ...config.headers, }, method, + signal: config.signal, }); const data = await getResponseData(response); const result = { diff --git a/packages/web/src/api/contacts.api.ts b/packages/web/src/api/contacts.api.ts new file mode 100644 index 0000000000..a7aa576882 --- /dev/null +++ b/packages/web/src/api/contacts.api.ts @@ -0,0 +1,25 @@ +import { + type ContactSuggestionsResponse, + ContactSuggestionsResponseSchema, +} from "@core/types/contact.contracts"; +import { BaseApi } from "@web/api/base/base.api"; + +const ContactsApi = { + // Google-contact suggestions for the attendee type-ahead. The backend + // proxies sync and degrades every provider-side failure to an empty list, + // so a non-2xx here is exceptional (auth loss, malformed query). Never log + // the query or the response — suggestion content stays out of every log. + async getSuggestions( + query: string, + signal?: AbortSignal, + ): Promise { + const response = await BaseApi.get( + `/contacts/suggestions?q=${encodeURIComponent(query)}`, + { signal }, + ); + + return ContactSuggestionsResponseSchema.parse(response.data); + }, +}; + +export { ContactsApi }; diff --git a/packages/web/src/app.bootstrap.tsx b/packages/web/src/app.bootstrap.tsx index 1cff5c7452..b331d4560d 100644 --- a/packages/web/src/app.bootstrap.tsx +++ b/packages/web/src/app.bootstrap.tsx @@ -4,6 +4,7 @@ import "react-toastify/dist/ReactToastify.css"; import { sessionInit } from "@web/auth/compass/session/SessionProvider"; import { readGoogleConnectStatus, + refreshUserMetadataAfterGoogleConnect, showGoogleConnectStatusToast, } from "@web/auth/google/authorization/google-connect-status.util"; import { configureGoogleRevocationApiHandler } from "@web/auth/google/util/google-revocation-api.config"; @@ -45,5 +46,6 @@ export async function bootstrapApp(): Promise { } if (connectStatus) { showGoogleConnectStatusToast(connectStatus); + refreshUserMetadataAfterGoogleConnect(connectStatus); } } diff --git a/packages/web/src/auth/google/authorization/google-authorization.constants.ts b/packages/web/src/auth/google/authorization/google-authorization.constants.ts index ce017e9d6a..339e5cf7ed 100644 --- a/packages/web/src/auth/google/authorization/google-authorization.constants.ts +++ b/packages/web/src/auth/google/authorization/google-authorization.constants.ts @@ -5,12 +5,32 @@ export const GOOGLE_AUTH_INTENT_STORAGE_PREFIX = "compass.googleAuthorizationIntent"; export const GOOGLE_AUTH_INTENT_MAX_AGE_MS = 10 * 60 * 1000; +// Sign-in fails without every scope in this list (complete-google-authorization +// verifies the callback granted them all). It must NEVER gain a contacts scope: +// contacts are optional (below), and requiring one would brick sign-in for +// every user who declines it. export const GOOGLE_AUTH_SCOPES_REQUIRED = [ "https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/calendar.readonly", "https://www.googleapis.com/auth/calendar.events", ]; +// Optional scopes the consent screen asks for but sign-in never verifies. The +// user can leave them unchecked and proceed; the backend stores whatever was +// actually granted. Approved as optional sensitive scopes 2026-08-25. +export const GOOGLE_AUTH_SCOPES_OPTIONAL = [ + "https://www.googleapis.com/auth/contacts.readonly", + "https://www.googleapis.com/auth/contacts.other.readonly", +]; + +// What the sign-in flow REQUESTS from Google: required plus optional. Only for +// building the consent request — callback verification stays on +// GOOGLE_AUTH_SCOPES_REQUIRED alone. +export const GOOGLE_AUTH_SCOPES_REQUESTED = [ + ...GOOGLE_AUTH_SCOPES_REQUIRED, + ...GOOGLE_AUTH_SCOPES_OPTIONAL, +]; + export const GOOGLE_AUTHORIZATION_ERROR_MESSAGE = "We couldn't connect your Google account. Please try again."; export const MISSING_GOOGLE_SCOPES_ERROR_MESSAGE = diff --git a/packages/web/src/auth/google/authorization/google-authorization.test.ts b/packages/web/src/auth/google/authorization/google-authorization.test.ts index 36749e8f93..b5b8362e49 100644 --- a/packages/web/src/auth/google/authorization/google-authorization.test.ts +++ b/packages/web/src/auth/google/authorization/google-authorization.test.ts @@ -1,5 +1,9 @@ import { completeGoogleAuthorization } from "./complete-google-authorization"; -import { GOOGLE_AUTH_SCOPES_REQUIRED } from "./google-authorization.constants"; +import { + GOOGLE_AUTH_SCOPES_OPTIONAL, + GOOGLE_AUTH_SCOPES_REQUESTED, + GOOGLE_AUTH_SCOPES_REQUIRED, +} from "./google-authorization.constants"; import { consumeGoogleAuthNeedsConsentRetry, readGoogleAuthorizationIntent, @@ -66,6 +70,54 @@ describe("completeGoogleAuthorization", () => { expect(readGoogleAuthorizationIntent("state-1")).toBeNull(); }); + it("completes sign-in when the optional contacts scopes are not granted", async () => { + // The consent screen REQUESTS the contacts scopes but never requires + // them: a callback granting exactly the required list (contacts left + // unchecked) must complete like any other sign-in. + const withoutContacts = GOOGLE_AUTH_SCOPES_REQUESTED.filter( + (scope) => !GOOGLE_AUTH_SCOPES_OPTIONAL.includes(scope), + ); + expect(withoutContacts).toEqual(GOOGLE_AUTH_SCOPES_REQUIRED); + const deps = makeDeps(); + writeGoogleAuthorizationIntent("state-optional", { + intent: "signIn", + returnPath: "/week", + createdAt: Date.now(), + }); + + await expect( + completeGoogleAuthorization({ + ...deps, + search: callbackSearch("state-optional", withoutContacts.join(" ")), + }), + ).resolves.toEqual({ + status: "completed", + returnPath: "/week", + isNewUser: false, + }); + expect(deps.authApi.loginOrSignup).toHaveBeenCalledTimes(1); + }); + + it("keeps the required scope list free of the optional contacts scopes", () => { + // GOOGLE_AUTH_SCOPES_REQUIRED is what verification checks — a contacts + // scope here would brick sign-in for every user who declines it. The + // literal pin guards the split: required unchanged, contacts requested + // only as optional additions. + expect(GOOGLE_AUTH_SCOPES_REQUIRED).toEqual([ + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/calendar.readonly", + "https://www.googleapis.com/auth/calendar.events", + ]); + expect(GOOGLE_AUTH_SCOPES_REQUESTED).toEqual([ + ...GOOGLE_AUTH_SCOPES_REQUIRED, + "https://www.googleapis.com/auth/contacts.readonly", + "https://www.googleapis.com/auth/contacts.other.readonly", + ]); + for (const scope of GOOGLE_AUTH_SCOPES_OPTIONAL) { + expect(GOOGLE_AUTH_SCOPES_REQUIRED).not.toContain(scope); + } + }); + it("rejects callbacks that are missing required Google Calendar scopes", async () => { const deps = makeDeps(); writeGoogleAuthorizationIntent("state-3", { diff --git a/packages/web/src/auth/google/authorization/google-connect-status.util.test.ts b/packages/web/src/auth/google/authorization/google-connect-status.util.test.ts index e6fc2025e6..b5785e87f5 100644 --- a/packages/web/src/auth/google/authorization/google-connect-status.util.test.ts +++ b/packages/web/src/auth/google/authorization/google-connect-status.util.test.ts @@ -1,7 +1,9 @@ import { createTestToastPort } from "@web/__tests__/helpers/web-test-seams"; +import * as userMetadataUtil from "@web/auth/compass/user/util/user-metadata.util"; import { registerToastPort } from "@web/common/utils/toast/toast.port"; import { readGoogleConnectStatus, + refreshUserMetadataAfterGoogleConnect, showGoogleConnectStatusToast, } from "./google-connect-status.util"; import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; @@ -130,4 +132,32 @@ describe("google-connect-status.util", () => { ); }); }); + + describe("refreshUserMetadataAfterGoogleConnect", () => { + it("force-refreshes metadata after a completed connect, so new capabilities (e.g. contacts) go live without a reload", () => { + const refreshSpy = spyOn( + userMetadataUtil, + "refreshUserMetadata", + ).mockResolvedValue(undefined); + + refreshUserMetadataAfterGoogleConnect("connected"); + expect(refreshSpy).toHaveBeenCalledWith({ force: true }); + + refreshSpy.mockRestore(); + }); + + it("does nothing for non-connected outcomes", () => { + const refreshSpy = spyOn( + userMetadataUtil, + "refreshUserMetadata", + ).mockResolvedValue(undefined); + + refreshUserMetadataAfterGoogleConnect("declined"); + refreshUserMetadataAfterGoogleConnect("missingScopes"); + refreshUserMetadataAfterGoogleConnect("error"); + expect(refreshSpy).not.toHaveBeenCalled(); + + refreshSpy.mockRestore(); + }); + }); }); diff --git a/packages/web/src/auth/google/authorization/google-connect-status.util.ts b/packages/web/src/auth/google/authorization/google-connect-status.util.ts index 0df39e12da..d531cc7944 100644 --- a/packages/web/src/auth/google/authorization/google-connect-status.util.ts +++ b/packages/web/src/auth/google/authorization/google-connect-status.util.ts @@ -1,3 +1,4 @@ +import { refreshUserMetadata } from "@web/auth/compass/user/util/user-metadata.util"; import { track } from "@web/auth/posthog/track"; import { GOOGLE_CONNECT_FAILED_TOAST_ID, @@ -60,6 +61,19 @@ export function showGoogleConnectStatusToast( }); } +// A completed connect/reconnect may have widened what the connection can do +// (e.g. the optional contacts grant behind attendee suggestions), so force +// the freshest connection summaries into the store the moment the browser +// lands back — the new capability goes live in this page load, no manual +// reload. `force` chains onto any bootstrap fetch already in flight rather +// than racing it. Called once at bootstrap alongside the status toast. +export function refreshUserMetadataAfterGoogleConnect( + status: GoogleConnectStatus, +): void { + if (status !== "connected") return; + void refreshUserMetadata({ force: true }); +} + function fireGoogleConnectStatusToast(status: GoogleConnectStatus): void { const toast = getToast(); switch (status) { diff --git a/packages/web/src/auth/google/authorization/useStartGoogleAuthorization.impl.ts b/packages/web/src/auth/google/authorization/useStartGoogleAuthorization.impl.ts index 4854760624..4992203779 100644 --- a/packages/web/src/auth/google/authorization/useStartGoogleAuthorization.impl.ts +++ b/packages/web/src/auth/google/authorization/useStartGoogleAuthorization.impl.ts @@ -3,7 +3,7 @@ import { useGoogleLogin as useGoogleLoginBase, } from "@react-oauth/google"; import { useCallback, useMemo, useState } from "react"; -import { GOOGLE_AUTH_SCOPES_REQUIRED } from "./google-authorization.constants"; +import { GOOGLE_AUTH_SCOPES_REQUESTED } from "./google-authorization.constants"; import { type GoogleAuthorizationIntent, writeGoogleAuthorizationIntent, @@ -35,7 +35,10 @@ export const useStartGoogleAuthorizationImpl = ({ >( () => ({ flow: "auth-code", - scope: GOOGLE_AUTH_SCOPES_REQUIRED.join(" "), + // Requested = required + optional contacts. Callback verification + // (complete-google-authorization) checks REQUIRED only, so leaving the + // contacts boxes unchecked still signs in. + scope: GOOGLE_AUTH_SCOPES_REQUESTED.join(" "), prompt, state, ux_mode: "redirect", diff --git a/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.scope.test.tsx b/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.scope.test.tsx index bcc6c61203..95594b2180 100644 --- a/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.scope.test.tsx +++ b/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.scope.test.tsx @@ -20,6 +20,7 @@ const connection = ( lastHealthyAt: null, accountEmail: "primary@example.com", connectionState: "RECONNECT_REQUIRED", + canSuggestContacts: false, ...overrides, }); @@ -44,6 +45,7 @@ describe("useConnectGoogle account scoping", () => { stateReason: null, accountEmail: "second@example.com", connectionState: "HEALTHY", + canSuggestContacts: false, }), ], }, @@ -62,6 +64,7 @@ describe("useConnectGoogle account scoping", () => { stateReason: null, accountEmail: "second@example.com", connectionState: "HEALTHY", + canSuggestContacts: false, }); // The aggregate is RECONNECT_REQUIRED because the other account is broken. @@ -133,4 +136,51 @@ describe("useConnectGoogle account scoping", () => { beginSpy.mockRestore(); }); + + it("adds requested feature groups to the begin body (WP-06 contacts nudge)", async () => { + const beginSpy = spyOn(AuthApi, "beginGoogleConnection").mockResolvedValue({ + authorizationUrl: "#consent", + }); + + const { wrapper } = createStoreWrapper(); + const { result } = renderHook( + () => useConnectGoogle({ features: ["contacts"] }), + { wrapper }, + ); + act(() => result.current.connect()); + + await waitFor(() => { + // The aggregate here is RECONNECT_REQUIRED, so features ride along on a + // reconnect body too (incremental re-consent keeps the account pinned). + expect(beginSpy).toHaveBeenCalledWith({ + connectionId: "connection-primary", + features: ["contacts"], + }); + }); + + beginSpy.mockRestore(); + }); + + it("keeps the begin body free of features when none are requested", async () => { + const beginSpy = spyOn(AuthApi, "beginGoogleConnection").mockResolvedValue({ + authorizationUrl: "#consent", + }); + + const { wrapper } = createStoreWrapper(); + const { result } = renderHook( + () => useConnectGoogle({ newAccount: true }), + { + wrapper, + }, + ); + act(() => result.current.connect()); + + await waitFor(() => { + expect(beginSpy).toHaveBeenCalledTimes(1); + }); + // Byte-identical legacy body: no features key at all. + expect(Object.keys(beginSpy.mock.calls[0]?.[0] ?? {})).toEqual([]); + + beginSpy.mockRestore(); + }); }); diff --git a/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.ts b/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.ts index 2dba921042..8669a6a24e 100644 --- a/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.ts +++ b/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.ts @@ -1,5 +1,6 @@ import { useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useRef, useState } from "react"; +import { type ConnectionBeginFeatures } from "@core/types/sync/connection.contracts"; import { type ConnectionId } from "@core/types/sync/identity.contracts"; import { type GoogleSyncConnectionSummary } from "@core/types/user.types"; import { AuthApi } from "@web/api/auth.api"; @@ -42,6 +43,13 @@ export interface UseConnectGoogleOptions { * to a reconnect. */ newAccount?: boolean; + /** + * Optional feature groups to add to the consent request (e.g. + * `["contacts"]` for attendee suggestions). Each maps to OPTIONAL scopes + * the user may decline while the connect flow still completes; omitted, + * the begin request stays byte-identical to before features existed. + */ + features?: ConnectionBeginFeatures; } export const useConnectGoogle = ( @@ -129,11 +137,17 @@ export const useConnectGoogle = ( // sync service. Reconnect binds the flow to the primary connection id // from metadata so the wrong account cannot spawn a second. try { - const beginRequest = - options?.newAccount || + const beginRequest = { + ...(options?.newAccount || !(state === "RECONNECT_REQUIRED" && syncConnection?.id) ? {} - : { connectionId: syncConnection.id as ConnectionId }; + : { connectionId: syncConnection.id as ConnectionId }), + // Optional feature scopes ride along on connect AND reconnect; + // absent, the request body is byte-identical to before. + ...(options?.features !== undefined + ? { features: options.features } + : {}), + }; const { authorizationUrl } = await AuthApi.beginGoogleConnection(beginRequest); window.location.assign(authorizationUrl); @@ -147,7 +161,13 @@ export const useConnectGoogle = ( }; void start(); - }, [options?.newAccount, state, stopConnecting, syncConnection?.id]); + }, [ + options?.features, + options?.newAccount, + state, + stopConnecting, + syncConnection?.id, + ]); const onRefreshGoogle = useCallback( (options?: { silent?: boolean }) => { diff --git a/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.util.test.ts b/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.util.test.ts index fa9756cb91..a6da670fac 100644 --- a/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.util.test.ts +++ b/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.util.test.ts @@ -80,6 +80,7 @@ describe("getGoogleSyncStatus", () => { lastHealthyAt: "2026-07-24T12:00:00.000Z", accountEmail: "a@example.com", connectionState: "HEALTHY", + canSuggestContacts: false, }), ).toEqual({ variant: "healthy", @@ -99,6 +100,7 @@ describe("getGoogleSyncStatus", () => { lastHealthyAt: "2026-07-24T11:59:00.000Z", accountEmail: "a@example.com", connectionState: "IMPORTING", + canSuggestContacts: false, }, nowMs, ), @@ -120,6 +122,7 @@ describe("getGoogleSyncStatus", () => { lastHealthyAt: "2026-07-24T11:45:00.000Z", accountEmail: "a@example.com", connectionState: "IMPORTING", + canSuggestContacts: false, }, nowMs, ), @@ -155,6 +158,7 @@ describe("getGoogleSyncStatus", () => { lastHealthyAt: "2026-07-24T11:45:00.000Z", accountEmail: "a@example.com", connectionState: "IMPORTING", + canSuggestContacts: false, }, nowMs, ), @@ -172,6 +176,7 @@ describe("getGoogleSyncStatus", () => { lastHealthyAt: "2026-07-24T12:00:00.000Z", accountEmail: "a@example.com", connectionState: "RECONNECT_REQUIRED", + canSuggestContacts: false, }), ).toEqual({ variant: "error", @@ -189,6 +194,7 @@ describe("getGoogleSyncStatus", () => { lastHealthyAt: null, accountEmail: "a@example.com", connectionState: "IMPORTING", + canSuggestContacts: false, }), ).toEqual({ variant: "syncing", @@ -208,6 +214,7 @@ describe("getGoogleSyncStatus", () => { lastHealthyAt: null, accountEmail: null, connectionState: "ATTENTION", + canSuggestContacts: false, }, nowMs, ), @@ -229,6 +236,7 @@ describe("getGoogleSyncStatus", () => { lastHealthyAt: null, accountEmail: null, connectionState: "ATTENTION", + canSuggestContacts: false, }, nowMs, ), @@ -250,6 +258,7 @@ describe("getGoogleSyncStatus", () => { lastHealthyAt: null, accountEmail: null, connectionState: "ATTENTION", + canSuggestContacts: false, }, nowMs, { refreshGaveUp: true }, @@ -275,6 +284,7 @@ describe("getSidebarSyncStatus", () => { lastHealthyAt: "2026-07-24T11:59:00.000Z", accountEmail: "a@example.com", connectionState: "IMPORTING", + canSuggestContacts: false, }, isConnecting: false, state: "IMPORTING", @@ -294,6 +304,7 @@ describe("getSidebarSyncStatus", () => { lastHealthyAt: "2026-07-24T11:45:00.000Z", accountEmail: "a@example.com", connectionState: "IMPORTING", + canSuggestContacts: false, }, isConnecting: false, state: "IMPORTING", @@ -316,6 +327,7 @@ describe("getSidebarSyncStatus", () => { lastHealthyAt: null, accountEmail: null, connectionState: "ATTENTION", + canSuggestContacts: false, }, isConnecting: false, state: "ATTENTION", @@ -338,6 +350,7 @@ describe("getSidebarSyncStatus", () => { lastHealthyAt: null, accountEmail: null, connectionState: "ATTENTION", + canSuggestContacts: false, }, isConnecting: false, state: "ATTENTION", @@ -361,6 +374,7 @@ describe("getSidebarSyncStatus", () => { lastHealthyAt: null, accountEmail: null, connectionState: "ATTENTION", + canSuggestContacts: false, }, isConnecting: false, state: "ATTENTION", @@ -383,6 +397,7 @@ describe("getSidebarSyncStatus", () => { lastHealthyAt: null, accountEmail: "a@example.com", connectionState: "RECONNECT_REQUIRED", + canSuggestContacts: false, }, isConnecting: false, state: "RECONNECT_REQUIRED", @@ -405,6 +420,7 @@ describe("getSidebarSyncStatus", () => { lastHealthyAt: null, accountEmail: null, connectionState: "RECONNECT_REQUIRED", + canSuggestContacts: false, }, isConnecting: false, state: "RECONNECT_REQUIRED", @@ -428,6 +444,7 @@ describe("isFirstImportInProgress", () => { lastHealthyAt: null, accountEmail: "a@example.com", connectionState: "IMPORTING", + canSuggestContacts: false, ...overrides, }); @@ -497,6 +514,7 @@ describe("isFirstImportFailed", () => { lastHealthyAt: null, accountEmail: "a@example.com", connectionState: "ATTENTION", + canSuggestContacts: false, ...overrides, }); @@ -524,6 +542,7 @@ describe("isFirstImportFailed", () => { state: "importing", stateReason: null, connectionState: "IMPORTING", + canSuggestContacts: false, }), ), ).toBe(false); @@ -541,6 +560,7 @@ describe("getCalendarConnectionBannerKind", () => { lastHealthyAt: "2026-07-24T11:45:00.000Z", accountEmail: "a@example.com", connectionState: "HEALTHY", + canSuggestContacts: false, ...overrides, }); @@ -552,6 +572,7 @@ describe("getCalendarConnectionBannerKind", () => { state: "actionRequired", stateReason: "authorizationRevoked", connectionState: "RECONNECT_REQUIRED", + canSuggestContacts: false, }), ), ).toBe("reconnect"); @@ -565,6 +586,7 @@ describe("getCalendarConnectionBannerKind", () => { state: "delayed", lastHealthyAt: null, connectionState: "ATTENTION", + canSuggestContacts: false, }), ), ).toBe("importFailed"); diff --git a/packages/web/src/auth/state/user-metadata.store.test.ts b/packages/web/src/auth/state/user-metadata.store.test.ts index 45a7b28d4e..094282b5a3 100644 --- a/packages/web/src/auth/state/user-metadata.store.test.ts +++ b/packages/web/src/auth/state/user-metadata.store.test.ts @@ -17,6 +17,7 @@ const connection = ( lastHealthyAt: null, accountEmail: "ahab@pequod.com", connectionState: "HEALTHY", + canSuggestContacts: false, ...overrides, }); @@ -35,6 +36,7 @@ describe("selectPrimaryGoogleSyncConnection", () => { id: "broken", accountEmail: "starbuck@pequod.com", connectionState: "RECONNECT_REQUIRED", + canSuggestContacts: false, }); userMetadataActions.set({ google: { @@ -95,6 +97,7 @@ describe("findPrimaryGoogleSyncConnectionFromMetadata", () => { const broken = connection({ id: "broken", connectionState: "RECONNECT_REQUIRED", + canSuggestContacts: false, }); expect( diff --git a/packages/web/src/auth/state/user-metadata.store.ts b/packages/web/src/auth/state/user-metadata.store.ts index af3a3582bb..df2a99e822 100644 --- a/packages/web/src/auth/state/user-metadata.store.ts +++ b/packages/web/src/auth/state/user-metadata.store.ts @@ -113,6 +113,17 @@ export const selectGoogleSyncConnections = ( ): GoogleSyncConnectionSummary[] => state.current?.google?.connections ?? NO_CONNECTIONS; +/** + * True when ANY connected account granted the optional contacts scopes, so + * the attendee field can query live suggestions (the backend proxy fans out + * across every capable connection). `=== true` keeps a payload from an older + * backend (field absent) reading as "not granted" rather than crashing. + */ +export const selectCanSuggestContacts = (state: UserMetadataState): boolean => + (state.current?.google?.connections ?? NO_CONNECTIONS).some( + (connection) => connection.canSuggestContacts === true, + ); + /** * The connection whose own state matches the aggregate `connectionState` - * the account most responsible for it, so an unscoped reconnect targets the diff --git a/packages/web/src/calendars/calendar.util.test.ts b/packages/web/src/calendars/calendar.util.test.ts index a06aa35554..8c414f9a4c 100644 --- a/packages/web/src/calendars/calendar.util.test.ts +++ b/packages/web/src/calendars/calendar.util.test.ts @@ -410,6 +410,7 @@ describe("groupCalendarsByAccount", () => { lastHealthyAt: null, accountEmail, connectionState: "HEALTHY", + canSuggestContacts: false, }); it("buckets by account in connection order and leaves the local calendar ungrouped", () => { diff --git a/packages/web/src/common/utils/parse/dirty.parser.ts b/packages/web/src/common/utils/parse/dirty.parser.ts index 02cb696b3d..0374f302eb 100644 --- a/packages/web/src/common/utils/parse/dirty.parser.ts +++ b/packages/web/src/common/utils/parse/dirty.parser.ts @@ -3,6 +3,7 @@ import { type CompassEvent } from "@core/types/compass-event.contracts"; import { type GridEventDraft } from "@web/events/event-draft.types"; import { editGridEventDraft, + gridDraftGuestsChanged, resolveDraftRecurrenceRules, } from "@web/events/grid-event-draft.adapter"; @@ -142,6 +143,10 @@ export class DirtyParser { return true; } if (values.recurrence.kind !== orig.recurrence.kind) return true; + // A pristine edit draft never carries `attendees`; a touched guest list + // is only dirty when it actually changes membership (adding a guest and + // removing them again closes without prompting, like retyping a title). + if (gridDraftGuestsChanged(draft)) return true; return false; } diff --git a/packages/web/src/common/utils/toast/google-reconnect.toast.tsx b/packages/web/src/common/utils/toast/google-reconnect.toast.tsx index 0f3793a402..ad47cf954a 100644 --- a/packages/web/src/common/utils/toast/google-reconnect.toast.tsx +++ b/packages/web/src/common/utils/toast/google-reconnect.toast.tsx @@ -41,6 +41,9 @@ const toastScopedConnection = ( lastHealthyAt: null, accountEmail: accountEmail ?? null, connectionState: "RECONNECT_REQUIRED", + // A synthetic reconnect target, not a real summary — the credential is + // broken, so no capability can be assumed granted. + canSuggestContacts: false, }); // Shown when Google reports invalid_grant, which covers both "access expired" diff --git a/packages/web/src/components/CommandPalette/CommandPalette.test.tsx b/packages/web/src/components/CommandPalette/CommandPalette.test.tsx index cd3828c168..0e9be1d97d 100644 --- a/packages/web/src/components/CommandPalette/CommandPalette.test.tsx +++ b/packages/web/src/components/CommandPalette/CommandPalette.test.tsx @@ -319,12 +319,12 @@ describe("CommandPalette", () => { const repository: EventRepository = { list: async () => [], create: async () => before, - replace: async (id, input) => ({ - ...before, - id, - content: input.content, - schedule: input.schedule, - }), + replace: async (id, input) => { + // The write contract's guest-edit field never round-trips into the + // read-side Event content this stub returns. + const { attendees: _guestEdit, ...content } = input.content; + return { ...before, id, content, schedule: input.schedule }; + }, delete: async () => {}, }; renderPalette({ @@ -365,12 +365,12 @@ describe("CommandPalette", () => { const repository: EventRepository = { list: async () => [], create: async () => before, - replace: async (id, input) => ({ - ...before, - id, - content: input.content, - schedule: input.schedule, - }), + replace: async (id, input) => { + // The write contract's guest-edit field never round-trips into the + // read-side Event content this stub returns. + const { attendees: _guestEdit, ...content } = input.content; + return { ...before, id, content, schedule: input.schedule }; + }, delete: async () => {}, }; renderPalette({ diff --git a/packages/web/src/components/Settings/SettingsModal.test.tsx b/packages/web/src/components/Settings/SettingsModal.test.tsx index 51870ec2d2..4c426a41c1 100644 --- a/packages/web/src/components/Settings/SettingsModal.test.tsx +++ b/packages/web/src/components/Settings/SettingsModal.test.tsx @@ -120,6 +120,7 @@ const connection = ( lastHealthyAt: null, accountEmail: "ahab@pequod.com", connectionState: "HEALTHY", + canSuggestContacts: false, ...overrides, }); @@ -171,6 +172,7 @@ describe("SettingsModal", () => { state: "actionRequired", stateReason: "authorizationRevoked", connectionState: "RECONNECT_REQUIRED", + canSuggestContacts: false, }), ], }); @@ -216,6 +218,7 @@ describe("SettingsModal", () => { state: "actionRequired", stateReason: "authorizationRevoked", connectionState: "RECONNECT_REQUIRED", + canSuggestContacts: false, }), ], }); diff --git a/packages/web/src/components/Sidebar/CalendarList/CalendarListHeader.test.tsx b/packages/web/src/components/Sidebar/CalendarList/CalendarListHeader.test.tsx index 0b71fdcbd3..61920b1dc6 100644 --- a/packages/web/src/components/Sidebar/CalendarList/CalendarListHeader.test.tsx +++ b/packages/web/src/components/Sidebar/CalendarList/CalendarListHeader.test.tsx @@ -257,6 +257,7 @@ describe("CalendarListHeader", () => { lastHealthyAt: new Date().toISOString(), accountEmail: "ahab@pequod.com", connectionState: "IMPORTING" as const, + canSuggestContacts: false, }; userMetadataActions.set({ google: { @@ -289,6 +290,7 @@ describe("CalendarListHeader", () => { lastHealthyAt: new Date().toISOString(), accountEmail: "ahab@pequod.com", connectionState: "HEALTHY" as const, + canSuggestContacts: false, }; const otherAccountsBrokenConnection = { id: "conn-other", @@ -298,6 +300,7 @@ describe("CalendarListHeader", () => { lastHealthyAt: null, accountEmail: "starbuck@pequod.com", connectionState: "RECONNECT_REQUIRED" as const, + canSuggestContacts: false, }; userMetadataActions.set({ google: { diff --git a/packages/web/src/components/Sidebar/EventDetails/SidebarEventDetails.tsx b/packages/web/src/components/Sidebar/EventDetails/SidebarEventDetails.tsx index aba7a6fe35..b0802df8bd 100644 --- a/packages/web/src/components/Sidebar/EventDetails/SidebarEventDetails.tsx +++ b/packages/web/src/components/Sidebar/EventDetails/SidebarEventDetails.tsx @@ -13,6 +13,7 @@ import { } from "@web/events/stores/draft.store"; import { ConvertToStandaloneDialog } from "@web/views/Forms/EventForm/ConvertToStandaloneDialog"; import { RecurrenceScopeConfirmationDialog } from "@web/views/Forms/EventForm/RecurrenceScopeDialog"; +import { SendInvitationsDialog } from "@web/views/Forms/EventForm/SendInvitationsDialog"; import { EventFormPanel } from "@web/views/Forms/EventFormPanel/EventFormPanel"; import { useCloseEventForm } from "@web/views/Forms/hooks/useCloseEventForm"; import { useDeleteEvent } from "@web/views/Forms/hooks/useDeleteEvent"; @@ -41,6 +42,7 @@ export function SidebarEventDetails({ saveEventForm: onSave, fieldErrors, clearFieldErrors, + invitationPrompt, } = useSaveEventForm(); const onDelete = useDeleteEvent(_id as string); const onDuplicate = useDuplicateEvent(_id as string); @@ -114,6 +116,7 @@ export function SidebarEventDetails({ onCancel={confirmation.onCancelConvertToStandalone} onConfirm={confirmation.onConfirmConvertToStandalone} /> + } draft={draft} diff --git a/packages/web/src/events/event-draft.parser.ts b/packages/web/src/events/event-draft.parser.ts index 33e3efb32d..ca7be7e954 100644 --- a/packages/web/src/events/event-draft.parser.ts +++ b/packages/web/src/events/event-draft.parser.ts @@ -206,6 +206,12 @@ export function parseEventDraft(draft: EventDraft): ParseEventDraftResult { // no-op clear on create). Omitting would preserve an existing sync color // on title-only saves that still carry an explicit draft color of null. color: draft.values.color, + // Only a draft that touched the guest list carries attendees; omitted + // means "not editing guests" and preserves the provider list (see + // EditableContentSchema). + ...(draft.values.attendees !== undefined + ? { attendees: draft.values.attendees } + : {}), }; if (draft.mode === "create") { diff --git a/packages/web/src/events/event-draft.types.ts b/packages/web/src/events/event-draft.types.ts index 42f0a53660..e01a3d125a 100644 --- a/packages/web/src/events/event-draft.types.ts +++ b/packages/web/src/events/event-draft.types.ts @@ -1,5 +1,6 @@ import { type CalendarId, type EventId } from "@core/types/domain-primitives"; import { type Event } from "@core/types/event.contracts"; +import { type AttendeeInput } from "@core/types/event-attendance.contracts"; import { type EventColorSlot } from "@core/types/event-color.contracts"; import { type RecurrenceScope } from "@core/types/event-command.contracts"; @@ -34,6 +35,11 @@ type SharedEventFormValues = { schedule: EventScheduleDraft; // Null means calendar-default / no event color tag. color: EventColorSlot | null; + // Guest-list edit state, mirroring EditableContentSchema.attendees: absent + // means the draft never touched guests (saves omit attendees — preserve + // semantics); present, including [], means "replace membership with exactly + // this set" on save. + attendees?: readonly AttendeeInput[]; }; // A new draft can only use "single" or "series" and has no irrelevant diff --git a/packages/web/src/events/event.api.ts b/packages/web/src/events/event.api.ts index feff2c3963..af91b1de36 100644 --- a/packages/web/src/events/event.api.ts +++ b/packages/web/src/events/event.api.ts @@ -6,6 +6,7 @@ import { EventListResponseSchema, EventResponseSchema, type ReplaceEventInput, + type RsvpEventInput, } from "@core/types/event-command.contracts"; import { BaseApi } from "@web/api/base/base.api"; @@ -51,6 +52,14 @@ const EventApi = { `/event/${encodeURIComponent(id)}?scope=${scope}`, ); }, + + // Answer an invitation. The id addresses the target (a composite + // occurrence id for one occurrence of a series); scope "single" answers + // just that target, "all" the whole series. The backend answers 204 — the + // browser is optimistic and the provider-confirmed list settles via SSE. + rsvpEvent: async (id: EventId, input: RsvpEventInput): Promise => { + await BaseApi.post(`/event/${encodeURIComponent(id)}/rsvp`, input); + }, }; export { EventApi }; diff --git a/packages/web/src/events/grid-event-draft.adapter.ts b/packages/web/src/events/grid-event-draft.adapter.ts index a127af79b7..246c8f6910 100644 --- a/packages/web/src/events/grid-event-draft.adapter.ts +++ b/packages/web/src/events/grid-event-draft.adapter.ts @@ -5,11 +5,18 @@ import { type Calendar } from "@core/types/calendar.contracts"; import { type CompassEvent } from "@core/types/compass-event.contracts"; import { type CalendarId, type EventId } from "@core/types/domain-primitives"; import { type Event } from "@core/types/event.contracts"; +import { + type Attendee, + type AttendeeInput, +} from "@core/types/event-attendance.contracts"; import { type EventColorSlot, withColor, } from "@core/types/event-color.contracts"; -import { type RecurrenceScope } from "@core/types/event-command.contracts"; +import { + type CreateEventInput, + type RecurrenceScope, +} from "@core/types/event-command.contracts"; import dayjs from "@core/util/date/dayjs"; import { CompassEventRRule } from "@core/util/event/compass.event.rrule"; import { type GridEvent } from "@web/common/types/web.event.types"; @@ -492,7 +499,7 @@ export function patchGridDraftFields( patch: Partial< Pick< GridEventDraft["values"], - "title" | "description" | "location" | "color" + "title" | "description" | "location" | "color" | "attendees" > >, ): GridEventDraft { @@ -513,14 +520,14 @@ export function patchGridDraftFields( const applyDraftFieldPatch = < T extends Pick< GridEventDraft["values"], - "title" | "description" | "location" | "color" + "title" | "description" | "location" | "color" | "attendees" >, >( values: T, patch: Partial< Pick< GridEventDraft["values"], - "title" | "description" | "location" | "color" + "title" | "description" | "location" | "color" | "attendees" > >, ): T => ({ @@ -531,17 +538,96 @@ const applyDraftFieldPatch = < : {}), ...(patch.location !== undefined ? { location: patch.location } : {}), ...(patch.color !== undefined ? { color: patch.color } : {}), + ...(patch.attendees !== undefined ? { attendees: patch.attendees } : {}), }); +// Guest membership is keyed by email and providers treat emails +// case-insensitively (see uniqueAttendeeEmails in core), so "did the guest +// set change" is lower-cased email-set inequality — displayName drift never +// counts as an edit (the editor cannot change names). +const attendeeEmailSet = ( + attendees: ReadonlyArray>, +): Set => new Set(attendees.map(({ email }) => email.toLowerCase())); + +const attendeeEmailSetsEqual = ( + a: ReadonlyArray>, + b: ReadonlyArray>, +): boolean => { + const setA = attendeeEmailSet(a); + const setB = attendeeEmailSet(b); + return setA.size === setB.size && [...setA].every((email) => setB.has(email)); +}; + +/** + * True when the draft carries a guest edit that actually changes membership: + * against the source event's guest list for an edit draft, against the empty + * set for a create draft. An untouched draft (no `attendees` key) is never a + * guest change. + */ +export function gridDraftGuestsChanged(draft: GridEventDraft): boolean { + const edited = draft.values.attendees; + if (edited === undefined) return false; + if (draft.kind === "create") return edited.length > 0; + + const source = + draft.source.content.kind === "details" + ? (draft.source.content.attendees ?? []) + : []; + return !attendeeEmailSetsEqual(edited, source); +} + +/** + * Drops the draft's guest edit, returning it to "not editing guests" so the + * save omits `content.attendees` entirely (preserve semantics). + */ +export function withoutGuestEdit(draft: GridEventDraft): GridEventDraft { + if (draft.values.attendees === undefined) return draft; + // Branching on kind keeps create/edit values correlated with the + // discriminant (see replaceGridDraftSchedule above). + if (draft.kind === "create") { + const { attendees: _dropped, ...values } = draft.values; + return { ...draft, values }; + } + const { attendees: _dropped, ...values } = draft.values; + return { ...draft, values }; +} + +// Details content from either side of the wire: the read-shaped +// Event["content"] or the stricter write-input content (whose attendee +// entries carry no responseStatus). The helpers below only touch the fields +// the two shapes share. +type DetailsContentSource = + | Extract + | CreateEventInput["content"]; + // Event["content"].location is nullable/optional (a provider-sourced event // may never have set one, and older local records predate the field // entirely), but every editable-content consumer (draft values, replay // payloads sent to mutations.replace/create) needs a definite string. Shared // so every read of a stored event's location defaults it the same way - // also used by useUndoRedo.ts's replay/comparison paths. -export const detailsLocation = ( - content: Extract, -): string => content.location ?? ""; +export const detailsLocation = (content: DetailsContentSource): string => + content.location ?? ""; + +// Wire-boundary attendee pick: only a genuine guest edit may cross. Replay +// flows (undo/redo, snapshot restore in useUndoRedo) deliberately funnel a +// full read-side Event["content"] through the write input type, so their +// attendee entries carry responseStatus as excess structure — those must +// keep today's preserve semantics (drop the key: the strict +// AttendeeInputSchema would reject them, and a replayed non-organizer edit +// must not start tripping sync's organizer guard). A genuine guest-edit +// input's entries never carry responseStatus; they are re-picked to the +// exact input shape so no excess key can leak onto the wire. +const intendedAttendeeInputs = ( + attendees: ReadonlyArray | undefined, +): readonly AttendeeInput[] | undefined => { + if (attendees === undefined) return undefined; + const isGenuineGuestEdit = attendees.every( + (attendee) => !("responseStatus" in attendee), + ); + if (!isGenuineGuestEdit) return undefined; + return attendees.map(({ email, displayName }) => ({ email, displayName })); +}; // The write contracts (CreateEventInputSchema/ReplaceEventInputSchema) accept // only the editable subset via a strict content schema; a read-shaped @@ -552,15 +638,19 @@ export const detailsLocation = ( // Preserves color's absent-vs-null distinction: omitted leaves sync's color // alone, null clears it (see EditableContentSchema's comment) - a spread of // `color` would already do this correctly, this mirrors that. -export const editableContent = ( - content: Extract, -) => ({ - kind: "details" as const, - title: content.title, - description: content.description, - location: detailsLocation(content), - ...(content.color !== undefined ? { color: content.color } : {}), -}); +// Attendees cross only for genuine guest edits (see intendedAttendeeInputs); +// omitted means "not editing guests" and preserves the provider list. +export const editableContent = (content: DetailsContentSource) => { + const attendees = intendedAttendeeInputs(content.attendees); + return { + kind: "details" as const, + title: content.title, + description: content.description, + location: detailsLocation(content), + ...(content.color !== undefined ? { color: content.color } : {}), + ...(attendees !== undefined ? { attendees } : {}), + }; +}; const editableDetailsFromEvent = ( event: Event, diff --git a/packages/web/src/events/mutations/event.mutation.keys.ts b/packages/web/src/events/mutations/event.mutation.keys.ts index 8c5d2b794d..7ab84a065c 100644 --- a/packages/web/src/events/mutations/event.mutation.keys.ts +++ b/packages/web/src/events/mutations/event.mutation.keys.ts @@ -1,4 +1,4 @@ -export type EventMutationOperation = "create" | "replace" | "delete"; +export type EventMutationOperation = "create" | "replace" | "delete" | "rsvp"; export const eventMutationKeys = { all: ["events", "mutation"] as const, diff --git a/packages/web/src/events/mutations/useEventMutations.attendees.test.tsx b/packages/web/src/events/mutations/useEventMutations.attendees.test.tsx new file mode 100644 index 0000000000..099966e9e9 --- /dev/null +++ b/packages/web/src/events/mutations/useEventMutations.attendees.test.tsx @@ -0,0 +1,320 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import { rest } from "msw"; +import { act, type PropsWithChildren } from "react"; +import { type EventId } from "@core/types/domain-primitives"; +import { type Event } from "@core/types/event.contracts"; +import { type ReplaceEventInput } from "@core/types/event-command.contracts"; +import { server } from "@web/__tests__/__mocks__/server/mock.server"; +import { createMockEvent } from "@web/__tests__/utils/factories/event.factory"; +import { ENV_WEB } from "@web/common/constants/env.constants"; +import { eventQueryKeys } from "@web/events/queries/event.query.keys"; +import { type NormalizedEventQueryData } from "@web/events/queries/event.query.types"; +import { type EventRepository } from "@web/events/repositories/event.repository.types"; +import { RemoteEventRepository } from "@web/events/repositories/remote.event.repository"; +import { useEventMutations } from "./useEventMutations"; +import { describe, expect, it } from "bun:test"; + +// WP-04: what a guest edit puts ON THE WIRE (through MSW and the real +// RemoteEventRepository/BaseApi stack) and what it paints optimistically. + +const weekKey = eventQueryKeys.week({ + source: "remote", + start: "2026-05-04T00:00:00.000Z", + end: "2026-05-11T00:00:00.000Z", +}); + +const meetingEvent = (overrides: Partial = {}): Event => + createMockEvent({ + content: { + kind: "details", + title: "Weekly sync", + description: "", + attendees: [ + { + email: "guest@example.com", + displayName: "Guest One", + responseStatus: "accepted", + }, + ], + }, + ...overrides, + }); + +const normalized = (...events: Event[]): NormalizedEventQueryData => ({ + ids: events.map(({ id }) => id), + entities: Object.fromEntries(events.map((item) => [item.id, item])), +}); + +const replaceInput = ( + event: Event, + overrides: Partial = {}, +): ReplaceEventInput => + ({ + content: { + kind: "details", + title: "Weekly sync", + description: "", + location: "", + color: null, + }, + schedule: event.schedule, + recurrence: { kind: "preserve" }, + scope: "this", + ...overrides, + }) as ReplaceEventInput; + +const setup = (repository?: EventRepository) => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + const wrapper = ({ children }: PropsWithChildren) => ( + {children} + ); + const hook = renderHook( + () => + useEventMutations({ + source: "remote", + repository: repository ?? new RemoteEventRepository(), + markWrite: async () => {}, + reportError: () => {}, + }), + { wrapper }, + ); + return { hook, queryClient }; +}; + +const captureReplaceBody = () => { + const bodies: unknown[] = []; + server.use( + rest.put(`${ENV_WEB.API_BASEURL}/event/:id`, async (req, res, ctx) => { + bodies.push(await req.json()); + return res( + ctx.json({ event: meetingEvent({ id: req.params.id as EventId }) }), + ); + }), + ); + return bodies; +}; + +describe("useEventMutations guest edits on the wire (MSW)", () => { + it("sends content.attendees and invitation for a guest-edit replace", async () => { + const bodies = captureReplaceBody(); + const event = meetingEvent(); + const { hook } = setup(); + + act(() => { + hook.result.current.replace({ + id: event.id, + input: replaceInput(event, { + content: { + kind: "details", + title: "Weekly sync", + description: "", + location: "", + color: null, + attendees: [ + { email: "guest@example.com", displayName: null }, + { email: "new-guest@example.com", displayName: null }, + ], + }, + invitation: "all", + }), + }); + }); + + await waitFor(() => expect(bodies).toHaveLength(1)); + const body = bodies[0] as { + content: { attendees?: unknown }; + invitation?: string; + }; + expect(body.content.attendees).toEqual([ + { email: "guest@example.com", displayName: null }, + { email: "new-guest@example.com", displayName: null }, + ]); + expect(body.invitation).toBe("all"); + }); + + it("omits attendees and invitation entirely for an untouched save", async () => { + const bodies = captureReplaceBody(); + const event = meetingEvent(); + const { hook } = setup(); + + act(() => { + hook.result.current.replace({ id: event.id, input: replaceInput(event) }); + }); + + await waitFor(() => expect(bodies).toHaveLength(1)); + const body = bodies[0] as { content: object } & object; + expect(body.content).not.toContainKey("attendees"); + expect(body).not.toContainKey("invitation"); + }); + + it("strips replayed read-shaped attendee entries at the wire boundary (undo/redo stays preserve)", async () => { + const bodies = captureReplaceBody(); + const event = meetingEvent(); + const { hook } = setup(); + + act(() => { + hook.result.current.replace({ + id: event.id, + input: replaceInput(event, { + // What useUndoRedo replays: full read content, responseStatus and all. + content: { + kind: "details", + title: "Weekly sync", + description: "", + location: "", + color: null, + attendees: [ + { + email: "guest@example.com", + displayName: "Guest One", + responseStatus: "accepted", + }, + ], + } as unknown as ReplaceEventInput["content"], + }), + }); + }); + + await waitFor(() => expect(bodies).toHaveLength(1)); + const body = bodies[0] as { content: object }; + expect(body.content).not.toContainKey("attendees"); + }); +}); + +describe("useEventMutations optimistic guest list", () => { + it("paints retained guests with their current status and new guests as needsAction, then rolls back on failure", async () => { + const event = meetingEvent(); + let rejectWrite: (reason: Error) => void = () => {}; + const repository: EventRepository = { + list: async () => [], + create: async () => event, + replace: () => + new Promise((_, reject) => { + rejectWrite = reject; + }), + delete: async () => {}, + }; + const { hook, queryClient } = setup(repository); + queryClient.setQueryData(weekKey, normalized(event)); + + act(() => { + hook.result.current.replace({ + id: event.id, + input: replaceInput(event, { + content: { + kind: "details", + title: "Weekly sync", + description: "", + location: "", + color: null, + attendees: [ + { email: "guest@example.com", displayName: null }, + { email: "new-guest@example.com", displayName: "New Guest" }, + ], + }, + invitation: "all", + }), + }); + }); + + // onMutate awaits cancelQueries before painting, so the optimistic list + // lands a microtask later. + await waitFor(() => { + const optimistic = + queryClient.getQueryData(weekKey)?.entities[ + event.id + ]; + expect( + optimistic?.content.kind === "details" + ? optimistic.content.attendees + : undefined, + ).toEqual([ + // Retained: keeps the provider's responseStatus and displayName. + { + email: "guest@example.com", + displayName: "Guest One", + responseStatus: "accepted", + }, + // New: enters as needsAction until sync settles the truth. + { + email: "new-guest@example.com", + displayName: "New Guest", + responseStatus: "needsAction", + }, + ]); + }); + + act(() => { + rejectWrite(new Error("save failed")); + }); + + // Rollback restores the pre-edit guest list. + await waitFor(() => { + const restored = + queryClient.getQueryData(weekKey)?.entities[ + event.id + ]; + expect( + restored?.content.kind === "details" + ? restored.content.attendees + : undefined, + ).toEqual([ + { + email: "guest@example.com", + displayName: "Guest One", + responseStatus: "accepted", + }, + ]); + }); + }); + + it("inserts a create's intended guests as needsAction optimistically", async () => { + const repository: EventRepository = { + list: async () => [], + create: () => new Promise(() => {}), + replace: async (id) => meetingEvent({ id }), + delete: async () => {}, + }; + const { hook, queryClient } = setup(repository); + queryClient.setQueryData(weekKey, normalized()); + const event = meetingEvent(); + + act(() => { + hook.result.current.create({ + id: event.id, + calendarId: event.calendarId, + content: { + kind: "details", + title: "Kickoff", + description: "", + location: "", + color: null, + attendees: [{ email: "new-guest@example.com", displayName: null }], + }, + schedule: event.schedule, + recurrence: { kind: "single" }, + }); + }); + + await waitFor(() => { + const inserted = + queryClient.getQueryData(weekKey)?.entities[ + event.id + ]; + expect( + inserted?.content.kind === "details" + ? inserted.content.attendees + : undefined, + ).toEqual([ + { + email: "new-guest@example.com", + displayName: null, + responseStatus: "needsAction", + }, + ]); + }); + }); +}); diff --git a/packages/web/src/events/mutations/useEventMutations.rsvp.test.tsx b/packages/web/src/events/mutations/useEventMutations.rsvp.test.tsx new file mode 100644 index 0000000000..dc0072405c --- /dev/null +++ b/packages/web/src/events/mutations/useEventMutations.rsvp.test.tsx @@ -0,0 +1,291 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import { rest } from "msw"; +import { act, type PropsWithChildren } from "react"; +import { EventIdSchema } from "@core/types/domain-primitives"; +import { type Event } from "@core/types/event.contracts"; +import { type Attendee } from "@core/types/event-attendance.contracts"; +import { composeOccurrenceId } from "@core/util/occurrence-id"; +import { server } from "@web/__tests__/__mocks__/server/mock.server"; +import { createMockEvent } from "@web/__tests__/utils/factories/event.factory"; +import { ENV_WEB } from "@web/common/constants/env.constants"; +import { createObjectIdString } from "@web/common/utils/id/object-id.util"; +import { eventQueryKeys } from "@web/events/queries/event.query.keys"; +import { type NormalizedEventQueryData } from "@web/events/queries/event.query.types"; +import { useEventMutations } from "./useEventMutations"; +import { describe, expect, it } from "bun:test"; + +// WP-08: what an RSVP puts on the wire (through MSW and the real +// EventApi/BaseApi stack), what it paints optimistically (only the caller's +// own status dot), and that it rolls back on failure and settles by +// invalidation (the same path SSE eventsChanged converges through). + +const ACCOUNT_EMAIL = "Me@Example.com"; + +const weekKey = eventQueryKeys.week({ + source: "remote", + start: "2026-05-04T00:00:00.000Z", + end: "2026-05-11T00:00:00.000Z", +}); + +const selfEntry = ( + responseStatus: Attendee["responseStatus"] = "needsAction", +): Attendee => ({ + // Deliberately lower-cased vs ACCOUNT_EMAIL: the self match must be + // case-insensitive, like sync's. + email: "me@example.com", + displayName: null, + responseStatus, +}); + +const otherGuest: Attendee = { + email: "guest@example.com", + displayName: "Guest One", + responseStatus: "declined", +}; + +const invitedEvent = (overrides: Partial = {}): Event => + createMockEvent({ + content: { + kind: "details", + title: "Planning", + description: "", + attendees: [selfEntry(), otherGuest], + }, + ...overrides, + }); + +const normalized = (...events: Event[]): NormalizedEventQueryData => ({ + ids: events.map(({ id }) => id), + entities: Object.fromEntries(events.map((item) => [item.id, item])), +}); + +const setup = () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + const wrapper = ({ children }: PropsWithChildren) => ( + {children} + ); + const hook = renderHook( + () => + useEventMutations({ + source: "remote", + markWrite: async () => {}, + reportError: () => {}, + }), + { wrapper }, + ); + return { hook, queryClient }; +}; + +const captureRsvpRequests = () => { + const requests: Array<{ path: string; body: unknown }> = []; + server.use( + rest.post( + `${ENV_WEB.API_BASEURL}/event/:id/rsvp`, + async (req, res, ctx) => { + requests.push({ path: req.url.pathname, body: await req.json() }); + return res(ctx.status(204)); + }, + ), + ); + return requests; +}; + +const cachedAttendees = ( + queryClient: QueryClient, + id: string, +): readonly Attendee[] | undefined => { + const cached = + queryClient.getQueryData(weekKey)?.entities[ + EventIdSchema.parse(id) + ]; + return cached?.content.kind === "details" + ? cached.content.attendees + : undefined; +}; + +describe("useEventMutations rsvp on the wire (MSW)", () => { + it("posts responseStatus + scope single for an accept on a single event", async () => { + const requests = captureRsvpRequests(); + const event = invitedEvent(); + const { hook } = setup(); + + act(() => { + hook.result.current.rsvp({ + id: event.id, + responseStatus: "accepted", + scope: "single", + accountEmail: ACCOUNT_EMAIL, + }); + }); + + await waitFor(() => expect(requests).toHaveLength(1)); + expect(requests[0]?.path.endsWith(`/event/${event.id}/rsvp`)).toBe(true); + expect(requests[0]?.body).toEqual({ + responseStatus: "accepted", + scope: "single", + }); + }); + + it("posts the composite occurrence id for a decline scoped to this event", async () => { + const requests = captureRsvpRequests(); + const seriesId = createObjectIdString(); + const occurrenceId = composeOccurrenceId({ + eventId: seriesId, + recurrenceId: "2026-05-05T09:00:00.000Z", + }); + const occurrence = invitedEvent({ + id: EventIdSchema.parse(occurrenceId), + recurrence: { + kind: "occurrence", + seriesId: EventIdSchema.parse(seriesId), + }, + }); + const { hook } = setup(); + + act(() => { + hook.result.current.rsvp({ + id: occurrence.id, + responseStatus: "declined", + scope: "single", + accountEmail: ACCOUNT_EMAIL, + }); + }); + + await waitFor(() => expect(requests).toHaveLength(1)); + // The occurrence id (not the bare series id) rides the URL, so the + // backend's decode addresses exactly that occurrence. + expect( + requests[0]?.path.endsWith( + `/event/${encodeURIComponent(occurrenceId)}/rsvp`, + ), + ).toBe(true); + expect(requests[0]?.body).toEqual({ + responseStatus: "declined", + scope: "single", + }); + }); +}); + +describe("useEventMutations optimistic rsvp", () => { + it("paints only the caller's own status immediately and rolls back on failure", async () => { + // Hold the 503 until the optimistic paint is asserted — a same-tick + // failure would roll back before the assertion could observe the paint. + let failRequest = () => {}; + const gate = new Promise((resolve) => { + failRequest = resolve; + }); + server.use( + rest.post( + `${ENV_WEB.API_BASEURL}/event/:id/rsvp`, + async (_req, res, ctx) => { + await gate; + return res( + ctx.status(503), + ctx.json({ + code: "SYNC_UNAVAILABLE", + message: "Sync command unavailable", + retryable: true, + }), + ); + }, + ), + ); + const event = invitedEvent(); + const { hook, queryClient } = setup(); + queryClient.setQueryData(weekKey, normalized(event)); + + act(() => { + hook.result.current.rsvp({ + id: event.id, + responseStatus: "accepted", + scope: "single", + accountEmail: ACCOUNT_EMAIL, + }); + }); + + // Optimistic: self flips to accepted; the other guest's provider-owned + // status is untouched. + await waitFor(() => { + expect(cachedAttendees(queryClient, event.id)).toEqual([ + selfEntry("accepted"), + otherGuest, + ]); + }); + + act(() => failRequest()); + + // Rollback: the 503 restores the pre-answer list. + await waitFor(() => { + expect(cachedAttendees(queryClient, event.id)).toEqual([ + selfEntry(), + otherGuest, + ]); + }); + }); + + it("paints the master and every cached occurrence for a series-wide answer", async () => { + captureRsvpRequests(); + const seriesId = EventIdSchema.parse(createObjectIdString()); + const master = invitedEvent({ + id: seriesId, + recurrence: { kind: "series", rules: ["RRULE:FREQ=WEEKLY"] }, + }); + const occurrenceAt = (recurrenceId: string): Event => + invitedEvent({ + id: EventIdSchema.parse( + composeOccurrenceId({ eventId: seriesId, recurrenceId }), + ), + recurrence: { kind: "occurrence", seriesId }, + }); + const first = occurrenceAt("2026-05-05T09:00:00.000Z"); + const second = occurrenceAt("2026-05-06T09:00:00.000Z"); + const { hook, queryClient } = setup(); + queryClient.setQueryData(weekKey, normalized(master, first, second)); + + act(() => { + hook.result.current.rsvp({ + id: first.id, + responseStatus: "tentative", + scope: "all", + accountEmail: ACCOUNT_EMAIL, + }); + }); + + await waitFor(() => { + for (const id of [master.id, first.id, second.id]) { + expect(cachedAttendees(queryClient, id)).toEqual([ + selfEntry("tentative"), + otherGuest, + ]); + } + }); + }); + + // Regression only — the live-update path already exists: a confirmed RSVP + // settles by invalidating the event queries, the same invalidation SSE + // eventsChanged rides, so the provider-confirmed list (anyone's RSVP, not + // just ours) wins over the optimistic paint on the next refetch. + it("invalidates event queries after the answer settles", async () => { + const requests = captureRsvpRequests(); + const event = invitedEvent(); + const { hook, queryClient } = setup(); + queryClient.setQueryData(weekKey, normalized(event)); + + act(() => { + hook.result.current.rsvp({ + id: event.id, + responseStatus: "accepted", + scope: "single", + accountEmail: ACCOUNT_EMAIL, + }); + }); + + await waitFor(() => expect(requests).toHaveLength(1)); + await waitFor(() => { + expect(queryClient.getQueryState(weekKey)?.isInvalidated).toBe(true); + }); + }); +}); diff --git a/packages/web/src/events/mutations/useEventMutations.ts b/packages/web/src/events/mutations/useEventMutations.ts index 3375e82005..3795afd8f5 100644 --- a/packages/web/src/events/mutations/useEventMutations.ts +++ b/packages/web/src/events/mutations/useEventMutations.ts @@ -15,10 +15,16 @@ import { type EventRecurrence, type EventSchedule, } from "@core/types/event.contracts"; +import { + type Attendee, + type AttendeeInput, + type RsvpResponseStatus, +} from "@core/types/event-attendance.contracts"; import { type CreateEventInput, type RecurrenceScope, type ReplaceEventInput, + type RsvpEventInput, } from "@core/types/event-command.contracts"; import { shiftSeriesScheduleByOccurrenceEdit } from "@core/util/event/shift-series-schedule-by-occurrence-edit"; import { decodeOccurrenceId } from "@core/util/occurrence-id"; @@ -44,6 +50,7 @@ import { showRecurrenceScopeSuccessToast, } from "@web/common/utils/toast/recurrence-scope.toast"; import { noteFirstRealEventCreated } from "@web/components/FirstEventPrompt/first-event.store"; +import { EventApi } from "@web/events/event.api"; import { editableContent } from "@web/events/grid-event-draft.adapter"; import { applyEventProjectionAcrossQueries, @@ -53,6 +60,7 @@ import { getEventQueryEntries, insertEventIntoQueries, isEventQueryKey, + patchEventInQueries, removeEventFromQueries, upsertEventAcrossQueries, } from "@web/events/queries/event.query.cache"; @@ -200,14 +208,98 @@ function resolveRemoteReplaceSchedule( }; } +// Replay flows (undo/redo, snapshot restore in useUndoRedo) deliberately +// funnel a full read-side Event["content"] through the write input type, so +// their attendee entries really carry responseStatus as excess structure; a +// genuine guest-edit input's entries never do. When building optimistic +// Event content, keep only real read-state entries: a replay restores its +// guest list without flicker exactly as before. +function readAttendees( + attendees: CreateEventInput["content"]["attendees"], +): readonly Attendee[] | undefined { + if (attendees === undefined) return undefined; + const withStatus = attendees.filter( + (attendee): attendee is Attendee => "responseStatus" in attendee, + ); + return withStatus.length === attendees.length ? withStatus : undefined; +} + +// The counterpart to readAttendees: a genuine guest edit (no entry carries +// responseStatus — see the wire boundary in grid-event-draft.adapter.ts). +function isGuestEditInput( + attendees: CreateEventInput["content"]["attendees"], +): attendees is readonly AttendeeInput[] { + return ( + attendees?.every((attendee) => !("responseStatus" in attendee)) ?? false + ); +} + +// Optimistic read-shape for an intended guest list, mirroring sync's +// merge-by-email (WP-02) and the backend's synthesized response (WP-03): +// retained emails keep their current responseStatus/displayName, new emails +// enter as needsAction, dropped emails disappear. The settle refetch still +// owns the provider-confirmed list; a failed save rolls back through the +// snapshot restore like every other optimistic write. +function optimisticGuestList( + intended: readonly AttendeeInput[], + existing: readonly Attendee[] | undefined, +): readonly Attendee[] { + const byEmail = new Map( + (existing ?? []).map((attendee) => [ + attendee.email.toLowerCase(), + attendee, + ]), + ); + return intended.map( + (attendee) => + byEmail.get(attendee.email.toLowerCase()) ?? { + email: attendee.email, + displayName: attendee.displayName, + responseStatus: "needsAction" as const, + }, + ); +} + +// Optimistic RSVP: rewrite only the caller's own attendee entry (matched by +// the calendar's account email, case-insensitively — the same self-match sync +// applies), leaving every other guest's provider-owned status untouched. +// Events without a matching entry pass through unchanged. +function rsvpEventContent( + event: Event, + accountEmail: string, + responseStatus: RsvpResponseStatus, +): Event { + if (event.content.kind !== "details" || !event.content.attendees) { + return event; + } + return { + ...event, + content: { + ...event.content, + attendees: event.content.attendees.map((attendee) => + attendee.email.toLowerCase() === accountEmail.toLowerCase() + ? { ...attendee, responseStatus } + : attendee, + ), + }, + }; +} + // A create's optimistic insert needs a full Event before the server response // lands; recurrence is a strict subset of EditableRecurrence ("single" | // "series"), so it's assignable as-is. function optimisticEventFromCreate(input: CreateEventInput): Event { + const { attendees, ...details } = input.content; + const optimisticAttendees = isGuestEditInput(attendees) + ? optimisticGuestList(attendees, undefined) + : readAttendees(attendees); return { id: input.id as EventId, calendarId: input.calendarId, - content: input.content, + content: + optimisticAttendees === undefined + ? details + : { ...details, attendees: optimisticAttendees }, schedule: input.schedule, recurrence: input.recurrence as EventRecurrence, createdAt: nowDateTime(), @@ -216,7 +308,7 @@ function optimisticEventFromCreate(input: CreateEventInput): Event { } // The form only ever submits the editable subset (title/description/ -// location/color) — organizer/attendees/conference are read-only, +// location/color/attendees) — organizer/conference stay read-only, // provider-sourced fields the server never asks the client to resubmit. // Merging input.content wholesale onto the optimistic cache entry would make // those fields flicker away until the settle-time refetch restores them from @@ -225,15 +317,29 @@ function mergeReplaceContent( existing: Event["content"], input: ReplaceEventInput["content"], ): Event["content"] { - if (existing.kind !== "details") return input; + // Same replay-vs-guest-edit split as optimisticEventFromCreate: read-state + // attendee entries re-enter the optimistic cache as-is; a genuine guest + // edit merges optimistically against the cached list (new guests render as + // needsAction immediately and settle from sync). + const { attendees, ...details } = input; + const existingAttendees = + existing.kind === "details" ? existing.attendees : undefined; + const optimisticAttendees = isGuestEditInput(attendees) + ? optimisticGuestList(attendees, existingAttendees) + : readAttendees(attendees); + const replayInput = + optimisticAttendees === undefined + ? details + : { ...details, attendees: optimisticAttendees }; + if (existing.kind !== "details") return replayInput; // Slot writes (including null clear) supersede a provider custom hex on the // optimistic card. Palette resolution prefers colorHex over color, so keeping // the old hex would leave the prior fill until settle/refetch. - if (input.kind === "details" && input.color !== undefined) { + if (replayInput.kind === "details" && replayInput.color !== undefined) { const { colorHex: _cleared, ...withoutHex } = existing; - return { ...withoutHex, ...input }; + return { ...withoutHex, ...replayInput }; } - return { ...existing, ...input }; + return { ...existing, ...replayInput }; } function mergeReplaceInput(existing: Event, input: ReplaceEventInput): Event { @@ -295,6 +401,21 @@ type DeleteVariables = { originalOverride?: Event; opportunityId?: number; }; +type RsvpVariables = { + id: EventId; + input: RsvpEventInput; + // The connected account answering — the self attendee entry is matched by + // the calendar's account email, case-insensitively (same rule sync applies). + accountEmail: string; + writeKey: EventId; +}; + +export type RsvpPayload = { + id: EventId; + responseStatus: RsvpResponseStatus; + scope: RsvpEventInput["scope"]; + accountEmail: string; +}; export type EventMutationCallbacks = { onSuccess?: () => void; @@ -309,6 +430,7 @@ export type EventMutations = { callbacks?: EventMutationCallbacks, ) => boolean; delete: (payload: { id: EventId; scope: RecurrenceScope }) => void; + rsvp: (payload: RsvpPayload) => void; promoteRecurring: ( opportunity: RecurrenceScopeOpportunity, scope: "thisAndFollowing" | "all", @@ -740,6 +862,42 @@ export function useEventMutations( ), ); + // WP-08: answer an invitation. Goes straight to EventApi rather than the + // repository — RSVP only exists for provider-backed events (the control is + // hidden on local calendars), and it deliberately skips the read-only + // target gate below: answering is allowed on viewer-access calendars + // because it is not a calendar write. Rollback rides the shared snapshot + // restore; the settle-time invalidation converges to the provider truth + // (SSE eventsChanged lands on this same invalidation path). + const rsvpMutation = useMutation( + buildMutation( + "rsvp", + (variables) => EventApi.rsvpEvent(variables.id, variables.input), + ({ id, input, accountEmail }) => { + const rewrite = (event: Event) => + rsvpEventContent(event, accountEmail, input.responseStatus); + patchEventInQueries(queryClient, id, rewrite, { source }); + if (input.scope !== "all") return; + // Series-wide answer: paint the master and every cached occurrence + // too, so the user's dot doesn't flicker per-instance until settle. + const existing = findEventInCache(queryClient, id, source); + const parts = decodeOccurrenceId(id); + const seriesId = + seriesIdOf(existing) ?? + (parts ? EventIdSchema.parse(parts.eventId) : null); + if (!seriesId) return; + patchEventInQueries(queryClient, seriesId, rewrite, { source }); + for (const sibling of findSeriesEventsInCache( + queryClient, + seriesId, + source, + )) { + patchEventInQueries(queryClient, sibling.id, rewrite, { source }); + } + }, + ), + ); + // Undo recording happens here at the `.mutate()` boundary: it's the one // place every caller funnels through and the cache still holds the // pre-mutation event. Replays from useUndoRedo set the restoring flag so @@ -878,6 +1036,25 @@ export function useEventMutations( opportunityId, }); }, + rsvp: ({ id, responseStatus, scope, accountEmail }: RsvpPayload) => { + const original = findEventInCache(queryClient, id, source); + // No isTargetReadOnly gate on purpose (finish line 5: RSVP works on + // viewer-access calendars) — but a reconnect-required Google account + // cannot deliver any command, so that block stays. + if (blockReconnectRequiredCalendar(original?.calendarId)) { + return; + } + // A series-wide answer serializes against the series id, same as a + // scope-"all" replace. + const writeKey = + scope === "all" ? seriesWriteKey(original, "all", id) : id; + rsvpMutation.mutate({ + id, + input: { responseStatus, scope }, + accountEmail, + writeKey, + }); + }, promoteRecurring: ( opportunity: RecurrenceScopeOpportunity, scope: "thisAndFollowing" | "all", @@ -940,6 +1117,7 @@ export function useEventMutations( createMutation.mutate, deleteMutation.mutate, replaceMutation.mutate, + rsvpMutation.mutate, ], ); } diff --git a/packages/web/src/events/repositories/local.event.repository.ts b/packages/web/src/events/repositories/local.event.repository.ts index a2a0ee2ce7..659cde0abd 100644 --- a/packages/web/src/events/repositories/local.event.repository.ts +++ b/packages/web/src/events/repositories/local.event.repository.ts @@ -72,6 +72,19 @@ function truncateRules( }); } +// Local calendars have no attendee support (a guest-edit against one is the +// backend's typed rejection, and the wire boundary in useEventMutations +// already strips content to the editable subset before any repository call), +// so the write-only guest-edit field never reaches here with a value at +// runtime. Dropping the key keeps the stored record typed to the read-side +// Event["content"], whose attendee entries carry responseStatus. +function detailsContent({ + attendees: _guestEdit, + ...content +}: CreateEventInput["content"]): Event["content"] { + return content; +} + // Shared by create/replace/replaceSeries: "preserve" keeps the existing // event's recurrence (not meaningful for create, which has no existing // event), "series"/"single" apply the input's own recurrence. @@ -179,7 +192,7 @@ export class LocalEventRepository implements EventRepository { const event: Event = { id, calendarId: input.calendarId, - content: input.content, + content: detailsContent(input.content), schedule: input.schedule, recurrence: resolveRecurrence(input.recurrence, { kind: "single" }), createdAt: now, @@ -219,7 +232,7 @@ export class LocalEventRepository implements EventRepository { input.calendarId ?? existing?.calendarId ?? getLocalCalendarSentinelId(), - content: input.content, + content: detailsContent(input.content), schedule: input.schedule, recurrence: resolveRecurrence(input.recurrence, recurrenceFallback), createdAt: existing?.createdAt ?? nowDateTime(), @@ -271,7 +284,7 @@ export class LocalEventRepository implements EventRepository { const event: Event = { ...record.event, calendarId: input.calendarId ?? record.event.calendarId, - content: input.content, + content: detailsContent(input.content), schedule, recurrence, updatedAt: nowDateTime(), @@ -294,7 +307,7 @@ export class LocalEventRepository implements EventRepository { const event: Event = { id, calendarId: input.calendarId ?? record.event.calendarId, - content: input.content, + content: detailsContent(input.content), schedule: input.schedule, recurrence, createdAt: nowDateTime(), diff --git a/packages/web/src/events/repositories/remote.event.repository.test.ts b/packages/web/src/events/repositories/remote.event.repository.test.ts index 40c92ef6a0..7b69458e26 100644 --- a/packages/web/src/events/repositories/remote.event.repository.test.ts +++ b/packages/web/src/events/repositories/remote.event.repository.test.ts @@ -13,6 +13,10 @@ const api = { list: mock(), replace: mock(), delete: mock(), + // Not part of the repository surface — RSVP bypasses the repository (it + // only exists for provider-backed events), but the mock mirrors the full + // EventApi shape. + rsvpEvent: mock(), } satisfies Record>; const repository = new RemoteEventRepository(api as unknown as typeof EventApi); diff --git a/packages/web/src/index.css b/packages/web/src/index.css index d7d75d4f0c..ae36611d98 100644 --- a/packages/web/src/index.css +++ b/packages/web/src/index.css @@ -897,6 +897,44 @@ } } +/* + * Guest email-chip combobox (AttendeeField). Unlayered for the same reason + * as .c-time-picker above: it must override react-select's unlayered CSS. + */ +.c-attendee-field { + @apply relative w-full min-w-0; + font-size: var(--font-size-m); + & span[aria-live="polite"], + & .attendee-field__indicators { + display: none; + } + & .attendee-field__control { + min-height: 30px; + border: 0; + border-radius: var(--radius-default); + background: transparent; + box-shadow: none; + transition: var(--transition-default); + } + & .attendee-field__control--is-focused { + box-shadow: 0 0 0 2px var(--accent); + } + & .attendee-field__value-container { + padding: 0; + gap: 4px; + } + & .attendee-field__menu { + min-width: 220px; + border: 1px solid var(--color-border); + border-radius: 2px; + background: var(--surface); + } + & .attendee-field__menu-list { + padding: 0; + font-size: var(--font-size-m); + } +} + /* * Unlayered (plain rule, not @utility) so it overrides react-datepicker's * unlayered defaults and the c-date-picker base. Being unlayered + defined diff --git a/packages/web/src/sse/hooks/useTransientSyncPolling.test.ts b/packages/web/src/sse/hooks/useTransientSyncPolling.test.ts index bc3a25bf8e..a2b1b6e4b4 100644 --- a/packages/web/src/sse/hooks/useTransientSyncPolling.test.ts +++ b/packages/web/src/sse/hooks/useTransientSyncPolling.test.ts @@ -54,6 +54,7 @@ describe("useTransientSyncPolling", () => { lastHealthyAt: null, accountEmail: "a@example.com", connectionState: "IMPORTING", + canSuggestContacts: false, }, ], }, @@ -84,6 +85,7 @@ describe("useTransientSyncPolling", () => { lastHealthyAt: null, accountEmail: "a@example.com", connectionState: "HEALTHY", + canSuggestContacts: false, }, ], }, @@ -108,6 +110,7 @@ describe("useTransientSyncPolling", () => { lastHealthyAt: null, accountEmail: "a@example.com", connectionState: "HEALTHY", + canSuggestContacts: false, }, ], }, diff --git a/packages/web/src/views/Day/hooks/shortcuts/useDayEventNudgeShortcuts.test.tsx b/packages/web/src/views/Day/hooks/shortcuts/useDayEventNudgeShortcuts.test.tsx index 10347e73fa..d131312cd4 100644 --- a/packages/web/src/views/Day/hooks/shortcuts/useDayEventNudgeShortcuts.test.tsx +++ b/packages/web/src/views/Day/hooks/shortcuts/useDayEventNudgeShortcuts.test.tsx @@ -177,12 +177,17 @@ const renderEditShortcuts = ({ const repository: EventRepository = { list: async () => [], create: async () => timedEventContract, - replace: async (id, input) => ({ - ...(id === ALL_DAY_EVENT_ID ? allDayEventContract : timedEventContract), - id, - content: input.content, - schedule: input.schedule, - }), + replace: async (id, input) => { + // The write contract's guest-edit field never round-trips into the + // read-side Event content this stub returns. + const { attendees: _guestEdit, ...content } = input.content; + return { + ...(id === ALL_DAY_EVENT_ID ? allDayEventContract : timedEventContract), + id, + content, + schedule: input.schedule, + }; + }, delete: async () => {}, }; const dependencies = { diff --git a/packages/web/src/views/Forms/EventForm/AttendeeField/AttendeeField.test.tsx b/packages/web/src/views/Forms/EventForm/AttendeeField/AttendeeField.test.tsx new file mode 100644 index 0000000000..48314d2eb0 --- /dev/null +++ b/packages/web/src/views/Forms/EventForm/AttendeeField/AttendeeField.test.tsx @@ -0,0 +1,219 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useState } from "react"; +import { type AttendeeInput } from "@core/types/event-attendance.contracts"; +import { isFloatingLayerOpen } from "@web/shortcuts/floating-layer"; +import { AttendeeField } from "./AttendeeField"; +import { describe, expect, it, mock } from "bun:test"; + +const alice: AttendeeInput = { email: "alice@example.com", displayName: null }; +const bob: AttendeeInput = { email: "bob@example.com", displayName: "Bob B" }; + +function Harness({ + initialValue = [], + onFormSubmit = () => {}, + onEscapeReachedForm = () => {}, + onValueChange = () => {}, +}: { + initialValue?: readonly AttendeeInput[]; + onFormSubmit?: () => void; + onEscapeReachedForm?: (key: string) => void; + onValueChange?: (next: readonly AttendeeInput[]) => void; +}) { + const [value, setValue] = useState(initialValue); + + return ( + // biome-ignore lint/a11y/noStaticElementInteractions: test stand-in for the form's document-level key handling +
{ + // Stand-in for the form's own Escape/Enter handling: only keys the + // combobox lets bubble arrive here. + onEscapeReachedForm(e.key); + }} + > +
{ + e.preventDefault(); + onFormSubmit(); + }} + > + { + setValue(next); + onValueChange(next); + }} + /> + + +
+ ); +} + +describe("AttendeeField", () => { + it("creates a chip from a valid email on Enter without submitting the form", async () => { + const user = userEvent.setup(); + const onFormSubmit = mock(); + const onValueChange = mock(); + render( + , + ); + + const combobox = screen.getByRole("combobox", { name: "Guests" }); + await user.type(combobox, "alice@example.com"); + expect(screen.getByRole("listbox")).toBeInTheDocument(); + await user.keyboard("{Enter}"); + + expect(onValueChange).toHaveBeenCalledWith([alice]); + expect(screen.getByText("alice@example.com")).toBeInTheDocument(); + expect(onFormSubmit).not.toHaveBeenCalled(); + }); + + it("rejects an invalid email inline: no chip, no submit, form still usable", async () => { + const user = userEvent.setup(); + const onFormSubmit = mock(); + const onValueChange = mock(); + render( + , + ); + + const combobox = screen.getByRole("combobox", { name: "Guests" }); + await user.click(combobox); + await user.paste("not-an-email"); + expect(screen.getByText("Enter a valid email address")).toBeInTheDocument(); + + await user.keyboard("{Enter}"); + expect(onValueChange).not.toHaveBeenCalled(); + expect(onFormSubmit).not.toHaveBeenCalled(); + + // The rejection is inline only - the form's own submit path still works. + await user.click(screen.getByRole("button", { name: "Save" })); + expect(onFormSubmit).toHaveBeenCalledTimes(1); + }); + + it("refuses a duplicate email (case-insensitive) with an inline message", async () => { + const user = userEvent.setup(); + const onValueChange = mock(); + render(); + + const combobox = screen.getByRole("combobox", { name: "Guests" }); + await user.type(combobox, "ALICE@example.com"); + + expect( + screen.getByText("ALICE@example.com is already a guest"), + ).toBeInTheDocument(); + await user.keyboard("{Enter}"); + expect(onValueChange).not.toHaveBeenCalled(); + }); + + it("shows displayName on chips and removes one via its remove button", async () => { + const user = userEvent.setup(); + const onValueChange = mock(); + render( + , + ); + + expect(screen.getByText("Bob B")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Remove Bob B" })); + + expect(onValueChange).toHaveBeenCalledWith([alice]); + }); + + it("removes the last chip with Backspace on an empty input", async () => { + const user = userEvent.setup(); + const onValueChange = mock(); + render( + , + ); + + await user.click(screen.getByRole("combobox", { name: "Guests" })); + await user.keyboard("{Backspace}"); + + expect(onValueChange).toHaveBeenCalledWith([alice]); + }); + + it("registers a floating layer while the listbox is open and closes it on Escape before the form sees the key", async () => { + const user = userEvent.setup(); + const escapesAtForm: string[] = []; + render( + { + if (key === "Escape") escapesAtForm.push(key); + }} + />, + ); + + const combobox = screen.getByRole("combobox", { name: "Guests" }); + await user.type(combobox, "alice@"); + expect(screen.getByRole("listbox")).toBeInTheDocument(); + expect(isFloatingLayerOpen()).toBe(true); + + // First Escape: closes the listbox, never reaches the form. + await user.keyboard("{Escape}"); + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); + expect(isFloatingLayerOpen()).toBe(false); + expect(escapesAtForm).toHaveLength(0); + + // Second Escape: nothing left to close here, so the form gets it. + await user.keyboard("{Escape}"); + expect(escapesAtForm).toHaveLength(1); + }); + + it("offers entries from the pluggable suggestion source and maps a pick to its attendee", async () => { + const user = userEvent.setup(); + const onValueChange = mock(); + const suggestionSource = mock(async (query: string) => + "carol team".includes(query.toLowerCase()) + ? [{ email: "carol@example.com", displayName: "Carol" }] + : [], + ); + render( + , + ); + + const combobox = screen.getByRole("combobox", { name: "Guests" }); + await user.type(combobox, "carol"); + + expect(await screen.findByText("Carol")).toBeInTheDocument(); + await user.click(screen.getByText("Carol")); + + expect(onValueChange).toHaveBeenCalledWith([ + { email: "carol@example.com", displayName: "Carol" }, + ]); + expect(suggestionSource).toHaveBeenCalledWith("carol"); + }); + + it("renders the menuFooter inside the open listbox menu, and not before", async () => { + const user = userEvent.setup(); + render( + {}} + menuFooter={
Enable contact suggestions
} + />, + ); + + // Closed menu: no footer anywhere (the affordance lives in the combobox + // footer only — never floating free, never a modal). + expect( + screen.queryByText("Enable contact suggestions"), + ).not.toBeInTheDocument(); + + const combobox = screen.getByRole("combobox", { name: "Guests" }); + await user.type(combobox, "ca"); + + expect(screen.getByRole("listbox")).toBeInTheDocument(); + expect(screen.getByText("Enable contact suggestions")).toBeInTheDocument(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + + // Menu closes -> footer goes with it. + await user.keyboard("{Escape}"); + expect( + screen.queryByText("Enable contact suggestions"), + ).not.toBeInTheDocument(); + }); +}); diff --git a/packages/web/src/views/Forms/EventForm/AttendeeField/AttendeeField.tsx b/packages/web/src/views/Forms/EventForm/AttendeeField/AttendeeField.tsx new file mode 100644 index 0000000000..0e616b56fe --- /dev/null +++ b/packages/web/src/views/Forms/EventForm/AttendeeField/AttendeeField.tsx @@ -0,0 +1,281 @@ +import { + createContext, + type ReactNode, + useContext, + useId, + useMemo, + useRef, + useState, +} from "react"; +import { + type CSSObjectWithLabel, + type GroupBase, + type MenuProps, + type MultiValue, + components as selectComponents, +} from "react-select"; +import CreatableSelect from "react-select/creatable"; +import { type AttendeeInput } from "@core/types/event-attendance.contracts"; +import { useFloatingLayer } from "@web/shortcuts/floating-layer"; + +/** + * Pluggable suggestion source for the guest combobox. WP-06 plugs the + * Google-contacts proxy in here; until then the field defaults to no + * suggestions and is a plain email-chip input. + */ +export type AttendeeSuggestionSource = ( + query: string, +) => Promise; + +const emptySuggestionSource: AttendeeSuggestionSource = () => + Promise.resolve([]); + +interface AttendeeOption { + /** Lower-cased email — react-select's identity key for chips/options. */ + value: string; + label: string; + attendee: AttendeeInput; +} + +const toOption = (attendee: AttendeeInput): AttendeeOption => ({ + value: attendee.email.toLowerCase(), + label: attendee.displayName ?? attendee.email, + attendee, +}); + +// Pragmatic shape check, not RFC 5322: something@something.tld with no +// whitespace. The core contract only enforces length (AttendeeInputSchema), +// so this is the gate that keeps junk strings from ever becoming chips. +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +const isValidAttendeeEmail = (value: string): boolean => { + const email = value.trim(); + return email.length > 0 && email.length <= 320 && EMAIL_PATTERN.test(email); +}; + +const ATTENDEE_FIELD = "attendee-field"; + +// The menu footer travels by context, not by a custom react-select prop: +// the Menu component below stays a stable module-scope identity (react-select +// remounts the whole menu when component identities change per render) while +// each AttendeeField instance provides its own footer node. +const AttendeeMenuFooterContext = createContext(null); + +// Menu with an optional non-scrolling footer under the option list — the +// combobox-footer slot the enable-contacts nudge renders into (WP-06). With +// no footer in context this is exactly react-select's own Menu. +const AttendeeMenu = ( + props: MenuProps>, +) => { + const footer = useContext(AttendeeMenuFooterContext); + return ( + + {props.children} + {footer} + + ); +}; + +const attendeeFieldComponents = { Menu: AttendeeMenu }; + +// react-select's emotion style objects beat class-based CSS for these text +// roles — same recipe as TimePicker/FreqSelect. +const themeColor = + (cssVar: string) => + (base: CSSObjectWithLabel): CSSObjectWithLabel => ({ + ...base, + color: cssVar, + }); + +const attendeeFieldStyles = { + input: themeColor("var(--text)"), + placeholder: themeColor("var(--text-muted)"), + noOptionsMessage: themeColor("var(--text-muted)"), + option: ( + base: CSSObjectWithLabel, + { isFocused }: { isFocused: boolean }, + ): CSSObjectWithLabel => ({ + ...base, + color: isFocused ? "var(--on-accent)" : "var(--text)", + backgroundColor: isFocused ? "var(--accent)" : "transparent", + }), + multiValue: (base: CSSObjectWithLabel): CSSObjectWithLabel => ({ + ...base, + backgroundColor: "var(--surface-raised)", + borderRadius: "var(--radius-default)", + }), + multiValueLabel: themeColor("var(--text)"), + multiValueRemove: (base: CSSObjectWithLabel): CSSObjectWithLabel => ({ + ...base, + color: "var(--text-muted)", + ":hover": { + backgroundColor: "var(--error)", + color: "var(--on-accent)", + }, + }), +}; + +export interface AttendeeFieldProps { + id?: string; + /** Current guest chips, in the write-input shape (no responseStatus). */ + value: readonly AttendeeInput[]; + onChange: (next: readonly AttendeeInput[]) => void; + suggestionSource?: AttendeeSuggestionSource; + /** + * Rendered at the bottom of the open listbox (non-scrolling) — the slot + * the "Enable contact suggestions" nudge lives in when the contacts + * capability is absent. Never a modal. + */ + menuFooter?: ReactNode; +} + +/** + * Email-chip combobox for the event form's guest list. Typing a valid email + * and pressing Enter (or picking a suggestion) adds a chip; Backspace or the + * chip's remove button drops one; invalid strings never become chips and get + * an inline "Enter a valid email address" rejection in the listbox instead. + * Enter/Backspace stop at the combobox (isComboboxInteraction also gates the + * form's own shortcuts) and Escape closes the listbox before the form + * (registered as a floating layer while open). + */ +export const AttendeeField = ({ + id, + value, + onChange, + suggestionSource = emptySuggestionSource, + menuFooter = null, +}: AttendeeFieldProps) => { + const [inputValue, setInputValue] = useState(""); + const [isMenuOpen, setIsMenuOpen] = useState(false); + const [suggestions, setSuggestions] = useState([]); + // Monotonic guard so a slow suggestion query can never clobber the results + // of a newer one (or of a cleared input). + const queryVersionRef = useRef(0); + const layerId = useId(); + useFloatingLayer(`attendeeField:${layerId}`, isMenuOpen); + + const selectedOptions = useMemo(() => value.map(toOption), [value]); + const selectedEmails = useMemo( + () => new Set(value.map((attendee) => attendee.email.toLowerCase())), + [value], + ); + const options = useMemo( + () => + suggestions + .filter((entry) => !selectedEmails.has(entry.email.toLowerCase())) + .map(toOption), + [selectedEmails, suggestions], + ); + + const loadSuggestions = (query: string) => { + const version = ++queryVersionRef.current; + suggestionSource(query) + .then((results) => { + if (queryVersionRef.current === version) setSuggestions(results); + }) + .catch(() => { + if (queryVersionRef.current === version) setSuggestions([]); + }); + }; + + const closeMenu = () => { + queryVersionRef.current += 1; + setInputValue(""); + setSuggestions([]); + setIsMenuOpen(false); + }; + + return ( +
+ + + inputId={id} + aria-label="Guests" + classNamePrefix={ATTENDEE_FIELD} + components={attendeeFieldComponents} + styles={attendeeFieldStyles} + isMulti + isClearable={false} + // The suggestion source already matched (and ranked) against the + // query — People matches can hinge on fields the label/value never + // show (e.g. a nickname), so react-select's default substring + // filter would silently hide legitimate suggestions. + filterOption={null} + value={selectedOptions} + options={options} + inputValue={inputValue} + menuIsOpen={isMenuOpen} + placeholder="Add guests" + onChange={(next: MultiValue) => { + onChange(next.map((option) => option.attendee)); + }} + onInputChange={(nextInput, { action }) => { + if (action === "input-change") { + setInputValue(nextInput); + const query = nextInput.trim(); + setIsMenuOpen(query.length > 0); + if (query.length > 0) loadSuggestions(query); + else setSuggestions([]); + return; + } + // set-value (chip added), input-blur, menu-close: reset the query. + closeMenu(); + }} + onKeyDown={(e) => { + const key = e.key; + + // Chip creation/removal are the combobox's own interactions — + // never the form's Enter-to-save or Delete-event shortcuts. + if (key === "Enter" || key === "Backspace" || key === "Delete") { + e.stopPropagation(); + } + + // Swallow Enter whenever react-select would not consume it itself + // (menu closed, or open with nothing selectable — e.g. an invalid + // email showing the inline rejection): otherwise the native + // keypress submits the surrounding form. + if (key === "Enter") { + const query = inputValue.trim(); + const canCreateChip = + isValidAttendeeEmail(query) && + !selectedEmails.has(query.toLowerCase()); + if (!isMenuOpen || (!canCreateChip && options.length === 0)) { + e.preventDefault(); + } + } + + if (key === "Escape" && isMenuOpen) { + // Close the listbox only; a second Escape (menu closed, so this + // handler lets it bubble) reaches the form's close handler. + closeMenu(); + e.stopPropagation(); + } + }} + isValidNewOption={(candidate) => + isValidAttendeeEmail(candidate) && + !selectedEmails.has(candidate.trim().toLowerCase()) + } + getNewOptionData={(candidate) => { + const email = candidate.trim(); + return { + value: email.toLowerCase(), + label: email, + attendee: { email, displayName: null }, + }; + }} + formatCreateLabel={(candidate) => `Add "${candidate.trim()}"`} + noOptionsMessage={({ inputValue: query }) => { + const email = query.trim(); + if (email.length === 0) return null; + if (selectedEmails.has(email.toLowerCase())) { + return `${email} is already a guest`; + } + return "Enter a valid email address"; + }} + createOptionPosition="first" + tabSelectsValue={false} + /> + +
+ ); +}; diff --git a/packages/web/src/views/Forms/EventForm/AttendeeField/EnableContactSuggestionsNudge.test.tsx b/packages/web/src/views/Forms/EventForm/AttendeeField/EnableContactSuggestionsNudge.test.tsx new file mode 100644 index 0000000000..038344e7fa --- /dev/null +++ b/packages/web/src/views/Forms/EventForm/AttendeeField/EnableContactSuggestionsNudge.test.tsx @@ -0,0 +1,143 @@ +import { cleanup, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithStore } from "@web/__tests__/render-with-store"; +import { createMockConnection } from "@web/__tests__/utils/factories/calendar.factory"; +import { userMetadataActions } from "@web/auth/state/user-metadata.store"; +import { resetContactsNudgeSessionForTests } from "./contact-nudge.gate"; +import { EnableContactSuggestionsNudge } from "./EnableContactSuggestionsNudge"; +import { + afterAll, + afterEach, + beforeEach, + describe, + expect, + it, + mock, +} from "bun:test"; + +// WP-06: the "occasional, non-nagging" affordance (product decision 1). +// Frequency is pinned here at the component level too: one mount per session +// shows it, the next stays empty, and dismissal survives a new session. +// +// The web suite runs in ONE process and earlier files (Sidebar/CalendarList) +// register process-wide mock.module stubs for useConnectGoogle — some without +// a `connect` at all — so this file cannot reach the real hook -> AuthApi +// path reliably. It follows the repo's delegating-mock pattern instead and +// asserts the nudge's contract AT THE HOOK BOUNDARY: it asks for the +// contacts feature and starts the flow on click. The features -> begin-body +// wire threading is covered by useConnectGoogle.scope.test.tsx, which runs +// before any module mock exists. +const actualUseConnectGoogle = ( + await import("@web/auth/google/hooks/useConnectGoogle/useConnectGoogle") +).useConnectGoogle; +let isConnectGoogleMocked = true; +const connectMock = mock(); +const mockUseConnectGoogle = mock( + (_options?: Parameters[0]) => ({ + commandAction: null, + connect: connectMock, + connection: null, + refresh: mock(), + isAvailable: true, + isConnecting: false, + isRefreshing: false, + state: "HEALTHY" as const, + }), +); +mock.module("@web/auth/google/hooks/useConnectGoogle/useConnectGoogle", () => ({ + useConnectGoogle: ( + ...args: Parameters + ): ReturnType => + isConnectGoogleMocked + ? (mockUseConnectGoogle(...args) as unknown as ReturnType< + typeof actualUseConnectGoogle + >) + : actualUseConnectGoogle(...args), +})); + +afterAll(() => { + // Hand later files the real hook — mock.module itself is process-wide. + isConnectGoogleMocked = false; +}); + +const seedHealthyConnection = () => { + userMetadataActions.set({ + google: { + connectionState: "HEALTHY", + connections: [ + createMockConnection("a@example.com", { canSuggestContacts: false }), + ], + }, + }); +}; + +describe("EnableContactSuggestionsNudge", () => { + beforeEach(() => { + localStorage.clear(); + resetContactsNudgeSessionForTests(); + seedHealthyConnection(); + mockUseConnectGoogle.mockClear(); + connectMock.mockClear(); + }); + + afterEach(() => { + cleanup(); + userMetadataActions.clear(); + }); + + it("shows once per session: the first mount renders, the second stays empty", () => { + renderWithStore(); + expect( + screen.getByRole("button", { name: "Enable contact suggestions" }), + ).toBeInTheDocument(); + // Inline affordance only — never a modal. + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + cleanup(); + + // Next menu open in the SAME session: nudge-free. + renderWithStore(); + expect( + screen.queryByRole("button", { name: "Enable contact suggestions" }), + ).not.toBeInTheDocument(); + }); + + it("dismissal hides it now and persists to localStorage for future sessions", async () => { + const user = userEvent.setup(); + renderWithStore(); + + await user.click( + screen.getByRole("button", { name: "Dismiss contact suggestions tip" }), + ); + + expect( + screen.queryByRole("button", { name: "Enable contact suggestions" }), + ).not.toBeInTheDocument(); + expect(localStorage.getItem("compass.contactsNudge.dismissed")).toBe( + "true", + ); + + // A NEW session (fresh session flag) still honors the dismissal. + cleanup(); + resetContactsNudgeSessionForTests(); + renderWithStore(); + expect( + screen.queryByRole("button", { name: "Enable contact suggestions" }), + ).not.toBeInTheDocument(); + }); + + it("asks the connect flow for the contacts feature and starts it on click", async () => { + const user = userEvent.setup(); + renderWithStore(); + + // The nudge's whole purpose: incremental re-consent WITH contacts. + expect(mockUseConnectGoogle).toHaveBeenCalled(); + expect(mockUseConnectGoogle.mock.calls[0]?.[0]).toEqual({ + features: ["contacts"], + }); + + await user.click( + screen.getByRole("button", { name: "Enable contact suggestions" }), + ); + expect(connectMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/web/src/views/Forms/EventForm/AttendeeField/EnableContactSuggestionsNudge.tsx b/packages/web/src/views/Forms/EventForm/AttendeeField/EnableContactSuggestionsNudge.tsx new file mode 100644 index 0000000000..ee6e37f8a6 --- /dev/null +++ b/packages/web/src/views/Forms/EventForm/AttendeeField/EnableContactSuggestionsNudge.tsx @@ -0,0 +1,56 @@ +import { XIcon } from "@phosphor-icons/react"; +import { useEffect, useState } from "react"; +import { useConnectGoogle } from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle"; +import { + dismissContactsNudge, + markContactsNudgeShown, + shouldShowContactsNudge, +} from "./contact-nudge.gate"; + +/** + * The "occasional, non-nagging" enable-contacts affordance (product decision + * 1): an inline footer row in the attendee combobox — never a modal — shown + * at most once per session and never again after an explicit dismiss (the + * gate persists dismissal to localStorage). Clicking it starts the connect + * flow's incremental re-consent with the optional contacts scopes; on return + * the refreshed metadata flips the capability on and suggestions go live + * without a manual reload. + */ +export const EnableContactSuggestionsNudge = () => { + // Decided once per mount (one menu-open episode): the nudge either owns + // this opening or stays away entirely — it never pops in mid-typing. + const [isVisible, setIsVisible] = useState(() => shouldShowContactsNudge()); + const { connect, isConnecting } = useConnectGoogle({ + features: ["contacts"], + }); + + useEffect(() => { + if (isVisible) markContactsNudgeShown(); + }, [isVisible]); + + if (!isVisible) return null; + + return ( +
+ + +
+ ); +}; diff --git a/packages/web/src/views/Forms/EventForm/AttendeeField/contact-nudge.gate.test.ts b/packages/web/src/views/Forms/EventForm/AttendeeField/contact-nudge.gate.test.ts new file mode 100644 index 0000000000..1c265ff134 --- /dev/null +++ b/packages/web/src/views/Forms/EventForm/AttendeeField/contact-nudge.gate.test.ts @@ -0,0 +1,74 @@ +import { + dismissContactsNudge, + markContactsNudgeShown, + resetContactsNudgeSessionForTests, + shouldShowContactsNudge, +} from "./contact-nudge.gate"; +import { beforeEach, describe, expect, it } from "bun:test"; + +// Product decision 1: the enable-contacts nudge is OCCASIONAL and +// non-nagging. These tests ARE the frequency rule — at most one showing per +// session, and a dismissal silences it forever — so loosening them is a +// product regression, not a cleanup. + +describe("contact-nudge gate (nudge frequency rules)", () => { + beforeEach(() => { + localStorage.clear(); + resetContactsNudgeSessionForTests(); + }); + + it("shows at most once per session", () => { + expect(shouldShowContactsNudge()).toBe(true); + + markContactsNudgeShown(); + + // Every later menu open in the same session stays nudge-free. + expect(shouldShowContactsNudge()).toBe(false); + expect(shouldShowContactsNudge()).toBe(false); + }); + + it("shows again in a NEW session when it was never dismissed", () => { + markContactsNudgeShown(); + expect(shouldShowContactsNudge()).toBe(false); + + // A new session (fresh module state, same storage): occasional, not gone. + resetContactsNudgeSessionForTests(); + expect(shouldShowContactsNudge()).toBe(true); + }); + + it("persists an explicit dismissal across sessions via localStorage", () => { + dismissContactsNudge(); + expect(shouldShowContactsNudge()).toBe(false); + expect(localStorage.getItem("compass.contactsNudge.dismissed")).toBe( + "true", + ); + + // Simulate the next app session: the dismissal survives. + resetContactsNudgeSessionForTests(); + expect(shouldShowContactsNudge()).toBe(false); + }); + + it("still bounds showing to once per session when storage throws", () => { + const original = Storage.prototype.getItem; + Storage.prototype.getItem = () => { + throw new Error("storage unavailable"); + }; + try { + expect(shouldShowContactsNudge()).toBe(true); + markContactsNudgeShown(); + expect(shouldShowContactsNudge()).toBe(false); + // dismiss must not throw even when setItem fails too. + const originalSet = Storage.prototype.setItem; + Storage.prototype.setItem = () => { + throw new Error("storage unavailable"); + }; + try { + expect(() => dismissContactsNudge()).not.toThrow(); + } finally { + Storage.prototype.setItem = originalSet; + } + } finally { + Storage.prototype.getItem = original; + } + }); +}); diff --git a/packages/web/src/views/Forms/EventForm/AttendeeField/contact-nudge.gate.ts b/packages/web/src/views/Forms/EventForm/AttendeeField/contact-nudge.gate.ts new file mode 100644 index 0000000000..f05b531162 --- /dev/null +++ b/packages/web/src/views/Forms/EventForm/AttendeeField/contact-nudge.gate.ts @@ -0,0 +1,47 @@ +// Frequency gate for the "Enable contact suggestions" nudge (WP-06). +// +// Product decision 1 (2026-08-25): the nudge is OCCASIONAL and non-nagging. +// Encoded here as two independent rules: +// 1. At most once per app session — after one showing, every later menu +// open in this session stays nudge-free. +// 2. Dismissal is forever — an explicit dismiss persists to localStorage +// and no future session shows the nudge again. +// Over-showing is a regression; both rules are pinned by tests. + +const DISMISSED_STORAGE_KEY = "compass.contactsNudge.dismissed"; + +let shownThisSession = false; + +function isDismissedForever(): boolean { + try { + return localStorage.getItem(DISMISSED_STORAGE_KEY) === "true"; + } catch { + // Storage unavailable (private mode, quota): fail toward showing less + // is not required — the per-session flag still bounds it to one showing. + return false; + } +} + +export function shouldShowContactsNudge(): boolean { + return !shownThisSession && !isDismissedForever(); +} + +/** One showing consumed — no further nudge this session. */ +export function markContactsNudgeShown(): void { + shownThisSession = true; +} + +/** Explicit dismiss: never show again, in this or any future session. */ +export function dismissContactsNudge(): void { + shownThisSession = true; + try { + localStorage.setItem(DISMISSED_STORAGE_KEY, "true"); + } catch { + // Best effort — the session flag still silences the rest of this session. + } +} + +/** Test-only: reset the per-session flag (localStorage is the test's own). */ +export function resetContactsNudgeSessionForTests(): void { + shownThisSession = false; +} diff --git a/packages/web/src/views/Forms/EventForm/AttendeeField/useContactSuggestions.test.tsx b/packages/web/src/views/Forms/EventForm/AttendeeField/useContactSuggestions.test.tsx new file mode 100644 index 0000000000..2489082be5 --- /dev/null +++ b/packages/web/src/views/Forms/EventForm/AttendeeField/useContactSuggestions.test.tsx @@ -0,0 +1,188 @@ +import { renderHook } from "@testing-library/react"; +import { rest } from "msw"; +import { server } from "@web/__tests__/__mocks__/server/mock.server"; +import { createStoreWrapper } from "@web/__tests__/render-with-store"; +import { createMockConnection } from "@web/__tests__/utils/factories/calendar.factory"; +import { userMetadataActions } from "@web/auth/state/user-metadata.store"; +import { ENV_WEB } from "@web/common/constants/env.constants"; +import { + CONTACT_SUGGESTION_DEBOUNCE_MS, + rankContactSuggestions, + useContactSuggestions, +} from "./useContactSuggestions"; +import { afterEach, describe, expect, it } from "bun:test"; + +// WP-06: the browser side of the quota guard (debounce + min length), the +// ranking contract, and the silent-fallback behavior of the suggestion source. + +const SUGGESTIONS_URL = `${ENV_WEB.API_BASEURL}/contacts/suggestions`; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const seedCapability = (canSuggestContacts: boolean) => { + userMetadataActions.set({ + google: { + connectionState: "HEALTHY", + connections: [ + createMockConnection("a@example.com", { canSuggestContacts }), + ], + }, + }); +}; + +const serveSuggestions = ( + suggestions: Array<{ email: string; displayName: string | null }>, +) => { + const queries: string[] = []; + server.use( + rest.get(SUGGESTIONS_URL, (req, res, ctx) => { + queries.push(req.url.searchParams.get("q") ?? ""); + return res(ctx.status(200), ctx.json({ suggestions })); + }), + ); + return queries; +}; + +const renderSuggestions = () => { + const { wrapper } = createStoreWrapper(); + return renderHook(() => useContactSuggestions(), { wrapper }); +}; + +afterEach(() => { + userMetadataActions.clear(); +}); + +describe("useContactSuggestions", () => { + it("offers no suggestion source without the contacts capability", () => { + seedCapability(false); + const { result } = renderSuggestions(); + expect(result.current.canSuggestContacts).toBe(false); + expect(result.current.suggestionSource).toBeUndefined(); + }); + + it("debounces ≥250ms: no request during the pause, one after it", async () => { + seedCapability(true); + const queries = serveSuggestions([ + { email: "ada@example.com", displayName: "Ada Lovelace" }, + ]); + const { result } = renderSuggestions(); + const source = result.current.suggestionSource; + if (!source) throw new Error("expected a suggestion source"); + + expect(CONTACT_SUGGESTION_DEBOUNCE_MS).toBeGreaterThanOrEqual(250); + + const pending = source("ada"); + // Well inside the debounce window: nothing on the wire yet. + await sleep(100); + expect(queries).toHaveLength(0); + + const results = await pending; + expect(queries).toEqual(["ada"]); + expect(results).toEqual([ + { email: "ada@example.com", displayName: "Ada Lovelace" }, + ]); + }); + + it("resolves empty for a sub-2-char query without any request", async () => { + seedCapability(true); + const queries = serveSuggestions([ + { email: "ada@example.com", displayName: "Ada Lovelace" }, + ]); + const { result } = renderSuggestions(); + const source = result.current.suggestionSource; + if (!source) throw new Error("expected a suggestion source"); + + await expect(source("a")).resolves.toEqual([]); + expect(queries).toHaveLength(0); + }); + + it("supersedes a pending query with a newer keystroke (one request total)", async () => { + seedCapability(true); + const queries = serveSuggestions([ + { email: "ada@example.com", displayName: "Ada Lovelace" }, + ]); + const { result } = renderSuggestions(); + const source = result.current.suggestionSource; + if (!source) throw new Error("expected a suggestion source"); + + const first = source("ad"); + await sleep(50); + const second = source("ada"); + + await expect(first).resolves.toEqual([]); + const results = await second; + expect(results).toHaveLength(1); + expect(queries).toEqual(["ada"]); + }); + + it("ranks the page with the command-palette scorer (label + email keyword)", async () => { + seedCapability(true); + // Server order deliberately wrong for the query: the client re-ranks. + serveSuggestions([ + { email: "zed@example.com", displayName: "Zed" }, + { email: "ada@example.com", displayName: "Ada Lovelace" }, + ]); + const { result } = renderSuggestions(); + const source = result.current.suggestionSource; + if (!source) throw new Error("expected a suggestion source"); + + const results = await source("ada"); + expect(results.map(({ email }) => email)).toEqual([ + "ada@example.com", + // Zero-score entries stay (the People API matched on data the scorer + // cannot see) but sort last. + "zed@example.com", + ]); + }); + + it("resolves empty on a proxy failure — silent fallback, nothing thrown", async () => { + seedCapability(true); + server.use( + rest.get(SUGGESTIONS_URL, (_req, res, ctx) => res(ctx.status(503))), + ); + const { result } = renderSuggestions(); + const source = result.current.suggestionSource; + if (!source) throw new Error("expected a suggestion source"); + + await expect(source("ada")).resolves.toEqual([]); + }); + + it("cancels on unmount: the pending query resolves empty and never hits the wire", async () => { + seedCapability(true); + const queries = serveSuggestions([ + { email: "ada@example.com", displayName: "Ada Lovelace" }, + ]); + const { result, unmount } = renderSuggestions(); + const source = result.current.suggestionSource; + if (!source) throw new Error("expected a suggestion source"); + + const pending = source("ada"); + unmount(); + + await expect(pending).resolves.toEqual([]); + // Past the debounce window: the cancelled timer never fired. + await sleep(CONTACT_SUGGESTION_DEBOUNCE_MS + 100); + expect(queries).toHaveLength(0); + }); +}); + +describe("rankContactSuggestions", () => { + it("is stable for equal scores (server order preserved)", () => { + const page = [ + { email: "amy@example.com", displayName: "Amy" }, + { email: "ann@example.com", displayName: "Ann" }, + ]; + // Both zero-score for an unrelated query: server order stands. + expect(rankContactSuggestions(page, "zzz")).toEqual(page); + }); + + it("prefers a display-name prefix hit over an email-only hit", () => { + const page = [ + { email: "ada@example.com", displayName: "Zed" }, + { email: "zed@example.com", displayName: "Ada Lovelace" }, + ]; + expect(rankContactSuggestions(page, "ada")[0]?.email).toBe( + "zed@example.com", + ); + }); +}); diff --git a/packages/web/src/views/Forms/EventForm/AttendeeField/useContactSuggestions.ts b/packages/web/src/views/Forms/EventForm/AttendeeField/useContactSuggestions.ts new file mode 100644 index 0000000000..fafe873606 --- /dev/null +++ b/packages/web/src/views/Forms/EventForm/AttendeeField/useContactSuggestions.ts @@ -0,0 +1,159 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback, useEffect, useRef } from "react"; +import { + CONTACT_SUGGESTION_QUERY_MIN_LENGTH, + type ContactSuggestion, +} from "@core/types/contact.contracts"; +import { type AttendeeInput } from "@core/types/event-attendance.contracts"; +import { ContactsApi } from "@web/api/contacts.api"; +import { + selectCanSuggestContacts, + useUserMetadataStore, +} from "@web/auth/state/user-metadata.store"; +import { scoreCommandItem } from "@web/components/CommandPalette/command-palette.search"; +import { type AttendeeSuggestionSource } from "./AttendeeField"; + +// Quota guard, half one: no request leaves the browser until the user pauses +// typing this long. (Half two is the ≥2-char minimum below; sync adds server +// rate limits behind both.) +export const CONTACT_SUGGESTION_DEBOUNCE_MS = 250; + +// Matching queries within this window reuse the cached page instead of +// re-querying the People API (e.g. type, backspace, retype). +const SUGGESTION_STALE_TIME_MS = 30_000; + +const contactSuggestionsQueryKey = (query: string) => + ["contactSuggestions", query] as const; + +/** + * Rank a suggestion page with the command palette's fuzzy scorer: display + * name is the label, email the keyword, best score first. Zero-score entries + * are kept (the People API matched on data the scorer cannot see) but sort + * last, in server order — the sort is stable via the explicit index. + */ +export function rankContactSuggestions( + suggestions: readonly ContactSuggestion[], + query: string, +): ContactSuggestion[] { + return suggestions + .map((suggestion, index) => ({ + suggestion, + index, + score: scoreCommandItem( + { + label: suggestion.displayName ?? suggestion.email, + keywords: [suggestion.email], + }, + query, + ), + })) + .sort((a, b) => b.score - a.score || a.index - b.index) + .map(({ suggestion }) => suggestion); +} + +const toAttendeeInput = (suggestion: ContactSuggestion): AttendeeInput => ({ + email: suggestion.email, + displayName: suggestion.displayName, +}); + +export interface UseContactSuggestionsResult { + /** True when any connected Google account granted a contacts scope. */ + canSuggestContacts: boolean; + /** + * Suggestion source for AttendeeField — present only while the capability + * is granted. Debounced ≥250ms, ≥2-char minimum, ranked; every failure + * resolves to [] (raw email entry keeps working, no error toast). + */ + suggestionSource: AttendeeSuggestionSource | undefined; +} + +/** + * Live Google-contact suggestions for the attendee field, behind TanStack + * Query (per-query cache + request dedupe). Unmounting the owning form + * cancels the pending debounce and aborts any in-flight request. + */ +export function useContactSuggestions(): UseContactSuggestionsResult { + const canSuggestContacts = useUserMetadataStore(selectCanSuggestContacts); + const queryClient = useQueryClient(); + + const debounceTimerRef = useRef | null>(null); + // The promise superseded by a newer keystroke resolves [] immediately — + // AttendeeField's own version guard ignores it, and nothing leaks. + const supersededResolveRef = useRef< + ((value: readonly AttendeeInput[]) => void) | null + >(null); + const abortControllerRef = useRef(null); + const isMountedRef = useRef(true); + + useEffect(() => { + isMountedRef.current = true; + return () => { + // Cancel on unmount: drop the pending debounce and abort the wire. + isMountedRef.current = false; + if (debounceTimerRef.current !== null) { + clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = null; + } + supersededResolveRef.current?.([]); + supersededResolveRef.current = null; + abortControllerRef.current?.abort(); + }; + }, []); + + const suggestionSource = useCallback( + (rawQuery) => + new Promise((resolve) => { + // A newer keystroke supersedes the pending one entirely. + if (debounceTimerRef.current !== null) { + clearTimeout(debounceTimerRef.current); + } + supersededResolveRef.current?.([]); + supersededResolveRef.current = resolve; + + debounceTimerRef.current = setTimeout(() => { + debounceTimerRef.current = null; + supersededResolveRef.current = null; + + const query = rawQuery.trim(); + if ( + !isMountedRef.current || + query.length < CONTACT_SUGGESTION_QUERY_MIN_LENGTH + ) { + resolve([]); + return; + } + + abortControllerRef.current?.abort(); + const controller = new AbortController(); + abortControllerRef.current = controller; + + queryClient + .fetchQuery({ + queryKey: contactSuggestionsQueryKey(query), + queryFn: () => + ContactsApi.getSuggestions(query, controller.signal), + staleTime: SUGGESTION_STALE_TIME_MS, + retry: false, + }) + .then((response) => { + resolve( + rankContactSuggestions(response.suggestions, query).map( + toAttendeeInput, + ), + ); + }) + .catch(() => { + // Aborted, offline, or a contract mismatch: suggestions are a + // convenience — degrade silently to raw email entry. + resolve([]); + }); + }, CONTACT_SUGGESTION_DEBOUNCE_MS); + }), + [queryClient], + ); + + return { + canSuggestContacts, + suggestionSource: canSuggestContacts ? suggestionSource : undefined, + }; +} diff --git a/packages/web/src/views/Forms/EventForm/EventDetailsSection.tsx b/packages/web/src/views/Forms/EventForm/EventDetailsSection.tsx index 2077600c72..bcef677352 100644 --- a/packages/web/src/views/Forms/EventForm/EventDetailsSection.tsx +++ b/packages/web/src/views/Forms/EventForm/EventDetailsSection.tsx @@ -7,6 +7,12 @@ type EventDetails = Extract; interface EventDetailsSectionProps { details: Pick; + /** + * Set when the editable AttendeeField owns the guest list for this event — + * this section then renders only the remaining read-only details (the + * conference link) instead of duplicating the guests below the editor. + */ + hideAttendees?: boolean; } const ATTENDEE_STATUS_DOT: Record = { @@ -29,10 +35,13 @@ const MAX_VISIBLE_ATTENDEES = 6; * least one of these - absent for a plain Compass-native event and for a * busy-projection event (whose content carries none of this). */ -export const EventDetailsSection = ({ details }: EventDetailsSectionProps) => { +export const EventDetailsSection = ({ + details, + hideAttendees = false, +}: EventDetailsSectionProps) => { const { organizer, attendees = [], conference } = details; const [showAllAttendees, setShowAllAttendees] = useState(false); - const hasAttendees = attendees.length > 0; + const hasAttendees = attendees.length > 0 && !hideAttendees; if (!conference && !hasAttendees) return null; diff --git a/packages/web/src/views/Forms/EventForm/EventForm.attendees.test.tsx b/packages/web/src/views/Forms/EventForm/EventForm.attendees.test.tsx new file mode 100644 index 0000000000..a54434f181 --- /dev/null +++ b/packages/web/src/views/Forms/EventForm/EventForm.attendees.test.tsx @@ -0,0 +1,240 @@ +import { HotkeyManager } from "@tanstack/react-hotkeys"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { + type Calendar, + getCalendarCapabilities, +} from "@core/types/calendar.contracts"; +import { CalendarIdSchema, EventIdSchema } from "@core/types/domain-primitives"; +import { type Event } from "@core/types/event.contracts"; +import { createStoreWrapper } from "@web/__tests__/render-with-store"; +import { createMockEvent } from "@web/__tests__/utils/factories/event.factory"; +import { calendarQueryKeys } from "@web/calendars/calendar.query"; +import { createObjectIdString } from "@web/common/utils/id/object-id.util"; +import { type GridEventDraft } from "@web/events/event-draft.types"; +import { editGridEventDraft } from "@web/events/grid-event-draft.adapter"; +import { EventForm } from "@web/views/Forms/EventForm/EventForm"; +import { beforeEach, describe, expect, it, mock } from "bun:test"; + +// WP-04 attendee-editor gating: the guest combobox renders only where the +// whole write path can deliver a guest edit — a writable Google calendar, an +// event the user organizes, never a single occurrence of a series. Everyone +// else keeps the read-only guest list. Sibling to EventForm.readOnly.test.tsx +// (full form, no subcomponent mocks) for the same isolation reasons. + +const ACCOUNT_EMAIL = "me@example.com"; + +const makeCalendar = (overrides: Partial = {}): Calendar => ({ + id: CalendarIdSchema.parse(createObjectIdString()), + name: "Work calendar", + description: "", + timeZone: null, + foregroundColor: "#000000", + backgroundColor: "#3b82f6", + provider: "google", + access: "owner", + capabilities: getCalendarCapabilities("owner"), + isPrimary: true, + isVisible: true, + isActive: true, + accountEmail: ACCOUNT_EMAIL, + ...overrides, +}); + +const makeMeetingEvent = ( + calendarId: Calendar["id"], + overrides: Partial = {}, +): Event => + createMockEvent({ + calendarId, + content: { + kind: "details", + title: "Weekly sync", + description: "", + organizer: { email: ACCOUNT_EMAIL, displayName: null }, + attendees: [ + { + email: "guest@example.com", + displayName: "Guest One", + responseStatus: "accepted", + }, + ], + }, + ...overrides, + }); + +const renderEventForm = ( + draft: GridEventDraft, + calendars: Calendar[], + overrides: { onSubmit?: (draft: GridEventDraft | null) => void } = {}, +) => { + const { queryClient, wrapper } = createStoreWrapper(); + queryClient.setQueryData(calendarQueryKeys.all, calendars); + + const onSubmit = overrides.onSubmit ?? mock(); + const setDraft = mock(); + + const utils = render( + , + { wrapper }, + ); + + return { onSubmit, setDraft, ...utils }; +}; + +const editDraftOrThrow = (event: Event): GridEventDraft => { + const draft = editGridEventDraft(event); + if (!draft) throw new Error("expected an edit draft"); + return draft; +}; + +describe("EventForm attendee editor gating", () => { + beforeEach(() => { + HotkeyManager.resetInstance(); + document.body.removeAttribute("data-app-locked"); + }); + + it("renders the guest combobox (and not the read-only list) for an organized event on a writable Google calendar", () => { + const calendar = makeCalendar(); + const draft = editDraftOrThrow(makeMeetingEvent(calendar.id)); + + renderEventForm(draft, [calendar]); + + expect(screen.getByRole("combobox", { name: "Guests" })).toBeEnabled(); + // The editor owns the guest list; the legacy read-only list stands down. + expect(screen.queryByText(/1 guest/)).not.toBeInTheDocument(); + // Existing guests appear as chips (displayName preferred). + expect(screen.getByText("Guest One")).toBeInTheDocument(); + }); + + it("keeps the read-only guest list for an event the user does not organize", () => { + const calendar = makeCalendar(); + const event = makeMeetingEvent(calendar.id, { + content: { + kind: "details", + title: "Their meeting", + description: "", + organizer: { email: "someone-else@example.com", displayName: null }, + attendees: [ + { + email: "guest@example.com", + displayName: "Guest One", + responseStatus: "needsAction", + }, + ], + }, + }); + + renderEventForm(editDraftOrThrow(event), [calendar]); + + expect( + screen.queryByRole("combobox", { name: "Guests" }), + ).not.toBeInTheDocument(); + expect(screen.getByText(/1 guest/)).toBeInTheDocument(); + }); + + it("keeps the read-only guest list on a read-only calendar", () => { + const calendar = makeCalendar({ + access: "reader", + capabilities: getCalendarCapabilities("reader"), + }); + const draft = editDraftOrThrow(makeMeetingEvent(calendar.id)); + + renderEventForm(draft, [calendar]); + + expect( + screen.queryByRole("combobox", { name: "Guests" }), + ).not.toBeInTheDocument(); + expect(screen.getByText(/1 guest/)).toBeInTheDocument(); + }); + + it("shows no editor on a non-Google (local) calendar", () => { + const calendar = makeCalendar({ + provider: "local", + accountEmail: undefined, + }); + const event = createMockEvent({ + calendarId: calendar.id, + content: { kind: "details", title: "Errand", description: "" }, + }); + + renderEventForm(editDraftOrThrow(event), [calendar]); + + expect( + screen.queryByRole("combobox", { name: "Guests" }), + ).not.toBeInTheDocument(); + }); + + it("hides the editor for a single occurrence of a series (guest edits are series-wide only)", () => { + const calendar = makeCalendar(); + const seriesId = EventIdSchema.parse(createObjectIdString()); + const event = makeMeetingEvent(calendar.id, { + recurrence: { kind: "occurrence", seriesId }, + }); + + renderEventForm(editDraftOrThrow(event), [calendar]); + + expect( + screen.queryByRole("combobox", { name: "Guests" }), + ).not.toBeInTheDocument(); + expect(screen.getByText(/1 guest/)).toBeInTheDocument(); + }); + + it("shows the editor when editing the series base itself", () => { + const calendar = makeCalendar(); + const event = makeMeetingEvent(calendar.id, { + recurrence: { kind: "series", rules: ["RRULE:FREQ=WEEKLY"] }, + }); + + renderEventForm(editDraftOrThrow(event), [calendar]); + + expect( + screen.getByRole("combobox", { name: "Guests" }), + ).toBeInTheDocument(); + }); + + it("treats an event with no organizer as organized by the account (Compass-created)", () => { + const calendar = makeCalendar(); + const event = createMockEvent({ + calendarId: calendar.id, + content: { kind: "details", title: "My own event", description: "" }, + }); + + renderEventForm(editDraftOrThrow(event), [calendar]); + + expect( + screen.getByRole("combobox", { name: "Guests" }), + ).toBeInTheDocument(); + }); + + it("does not submit the form when Enter interacts with the guest combobox", async () => { + const user = userEvent.setup(); + const calendar = makeCalendar(); + const draft = editDraftOrThrow(makeMeetingEvent(calendar.id)); + const onSubmit = mock(); + + const { setDraft } = renderEventForm(draft, [calendar], { onSubmit }); + + const combobox = screen.getByRole("combobox", { name: "Guests" }); + await user.type(combobox, "new-guest@example.com"); + await user.keyboard("{Enter}"); + + // The chip landed in the draft, and the form did not save. + expect(setDraft).toHaveBeenCalled(); + expect(onSubmit).not.toHaveBeenCalled(); + + // Enter outside the combobox still saves (regression guard). + await user.click(screen.getByPlaceholderText("Title")); + await user.keyboard("{Enter}"); + expect(onSubmit).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/web/src/views/Forms/EventForm/EventForm.contact-suggestions.test.tsx b/packages/web/src/views/Forms/EventForm/EventForm.contact-suggestions.test.tsx new file mode 100644 index 0000000000..e3b750790d --- /dev/null +++ b/packages/web/src/views/Forms/EventForm/EventForm.contact-suggestions.test.tsx @@ -0,0 +1,213 @@ +import { HotkeyManager } from "@tanstack/react-hotkeys"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { rest } from "msw"; +import { + type Calendar, + getCalendarCapabilities, +} from "@core/types/calendar.contracts"; +import { CalendarIdSchema } from "@core/types/domain-primitives"; +import { type Event } from "@core/types/event.contracts"; +import { server } from "@web/__tests__/__mocks__/server/mock.server"; +import { createStoreWrapper } from "@web/__tests__/render-with-store"; +import { createMockConnection } from "@web/__tests__/utils/factories/calendar.factory"; +import { createMockEvent } from "@web/__tests__/utils/factories/event.factory"; +import { userMetadataActions } from "@web/auth/state/user-metadata.store"; +import { calendarQueryKeys } from "@web/calendars/calendar.query"; +import { ENV_WEB } from "@web/common/constants/env.constants"; +import { createObjectIdString } from "@web/common/utils/id/object-id.util"; +import { type GridEventDraft } from "@web/events/event-draft.types"; +import { editGridEventDraft } from "@web/events/grid-event-draft.adapter"; +import { resetContactsNudgeSessionForTests } from "@web/views/Forms/EventForm/AttendeeField/contact-nudge.gate"; +import { EventForm } from "@web/views/Forms/EventForm/EventForm"; +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; + +// WP-06 in the real form: live Google-contact suggestions in the guest +// combobox when the capability is granted, the occasional enable-contacts +// nudge in the combobox footer when it is not, and silent raw-email fallback +// when the proxy fails. + +const ACCOUNT_EMAIL = "me@example.com"; +const SUGGESTIONS_URL = `${ENV_WEB.API_BASEURL}/contacts/suggestions`; + +const makeCalendar = (overrides: Partial = {}): Calendar => ({ + id: CalendarIdSchema.parse(createObjectIdString()), + name: "Work calendar", + description: "", + timeZone: null, + foregroundColor: "#000000", + backgroundColor: "#3b82f6", + provider: "google", + access: "owner", + capabilities: getCalendarCapabilities("owner"), + isPrimary: true, + isVisible: true, + isActive: true, + accountEmail: ACCOUNT_EMAIL, + ...overrides, +}); + +const makeMeetingEvent = (calendarId: Calendar["id"]): Event => + createMockEvent({ + calendarId, + content: { + kind: "details", + title: "Weekly sync", + description: "", + organizer: { email: ACCOUNT_EMAIL, displayName: null }, + attendees: [], + }, + }); + +const seedContactsCapability = (canSuggestContacts: boolean) => { + userMetadataActions.set({ + google: { + connectionState: "HEALTHY", + connections: [ + createMockConnection(ACCOUNT_EMAIL, { canSuggestContacts }), + ], + }, + }); +}; + +const serveSuggestions = ( + suggestions: Array<{ email: string; displayName: string | null }>, +) => { + const queries: string[] = []; + server.use( + rest.get(SUGGESTIONS_URL, (req, res, ctx) => { + queries.push(req.url.searchParams.get("q") ?? ""); + return res(ctx.status(200), ctx.json({ suggestions })); + }), + ); + return queries; +}; + +const renderGuestEditor = () => { + const calendar = makeCalendar(); + const draft = editGridEventDraft(makeMeetingEvent(calendar.id)); + if (!draft) throw new Error("expected an edit draft"); + + const { queryClient, wrapper } = createStoreWrapper(); + queryClient.setQueryData(calendarQueryKeys.all, [calendar]); + + const setDraft = mock(); + render( + , + { wrapper }, + ); + + // The form is prop-controlled: a guest edit lands as a resolved draft on + // setDraft (the harness never feeds it back, so chips don't re-render). + // Read the intended guests off the latest resolved draft. + const latestDraftAttendees = () => { + const resolved = setDraft.mock.calls.at(-1)?.[0] as + | GridEventDraft + | null + | undefined; + return resolved?.values.attendees; + }; + + return { + combobox: screen.getByRole("combobox", { name: "Guests" }), + setDraft, + latestDraftAttendees, + }; +}; + +describe("EventForm contact suggestions (WP-06)", () => { + beforeEach(() => { + HotkeyManager.resetInstance(); + document.body.removeAttribute("data-app-locked"); + localStorage.clear(); + resetContactsNudgeSessionForTests(); + }); + + afterEach(() => { + cleanup(); + userMetadataActions.clear(); + }); + + it("shows ranked suggestions after typing and fills the chip from a pick (displayName + email)", async () => { + seedContactsCapability(true); + const queries = serveSuggestions([ + { email: "zed@example.com", displayName: "Zed" }, + { email: "ada@example.com", displayName: "Ada Lovelace" }, + ]); + const user = userEvent.setup(); + + const { combobox, latestDraftAttendees } = renderGuestEditor(); + await user.type(combobox, "ada"); + + // Debounced (≥250ms) round-trip; findBy* waits it out. + await screen.findByText("Ada Lovelace"); + const labels = screen + .getAllByRole("option") + .map((option) => option.textContent); + // Ranked: Ada (name-prefix match) above Zed (server-matched only). + expect(labels.indexOf("Ada Lovelace")).toBeGreaterThan(-1); + expect(labels.indexOf("Ada Lovelace")).toBeLessThan(labels.indexOf("Zed")); + expect(queries).toEqual(["ada"]); + + await user.click(screen.getByText("Ada Lovelace")); + // The pick lands in the draft with displayName AND email. + expect(latestDraftAttendees()).toEqual([ + { email: "ada@example.com", displayName: "Ada Lovelace" }, + ]); + // No nudge while the capability is granted. + expect( + screen.queryByRole("button", { name: "Enable contact suggestions" }), + ).not.toBeInTheDocument(); + }); + + it("without the capability: no request, raw email entry works, and the footer nudge appears once", async () => { + seedContactsCapability(false); + const queries = serveSuggestions([ + { email: "ada@example.com", displayName: "Ada Lovelace" }, + ]); + const user = userEvent.setup(); + + const { combobox, latestDraftAttendees } = renderGuestEditor(); + await user.type(combobox, "ada"); + + // The nudge sits in the combobox footer of the open menu; no modal. + expect( + await screen.findByRole("button", { name: "Enable contact suggestions" }), + ).toBeInTheDocument(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + + // Raw email entry still lands a guest edit. + await user.clear(combobox); + await user.type(combobox, "ada@example.com"); + await user.keyboard("{Enter}"); + expect(latestDraftAttendees()).toEqual([ + { email: "ada@example.com", displayName: null }, + ]); + expect(queries).toHaveLength(0); + }); + + it("degrades silently when the proxy fails: no suggestions, raw entry unaffected", async () => { + seedContactsCapability(true); + server.use( + rest.get(SUGGESTIONS_URL, (_req, res, ctx) => res(ctx.status(503))), + ); + const user = userEvent.setup(); + + const { combobox, latestDraftAttendees } = renderGuestEditor(); + await user.type(combobox, "ada@example.com"); + await user.keyboard("{Enter}"); + + expect(latestDraftAttendees()).toEqual([ + { email: "ada@example.com", displayName: null }, + ]); + }); +}); diff --git a/packages/web/src/views/Forms/EventForm/EventForm.rsvp.test.tsx b/packages/web/src/views/Forms/EventForm/EventForm.rsvp.test.tsx new file mode 100644 index 0000000000..037f57a3ab --- /dev/null +++ b/packages/web/src/views/Forms/EventForm/EventForm.rsvp.test.tsx @@ -0,0 +1,194 @@ +import { HotkeyManager } from "@tanstack/react-hotkeys"; +import { render, screen } from "@testing-library/react"; +import { + type Calendar, + getCalendarCapabilities, +} from "@core/types/calendar.contracts"; +import { CalendarIdSchema } from "@core/types/domain-primitives"; +import { type Event } from "@core/types/event.contracts"; +import { type Attendee } from "@core/types/event-attendance.contracts"; +import { createStoreWrapper } from "@web/__tests__/render-with-store"; +import { createMockEvent } from "@web/__tests__/utils/factories/event.factory"; +import { calendarQueryKeys } from "@web/calendars/calendar.query"; +import { createObjectIdString } from "@web/common/utils/id/object-id.util"; +import { type GridEventDraft } from "@web/events/event-draft.types"; +import { editGridEventDraft } from "@web/events/grid-event-draft.adapter"; +import { EventForm } from "@web/views/Forms/EventForm/EventForm"; +import { beforeEach, describe, expect, it, mock } from "bun:test"; + +// WP-08 RSVP-control gating: Going / Maybe / Decline shows exactly when the +// calendar's connected account email appears in the attendee list (organizer +// included), regardless of calendar writability — and never on local events +// or events the user is not invited to. + +const ACCOUNT_EMAIL = "me@example.com"; + +const makeCalendar = (overrides: Partial = {}): Calendar => ({ + id: CalendarIdSchema.parse(createObjectIdString()), + name: "Work calendar", + description: "", + timeZone: null, + foregroundColor: "#000000", + backgroundColor: "#3b82f6", + provider: "google", + access: "owner", + capabilities: getCalendarCapabilities("owner"), + isPrimary: true, + isVisible: true, + isActive: true, + accountEmail: ACCOUNT_EMAIL, + ...overrides, +}); + +const selfAttendee: Attendee = { + // Case-differing on purpose: the self match is case-insensitive. + email: "Me@Example.com", + displayName: null, + responseStatus: "needsAction", +}; + +const otherAttendee: Attendee = { + email: "guest@example.com", + displayName: "Guest One", + responseStatus: "accepted", +}; + +const makeInvitedEvent = ( + calendarId: Calendar["id"], + overrides: Partial = {}, +): Event => + createMockEvent({ + calendarId, + content: { + kind: "details", + title: "Team offsite", + description: "", + organizer: { email: "organizer@example.com", displayName: null }, + attendees: [selfAttendee, otherAttendee], + }, + ...overrides, + }); + +const renderEventForm = (draft: GridEventDraft, calendars: Calendar[]) => { + const { queryClient, wrapper } = createStoreWrapper(); + queryClient.setQueryData(calendarQueryKeys.all, calendars); + + return render( + , + { wrapper }, + ); +}; + +const editDraftOrThrow = (event: Event): GridEventDraft => { + const draft = editGridEventDraft(event); + if (!draft) throw new Error("expected an edit draft"); + return draft; +}; + +const queryRsvpGroup = () => + screen.queryByRole("radiogroup", { name: "Going?" }); + +describe("EventForm RSVP control gating", () => { + beforeEach(() => { + HotkeyManager.resetInstance(); + document.body.removeAttribute("data-app-locked"); + }); + + it("shows the control when the account email is an attendee on a Google calendar", () => { + const calendar = makeCalendar(); + renderEventForm(editDraftOrThrow(makeInvitedEvent(calendar.id)), [ + calendar, + ]); + + expect(queryRsvpGroup()).toBeInTheDocument(); + expect(screen.getByRole("radio", { name: "Going" })).toBeInTheDocument(); + }); + + it("shows the control on a viewer-access (read-only) calendar — RSVP is not a calendar write", () => { + const calendar = makeCalendar({ + access: "reader", + capabilities: getCalendarCapabilities("reader"), + }); + renderEventForm(editDraftOrThrow(makeInvitedEvent(calendar.id)), [ + calendar, + ]); + + expect(queryRsvpGroup()).toBeInTheDocument(); + // The rest of the form stays read-only. + expect(screen.getByRole("note")).toHaveTextContent(/read-only/i); + }); + + it("shows the control for the organizer when they are in the attendee list", () => { + const calendar = makeCalendar(); + const event = makeInvitedEvent(calendar.id, { + content: { + kind: "details", + title: "My own meeting", + description: "", + organizer: { email: ACCOUNT_EMAIL, displayName: null }, + attendees: [ + { ...selfAttendee, responseStatus: "accepted" }, + otherAttendee, + ], + }, + }); + + renderEventForm(editDraftOrThrow(event), [calendar]); + + expect(queryRsvpGroup()).toBeInTheDocument(); + expect(screen.getByRole("radio", { name: "Going" })).toBeChecked(); + }); + + it("hides the control when the account email is not among the attendees", () => { + const calendar = makeCalendar(); + const event = makeInvitedEvent(calendar.id, { + content: { + kind: "details", + title: "Their meeting", + description: "", + organizer: { email: "organizer@example.com", displayName: null }, + attendees: [otherAttendee], + }, + }); + + renderEventForm(editDraftOrThrow(event), [calendar]); + + expect(queryRsvpGroup()).not.toBeInTheDocument(); + }); + + it("hides the control on a local (non-Google) event", () => { + const calendar = makeCalendar({ + provider: "local", + accountEmail: undefined, + }); + const event = createMockEvent({ + calendarId: calendar.id, + content: { kind: "details", title: "Errand", description: "" }, + }); + + renderEventForm(editDraftOrThrow(event), [calendar]); + + expect(queryRsvpGroup()).not.toBeInTheDocument(); + }); + + it("hides the control for an event without attendees", () => { + const calendar = makeCalendar(); + const event = createMockEvent({ + calendarId: calendar.id, + content: { kind: "details", title: "Solo focus", description: "" }, + }); + + renderEventForm(editDraftOrThrow(event), [calendar]); + + expect(queryRsvpGroup()).not.toBeInTheDocument(); + }); +}); diff --git a/packages/web/src/views/Forms/EventForm/EventForm.tsx b/packages/web/src/views/Forms/EventForm/EventForm.tsx index 45ed187b20..1f9bc16889 100644 --- a/packages/web/src/views/Forms/EventForm/EventForm.tsx +++ b/packages/web/src/views/Forms/EventForm/EventForm.tsx @@ -1,4 +1,4 @@ -import { MapPinIcon } from "@phosphor-icons/react"; +import { MapPinIcon, UsersIcon } from "@phosphor-icons/react"; import classNames from "classnames"; import fastDeepEqual from "fast-deep-equal/react"; import type React from "react"; @@ -14,11 +14,14 @@ import { useState, } from "react"; import { type CalendarId } from "@core/types/domain-primitives"; +import { type AttendeeInput } from "@core/types/event-attendance.contracts"; import dayjs from "@core/util/date/dayjs"; +import { useCalendarsQuery } from "@web/calendars/calendar.query"; import { isEventReadOnly, useCalendarLookup, } from "@web/calendars/useCalendarLookup"; +import { useDefaultTargetCalendar } from "@web/calendars/useDefaultTargetCalendar"; import { ID_EVENT_FORM } from "@web/common/constants/web.constants"; import { useEventPalette } from "@web/common/styles/theme.util"; import { type SelectOption } from "@web/common/types/component.types"; @@ -53,6 +56,9 @@ import { useFormDigitJumpShortcut } from "@web/shortcuts/form-digit-jump/useForm import { keyboardKey } from "@web/shortcuts/is-bare-letter-key"; import { KEYMAP } from "@web/shortcuts/keymap"; import { useAppShortcut } from "@web/shortcuts/useAppShortcut"; +import { AttendeeField } from "@web/views/Forms/EventForm/AttendeeField/AttendeeField"; +import { EnableContactSuggestionsNudge } from "@web/views/Forms/EventForm/AttendeeField/EnableContactSuggestionsNudge"; +import { useContactSuggestions } from "@web/views/Forms/EventForm/AttendeeField/useContactSuggestions"; import { CalendarSelect } from "@web/views/Forms/EventForm/CalendarSelect/CalendarSelect"; import { DateControlsSection } from "@web/views/Forms/EventForm/DateControlsSection/DateControlsSection/DateControlsSection"; import { getFormDates } from "@web/views/Forms/EventForm/DateControlsSection/DateTimeSection/form.datetime.util"; @@ -61,6 +67,7 @@ import { DiscardUnsavedChangesDialog } from "@web/views/Forms/EventForm/DiscardU import { EventActionMenu } from "@web/views/Forms/EventForm/EventActionMenu"; import { EventColorPicker } from "@web/views/Forms/EventForm/EventColorPicker/EventColorPicker"; import { EventDetailsSection } from "@web/views/Forms/EventForm/EventDetailsSection"; +import { RsvpControl } from "@web/views/Forms/EventForm/RsvpControl"; import { SaveSection } from "@web/views/Forms/EventForm/SaveSection"; import { TitleActionsRow } from "@web/views/Forms/EventForm/TitleActionsRow"; import { @@ -77,6 +84,7 @@ const EVENT_FORM_PLAIN_HOTKEY_OPTIONS = { const EVENT_FORM_TITLE_ID = "event-form-title"; const EVENT_FORM_LOCATION_ID = "event-form-location"; +const EVENT_FORM_ATTENDEES_ID = "event-form-attendees"; const EVENT_FORM_DESCRIPTION_ID = "event-form-description"; const EVENT_FORM_CALENDAR_ID = "event-form-calendar"; const EVENT_FORM_COLOR_ID = "event-form-color"; @@ -266,6 +274,80 @@ export const EventForm: React.FC = memo( draft.kind === "edit" && draft.source.content.kind === "details" ? draft.source.content : undefined; + // Cache-backed view of the same source event (WP-08): an optimistic RSVP + // patches the query cache, and reading it live here is what makes the + // user's own status dot (and the segmented control's selection) update + // immediately — draft.source is a snapshot and never re-renders. Other + // attendees' RSVP changes arriving via SSE update through this same + // read. Falls back to the snapshot while the event is not cached. + const liveSource = useEventById( + draft.kind === "edit" ? draft.source.id : undefined, + ); + const rsvpSource = + draft.kind === "edit" ? (liveSource ?? draft.source) : null; + const liveDetails = + rsvpSource?.content.kind === "details" ? rsvpSource.content : undefined; + // Attendee-editor gate (WP-04): guests are editable only where the whole + // write path can deliver them — a writable Google calendar, an event the + // user organizes, and never a single occurrence of a series (sync + // refuses per-occurrence guest replacements; series-wide edits go + // through the "all"-scope path via the scope dialog's narrowing). + const { data: allCalendars } = useCalendarsQuery(); + const defaultTargetCalendar = useDefaultTargetCalendar(allCalendars ?? []); + const attendeeCalendar = + draft.kind === "create" + ? draft.values.calendarId + ? calendarLookup.get(draft.values.calendarId) + : defaultTargetCalendar + : calendarLookup.get(draft.source.calendarId); + // Google auto-sets the organizer to the creating account, so "the user + // organizes this event" is organizer-email == calendar-account-email. + // A missing organizer means Compass created the event on this account + // (organizes it); a missing account email fails closed — sync refuses + // non-organizer guest edits anyway, so don't offer the editor. + const organizesEvent = + draft.kind === "create" || + !sourceDetails?.organizer || + (attendeeCalendar?.accountEmail !== undefined && + sourceDetails.organizer.email.toLowerCase() === + attendeeCalendar.accountEmail.toLowerCase()); + const showAttendeeEditor = + !isReadOnly && + attendeeCalendar?.provider === "google" && + attendeeCalendar.capabilities.canWrite && + organizesEvent && + (draft.kind === "create" || + draft.source.recurrence.kind !== "occurrence"); + // RSVP gate (WP-08): show Going / Maybe / Decline when the calendar's + // connected account email appears in the attendee list (organizer + // included — organizers can answer their own event). Hidden when self is + // not an attendee or the event is local (no provider account email). + // Deliberately NOT gated on writability: answering an invitation is + // allowed on viewer-access calendars. + const rsvpAccountEmail = + attendeeCalendar?.provider === "google" + ? attendeeCalendar.accountEmail + : undefined; + const showRsvpControl = + rsvpSource !== null && + rsvpAccountEmail !== undefined && + (liveDetails?.attendees ?? []).some( + (attendee) => + attendee.email.toLowerCase() === rsvpAccountEmail.toLowerCase(), + ); + // Contact suggestions (WP-06): live when a connected account granted the + // optional contacts scopes; otherwise the field is a raw email input and + // the combobox footer may carry the occasional enable-contacts nudge. + const { canSuggestContacts, suggestionSource: contactSuggestionSource } = + useContactSuggestions(); + // Untouched drafts show the source event's guests as chips; a touched + // draft owns its membership in values.attendees (present = replace). + const attendeeChips: readonly AttendeeInput[] = + draft.values.attendees ?? + (sourceDetails?.attendees ?? []).map(({ email, displayName }) => ({ + email, + displayName, + })); const latestDraftRef = useRef(draft); const { startDate: eventStartDate, endDate: eventEndDate } = scheduleDateStrings(draft); @@ -375,7 +457,7 @@ export const EventForm: React.FC = memo( patch: Partial< Pick< GridEventDraft["values"], - "title" | "description" | "location" | "color" + "title" | "description" | "location" | "color" | "attendees" > >, ) => { @@ -873,6 +955,28 @@ export const EventForm: React.FC = memo( + {showAttendeeEditor && ( + +
+ + patchDraftFields({ attendees: next })} + suggestionSource={contactSuggestionSource} + menuFooter={ + canSuggestContacts ? null : ( + + ) + } + /> +
+
+ )} + = memo( + {/* Outside the fieldset (like the details section below): RSVP + must stay interactive on a read-only calendar. */} + {showRsvpControl && rsvpSource && rsvpAccountEmail && ( + + )} + {/* Outside the fieldset: read-only display, not an editable control, so it stays interactive (the "+N more" toggle) even when the event itself is read-only. Renders its own card - styling and returns null when the event has none of this data. */} - {sourceDetails && } + styling and returns null when the event has none of this data. + Prefers the live cache content so other attendees' RSVP changes + (SSE) and the user's own optimistic answer paint immediately. */} + {sourceDetails && ( + + )} {isReadOnly && (

diff --git a/packages/web/src/views/Forms/EventForm/RecurrenceScopeDialog.test.tsx b/packages/web/src/views/Forms/EventForm/RecurrenceScopeDialog.test.tsx new file mode 100644 index 0000000000..aedb7ac494 --- /dev/null +++ b/packages/web/src/views/Forms/EventForm/RecurrenceScopeDialog.test.tsx @@ -0,0 +1,84 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { type Event } from "@core/types/event.contracts"; +import { createMockEvent } from "@web/__tests__/utils/factories/event.factory"; +import { RecurringEventUpdateScope } from "@web/common/types/web.event.types"; +import { editGridEventDraft } from "@web/events/grid-event-draft.adapter"; +import { RecurringEventUpdateScopeDialogContent } from "@web/views/Forms/EventForm/RecurrenceScopeDialog"; +import { describe, expect, it, mock } from "bun:test"; + +// WP-04: a save whose guest set changed narrows the scope chooser to +// "All Events" — sync refuses attendee replacements at scope +// "this"/"thisAndFollowing", so those options must not be offered. + +const seriesEvent = (overrides: Partial = {}): Event => + createMockEvent({ + content: { + kind: "details", + title: "Weekly sync", + description: "", + attendees: [ + { + email: "guest@example.com", + displayName: null, + responseStatus: "accepted", + }, + ], + }, + recurrence: { kind: "series", rules: ["RRULE:FREQ=WEEKLY"] }, + ...overrides, + }); + +const editDraftOrThrow = (event: Event) => { + const draft = editGridEventDraft(event); + if (!draft) throw new Error("expected an edit draft"); + return draft; +}; + +describe("RecurringEventUpdateScopeDialogContent guest narrowing", () => { + it("offers every scope when the guest set is untouched", () => { + render( + , + ); + + expect(screen.getAllByRole("radio")).toHaveLength(3); + }); + + it("narrows to All Events and explains why when the guest set changed", async () => { + const user = userEvent.setup(); + const onUpdateScopeChange = mock(); + const draft = editDraftOrThrow(seriesEvent()); + draft.values.attendees = [ + { email: "guest@example.com", displayName: null }, + { email: "new-guest@example.com", displayName: null }, + ]; + + render( + , + ); + + const radios = screen.getAllByRole("radio"); + expect(radios).toHaveLength(1); + expect( + screen.getByRole("radio", { name: RecurringEventUpdateScope.ALL_EVENTS }), + ).toBeChecked(); + expect( + screen.getByText("Guest changes apply to all events in the series."), + ).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Ok" })); + expect(onUpdateScopeChange).toHaveBeenCalledWith( + RecurringEventUpdateScope.ALL_EVENTS, + ); + }); +}); diff --git a/packages/web/src/views/Forms/EventForm/RecurrenceScopeDialog.tsx b/packages/web/src/views/Forms/EventForm/RecurrenceScopeDialog.tsx index c890c24d07..c7663ffa30 100644 --- a/packages/web/src/views/Forms/EventForm/RecurrenceScopeDialog.tsx +++ b/packages/web/src/views/Forms/EventForm/RecurrenceScopeDialog.tsx @@ -7,6 +7,7 @@ import { OverlayPanelActions, } from "@web/components/OverlayPanel/OverlayPanel"; import { type GridEventDraft } from "@web/events/event-draft.types"; +import { gridDraftGuestsChanged } from "@web/events/grid-event-draft.adapter"; import { type RecurrenceScopePendingAction } from "@web/events/recurrence/useRecurrenceScopeConfirmation"; import { selectGridDraft, useDraftStore } from "@web/events/stores/draft.store"; @@ -21,12 +22,22 @@ const RECURRENCE_CHANGED_UPDATE_SCOPE_OPTIONS: RecurringEventUpdateScope[] = [ RecurringEventUpdateScope.ALL_EVENTS, ]; -const updateScopeOptionClassName = +// Guest edits have no per-occurrence semantics in v1: sync refuses attendee +// replacements at scope "this"/"thisAndFollowing", so a save that changed the +// guest set narrows to the whole series — same narrowing mechanism as a +// structural recurrence change above. +const GUESTS_CHANGED_UPDATE_SCOPE_OPTIONS: RecurringEventUpdateScope[] = [ + RecurringEventUpdateScope.ALL_EVENTS, +]; + +// Shared with RsvpScopeDialog so both scope dialogs render identical radio +// rows. +export const updateScopeOptionClassName = "flex min-h-11 cursor-pointer items-center gap-3 rounded px-3 text-base text-text transition-colors hover:bg-surface-overlay"; -const selectedUpdateScopeOptionClassName = "bg-surface-overlay"; +export const selectedUpdateScopeOptionClassName = "bg-surface-overlay"; -const radioDotClassName = +export const radioDotClassName = "relative flex size-[18px] flex-none rounded-full border-2 border-border-strong transition-colors after:absolute after:inset-0 after:m-auto after:size-2 after:scale-0 after:rounded-full after:bg-accent after:transition-transform peer-checked:border-accent peer-checked:after:scale-100 peer-focus-visible:ring-2 peer-focus-visible:ring-accent peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-surface-panel"; type RecurrenceScopeConfirmationDialogProps = { @@ -75,9 +86,12 @@ export function RecurringEventUpdateScopeDialogContent({ (draft && storeDraft ? DirtyParser.gridDraftRecurrenceChanged(draft, storeDraft) : false); - const options = recurrenceChanged - ? RECURRENCE_CHANGED_UPDATE_SCOPE_OPTIONS - : UPDATE_SCOPE_OPTIONS; + const guestsChanged = draft ? gridDraftGuestsChanged(draft) : false; + const options = guestsChanged + ? GUESTS_CHANGED_UPDATE_SCOPE_OPTIONS + : recurrenceChanged + ? RECURRENCE_CHANGED_UPDATE_SCOPE_OPTIONS + : UPDATE_SCOPE_OPTIONS; const [fallbackScope] = options; const [selectedScope, setSelectedScope] = @@ -127,6 +141,12 @@ export function RecurringEventUpdateScopeDialogContent({ })} + {guestsChanged && ( +

+ Guest changes apply to all events in the series. +

+ )} + Cancel diff --git a/packages/web/src/views/Forms/EventForm/RsvpControl.test.tsx b/packages/web/src/views/Forms/EventForm/RsvpControl.test.tsx new file mode 100644 index 0000000000..f17314c824 --- /dev/null +++ b/packages/web/src/views/Forms/EventForm/RsvpControl.test.tsx @@ -0,0 +1,241 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { rest } from "msw"; +import { EventIdSchema } from "@core/types/domain-primitives"; +import { type Event } from "@core/types/event.contracts"; +import { type Attendee } from "@core/types/event-attendance.contracts"; +import { composeOccurrenceId } from "@core/util/occurrence-id"; +import { server } from "@web/__tests__/__mocks__/server/mock.server"; +import { createStoreWrapper } from "@web/__tests__/render-with-store"; +import { createMockEvent } from "@web/__tests__/utils/factories/event.factory"; +import { ENV_WEB } from "@web/common/constants/env.constants"; +import { createObjectIdString } from "@web/common/utils/id/object-id.util"; +import { RsvpControl } from "@web/views/Forms/EventForm/RsvpControl"; +import { describe, expect, it } from "bun:test"; + +// WP-08: the Going / Maybe / Decline segmented control. Semantics are pinned +// through RTL role/name queries (radiogroup + named radios) — the same +// contract an assistive technology reads — because the axe e2e harness runs +// the anonymous local-mode app, where no Google-calendar invitation can exist +// for the control to mount on. + +const ACCOUNT_EMAIL = "me@example.com"; + +const attendees = (selfStatus: Attendee["responseStatus"]): Attendee[] => [ + // Case-differing self entry: the match must be case-insensitive. + { email: "Me@Example.com", displayName: null, responseStatus: selfStatus }, + { + email: "guest@example.com", + displayName: "Guest One", + responseStatus: "declined", + }, +]; + +const invitedEvent = ( + selfStatus: Attendee["responseStatus"], + overrides: Partial = {}, +): Event => + createMockEvent({ + content: { + kind: "details", + title: "Planning", + description: "", + attendees: attendees(selfStatus), + }, + ...overrides, + }); + +const renderControl = (event: Event) => { + const { wrapper } = createStoreWrapper(); + return render(, { + wrapper, + }); +}; + +const captureRsvpRequests = () => { + const requests: Array<{ path: string; body: unknown }> = []; + server.use( + rest.post( + `${ENV_WEB.API_BASEURL}/event/:id/rsvp`, + async (req, res, ctx) => { + requests.push({ path: req.url.pathname, body: await req.json() }); + return res(ctx.status(204)); + }, + ), + ); + return requests; +}; + +describe("RsvpControl", () => { + it("renders a labelled radiogroup with Going / Maybe / Decline and the current answer checked", () => { + renderControl(invitedEvent("tentative")); + + expect( + screen.getByRole("radiogroup", { name: "Going?" }), + ).toBeInTheDocument(); + expect(screen.getByRole("radio", { name: "Going" })).not.toBeChecked(); + expect(screen.getByRole("radio", { name: "Maybe" })).toBeChecked(); + expect(screen.getByRole("radio", { name: "Decline" })).not.toBeChecked(); + }); + + it("leaves every option unchecked while the invitation is unanswered", () => { + renderControl(invitedEvent("needsAction")); + + for (const name of ["Going", "Maybe", "Decline"]) { + expect(screen.getByRole("radio", { name })).not.toBeChecked(); + } + }); + + it("renders nothing when the account email is not in the attendee list", () => { + const event = createMockEvent({ + content: { + kind: "details", + title: "Their meeting", + description: "", + attendees: [ + { + email: "guest@example.com", + displayName: null, + responseStatus: "accepted", + }, + ], + }, + }); + + renderControl(event); + + expect(screen.queryByRole("radiogroup")).not.toBeInTheDocument(); + }); + + it("answers a single event immediately with scope single and no dialog", async () => { + const user = userEvent.setup(); + const requests = captureRsvpRequests(); + const event = invitedEvent("needsAction"); + + renderControl(event); + await user.click(screen.getByRole("radio", { name: "Going" })); + + await waitFor(() => expect(requests).toHaveLength(1)); + expect(requests[0]?.path.endsWith(`/event/${event.id}/rsvp`)).toBe(true); + expect(requests[0]?.body).toEqual({ + responseStatus: "accepted", + scope: "single", + }); + // Single events never see the scope dialog. + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("offers This Event / All Events (never this-and-following) for an occurrence and posts the occurrence id for This Event", async () => { + const user = userEvent.setup(); + const requests = captureRsvpRequests(); + const seriesId = createObjectIdString(); + const occurrenceId = composeOccurrenceId({ + eventId: seriesId, + recurrenceId: "2026-05-05T14:00:00.000Z", + }); + const occurrence = invitedEvent("needsAction", { + id: EventIdSchema.parse(occurrenceId), + recurrence: { + kind: "occurrence", + seriesId: EventIdSchema.parse(seriesId), + }, + }); + + renderControl(occurrence); + await user.click(screen.getByRole("radio", { name: "Decline" })); + + // The choice defers to the dialog: nothing on the wire yet. + const dialog = await screen.findByRole("dialog"); + expect(requests).toHaveLength(0); + expect( + screen.getByRole("radio", { name: "This Event" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("radio", { name: "All Events" }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("radio", { name: /following/i }), + ).not.toBeInTheDocument(); + expect(dialog).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Ok" })); + + await waitFor(() => expect(requests).toHaveLength(1)); + // The composite occurrence id rides the URL — this occurrence only. + expect( + requests[0]?.path.endsWith( + `/event/${encodeURIComponent(occurrenceId)}/rsvp`, + ), + ).toBe(true); + expect(requests[0]?.body).toEqual({ + responseStatus: "declined", + scope: "single", + }); + }); + + it("posts scope all when All Events is chosen for an occurrence", async () => { + const user = userEvent.setup(); + const requests = captureRsvpRequests(); + const seriesId = createObjectIdString(); + const occurrence = invitedEvent("needsAction", { + id: EventIdSchema.parse( + composeOccurrenceId({ + eventId: seriesId, + recurrenceId: "2026-05-05T14:00:00.000Z", + }), + ), + recurrence: { + kind: "occurrence", + seriesId: EventIdSchema.parse(seriesId), + }, + }); + + renderControl(occurrence); + await user.click(screen.getByRole("radio", { name: "Maybe" })); + await user.click(await screen.findByRole("radio", { name: "All Events" })); + await user.click(screen.getByRole("button", { name: "Ok" })); + + await waitFor(() => expect(requests).toHaveLength(1)); + expect(requests[0]?.body).toEqual({ + responseStatus: "tentative", + scope: "all", + }); + }); + + it("sends nothing when the scope dialog is cancelled", async () => { + const user = userEvent.setup(); + const requests = captureRsvpRequests(); + const seriesId = createObjectIdString(); + const occurrence = invitedEvent("needsAction", { + id: EventIdSchema.parse( + composeOccurrenceId({ + eventId: seriesId, + recurrenceId: "2026-05-05T14:00:00.000Z", + }), + ), + recurrence: { + kind: "occurrence", + seriesId: EventIdSchema.parse(seriesId), + }, + }); + + renderControl(occurrence); + await user.click(screen.getByRole("radio", { name: "Decline" })); + await user.click( + (await screen.findAllByRole("button", { name: "Cancel" }))[0] as Element, + ); + + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(requests).toHaveLength(0); + }); + + it("re-choosing the current answer sends nothing", async () => { + const user = userEvent.setup(); + const requests = captureRsvpRequests(); + + renderControl(invitedEvent("accepted")); + await user.click(screen.getByRole("radio", { name: "Going" })); + + expect(requests).toHaveLength(0); + }); +}); diff --git a/packages/web/src/views/Forms/EventForm/RsvpControl.tsx b/packages/web/src/views/Forms/EventForm/RsvpControl.tsx new file mode 100644 index 0000000000..8a45d1342f --- /dev/null +++ b/packages/web/src/views/Forms/EventForm/RsvpControl.tsx @@ -0,0 +1,112 @@ +import { useId, useState } from "react"; +import { type Event } from "@core/types/event.contracts"; +import { type RsvpResponseStatus } from "@core/types/event-attendance.contracts"; +import { useEventMutations } from "@web/events/mutations/useEventMutations"; +import { RsvpScopeDialog } from "@web/views/Forms/EventForm/RsvpScopeDialog"; + +// Going / Maybe / Decline segmented control (WP-08). Rendered only when the +// calendar's account email appears in the event's attendee list (organizer +// included) — the gate lives in EventForm, but the self lookup here fails +// closed too. Answering is allowed on viewer-access calendars (it is not a +// calendar write), so this control lives outside the form's read-only +// fieldset. Choosing a response on a single event submits immediately; on an +// occurrence of a series it opens the "This Event" / "All Events" choice +// (RecurrenceScopeDialog pattern — never this-and-following); a series base +// answers the whole series. +const RSVP_OPTIONS: ReadonlyArray<{ + status: RsvpResponseStatus; + label: string; +}> = [ + { status: "accepted", label: "Going" }, + { status: "tentative", label: "Maybe" }, + { status: "declined", label: "Decline" }, +]; + +interface RsvpControlProps { + /** The live source event (cache-backed, so the optimistic answer paints). */ + event: Event; + /** The connected account email the self attendee entry is matched by. */ + accountEmail: string; +} + +export const RsvpControl = ({ event, accountEmail }: RsvpControlProps) => { + const groupName = useId(); + const labelId = useId(); + const [pendingStatus, setPendingStatus] = useState( + null, + ); + const { rsvp } = useEventMutations(); + + const selfStatus = + event.content.kind === "details" + ? event.content.attendees?.find( + (attendee) => + attendee.email.toLowerCase() === accountEmail.toLowerCase(), + )?.responseStatus + : undefined; + // Fail closed: no self entry, nothing to answer (EventForm gates this too). + if (selfStatus === undefined) return null; + + const respond = (responseStatus: RsvpResponseStatus) => { + if (responseStatus === selfStatus) return; + if (event.recurrence.kind === "occurrence") { + // Recurring: the scope choice decides between this occurrence and the + // whole series. Single events never see the dialog. + setPendingStatus(responseStatus); + return; + } + rsvp({ + id: event.id, + responseStatus, + // A series base has no single occurrence to answer — its RSVP covers + // the whole series; a plain event answers itself. + scope: event.recurrence.kind === "series" ? "all" : "single", + accountEmail, + }); + }; + + return ( + <> +
+ + Going? + +
+ {RSVP_OPTIONS.map(({ status, label }) => ( + + ))} +
+
+ {pendingStatus !== null && ( + setPendingStatus(null)} + onConfirm={(scope) => { + rsvp({ + id: event.id, + responseStatus: pendingStatus, + scope, + accountEmail, + }); + setPendingStatus(null); + }} + /> + )} + + ); +}; diff --git a/packages/web/src/views/Forms/EventForm/RsvpScopeDialog.tsx b/packages/web/src/views/Forms/EventForm/RsvpScopeDialog.tsx new file mode 100644 index 0000000000..631f95cc47 --- /dev/null +++ b/packages/web/src/views/Forms/EventForm/RsvpScopeDialog.tsx @@ -0,0 +1,78 @@ +import { useState } from "react"; +import { type RsvpEventInput } from "@core/types/event-command.contracts"; +import { + OverlayPanel, + OverlayPanelActionButton, + OverlayPanelActions, +} from "@web/components/OverlayPanel/OverlayPanel"; +import { + radioDotClassName, + selectedUpdateScopeOptionClassName, + updateScopeOptionClassName, +} from "@web/views/Forms/EventForm/RecurrenceScopeDialog"; + +type RsvpScope = RsvpEventInput["scope"]; + +// The per-occurrence choice for answering a recurring invitation +// (RecurrenceScopeDialog pattern, product decision 3). Deliberately only +// "This Event" / "All Events": an RSVP has no this-and-following semantics — +// sync refuses that scope typed, so this dialog must never offer it. +const RSVP_SCOPE_OPTIONS: ReadonlyArray<{ value: RsvpScope; label: string }> = [ + { value: "single", label: "This Event" }, + { value: "all", label: "All Events" }, +]; + +interface RsvpScopeDialogProps { + onCancel: () => void; + onConfirm: (scope: RsvpScope) => void; +} + +export function RsvpScopeDialog({ onCancel, onConfirm }: RsvpScopeDialogProps) { + const [selectedScope, setSelectedScope] = useState("single"); + + return ( + +
+ {RSVP_SCOPE_OPTIONS.map(({ value, label }) => { + const isSelected = selectedScope === value; + + return ( + + ); + })} +
+ + + + Cancel + + onConfirm(selectedScope)} + > + Ok + + +
+ ); +} diff --git a/packages/web/src/views/Forms/EventForm/SendInvitationsDialog.tsx b/packages/web/src/views/Forms/EventForm/SendInvitationsDialog.tsx new file mode 100644 index 0000000000..f82824f360 --- /dev/null +++ b/packages/web/src/views/Forms/EventForm/SendInvitationsDialog.tsx @@ -0,0 +1,47 @@ +import { useRef } from "react"; +import { + OverlayPanel, + OverlayPanelActionButton, + OverlayPanelActions, +} from "@web/components/OverlayPanel/OverlayPanel"; +import { type EventInvitationPrompt } from "@web/views/Forms/hooks/useSaveEventForm"; + +type SendInvitationsDialogProps = { + prompt: EventInvitationPrompt; +}; + +/** + * Save-time "Send invitation emails?" choice, shown only when a save changed + * the guest set. Send (the default, focused on open) has Google email the + * affected guests (`invitation: "all"`); Don't send saves silently + * (`"none"`). Dismissing (Escape / backdrop) cancels the save and returns to + * the form. Compass never sends email itself — Google does, via sendUpdates. + */ +export function SendInvitationsDialog({ prompt }: SendInvitationsDialogProps) { + const sendButtonRef = useRef(null); + + if (!prompt) return null; + + return ( + + + + Don't send + + + Send + + + + ); +} diff --git a/packages/web/src/views/Forms/hooks/shouldConfirmDiscardUnsavedChanges.test.ts b/packages/web/src/views/Forms/hooks/shouldConfirmDiscardUnsavedChanges.test.ts index f419a612de..9e154daaa0 100644 --- a/packages/web/src/views/Forms/hooks/shouldConfirmDiscardUnsavedChanges.test.ts +++ b/packages/web/src/views/Forms/hooks/shouldConfirmDiscardUnsavedChanges.test.ts @@ -89,4 +89,36 @@ describe("shouldConfirmDiscardUnsavedChanges", () => { expect(shouldConfirmDiscardUnsavedChanges(draft)).toBe(true); }); + + it("returns true when the guest set changed, false when guests were touched but restored", () => { + const existingEvent = createMockEvent({ + id: EventIdSchema.parse("aaaaaaaaaaaaaaaaaaaaaaaa"), + content: { + kind: "details", + title: "Existing Event", + description: "", + attendees: [ + { + email: "guest@example.com", + displayName: null, + responseStatus: "accepted", + }, + ], + }, + }); + const draft = editGridEventDraft(existingEvent); + if (!draft) throw new Error("expected an edit draft"); + + draft.values.attendees = [ + { email: "guest@example.com", displayName: null }, + { email: "new-guest@example.com", displayName: null }, + ]; + expect(shouldConfirmDiscardUnsavedChanges(draft)).toBe(true); + + // Adding a guest and removing them again is not an unsaved change. + draft.values.attendees = [ + { email: "GUEST@example.com", displayName: null }, + ]; + expect(shouldConfirmDiscardUnsavedChanges(draft)).toBe(false); + }); }); diff --git a/packages/web/src/views/Forms/hooks/useSaveEventForm.attendees.test.tsx b/packages/web/src/views/Forms/hooks/useSaveEventForm.attendees.test.tsx new file mode 100644 index 0000000000..164c592a30 --- /dev/null +++ b/packages/web/src/views/Forms/hooks/useSaveEventForm.attendees.test.tsx @@ -0,0 +1,302 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook } from "@testing-library/react"; +import { type PropsWithChildren } from "react"; +import { + type Calendar, + getCalendarCapabilities, +} from "@core/types/calendar.contracts"; +import { CalendarIdSchema } from "@core/types/domain-primitives"; +import { type Event } from "@core/types/event.contracts"; +import { + type CreateEventInput, + type ReplaceEventInput, +} from "@core/types/event-command.contracts"; +import { createMockEvent } from "@web/__tests__/utils/factories/event.factory"; +import { calendarQueryKeys } from "@web/calendars/calendar.query"; +import { RecurringEventUpdateScope } from "@web/common/types/web.event.types"; +import { + createGridEventDraft, + editGridEventDraft, + timedGridSchedule, +} from "@web/events/grid-event-draft.adapter"; +import { draftActions } from "@web/events/stores/draft.store"; +import { useSaveEventForm } from "./useSaveEventForm"; +import { beforeEach, describe, expect, it } from "bun:test"; + +// WP-04: guest-set change detection, the save-time "Send invitation emails?" +// prompt, and the payloads each choice produces. Payload assertions read the +// mutation variables at the useEventMutations funnel — the exact input every +// repository receives; the byte-level wire assertion (through MSW) lives in +// useEventMutations.attendees.test.tsx. + +const calendarId = CalendarIdSchema.parse("cccccccccccccccccccccccc"); + +const googleCalendar = (overrides: Partial = {}): Calendar => ({ + id: calendarId, + name: "Work", + description: "", + timeZone: null, + foregroundColor: "#000000", + backgroundColor: "#3b82f6", + provider: "google", + access: "owner", + capabilities: getCalendarCapabilities("owner"), + isPrimary: true, + isVisible: true, + isActive: true, + accountEmail: "me@example.com", + ...overrides, +}); + +const meetingEvent = (overrides: Partial = {}): Event => + createMockEvent({ + calendarId, + content: { + kind: "details", + title: "Weekly sync", + description: "", + attendees: [ + { + email: "guest@example.com", + displayName: null, + responseStatus: "accepted", + }, + ], + }, + ...overrides, + }); + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + queryClient.setQueryData(calendarQueryKeys.all, [googleCalendar()]); + + function Wrapper({ children }: PropsWithChildren) { + return ( + {children} + ); + } + + return { queryClient, Wrapper }; +} + +const editDraftOrThrow = (event: Event) => { + const draft = editGridEventDraft(event); + if (!draft) throw new Error("expected an edit draft"); + return draft; +}; + +const replaceVariables = (queryClient: QueryClient) => + queryClient.getMutationCache().getAll()[0]?.state.variables as + | { id: string; input: ReplaceEventInput } + | undefined; + +const createVariables = (queryClient: QueryClient) => + queryClient.getMutationCache().getAll()[0]?.state.variables as + | { input: CreateEventInput } + | undefined; + +describe("useSaveEventForm guest edits", () => { + beforeEach(() => { + draftActions.discard(); + }); + + it("saves an untouched guest list with no prompt, omitting attendees and invitation", () => { + const { queryClient, Wrapper } = createWrapper(); + const { result } = renderHook(() => useSaveEventForm(), { + wrapper: Wrapper, + }); + const draft = editDraftOrThrow(meetingEvent()); + + act(() => { + result.current.saveEventForm(draft); + }); + + expect(result.current.invitationPrompt).toBeNull(); + const variables = replaceVariables(queryClient); + expect(variables).toBeDefined(); + expect(variables?.input.content).not.toContainKey("attendees"); + expect(variables?.input).not.toContainKey("invitation"); + }); + + it("treats a touched-but-unchanged guest list (case-insensitive) as untouched", () => { + const { queryClient, Wrapper } = createWrapper(); + const { result } = renderHook(() => useSaveEventForm(), { + wrapper: Wrapper, + }); + const draft = editDraftOrThrow(meetingEvent()); + draft.values.attendees = [ + { email: "GUEST@example.com", displayName: null }, + ]; + + act(() => { + result.current.saveEventForm(draft); + }); + + expect(result.current.invitationPrompt).toBeNull(); + const variables = replaceVariables(queryClient); + expect(variables?.input.content).not.toContainKey("attendees"); + expect(variables?.input).not.toContainKey("invitation"); + }); + + it("prompts before saving a changed guest set; Send maps to invitation 'all'", () => { + const { queryClient, Wrapper } = createWrapper(); + const { result } = renderHook(() => useSaveEventForm(), { + wrapper: Wrapper, + }); + const draft = editDraftOrThrow(meetingEvent()); + draft.values.attendees = [ + { email: "guest@example.com", displayName: null }, + { email: "new-guest@example.com", displayName: null }, + ]; + + act(() => { + result.current.saveEventForm(draft); + }); + + // Nothing saved yet - the invitation choice comes first. + expect(result.current.invitationPrompt).not.toBeNull(); + expect(queryClient.getMutationCache().getAll()).toHaveLength(0); + + act(() => { + result.current.invitationPrompt?.onSend(); + }); + + expect(result.current.invitationPrompt).toBeNull(); + const variables = replaceVariables(queryClient); + expect(variables?.input.content.attendees).toEqual([ + { email: "guest@example.com", displayName: null }, + { email: "new-guest@example.com", displayName: null }, + ]); + expect(variables?.input.invitation).toBe("all"); + }); + + it("maps Don't send to invitation 'none'", () => { + const { queryClient, Wrapper } = createWrapper(); + const { result } = renderHook(() => useSaveEventForm(), { + wrapper: Wrapper, + }); + const draft = editDraftOrThrow(meetingEvent()); + draft.values.attendees = []; + + act(() => { + result.current.saveEventForm(draft); + }); + act(() => { + result.current.invitationPrompt?.onDontSend(); + }); + + const variables = replaceVariables(queryClient); + // Removing every guest is still an explicit replace-with-empty-set. + expect(variables?.input.content.attendees).toEqual([]); + expect(variables?.input.invitation).toBe("none"); + }); + + it("cancelling the prompt abandons the save entirely", () => { + const { queryClient, Wrapper } = createWrapper(); + const { result } = renderHook(() => useSaveEventForm(), { + wrapper: Wrapper, + }); + const draft = editDraftOrThrow(meetingEvent()); + draft.values.attendees = []; + + act(() => { + result.current.saveEventForm(draft); + }); + act(() => { + result.current.invitationPrompt?.onCancel(); + }); + + expect(result.current.invitationPrompt).toBeNull(); + expect(queryClient.getMutationCache().getAll()).toHaveLength(0); + }); + + it("prompts for a create draft that added guests and threads them into the create input", () => { + const { queryClient, Wrapper } = createWrapper(); + const { result } = renderHook(() => useSaveEventForm(), { + wrapper: Wrapper, + }); + const draft = createGridEventDraft( + timedGridSchedule( + new Date("2026-05-20T10:00:00.000Z"), + new Date("2026-05-20T11:00:00.000Z"), + ), + undefined, + calendarId, + ); + draft.values.attendees = [ + { email: "new-guest@example.com", displayName: null }, + ]; + + act(() => { + result.current.saveEventForm(draft); + }); + expect(result.current.invitationPrompt).not.toBeNull(); + + act(() => { + result.current.invitationPrompt?.onSend(); + }); + + const variables = createVariables(queryClient); + expect(variables?.input.content.attendees).toEqual([ + { email: "new-guest@example.com", displayName: null }, + ]); + expect(variables?.input.invitation).toBe("all"); + }); + + it("drops a guest edit on a recurring event saved at a non-'all' scope (belt behind the UI gates)", () => { + const { queryClient, Wrapper } = createWrapper(); + const { result } = renderHook(() => useSaveEventForm(), { + wrapper: Wrapper, + }); + const draft = editDraftOrThrow( + meetingEvent({ + recurrence: { kind: "series", rules: ["RRULE:FREQ=WEEKLY"] }, + }), + ); + draft.values.attendees = [ + { email: "guest@example.com", displayName: null }, + { email: "new-guest@example.com", displayName: null }, + ]; + + act(() => { + result.current.saveEventForm(draft, RecurringEventUpdateScope.THIS_EVENT); + }); + + // Dropped back to preserve semantics: no prompt, no attendees on the wire. + expect(result.current.invitationPrompt).toBeNull(); + const variables = replaceVariables(queryClient); + expect(variables?.input.scope).toBe("this"); + expect(variables?.input.content).not.toContainKey("attendees"); + expect(variables?.input).not.toContainKey("invitation"); + }); + + it("keeps a recurring guest edit when saved series-wide ('all' scope)", () => { + const { queryClient, Wrapper } = createWrapper(); + const { result } = renderHook(() => useSaveEventForm(), { + wrapper: Wrapper, + }); + const draft = editDraftOrThrow( + meetingEvent({ + recurrence: { kind: "series", rules: ["RRULE:FREQ=WEEKLY"] }, + }), + ); + draft.values.attendees = [ + { email: "guest@example.com", displayName: null }, + { email: "new-guest@example.com", displayName: null }, + ]; + + act(() => { + result.current.saveEventForm(draft, RecurringEventUpdateScope.ALL_EVENTS); + }); + act(() => { + result.current.invitationPrompt?.onSend(); + }); + + const variables = replaceVariables(queryClient); + expect(variables?.input.scope).toBe("all"); + expect(variables?.input.content.attendees).toHaveLength(2); + expect(variables?.input.invitation).toBe("all"); + }); +}); diff --git a/packages/web/src/views/Forms/hooks/useSaveEventForm.ts b/packages/web/src/views/Forms/hooks/useSaveEventForm.ts index 3226830981..96d91e15a9 100644 --- a/packages/web/src/views/Forms/hooks/useSaveEventForm.ts +++ b/packages/web/src/views/Forms/hooks/useSaveEventForm.ts @@ -1,34 +1,100 @@ -import { useCallback, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; +import { type CreateEventInput } from "@core/types/event-command.contracts"; import { useCalendarsQuery } from "@web/calendars/calendar.query"; import { useDefaultTargetCalendar } from "@web/calendars/useDefaultTargetCalendar"; import { RecurringEventUpdateScope } from "@web/common/types/web.event.types"; import { type GridEventDraft } from "@web/events/event-draft.types"; -import { parseGridEventDraft } from "@web/events/grid-event-draft.adapter"; +import { + gridDraftGuestsChanged, + parseGridEventDraft, + withoutGuestEdit, +} from "@web/events/grid-event-draft.adapter"; import { useEventMutations } from "@web/events/mutations/useEventMutations"; import { toRecurrenceScope } from "@web/events/recurrence/recurrence-scope"; import { useCloseEventForm } from "@web/views/Forms/hooks/useCloseEventForm"; +type InvitationIntentValue = NonNullable; + +/** + * Live "Send invitation emails?" decision for a save whose guest set changed: + * Send maps to invitation "all", Don't send to "none", Cancel abandons the + * save (the form stays open with the draft intact). Null when no save is + * waiting on the choice. + */ +export type EventInvitationPrompt = { + onSend: () => void; + onDontSend: () => void; + onCancel: () => void; +} | null; + export function useSaveEventForm() { const closeEventForm = useCloseEventForm(); const { create, replace } = useEventMutations(); const { data: calendars } = useCalendarsQuery(); const defaultTargetCalendarId = useDefaultTargetCalendar(calendars ?? [])?.id; const [fieldErrors, setFieldErrors] = useState>({}); + const [pendingInvitationSave, setPendingInvitationSave] = useState<{ + draft: GridEventDraft; + applyTo: RecurringEventUpdateScope; + } | null>(null); const clearFieldErrors = useCallback(() => { setFieldErrors({}); }, []); - const saveEventForm = useCallback( + // Belt behind the editor's own render gates: a guest edit that could never + // deliver is dropped back to preserve semantics instead of submitting a + // command sync/backend will refuse. The UI cannot reach these states — the + // editor only renders on writable Google calendars the user organizes, + // occurrence drafts never render it, and the scope dialog narrows a + // guest-changed recurring edit to "all" — so this only defends replayed or + // hand-built drafts. + const normalizeGuestEdit = useCallback( ( - draft: GridEventDraft | null, - applyTo: RecurringEventUpdateScope = RecurringEventUpdateScope.THIS_EVENT, - ) => { - if (!draft) { - clearFieldErrors(); - return closeEventForm(); + draft: GridEventDraft, + applyTo: RecurringEventUpdateScope, + ): GridEventDraft => { + if (draft.values.attendees === undefined) return draft; + // A touched-but-unchanged guest list is not an edit: omit attendees so + // the payload stays byte-identical to an untouched save and no + // invitation prompt appears. + if (!gridDraftGuestsChanged(draft)) return withoutGuestEdit(draft); + + if (draft.kind === "edit") { + // Sync refuses guest replacements at scope "this"/"thisAndFollowing" + // (per-occurrence guest lists have no v1 semantics) — recurring + // guest edits are series-wide only. + const isRecurring = draft.source.recurrence.kind !== "single"; + if (isRecurring && toRecurrenceScope(applyTo) !== "all") { + console.warn( + "[useSaveEventForm] dropped guest edit: recurring guest edits apply to the whole series only", + ); + return withoutGuestEdit(draft); + } + return draft; } + // Create: guests only deliver to a writable Google calendar + // (ATTENDEES_UNSUPPORTED backstop server-side). + const calendarId = draft.values.calendarId ?? defaultTargetCalendarId; + const calendar = calendars?.find((entry) => entry.id === calendarId); + if (calendar?.provider !== "google" || !calendar.capabilities.canWrite) { + console.warn( + "[useSaveEventForm] dropped guest edit: target calendar cannot deliver a guest list", + ); + return withoutGuestEdit(draft); + } + return draft; + }, + [calendars, defaultTargetCalendarId], + ); + + const commitSave = useCallback( + ( + draft: GridEventDraft, + applyTo: RecurringEventUpdateScope, + invitation?: InvitationIntentValue, + ) => { if (draft.kind === "create") { // Respects a calendar the user explicitly chose via CalendarSelect; // only an untouched draft (calendarId still null) falls back to the @@ -54,7 +120,12 @@ export function useSaveEventForm() { // Closing via the callback (not after `create` returns) keeps the draft // card mounted until the optimistic insert exists, so the saved card // replaces it in one commit instead of flashing empty. - create(parsed.input, { onOptimisticApplied: closeEventForm }); + create( + invitation === undefined + ? parsed.input + : { ...parsed.input, invitation }, + { onOptimisticApplied: closeEventForm }, + ); } return; } @@ -75,7 +146,13 @@ export function useSaveEventForm() { // Same as create: keep the draft mounted until the optimistic replace // exists so the grid never paints a frame with the pre-edit color. replace( - { id: parsed.eventId, input: parsed.input }, + { + id: parsed.eventId, + input: + invitation === undefined + ? parsed.input + : { ...parsed.input, invitation }, + }, { onOptimisticApplied: closeEventForm }, ); } @@ -89,5 +166,44 @@ export function useSaveEventForm() { ], ); - return { saveEventForm, fieldErrors, clearFieldErrors }; + const saveEventForm = useCallback( + ( + draft: GridEventDraft | null, + applyTo: RecurringEventUpdateScope = RecurringEventUpdateScope.THIS_EVENT, + ) => { + if (!draft) { + clearFieldErrors(); + return closeEventForm(); + } + + const normalized = normalizeGuestEdit(draft, applyTo); + + // A membership-changing guest edit needs the save-time invitation + // choice first — Google emails the guests itself via sendUpdates, so + // this is the one moment the user decides whether it should. + if (gridDraftGuestsChanged(normalized)) { + setPendingInvitationSave({ draft: normalized, applyTo }); + return; + } + + commitSave(normalized, applyTo); + }, + [clearFieldErrors, closeEventForm, commitSave, normalizeGuestEdit], + ); + + const invitationPrompt: EventInvitationPrompt = useMemo(() => { + if (!pendingInvitationSave) return null; + const { draft, applyTo } = pendingInvitationSave; + const resolve = (invitation: InvitationIntentValue) => { + setPendingInvitationSave(null); + commitSave(draft, applyTo, invitation); + }; + return { + onSend: () => resolve("all"), + onDontSend: () => resolve("none"), + onCancel: () => setPendingInvitationSave(null), + }; + }, [commitSave, pendingInvitationSave]); + + return { saveEventForm, fieldErrors, clearFieldErrors, invitationPrompt }; } diff --git a/packages/web/src/views/Week/hooks/shortcuts/useUndoRedoShortcuts.test.tsx b/packages/web/src/views/Week/hooks/shortcuts/useUndoRedoShortcuts.test.tsx index c11fdc5cad..1d6e51885b 100644 --- a/packages/web/src/views/Week/hooks/shortcuts/useUndoRedoShortcuts.test.tsx +++ b/packages/web/src/views/Week/hooks/shortcuts/useUndoRedoShortcuts.test.tsx @@ -55,12 +55,12 @@ const setup = () => { const repository: EventRepository = { list: async () => [], create: async (input) => ({ ...before, id: input.id as EventId }), - replace: async (id: EventId, input): Promise => ({ - ...before, - id, - content: input.content, - schedule: input.schedule, - }), + replace: async (id: EventId, input): Promise => { + // The write contract's guest-edit field never round-trips into the + // read-side Event content this stub returns. + const { attendees: _guestEdit, ...content } = input.content; + return { ...before, id, content, schedule: input.schedule }; + }, delete: async () => {}, }; const dependencies = { diff --git a/wip/attendee-support/00-context-and-invariants.md b/wip/attendee-support/00-context-and-invariants.md new file mode 100644 index 0000000000..d5b9bbc61c --- /dev/null +++ b/wip/attendee-support/00-context-and-invariants.md @@ -0,0 +1,139 @@ +# Attendee support — context, decisions, invariants + +Read once before your first WP. Skip if your WP is already unambiguous. + +## Where the codebase is today + +Attendees are fully modeled and **read-only end-to-end by explicit +design**: + +- Model: + [`packages/core/src/types/event-attendance.contracts.ts`](../../packages/core/src/types/event-attendance.contracts.ts) + — `AttendeeSchema` {email, displayName, responseStatus}, + `OrganizerSchema`, `ConferenceSchema`. +- Read path already works: gcal → `google-event.normalizer.ts` + (`mapAttendees`) → sync store → `event-instance-assembly.ts` → backend + `event-list.translation.ts` → web `event.view-model.ts` → + [`EventDetailsSection.tsx`](../../packages/web/src/views/Forms/EventForm/EventDetailsSection.tsx) + (read-only guest list with RSVP dots). Incoming RSVP changes from other + attendees already flow in via incremental sync + SSE `eventsChanged`. +- The write path drops attendees in four defended places: + 1. [`packages/core/src/types/event-command.contracts.ts`](../../packages/core/src/types/event-command.contracts.ts) + — `EditableContentSchema` (strictObject) excludes attendees. + 2. [`packages/backend/src/common/services/sync-service/event-command.translation.ts`](../../packages/backend/src/common/services/sync-service/event-command.translation.ts) + — `toSyncContent` hardcodes `organizer: null, attendees: [], + conference: null`; `invitation: "none"` hardcoded at three call + sites. + 3. [`packages/sync/src/domain/merge-update-content.ts`](../../packages/sync/src/domain/merge-update-content.ts) + — merges only title/description/location/color, so an edit cannot + wipe provider attendees. + 4. [`packages/sync/src/providers/google/google-event-writer.adapter.ts`](../../packages/sync/src/providers/google/google-event-writer.adapter.ts) + — `toGoogleBody` deliberately omits attendees (see the comment at + ~line 300); `toSendUpdates` maps `InvitationIntent` 1:1 to Google + `sendUpdates`. +- Sync's command inputs already carry + `invitation: InvitationIntentSchema` (`all | externalOnly | none`, + default none) end-to-end + ([`packages/core/src/types/sync/command.contracts.ts`](../../packages/core/src/types/sync/command.contracts.ts)); + only the backend hardcodes `"none"`. **Google sends invitation emails + itself when `sendUpdates != none` — Compass needs no email infra.** +- OAuth scopes live in FOUR lists that must stay in sync: + [`packages/backend/src/auth/services/google/google.auth.scopes.ts`](../../packages/backend/src/auth/services/google/google.auth.scopes.ts), + [`packages/sync/src/providers/google/google.scopes.ts`](../../packages/sync/src/providers/google/google.scopes.ts), + [`packages/web/src/auth/google/authorization/google-authorization.constants.ts`](../../packages/web/src/auth/google/authorization/google-authorization.constants.ts) + (client-side REQUIRED verification — sign-in fails if any is missing), + and [`e2e/oauth/google-auth-callback.spec.ts`](../../e2e/oauth/google-auth-callback.spec.ts). +- Sync's connect flow already does incremental auth: + `buildAuthorizationUrl` sets `include_granted_scopes: true`; granted + scopes persist per connection (`ProviderConnection.grantedScopes`) and + [`google-capabilities.ts`](../../packages/sync/src/providers/google/google-capabilities.ts) + derives capabilities from them (an `inviteAttendees` capability already + exists). Connection state has an `insufficientScopes` value. +- **No People API / contacts code exists anywhere.** + +## Invariants that must survive every WP + +1. [`packages/sync/src/safety/safety-canary.ts`](../../packages/sync/src/safety/safety-canary.ts): + attendee JSON is a forbidden log pattern. No attendee or contact data + in sync logs, SSE payloads, or error causes. Sync WPs state + "safety-canary tests pass" in Evidence. +2. Busy projections never carry attendee content + ([`packages/core/src/types/sync/busy.contracts.test.ts`](../../packages/core/src/types/sync/busy.contracts.test.ts)). +3. RSVP drift must not block command replay: `matchesIntendedEdit` + ([`provider-command.service.ts`](../../packages/sync/src/domain/provider-command.service.ts)) + keeps ignoring `responseStatus`; with `attendeesEdit: "replace"` it + compares email sets only (WP-02). +4. Legacy payloads keep parsing: every contract change is additive with + defaults (`attendeesEdit` defaults `"preserve"`; browser `attendees` + and `invitation` are optional). +5. `GOOGLE_AUTH_SCOPES_REQUIRED` (web) and the backend's required-scope + validation never gain a contacts scope — contacts are optional. +6. No barrel files; alias imports; Zod contracts for shared shapes in + `packages/core`; RTL semantic queries; Tailwind semantic colors (see + [`AGENTS.md`](../../AGENTS.md)). + +## Product decisions (approved 2026-08-25 — do not re-litigate) + +1. **Contacts = optional incremental grant, both scopes** + (`contacts.readonly` + `contacts.other.readonly`). Requested on the + onboarding consent screen but never required; users who leave them + unchecked proceed. An occasional, non-nagging nudge in the attendee + field offers to enable contacts via the connect flow's incremental + re-consent. +2. **Invitation emails: save-time prompt.** When the guest set changed, + ask "Send invitation emails?" defaulting to Send + (`sendUpdates: "all"`); "Don't send" maps to `"none"`. No prompt when + guests are untouched. +3. **Full RSVP including per-occurrence.** Accepted / declined / + tentative from Compass, targetable at one occurrence ("this event", + via the composite `eventId::recurrenceId` occurrence id) or the whole + series ("all events"), reusing the RecurrenceScopeDialog pattern. +4. **Non-organizer guest-list editing is rejected in v1** with a typed + `unsupportedCapability`; invited users get the RSVP control only. + `guestsCanModify` support is a documented follow-up. + +## Architecture decisions baked into the WPs + +- **Attendee input contract**: new `AttendeeInputSchema` {email, + displayName nullable} — no `responseStatus`. Browser + `EditableContentSchema` gains optional `attendees`; omitted = "not + editing guests" (today's behavior), present (including `[]`) = + "replace membership with this set". +- **Sync command intent**: create/update command inputs gain + `attendeesEdit: z.enum(["replace", "preserve"]).default("preserve")`. + Stored `SyncEventContentSchema` is unchanged. The default is the + backward-compat guarantee: every existing caller and stored command + stays valid and byte-identical in behavior. +- **Merge-by-email in sync, against freshly fetched provider state**: + retained emails keep the provider's current responseStatus and + displayName; new emails enter as `needsAction`; dropped emails are + removed. Google patch replaces the whole `attendees` array, so the + merge input must be `current.content`, not sync's stored record — + otherwise a concurrent RSVP between syncs gets clobbered. +- **RSVP is a new command kind**, not an overloaded update: + `{kind: "rsvp", responseStatus, ...}` in `SyncCommandInputSchema`. + Execution: fetch current provider state, rewrite only the self + attendee entry (matched by the connection's account email, + case-insensitive), patch the merged list with `sendUpdates: "none"`. + Replay check: current self status equals intended. Per-occurrence RSVP + targets the occurrence event via the existing occurrence-id decode. +- **People API code lives in sync** (sync owns all Google code): + `google-people.adapter.ts` behind a narrow `ContactsPort`; sync route + `/internal/contacts/suggestions` (principal-scoped); backend proxy + `GET /api/contacts/suggestions?q=` mirroring the existing + `sync-service.client.ts` proxy pattern. The grant lives where grants + already live: per-connection `grantedScopes` → new `suggestContacts` + capability → surfaced to the browser on `GoogleSyncConnectionSummary`. +- **Etag discipline unchanged**: `expectedVersion` stays as-is; no + If-Match retry loops. The fetch→patch race window is a named wart + documented in WP-09. + +## Named warts (documented, accepted for v1) + +- Fetch→patch race: a provider-side change between sync's fetch and + patch can be overwritten within that window. +- Alias-email self-match: RSVP matches the self attendee by the + connection's account email; Google aliases may not match. +- Google auto-adds the organizer as an accepted attendee on create, so + post-create readback can differ from the intended set (normalizer + handles it; never compare create bodies to readback). diff --git a/wip/attendee-support/README.md b/wip/attendee-support/README.md new file mode 100644 index 0000000000..8968eb92df --- /dev/null +++ b/wip/attendee-support/README.md @@ -0,0 +1,124 @@ +# Attendee support — work pack + +Temporary project pack. Manager sessions execute the work packages (WPs) +in dependency order on the integration branch +`claude/attendee-support-planning-nljgeg`, then **delete this entire +directory** when the pack finish line holds and the PR is merged. + +Do not re-litigate the design. The architecture decisions live in +[`00-context-and-invariants.md`](00-context-and-invariants.md) and were +approved by the product owner on 2026-08-25. If a WP's steps contradict +the code you find, prefer the code, note the delta in the WP's Evidence +section, and keep the WP's finish line intact. + +## How to pick up (manager-loop protocol) + +1. Check out `claude/attendee-support-planning-nljgeg` from origin. If it + no longer exists (pack merged to `main`), recreate it from + `origin/main`. +2. Read this file and [`TRACKING.md`](TRACKING.md). Do not start from chat + memory. +3. Concurrency guard: if any row is `running` with a `started_at` + timestamp younger than 3 hours, stop — another session is working. + Exit as a no-op. If the `running` row is older than 3 hours, treat it + as abandoned: fill that WP's Handoff block from what you can observe, + then take the WP over. +4. Take the first WP whose status is `queued` and whose dependencies are + `done`. Set that row to `running` with yourself as `owner` and a UTC + `started_at` timestamp in the evidence column. Commit and push the + ledger update before implementing, so overlapping sessions see it. +5. Implement only that WP. The finish line, steps, acceptance tests, and + a paste-ready session prompt are in the WP file. +6. Validate before pushing: the changed package's test suite, + `bun run type-check`, `bun lint`, `bun knip`. Sync-package WPs must + also state "safety-canary tests pass" explicitly in Evidence. +7. Fill Evidence on the WP and the tracking row. Set status to + `verifying`, re-run the checks on the final tree, then `done` or + `escalated`. Commit with a conventional message scoped to the change + (`feat(core): …`, `feat(sync): …`, `feat(web): …`) and push. +8. Update the PR description checklist (one checkbox per WP). +9. If you cannot finish, write the typed handoff in the WP file, set the + row to `waiting` or `escalated`, push, and stop. Do not silently mark + `done`. +10. On `escalated`: post one PR comment with the escalation packet + (decision required, recommended option, alternatives tried, cost of + waiting, safest default) and disable the manager Routine so it stops + burning sessions until a human re-enables it. +11. When every WP is `done` and the finish line below holds: post one + closing PR comment requesting review, then disable the Routine. + +## Order + +| WP | File | Depends on | Lane | +| --- | --- | --- | --- | +| 01 | [WP-01-core-write-contracts.md](WP-01-core-write-contracts.md) | — | — | +| 02 | [WP-02-sync-attendee-writes.md](WP-02-sync-attendee-writes.md) | 01 | A (sync) | +| 03 | [WP-03-backend-write-path.md](WP-03-backend-write-path.md) | 01 | B (backend), may run parallel with 02 | +| 04 | [WP-04-web-attendee-editor.md](WP-04-web-attendee-editor.md) | 02, 03 | B — launch gate for editing | +| 05 | [WP-05-contacts-scope-and-suggestions.md](WP-05-contacts-scope-and-suggestions.md) | — | C, parallel with 01–04 | +| 06 | [WP-06-contacts-surface.md](WP-06-contacts-surface.md) | 04, 05 | C | +| 07 | [WP-07-rsvp-sync.md](WP-07-rsvp-sync.md) | 01, 02 | A | +| 08 | [WP-08-rsvp-surface.md](WP-08-rsvp-surface.md) | 03, 07 | B | +| 09 | [WP-09-e2e-docs-closeout.md](WP-09-e2e-docs-closeout.md) | 04, 06, 08 | — | + +Lanes touch disjoint packages, so WPs in different lanes may run in +parallel **only** when different owners hold them; a solo manager session +works strictly in table order, skipping WPs whose dependencies are not +`done`. Everything is sequential unless `TRACKING.md` says otherwise. + +## Pack finish line + +A Compass user on a writable Google calendar can add and remove attendees +from the event form (with Google-contact suggestions once they grant the +optional contacts scope), choose at save time whether Google emails +invitations, see other attendees' RSVP changes arrive live, and RSVP +(accepted / declined / tentative) to events they are invited to — per +occurrence or for the whole series. All package suites, `type-check`, +`lint`, `knip`, and the e2e suite are green, and no attendee or contact +content appears in sync logs, SSE payloads, or error causes +(safety-canary suite green). + +## Dark launch + +There is no feature-flag system. The launch mechanism is layering: +contracts (WP-01) and provider plumbing (WP-02/03/05/07) are inert until +a UI WP (04, 06, 08) starts sending data. Every WP must land +independently green and shippable. + +## Out of scope for the whole pack + +- Compass-sent email of any kind (Google sends invitation emails via + `sendUpdates`) +- `guestsCanModify` (non-organizer guest-list editing) — documented + follow-up +- Conference/Meet creation or editing +- Attendee support for non-Google (local/anonymous) calendars beyond a + typed rejection +- Etag/If-Match conditional-retry loops (fetch→patch race is a named + wart, see WP-09 docs) +- Free/busy attendee availability lookup + +## Capability budget (standing) + +| Action | Default | +| --- | --- | +| Read, draft, tests, commits to the integration branch, ledger writes | Allow | +| New sync/backend routes, optional-scope consent additions per WP-05 | Allow (pre-approved 2026-08-25) | +| Touching `GOOGLE_AUTH_SCOPES_REQUIRED` or any required scope list | Human | +| Squash-merge to `main`, production deploy, secret changes | Human | + +The contacts scopes (`contacts.readonly`, `contacts.other.readonly`) are +Google *sensitive* scopes: adding them to the consent screen was approved +by the product owner, but production OAuth re-verification with Google is +a human-side external action and never blocks a WP. + +## Deletion criteria + +Delete `wip/attendee-support/` when all of the following are true: + +1. WP-01 through WP-09 are `done` with evidence another agent can replay. +2. The integration PR is merged. +3. Durable documentation lives in `docs/features/attendees.md` (WP-09) — + nothing in this directory is still the source of truth. +4. A final commit removes the directory and mentions the replacement + paths. diff --git a/wip/attendee-support/TRACKING.md b/wip/attendee-support/TRACKING.md new file mode 100644 index 0000000000..47dd3ebcef --- /dev/null +++ b/wip/attendee-support/TRACKING.md @@ -0,0 +1,33 @@ +# Attendee-support ledger + +Manager-owned. Update at every handoff. Do not append narrative; change +the row. Conversations are not the source of truth. + +Status: `queued` | `running` | `waiting` | `verifying` | `done` | +`escalated` + +When taking a WP, put `started_at: ` at the front of +the evidence cell; replace it with real evidence when finishing. The +3-hour concurrency guard in [`README.md`](README.md) reads that +timestamp. + +## In-flight work + +| task_id | priority | owner | status | artifact | evidence | next_deadline | retry | approval | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| PACK-WRITE | high | planning-session | done | `wip/attendee-support/` | this directory exists; WPs have finish lines and session prompts; plan approved by product owner 2026-08-25 | — | 0 | user, 2026-08-25 | +| WP-01 | high | manager-loop | done | [WP-01-core-write-contracts.md](WP-01-core-write-contracts.md) | 2026-08-25: test:core 605 pass; type-check/lint/knip exit 0 (re-run on final tree); test:web 2331 pass; test:sync:fast 361 pass; backend:fast 20 failures identical to baseline 65452e3 (pre-existing env, unrelated). Full evidence in WP file | — | 0 | none | +| WP-02 | high | manager-loop | done | [WP-02-sync-attendee-writes.md](WP-02-sync-attendee-writes.md) | 2026-08-26: test:sync 1017 pass 0 fail (78 files, safety-canary green, re-run on final tree); type-check/lint/knip exit 0 (lint: 0 errors, 10 pre-existing warnings). mergeAttendees merges by email vs freshly fetched provider state; replace threaded through single update, series edit-all, and create; organizer guard fails typed pre-fetch; matchesIntendedEdit compares email sets on replace only; preserve byte-identical (regression-tested). Full evidence + deltas in WP file | — | 0 | none | +| WP-03 | high | manager-loop | done | [WP-03-backend-write-path.md](WP-03-backend-write-path.md) | 2026-08-26: test:backend 390 pass 1 skip 8 fail — identical 8 (config x3, UserController x5) fail on base with work stashed (pre-existing env; +16 new passing tests, re-run on final tree); test:core 605 pass; type-check/lint/knip exit 0 (lint: 0 errors, 10 pre-existing warnings). Legacy keys pinned pre-change and byte-stable post-change (update:0b7c2048…, delete:b65cb278…); legacy replace submit request byte-identical (full-literal snapshot); attendees→needsAction + attendeesEdit replace; invitation threaded at create/update/delete (delete via query param); ATTENDEES_UNSUPPORTED 403 gate before any submit; optimistic response events carry intended attendees. Full evidence + deltas in WP file | — | 0 | none | +| WP-04 | high | manager-loop | done | [WP-04-web-attendee-editor.md](WP-04-web-attendee-editor.md) | 2026-08-26: test:web 2362 pass 0 fail (312 files, +30 new tests); type-check/lint/knip exit 0 (lint: 0 errors, 10 pre-existing warnings). test:a11y: default 30s timeout fails 6/7 with axe frame.evaluate timeouts IDENTICALLY on base (container env); all 7 pass at --timeout=180000 on the final tree. AttendeeField chips gate on writable-Google + organizer + non-occurrence; guest-changed saves prompt Send/Don't send (all/none); scope dialog narrows recurring guest edits to All Events; optimistic needsAction merge + rollback; wire boundary passes genuine guest edits and strips replays (MSW-proven). Full evidence + recurring-UX choice in WP file | — | 0 | none | +| WP-05 | medium | manager-loop | done | [WP-05-contacts-scope-and-suggestions.md](WP-05-contacts-scope-and-suggestions.md) | 2026-08-26: test:sync 1052 pass 0 fail (81 files, safety-canary green incl. new People patterns, re-run on final tree); test:core 615 pass; type-check/lint/knip exit 0 (lint: 0 errors, 10 pre-existing warnings); test:web 2364 pass; backend:fast 20 failures identical to WP-01 baseline (pre-existing env). Required lists untouched and literal-pinned (web GOOGLE_AUTH_SCOPES_REQUIRED, backend GOOGLE_AUTH_SCOPES, sync GOOGLE_SCOPES, e2e REQUIRED_SCOPES); begin features:["contacts"] adds both scopes, plain begin byte-identical; suggestContacts from either granted contacts scope; ContactsPort + google-people adapter (scope-gated surfaces, merge+rank); GET /internal/contacts/suggestions returns {email,displayName}[] only, 403 typed without grant, empty 200 under 2 chars; explicit sign-in-succeeds-without-contacts tests (backend+web). Full evidence + deltas in WP file | — | 0 | user, 2026-08-25 (optional sensitive scopes) | +| WP-06 | medium | manager-loop | done | [WP-06-contacts-surface.md](WP-06-contacts-surface.md) | 2026-08-26: test:web 2388 pass 0 fail (316 files, +24 new tests); test:core 618 pass; test:backend 406 pass 1 skip 8 fail — identical pre-existing WP-03 baseline (config x3, UserController x5; IPv4 shim env, +16 new passing); type-check/lint/knip exit 0 (lint: 0 errors, 10 pre-existing warnings). e2e oauth --timeout=180000: both NEW contacts tests pass (granted → capability true; denied → sign-in completes, connection HEALTHY, capability false, no insufficientScopes); pre-existing spinner test fails IDENTICALLY on base (env timing). Required scope lists untouched (diff-empty on all four). Proxy degrades every sync failure to typed empty 200; canSuggestContacts sync capability → summary → metadata → web; 250ms-debounced ranked suggestions via TanStack + command-palette scorer; nudge frequency rule pinned in contact-nudge.gate.test.ts. Full evidence + deltas in WP file | — | 0 | none | +| WP-07 | high | manager-loop | done | [WP-07-rsvp-sync.md](WP-07-rsvp-sync.md) | 2026-08-26: test:sync 1072 pass 0 fail (81 files, safety-canary green incl. new rsvp canary assertions, re-run on final tree); type-check/lint/knip exit 0 (lint: 0 errors, 10 pre-existing warnings). executeProviderRsvp rewrites only the self entry (case-insensitive account-email match) against freshly fetched state, patches the full list with sendUpdates none, unconditional (RSVP drift never blocks); scope "this" patches the fetchInstanceAt-resolved Google instance (never hand-built, master byte-untouched — proven), scope "all" the master; replay confirms without a write; typed unsupportedCapability guards (stored+fetched self-match, unverifiable connection, cloud-only, thisAndFollowing); organizer self-RSVP allowed; stored record/exception updated on confirm + invalidation outbox rows (SSE eventsChanged) pinned end-to-end through the route; needsAction 400 at route; rsvp in stale-sweep RETRYABLE_KINDS. Full evidence + deltas in WP file | — | 0 | none | +| WP-08 | high | manager-loop | done | [WP-08-rsvp-surface.md](WP-08-rsvp-surface.md) | 2026-08-26 (takeover: prior worker lost to a usage limit mid-implementation; its uncommitted tree was reviewed, kept, and finished — formatter-only fixes): test:web 2407 pass 0 fail (319 files, +19 new tests); test:backend 415 pass 1 skip 8 fail — identical pre-existing WP-03/06 baseline (config x3, UserController x5; IPv4 shim env, +9 new passing); test:core 618 pass; a11y 7/7 at --timeout=180000 (RsvpControl not axe-mountable in anonymous local mode — RTL-pinned semantics, see WP file); type-check/lint/knip exit 0 (lint: 0 errors, 10 pre-existing warnings). POST /api/event/:id/rsvp (204, no writable-calendar gate, needsAction 400, idempotency key event+status+scope); Going/Maybe/Decline radiogroup gated on case-insensitive accountEmail self-match; occurrence → This Event/All Events dialog (never this-and-following), single events skip it, series base answers "all"; optimistic self-only rewrite + 503 rollback, settles via the SSE-backed invalidation. Full evidence + deltas in WP file | — | 0 | none | +| WP-09 | medium | manager-loop | done | [WP-09-e2e-docs-closeout.md](WP-09-e2e-docs-closeout.md) | 2026-08-26 (2nd takeover; prior worker's partial e2e/attendees/ tree reviewed critically by running it — 2 real harness bugs found and fixed (auth-gate-vs-calendars-query, metadata-clobber), OverlayPanel click-race worked around with dispatchClick, rsvp-control.spec.ts discarded as a stale/incompatible-API duplicate of rsvp.spec.ts, debug.spec.ts deleted): e2e/attendees 8 pass 0 fail (4 files, 4 consecutive clean runs); e2e/oauth --timeout=180000 2 pass 1 pre-existing fail (identical WP-06/08 spinner-timing); e2e/accessibility --timeout=180000 7 pass 0 fail; test:core 618 pass; test:web 2407 pass; test:sync 1072 pass (safety-canary 19 pass, IPv4 shim env, re-run on final tree); test:backend 415 pass 1 skip 8 fail — identical WP-03/06/08 baseline; type-check/lint/knip exit 0 (lint: 0 errors, 10 pre-existing warnings). docs/features/attendees.md written (flow diagram, invitation-intent semantics, merge-by-email+replay rules, contacts consent flow, RSVP semantics, 3 named warts, e2e coverage boundary — every claim cites a passing test); README "can't do yet" line corrected (attendees moved to can-do, reminders/meeting-links honestly still-missing); docs/README.md + feature-file-map.md updated. Ledger audit: WP-01..08 all `done` with non-empty replayable Evidence in both TRACKING.md and their own WP files. Full evidence in WP file | — | 0 | none | + +## Escalation log + +| date | task_id | decision required | recommended option | alternatives tried | cost of waiting | safest default | +| --- | --- | --- | --- | --- | --- | --- | +| | | | | | | | diff --git a/wip/attendee-support/WP-01-core-write-contracts.md b/wip/attendee-support/WP-01-core-write-contracts.md new file mode 100644 index 0000000000..628a94ff35 --- /dev/null +++ b/wip/attendee-support/WP-01-core-write-contracts.md @@ -0,0 +1,194 @@ +# WP-01 — Core write contracts + +**task_id:** WP-01 +**status:** done +**owner:** Implementer (core) +**depends on:** none +**next owner after done:** WP-02 (sync) and WP-03 (backend) may start, in +parallel with different owners + +## Why + +Every write layer needs the same vocabulary before any of them can move: +an attendee *input* shape (no responseStatus — callers don't set other +people's RSVPs), an explicit "am I editing the guest list" signal, an +invitation intent on the browser API, and an RSVP command. Contracts live +in `packages/core` (shared Zod, see AGENTS.md), so this WP is pure +contracts + tests with **zero runtime behavior change**. + +Key files: + +- [`packages/core/src/types/event-attendance.contracts.ts`](../../packages/core/src/types/event-attendance.contracts.ts) +- [`packages/core/src/types/event-command.contracts.ts`](../../packages/core/src/types/event-command.contracts.ts) +- [`packages/core/src/types/sync/command.contracts.ts`](../../packages/core/src/types/sync/command.contracts.ts) + +## Finish line + +1. `AttendeeInputSchema` exists in `event-attendance.contracts.ts`: + strictObject {email (trimmed, 1–320), displayName (trimmed 1–256, + nullable)}; rejects any `responseStatus` key; an array refine rejects + duplicate emails (case-insensitive). +2. `EditableContentSchema` gains optional + `attendees: z.array(AttendeeInputSchema)`; omitted parses as before. + Create/Replace event inputs gain optional + `invitation: InvitationIntentSchema`; delete input gains it too + (cancellation emails). +3. Sync create/update command inputs gain + `attendeesEdit: z.enum(["replace", "preserve"]).default("preserve")`. + Stored `SyncEventContentSchema` is unchanged. +4. A new `rsvp` member exists on `SyncCommandInputSchema`: + {kind: "rsvp", responseStatus: enum(accepted|declined|tentative) — + `needsAction` rejected, plus the same target fields update commands + use so occurrence ids (`eventId::recurrenceId`) work}. A matching + browser `RsvpEventInputSchema` exists in + `event-command.contracts.ts` with + `scope: z.enum(["single", "all"])` for recurring targets. +5. Every pre-existing contract test passes unmodified: payloads without + `attendees` / `invitation` / `attendeesEdit` parse identically + (defaults apply). +6. `bun test:core`, `bun run type-check`, `bun lint`, `bun knip` green. + +## Steps + +1. Read the three key files plus their colocated `*.test.ts` and + `packages/core/src/types/sync/event.contracts.ts` (note + `SyncEventContentSchema.attendees` is already required there). +2. Add `AttendeeInputSchema` (+ exported TS type) next to + `AttendeeSchema`. Add a `uniqueAttendeeEmails` refine helper for + arrays of it. +3. Widen `EditableContentSchema` and the create/replace/delete inputs in + `event-command.contracts.ts`. These are strictObjects — additive + optional fields only. +4. Add `attendeesEdit` to the sync create/update command inputs in + `sync/command.contracts.ts`; add the `rsvp` union member. Check the + union's discriminator and any recurrence-coherence refines — the rsvp + member must satisfy or be exempted from them explicitly. +5. Add `RsvpEventInputSchema` to `event-command.contracts.ts`. +6. Tests (colocated): new-shape acceptance, `responseStatus` rejection + on input attendees, duplicate-email rejection, legacy-payload + round-trips (fixture JSON without the new fields → parsed output + identical to before), rsvp `needsAction` rejection. +7. Run the finish-line checks. + +## Acceptance tests + +- **Normal:** a replace input with two attendees and + `invitation: "all"` parses; parsed `attendeesEdit` on a sync update + command carrying it round-trips. +- **Incomplete input:** attendee with empty email rejected; duplicate + emails (case-insensitive) rejected; rsvp with + `responseStatus: "needsAction"` rejected. +- **Tool failure:** n/a (contracts only). +- **Policy:** a legacy sync update command JSON without `attendeesEdit` + parses with `"preserve"`; a legacy browser create without `attendees` + or `invitation` parses identically to today (snapshot equality). + +## Evidence + +Recorded 2026-08-25 (implementer: manager-loop session): + +```text +commands run: bun test:core; bun run type-check; bun lint (after bun + lint:fix for formatter/import-order); bun knip; regression: bun + test:backend:fast, bun test:web, bun test:sync:fast +test:core result: 605 pass, 0 fail (36 files) — includes new + event-attendance.contracts.test.ts and extended event-command / + sync command contract tests +type-check / lint / knip result: all exit 0. lint: 0 errors, 10 + pre-existing warnings (untouched files). knip: no findings (one + pre-existing .css configuration hint only) +regression: test:web 2331 pass 0 fail; test:sync:fast 361 pass 0 fail; + test:backend:fast 282 pass / 20 fail — the same 20 failures (SSE + Server 11, supertokens.middleware.util 6, GET /api/config 3) fail + identically on the base commit 65452e3 with the work tree stashed: + sandbox-environment issues, unrelated to this WP +legacy-payload snapshot proof: + - event-command.contracts.test.ts: "parses a legacy payload without + attendees or invitation to an identical output" (create + replace, + toStrictEqual against the input) and delete "parses a legacy payload + without an invitation to an identical output" + - sync/command.contracts.test.ts: "defaults a legacy %s input without + attendeesEdit to preserve" (create/update) and "parses a legacy + update command JSON without attendeesEdit with preserve" (JSON + fixture through SyncCommandSchema); pre-existing round-trip test + unchanged and green +deltas from spec (if any): + - EditableContentSchema.attendees is z.array(AttendeeInputSchema) + .readonly().refine(...) — readonly mirrors the read-side lists and + keeps a replayed Event["content"] structurally assignable to the + write input (undo/redo funnels full read content through it). + - The browser inputs use a new undefaulted InvitationIntentValueSchema + (exported from event-command.contracts.ts); sync's + InvitationIntentSchema now derives from it via .default("none") + (identical semantics, no import cycle, and legacy browser payloads + stay snapshot-identical because no default is injected). + - RsvpResponseStatusSchema lives in event-attendance.contracts.ts + (AttendeeResponseStatusSchema.exclude(["needsAction"])) so the + browser input and the sync rsvp command share one enum. + - The sync rsvp member reuses update's exact target fields + (scope: RecurrenceScopeSchema, recurrenceId nullable default null) + and is explicitly included in the recurrence-coherence refine on + SyncCommandSchema / CommandSubmitRequestSchema. + - Type-level companion edits in web keep type-check green with zero + runtime behavior change: grid-event-draft.adapter.ts widens + detailsLocation/editableContent to accept write-input content; + useEventMutations.ts filters replayed read-state attendee entries + (responseStatus present) when building optimistic Event content — + replay flows behave byte-identically, a pure guest-edit input (none + exist yet) contributes nothing optimistic; local.event.repository.ts + drops the write-only attendees key (local calendars have no attendee + support and the wire boundary already strips it at runtime); three + web test stubs mirror the same key drop. +``` + +## Out of scope + +- Any backend/sync/web behavior change (they still ignore the fields) +- `guestsCanModify`, conference editing +- Contact contracts (WP-05 owns `contact.contracts.ts`) + +## Risks + +- `EditableContentSchema` is embedded in create/replace and re-used by + web fixtures and backend tests — a strictObject mistake breaks many + suites at once. Run `bun test:backend:fast` and `bun test:web` locally + if in doubt; they must stay green even though this WP only claims + core. +- The sync command union has cross-field refines; adding a member + carelessly can change error messages other tests assert on. + +## Handoff + +Fill when stopping mid-WP: + +```yaml +task_id: WP-01 +from: +to: Implementer (core) +status: +artifact: +evidence: +assumptions: +open_risks: +next_deadline: +``` + +## Session prompt + +```text +You are implementing WP-01 from +wip/attendee-support/WP-01-core-write-contracts.md in the Compass repo, +on branch claude/attendee-support-planning-nljgeg. Read +wip/attendee-support/README.md and TRACKING.md first, mark WP-01 running +(owner + started_at), push the ledger update, and do not start other +WPs. + +Finish line: AttendeeInputSchema (no responseStatus, unique emails); +optional attendees on EditableContentSchema; optional invitation on +create/replace/delete inputs; attendeesEdit enum(replace|preserve) +default preserve on sync create/update commands; rsvp command member + +RsvpEventInputSchema (accepted|declined|tentative only, occurrence +targeting); all legacy payloads parse unchanged. bun test:core, +type-check, lint, knip green. Zero runtime behavior change. Fill +Evidence, update TRACKING.md, commit conventionally, push. +``` diff --git a/wip/attendee-support/WP-02-sync-attendee-writes.md b/wip/attendee-support/WP-02-sync-attendee-writes.md new file mode 100644 index 0000000000..47a745fc53 --- /dev/null +++ b/wip/attendee-support/WP-02-sync-attendee-writes.md @@ -0,0 +1,213 @@ +# WP-02 — Sync provider write path: attendee replace + +**task_id:** WP-02 +**status:** done +**owner:** Implementer (sync) +**depends on:** WP-01 +**next owner after done:** WP-04 unblocks (with WP-03); WP-07 unblocks + +## Why + +Sync deliberately never writes attendees today. Four spots enforce it: +`merge-update-content.ts` merges only title/description/location/color; +`toGoogleBody` omits attendees; `matchesIntendedEdit` ignores them in +replay comparison; cloud records never receive them from commands. This +WP makes `attendeesEdit: "replace"` real while keeping +`"preserve"` byte-identical to today's behavior. + +Key files: + +- [`packages/sync/src/domain/merge-update-content.ts`](../../packages/sync/src/domain/merge-update-content.ts) +- [`packages/sync/src/domain/provider-command.service.ts`](../../packages/sync/src/domain/provider-command.service.ts) + (`executeProviderUpdate` ~line 302, `matchesIntendedEdit` ~line 1389) +- [`packages/sync/src/domain/cloud-command.service.ts`](../../packages/sync/src/domain/cloud-command.service.ts) +- [`packages/sync/src/providers/google/google-event-writer.adapter.ts`](../../packages/sync/src/providers/google/google-event-writer.adapter.ts) + (`toGoogleBody` ~line 300, `toSendUpdates` ~line 495) +- [`packages/sync/src/safety/safety-canary.ts`](../../packages/sync/src/safety/safety-canary.ts) + (do not touch; keep green) + +## Finish line + +1. An update command with `attendeesEdit: "replace"` produces a Google + patch whose `attendees` are the merge of the intended email set + against the **freshly fetched** provider state: retained emails keep + the provider's current responseStatus/displayName, new emails enter + as `needsAction`, dropped emails are removed. The fake + `GoogleEventsApi` asserts the exact body including `sendUpdates`. +2. Create commands with intended attendees emit them (organizer not + synthesized; Google adds it). +3. Every `attendeesEdit: "preserve"` (and legacy) command produces a + patch body byte-identical to today — regression tests prove it. +4. Replay safety: an attendee-only edit retried after landing confirms + without a second write (`matchesIntendedEdit` compares email sets, + order-insensitive, status-ignored, only when the command intent is + `"replace"`); pure RSVP drift still never blocks replay. +5. A replace on an event the connection's account does not organize + fails typed `unsupportedCapability` with no provider call. +6. Confirmed commands store the merged attendees on the sync record so + reads reflect them before the next Google round-trip. +7. `bun test:sync` (incl. safety-canary suite) green; `type-check`, + `lint`, `knip` green. + +## Steps + +1. Read the key files and their tests; read + `00-context-and-invariants.md` merge rules. +2. Add `mergeAttendees(intended, providerCurrent)` (pure, exhaustively + table-tested) beside `mergeUpdateContent`. Case-insensitive email + match; preserve provider order for retained entries, append new ones. +3. Thread `attendeesEdit` through `executeProviderUpdate` and the create + path. For attendees, merge against `current.content` (the fetched + state), NOT sync's stored `event.content` — a concurrent RSVP between + syncs must survive. +4. Organizer guard: if the stored/fetched organizer email ≠ connection + account email (case-insensitive), fail `unsupportedCapability` + before any provider write. +5. `toGoogleBody` gains attendees only when the write intends them + (keep the existing "deliberately NOT written" comment accurate — + rewrite it to describe the new rule). +6. Extend `matchesIntendedEdit` per finish line 4. +7. Update `cloud-command.service.ts` so cloud-only records store the + intended attendees on replace. +8. Tests: fake `GoogleEventsApi` body assertions, merge table tests, + preserve-regression snapshots, replay tests, organizer-guard test, + canary assertion that a failed attendee command logs no attendee + JSON. +9. Run the finish-line checks. + +## Acceptance tests + +- **Normal:** add one attendee to an event with three existing → patch + body has four entries; existing statuses preserved even when one + changed provider-side since last sync. +- **Incomplete input:** replace with `[]` → patch body `attendees: []` + (explicit remove-everyone). +- **Tool failure:** provider fetch fails transiently → command stays + pending/retryable, no patch sent. +- **Policy:** non-organizer replace → `unsupportedCapability`, no + provider call; log/SSE output of a failed attendee command contains no + attendee JSON (safety-canary). + +## Evidence + +Recorded 2026-08-26 (implementer: manager-loop takeover session; prior worker +lost to a spend limit with no code landed): + +```text +commands run: bun test:sync (full, in-memory Mongo harness); bun run + type-check; bun lint (after bun lint:fix for formatting of new test + fixtures); bun knip +test:sync result: 1017 pass, 0 fail (78 files) — includes new/extended suites: + merge-update-content.test.ts (mergeAttendees table, 9 cases + purity), + google-event-writer.adapter.test.ts (exact insert/patch bodies with and + without attendees, sendUpdates asserted), provider-command.service.db.test.ts + ("attendeesEdit replace": merge-against-fetched, empty-set replace, organizer + guard, fail-closed on unresolvable connection, replay by email set, patch on + membership drift, preserve byte-identical, transient-fetch pending, series + edit-all, scope-this / thisAndFollowing typed refusals, create needsAction + normalization, legacy create), cloud-command.service.db.test.ts ("cloud-only + attendeesEdit replace": stored-list merge, create normalization, occurrence/ + split refusals) +safety-canary tests pass: yes — safety-canary.ts untouched; full suite green + within test:sync and packages/sync/src/safety/ re-run standalone under the + harness (18 pass, 0 fail). New canary assertions: a failed non-organizer + replace's outcome and the command route's log-line template contain no + attendee JSON (findSafetyCanaryHit null), both provider-side and cloud-side. +preserve-regression proof: + - adapter: "omits the attendees key when the write does not intend a guest + edit" asserts the FULL insert body toEqual the pre-WP shape (no attendees + key) even when content.attendees is populated, and the patch body has no + attendees property. + - executor: "keeps a preserve command byte-identical" asserts no attendees + key on the patch input, content.attendees passed through as the stored + (mergeUpdateContent) list, and the stored record's list untouched even + when the command echoes stray attendees. + - every pre-existing update/create/delete test (none set attendeesEdit; + schema defaults to "preserve") passes unmodified. +type-check / lint / knip result: all exit 0. lint: 0 errors, 10 pre-existing + warnings (untouched files). knip: no findings (pre-existing .css + configuration hint only). +deltas from spec (if any): + - Organizer guard needs the connection's account email, which no executor + dep carried: ProviderMutationDeps/CloudCommandDeps gain a narrow + `connections: ProviderConnectionLookup` (findById -> {account:{email}}), + wired from syncRepositories in command.routes.ts and app.ts's + stale-command sweep. Guard compares the STORED organizer (case-insensitive) + BEFORE any provider call, fetch included; a null stored organizer passes + (Compass-created event, the account organizes it); unverifiable states + (missing connection row / no account email) fail closed with the same + typed unsupportedCapability. + - The intended membership rides the writer port as an optional + `attendees` field on ProviderWriteBody (absent = merge-by-key leaves + Google's list; present incl. [] = replace), NOT via content.attendees — + content stays read-reflected and never reaches the body. + - Beyond the spec's named executeProviderUpdate + create, + executeProviderSeriesUpdate (scope "all") also supports replace (same + fetch-merge-patch shape; the override-align patches carry the replaced + membership so reverted overrides don't keep a stale guest list). Replace + on scope "this"/"thisAndFollowing" (provider AND cloud) fails typed + unsupportedCapability instead of silently preserving — per-occurrence + guest lists have no v1 semantics, and dropped intent must not read as + success. + - Creates merge against an empty provider list, so every intended guest is + normalized to needsAction regardless of the command's own responseStatus + values; the merged list is stored on the record (finish line 6) for + provider creates, cloud creates, and cloud updates (cloud updates merge + against the STORED list — no provider copy exists). + - Environment note: this container's kernel has IPv6 disabled and Bun's + host-less listen() binds "::", so mongodb-memory-server could not boot. + Validation ran with a TEMPORARY, uncommitted bunfig.toml preload shim + forcing IPv4 binds (scratchpad-only; reverted before commit). Bun 1.3.11 + vs pinned 1.3.14 (harness warns; behavior identical here). +``` + +## Out of scope + +- RSVP command execution (WP-07) +- Backend translation (WP-03) +- If-Match/etag retry loops (named wart) +- `guestsCanModify` + +## Risks + +- Google patch replaces the whole attendees array — a merge bug silently + uninvites people. The merge function must be pure and table-tested + before wiring it in. +- `provider-command.service.ts` is ~1600 lines; keep the diff surgical + and lean on existing helpers. +- Do not compare create bodies to post-create readback (Google adds the + organizer as accepted). + +## Handoff + +```yaml +task_id: WP-02 +from: +to: Implementer (sync) +status: +artifact: +evidence: +assumptions: +open_risks: +next_deadline: +``` + +## Session prompt + +```text +You are implementing WP-02 from +wip/attendee-support/WP-02-sync-attendee-writes.md in the Compass repo, +on branch claude/attendee-support-planning-nljgeg. Read +wip/attendee-support/README.md, TRACKING.md, and +00-context-and-invariants.md first, mark WP-02 running (owner + +started_at), push the ledger update, and do not start other WPs. WP-01 +must be done. + +Finish line: attendeesEdit "replace" merges by email against freshly +fetched provider state and patches Google (fake API asserts exact +bodies); "preserve" byte-identical to today; matchesIntendedEdit +compares email sets on replace only; organizer guard +unsupportedCapability; merged attendees stored on confirm; test:sync + +safety-canary + type-check + lint + knip green. Fill Evidence, update +TRACKING.md, commit conventionally, push. +``` diff --git a/wip/attendee-support/WP-03-backend-write-path.md b/wip/attendee-support/WP-03-backend-write-path.md new file mode 100644 index 0000000000..ca37bc4bb5 --- /dev/null +++ b/wip/attendee-support/WP-03-backend-write-path.md @@ -0,0 +1,185 @@ +# WP-03 — Backend write path: stop zeroing, thread invitation + +**task_id:** WP-03 +**status:** done +**owner:** Implementer (backend) +**depends on:** WP-01 (may run parallel with WP-02 — different packages) +**next owner after done:** WP-04 unblocks (with WP-02); WP-08 partially + +## Why + +The backend translator is where attendee intent dies today: +`toSyncContent` pads `attendees: []` and three call sites hardcode +`invitation: "none"`. This WP maps the new browser fields (WP-01) onto +the sync command inputs and gates who may send them, without any UI — +the API accepts the fields, but nothing sends them yet (dark launch). + +Key files: + +- [`packages/backend/src/common/services/sync-service/event-command.translation.ts`](../../packages/backend/src/common/services/sync-service/event-command.translation.ts) + (`toSyncContent`; `invitation: "none"` at ~lines 145, 190, 253) +- [`packages/backend/src/event/controllers/event.controller.ts`](../../packages/backend/src/event/controllers/event.controller.ts) +- [`packages/backend/src/common/services/sync-service/event-list.translation.ts`](../../packages/backend/src/common/services/sync-service/event-list.translation.ts) + (read path — verify only, it already flows attendees) + +## Finish line + +1. A POST/PUT whose content carries `attendees` plus + `invitation: "all"` produces a `CommandSubmitRequest` with the + attendees mapped (placeholder `responseStatus: "needsAction"`), + `attendeesEdit: "replace"`, and `invitation: "all"` — asserted + against `CommandSubmitRequestSchema`. +2. Requests omitting both fields produce submit requests byte-identical + to today (snapshot regression), including unchanged idempotency + keys for identical legacy payloads. +3. `invitation` on DELETE flows through (guest cancellation emails). +4. Attendees on a non-Google or read-only calendar → typed 4xx (new + error code, e.g. `ATTENDEES_UNSUPPORTED`), no sync submission. +5. `bun test:backend` green; `type-check`, `lint`, `knip` green. + +## Steps + +1. Read the key files and colocated tests (`.test.ts` and + `.db.test.ts`). +2. `toSyncContent`: accept optional attendees; present → map with + `needsAction` placeholder + `attendeesEdit: "replace"`; absent → + today's `[]` pad + `"preserve"`. +3. Replace the three hardcoded `invitation: "none"` with the input's + value, defaulting to `"none"` when absent. +4. Verify how the update idempotency key hashes the payload: it must + hash the content as received so legacy keys stay stable; add a test + proving a legacy payload's key is unchanged. +5. Controller gating: attendees present → calendar must be + Google-linked and writable, else the typed 4xx. Reuse the existing + calendar lookup the controller already performs. +6. Synthesized optimistic response events must carry the intended + attendees so the browser cache stays coherent. +7. Spot-check `event-list.translation.ts` needs no change. +8. Run the finish-line checks. + +## Acceptance tests + +- **Normal:** replace with attendees + invitation round-trips into a + schema-valid submit request. +- **Incomplete input:** attendee email failing the schema → + `INVALID_INPUT` 400, not a 500. +- **Tool failure:** sync 503 during submit → existing retryable + `SYNC_UNAVAILABLE` behavior unchanged with the new fields present. +- **Policy:** `invitation` accepted on delete; attendees on a local + calendar rejected with the new code and no sync call. + +## Evidence + +Recorded 2026-08-26 (implementer: manager-loop session): + +```text +commands run: bun test:backend (full, via test-mongo-env with the + scratchpad-only IPv4 listen shim — see environment note); bun test:core + (regression for the new core error code); bun run type-check; bun lint + (after bun lint:fix for formatting only); bun knip. All re-run on the + final tree. +test:backend result: 390 pass, 1 skip, 8 fail (50 files). The 8 failures + (GET /api/config x3, UserController x5) are pre-existing + sandbox-environment issues: with this WP's work stashed, the base tree + fails the IDENTICAL 8 tests (374 pass, 8 fail) under the same command — + this WP adds 16 passing tests and no failures. test:core: 605 pass 0 + fail (36 files). +legacy idempotency-key proof: + - Before changing any code, the pre-WP translator was executed against + fixed legacy payloads and its keys captured: + update:0b7c2048556d01da12ae81970f090b767bc6a6bc (replace) and + delete:b65cb27825e51d116acdcb198b1f11786dd971c4 (delete). Both + literals are pinned in event-command.translation.test.ts ("keeps the + legacy update idempotency key stable across the attendee rollout", + "threads invitation through a delete without changing its identity + key") and pass against the post-WP translator — legacy keys are + byte-stable. The update hash covers the browser content AS RECEIVED, + so absent attendees/invitation serialize exactly as pre-WP; + invitation stays outside the hash (like restore — per-submission + delivery intent, and the UI only offers it when the guest set + changed, which changes content and therefore the key anyway); a + guest-list edit rides inside content and mints a distinct key + (asserted). Full legacy submit-request byte-identity is pinned by + "builds a byte-identical submit request for a legacy replace + payload" (toEqual against the complete request literal, including + invitation "none" + attendeesEdit "preserve" + [] attendee pad). +type-check / lint / knip result: all exit 0. lint: 0 errors, 10 + pre-existing warnings (untouched files). knip: no findings + (pre-existing .css configuration hint only). +deltas from spec (if any): + - ATTENDEES_UNSUPPORTED is 403 (FORBIDDEN, retryable false), joining + the capability-refusal family (CALENDAR_READ_ONLY, + UNSUPPORTED_OPERATION); code added to core's + EventMutationErrorCodeSchema since that is where the shared error + vocabulary lives (backend maps status/retryable in event.error.ts). + - Replace carries no calendarId (cross-calendar moves are rejected + pre-submit as MOVE_UNSUPPORTED) and the sync client has no + event-by-id lookup, so the replace gate degrades to "the principal + has at least one writable Google calendar" from the same + listCalendars(activeOnly) lookup the read path uses; create gates + exactly on the target calendarId. Per-event backstops: the web only + renders the editor on the event's own writable Google calendar + (WP-04) and sync's organizer guard (WP-02) refuses per-event misuse. + The local calendar is never in sync's calendar list, so local-only + accounts fail the gate by construction. + - DELETE has no body, so the cancellation-email choice rides the query + string (?invitation=all|externalOnly|none), validated through + DeleteEventInputSchema; invalid values are 400 INVALID_INPUT with no + sync call. + - Synthesized optimistic response events map intended attendees to the + read shape with responseStatus "needsAction" (toResponseContent) — + required, not just nice: the write-input attendee shape has no + responseStatus and would fail EventSchema.parse otherwise. + - event-list.translation.ts spot-checked: already flows + organizer/attendees/conference through toBrowserDetails — no change. + - Environment note (same as WP-02): this container's kernel has IPv6 + disabled and Bun's host-less listen() binds "::", so + mongodb-memory-server needs a TEMPORARY scratchpad-only bun + --preload shim forcing IPv4 binds (never committed). Bun 1.3.11 vs + pinned 1.3.14 (harness warns; behavior identical here). +``` + +## Out of scope + +- Web UI (WP-04), RSVP endpoint (WP-08), contacts proxy (WP-06) +- Any sync-package change + +## Risks + +- Idempotency-key drift for legacy payloads would double-apply retried + commands after deploy — the explicit key-stability test is mandatory. +- Gating must not regress local/anonymous calendar writes that omit + attendees. + +## Handoff + +```yaml +task_id: WP-03 +from: +to: Implementer (backend) +status: +artifact: +evidence: +assumptions: +open_risks: +next_deadline: +``` + +## Session prompt + +```text +You are implementing WP-03 from +wip/attendee-support/WP-03-backend-write-path.md in the Compass repo, on +branch claude/attendee-support-planning-nljgeg. Read +wip/attendee-support/README.md, TRACKING.md, and +00-context-and-invariants.md first, mark WP-03 running (owner + +started_at), push the ledger update, and do not start other WPs. WP-01 +must be done. + +Finish line: toSyncContent maps optional attendees → +attendeesEdit replace/preserve; invitation threaded from input at all +three call sites incl. delete; legacy submit requests byte-identical +with stable idempotency keys; attendees gated to writable Google +calendars with a typed 4xx; test:backend + type-check + lint + knip +green. Fill Evidence, update TRACKING.md, commit conventionally, push. +``` diff --git a/wip/attendee-support/WP-04-web-attendee-editor.md b/wip/attendee-support/WP-04-web-attendee-editor.md new file mode 100644 index 0000000000..917f9c4732 --- /dev/null +++ b/wip/attendee-support/WP-04-web-attendee-editor.md @@ -0,0 +1,239 @@ +# WP-04 — Web attendee editor + "send invitations?" prompt + +**task_id:** WP-04 +**status:** done +**owner:** Implementer (web) +**depends on:** WP-02 and WP-03 (this WP turns editing ON) +**next owner after done:** WP-06 unblocks (with WP-05); WP-08 borrows +form conventions + +## Why + +This is the launch gate: the first WP whose merge makes attendee editing +user-visible. The event form gets an email-chip combobox; the draft +adapter and mutations start carrying attendees; saving with a changed +guest set asks whether Google should email invitations. + +Key files: + +- [`packages/web/src/views/Forms/EventForm/EventForm.tsx`](../../packages/web/src/views/Forms/EventForm/EventForm.tsx) +- [`packages/web/src/views/Forms/EventForm/EventDetailsSection.tsx`](../../packages/web/src/views/Forms/EventForm/EventDetailsSection.tsx) + (stays the read-only renderer for read-only events) +- [`packages/web/src/events/grid-event-draft.adapter.ts`](../../packages/web/src/events/grid-event-draft.adapter.ts) + (`editableContent()` runtime pick) +- [`packages/web/src/events/mutations/useEventMutations.ts`](../../packages/web/src/events/mutations/useEventMutations.ts) + (`mergeReplaceContent`) +- [`packages/web/src/views/Forms/hooks/useSaveEventForm.ts`](../../packages/web/src/views/Forms/hooks/useSaveEventForm.ts) +- Building blocks: react-select `CreatableSelect` (pattern: + [`TimePicker.tsx`](../../packages/web/src/views/Forms/EventForm/DateControlsSection/DateTimeSection/TimePicker/TimePicker.tsx)), + [`form.util.ts`](../../packages/web/src/common/utils/form/form.util.ts) + (`isComboboxInteraction`), + [`floating-layer.ts`](../../packages/web/src/shortcuts/floating-layer.ts) + +## Finish line + +1. On a writable Google calendar, the event form shows an `AttendeeField` + (CreatableSelect, isMulti, email chips). Adding/removing chips and + saving sends `content.attendees` + `invitation`; untouched guest + lists omit both (preserve semantics) and show no prompt. +2. When the guest set changed, save asks "Send invitation emails?" — + Send (default, `"all"`) / Don't send (`"none"`). +3. Optimistic UI: new attendees render as `needsAction` immediately and + settle from sync. +4. Non-Google / read-only calendars: no editor; the existing read-only + guest list is unchanged. +5. Keyboard: Enter inside the combobox creates a chip and does not + submit the form (`isComboboxInteraction` participation); Escape + closes the listbox before the form (`useFloatingLayer` registered). +6. Invalid email strings cannot become chips (inline rejection). +7. `bun test:web` and `bun test:a11y` green; semantic colors only; + `type-check`, `lint`, `knip` green. + +## Steps + +1. Read the key files, `00-context-and-invariants.md`, and the + TimePicker CreatableSelect styling. +2. Build `AttendeeField` under + `packages/web/src/views/Forms/EventForm/AttendeeField/` with a + pluggable suggestion-source prop (static empty for now — WP-06 plugs + contacts in). Chips show displayName or email; removal via + backspace/click; email validation on create. +3. Wire draft state: `editableContent()` includes `attendees` only when + the draft touched them; draft store patch helpers as needed. +4. `useSaveEventForm`: detect guest-set change (email-set inequality vs + the source event), show the prompt (reuse the existing dialog + patterns, e.g. `RecurrenceScopeDialog` styling), map to + `invitation`. +5. `useEventMutations`: optimistic merge of intended attendees + (`mergeReplaceContent` update) and rollback on failure. +6. Gate rendering: `calendar.provider === "google"` and writable, and + the user organizes the event (non-organizer sees read-only list — + sync would reject anyway, don't offer the editor). +7. RTL tests (semantic queries) + MSW handlers asserting payloads; + keyboard/layer tests; a11y sweep on the new control. +8. Run the finish-line checks. + +## Acceptance tests + +- **Normal:** add two chips, save, choose Send → MSW asserts + `attendees` + `invitation: "all"`. +- **Incomplete input:** paste "not-an-email" → inline rejection, no + chip, form still submittable. +- **Tool failure:** save 503 → existing retry/rollback path restores the + previous guest list. +- **Policy:** read-only event renders the legacy read-only list with no + input; untouched-guest-list save omits `attendees` and shows no + prompt. + +## Evidence + +Recorded 2026-08-26 (implementer: manager-loop session): + +```text +commands run: bun test:web; bun test:a11y (plus a long-timeout rerun, see + below); bun run type-check; bun lint (after bun lint:fix for formatting); + bun knip. All re-run on the final tree. +test:web result: 2362 pass, 0 fail (312 files) — includes new suites + AttendeeField.test.tsx (7: chip create on Enter without form submit, + invalid-email inline rejection with form still submittable, duplicate + refusal case-insensitive, displayName chips + remove button, Backspace + removal, Escape closes listbox first + floating-layer registration, + pluggable suggestion source), EventForm.attendees.test.tsx (8: editor + gating — organized/writable-Google renders combobox and read-only list + stands down; non-organizer, read-only calendar, local calendar, and + series occurrence keep the legacy read-only list; series base and + organizer-less (Compass-created) events get the editor; Enter in the + combobox never submits while Enter elsewhere still does), + useSaveEventForm.attendees.test.tsx (8: untouched and + touched-but-unchanged saves omit attendees+invitation with no prompt; + changed set prompts BEFORE any mutation; Send→"all", Don't send→"none", + Cancel aborts; create-with-guests prompts and threads attendees; + recurring non-"all" belt drops the guest edit; series-wide "all" keeps + it), useEventMutations.attendees.test.tsx (5, see MSW proof), + RecurrenceScopeDialog.test.tsx (2: guest-changed narrowing) and a + shouldConfirmDiscardUnsavedChanges case (guest change dirty; touched-but- + restored not dirty). Pre-existing MSW "GET /api/calendars" unhandled- + request noise is identical on the base tree with the work stashed. +test:a11y result: `bun test:a11y` at its default 30s per-test timeout fails + 6 of 7 in THIS container with axe `frame.evaluate` timeouts — the base + tree with the work stashed fails the IDENTICAL 6 the same way + (environment slowness, not WP-04). With the same suite run as + `bunx playwright test e2e/accessibility --timeout=180000` on the final + tree, all 7 pass (axe "incomplete" items are logged, not failures, per + docs/development/testing-playbook.md). Not claiming the default-timeout + command green. +payload assertion proof (MSW): useEventMutations.attendees.test.tsx drives + the real RemoteEventRepository -> EventApi -> BaseApi(fetch) stack into + MSW handlers that capture the request body: (1) a guest-edit replace puts + content.attendees (input shape, no responseStatus) and invitation "all" + on the wire; (2) an untouched save's body has NO attendees key and NO + invitation key (preserve semantics, byte-compatible with pre-WP saves); + (3) a replayed read-shaped guest list (undo/redo content with + responseStatus) is stripped at the wire boundary — the body has no + attendees key, so replays keep pre-WP behavior and never trip sync's + organizer guard. Same file also proves the optimistic contract: retained + guests keep their provider responseStatus/displayName, new guests paint + as needsAction immediately, and a failed save rolls the cached guest + list back to its pre-edit state. +type-check / lint / knip result: all exit 0. lint: 0 errors, 10 + pre-existing warnings (untouched files). knip: no findings (pre-existing + .css configuration hint only). +recurring-event UX choice (documented per spec): guest edits are + series-wide only, implemented as BOTH halves of the spec's alternatives, + each where it matches existing UX: (a) a single occurrence of a series + never renders the editor (occurrence edits flow through scope "this" + + the promotion toast, which sync refuses for guest replacements — the + read-only guest list stays); (b) a series-base edit renders the editor, + and when the guest set changed the RecurrenceScopeDialog narrows its + options to just "All Events" with an explanatory line — the same + narrowing mechanism the dialog already uses for structural recurrence + changes (RECURRENCE_CHANGED_UPDATE_SCOPE_OPTIONS). A belt in + useSaveEventForm drops (console.warn) any guest edit that still arrives + on a recurring event at a non-"all" scope rather than submitting a + command sync would refuse asynchronously. +deltas from spec (if any): + - editableContent() now forwards attendees across the wire boundary ONLY + for genuine guest edits (no entry carries responseStatus, entries + re-picked to {email, displayName}); replayed read-shaped lists are + dropped as before. Consequence: undo/redo does not restore guest + membership (replays stay preserve, byte-identical to pre-WP) — accepted + v1 limitation, mirrors WP-01's replay/guest-edit split. + - WP-01's "a pure guest-edit input contributes nothing optimistic" note + is superseded per this WP's finish line 3: mergeReplaceContent / + optimisticEventFromCreate now merge intended guests by email against + the cached list (retained keep status, new enter needsAction, dropped + disappear), mirroring sync's mergeAttendees and the backend's + synthesized response; rollback rides the existing snapshot restore. + - A touched-but-unchanged guest list (case-insensitive email-set equality + vs the source) is normalized back to "not editing guests" before parse + AND in the discard-confirmation dirty check, so add-then-remove behaves + exactly like never touching the field. + - The prompt also covers create drafts that added guests (guest set + changed from empty); a create belt drops guest edits when the resolved + target calendar is not a writable Google calendar. + - Organizer detection compares the source event's organizer email to the + calendar's accountEmail (case-insensitive); a null organizer counts as + organized (Compass-created — matches WP-02's sync guard); an + unverifiable account email fails closed to the read-only list. + - AttendeeField's pluggable source is `suggestionSource?: (query: + string) => Promise` (default resolves []); + WP-06 swaps in the contacts proxy without touching the field. + - The invitation dialog is a new SendInvitationsDialog (OverlayPanel, + Send focused as the default) rendered by SidebarEventDetails from the + `invitationPrompt` state useSaveEventForm now returns; invitation rides + the parsed input at the top level (spread after parse — the strict + schemas already model it as optional). + - Environment note: container runs bun 1.3.11 vs pinned 1.3.14 (harness + warns); Playwright chromium installed via `bunx playwright install + chromium` for the a11y run. +``` + +## Out of scope + +- Contact suggestions and the enable-contacts nudge (WP-06) +- RSVP control (WP-08) +- Editing organizer or conference + +## Risks + +- This merge is the launch: WP-02 and WP-03 must be `done` and green on + the branch first — verify TRACKING before starting. +- The form's global Enter/Delete/digit shortcuts leak into new inputs + easily; test the combobox against `form.util.ts` gating explicitly. +- react-select must be themed with semantic tokens (`bun lint` runs the + semantic-color check). + +## Handoff + +```yaml +task_id: WP-04 +from: +to: Implementer (web) +status: +artifact: +evidence: +assumptions: +open_risks: +next_deadline: +``` + +## Session prompt + +```text +You are implementing WP-04 from +wip/attendee-support/WP-04-web-attendee-editor.md in the Compass repo, +on branch claude/attendee-support-planning-nljgeg. Read +wip/attendee-support/README.md, TRACKING.md, and +00-context-and-invariants.md first, mark WP-04 running (owner + +started_at), push the ledger update, and do not start other WPs. WP-02 +and WP-03 must be done. + +Finish line: AttendeeField email-chip combobox (CreatableSelect, +pluggable suggestion source) in the event form on writable Google +calendars the user organizes; changed guest set → save-time "Send +invitation emails?" prompt defaulting to Send; untouched → omit +attendees, no prompt; optimistic needsAction chips with rollback; +keyboard/floating-layer correctness; test:web + test:a11y + type-check ++ lint + knip green. Fill Evidence, update TRACKING.md, commit +conventionally, push. +``` diff --git a/wip/attendee-support/WP-05-contacts-scope-and-suggestions.md b/wip/attendee-support/WP-05-contacts-scope-and-suggestions.md new file mode 100644 index 0000000000..975f9226f8 --- /dev/null +++ b/wip/attendee-support/WP-05-contacts-scope-and-suggestions.md @@ -0,0 +1,228 @@ +# WP-05 — Contacts scopes, People adapter, sync suggestions endpoint + +**task_id:** WP-05 +**status:** queued +**owner:** Implementer (sync) +**depends on:** none (lane C — may run parallel with WP-01–04) +**next owner after done:** WP-06 unblocks (with WP-04) + +## Why + +Attendee suggestions need Google contacts, which Compass has never +touched. Sync owns all Google code, incremental auth, and per-connection +granted scopes — so the People adapter, the optional-scope consent, the +`suggestContacts` capability, and the internal suggestions route all +land here. The contacts scopes are **optional**: sign-in and calendar +connect keep working for users who never grant them. + +Approval note: adding `contacts.readonly` and `contacts.other.readonly` +as optional consent-screen scopes was approved by the product owner on +2026-08-25 (see TRACKING approval column). The required scope lists +(`GOOGLE_AUTH_SCOPES_REQUIRED` in web, backend required validation) +must NOT change — that would brick sign-in and is Human-gated. + +Key files: + +- [`packages/sync/src/providers/google/google.scopes.ts`](../../packages/sync/src/providers/google/google.scopes.ts) +- [`packages/sync/src/providers/google/google-auth.adapter.ts`](../../packages/sync/src/providers/google/google-auth.adapter.ts) + (`buildAuthorizationUrl`, already `include_granted_scopes: true`) +- [`packages/sync/src/server/connection.routes.ts`](../../packages/sync/src/server/connection.routes.ts) + (`BEGIN_PATH`; callback already persists granted scopes) +- [`packages/sync/src/providers/google/google-capabilities.ts`](../../packages/sync/src/providers/google/google-capabilities.ts) +- [`packages/core/src/types/sync/identity.contracts.ts`](../../packages/core/src/types/sync/identity.contracts.ts) + (capability enum, contract test) +- New: `packages/sync/src/providers/google/google-people.adapter.ts`, + `packages/sync/src/server/contacts.routes.ts` (or colocated in an + existing routes file per repo taste), + `packages/core/src/types/contact.contracts.ts` + +## Finish line + +1. Named scope consts exist for both contacts scopes; the base + `GOOGLE_SCOPES` array is unchanged (regression test). +2. Connection begin accepts optional `features: ["contacts"]`; with it, + the consent URL contains both contacts scopes plus the calendar + scopes; without it, the URL is byte-identical to today. +3. A callback persisting a contacts grant yields a connection whose + capabilities include `suggestContacts` (either contacts scope + suffices — partial grants are normal). +4. `GET/POST /internal/contacts/suggestions` (principal-scoped, + rate-limited) returns ranked `{email, displayName}[]` from the People + API via a narrow `ContactsPort`; refuses (typed, non-500) when the + capability is absent; queries under 2 chars return empty without a + Google call. Response shape lives in `contact.contracts.ts` — + email + displayName only, nothing else from People. +5. Contact data never appears in logs or error causes — extend the + safety-canary patterns with a People-shaped pattern (e.g. + `"emailAddresses"`), and the suite passes. +6. Onboarding consent: the sign-in flow's *requested* scope list gains + the contacts scopes as optional (users can leave them unchecked and + proceed) — the backend adoption path stores what was granted. The + *required* verification lists stay untouched, with an explicit test + asserting sign-in succeeds when contacts are not granted. +7. `bun test:sync`, `test:core`, `type-check`, `lint`, `knip` green. + +## Steps + +1. Read the key files, their tests, and + `e2e/oauth/google-auth-callback.spec.ts` (do not change its required + list; WP-06 extends e2e). +2. Scope consts + `CONTACTS_FEATURE_SCOPES` array; extend + `buildAuthorizationUrl` input with optional extra scopes; extend the + BEGIN_PATH request schema with `features`. +3. Capability: `googleCapabilitiesFromScopes` emits `suggestContacts`; + add the enum member + contract test in `identity.contracts.ts`. +4. `ContactsPort` + `google-people.adapter.ts` with an injectable + client factory (mirror `GoogleOAuthClientFactory`); implement search + over `people.searchContacts` and `otherContacts.search`, querying + only APIs the granted scope allows; merge + rank results. +5. Suggestions route: resolve connection by principal, check + capability, call the port, map to the contract. Rate-limit like + existing internal routes. +6. Sign-in requested-scope addition: locate where the sign-in flow's + requested scopes are assembled (backend SuperTokens clients config + + web `useStartGoogleAuthorization`) and add contacts as + requested-but-not-required. Server-side validation + (`grantedGoogleScopes`) must keep passing without them. +7. Fakes: `FakePeopleApi` class implementing the port; route + `.db.test.ts`; canary extension tests. +8. Run the finish-line checks. + +## Acceptance tests + +- **Normal:** begin with `features: ["contacts"]` → consent URL has both + contacts scopes; query "al" → ranked suggestions from the fake. +- **Incomplete input:** query under 2 chars → empty 200, no Google + call; connection with only `contacts.other.readonly` → capability + present, only otherContacts queried. +- **Tool failure:** People API 429 → typed retryable error, no crash, + no contact data in the error cause. +- **Policy:** connection without a contacts grant → capability absent, + route refuses; base connect URL and required sign-in scopes + byte-identical to today; sign-in succeeds with contacts unchecked. + +## Evidence + +Recorded 2026-08-26 (implementer: manager-loop session): + +```text +commands run: bun test:sync (full, in-memory Mongo harness); bun test:core; + bun run type-check; bun lint (after bun lint:fix for formatting of new + files); bun knip; regression for the sign-in scope split: bun + test:backend:fast, bun test:web. All re-run on the final tree. +test:sync / test:core result: test:sync 1052 pass 0 fail (81 files; was 1017/78 + — +35 tests, +3 files: google.scopes.test.ts, google-people.adapter.test.ts, + contacts.routes.db.test.ts, plus extended auth-adapter/capabilities/ + connection-routes/canary suites). test:core 615 pass 0 fail (37 files; + +contact.contracts.test.ts, extended identity/connection contract tests). +safety-canary tests pass: yes — patterns extended with two People shapes + ("emailAddresses":, "suggestions":[{) in EVENT_CONTENT_PATTERNS; safety + suite re-run standalone under the harness: 19 pass 0 fail. The people + adapter test additionally proves a failed search's error/cause chain + carries no contact data or token (findSafetyCanaryHit null + literal + absence asserts); route log lines are static text with redactedCause only. +required-scope-lists-unchanged proof: + - web GOOGLE_AUTH_SCOPES_REQUIRED: untouched (git diff shows only comments + + new OPTIONAL/REQUESTED consts around it); pinned by the new literal + test "keeps the required scope list free of the optional contacts + scopes" in google-authorization.test.ts. Verification in + complete-google-authorization.ts still checks REQUIRED only (unchanged). + - backend GOOGLE_AUTH_SCOPES (the required-validation list used by + grantedGoogleScopes): untouched; pinned by the new literal test in + google.auth.scopes.test.ts ("no contacts scope, ever"). Only the + SuperTokens client config now uses GOOGLE_AUTH_SCOPES_REQUESTED. + - sync base GOOGLE_SCOPES: untouched; pinned literally by + google.scopes.test.ts, and the auth-adapter test proves a begin without + features mints a byte-identical consent URL. + - e2e/oauth/google-auth-callback.spec.ts REQUIRED_SCOPES: not modified + (file untouched by this WP's diff). + - explicit sign-in-without-contacts tests: backend "signs up successfully + when the optional contacts scopes are not granted" (REQUESTED minus + contacts === GOOGLE_AUTH_SCOPES, sign-up completes + adoption fires); + web "completes sign-in when the optional contacts scopes are not + granted" (callback granting REQUIRED only resolves status "completed"). +type-check / lint / knip result: all exit 0. lint: 0 errors, 10 pre-existing + warnings (untouched files). knip: no findings (pre-existing .css + configuration hint only). +regression: test:web 2364 pass 0 fail (312 files, +2 tests); + test:backend:fast 301 pass 20 fail — the identical pre-existing baseline + set documented in WP-01 (SSE Server 11, supertokens.middleware.util 6, + GET /api/config 3; sandbox-environment issues). The new backend scope + tests pass. +deltas from spec (if any): + - Suggestions route is GET (?q=) only, not GET/POST — one verb suffices + for a read and matches the internal read routes; WP-06's backend proxy + was already specified as GET. + - The begin `features` request shape lives in core + (ConnectionBeginFeaturesSchema in connection.contracts.ts) so WP-06's + backend proxy reuses it; adapter input field is `extraScopes` + (provider-neutral), populated from CONTACTS_FEATURE_SCOPES by the route. + - Granted-scope split (which People surfaces to query) is read from the + connection credential's stored `scopes` (only non-token field read); + the People adapter receives it as `sources` and only queries allowed + surfaces (searchContacts / otherContacts.search), asserted by fakes. + - Multi-connection principals: every connection with the suggestContacts + capability is queried and results are merged + deduped by email; a + connection without a stored credential contributes nothing. + - Error mapping: rateLimited → 429 {error:"rate_limited",retryable:true}; + other typed search failures and custody ProviderAuthError → 503 + {error:"contacts_unavailable",retryable:true}; capability absent → 403 + {error:"contacts_not_granted"}; passive/unconfigured → 409 + provider_work_disabled (all non-500, tested). + - New dependency @googleapis/people@8.0.0 in packages/sync (mirrors + @googleapis/calendar; injectable GooglePeopleApiFactory keeps tests + network-free). + - Environment note (same as WP-02/03): this container's kernel has IPv6 + disabled and Bun's host-less listen() binds "::", so the DB-backed + suites ran with a TEMPORARY scratchpad-only bunfig preload shim forcing + IPv4 binds (reverted before commit; bunfig.toml byte-identical to HEAD). + Bun 1.3.11 vs pinned 1.3.14 (harness warns; behavior identical here). +``` + +## Out of scope + +- Backend browser proxy and all web UI (WP-06) +- Contact caching/persistence — every query hits the People API v1 +- Directory/domain-shared contacts + +## Risks + +- The four scope lists are a known trap: only *requested* lists change; + a contacts scope in any *required* list bricks sign-in. The explicit + regression test in finish line 6 is mandatory. +- People API quotas: min-length + rate limiting bound it; WP-06 adds + client debounce. + +## Handoff + +```yaml +task_id: WP-05 +from: +to: Implementer (sync) +status: +artifact: +evidence: +assumptions: +open_risks: +next_deadline: +``` + +## Session prompt + +```text +You are implementing WP-05 from +wip/attendee-support/WP-05-contacts-scope-and-suggestions.md in the +Compass repo, on branch claude/attendee-support-planning-nljgeg. Read +wip/attendee-support/README.md, TRACKING.md, and +00-context-and-invariants.md first, mark WP-05 running (owner + +started_at), push the ledger update, and do not start other WPs. + +Finish line: contacts.readonly + contacts.other.readonly as OPTIONAL +scopes (begin features:["contacts"], onboarding requested list) with +required lists untouched and tested; suggestContacts capability from +granted scopes; ContactsPort + google-people.adapter with fakes; +principal-scoped /internal/contacts/suggestions returning +{email, displayName}[] only; canary extended for contact data; +test:sync + test:core + type-check + lint + knip green. Fill Evidence, +update TRACKING.md, commit conventionally, push. +``` diff --git a/wip/attendee-support/WP-06-contacts-surface.md b/wip/attendee-support/WP-06-contacts-surface.md new file mode 100644 index 0000000000..bf291b2daf --- /dev/null +++ b/wip/attendee-support/WP-06-contacts-surface.md @@ -0,0 +1,243 @@ +# WP-06 — Contacts surface: proxy, suggestions, enable nudge + +**task_id:** WP-06 +**status:** done +**owner:** Implementer (backend + web) +**depends on:** WP-04, WP-05 +**next owner after done:** WP-09 partially unblocks + +## Why + +WP-05 gives sync a suggestions endpoint and a capability; WP-04 gives +the form an attendee field with a pluggable suggestion source. This WP +connects them: a backend browser proxy, the capability surfaced on the +connection summary, live suggestions in the editor, and the approved +"occasional nudge" for users who haven't granted contacts. + +Key files: + +- Backend: new `GET /api/contacts/suggestions` route + controller; + [`packages/backend/src/common/services/sync-service/sync-service.client.ts`](../../packages/backend/src/common/services/sync-service/sync-service.client.ts) + (add the proxy method, mirroring existing internal calls); + [`packages/backend/src/common/services/sync-service/connection-state.translation.ts`](../../packages/backend/src/common/services/sync-service/connection-state.translation.ts) +- Contracts: `GoogleSyncConnectionSummary` in + [`packages/core/src/types/user.types.ts`](../../packages/core/src/types/user.types.ts) + gains `canSuggestContacts: boolean` +- Web: suggestion source for `AttendeeField` (debounced query → + backend, ranked with + [`command-palette.search.ts`](../../packages/web/src/components/CommandPalette/command-palette.search.ts)); + [`useConnectGoogle.ts`](../../packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.ts) + begin call gains `features: ["contacts"]` +- E2E: [`e2e/oauth/google-auth-callback.spec.ts`](../../e2e/oauth/google-auth-callback.spec.ts) + additions (grant and deny paths) + +## Finish line + +1. With the capability granted, typing ≥2 chars in the attendee field + shows Google contact suggestions (debounced ≥250ms); selecting one + fills the chip with displayName + email. +2. Without it, the field works as a raw email input and shows an + occasional, dismissible "Enable contact suggestions" affordance in + the combobox footer — never a modal, never on every open (persist a + dismissal, e.g. localStorage, and show at most once per session). + Clicking it starts the connect flow with `features: ["contacts"]`; + completing consent updates the summary and suggestions work without + a reload (metadata refetch). +3. `canSuggestContacts` flows: sync capability → + connection-state translation → user metadata → web. +4. Sign-in flow untouched: `GOOGLE_AUTH_SCOPES_REQUIRED` unchanged, + with an explicit test. +5. Proxy hardening: same auth/billing middleware as event reads; typed + empty response when sync is down (no error-toast storm); suggestion + responses never logged client- or server-side. +6. E2E oauth spec covers contacts granted and contacts denied; denied + leaves the connection healthy with the capability false (no + `insufficientScopes` regression). +7. `bun test:backend`, `test:web`, `test:core`, the e2e oauth spec, + `type-check`, `lint`, `knip` green. + +## Steps + +1. Read the key files and WP-04's `AttendeeField` suggestion-source + interface. +2. Backend: proxy route + client method + translation; wire + `canSuggestContacts` through the summary. +3. Web: `useContactSuggestions` hook (TanStack Query, debounce, + min-length, cancel-on-unmount); rank with the command-palette + scorer; plug into `AttendeeField`. +4. Nudge affordance + `useConnectGoogle` `features` param; metadata + refetch on connect completion (existing reconnect toast flow shows + the pattern). +5. MSW handlers + RTL tests; backend controller tests; e2e additions. +6. Run the finish-line checks. + +## Acceptance tests + +- **Normal:** type 3 chars → MSW-served suggestions ranked; select → + chip with displayName. +- **Incomplete input:** backend returns empty → "no matches" state, raw + email entry still works. +- **Tool failure:** proxy 503 → silent fallback to raw entry, no toast + storm. +- **Policy:** user denies contacts at Google → connection stays healthy, + capability false, nudge still available later; required sign-in scopes + unchanged (test). + +## Evidence + +Recorded 2026-08-26 (implementer: manager-loop session): + +```text +commands run: bun test:backend (via the scratchpad IPv4 listen shim preloaded + into the runner AND bunfig [test] preload — TEMPORARY, reverted before + commit, bunfig.toml byte-identical to HEAD; same environment workaround as + WP-02/03/05); bun test:web; bun test:core; bunx playwright test e2e/oauth + --timeout=180000 (chromium installed via bunx playwright install chromium); + bun run type-check; bun lint (after bun lint:fix); bun knip. All re-run on + the final tree (verifying pass). +test:backend / test:web result: test:backend 406 pass 1 skip 8 fail — the + IDENTICAL pre-existing baseline set documented in WP-03 (GET /api/config x3, + UserController x5; sandbox-environment issues), +16 net new passing tests + (contacts.controller.test.ts 8, sync-service.client contacts trio, + connection-state canSuggestContacts, core begin-request features trio, ...). + test:web 2388 pass 0 fail (316 files; was 2364 — +24: contact-nudge.gate 4, + useContactSuggestions 7 + rankContactSuggestions 2, + EnableContactSuggestionsNudge 3, AttendeeField menuFooter 1, + EventForm.contact-suggestions 3, useConnectGoogle features 2, + refreshUserMetadataAfterGoogleConnect 2). test:core 618 pass 0 fail. +e2e oauth spec result: bunx playwright test e2e/oauth --timeout=180000 — the + 2 NEW WP-06 tests pass (contacts granted: sign-in completes and the store + carries canSuggestContacts true; contacts denied: sign-in completes, no + missing-scopes error, connection HEALTHY, capability false — no + insufficientScopes regression). The PRE-EXISTING "finishes a saved Google + sign-in callback" test fails in THIS container on its transient + role=status spinner visibility assertion — the base tree with the work + stashed fails the IDENTICAL assertion the same way (environment timing, + not WP-06). Recording loudly per instructions: 2 passed, 1 pre-existing + environment failure identical on base. +required-scopes-unchanged proof: git diff shows NO change to web + GOOGLE_AUTH_SCOPES_REQUIRED, backend GOOGLE_AUTH_SCOPES, sync GOOGLE_SCOPES, + or e2e REQUIRED_SCOPES (the e2e spec's REQUIRED_SCOPES const is untouched + verbatim; the new OPTIONAL_CONTACTS_SCOPES const is additive with a warning + comment). WP-05's literal-pin tests (google-authorization.test.ts, + google.auth.scopes.test.ts, google.scopes.test.ts) all still pass in the + suites above; the web "completes sign-in when the optional contacts scopes + are not granted" test is unchanged and green. +type-check / lint / knip result: all exit 0. lint: 0 errors, 10 pre-existing + warnings (untouched files; semantic-color check green). knip: no findings + (pre-existing .css configuration hint only). +suggestion-content-never-logged proof: backend — the controller's ONLY log + emission goes through exported contactSuggestionsFailureLogLine(error), + whose input type (SyncClientError: kind/status/correlationId) physically + cannot carry the query or a suggestion; the rendered line is literal-pinned + in contacts.controller.test.ts. SyncServiceClient never logs. web — no + console/log call exists in ContactsApi, useContactSuggestions, or the + AttendeeField path; failures resolve to [] silently (tested: proxy 503 → + empty, no toast). +nudge frequency rule encoded in tests: contact-nudge.gate.test.ts IS the + frequency rule (at most once per session; dismissal persisted in + localStorage forever; storage-throw still bounded), plus component-level + pins in EnableContactSuggestionsNudge.test.tsx (second mount in a session + renders nothing; dismissal survives a new session; never a modal). +deltas from spec (if any): + - Backend "billing middleware parity with event reads": event READS are + verifySession() only — billing (assertBillingAllowsWrites) guards writes, + not reads — so the proxy route is verifySession() only, documented in + contacts.routes.config.ts. Exact parity, no billing gate to copy. + - Every sync-side failure (not just "sync down") degrades to the typed + empty 200 {suggestions: []}: unavailable/timeout/conflict AND the 403 + contacts_not_granted race (metadata staler than a revocation) — one + keystroke can never toast. Only a malformed browser query is a 400. + - ConnectionBeginRequestSchema (core) gained optional `features` + (ConnectionBeginFeaturesSchema); a legacy body parses byte-identically + (tested). The backend begin controller and sync route needed NO changes — + the schema flows web → backend parse → sync body, and sync's route + already read `features` (WP-05). + - useConnectGoogle options gained `features`; it rides both fresh-connect + and reconnect begin bodies; absent, the body is byte-identical (tested). + - canSuggestContacts is REQUIRED on GoogleSyncConnectionSummary (backend + always sets it from the suggestContacts capability); the web selector + reads `=== true` so an older backend payload (field absent) degrades to + "not granted" rather than crashing. Test fixtures updated accordingly. + - AttendeeField gained `menuFooter` (rendered via a module-scope custom + react-select Menu reading a context, so component identity stays stable) + and `filterOption={null}`: the suggestion source already matches AND + ranks, and react-select's default substring filter was hiding legitimate + People matches that hinge on fields the label never shows (regression + surfaced by the ranked-suggestions test). + - useContactSuggestions debounces inside the suggestionSource callback + (250ms, superseding pending calls), enforces the 2-char minimum, runs + fetches through queryClient.fetchQuery (TanStack cache + dedupe, + staleTime 30s), and aborts in-flight requests on unmount via a new + optional `signal` on the web ApiRequestConfig/BaseApi fetch (additive). + - "Metadata refetch on completion": the connect flow is a full-page OAuth + round-trip, so the return IS a fresh load; additionally + refreshUserMetadataAfterGoogleConnect("connected") force-refreshes + metadata at bootstrap (chains onto any in-flight fetch), so the + capability is live in that page load with no manual reload. (tested) + - EnableContactSuggestionsNudge.test.tsx uses the repo's delegating + mock.module pattern for useConnectGoogle: the single-process web suite + already carries earlier files' process-wide stubs of that module (one + without `connect` at all), so the real hook is unreachable there. The + nudge is asserted at the hook boundary (features:["contacts"] passed, + click invokes connect); the features → begin-body wire threading is + asserted in useConnectGoogle.scope.test.tsx, which runs before any + module mock exists. My mock delegates to the real hook after the file + (afterAll), unlike the pre-existing permanent stub. + - e2e grant/deny paths ride the SIGN-IN callback spec (the flow the spec + harness models): granted = REQUIRED + both contacts scopes on the + callback; denied = REQUIRED only; both complete sign-in, and the + userMetadata e2e store bridge is asserted for connectionState HEALTHY + + canSuggestContacts true/false. + - Environment notes: bun 1.3.11 vs pinned 1.3.14 (harness warns); IPv4 + listen shim as above (scratchpad-only, never committed); Playwright + chromium installed in-container. +``` + +## Out of scope + +- Contact caching, avatars, or any People field beyond email + + displayName +- Nudges anywhere outside the attendee field + +## Risks + +- The nudge must respect the "occasional, non-nagging" product decision + — over-showing it is a regression, encode the frequency rule in a + test. +- Debounce + min-length are the quota guard; don't drop them when + wiring TanStack. + +## Handoff + +```yaml +task_id: WP-06 +from: +to: Implementer (backend + web) +status: +artifact: +evidence: +assumptions: +open_risks: +next_deadline: +``` + +## Session prompt + +```text +You are implementing WP-06 from +wip/attendee-support/WP-06-contacts-surface.md in the Compass repo, on +branch claude/attendee-support-planning-nljgeg. Read +wip/attendee-support/README.md, TRACKING.md, and +00-context-and-invariants.md first, mark WP-06 running (owner + +started_at), push the ledger update, and do not start other WPs. WP-04 +and WP-05 must be done. + +Finish line: GET /api/contacts/suggestions proxy; canSuggestContacts on +GoogleSyncConnectionSummary; debounced ranked suggestions in +AttendeeField; occasional dismissible enable-contacts nudge → +useConnectGoogle features:["contacts"] with no-reload refresh; sign-in +required scopes untouched (tested); e2e grant/deny paths; test:backend ++ test:web + type-check + lint + knip green. Fill Evidence, update +TRACKING.md, commit conventionally, push. +``` diff --git a/wip/attendee-support/WP-07-rsvp-sync.md b/wip/attendee-support/WP-07-rsvp-sync.md new file mode 100644 index 0000000000..62252c1875 --- /dev/null +++ b/wip/attendee-support/WP-07-rsvp-sync.md @@ -0,0 +1,214 @@ +# WP-07 — RSVP write path in sync + +**task_id:** WP-07 +**status:** done +**owner:** Implementer (sync) +**depends on:** WP-01, WP-02 (shares merge helpers and writer surface) +**next owner after done:** WP-08 unblocks (with WP-03) + +## Why + +An RSVP is not a content edit: it rewrites exactly one attendee entry — +the user's own — and must not fight `mergeUpdateContent` or +`matchesIntendedEdit`. WP-01 added the `rsvp` command kind; this WP +executes it, including per-occurrence targeting (product decision: +"this event" RSVPs are in scope for v1). + +Key files: + +- [`packages/sync/src/domain/cloud-command.service.ts`](../../packages/sync/src/domain/cloud-command.service.ts) +- [`packages/sync/src/domain/provider-command.service.ts`](../../packages/sync/src/domain/provider-command.service.ts) +- [`packages/sync/src/providers/google/google-event-writer.adapter.ts`](../../packages/sync/src/providers/google/google-event-writer.adapter.ts) + (reuse patch + `fetchInstanceAt`/instances surface) +- [`packages/sync/src/providers/google/google-instance-id.ts`](../../packages/sync/src/providers/google/google-instance-id.ts) + (occurrence-id mechanics) + +## Finish line + +1. An rsvp command against a provider-linked event where the + connection's account email appears as an attendee patches only that + entry's `responseStatus`; the fake `GoogleEventsApi` asserts every + other entry is byte-identical to the fetched state and + `sendUpdates: "none"`. +2. Per-occurrence: an rsvp targeting an occurrence id + (`eventId::recurrenceId`) patches the Google instance event (the + instance id from the existing decode), leaving the series master + untouched; a series-scoped rsvp patches the master. +3. Replay-safe: a retry after a landed patch confirms without a second + write (current self status equals intended). +4. Self not an attendee, or event unlinked/local → typed + `unsupportedCapability` failure, no provider call. RSVP by the + organizer on their own event is allowed (organizer self-status). +5. A confirmed rsvp updates sync's stored record (and reprojects + occurrences if attendee-bearing reads flow from them) so the next + backend read reflects it before Google round-trips. +6. `bun test:sync` incl. safety-canary green; `type-check`, `lint`, + `knip` green. + +## Steps + +1. Read the key files, WP-02's merge helpers, and the occurrence-id + decode used by update/delete commands. +2. Implement rsvp execution in `provider-command.service.ts`: resolve + target (master vs instance), fetch current, match self by + case-insensitive email, rewrite status, patch full list. Reuse + WP-02's attendee body emission. +3. Replay branch: before writing, if the fetched self entry already + holds the intended status, confirm without a write. +4. `cloud-command.service.ts`: cloud-only events with attendees are not + expected in v1; a rsvp against one fails typed (document in test). +5. Persist on confirm: update stored attendees; trigger the same + post-write bookkeeping update commands use (invalidations outbox → + SSE `eventsChanged`). +6. Tests: self-match (incl. case difference), instance vs master + targeting, replay, guards, canary. +7. Run the finish-line checks. + +## Acceptance tests + +- **Normal:** accepted → declined patch on a single event; declined on + one occurrence leaves the master and sibling occurrences untouched. +- **Incomplete input:** rsvp to `needsAction` is unrepresentable + (rejected at WP-01 contract — assert route-level 400). +- **Tool failure:** fetch 5xx → command stays pending/retryable. +- **Policy:** self not in attendee list → `unsupportedCapability`, no + provider call; attendee JSON absent from all logs (canary). + +## Evidence + +Recorded 2026-08-26 (implementer: manager-loop session): + +```text +commands run: bun test:sync (full, in-memory Mongo harness); bun run + type-check; bun lint (after bun lint:fix for formatting of new tests); + bun knip. All re-run on the final tree. +test:sync result: 1072 pass, 0 fail (81 files) — includes the new/extended + suites: provider-command.service.db.test.ts "executeProviderRsvp" (13 + tests: self-entry rewrite with case-insensitive match, replay without a + second write, organizer self-RSVP allowed, stored-list guard pre-fetch, + unresolvable-connection and no-account-email fail-closed, fetched-list + guard post-fetch, transient-fetch pending, nothing-live permanent + failure, instance-vs-master targeting pair, cancelled-occurrence + non-resurrection, scope-this replay), cloud-command.service.db.test.ts + "rsvp routing" (provider dispatch end-to-end, cloud-only typed refusal + documented, thisAndFollowing typed refusal, missing-event + versionConflict), command.routes.db.test.ts (end-to-end confirmed rsvp + through the signed route with invalidation-outbox rows asserted; + route-level 400 for responseStatus "needsAction"), + stale-command-retry.service.db.test.ts (a transiently-failed rsvp is + swept and confirmed — "rsvp" added to RETRYABLE_KINDS). +safety-canary tests pass: yes — safety-canary.ts untouched; full suite green + within test:sync and packages/sync/src/safety/ re-run standalone under the + harness (19 pass, 0 fail). New canary assertions: a failed rsvp's outcome + and the command route's log-line template contain no attendee JSON + (findSafetyCanaryHit null), both executor-side (stored-list guard) and + dispatch-side (cloud-only refusal). +instance-vs-master targeting proof: + - "patches the resolved Google instance on a scope-this rsvp, leaving the + master untouched": fetchInstanceCalls[0] = {seriesProviderEventId: + "g-evt-1", originalStartAt: 2026-07-21T15:00:00.000Z, scheduleKind: + timed} (the writer's own resolution — no hand-built instance id + anywhere in the executor; the id used is the one fetchInstanceAt + returned), fetchEventCalls 0, exactly one patch and its providerEventId + is "g-inst-1" (never "g-evt-1") with recurrence {kind: "instance"}; + the master's stored guest list and providerVersion are unchanged, the + answer lands on the exception record carrying the instance identity, + and the master still projects 07-14 + 07-28 (sibling occurrences + untouched) with the exception projecting 07-21. + - "patches the series master on a scope-all rsvp, never resolving an + instance": fetchInstanceCalls 0, fetchEventCalls 1, the one patch + targets "g-evt-1" and re-writes the master's own rules unchanged. +type-check / lint / knip result: all exit 0. lint: 0 errors, 10 pre-existing + warnings (untouched files). knip: no findings (pre-existing .css + configuration hint only). +deltas from spec (if any): + - The rsvp patch is UNCONDITIONAL (expectedVersion null), not conditioned + on the command's expectedVersion: any other guest's concurrent RSVP + bumps the etag, and RSVP drift must never block an RSVP (invariant 3's + spirit). The fetch→patch window is the pack's named clobber-window wart. + - The write port requires a full body, so the patch echoes the freshly + fetched content/schedule back (self-describing, mirroring how + "preserve" re-writes current rules) with color/colorHex STRIPPED — a + slot color in the body would trigger the writer's label-clearing + pre-patch and could touch Google color state an RSVP must not. + - Guard order: the self-attendee guard checks the STORED list before any + provider call (the only pre-fetch source of truth), and re-checks the + FETCHED list after the fetch (uninvited provider-side since the last + pull) — both fail the same typed unsupportedCapability. Unverifiable + connections (missing row / no account email) fail closed pre-fetch, + reusing WP-02's ProviderConnectionLookup dep. + - Scope "thisAndFollowing" rsvp (representable in the sync contract, + unreachable from the browser whose scope enum is single|all) fails + typed unsupportedCapability at dispatch. A scope-"this" rsvp on a + NON-recurring event answers the event itself, mirroring how update + ignores scope on single events. + - A per-occurrence rsvp commits locally through upsertException with the + instance's own provider identity + fetched instance content (rewritten + self entry), then reprojectMaster + exception projection — the exact + commitProviderOccurrenceUpdate shape — because occurrence rows carry + only title/schedule and attendee-bearing reads flow from event records; + the whole-event commit rewrites ONLY content.attendees on the stored + record and reprojects via reprojectMaster so cancelled tombstones stay + excluded (regression-tested). + - SSE: no rsvp-specific plumbing was needed — the command route already + appends invalidation-outbox notices (command + event kinds) for any + submission that durably changed state; the end-to-end route test pins + that a confirmed rsvp produces both rows. + - "rsvp" added to the stale-command sweep's RETRYABLE_KINDS so a + transient provider blip mid-execute self-heals like update/delete + (tested). + - Environment note (same as WP-02): this container's kernel has IPv6 + disabled and Bun's host-less listen() binds "::", so mongodb-memory- + server could not boot. Validation ran with a TEMPORARY, uncommitted + bunfig.toml preload shim forcing IPv4 binds (scratchpad-only; reverted + before commit). Bun 1.3.11 vs pinned 1.3.14 (harness warns; behavior + identical here). +``` + +## Out of scope + +- Backend endpoint and web UI (WP-08) +- Google's `self`/`optional` attendee flags in the normalizer (alias + matching is a named wart) +- Propose-new-time, comments + +## Risks + +- Google instance patching: the instance id format must come from the + existing decode helpers, never hand-built. +- Patching the full list to change one entry inherits WP-02's + clobber-window wart — acceptable, documented. + +## Handoff + +```yaml +task_id: WP-07 +from: +to: Implementer (sync) +status: +artifact: +evidence: +assumptions: +open_risks: +next_deadline: +``` + +## Session prompt + +```text +You are implementing WP-07 from +wip/attendee-support/WP-07-rsvp-sync.md in the Compass repo, on branch +claude/attendee-support-planning-nljgeg. Read +wip/attendee-support/README.md, TRACKING.md, and +00-context-and-invariants.md first, mark WP-07 running (owner + +started_at), push the ledger update, and do not start other WPs. WP-01 +and WP-02 must be done. + +Finish line: rsvp command execution — fetch current, rewrite only the +self entry (case-insensitive account-email match), patch with +sendUpdates none; per-occurrence targets the Google instance, series +targets the master; replay confirms without rewrite; typed +unsupportedCapability guards; stored record updated on confirm with SSE +invalidation; test:sync + safety-canary + type-check + lint + knip +green. Fill Evidence, update TRACKING.md, commit conventionally, push. +``` diff --git a/wip/attendee-support/WP-08-rsvp-surface.md b/wip/attendee-support/WP-08-rsvp-surface.md new file mode 100644 index 0000000000..f8b85fcc41 --- /dev/null +++ b/wip/attendee-support/WP-08-rsvp-surface.md @@ -0,0 +1,240 @@ +# WP-08 — RSVP surface: endpoint + web control + +**task_id:** WP-08 +**status:** queued +**owner:** Implementer (backend + web) +**depends on:** WP-03, WP-07 (WP-04 for form conventions) +**next owner after done:** WP-09 unblocks (with WP-04, WP-06) + +## Why + +With sync executing rsvp commands, the user needs a way to send them: a +backend endpoint translating the browser input into the sync command, +and a Going / Maybe / Decline control on events the user is invited to, +with the approved per-occurrence choice ("this event" / "all events") +for recurring events. + +Key files: + +- Backend: + [`packages/backend/src/event/event.routes.config.ts`](../../packages/backend/src/event/event.routes.config.ts), + [`packages/backend/src/event/controllers/event.controller.ts`](../../packages/backend/src/event/controllers/event.controller.ts), + [`packages/backend/src/common/services/sync-service/event-command.translation.ts`](../../packages/backend/src/common/services/sync-service/event-command.translation.ts) + (occurrence-id decode helpers already live here) +- Web: + [`packages/web/src/views/Forms/EventForm/EventDetailsSection.tsx`](../../packages/web/src/views/Forms/EventForm/EventDetailsSection.tsx), + [`packages/web/src/views/Forms/EventForm/RecurrenceScopeDialog.tsx`](../../packages/web/src/views/Forms/EventForm/RecurrenceScopeDialog.tsx) + (scope-choice pattern), + [`packages/web/src/events/mutations/useEventMutations.ts`](../../packages/web/src/events/mutations/useEventMutations.ts), + [`packages/web/src/events/event.api.ts`](../../packages/web/src/events/event.api.ts) + +## Finish line + +1. `POST /api/events/:id/rsvp` accepts `RsvpEventInputSchema` + (responseStatus + scope), resolves occurrence targets via the + existing decode, submits the rsvp sync command, and returns the + updated event; idempotency key derived from + event + status + scope. +2. An invited event (user's calendar account email in attendees, any + status) shows a Going / Maybe / Decline segmented control with the + current status selected — in the event form and consistent with + `EventDetailsSection` styling. +3. On a recurring event, choosing a response opens the scope choice + ("this event" / "all events") using the RecurrenceScopeDialog + pattern; single events skip the dialog. +4. Optimistic: the user's status dot updates immediately, rolls back on + failure; other attendees' RSVP changes arriving via SSE update the + list live (regression test only — the path exists). +5. Events where the user is not an attendee show no control. RSVP is + allowed on viewer-access (read-only) calendars — it is not a + calendar write. +6. `bun test:backend`, `test:web`, `test:a11y`, `type-check`, `lint`, + `knip` green. + +## Steps + +1. Read the key files and WP-07's command semantics. +2. Backend: route + controller (auth/billing parity with event writes, + but skip the writable-calendar gate — see finish line 5), translate + scope + occurrence id into the sync command target, reuse + `submitCommandOrThrow`. +3. Web: `EventApi.rsvpEvent`, mutation with optimistic status update + (`useEventMutations` patterns), `RsvpControl` component gated on + self-in-attendees (compare against the calendar's account email), + scope dialog for recurring. +4. RTL + MSW tests; controller `.test.ts`/`.db.test.ts`; a11y sweep of + the segmented control. +5. Run the finish-line checks. + +## Acceptance tests + +- **Normal:** needsAction → Accept on a single event; Decline "this + event" on a recurring event posts the occurrence id. +- **Incomplete input:** event with attendees but no self match → + control hidden; invalid status → 400. +- **Tool failure:** POST 503 → optimistic status rolls back via the + existing error surface. +- **Policy:** RSVP works on a viewer-access calendar; organizer can + RSVP on their own event. + +## Evidence + +Recorded 2026-08-26 (implementer: manager-loop session — TAKEOVER: the +prior worker implemented most of this WP then hit a usage limit mid-way +through the EventForm gating tests; its uncommitted tree was reviewed +critically, kept, validated, and shipped — the only fixes needed were +formatter-only, via `bun lint:fix`, on 4 new test files): + +```text +commands run: bun test:backend (full, via test-mongo-env with the + scratchpad-only IPv4 listen shim — same environment note as WP-02/03/07, + never committed); bun test:web; bun test:core (regression — no core + contract changes in this WP; RsvpEventInputSchema shipped in WP-01); + bunx playwright test e2e/accessibility --timeout=180000; bun run + type-check; bun lint (after bun lint:fix, formatting only); bun knip. + All re-run on the final tree. +test:backend / test:web / test:a11y result: + - test:backend: 415 pass, 1 skip, 8 fail (52 files) — the 8 (GET + /api/config x3, UserController x5) are the IDENTICAL pre-existing + WP-03/WP-06 baseline (container env); this WP adds 9 new passing + tests (5 toRsvpSubmitRequest, 4 controller rsvp) and no failures. + - test:web: 2407 pass, 0 fail (319 files; +19 tests / +3 files over + WP-06's 2388) — new suites RsvpControl.test.tsx (8: labelled + radiogroup with current answer checked, unanswered = none checked, + hidden without self match, single event answers immediately with no + dialog, occurrence offers This Event / All Events and NEVER + this-and-following, All Events posts scope all, cancel sends + nothing, re-choosing the current answer sends nothing), + EventForm.rsvp.test.tsx (6 gating tests: shown on writable Google, + shown on viewer-access (reader) calendar while the rest of the form + stays read-only, shown for the organizer (checked Going), hidden + when self not an attendee, hidden on local events, hidden with no + attendees), useEventMutations.rsvp.test.tsx (5, MSW through the + real EventApi/BaseApi stack: wire bodies below; optimistic + self-only paint + 503 rollback; series-wide paint of master + + cached occurrences; settles via invalidation — the same path SSE + eventsChanged rides, pinned as the finish-line-4 regression test). + - test:a11y: all 7 pass as `bunx playwright test e2e/accessibility + --timeout=180000` (axe "incomplete" logged, not failures, per + docs/development/testing-playbook.md; the default-30s command is + not claimed green — same container-slowness note as WP-04/06). + LOUD NOTE: the axe e2e harness runs the anonymous local-mode app, + where no Google invitation can exist for RsvpControl to mount on, + so the control itself is NOT axe-swept; its accessibility contract + is pinned in RsvpControl.test.tsx via RTL role/name semantics + (aria-labelledby radiogroup "Going?", three named radios with + checked state, sr-only inputs with peer focus-visible rings — + the same pattern as the shipped RecurrenceScopeDialog). +occurrence-scope payload proof: + - Backend (event-command.translation.test.ts "addresses one + occurrence for scope single on a composite id"): composite id + `::2026-07-21T15:00:00.000Z` + browser input + {responseStatus: "declined", scope: "single"} translates to + request.eventId = (bare series id), expectedVersion null, + input {kind: "rsvp", responseStatus: "declined", scope: "this", + recurrenceId: "2026-07-21T15:00:00.000Z"} — the WP-07 executor's + per-occurrence target. Scope "all" on the same composite id drops + the recurrenceId (scope "all", recurrenceId null) and targets the + master; "thisAndFollowing" is never mintable (no code path). + - Web (RsvpControl.test.tsx "offers This Event / All Events …"): + Decline → dialog (nothing on the wire yet) → Ok posts to + /event//rsvp with body + {responseStatus: "declined", scope: "single"} — the composite id + rides the URL, so the backend decode addresses exactly that + occurrence. Controller test pins the decoded pass-through + end-to-end (same input shape reaches submitCommand). + - Idempotency: key = hash of event + status + scope (+ decoded + recurrenceId), nonce-free — replaying the same answer reuses the + key; changing answer, scope, or target mints a new one (tested). +type-check / lint / knip result: all exit 0. lint: 0 errors, 10 + pre-existing warnings (untouched files). knip: no findings + (pre-existing .css configuration hint only). +deltas from spec (if any): + - Route is POST /api/event/:id/rsvp (singular), matching the existing + /api/event/:id write routes — the WP's "/api/events/:id/rsvp" + spelling followed no existing route (README: prefer the code). + - The endpoint answers 204 No Content, not "the updated event": the + sync command outcome carries no event content and the sync client + has no event-by-id lookup. The web is optimistic (self-entry + rewrite) and settles the provider-confirmed list via the SSE-backed + invalidation — the same way it discards create/replace's + synthesized bodies. Auth/billing/maintenance parity with the other + writes; deliberately NO writable-calendar gate (finish line 5) — + pinned by a controller test whose sync-client stub has no + listCalendars at all. + - Scope dialog fires only for an OCCURRENCE of a series. A series-base + answer submits scope "all" directly: a base id carries no + recurrenceId, so "this event" is not representable for it (the + translation would coerce it to the series anyway) and offering the + choice would be a lie. Single events skip the dialog (pinned). + - The rsvp mutation calls EventApi directly, bypassing the event + repository: RSVP exists only for provider-backed events (control + hidden on local calendars) and must skip the read-only target gate; + the reconnect-required block stays. Series-wide answers serialize + against the series write key like scope-"all" replaces and + optimistically paint the master + every cached occurrence. + - Self-identification is the calendar's accountEmail, + case-insensitive, in both the gate and the optimistic rewrite — + the same single mechanism sync uses (alias wart stands, WP-09). + - EventForm reads the event live from the query cache (useEventById) + for the RSVP control and EventDetailsSection, falling back to the + draft snapshot: this is what makes the optimistic answer and other + attendees' SSE-delivered RSVP changes paint without reopening the + form. + - needsAction is 400 INVALID_INPUT at the route (strict Zod parse, no + sync call — a user answers, they don't un-answer); sync's typed + unsupportedCapability refusals surface as 403 UNSUPPORTED_OPERATION + (both tested). + - Environment notes (unchanged from WP-02/03/07): IPv6-disabled + kernel → scratchpad-only IPv4 preload shim for test:backend via + BUN_OPTIONS (never committed); bun 1.3.11 vs pinned 1.3.14 (harness + warns); Playwright chromium installed via `bunx playwright install + chromium` for the a11y run. +``` + +## Out of scope + +- Notifying the user of *incoming* invitations (inbox/badge UX) — the + event simply appears via sync +- Propose-new-time, RSVP comments/notes + +## Risks + +- Self-identification uses the calendar's account email (alias wart — + named in WP-09 docs); don't invent a second matching mechanism. +- The scope dialog must not fire for single events; snapshot the + no-dialog path. + +## Handoff + +```yaml +task_id: WP-08 +from: +to: Implementer (backend + web) +status: +artifact: +evidence: +assumptions: +open_risks: +next_deadline: +``` + +## Session prompt + +```text +You are implementing WP-08 from +wip/attendee-support/WP-08-rsvp-surface.md in the Compass repo, on +branch claude/attendee-support-planning-nljgeg. Read +wip/attendee-support/README.md, TRACKING.md, and +00-context-and-invariants.md first, mark WP-08 running (owner + +started_at), push the ledger update, and do not start other WPs. WP-03 +and WP-07 must be done. + +Finish line: POST /api/events/:id/rsvp (status + scope, occurrence +targeting, idempotent); Going/Maybe/Decline control on invited events +with per-occurrence scope dialog for recurring; optimistic update + +rollback; allowed on viewer-access calendars; hidden when self not an +attendee; test:backend + test:web + test:a11y + type-check + lint + +knip green. Fill Evidence, update TRACKING.md, commit conventionally, +push. +``` diff --git a/wip/attendee-support/WP-09-e2e-docs-closeout.md b/wip/attendee-support/WP-09-e2e-docs-closeout.md new file mode 100644 index 0000000000..a7a1bfc7b6 --- /dev/null +++ b/wip/attendee-support/WP-09-e2e-docs-closeout.md @@ -0,0 +1,273 @@ +# WP-09 — E2E, docs, polish, pack closeout + +**task_id:** WP-09 +**status:** done +**owner:** Implementer (e2e + docs), then Verifier +**depends on:** WP-04, WP-06, WP-08 +**next owner after done:** human review of the integration PR + +## Why + +The feature is shipped in layers; this WP proves it end-to-end, writes +the durable documentation that replaces this pack, and closes the +ledger so the pack can be deleted after merge. + +Key files: + +- New `e2e/attendees/` Playwright specs (follow `e2e/timed/`, + `e2e/allday/`, and `e2e/oauth/` patterns; API stubbing via + `page.route("**/api/**")`) +- New `docs/features/attendees.md` (created by this WP) +- [`README.md`](../../README.md) — "Things you can't do in Compass + (yet)" still lists attendees; update the line honestly (reminders are + still missing) +- [`docs/README.md`](../../docs/README.md) Common Change Paths + + [`docs/development/feature-file-map.md`](../../docs/development/feature-file-map.md) + — add the attendee feature area +- [`AGENTS.md`](../../AGENTS.md) — remove this pack's Lookups line when + deleting the directory (deletion happens post-merge, not in this WP) + +## Finish line + +1. `e2e/attendees/` covers: add attendees + save with Send prompt + (payload asserted via route stub), suggestion picker with a stubbed + suggestions endpoint, RSVP control incl. per-occurrence scope + dialog. Suite green locally (`bun test:e2e`), evidence pasted. +2. `docs/features/attendees.md` exists: flow diagram (web → backend → + `/internal/commands` → Google), invitation-intent semantics, the + merge-by-email and replay rules, contacts consent flow, and the + named warts (fetch→patch race, alias-email self-match, organizer + auto-added on create). +3. Root README's "can't do yet" line updated; docs index and + feature-file-map reference the new page. +4. Repo-wide `bun run type-check`, `bun lint`, `bun knip` clean; the + five package suites green. +5. TRACKING.md shows WP-01..08 `done` with evidence; this WP flips to + `done` last; the closing PR comment (README protocol step 11) is + posted and the Routine disabled. + +## Steps + +1. Read the e2e patterns and existing specs; write the attendee specs + with stubbed `/api/**` routes (no real Google). +2. Write `docs/features/attendees.md`; update README + docs index + + feature-file-map. +3. Full verification sweep: all package suites, type-check, lint, knip, + `bun test:e2e`, `bun test:a11y`. +4. Fill Evidence everywhere; audit every WP's Evidence section is + non-empty and replayable; complete the ledger. +5. Post the closing PR comment; disable the manager Routine. + +## Acceptance tests + +- **Normal:** the three e2e specs pass headless on chromium. +- **Incomplete input:** e2e spec asserting an invalid email cannot + become a chip. +- **Tool failure:** missing Playwright Chromium → print the install + command and do not claim e2e passed (never a silent skip). +- **Policy:** README claims match shipped behavior — no overclaiming + (reminders still unsupported). + +## Evidence + +Recorded 2026-08-26 (implementer: manager-loop session — 2ND TAKEOVER: the +first takeover's e2e/attendees/ tree (`attendee-harness.ts`, +`attendee-editor.spec.ts`, `contact-suggestions.spec.ts`, `rsvp.spec.ts`, +`rsvp-control.spec.ts`, `debug.spec.ts`) was reviewed critically by running +it, not trusted. Findings and fixes below): + +```text +commands run: bunx playwright test e2e/attendees e2e/oauth e2e/accessibility + --timeout=180000 (chromium via bunx playwright install chromium); bun + test:core; bun test:web; bun test:sync / bun test:backend (via + --preload /ipv4-listen-shim.ts + test-mongo-env.ts — TEMPORARY, + never committed, same IPv6-listen environment workaround as WP-02/03/05/ + 06/07/08, this session's own copy); bun run type-check; bun lint (bun + lint:fix for formatting only, 3 files); bun knip. + +inherited-tree audit (kept / fixed / discarded): + - attendee-harness.ts: KEPT the fixture/route-stub shape, FIXED two real + bugs found by actually running the suite (both silent failures — no + thrown error, no request, no chip/answer): + 1. `__COMPASS_E2E_TEST__` makes SessionProvider skip the real session + check, so `useSession().authenticated` starts and stays false. The + inherited harness only set the REMEMBERED-auth localStorage flag + (which steers event.repository.source.store's local-vs-remote + choice) — it never called + `window.__COMPASS_E2E_HOOKS__.setAuthenticated(true)`, which + useCalendarsQuery's `calendarsQueryOptions(authenticated)` gates on + separately. Every fixture event was therefore fetched against the + synthesized ANONYMOUS local-calendar id and never appeared on the + grid (openEventForm timeouts on every spec). Fix mirrors the + already-shipped e2e/calendars/calendar-experience.spec.ts pattern. + 2. GET /api/user/metadata was stubbed to always answer + `{connectionState:"HEALTHY"}` with no connections, ignoring + `options.canSuggestContacts` — the initial e2e-store-bridge + injection got clobbered back to false by the next real metadata + refetch (refreshUserMetadata() re-fires on its own), so + AttendeeField never got a real suggestionSource and + captured.suggestionQueries stayed empty. Fixed by making the route + stub itself answer with the capability on every fetch. + 3. Added `dispatchClick` (DOM `element.click()`, same technique as the + existing `clickSave` in e2e/utils/event-test-utils.ts) and used it + for every button/radio inside OverlayPanel-based floating UI (Send/ + Don't send, RecurrenceScopeDialog "Ok"/"Cancel", the RSVP + Going/Maybe/Decline radios, the scope-dialog radios, the picked + suggestion option): Playwright's built-in `.click()` on these + silently lands on nothing between its actionability check and the + floating panel's re-render — no thrown error, no network call, no + state change — confirmed by isolating each click with an evaluate- + click vs. a keyboard-Enter control. + - attendee-editor.spec.ts, contact-suggestions.spec.ts: KEPT (payload + assertions and scenarios were sound), updated only for the two harness + fixes above. + - rsvp.spec.ts: KEPT (payload assertions and scenarios were sound; same + fixes applied). + - rsvp-control.spec.ts: DISCARDED — imported helpers + (buildTimedEvent/setupAttendeePage/objectId/composeOccurrenceId/ + OTHER_ORGANIZER_EMAIL) that do not exist in the current + attendee-harness.ts (a stale draft from an earlier, incompatible + harness API — TypeError at runtime, never ran). Its three scenarios + (single/occurrence/series-base RSVP) fully duplicate rsvp.spec.ts + against the current harness, so nothing was lost. + - debug.spec.ts: DELETED per instructions (scratch investigation using + page.waitForTimeout — forbidden sleeps, not a real spec). Its output + (captured API traffic + a mismatched calendarId) is what led directly + to harness fix #1 above. + No scope was trimmed dishonestly: all three finish-line flows (guest add + + Send/Don't-send payload, suggestion picker incl. min-length/debounce, + RSVP incl. per-occurrence scope dialog) run against genuine mounted + AttendeeField/RsvpControl components under simulated real auth — not + faked, not skipped. + +e2e/attendees result: 8 pass, 0 fail (4 files) — 3 runs in a row confirmed + no flake after the fixes above. attendee-editor.spec.ts (4): guest add + + Send prompt asserts the exact wire body (attendees replace-shape, no + responseStatus, invitation "all"); Don't send -> invitation "none"; + untouched save carries neither an attendees nor invitation key; invalid + email never becomes a chip (acceptance test "Incomplete input"). + contact-suggestions.spec.ts (1): 1-char query fires nothing across the + 250ms debounce window, 2-char query fires exactly one suggestions + request, picking a suggestion adds a named chip. rsvp.spec.ts (3): single + event answers immediately with no dialog; an occurrence offers This + Event/All Events (exactly 2 radios, never "following") and posts the + composite eventId::recurrenceId; a series-base occurrence answers "all" + and Cancel sends nothing. +e2e/oauth --timeout=180000 result: 2 pass, 1 pre-existing fail — the SAME + "finishes a saved Google sign-in callback" role=status strict-mode/ + spinner-timing failure WP-06/WP-08 already documented as environment- + timing, identical on this container regardless of this WP's changes (file + untouched by this WP's diff). +e2e/accessibility --timeout=180000 result: 7 pass, 0 fail — unchanged from + WP-04/06/08 (RsvpControl/AttendeeField still do not mount under this + harness's anonymous local-mode app; their accessibility contracts stay + pinned via RTL semantics in RsvpControl.test.tsx/AttendeeField.test.tsx, + as WP-08 already documented). e2e/attendees/ is what actually axe-sweeps + neither component — it drives them under a real signed-in DOM instead, + which is a different, complementary kind of coverage, not a a11y sweep. +tool-failure check: chromium was not preinstalled in this container; + `bunx playwright install chromium` was run and printed progress (no + silent skip) before any spec ran. +test:core result: 618 pass, 0 fail (37 files) — unchanged (WP-09 makes no + core contract changes). +test:web result: 2407 pass, 0 fail (319 files) — unchanged from WP-08 (no + web source changes, only e2e/docs). +test:sync result: 1072 pass, 0 fail (81 files) — unchanged from WP-07; + safety-canary suite re-run standalone: 19 pass, 0 fail. +test:backend result: 415 pass, 1 skip, 8 fail (52 files) — the IDENTICAL + pre-existing baseline (GET /api/config x3, UserController x5) documented + since WP-03/06/08 (container env; re-confirmed via + `--preload ipv4-listen-shim.ts` + test-mongo-env.ts, the same temporary, + never-committed IPv6-listen workaround used by every prior sync/backend + WP in this pack — this sandbox cannot bind net.Server to "::"). +type-check / lint / knip result: all exit 0. lint: 0 errors, 10 + pre-existing warnings (untouched files, identical set every prior WP + documented). knip: no findings (pre-existing .css configuration hint + only). +docs paths written: + - docs/features/attendees.md (new): flow diagram (web -> backend -> + /internal/commands -> Google + contacts side-channel), invitation- + intent semantics, merge-by-email + replay rules, contacts consent flow, + RSVP semantics (self-entry rewrite, per-occurrence vs. series), the + three named warts, and an explicit note on the e2e coverage boundary. + Every claim in it cites the specific passing test(s) that back it. + - README.md: "Things you can't do in Compass (yet)" no longer lists + attendees (moved to the can-do list: "Add/remove event attendees and + RSVP to invites, with optional Google-contact suggestions"); reminders + and meeting links stay listed as not-yet, honestly (no reminders work + shipped in this pack). + - docs/README.md: added an "Attendees, contact suggestions, or RSVP" + Common Change Paths row pointing at the new doc + feature-file-map + anchor. + - docs/development/feature-file-map.md: added an "Attendees, Contacts, + And RSVP" section (key files across core/web/backend/sync + e2e/) that + points at docs/features/attendees.md. +ledger audit result: WP-01 through WP-08 all `done` in TRACKING.md, each + with a non-empty, replayable Evidence section in both TRACKING.md and its + own WP file (spot-read all eight WP files' Evidence sections directly, + not just the TRACKING.md summary cells). No row was `waiting` or + `escalated`. This WP-09 row flips to `done` last, after this Evidence + section and the final verification re-run below. +``` + +## Out of scope + +- Deleting `wip/attendee-support/` (post-merge, per README deletion + criteria) +- Staging QA (`/qa-test-staging` runs post-deploy, human-triggered) + +## Risks + +- E2e flake from the scope dialog timing — use semantic locators and + Playwright auto-waiting, no sleeps. +- Do not let doc claims drift ahead of code: verify each documented + behavior against a passing test before writing it. + +## Handoff + +```yaml +task_id: WP-09 +from: manager-loop (2nd takeover session) +to: human review of the integration PR +status: done +artifact: + - e2e/attendees/attendee-harness.ts + - e2e/attendees/attendee-editor.spec.ts + - e2e/attendees/contact-suggestions.spec.ts + - e2e/attendees/rsvp.spec.ts + - docs/features/attendees.md +evidence: see Evidence section above and TRACKING.md WP-09 row +assumptions: + - The e2e/attendees suite simulates signed-in Google state on the same + anonymous-local-mode Playwright web server every other e2e suite uses + (no real backend); this is the same sanctioned pattern as e2e/oauth and + e2e/calendars/calendar-experience.spec.ts, not a new mechanism. + - The scratchpad IPv4-listen shim used for test:sync/test:backend in this + session is local-only and was never committed, matching every prior + sync/backend WP's environment note. +open_risks: + - The 8 pre-existing backend failures (GET /api/config x3, UserController + x5) and the 1 pre-existing oauth spinner-timing failure are + container-environment issues unrelated to any code in this pack, + documented identically since WP-01/WP-03/WP-06. +next_deadline: none — pack complete pending human PR review +``` + +## Session prompt + +```text +You are implementing WP-09 from +wip/attendee-support/WP-09-e2e-docs-closeout.md in the Compass repo, on +branch claude/attendee-support-planning-nljgeg. Read +wip/attendee-support/README.md, TRACKING.md, and +00-context-and-invariants.md first, mark WP-09 running (owner + +started_at), push the ledger update, and do not start other WPs. WP-04, +WP-06, and WP-08 must be done. + +Finish line: e2e/attendees/ specs (editor + suggestions + rsvp incl. +scope dialog) green; docs/features/attendees.md with flow, semantics, +and named warts; README + docs index updated; full repo sweep green; +ledger complete. Then post the closing PR comment requesting review and +disable the manager Routine. Fill Evidence, update TRACKING.md, commit +conventionally, push. +```