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
28 changes: 28 additions & 0 deletions .agents/handoffs/3234.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
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/calendars/calendar.util.ts
- path: packages/backend/src/event/controllers/event.controller.ts
- path: packages/backend/src/booking/services/booking-page.service.ts
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."
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)

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
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
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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,
});
});
Expand Down Expand Up @@ -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,
});
});
Expand Down
15 changes: 8 additions & 7 deletions packages/backend/src/event/controllers/event.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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",
);
}
};
Expand All @@ -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.
Expand All @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion packages/backend/src/event/event.error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ const STATUS_BY_CODE: Record<EventMutationErrorCode, Status> = {
// 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,
};

Expand Down
4 changes: 2 additions & 2 deletions packages/web/src/api/util/api.util.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,15 +337,15 @@ 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,
});

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 () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/api/util/api.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
3 changes: 2 additions & 1 deletion packages/web/src/booking/BookingSettingsSection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}),
),
),
Expand Down
12 changes: 12 additions & 0 deletions packages/web/src/calendars/calendar.util.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
14 changes: 10 additions & 4 deletions packages/web/src/calendars/calendar.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,14 +217,20 @@ 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);

/** 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
Expand All @@ -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) &&
Expand All @@ -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) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
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 the connect button 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()).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");
});

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 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:
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("This 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.",
"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) =>
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 calendars are left alone", () => {
setup();

expect(screen.getByRole("dialog")).toHaveTextContent(
/Your Google Calendar is not affected/i,
/Your connected calendars are 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 connected calendars are not affected. Nothing there gets deleted, Compass just loses its access to them.",
].join("\n\n");

interface DeleteAccountConfirmationDialogProps {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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));

Expand Down Expand Up @@ -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"),
Expand All @@ -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,
Expand Down
Loading
Loading