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
122 changes: 122 additions & 0 deletions packages/sync/src/domain/merge-update-content.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,128 @@ describe("mergeUpdateContent", () => {
});
});

it("drops colorHex when a slot color is applied", () => {
const existing = {
title: "Labeled",
description: "",
location: null,
organizer: null,
attendees: [],
conference: null,
colorHex: "#009688",
};

expect(
mergeUpdateContent(existing, {
title: "Labeled",
description: "",
location: null,
organizer: null,
attendees: [],
conference: null,
color: "blue",
}),
).toEqual({
title: "Labeled",
description: "",
location: null,
organizer: null,
attendees: [],
conference: null,
color: "blue",
});
});

it("drops colorHex when clearing a prior slot color", () => {
const existing = {
title: "Slotted",
description: "",
location: null,
organizer: null,
attendees: [],
conference: null,
color: "blue" as const,
colorHex: "#009688",
};

expect(
mergeUpdateContent(existing, {
title: "Slotted",
description: "",
location: null,
organizer: null,
attendees: [],
conference: null,
color: null,
}),
).toEqual({
title: "Slotted",
description: "",
location: null,
organizer: null,
attendees: [],
conference: null,
});
});

it("keeps colorHex when drafts send null color on a hex-only event", () => {
const existing = {
title: "Labeled",
description: "",
location: null,
organizer: null,
attendees: [],
conference: null,
colorHex: "#009688",
};

expect(
mergeUpdateContent(existing, {
title: "Labeled",
description: "edited",
location: null,
organizer: null,
attendees: [],
conference: null,
color: null,
}),
).toEqual({
title: "Labeled",
description: "edited",
location: null,
organizer: null,
attendees: [],
conference: null,
colorHex: "#009688",
});
});

it("preserves colorHex when color is omitted", () => {
const existing = {
title: "Labeled",
description: "",
location: null,
organizer: null,
attendees: [],
conference: null,
colorHex: "#009688",
};

expect(
mergeUpdateContent(existing, {
title: "Renamed",
description: "",
location: null,
organizer: null,
attendees: [],
conference: null,
}),
).toEqual({
...existing,
title: "Renamed",
});
});

