From 65e71124e7e880e650b151d52c721c18f8efbf76 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 5 Sep 2026 02:17:57 +0000 Subject: [PATCH 1/2] feat(web): gate attendees on capabilities and neutralize provider copy Replace provider === google attendee and default-target gates with canInviteAttendees/canWrite, and update user-facing copy outside auth to use the host name or "your calendar". Co-authored-by: Tyler Dane --- .agents/handoffs/3234.md | 26 ++++++++ docs/features/google-sync-and-sse-flow.md | 5 ++ .../booking/services/booking-page.service.ts | 4 +- .../controllers/event.controller.test.ts | 6 +- .../src/event/controllers/event.controller.ts | 15 +++-- packages/backend/src/event/event.error.ts | 2 +- packages/web/src/api/util/api.util.test.ts | 4 +- packages/web/src/api/util/api.util.ts | 2 +- .../booking/BookingSettingsSection.test.tsx | 3 +- .../web/src/calendars/calendar.util.test.ts | 12 ++++ packages/web/src/calendars/calendar.util.ts | 14 ++-- .../migrations/external/demo-data-seed.ts | 6 +- .../export-user-data.util.test.ts | 6 +- .../offline-data/export-user-data.util.ts | 4 +- .../src/common/utils/event/event.util.test.ts | 2 +- .../web/src/common/utils/event/event.util.ts | 4 +- .../DeleteAccountConfirmationDialog.test.tsx | 4 +- .../DeleteAccountConfirmationDialog.tsx | 2 +- .../EventForm/EventForm.attendees.test.tsx | 33 +++++++--- .../src/views/Forms/EventForm/EventForm.tsx | 17 ++--- .../Forms/EventForm/SendInvitationsDialog.tsx | 6 +- .../hooks/useSaveEventForm.attendees.test.tsx | 65 ++++++++++++++++++- .../src/views/Forms/hooks/useSaveEventForm.ts | 27 ++++++-- 23 files changed, 205 insertions(+), 64 deletions(-) create mode 100644 .agents/handoffs/3234.md diff --git a/.agents/handoffs/3234.md b/.agents/handoffs/3234.md new file mode 100644 index 0000000000..e0b67b30e8 --- /dev/null +++ b/.agents/handoffs/3234.md @@ -0,0 +1,26 @@ +--- +schema_version: 1 +task_id: "3234" +from: Implementer +to: GitHub +owner: GitHub +status: implementing +artifact: + - path: packages/web/src/views/Forms/EventForm/EventForm.tsx + - path: packages/web/src/views/Forms/hooks/useSaveEventForm.ts + - path: packages/web/src/calendars/calendar.util.ts + - path: packages/backend/src/event/controllers/event.controller.ts + - path: packages/backend/src/booking/services/booking-page.service.ts +evidence: [] +assumptions: + - "RSVP still keys off accountEmail, not canInviteAttendees, so viewer-access calendars can answer invitations." + - "Google Meet and Google Maps product names stay; they are host products, not calendar-provider copy." +open_risks: [] +next_deadline: 2026-09-05T12:00:00Z +retry: 0 +approval: allow +waiting_on: null +escalation: null +--- + +P0 WP-09: capability gates replace provider checks; provider-neutral copy pass. diff --git a/docs/features/google-sync-and-sse-flow.md b/docs/features/google-sync-and-sse-flow.md index 9d54fafed2..3a5701e9ee 100644 --- a/docs/features/google-sync-and-sse-flow.md +++ b/docs/features/google-sync-and-sse-flow.md @@ -1,5 +1,10 @@ # Google Sync And Server-Sent Events (SSE) +Provider-neutral contracts, connection API, and capability gates live in +the [calendar providers spec](./calendar-providers.md). This document is the +Google-shaped sync and SSE walkthrough; new provider work should follow that +spec instead of adding Google-only branches here. + Google Calendar sync is owned entirely by the standalone **Sync service** (`packages/sync`) — the backend has no Google API calls or sync logic of its own. The backend's role is: proxy sync-related reads/writes to Sync, poll diff --git a/packages/backend/src/booking/services/booking-page.service.ts b/packages/backend/src/booking/services/booking-page.service.ts index faf7c11a96..9648d9edeb 100644 --- a/packages/backend/src/booking/services/booking-page.service.ts +++ b/packages/backend/src/booking/services/booking-page.service.ts @@ -67,7 +67,7 @@ const assertHealthyGoogleForEnable = async (userId: string): Promise => { if (connection.connectionState !== "HEALTHY") { throw bookingError( "GOOGLE_NOT_CONNECTED", - "Connect a healthy Google account before enabling booking", + "Connect a healthy calendar account before enabling booking", ); } }; @@ -102,7 +102,7 @@ const assertCalendarsForEnable = async ( if (!destination) { throw bookingError( "DESTINATION_NOT_WRITABLE", - "Destination calendar must be a writable Google calendar", + "Destination calendar must be a writable calendar", ); } diff --git a/packages/backend/src/event/controllers/event.controller.test.ts b/packages/backend/src/event/controllers/event.controller.test.ts index d783d35b0d..8eaa0b5709 100644 --- a/packages/backend/src/event/controllers/event.controller.test.ts +++ b/packages/backend/src/event/controllers/event.controller.test.ts @@ -384,7 +384,7 @@ describe("EventController", () => { expect(json).toHaveBeenCalledWith({ code: "ATTENDEES_UNSUPPORTED", message: - "Guests can only be added to events on a writable Google calendar", + "Guests can only be added to events on a writable calendar that can invite attendees", retryable: false, }); expect(submitCommand).not.toHaveBeenCalled(); @@ -651,7 +651,7 @@ describe("EventController", () => { expect(json).toHaveBeenCalledWith({ code: "GOOGLE_REVOKED", message: - "Google Calendar access expired or was revoked. Reconnect Google Calendar in Compass to resume syncing.", + "Calendar access expired or was revoked. Reconnect your calendar in Compass to resume syncing.", retryable: false, }); }); @@ -683,7 +683,7 @@ describe("EventController", () => { expect(json).toHaveBeenCalledWith({ code: "UNSUPPORTED_OPERATION", message: - "Google doesn't allow this change for this event (for example birthday or holiday events). Try deleting the entire series, or manage it in Google Calendar.", + "This calendar doesn't allow this change for this event (for example birthday or holiday events). Try deleting the entire series, or manage it in your calendar.", retryable: false, }); }); diff --git a/packages/backend/src/event/controllers/event.controller.ts b/packages/backend/src/event/controllers/event.controller.ts index 37570d0419..9831ed791b 100644 --- a/packages/backend/src/event/controllers/event.controller.ts +++ b/packages/backend/src/event/controllers/event.controller.ts @@ -243,10 +243,11 @@ const readAllFromSync = async (userId: string, query: EventListQuery) => { // // `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. +// degrades to "the principal has at least one writable calendar that can +// invite attendees": the browser only offers the editor on the event's own +// writable inviting 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, @@ -271,7 +272,7 @@ const assertAttendeesSupported = async ( if (!supported) { throw eventMutationError( "ATTENDEES_UNSUPPORTED", - "Guests can only be added to events on a writable Google calendar", + "Guests can only be added to events on a writable calendar that can invite attendees", ); } }; @@ -288,7 +289,7 @@ const mapSyncFailure = (reason: SyncCommandFailureReason) => { case "authorizationRevoked": return eventMutationError( "GOOGLE_REVOKED", - "Google Calendar access expired or was revoked. Reconnect Google Calendar in Compass to resume syncing.", + "Calendar access expired or was revoked. Reconnect your calendar in Compass to resume syncing.", ); case "unsupportedCapability": // The provider declined the operation for this specific event (e.g. @@ -297,7 +298,7 @@ const mapSyncFailure = (reason: SyncCommandFailureReason) => { // PROVIDER_FAILURE's retryable 502. return eventMutationError( "UNSUPPORTED_OPERATION", - "Google doesn't allow this change for this event (for example birthday or holiday events). Try deleting the entire series, or manage it in Google Calendar.", + "This calendar doesn't allow this change for this event (for example birthday or holiday events). Try deleting the entire series, or manage it in your calendar.", ); case "permanentProviderError": return eventMutationError( diff --git a/packages/backend/src/event/event.error.ts b/packages/backend/src/event/event.error.ts index e80a45c57d..bb6b621566 100644 --- a/packages/backend/src/event/event.error.ts +++ b/packages/backend/src/event/event.error.ts @@ -35,7 +35,7 @@ const STATUS_BY_CODE: Record = { // 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. + // writable calendar that can invite attendees, and retrying cannot change that. ATTENDEES_UNSUPPORTED: Status.FORBIDDEN, }; diff --git a/packages/web/src/api/util/api.util.test.ts b/packages/web/src/api/util/api.util.test.ts index b29cfc9aa3..55a7f9e53e 100644 --- a/packages/web/src/api/util/api.util.test.ts +++ b/packages/web/src/api/util/api.util.test.ts @@ -337,7 +337,7 @@ describe("handleErrorResponse", () => { expect(onGoogleRevoked).not.toHaveBeenCalled(); }); - it("fails clearly when the Google revocation handler is not configured", async () => { + it("fails clearly when the calendar revocation handler is not configured", async () => { const error = createApiError({ data: { code: "GOOGLE_REVOKED" }, status: Status.UNAUTHORIZED, @@ -345,7 +345,7 @@ describe("handleErrorResponse", () => { await expect( handleErrorResponse(error, { onGoogleRevoked: undefined }), - ).rejects.toThrow("Google revocation handler is not configured"); + ).rejects.toThrow("Calendar revocation handler is not configured"); }); it("does not sign the user out on a 404 from a data endpoint", async () => { diff --git a/packages/web/src/api/util/api.util.ts b/packages/web/src/api/util/api.util.ts index bc0fe7d7cd..680a851274 100644 --- a/packages/web/src/api/util/api.util.ts +++ b/packages/web/src/api/util/api.util.ts @@ -205,7 +205,7 @@ export const handleErrorResponse = async ( getApiErrorCode(error) === "GOOGLE_REVOKED" ) { if (!onGoogleRevoked) { - throw new Error("Google revocation handler is not configured"); + throw new Error("Calendar revocation handler is not configured"); } onGoogleRevoked({ diff --git a/packages/web/src/booking/BookingSettingsSection.test.tsx b/packages/web/src/booking/BookingSettingsSection.test.tsx index 3da91e5bdb..6b3b7c6027 100644 --- a/packages/web/src/booking/BookingSettingsSection.test.tsx +++ b/packages/web/src/booking/BookingSettingsSection.test.tsx @@ -1189,7 +1189,8 @@ describe("BookingSettingsSection", () => { ctx.status(403), ctx.json({ code: "GOOGLE_NOT_CONNECTED", - message: "Connect a healthy Google account before enabling booking", + message: + "Connect a healthy calendar account before enabling booking", }), ), ), diff --git a/packages/web/src/calendars/calendar.util.test.ts b/packages/web/src/calendars/calendar.util.test.ts index 82637eab2f..493fbdb128 100644 --- a/packages/web/src/calendars/calendar.util.test.ts +++ b/packages/web/src/calendars/calendar.util.test.ts @@ -111,6 +111,18 @@ describe("getDefaultTargetCalendar", () => { ); }); + it("prefers a writable microsoft primary the same way", () => { + const local = makeCalendar({ provider: "local" }); + const primaryMicrosoft = makeCalendar({ + provider: "microsoft", + isPrimary: true, + id: "507f1f77bcf86cd799439014" as Calendar["id"], + }); + expect(getDefaultTargetCalendar([local, primaryMicrosoft])).toBe( + primaryMicrosoft, + ); + }); + it("skips a reconnect-required account when choosing the default target", () => { const brokenPrimary = makeCalendar({ provider: "google", diff --git a/packages/web/src/calendars/calendar.util.ts b/packages/web/src/calendars/calendar.util.ts index fa6b3589fc..5e5694af60 100644 --- a/packages/web/src/calendars/calendar.util.ts +++ b/packages/web/src/calendars/calendar.util.ts @@ -217,14 +217,20 @@ export interface DefaultTargetCalendarOptions { reconnectRequiredEmails?: ReadonlySet | readonly string[]; } -const isWritableGoogleCalendar = ( +const isWritableProviderCalendar = ( calendar: Calendar, reconnectRequiredEmails: ReadonlySet | null, ): boolean => - calendar.provider === "google" && + calendar.provider !== "local" && calendar.capabilities.canWrite && !calendarNeedsReconnect(calendar, reconnectRequiredEmails); +/** True when the write path can deliver a guest list for this calendar. */ +export const canInviteOnCalendar = (calendar: Calendar | undefined): boolean => + Boolean( + calendar?.capabilities.canInviteAttendees && calendar.capabilities.canWrite, + ); + /** * Where a new event lands: the user's chosen default if it is still usable, * else the primary calendar of the oldest-connected account, else the local @@ -248,7 +254,7 @@ export function getDefaultTargetCalendar( ? calendars.find((calendar) => calendar.id === preferredCalendarId) : undefined; // The local calendar is a valid explicit choice while disconnected, even - // though it is not a writable *Google* calendar. + // though it is not a writable *provider* calendar. if ( preferred?.capabilities.canWrite && !calendarNeedsReconnect(preferred, reconnectRequiredEmails) && @@ -260,7 +266,7 @@ export function getDefaultTargetCalendar( const primaries = calendars.filter( (calendar) => calendar.isPrimary && - isWritableGoogleCalendar(calendar, reconnectRequiredEmails), + isWritableProviderCalendar(calendar, reconnectRequiredEmails), ); const byConnectionOrder = accountEmailOrder .map((email) => diff --git a/packages/web/src/common/storage/migrations/external/demo-data-seed.ts b/packages/web/src/common/storage/migrations/external/demo-data-seed.ts index 6ad73c99ed..60589100d4 100644 --- a/packages/web/src/common/storage/migrations/external/demo-data-seed.ts +++ b/packages/web/src/common/storage/migrations/external/demo-data-seed.ts @@ -139,8 +139,8 @@ function generateDemoData() { timeZone, }, // Showcases the meeting-link and attendee UI (normally only populated - // from a synced Google event) for first-time users who haven't - // connected Google yet. + // from a synced event) for first-time users who haven't connected a + // calendar yet. conference: { url: "https://meet.google.com/abc-defg-hij", label: "Google Meet", @@ -172,7 +172,7 @@ function generateDemoData() { createEventRecord({ title: "Try Compass", description: - "Welcome! Click any empty time slot to create an event, or press C. When you're ready to sync Google Calendar, use the Connect Google Calendar button in the sidebar.", + "Welcome! Click any empty time slot to create an event, or press C. When you're ready to sync your calendar, use the connect button in the sidebar.", schedule: { kind: "timed", start: todayAt(10, 0), diff --git a/packages/web/src/common/storage/offline-data/export-user-data.util.test.ts b/packages/web/src/common/storage/offline-data/export-user-data.util.test.ts index f26f6d848a..63f016036e 100644 --- a/packages/web/src/common/storage/offline-data/export-user-data.util.test.ts +++ b/packages/web/src/common/storage/offline-data/export-user-data.util.test.ts @@ -49,13 +49,13 @@ describe("collectExportData", () => { expect(typeof result.exportedAt).toBe("string"); }); - it("explains what the export contains and why Google-synced events are absent", async () => { + it("explains what the export contains and why synced events are absent", async () => { const result = await collectExportData(); expect(result.about.whatThisIs.toLowerCase()).toContain("indexeddb"); - expect(result.about.whatThisIs.toLowerCase()).toContain("google calendar"); + expect(result.about.whatThisIs.toLowerCase()).toContain("calendar dump"); expect(result.about.events.toLowerCase()).toContain("locally"); - expect(result.about.events.toLowerCase()).toContain("google calendar"); + expect(result.about.events.toLowerCase()).toContain("your calendar"); expect(result.about.tasks.toLowerCase()).toContain("tasks"); }); diff --git a/packages/web/src/common/storage/offline-data/export-user-data.util.ts b/packages/web/src/common/storage/offline-data/export-user-data.util.ts index 64811cf8bc..60d1bfd3a8 100644 --- a/packages/web/src/common/storage/offline-data/export-user-data.util.ts +++ b/packages/web/src/common/storage/offline-data/export-user-data.util.ts @@ -8,9 +8,9 @@ import { const EXPORT_ABOUT = { whatThisIs: - "Snapshot of data Compass stores in this browser (IndexedDB). It is not a full account or Google Calendar dump.", + "Snapshot of data Compass stores in this browser (IndexedDB). It is not a full account or calendar dump.", events: - "Only calendar events still stored locally in this browser. If you connected Google Calendar, those events live in Google Calendar (and on Compass's servers when signed in), so they will not appear here.", + "Only calendar events still stored locally in this browser. If you connected a calendar, those events live in your calendar (and on Compass's servers when signed in), so they will not appear here.", tasks: "Legacy to-do items from a Tasks feature we removed. Any still retained in this browser are listed below; they are cleared after a successful export.", someday: diff --git a/packages/web/src/common/utils/event/event.util.test.ts b/packages/web/src/common/utils/event/event.util.test.ts index 23f375bd62..50e816f09b 100644 --- a/packages/web/src/common/utils/event/event.util.test.ts +++ b/packages/web/src/common/utils/event/event.util.test.ts @@ -208,7 +208,7 @@ describe("handleError", () => { expect(mockCaptureException).not.toHaveBeenCalled(); expect(mocks.error).toHaveBeenCalledTimes(1); const [message] = mocks.error.mock.calls[0] ?? []; - expect(message).toContain("Google doesn't allow this change"); + expect(message).toContain("This calendar doesn't allow this change"); expect(message).not.toBe( "Something went wrong behind the scenes. Please try again later.", ); diff --git a/packages/web/src/common/utils/event/event.util.ts b/packages/web/src/common/utils/event/event.util.ts index 2701fd75e0..a4bfa69f67 100644 --- a/packages/web/src/common/utils/event/event.util.ts +++ b/packages/web/src/common/utils/event/event.util.ts @@ -224,13 +224,13 @@ const MUTATION_ERROR_TOAST_MESSAGES: Partial< Record > = { UNSUPPORTED_OPERATION: - "Google doesn't allow this change for this event (like birthdays or holidays). Try deleting the entire series, or manage it in Google Calendar.", + "This calendar doesn't allow this change for this event (like birthdays or holidays). Try deleting the entire series, or manage it in your calendar.", CALENDAR_READ_ONLY: "This calendar is read-only, so its events can't be changed from Compass.", RECURRENCE_CONFLICT: "This event was changed somewhere else. Refresh to load the latest version, then try again.", GOOGLE_REVOKED: - "Google Calendar access expired or was revoked. Reconnect Google Calendar in Compass to resume syncing.", + "Calendar access expired or was revoked. Reconnect your calendar in Compass to resume syncing.", }; const showCatchallToast = (message: string) => diff --git a/packages/web/src/components/DeleteAccountConfirmation/DeleteAccountConfirmationDialog.test.tsx b/packages/web/src/components/DeleteAccountConfirmation/DeleteAccountConfirmationDialog.test.tsx index 6d90bcc322..db7232cb0c 100644 --- a/packages/web/src/components/DeleteAccountConfirmation/DeleteAccountConfirmationDialog.test.tsx +++ b/packages/web/src/components/DeleteAccountConfirmation/DeleteAccountConfirmationDialog.test.tsx @@ -41,11 +41,11 @@ describe("DeleteAccountConfirmationDialog", () => { expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); }); - it("tells the user their Google Calendar data is left alone", () => { + it("tells the user their connected calendars are left alone", () => { setup(); expect(screen.getByRole("dialog")).toHaveTextContent( - /Your Google Calendar is not affected/i, + /Your connected calendars are not affected/i, ); }); diff --git a/packages/web/src/components/DeleteAccountConfirmation/DeleteAccountConfirmationDialog.tsx b/packages/web/src/components/DeleteAccountConfirmation/DeleteAccountConfirmationDialog.tsx index 29c8012685..7bae6a4d5f 100644 --- a/packages/web/src/components/DeleteAccountConfirmation/DeleteAccountConfirmationDialog.tsx +++ b/packages/web/src/components/DeleteAccountConfirmation/DeleteAccountConfirmationDialog.tsx @@ -10,7 +10,7 @@ export const DELETE_ACCOUNT_PHRASE = "Delete my Compass account"; const INTRO_TEXT = [ "This deletes your Compass account and data: your calendars, events, and settings. It can't be undone.", "If you have a Compass trial or subscription, it is canceled immediately and your saved payment details are removed. Previous payments aren't refunded automatically.", - "Your Google Calendar is not affected. Nothing there gets deleted, Compass just loses its access to it.", + "Your connected calendars are not affected. Nothing there gets deleted, Compass just loses its access to them.", ].join("\n\n"); interface DeleteAccountConfirmationDialogProps { diff --git a/packages/web/src/views/Forms/EventForm/EventForm.attendees.test.tsx b/packages/web/src/views/Forms/EventForm/EventForm.attendees.test.tsx index 19c7bd6c3e..c0205daf7f 100644 --- a/packages/web/src/views/Forms/EventForm/EventForm.attendees.test.tsx +++ b/packages/web/src/views/Forms/EventForm/EventForm.attendees.test.tsx @@ -17,11 +17,11 @@ 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 wherever the whole -// write path can deliver a guest edit — a writable Google calendar and an -// event the user organizes — repeating events included, where the edit is -// series-wide and the field says so. Everyone else keeps the read-only guest -// list. Sibling to EventForm.readOnly.test.tsx (full form, no subcomponent -// mocks) for the same isolation reasons. +// write path can deliver a guest edit — a writable calendar that can invite +// attendees and an event the user organizes — repeating events included, where +// the edit is series-wide and the field says so. 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"; @@ -120,7 +120,7 @@ describe("EventForm attendee editor gating", () => { 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", () => { + it("renders the guest combobox (and not the read-only list) for an organized event on a writable calendar that can invite", () => { const calendar = makeCalendar(); const draft = editDraftOrThrow(makeMeetingEvent(calendar.id)); @@ -228,7 +228,16 @@ describe("EventForm attendee editor gating", () => { ).toBeInTheDocument(); }); - it("keeps the read-only guest list on a read-only calendar", () => { + it("shows the editor on a microsoft calendar that can invite attendees", () => { + const calendar = makeCalendar({ provider: "microsoft" }); + const draft = editDraftOrThrow(makeMeetingEvent(calendar.id)); + + renderEventForm(draft, [calendar]); + + expect(screen.getByRole("combobox", { name: "Guests" })).toBeEnabled(); + }); + + it("keeps the read-only guest list on a google calendar that cannot invite", () => { const calendar = makeCalendar({ access: "reader", capabilities: getCalendarCapabilities("reader"), @@ -249,10 +258,18 @@ describe("EventForm attendee editor gating", () => { ).toBeInTheDocument(); }); - it("shows no editor on a non-Google (local) calendar", () => { + it("shows no editor on a local calendar", () => { const calendar = makeCalendar({ provider: "local", accountEmail: undefined, + capabilities: { + canReadAvailability: true, + canReadDetails: true, + canWrite: true, + canManage: false, + canWatchEvents: false, + canInviteAttendees: false, + }, }); const event = createMockEvent({ calendarId: calendar.id, diff --git a/packages/web/src/views/Forms/EventForm/EventForm.tsx b/packages/web/src/views/Forms/EventForm/EventForm.tsx index 59393ee2cd..f1c21ee3d2 100644 --- a/packages/web/src/views/Forms/EventForm/EventForm.tsx +++ b/packages/web/src/views/Forms/EventForm/EventForm.tsx @@ -17,6 +17,7 @@ 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 { canInviteOnCalendar } from "@web/calendars/calendar.util"; import { isEventReadOnly, useCalendarLookup, @@ -293,8 +294,8 @@ export const EventForm: React.FC = memo( const liveDetails = rsvpSource?.content.kind === "details" ? rsvpSource.content : undefined; // Attendee-editor gate (WP-04): guests are editable everywhere the whole - // write path can deliver them — a writable Google calendar and an event - // the user organizes. Repeating events included: sync refuses + // write path can deliver them — a writable calendar that can invite and an + // event the user organizes. Repeating events included: sync refuses // per-occurrence guest replacements, so a guest change on any instance // is saved series-wide (resolveRecurrenceScopeDecision widens the whole // save to "all"), and the field says so. @@ -306,7 +307,7 @@ export const EventForm: React.FC = memo( ? calendarLookup.get(draft.values.calendarId) : defaultTargetCalendar : calendarLookup.get(draft.source.calendarId); - // Google auto-sets the organizer to the creating account, so "the user + // The host 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 @@ -318,10 +319,7 @@ export const EventForm: React.FC = memo( sourceDetails.organizer.email.toLowerCase() === attendeeCalendar.accountEmail.toLowerCase()); const showAttendeeEditor = - !isReadOnly && - attendeeCalendar?.provider === "google" && - attendeeCalendar.capabilities.canWrite && - organizesEvent; + !isReadOnly && canInviteOnCalendar(attendeeCalendar) && organizesEvent; const guestEditIsSeriesWide = draft.kind === "edit" && draft.source.recurrence.kind !== "single"; // RSVP gate (WP-08): show Going / Maybe / Decline when the calendar's @@ -330,10 +328,7 @@ export const EventForm: React.FC = memo( // 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 rsvpAccountEmail = attendeeCalendar?.accountEmail; const showRsvpControl = rsvpSource !== null && rsvpAccountEmail !== undefined && diff --git a/packages/web/src/views/Forms/EventForm/SendInvitationsDialog.tsx b/packages/web/src/views/Forms/EventForm/SendInvitationsDialog.tsx index 657f17c0d8..6b8e4d7ab7 100644 --- a/packages/web/src/views/Forms/EventForm/SendInvitationsDialog.tsx +++ b/packages/web/src/views/Forms/EventForm/SendInvitationsDialog.tsx @@ -12,10 +12,10 @@ type SendInvitationsDialogProps = { /** * 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 + * the guest set. Send (the default, focused on open) has the host 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. + * the form. Compass never sends email itself — the calendar host does. */ export function SendInvitationsDialog({ prompt }: SendInvitationsDialogProps) { const sendButtonRef = useRef(null); @@ -25,7 +25,7 @@ export function SendInvitationsDialog({ prompt }: SendInvitationsDialogProps) { return ( = {}): Event => ...overrides, }); -function createWrapper() { +function createWrapper(calendar: Calendar = googleCalendar()) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }); - queryClient.setQueryData(calendarQueryKeys.all, [googleCalendar()]); + queryClient.setQueryData(calendarQueryKeys.all, [calendar]); function Wrapper({ children }: PropsWithChildren) { return ( @@ -333,4 +333,65 @@ describe("useSaveEventForm guest edits", () => { expect(variables?.input.content.attendees).toHaveLength(2); expect(variables?.input.invitation).toBe("all"); }); + + it("keeps attendee edits for a microsoft calendar that can invite", () => { + const { queryClient, Wrapper } = createWrapper( + googleCalendar({ provider: "microsoft" }), + ); + 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); + }); + + expect(result.current.invitationPrompt).not.toBeNull(); + expect(result.current.invitationPrompt?.hostLabel).toBe("Microsoft"); + + act(() => { + result.current.invitationPrompt?.onSend(); + }); + + const variables = replaceVariables(queryClient); + expect(variables?.input.content.attendees).toHaveLength(2); + expect(variables?.input.invitation).toBe("all"); + }); + + it("drops a create guest edit when the target calendar cannot invite", () => { + const { queryClient, Wrapper } = createWrapper( + googleCalendar({ + access: "reader", + capabilities: getCalendarCapabilities("reader"), + }), + ); + 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).toBeNull(); + const variables = createVariables(queryClient); + expect(variables?.input.content).not.toContainKey("attendees"); + expect(variables?.input).not.toContainKey("invitation"); + }); }); diff --git a/packages/web/src/views/Forms/hooks/useSaveEventForm.ts b/packages/web/src/views/Forms/hooks/useSaveEventForm.ts index 837a6b3164..bb5662980e 100644 --- a/packages/web/src/views/Forms/hooks/useSaveEventForm.ts +++ b/packages/web/src/views/Forms/hooks/useSaveEventForm.ts @@ -1,7 +1,10 @@ import { useCallback, useMemo, useState } from "react"; +import { type Calendar } from "@core/types/calendar.contracts"; import { EventIdSchema } from "@core/types/domain-primitives"; import { type CreateEventInput } from "@core/types/event-command.contracts"; +import { providerDisplayName } from "@core/types/sync/identity.contracts"; import { useCalendarsQuery } from "@web/calendars/calendar.query"; +import { canInviteOnCalendar } from "@web/calendars/calendar.util"; import { useDefaultTargetCalendar } from "@web/calendars/useDefaultTargetCalendar"; import { RecurringEventUpdateScope } from "@web/common/types/web.event.types"; import { createObjectIdString } from "@web/common/utils/id/object-id.util"; @@ -27,8 +30,16 @@ export type EventInvitationPrompt = { onSend: () => void; onDontSend: () => void; onCancel: () => void; + hostLabel: string; } | null; +const INVITATION_HOST_LABEL: Record = { + google: providerDisplayName("google"), + microsoft: providerDisplayName("microsoft"), + apple: providerDisplayName("apple"), + local: "Your calendar", +}; + export function useSaveEventForm() { const closeEventForm = useCloseEventForm(); const { create, replace } = useEventMutations(); @@ -47,7 +58,7 @@ export function useSaveEventForm() { // 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, and + // editor only renders on writable calendars that can invite attendees, and // a guest-changed recurring edit is saved as "all" automatically, whether // it started from the series base or from one occurrence — so this only // defends replayed or hand-built drafts. @@ -76,11 +87,11 @@ export function useSaveEventForm() { return draft; } - // Create: guests only deliver to a writable Google calendar + // Create: guests only deliver to a writable calendar that can invite // (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) { + if (!canInviteOnCalendar(calendar)) { console.warn( "[useSaveEventForm] dropped guest edit: target calendar cannot deliver a guest list", ); @@ -186,7 +197,7 @@ export function useSaveEventForm() { 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 + // choice first — the host 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 }); @@ -201,6 +212,11 @@ export function useSaveEventForm() { const invitationPrompt: EventInvitationPrompt = useMemo(() => { if (!pendingInvitationSave) return null; const { draft, applyTo } = pendingInvitationSave; + const calendarId = + draft.kind === "create" + ? (draft.values.calendarId ?? defaultTargetCalendarId) + : draft.source.calendarId; + const calendar = calendars?.find((entry) => entry.id === calendarId); const resolve = (invitation: InvitationIntentValue) => { setPendingInvitationSave(null); commitSave(draft, applyTo, invitation); @@ -209,8 +225,9 @@ export function useSaveEventForm() { onSend: () => resolve("all"), onDontSend: () => resolve("none"), onCancel: () => setPendingInvitationSave(null), + hostLabel: INVITATION_HOST_LABEL[calendar?.provider ?? "local"], }; - }, [commitSave, pendingInvitationSave]); + }, [calendars, commitSave, defaultTargetCalendarId, pendingInvitationSave]); return { saveEventForm, fieldErrors, clearFieldErrors, invitationPrompt }; } From d51e48292417ff6742e8bf482bd654d92c74081d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 5 Sep 2026 02:23:28 +0000 Subject: [PATCH 2/2] chore(handoff): record p0 wp-09 verify pass Co-authored-by: Tyler Dane --- .agents/handoffs/3234.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.agents/handoffs/3234.md b/.agents/handoffs/3234.md index e0b67b30e8..7d96d50be7 100644 --- a/.agents/handoffs/3234.md +++ b/.agents/handoffs/3234.md @@ -4,14 +4,16 @@ task_id: "3234" from: Implementer to: GitHub owner: GitHub -status: implementing +status: verifying artifact: - path: packages/web/src/views/Forms/EventForm/EventForm.tsx - path: packages/web/src/views/Forms/hooks/useSaveEventForm.ts - path: packages/web/src/calendars/calendar.util.ts - path: packages/backend/src/event/controllers/event.controller.ts - path: packages/backend/src/booking/services/booking-page.service.ts -evidence: [] +evidence: + - command: bun run verify --strict + result: "VERDICT: PASS (test:web, test:backend:fast, type-check, lint, knip, test:a11y, test:e2e)" assumptions: - "RSVP still keys off accountEmail, not canInviteAttendees, so viewer-access calendars can answer invitations." - "Google Meet and Google Maps product names stay; they are host products, not calendar-provider copy."