Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .agents/handoffs/3234.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
schema_version: 1
task_id: "3234"
from: Implementer
to: GitHub
owner: GitHub
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/views/Forms/EventForm/SendInvitationsDialog.tsx
- path: packages/web/src/calendars/calendar.util.ts
- path: packages/web/src/common/utils/event/event.util.ts
- path: packages/web/src/components/DeleteAccountConfirmation/DeleteAccountConfirmationDialog.tsx
- path: packages/web/src/common/storage/offline-data/export-user-data.util.ts
- path: packages/web/src/common/storage/migrations/external/demo-data-seed.ts
- path: packages/backend/src/booking/services/booking-page.service.ts
- path: docs/features/google-sync-and-sse-flow.md
evidence:
- command: bun run verify --strict
result: "VERDICT: PASS"
- command: bun test:web EventForm.attendees useSaveEventForm.attendees
result: pass
assumptions:
- "RSVP account email uses any non-local provider calendar with accountEmail."
- "SendInvitationsDialog host label comes from providerDisplayName when known."
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.
5 changes: 5 additions & 0 deletions docs/features/google-sync-and-sse-flow.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Google Sync And Server-Sent Events (SSE)

> **Multi-provider note:** Google is the first connected calendar host in
> Compass. Product-wide provider behavior, capability gates, and connect flows
> are specified in [`calendar-providers.md`](./calendar-providers.md). This
> document covers the Google sync path and SSE wiring only.

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
Expand Down
4 changes: 2 additions & 2 deletions packages/backend/src/booking/services/booking-page.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ const assertHealthyGoogleForEnable = async (userId: string): Promise<void> => {
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",
);
}
};
Expand Down Expand Up @@ -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",
);
}

Expand Down
6 changes: 3 additions & 3 deletions packages/web/src/calendars/calendar.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,11 +217,11 @@ export interface DefaultTargetCalendarOptions {
reconnectRequiredEmails?: ReadonlySet<string> | readonly string[];
}