it("clears an existing color when the command sends null", () => {
const existing = {
title: "Old",
Expand Down
33 changes: 26 additions & 7 deletions packages/sync/src/domain/merge-update-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,42 @@ import { type SyncEventContent } from "@core/types/sync/event.contracts";
//
// Merge editable fields from the command; keep the rest from existing.
// Color: slot replaces, null clears (omit the field), omit keeps existing.
// colorHex is provider-read-only; a slot write (or clear of a prior slot)
// must drop it so palette resolution cannot resurrect the old fill after
// settle. Drafts often send color:null for "no slot" on hex-only events —
// that must not wipe colorHex.
export function mergeUpdateContent(
existing: SyncEventContent,
incoming: SyncEventContent,
): SyncEventContent {
const { color: existingColor, ...existingWithoutColor } = existing;
const {
color: existingColor,
colorHex: existingColorHex,
...existingRest
} = existing;
const merged: SyncEventContent = {
...existingWithoutColor,
...existingRest,
title: incoming.title,
description: incoming.description,
location: incoming.location,
};

if (incoming.color === null) return merged;
if (incoming.color !== undefined) return { ...merged, color: incoming.color };
return existingColor !== undefined
? { ...merged, color: existingColor }
: merged;
if (incoming.color === null) {
if (existingColor !== undefined) return merged;
return existingColorHex !== undefined
? { ...merged, colorHex: existingColorHex }
: merged;
}
if (incoming.color !== undefined) {
return { ...merged, color: incoming.color };
}

let kept = merged;
if (existingColor !== undefined) kept = { ...kept, color: existingColor };
if (existingColorHex !== undefined) {
kept = { ...kept, colorHex: existingColorHex };
}
return kept;
}

// Null is a write-command "clear" signal. Stored/read rows omit the field;
Expand Down
5 changes: 5 additions & 0 deletions packages/sync/src/providers/google/google-color.map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ export function slotToGoogleColorId(slot: EventColorSlot): string {

// Fields for a Google create/patch body: a slot sets colorId, null clears it,
// undefined leaves Google's existing color untouched (omit the key).
//
// Clearing a prior custom eventLabelId is not done here: eventLabelVersion=1
// is required to write eventLabelId, and that version ignores colorId. The
// writer clears labels in a separate preconditioned patch before the colorId
// write (see GoogleEventWriter.patchEvent).
export function googleColorIdFields(
color: EventColorSlot | null | undefined,
): { colorId: string | null } | Record<string, never> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,7 @@ describe("GoogleEventWriter", () => {
await writer.patchEvent({ ...basePatch, content: content() });

expect(api.calls.insert[0].requestBody.colorId).toBe("7");
expect(api.calls.patch).toHaveLength(1);
expect(api.calls.patch[0].requestBody).not.toHaveProperty("colorId");
});

Expand All @@ -457,7 +458,34 @@ describe("GoogleEventWriter", () => {
content: content({ color: null }),
});

expect(api.calls.patch).toHaveLength(1);
expect(api.calls.patch[0].requestBody.colorId).toBeNull();
expect(api.calls.patch[0]).not.toHaveProperty("eventLabelVersion");
});

it("clears eventLabelId under v1 before writing a slot colorId", async () => {
const api = new FakeEventsApi();
const { writer } = writerWith(api);

await writer.patchEvent({
...basePatch,
expectedVersion: '"etag-v1"',
content: content({ color: "coral" }),
});

expect(api.calls.patch).toHaveLength(2);
expect(api.calls.patch[0]).toMatchObject({
requestBody: { eventLabelId: "" },
sendUpdates: "none",
eventLabelVersion: 1,
ifMatch: '"etag-v1"',
});
expect(api.calls.patch[1]).toMatchObject({
requestBody: { colorId: "4" },
ifMatch: '"v2"',
});
expect(api.calls.patch[1]).not.toHaveProperty("eventLabelVersion");
expect(api.calls.patch[1].requestBody).not.toHaveProperty("eventLabelId");
});

it("maps each invitation intent straight to sendUpdates", async () => {
Expand Down
48 changes: 41 additions & 7 deletions packages/sync/src/providers/google/google-event-writer.adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,19 @@ import { redactedCause } from "@sync/safety/redact-error";
export interface GoogleEventsApi {
insert(params: {
calendarId: string;
requestBody: calendar_v3.Schema$Event;
requestBody: gSchema$Event;
sendUpdates: string;
}): Promise<gSchema$Event>;
patch(params: {
calendarId: string;
eventId: string;
requestBody: calendar_v3.Schema$Event;
requestBody: gSchema$Event;
sendUpdates: string;
ifMatch: string | null;
// Required to write eventLabelId. Under version 1, colorId is ignored —
// callers that both clear a label and set a slot color must use two
// patches (label clear at v1, then colorId at the default v0).
eventLabelVersion?: 1;
}): Promise<gSchema$Event>;
delete(params: {
calendarId: string;
Expand Down Expand Up @@ -84,9 +88,23 @@ const defaultApiFactory: GoogleEventsApiFactory = (accessToken) => {
});
return data;
},
async patch({ calendarId, eventId, requestBody, sendUpdates, ifMatch }) {
async patch({
calendarId,
eventId,
requestBody,
sendUpdates,
ifMatch,
eventLabelVersion,
}) {
const { data } = await gcal.events.patch(
{ calendarId, eventId, requestBody, sendUpdates },
{
calendarId,
eventId,
requestBody,
sendUpdates,
// Installed @googleapis/calendar types predate event labels.
...(eventLabelVersion !== undefined ? { eventLabelVersion } : {}),
} as calendar_v3.Params$Resource$Events$Patch,
ifMatchOptions(ifMatch),
);
return data;
Expand Down Expand Up @@ -126,7 +144,7 @@ export class GoogleEventWriter implements ProviderEventWriter {

async createEvent(input: ProviderCreateInput): Promise<ProviderWriteResult> {
const api = this.#makeApi(input.accessToken);
const requestBody: calendar_v3.Schema$Event = {
const requestBody: gSchema$Event = {
id: input.providerEventId,
...toGoogleBody(input.content, input.schedule, input.recurrence),
};
Expand Down Expand Up @@ -161,6 +179,22 @@ export class GoogleEventWriter implements ProviderEventWriter {
async patchEvent(input: ProviderPatchInput): Promise<ProviderWriteResult> {
const api = this.#makeApi(input.accessToken);
try {
let ifMatch = input.expectedVersion;
// A slot color must also clear any custom event label. Labels supersede
// colorId on read and rehydrate as Compass colorHex. Google only accepts
// eventLabelId writes under eventLabelVersion=1, and that version ignores
// colorId — so clear the label first, then write colorId at default v0.
if (typeof input.content.color === "string") {
const cleared = await api.patch({
calendarId: input.calendarId,
eventId: input.providerEventId,
requestBody: { eventLabelId: "" },
sendUpdates: "none",
ifMatch,
eventLabelVersion: 1,
});
if (cleared.etag) ifMatch = cleared.etag;
}
const patched = await api.patch({
calendarId: input.calendarId,
eventId: input.providerEventId,
Expand All @@ -170,7 +204,7 @@ export class GoogleEventWriter implements ProviderEventWriter {
input.recurrence,
),
sendUpdates: toSendUpdates(input.invitation),
ifMatch: input.expectedVersion,
ifMatch,
});
return toResult(patched);
} catch (error) {
Expand Down Expand Up @@ -271,7 +305,7 @@ function toGoogleBody(
content: SyncEventContent,
schedule: EventSchedule,
recurrence: ProviderWriteRecurrence,
): calendar_v3.Schema$Event {
): gSchema$Event {
return {
summary: content.title,
description: content.description,
Expand Down
9 changes: 5 additions & 4 deletions packages/web/src/events/mutations/useEventMutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ export type EventMutations = {
replace: (
payload: { id: EventId; input: ReplaceEventInput },
callbacks?: EventMutationCallbacks,
) => void;
) => boolean;
delete: (payload: { id: EventId; scope: RecurrenceScope }) => void;
promoteRecurring: (
opportunity: RecurrenceScopeOpportunity,
Expand Down Expand Up @@ -697,20 +697,20 @@ export function useEventMutations(
replace: (
payload: { id: EventId; input: ReplaceEventInput },
callbacks?: EventMutationCallbacks,
) => {
): boolean => {
const original = findEventInCache(queryClient, payload.id, source);
if (isTargetReadOnly(original)) {
console.warn(
`[useEventMutations] blocked replace on read-only event ${payload.id}`,
);
return;
return false;
}
if (
blockReconnectRequiredCalendar(
payload.input.calendarId ?? original?.calendarId,
)
) {
return;
return false;
}
const writeKey = seriesWriteKey(
original,
Expand Down Expand Up @@ -746,6 +746,7 @@ export function useEventMutations(
{ ...payload, writeKey, opportunityId, callbacks },
callbacks,
);
return true;
},
delete: (payload: { id: EventId; scope: RecurrenceScope }) => {
const original = findEventInCache(queryClient, payload.id, source);
Expand Down
Loading