Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -122,14 +122,50 @@ 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 } },
],
},
}),
);
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.
Expand All @@ -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();
});
Expand Down
41 changes: 34 additions & 7 deletions packages/backend/src/calendar/controllers/calendar.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CalendarListResponse> => {
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -63,5 +70,6 @@ export const syncCalendarToBrowser = (calendar: ProviderCalendar): Calendar => {
isPrimary: calendar.primary,
isVisible: true,
isActive: calendar.active,
...(accountEmail ? { accountEmail } : {}),
});
};
6 changes: 6 additions & 0 deletions packages/core/src/types/calendar.contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof CalendarSchema>;

Expand Down
29 changes: 29 additions & 0 deletions packages/sync/src/domain/provider-page-applier.db.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
20 changes: 16 additions & 4 deletions packages/sync/src/domain/provider-page-applier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> | 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
Expand Down Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions packages/sync/src/providers/google/google-event.normalizer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" })),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
};
}
Expand Down
4 changes: 4 additions & 0 deletions packages/sync/src/providers/provider-event.port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down