diff --git a/packages/backend/src/calendar/controllers/calendar.controller.test.ts b/packages/backend/src/calendar/controllers/calendar.controller.test.ts index 5953c436d..5d26f7808 100644 --- a/packages/backend/src/calendar/controllers/calendar.controller.test.ts +++ b/packages/backend/src/calendar/controllers/calendar.controller.test.ts @@ -122,6 +122,42 @@ describe("CalendarController list", () => { createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }, + { + id: "507f1f77bcf86cd799439097", + tenantId: userId, + principalId: userId, + connectionId: "507f1f77bcf86cd799439096", + providerCalendarId: "gcal-2", + displayName: "Personal", + color: "#000000", + active: true, + primary: false, + accessRole: "owner" as const, + capabilities: { + canWriteEvents: true, + canReadBusy: true, + canInviteAttendees: true, + }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ], + }, + }), + ); + // The controller joins each calendar to its owning connection's account + // email by connectionId; the second connection reported no email, so its + // calendar must omit accountEmail rather than carry null/empty. + const listConnections = mock(() => + Promise.resolve({ + ok: true as const, + value: { + connections: [ + { + id: "507f1f77bcf86cd799439098", + account: { email: "bob@acme.co" }, + }, + { id: "507f1f77bcf86cd799439096", account: { email: null } }, ], }, }), @@ -129,7 +165,7 @@ describe("CalendarController list", () => { const clientSpy = spyOn( syncServiceFactory, "getSyncServiceClient", - ).mockReturnValue({ listCalendars } as never); + ).mockReturnValue({ listCalendars, listConnections } as never); // Not a .db.test.ts file, so there's no real Mongo connection here — // getLocalCalendar's own findOne would throw ("did you forget to call // `start`?") without this. @@ -148,11 +184,18 @@ describe("CalendarController list", () => { await calendarController.list(req, res); expect(listCalendars).toHaveBeenCalledTimes(1); + expect(listConnections).toHaveBeenCalledTimes(1); expect(promise).toHaveBeenCalledTimes(1); const sentBody = promise.mock.calls[0]?.[0]; expect(() => CalendarListResponseSchema.parse(sentBody)).not.toThrow(); + const parsed = CalendarListResponseSchema.parse(sentBody); + expect(parsed.calendars[0]?.accountEmail).toBe("bob@acme.co"); + expect(parsed.calendars[1] && "accountEmail" in parsed.calendars[1]).toBe( + false, + ); + clientSpy.mockRestore(); localCalendarSpy.mockRestore(); }); diff --git a/packages/backend/src/calendar/controllers/calendar.controller.ts b/packages/backend/src/calendar/controllers/calendar.controller.ts index c9bbf8008..e886a923b 100644 --- a/packages/backend/src/calendar/controllers/calendar.controller.ts +++ b/packages/backend/src/calendar/controllers/calendar.controller.ts @@ -66,22 +66,49 @@ const parseAvailabilityQuery = (query: SessionRequest["query"]) => { }; // List the caller's calendars from the sync service and translate them to the -// browser Calendar contract. A sync failure rejects (rather than returning an -// empty list) so the browser surfaces a load error and retries, instead of -// silently hiding every calendar. +// browser Calendar contract. The connection list rides along (both are cheap +// passive-mode reads on the same service) so each calendar can carry its +// owning account's email — sync's ProviderCalendar only has a connectionId. +// A sync failure on either call rejects (rather than returning an empty or +// email-less list) so the browser surfaces a load error and retries, instead +// of silently hiding every calendar. const listCalendarsFromSync = async ( userId: string, ): Promise => { const client = getSyncServiceClient(); - const result = await client.listCalendars(toSyncPrincipal(userId)); - if (!result.ok) { + const principal = toSyncPrincipal(userId); + const [calendarsResult, connectionsResult] = await Promise.all([ + client.listCalendars(principal), + client.listConnections(principal), + ]); + if (!calendarsResult.ok) { + throw error( + GenericError.NotSure, + `Failed to list calendars from sync (${calendarsResult.error.kind})`, + ); + } + if (!connectionsResult.ok) { throw error( GenericError.NotSure, - `Failed to list calendars from sync (${result.error.kind})`, + `Failed to list connections from sync (${connectionsResult.error.kind})`, ); } - return { calendars: result.value.calendars.map(syncCalendarToBrowser) }; + const emailByConnectionId = new Map( + connectionsResult.value.connections.map((connection) => [ + connection.id, + connection.account.email, + ]), + ); + + return { + calendars: calendarsResult.value.calendars.map((calendar) => + syncCalendarToBrowser( + calendar, + emailByConnectionId.get(calendar.connectionId) ?? undefined, + ), + ), + }; }; // Busy time from the sync service, translated to the browser's per-calendar diff --git a/packages/backend/src/common/services/sync-service/calendar-list.translation.test.ts b/packages/backend/src/common/services/sync-service/calendar-list.translation.test.ts index 631632242..8f2601d37 100644 --- a/packages/backend/src/common/services/sync-service/calendar-list.translation.test.ts +++ b/packages/backend/src/common/services/sync-service/calendar-list.translation.test.ts @@ -88,6 +88,18 @@ describe("syncCalendarToBrowser", () => { expect(result.timeZone).toBeNull(); }); + it("carries the owning account's email when the caller supplies one", () => { + const result = syncCalendarToBrowser(providerCalendar(), "bob@acme.co"); + expect(result.accountEmail).toBe("bob@acme.co"); + }); + + it("omits accountEmail entirely when the caller supplies none", () => { + // Absence (not null/empty) is the contract for the local calendar and for + // provider accounts that reported no email. + const result = syncCalendarToBrowser(providerCalendar()); + expect("accountEmail" in result).toBe(false); + }); + it.each([ ["owner", "owner"], ["editor", "writer"], diff --git a/packages/backend/src/common/services/sync-service/calendar-list.translation.ts b/packages/backend/src/common/services/sync-service/calendar-list.translation.ts index 06296542c..f13ab1d0d 100644 --- a/packages/backend/src/common/services/sync-service/calendar-list.translation.ts +++ b/packages/backend/src/common/services/sync-service/calendar-list.translation.ts @@ -41,7 +41,14 @@ const mapCalendarAccessRole = ( // becomes the background (foreground defaults), and isVisible is always true — // visibility is owned client-side now, so the server reports every calendar // visible and the web applies its own hidden set. -export const syncCalendarToBrowser = (calendar: ProviderCalendar): Calendar => { +// +// `accountEmail` is the owning connection's account email, joined by the +// caller from the principal's connection list (sync's ProviderCalendar only +// carries connectionId). Omitted when the connection reported no email. +export const syncCalendarToBrowser = ( + calendar: ProviderCalendar, + accountEmail?: string, +): Calendar => { const access = mapCalendarAccessRole(calendar.accessRole); // Sync stores the provider colour as a loose string; the browser Calendar // requires a hex colour. Fall back to the default rather than 500 the whole @@ -63,5 +70,6 @@ export const syncCalendarToBrowser = (calendar: ProviderCalendar): Calendar => { isPrimary: calendar.primary, isVisible: true, isActive: calendar.active, + ...(accountEmail ? { accountEmail } : {}), }); }; diff --git a/packages/core/src/types/calendar.contracts.ts b/packages/core/src/types/calendar.contracts.ts index d1db5c817..e94ddca4f 100644 --- a/packages/core/src/types/calendar.contracts.ts +++ b/packages/core/src/types/calendar.contracts.ts @@ -38,6 +38,12 @@ export const CalendarSchema = z.strictObject({ isPrimary: z.boolean(), isVisible: z.boolean(), isActive: z.boolean(), + // Email of the connected provider account this calendar belongs to. This is + // the calendar's only account identity on the wire: emails are unique per + // user (one Google account = one connection), so grouping and labelling key + // off it directly. Absent for the local calendar, and for provider accounts + // that reported no email. + accountEmail: z.string().optional(), }); export type Calendar = z.infer; diff --git a/packages/sync/src/domain/provider-page-applier.db.test.ts b/packages/sync/src/domain/provider-page-applier.db.test.ts index acff7afcc..397a13a63 100644 --- a/packages/sync/src/domain/provider-page-applier.db.test.ts +++ b/packages/sync/src/domain/provider-page-applier.db.test.ts @@ -99,6 +99,35 @@ describe("ProviderPageApplier", () => { const applier = (calendar: ProviderCalendarRecord) => new ProviderPageApplier(events, occurrences, calendar, 0, now); + it("stores transparency and iCalUID in providerMetadata, null when neither applies", async () => { + const calendar = await seedCalendar(); + const run = applier(calendar); + + await run.applyPage([ + { ...single("plain") }, + { ...single("correlated"), icalUid: "correlated@google.com" }, + { ...single("free-correlated"), busy: false, icalUid: "free@google.com" }, + ]); + + const byId = (providerEventId: string) => + events.findByProviderIdentity(calendar.tenantId, calendar.principalId, { + connectionId: calendar.connectionId, + calendarId: calendar._id, + providerEventId, + }); + + // Busy with no correlation key is the overwhelming default: no bag at all. + expect((await byId("plain"))?.providerMetadata).toBeNull(); + expect((await byId("correlated"))?.providerMetadata).toEqual({ + iCalUID: "correlated@google.com", + }); + // Both facts coexist in one bag rather than one overwriting the other. + expect((await byId("free-correlated"))?.providerMetadata).toEqual({ + transparency: "transparent", + iCalUID: "free@google.com", + }); + }); + it("returns standalone cancellations unconsumed and writes nothing for them", async () => { const calendar = await seedCalendar(); const run = applier(calendar); diff --git a/packages/sync/src/domain/provider-page-applier.ts b/packages/sync/src/domain/provider-page-applier.ts index 288940215..7363f87a3 100644 --- a/packages/sync/src/domain/provider-page-applier.ts +++ b/packages/sync/src/domain/provider-page-applier.ts @@ -21,6 +21,21 @@ import { type EventOccurrenceRepository } from "@sync/storage/repositories/event // 60s transaction lifetime limit on the shared tier. const PROJECTION_BATCH_SIZE = 200; +// The stored provider-fact bag for an imported read, null when empty. Busy is +// the overwhelming default, so only a free ("transparent") event records its +// transparency. iCalUID is the provider's cross-copy correlation key (copies +// of one meeting on different accounts share it) — stored so duplicate +// meetings across connected accounts can be recognized downstream. +function providerMetadataFor( + read: ProviderEvent, +): Record | null { + const metadata = { + ...(read.busy ? {} : { transparency: "transparent" }), + ...(read.icalUid ? { iCalUID: read.icalUid } : {}), + }; + return Object.keys(metadata).length > 0 ? metadata : null; +} + // Applies pages of provider event reads to the canonical store, shared by // initial import and incremental pull. It owns the parts both paths do // identically: upserting masters/singles, linking series members (modified @@ -303,10 +318,7 @@ export class ProviderPageApplier { : null, // Imported provider events carry no Compass delivery intent. deliveryState: null, - // Busy is the overwhelming default; only a free ("transparent") event - // records its transparency, so the fact survives until the busy-query - // slice decides how to read it. - providerMetadata: read.busy ? null : { transparency: "transparent" }, + providerMetadata: providerMetadataFor(read), content: read.content, schedule: read.schedule, recurrence, diff --git a/packages/sync/src/providers/google/google-event.normalizer.test.ts b/packages/sync/src/providers/google/google-event.normalizer.test.ts index a286da960..4ea643d28 100644 --- a/packages/sync/src/providers/google/google-event.normalizer.test.ts +++ b/packages/sync/src/providers/google/google-event.normalizer.test.ts @@ -334,6 +334,18 @@ describe("normalizeGoogleEvent", () => { expect(error.reason).toBe("unmappableContent"); }); + it("maps iCalUID onto the read (the cross-account correlation key)", () => { + const read = asProviderEvent( + normalizeGoogleEvent(gEvent({ iCalUID: "abc123@google.com" })), + ); + expect(read.icalUid).toBe("abc123@google.com"); + }); + + it("omits icalUid when Google reports no iCalUID", () => { + const read = asProviderEvent(normalizeGoogleEvent(gEvent({}))); + expect("icalUid" in read).toBe(false); + }); + it("maps Google colorId 7 to content.color blue", () => { const read = asProviderEvent( normalizeGoogleEvent(gEvent({ colorId: "7" })), diff --git a/packages/sync/src/providers/google/google-event.normalizer.ts b/packages/sync/src/providers/google/google-event.normalizer.ts index fe449ca46..b005d8e0c 100644 --- a/packages/sync/src/providers/google/google-event.normalizer.ts +++ b/packages/sync/src/providers/google/google-event.normalizer.ts @@ -57,6 +57,7 @@ export function normalizeGoogleEvent( schedule: mapSchedule(item), // Absent transparency means "opaque" (busy) in Google's model. busy: item.transparency !== "transparent", + ...(item.iCalUID ? { icalUid: item.iCalUID } : {}), recurrence: mapRecurrence(item), }; } diff --git a/packages/sync/src/providers/provider-event.port.ts b/packages/sync/src/providers/provider-event.port.ts index cef7b79db..05bdaf41b 100644 --- a/packages/sync/src/providers/provider-event.port.ts +++ b/packages/sync/src/providers/provider-event.port.ts @@ -32,6 +32,10 @@ export interface ProviderEvent { readonly schedule: EventSchedule; // Whether the event marks its time as busy. Free/"transparent" events do not. readonly busy: boolean; + // The provider's cross-copy correlation key (Google's iCalUID): copies of + // the same meeting on different accounts share it, unlike providerEventId. + // Absent when the provider reported none. + readonly icalUid?: string; readonly recurrence: ProviderEventRecurrence; }