const isWritableGoogleCalendar = (
const isWritableProviderCalendar = (
calendar: Calendar,
reconnectRequiredEmails: ReadonlySet<string> | null,
): boolean =>
calendar.provider === "google" &&
calendar.provider !== "local" &&
calendar.capabilities.canWrite &&
!calendarNeedsReconnect(calendar, reconnectRequiredEmails);

Expand Down Expand Up @@ -260,7 +260,7 @@ export function getDefaultTargetCalendar(
const primaries = calendars.filter(
(calendar) =>
calendar.isPrimary &&
isWritableGoogleCalendar(calendar, reconnectRequiredEmails),
isWritableProviderCalendar(calendar, reconnectRequiredEmails),
);
const byConnectionOrder = accountEmailOrder
.map((email) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ function generateDemoData() {
// connected Google yet.
conference: {
url: "https://meet.google.com/abc-defg-hij",
label: "Google Meet",
label: "Video call",
},
organizer: { email: "avery@example.com", displayName: "Avery" },
attendees: [
Expand Down Expand Up @@ -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 Connect calendar in the sidebar.",
schedule: {
kind: "timed",
start: todayAt(10, 0),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()).not.toContain("google");
expect(result.about.events.toLowerCase()).toContain("locally");
expect(result.about.events.toLowerCase()).toContain("google calendar");
expect(result.about.events.toLowerCase()).toContain("calendar host");
expect(result.about.tasks.toLowerCase()).toContain("tasks");
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 with your calendar host (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:
Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/common/utils/event/event.util.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("Your calendar doesn't allow this change");
expect(message).not.toBe(
"Something went wrong behind the scenes. Please try again later.",
);
Expand Down
4 changes: 2 additions & 2 deletions packages/web/src/common/utils/event/event.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,13 +224,13 @@ const MUTATION_ERROR_TOAST_MESSAGES: Partial<
Record<EventMutationError["code"], string>
> = {
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.",
"Your calendar doesn't allow this change for this event (like birthdays or holidays). Try deleting the entire series, or manage it in your calendar app.",
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) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 calendar data is left alone", () => {
setup();

expect(screen.getByRole("dialog")).toHaveTextContent(
/Your Google Calendar is not affected/i,
/Your calendar is not affected/i,
);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 calendar is not affected. Nothing there gets deleted, Compass just loses its access to it.",
].join("\n\n");

interface DeleteAccountConfirmationDialogProps {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -249,10 +249,47 @@ describe("EventForm attendee editor gating", () => {
).toBeInTheDocument();
});

it("shows no editor on a non-Google (local) calendar", () => {
it("keeps the read-only guest list on a Google read-only calendar", () => {
const calendar = makeCalendar({
provider: "google",
access: "reader",
capabilities: {
...getCalendarCapabilities("reader"),
canInviteAttendees: false,
},
});
const draft = editDraftOrThrow(makeMeetingEvent(calendar.id));

renderEventForm(draft, [calendar]);

expect(
screen.queryByRole("combobox", { name: "Guests" }),
).not.toBeInTheDocument();
});

it("renders the guest combobox for a Microsoft calendar with canInviteAttendees", () => {
const calendar = makeCalendar({
provider: "microsoft",
capabilities: {
...getCalendarCapabilities("owner"),
canInviteAttendees: true,
},
});
const draft = editDraftOrThrow(makeMeetingEvent(calendar.id));

renderEventForm(draft, [calendar]);

expect(screen.getByRole("combobox", { name: "Guests" })).toBeEnabled();
});

it("shows no editor on a non-provider (local) calendar", () => {
const calendar = makeCalendar({
provider: "local",
accountEmail: undefined,
capabilities: {
...getCalendarCapabilities("owner"),
canInviteAttendees: false,
},
});
const event = createMockEvent({
calendarId: calendar.id,
Expand Down
6 changes: 3 additions & 3 deletions packages/web/src/views/Forms/EventForm/EventForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,7 @@ export const EventForm: React.FC<GridEventFormProps> = memo(
attendeeCalendar.accountEmail.toLowerCase());
const showAttendeeEditor =
!isReadOnly &&
attendeeCalendar?.provider === "google" &&
attendeeCalendar?.capabilities.canInviteAttendees === true &&
attendeeCalendar.capabilities.canWrite &&
organizesEvent;
const guestEditIsSeriesWide =
Expand All @@ -331,8 +331,8 @@ export const EventForm: React.FC<GridEventFormProps> = memo(
// Deliberately NOT gated on writability: answering an invitation is
// allowed on viewer-access calendars.
const rsvpAccountEmail =
attendeeCalendar?.provider === "google"
? attendeeCalendar.accountEmail
attendeeCalendar?.provider !== "local"
? attendeeCalendar?.accountEmail
: undefined;
const showRsvpControl =
rsvpSource !== null &&
Expand Down
14 changes: 10 additions & 4 deletions packages/web/src/views/Forms/EventForm/SendInvitationsDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,26 @@ 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
* affected guests (`invitation: "all"`); Don't send saves silently
* the guest set. Send (the default, focused on open) has the calendar 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 connected calendar host
* does, via sendUpdates or equivalent provider semantics.
*/
export function SendInvitationsDialog({ prompt }: SendInvitationsDialogProps) {
const sendButtonRef = useRef<HTMLButtonElement>(null);

if (!prompt) return null;

const host =
prompt.invitationEmailHost === "your calendar"
? "Your calendar"
: prompt.invitationEmailHost;

return (
<OverlayPanel
title="Send invitation emails?"
message="Google will email the affected guests about this event."
message={`${host} will email the affected guests about this event.`}
onDismiss={prompt.onCancel}
initialFocusRef={sendButtonRef}
variant="modal"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import { beforeEach, describe, expect, it } from "bun:test";

const calendarId = CalendarIdSchema.parse("cccccccccccccccccccccccc");

const googleCalendar = (overrides: Partial<Calendar> = {}): Calendar => ({
const providerCalendar = (overrides: Partial<Calendar> = {}): Calendar => ({
id: calendarId,
name: "Work",
description: "",
Expand All @@ -48,6 +48,8 @@ const googleCalendar = (overrides: Partial<Calendar> = {}): Calendar => ({
...overrides,
});

const googleCalendar = providerCalendar;

const meetingEvent = (overrides: Partial<Event> = {}): Event =>
createMockEvent({
calendarId,
Expand Down Expand Up @@ -245,6 +247,46 @@ describe("useSaveEventForm guest edits", () => {
expect(variables?.input.invitation).toBe("all");
});

it("keeps attendee edits for any provider with canInviteAttendees", () => {
const { queryClient, Wrapper } = createWrapper();
queryClient.setQueryData(calendarQueryKeys.all, [
providerCalendar({
provider: "microsoft",
capabilities: {
...getCalendarCapabilities("owner"),
canInviteAttendees: true,
},
}),
]);
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);
});
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(), {
Expand Down
Loading
Loading