From 6637bdebb4137865701c3e72f66b6604747498a6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 5 Sep 2026 04:10:57 +0000 Subject: [PATCH 1/2] feat(sync): add Teams conferencing and instance fetch Graph createConference reads mailbox meeting providers and asks for Teams when one is allowed. fetchInstanceAt lists one day of series instances and normalizes the occurrence whose originalStart matches. Co-authored-by: Tyler Dane --- .agents/handoffs/3246.md | 32 +++ .../fixtures/microsoft/writer.json | 17 ++ .../microsoft-contract.factory.ts | 20 ++ .../__contract__/microsoft.contract.test.ts | 23 +- .../microsoft-event-writer.adapter.test.ts | 222 +++++++++++++++++- .../microsoft-event-writer.adapter.ts | 177 ++++++++++++-- .../microsoft/microsoft-event.normalizer.ts | 4 +- .../providers/provider-event-writer.port.ts | 8 +- 8 files changed, 457 insertions(+), 46 deletions(-) create mode 100644 .agents/handoffs/3246.md diff --git a/.agents/handoffs/3246.md b/.agents/handoffs/3246.md new file mode 100644 index 000000000..db3127742 --- /dev/null +++ b/.agents/handoffs/3246.md @@ -0,0 +1,32 @@ +--- +schema_version: 1 +task_id: "3246" +from: Implementer +to: GitHub +owner: GitHub +status: verifying +artifact: + - path: packages/sync/src/providers/microsoft/microsoft-event-writer.adapter.ts + - path: packages/sync/src/providers/microsoft/microsoft-event-writer.adapter.test.ts + - path: packages/sync/src/providers/microsoft/microsoft-event.normalizer.ts + - path: packages/sync/src/providers/provider-event-writer.port.ts + - path: packages/sync/src/providers/__contract__/microsoft-contract.factory.ts + - path: packages/sync/src/providers/__contract__/fixtures/microsoft/writer.json + - path: packages/sync/src/providers/__contract__/microsoft.contract.test.ts +evidence: + - command: bun run verify --strict + result: pending +assumptions: + - "Meeting-provider cache is per writer instance, keyed by access token, TTL 5 minutes." + - "Graph /instances occurrence rows are rewritten to exception before M-04 so the delta reader can keep skipping expansions." + - "conference: null on createConference when no Teams provider is allowed; omitted when the write did not ask for a conference." +open_risks: [] +next_deadline: 2026-09-05T12:00:00Z +retry: 0 +approval: allow +waiting_on: null +escalation: null +--- + +M WP-06b: Graph writer creates Teams conferences when asked and resolves +recurring instances by originalStart. diff --git a/packages/sync/src/providers/__contract__/fixtures/microsoft/writer.json b/packages/sync/src/providers/__contract__/fixtures/microsoft/writer.json index 48a800256..9bbae2d1c 100644 --- a/packages/sync/src/providers/__contract__/fixtures/microsoft/writer.json +++ b/packages/sync/src/providers/__contract__/fixtures/microsoft/writer.json @@ -33,5 +33,22 @@ "timeZone": "UTC" }, "showAs": "busy" + }, + "instance": { + "id": "series-1_instance", + "@odata.etag": "W/\"graph-instance-etag\"", + "type": "occurrence", + "subject": "Contract instance", + "seriesMasterId": "series-1", + "originalStart": "2025-01-15T14:00:00.0000000Z", + "start": { + "dateTime": "2025-01-15T14:00:00.0000000", + "timeZone": "UTC" + }, + "end": { + "dateTime": "2025-01-15T15:00:00.0000000", + "timeZone": "UTC" + }, + "showAs": "busy" } } diff --git a/packages/sync/src/providers/__contract__/microsoft-contract.factory.ts b/packages/sync/src/providers/__contract__/microsoft-contract.factory.ts index bf6c51679..fe98dc9e1 100644 --- a/packages/sync/src/providers/__contract__/microsoft-contract.factory.ts +++ b/packages/sync/src/providers/__contract__/microsoft-contract.factory.ts @@ -31,6 +31,7 @@ export interface MicrosoftReaderCorpus { interface WriterCorpus { readonly create: GraphEvent; readonly fetch: GraphEvent; + readonly instance: GraphEvent; } class CorpusEventListApi implements MicrosoftEventListApi { @@ -122,6 +123,25 @@ class CorpusEventWriteApi implements MicrosoftEventWriteApi { "@odata.etag": this.#etag, }; } + + async getCalendar() { + return { + allowedOnlineMeetingProviders: [] as const, + defaultOnlineMeetingProvider: "unknown", + }; + } + + async listInstances( + params: Parameters[0], + ): Promise { + if ( + params.seriesEventId !== + (this.corpus.instance.seriesMasterId ?? "series-1") + ) { + return []; + } + return [this.corpus.instance]; + } } /** Replay `fixtures/microsoft/reader.json` through the event reader adapter. */ diff --git a/packages/sync/src/providers/__contract__/microsoft.contract.test.ts b/packages/sync/src/providers/__contract__/microsoft.contract.test.ts index 66ffda56c..2613ba10b 100644 --- a/packages/sync/src/providers/__contract__/microsoft.contract.test.ts +++ b/packages/sync/src/providers/__contract__/microsoft.contract.test.ts @@ -33,7 +33,6 @@ import { type ProviderEventReadError, type ProviderEventReader, } from "@sync/providers/provider-event-reader.port"; -import { ProviderWriteError } from "@sync/providers/provider-event-writer.port"; const CLIENT_ID = "microsoft-client-id"; const CLIENT_SECRET = "microsoft-client-secret"; @@ -379,18 +378,16 @@ describe("microsoft writer contract", () => { }); }); - it("fetchInstanceAt remains unsupported until M-06b", async () => { - const error = await writerAdapter - .fetchInstanceAt({ - accessToken: "contract-access-token", - calendarId: "primary", - seriesProviderEventId: "series-1", - originalStartAt: "2025-01-15T14:00:00.000Z", - scheduleKind: "timed", - }) - .catch((caught) => caught); - expect(error).toBeInstanceOf(ProviderWriteError); - expect((error as ProviderWriteError).reason).toBe("unsupportedCapability"); + it("fetchInstanceAt resolves an occurrence by originalStart", async () => { + const instance = await writerAdapter.fetchInstanceAt({ + accessToken: "contract-access-token", + calendarId: "primary", + seriesProviderEventId: "series-1", + originalStartAt: "2025-01-15T14:00:00.000Z", + scheduleKind: "timed", + }); + expect(instance).not.toBeNull(); + expect(instance?.providerEventId.length).toBeGreaterThan(0); }); }); diff --git a/packages/sync/src/providers/microsoft/microsoft-event-writer.adapter.test.ts b/packages/sync/src/providers/microsoft/microsoft-event-writer.adapter.test.ts index 3073c8c31..6f57f8a80 100644 --- a/packages/sync/src/providers/microsoft/microsoft-event-writer.adapter.test.ts +++ b/packages/sync/src/providers/microsoft/microsoft-event-writer.adapter.test.ts @@ -1,7 +1,8 @@ import { type EventSchedule } from "@core/types/event.contracts"; import { type SyncEventContent } from "@core/types/sync/event.contracts"; +import { type GraphEvent } from "@sync/providers/microsoft/microsoft-event.normalizer"; import { - type GraphEvent, + type GraphCalendarMeetingInfo, type MicrosoftEventWriteApi, MicrosoftEventWriter, } from "@sync/providers/microsoft/microsoft-event-writer.adapter"; @@ -26,6 +27,8 @@ const scriptedEvent = (id: string, etag = 'W/"graph-v1"'): GraphEvent => ({ }); type Behavior = GraphEvent | Error | undefined; +type CalendarBehavior = GraphCalendarMeetingInfo | Error | undefined; +type InstancesBehavior = readonly GraphEvent[] | Error | undefined; class FakeWriteApi implements MicrosoftEventWriteApi { calls = { @@ -33,6 +36,10 @@ class FakeWriteApi implements MicrosoftEventWriteApi { patch: [] as Parameters[0][], delete: [] as Parameters[0][], get: [] as Parameters[0][], + getCalendar: 0, + listInstances: [] as Parameters< + MicrosoftEventWriteApi["listInstances"] + >[0][], }; #etag: string; @@ -45,6 +52,8 @@ class FakeWriteApi implements MicrosoftEventWriteApi { patch?: Behavior; delete?: Behavior; get?: Behavior; + getCalendar?: CalendarBehavior; + listInstances?: InstancesBehavior; } = {}, initialEtag = 'W/"graph-v1"', ) { @@ -97,6 +106,27 @@ class FakeWriteApi implements MicrosoftEventWriteApi { ); } + async getCalendar(): Promise { + this.calls.getCalendar += 1; + const scripted = this.behavior.getCalendar; + if (scripted instanceof Error) throw scripted; + return ( + scripted ?? { + allowedOnlineMeetingProviders: [], + defaultOnlineMeetingProvider: "unknown", + } + ); + } + + async listInstances( + params: Parameters[0], + ): Promise { + this.calls.listInstances.push(params); + const scripted = this.behavior.listInstances; + if (scripted instanceof Error) throw scripted; + return scripted ?? []; + } + #settle(method: "create" | "patch" | "get", fallback: () => GraphEvent) { const scripted = this.behavior[method]; if (scripted instanceof Error) throw scripted; @@ -432,20 +462,186 @@ describe("MicrosoftEventWriter", () => { expect(missing).toBeNull(); }); - it("fetchInstanceAt is unsupported until M-06b", async () => { - const { writer } = writerWith(new FakeWriteApi()); + it("does not read meeting providers unless createConference is set", async () => { + const api = new FakeWriteApi(); + await writerWith(api).writer.createEvent(baseCreate); + expect(api.calls.getCalendar).toBe(0); + expect(api.calls.create[0]?.body).not.toHaveProperty("isOnlineMeeting"); + }); - const error = (await writer - .fetchInstanceAt({ - accessToken: "at", - calendarId: "cal", - seriesProviderEventId: "series-1", - originalStartAt: "2025-01-15T14:00:00.000Z", - scheduleKind: "timed", - }) - .catch((e) => e)) as ProviderWriteError; + it("uses the default Teams provider when creating a conference", async () => { + const api = new FakeWriteApi({ + getCalendar: { + allowedOnlineMeetingProviders: ["teamsForBusiness", "skypeForConsumer"], + defaultOnlineMeetingProvider: "teamsForBusiness", + }, + create: { + ...scriptedEvent("abc12deadbeef00000000000"), + onlineMeeting: { + joinUrl: "https://teams.microsoft.com/l/meetup-join/abc", + }, + onlineMeetingProvider: "teamsForBusiness", + }, + }); + + const result = await writerWith(api).writer.createEvent({ + ...baseCreate, + createConference: true, + }); + + expect(api.calls.create[0]?.body).toMatchObject({ + isOnlineMeeting: true, + onlineMeetingProvider: "teamsForBusiness", + }); + expect(result.conference).toEqual({ + url: "https://teams.microsoft.com/l/meetup-join/abc", + label: "Microsoft Teams", + }); + }); + + it("falls back to the first allowed Teams provider when the default is not Teams", async () => { + const api = new FakeWriteApi({ + getCalendar: { + allowedOnlineMeetingProviders: ["skypeForBusiness", "teamsForConsumer"], + defaultOnlineMeetingProvider: "skypeForBusiness", + }, + create: { + ...scriptedEvent("abc12deadbeef00000000000"), + onlineMeeting: { + joinUrl: "https://teams.microsoft.com/l/meetup-join/consumer", + }, + onlineMeetingProvider: "teamsForConsumer", + }, + }); + + const result = await writerWith(api).writer.createEvent({ + ...baseCreate, + accessToken: "token-teams-allowed", + createConference: true, + }); + + expect(api.calls.create[0]?.body).toMatchObject({ + isOnlineMeeting: true, + onlineMeetingProvider: "teamsForConsumer", + }); + expect(result.conference).toEqual({ + url: "https://teams.microsoft.com/l/meetup-join/consumer", + label: "Microsoft Teams", + }); + }); + + it("creates without a conference when no Teams provider is allowed", async () => { + const api = new FakeWriteApi({ + getCalendar: { + allowedOnlineMeetingProviders: ["skypeForConsumer"], + defaultOnlineMeetingProvider: "skypeForConsumer", + }, + }); + + const result = await writerWith(api).writer.createEvent({ + ...baseCreate, + accessToken: "token-no-teams", + createConference: true, + }); + + expect(api.calls.create[0]?.body).not.toHaveProperty("isOnlineMeeting"); + expect(api.calls.create[0]?.body).not.toHaveProperty( + "onlineMeetingProvider", + ); + expect(result.conference).toBeNull(); + }); + + it("reads meeting providers once per access token within the cache TTL", async () => { + const api = new FakeWriteApi({ + getCalendar: { + allowedOnlineMeetingProviders: ["teamsForBusiness"], + defaultOnlineMeetingProvider: "teamsForBusiness", + }, + }); + const { writer } = writerWith(api); + + await writer.createEvent({ ...baseCreate, createConference: true }); + await writer.createEvent({ + ...baseCreate, + providerEventId: "def12deadbeef00000000000", + createConference: true, + }); + + expect(api.calls.getCalendar).toBe(1); + }); + + it("resolves an occurrence whose originalStart matches the requested instant", async () => { + const api = new FakeWriteApi({ + listInstances: [ + { + ...scriptedEvent("series-1_instance"), + type: "occurrence", + seriesMasterId: "series-1", + originalStart: "2025-01-15T14:00:00.0000000Z", + }, + ], + }); + const { writer } = writerWith(api); + + const read = await writer.fetchInstanceAt({ + accessToken: "at", + calendarId: "cal", + seriesProviderEventId: "series-1", + originalStartAt: "2025-01-15T14:00:00.000Z", + scheduleKind: "timed", + }); + + expect(api.calls.listInstances[0]).toEqual({ + seriesEventId: "series-1", + startDateTime: "2025-01-15T00:00:00.000", + endDateTime: "2025-01-16T00:00:00.000", + }); + expect(read?.kind).toBe("event"); + expect(read?.providerEventId).toBe("series-1_instance"); + if (read?.kind === "event") { + expect(read.recurrence).toEqual({ + kind: "instance", + seriesProviderId: "series-1", + recurrenceId: "2025-01-15T14:00:00.000Z", + }); + } + }); + + it("returns null when no instance exists at that instant", async () => { + const api = new FakeWriteApi({ + listInstances: [ + { + ...scriptedEvent("other-instance"), + type: "occurrence", + seriesMasterId: "series-1", + originalStart: "2025-01-16T14:00:00.0000000Z", + }, + ], + }); + + const read = await writerWith(api).writer.fetchInstanceAt({ + accessToken: "at", + calendarId: "cal", + seriesProviderEventId: "series-1", + originalStartAt: "2025-01-15T14:00:00.000Z", + scheduleKind: "timed", + }); + + expect(read).toBeNull(); + }); + + it("returns null when the series itself is gone", async () => { + const api = new FakeWriteApi({ listInstances: msError(404) }); + + const read = await writerWith(api).writer.fetchInstanceAt({ + accessToken: "at", + calendarId: "cal", + seriesProviderEventId: "gone", + originalStartAt: "2025-01-15T14:00:00.000Z", + scheduleKind: "timed", + }); - expect(error.reason).toBe("unsupportedCapability"); + expect(read).toBeNull(); }); it("never leaks the bearer token onto a thrown error cause", async () => { diff --git a/packages/sync/src/providers/microsoft/microsoft-event-writer.adapter.ts b/packages/sync/src/providers/microsoft/microsoft-event-writer.adapter.ts index 0affd35bc..218317cba 100644 --- a/packages/sync/src/providers/microsoft/microsoft-event-writer.adapter.ts +++ b/packages/sync/src/providers/microsoft/microsoft-event-writer.adapter.ts @@ -9,6 +9,7 @@ import { } from "@sync/providers/microsoft/microsoft-error"; import { type GraphEvent, + mapConference, normalizeMicrosoftEvent, } from "@sync/providers/microsoft/microsoft-event.normalizer"; import { @@ -73,8 +74,25 @@ export interface GraphEventWriteBody { readonly attendees?: readonly GraphAttendeeWrite[]; readonly responseRequested?: boolean; readonly transactionId?: string; + readonly isOnlineMeeting?: boolean; + readonly onlineMeetingProvider?: string; } +export interface GraphCalendarMeetingInfo { + readonly allowedOnlineMeetingProviders?: readonly string[]; + readonly defaultOnlineMeetingProvider?: string; +} + +const TEAMS_ONLINE_MEETING_PROVIDERS = new Set([ + "teamsForBusiness", + "teamsForConsumer", +]); + +const MEETING_PROVIDER_CACHE_TTL_MS = 5 * 60 * 1000; + +const CALENDAR_MEETING_SELECT = + "allowedOnlineMeetingProviders,defaultOnlineMeetingProvider"; + export interface MicrosoftEventWriteApi { create(params: { calendarId: string; @@ -87,6 +105,12 @@ export interface MicrosoftEventWriteApi { }): Promise; delete(params: { eventId: string; ifMatch: string | null }): Promise; get(params: { eventId: string }): Promise; + getCalendar(): Promise; + listInstances(params: { + seriesEventId: string; + startDateTime: string; + endDateTime: string; + }): Promise; } export type MicrosoftEventWriteApiFactory = ( @@ -99,31 +123,48 @@ const defaultApiFactory: MicrosoftEventWriteApiFactory = (accessToken) => // Microsoft Graph implementation of the event mutation port. Create is // idempotent via transactionId, patch and delete can be conditioned on etag, // and provider errors are classified into neutral, caller-actionable reasons. +// createConference reads the mailbox's allowed online-meeting providers and +// asks Graph for Teams when one is available; fetchInstanceAt lists one day +// of /instances and picks the occurrence whose originalStart matches. // // Named wart: Graph sends attendee mail on create and on attendee changes even // when responseRequested is false (invitation "none"). Compass honors "none" by // setting responseRequested false, but Outlook may still notify attendees. export class MicrosoftEventWriter implements ProviderEventWriter { #makeApi: MicrosoftEventWriteApiFactory; + #meetingProviders = new Map< + string, + { readonly expiresAt: number; readonly teamsProvider: string | null } + >(); constructor(makeApi: MicrosoftEventWriteApiFactory = defaultApiFactory) { this.#makeApi = makeApi; } async createEvent(input: ProviderCreateInput): Promise { - if (input.createConference) { - // Teams conference creation lands in M-06b; booking copy handles null URL. - } - const api = this.#makeApi(input.accessToken); - const body = toGraphCreateBody(input); - try { + const teamsProvider = input.createConference + ? await this.#resolveTeamsProvider(api, input.accessToken) + : null; + const body = { + ...toGraphCreateBody(input), + ...(teamsProvider + ? { + isOnlineMeeting: true, + onlineMeetingProvider: teamsProvider, + } + : {}), + }; const created = await api.create({ calendarId: input.calendarId, body, }); - return toResult(created); + const result = toResult(created); + if (input.createConference) { + return { ...result, conference: result.conference ?? null }; + } + return result; } catch (error) { throw classifyWriteError(error); } @@ -170,12 +211,46 @@ export class MicrosoftEventWriter implements ProviderEventWriter { } async fetchInstanceAt( - _input: ProviderInstanceFetchInput, + input: ProviderInstanceFetchInput, ): Promise { - throw new ProviderWriteError( - "unsupportedCapability", - "Microsoft instance resolution lands in M-06b", + const api = this.#makeApi(input.accessToken); + const { startDateTime, endDateTime } = instanceWindow( + input.originalStartAt, ); + try { + const instances = await api.listInstances({ + seriesEventId: input.seriesProviderEventId, + startDateTime, + endDateTime, + }); + const match = instances.find( + (item) => + item.originalStart !== undefined && + sameInstant(item.originalStart, input.originalStartAt), + ); + if (!match) return null; + return normalizeFetchedInstance(match, input); + } catch (error) { + if (isNotFound(error)) return null; + throw classifyWriteError(error); + } + } + + async #resolveTeamsProvider( + api: MicrosoftEventWriteApi, + accessToken: string, + ): Promise { + const now = Date.now(); + const cached = this.#meetingProviders.get(accessToken); + if (cached && cached.expiresAt > now) return cached.teamsProvider; + + const calendar = await api.getCalendar(); + const teamsProvider = pickTeamsProvider(calendar); + this.#meetingProviders.set(accessToken, { + expiresAt: now + MEETING_PROVIDER_CACHE_TTL_MS, + teamsProvider, + }); + return teamsProvider; } } @@ -323,13 +398,59 @@ function toResult(event: GraphEvent): ProviderWriteResult { "Microsoft returned an event without an id or etag", ); } + const conference = mapConference(event); return { providerEventId: event.id, providerVersion: event["@odata.etag"], ...(event.iCalUId ? { icalUid: event.iCalUId } : {}), + ...(conference ? { conference } : {}), }; } +function pickTeamsProvider(calendar: GraphCalendarMeetingInfo): string | null { + const defaultProvider = calendar.defaultOnlineMeetingProvider; + if (defaultProvider && TEAMS_ONLINE_MEETING_PROVIDERS.has(defaultProvider)) { + return defaultProvider; + } + return ( + calendar.allowedOnlineMeetingProviders?.find((provider) => + TEAMS_ONLINE_MEETING_PROVIDERS.has(provider), + ) ?? null + ); +} + +function instanceWindow(originalStartAt: string): { + startDateTime: string; + endDateTime: string; +} { + const dayStart = dayjs.utc(originalStartAt).startOf("day"); + return { + startDateTime: dayStart.format(GRAPH_DATETIME), + endDateTime: dayStart.add(1, "day").format(GRAPH_DATETIME), + }; +} + +function sameInstant(left: string, right: string): boolean { + const a = Date.parse(left); + const b = Date.parse(right); + return Number.isFinite(a) && a === b; +} + +// Graph /instances returns type "occurrence". The M-04 normalizer refuses +// those rows on the delta reader (they are expansions, not stored events). +// Rewrite to exception so the same mapper yields an addressable instance. +function normalizeFetchedInstance( + item: GraphEvent, + input: ProviderInstanceFetchInput, +): ProviderEventRead { + return normalizeMicrosoftEvent({ + ...item, + type: item.type === "occurrence" ? "exception" : item.type, + seriesMasterId: item.seriesMasterId ?? input.seriesProviderEventId, + originalStart: item.originalStart ?? input.originalStartAt, + }); +} + function classifyWriteError(error: unknown): ProviderWriteError { if (error instanceof ProviderWriteError) return error; if (error instanceof ProviderEventError) { @@ -438,12 +559,38 @@ class FetchMicrosoftEventWriteApi implements MicrosoftEventWriteApi { ); } - async #request( + getCalendar(): Promise { + const query = new URLSearchParams({ $select: CALENDAR_MEETING_SELECT }); + return this.#request( + "GET", + `${MICROSOFT_GRAPH_BASE_URL}/me/calendar?${query}`, + ); + } + + async listInstances(params: { + seriesEventId: string; + startDateTime: string; + endDateTime: string; + }): Promise { + const eventId = encodeURIComponent(params.seriesEventId); + const query = new URLSearchParams({ + startDateTime: params.startDateTime, + endDateTime: params.endDateTime, + $select: MICROSOFT_EVENT_SELECT, + }); + const data = await this.#request<{ value?: readonly GraphEvent[] }>( + "GET", + `${MICROSOFT_GRAPH_BASE_URL}/me/events/${eventId}/instances?${query}`, + ); + return data.value ?? []; + } + + async #request( method: "GET" | "POST" | "PATCH" | "DELETE", url: string, body?: GraphEventWriteBody, ifMatch: string | null = null, - ): Promise { + ): Promise { const headers: Record = { Authorization: `Bearer ${this.#accessToken}`, Prefer: 'outlook.timezone="UTC"', @@ -459,7 +606,7 @@ class FetchMicrosoftEventWriteApi implements MicrosoftEventWriteApi { }); if (method === "DELETE") { - if (response.ok) return {} as GraphEvent; + if (response.ok) return {} as T; const deleteError = await parseErrorBody(response); throw Object.assign( new Error(deleteError.message ?? "microsoft_event_write_failed"), @@ -467,7 +614,7 @@ class FetchMicrosoftEventWriteApi implements MicrosoftEventWriteApi { ); } - const data = (await response.json()) as GraphEvent & { + const data = (await response.json()) as T & { error?: { code?: string; message?: string }; }; diff --git a/packages/sync/src/providers/microsoft/microsoft-event.normalizer.ts b/packages/sync/src/providers/microsoft/microsoft-event.normalizer.ts index b2710346d..f4c263850 100644 --- a/packages/sync/src/providers/microsoft/microsoft-event.normalizer.ts +++ b/packages/sync/src/providers/microsoft/microsoft-event.normalizer.ts @@ -25,6 +25,7 @@ const NO_CATEGORY_COLORS: ReadonlyMap = new Map(); const MEETING_PROVIDER_LABELS: Readonly> = { teamsForBusiness: "Microsoft Teams", + teamsForConsumer: "Microsoft Teams", skypeForBusiness: "Skype for Business", skypeForConsumer: "Skype", }; @@ -235,7 +236,8 @@ function mapAttendees(attendees: GraphEvent["attendees"]): Attendee[] { })); } -function mapConference(item: GraphEvent): Conference | null { +/** Read-reflected Teams URL. Writers reuse this on create responses. */ +export function mapConference(item: GraphEvent): Conference | null { const url = item.onlineMeeting?.joinUrl; if (!url) return null; diff --git a/packages/sync/src/providers/provider-event-writer.port.ts b/packages/sync/src/providers/provider-event-writer.port.ts index 3d080be1d..dac49083b 100644 --- a/packages/sync/src/providers/provider-event-writer.port.ts +++ b/packages/sync/src/providers/provider-event-writer.port.ts @@ -106,10 +106,10 @@ export interface ProviderWriteResult { // Google's cross-copy correlation key when the write response includes it. // Optional so non-Google writers and older fixtures stay valid. readonly icalUid?: string; - // Meet URL Google minted on create (or echoed on a later write). Omitted - // when the response has none. Create records overlay this onto command - // content, which sends `conference: null`. - readonly conference?: Conference; + // Meet/Teams URL the provider minted on create (or echoed on a later write). + // Omitted when the write did not ask for a conference. Null when the write + // asked for one but the provider could not mint a link (no Teams allowed). + readonly conference?: Conference | null; } // A provider-neutral event mutation port. Neutral inputs in, provider identity From 2a162999f5dd6a863733e6fc8c019809e4875f56 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 5 Sep 2026 04:12:22 +0000 Subject: [PATCH 2/2] chore(handoff): record m wp-06b verify pass Co-authored-by: Tyler Dane --- .agents/handoffs/3246.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/handoffs/3246.md b/.agents/handoffs/3246.md index db3127742..08594bb8a 100644 --- a/.agents/handoffs/3246.md +++ b/.agents/handoffs/3246.md @@ -15,7 +15,7 @@ artifact: - path: packages/sync/src/providers/__contract__/microsoft.contract.test.ts evidence: - command: bun run verify --strict - result: pending + result: "VERDICT: PASS (test:sync:fast, type-check, lint, knip)" assumptions: - "Meeting-provider cache is per writer instance, keyed by access token, TTL 5 minutes." - "Graph /instances occurrence rows are rewritten to exception before M-04 so the delta reader can keep skipping expansions."