Skip to content

Commit 2195df4

Browse files
tyler-daneclaude
andauthored
refactor(core): define strict event contracts (#2015)
Implement plan packet 01 from the sub-calendar v1 roadmap (PR #2012): calendar-owned event contracts shared by core, backend, and web, alongside the legacy model (runtime cutover follows in packet 03). - core: branded domain primitives; discriminated Event (content/schedule/recurrence), BusyPeriod, Calendar with derived capabilities; command contracts incl. optional client create id and the someday<->scheduled transition; strict SSE ServerMessage union - backend: CalendarRecord/EventRecord schemas (zObjectId, single nullable externalReference, no origin); Google CalendarList/Event adapters with busy-content heuristic, timezone fallback ladder, exclusive all-day ends, and events.patch write bodies - web: EventDraft/view types, LocalEventRecord (v2, isDemo), and parseEventDraft gating every draft through the core input schemas - docs: target-contract section in the architecture domain model 499 new tests across the packages; all suites green (core 265, web 1260, backend 543, scripts 64), type-check and lint clean on new files. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 2b45920 commit 2195df4

28 files changed

Lines changed: 3959 additions & 0 deletions

docs/architecture/event-and-task-domain-model.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,37 @@
22

33
The event domain is the most cross-cutting part of Compass. Read this before changing event shape, recurrence logic, sync behavior, or local persistence.
44

5+
## Target Contracts (sub-calendar v1, pre-cutover)
6+
7+
Strict calendar-owned contracts exist alongside the legacy model and become the
8+
runtime shape at the sub-calendar v1 cutover. Until then the legacy sections
9+
below describe live behavior; the contracts describe where every consumer is
10+
headed.
11+
12+
- `packages/core/src/types/domain-primitives.ts` — branded ids, `DateOnly`,
13+
`DateTime` (RFC 3339 with offset), `TimeZone`, `SortOrder`, `RRule`.
14+
- `packages/core/src/types/event.contracts.ts` — canonical `Event`: required
15+
`calendarId`, discriminated `content` (`details` | `busy`), `schedule`
16+
(`timed` | `allDay` | `someday`, exclusive all-day ends), `recurrence`
17+
(`single` | `series` | `occurrence`), plus `BusyPeriod` for free/busy-only
18+
calendars.
19+
- `packages/core/src/types/event-command.contracts.ts` — create (optional
20+
client id), full-replace, delete, someday reorder, the someday↔scheduled
21+
transition (the only command that changes an event's calendar), list and
22+
availability queries.
23+
- `packages/core/src/types/calendar.contracts.ts``Calendar` read model with
24+
provider/access and derived capabilities (`getCalendarCapabilities`).
25+
- `packages/core/src/types/server-message.contracts.ts` — the discriminated
26+
SSE union every backend publish site must emit.
27+
- `packages/backend/src/calendar/calendar.record.ts`,
28+
`packages/backend/src/event/event.record.ts` — Mongo record shapes
29+
(ObjectIds/BSON dates, single nullable `externalReference`, no `origin`).
30+
- `packages/backend/src/event/google-event.adapter.ts` — Google↔record
31+
mapping; provider writes are `events.patch` bodies.
32+
- `packages/web/src/events/event-draft.types.ts` + `event-draft.parser.ts`
33+
the only intentionally incomplete event shape and the single parser that can
34+
turn it into a command.
35+
536
## Core Event Schema
637

738
Primary source:
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import { type calendar_v3 } from "@googleapis/calendar";
2+
import { ObjectId } from "mongodb";
3+
import {
4+
CalendarSchema,
5+
getCalendarCapabilities,
6+
} from "@core/types/calendar.contracts";
7+
import { type CalendarRecord } from "@backend/calendar/calendar.record";
8+
import {
9+
mapCalendarRecord,
10+
mapGoogleCalendar,
11+
} from "@backend/calendar/calendar.record.mapper";
12+
13+
const fullEntry = (): calendar_v3.Schema$CalendarListEntry => ({
14+
id: "gcal-1",
15+
etag: "etag-1",
16+
summary: "Work",
17+
summaryOverride: "My Work",
18+
description: "Company schedule",
19+
timeZone: "America/Denver",
20+
foregroundColor: "#ffffff",
21+
backgroundColor: "#5b6cff",
22+
accessRole: "writer",
23+
primary: false,
24+
selected: true,
25+
});
26+
27+
describe("mapGoogleCalendar", () => {
28+
const userId = new ObjectId();
29+
30+
it("maps a full entry", () => {
31+
const record = mapGoogleCalendar(fullEntry(), { userId });
32+
expect(record.name).toBe("My Work");
33+
expect(record.description).toBe("Company schedule");
34+
expect(record.timeZone).toBe("America/Denver");
35+
expect(record.foregroundColor).toBe("#ffffff");
36+
expect(record.backgroundColor).toBe("#5b6cff");
37+
expect(record.access).toBe("writer");
38+
expect(record.isPrimary).toBe(false);
39+
expect(record.isVisible).toBe(true);
40+
expect(record.isActive).toBe(true);
41+
expect(record.userId).toBe(userId);
42+
expect(record.source).toEqual({
43+
provider: "google",
44+
calendarId: "gcal-1",
45+
etag: "etag-1",
46+
});
47+
});
48+
49+
it("prefers summaryOverride over summary", () => {
50+
const record = mapGoogleCalendar(
51+
{ ...fullEntry(), summaryOverride: "Custom name" },
52+
{ userId },
53+
);
54+
expect(record.name).toBe("Custom name");
55+
});
56+
57+
it("falls back to summary when summaryOverride is absent", () => {
58+
const entry = fullEntry();
59+
delete entry.summaryOverride;
60+
const record = mapGoogleCalendar(entry, { userId });
61+
expect(record.name).toBe("Work");
62+
});
63+
64+
it("defaults missing colors", () => {
65+
const entry = fullEntry();
66+
delete entry.foregroundColor;
67+
delete entry.backgroundColor;
68+
const record = mapGoogleCalendar(entry, { userId });
69+
expect(record.backgroundColor).toBe("#9e9e9e");
70+
expect(record.foregroundColor).toBe("#000000");
71+
});
72+
73+
it("seeds isVisible from selected only when there is no existing record", () => {
74+
const record = mapGoogleCalendar(
75+
{ ...fullEntry(), selected: false },
76+
{ userId },
77+
);
78+
expect(record.isVisible).toBe(false);
79+
});
80+
81+
it("preserves _id and isVisible from an existing record", () => {
82+
const existingId = new ObjectId();
83+
const record = mapGoogleCalendar(
84+
{ ...fullEntry(), selected: false },
85+
{ userId, existing: { _id: existingId, isVisible: true } },
86+
);
87+
expect(record._id).toBe(existingId);
88+
expect(record.isVisible).toBe(true);
89+
});
90+
91+
it("maps reader and freeBusyReader access roles", () => {
92+
const reader = mapGoogleCalendar(
93+
{ ...fullEntry(), accessRole: "reader" },
94+
{ userId },
95+
);
96+
expect(reader.access).toBe("reader");
97+
98+
const freeBusyReader = mapGoogleCalendar(
99+
{ ...fullEntry(), accessRole: "freeBusyReader" },
100+
{ userId },
101+
);
102+
expect(freeBusyReader.access).toBe("freeBusyReader");
103+
});
104+
105+
it("throws when id is missing", () => {
106+
const entry = fullEntry();
107+
delete entry.id;
108+
expect(() => mapGoogleCalendar(entry, { userId })).toThrow();
109+
});
110+
111+
it("throws when etag is missing", () => {
112+
const entry = fullEntry();
113+
delete entry.etag;
114+
expect(() => mapGoogleCalendar(entry, { userId })).toThrow();
115+
});
116+
117+
it("throws when accessRole is invalid", () => {
118+
const entry = { ...fullEntry(), accessRole: "bogus" };
119+
expect(() => mapGoogleCalendar(entry, { userId })).toThrow();
120+
});
121+
});
122+
123+
describe("mapCalendarRecord", () => {
124+
const buildRecord = (
125+
overrides: Partial<CalendarRecord> = {},
126+
): CalendarRecord => ({
127+
_id: new ObjectId(),
128+
userId: new ObjectId(),
129+
name: "Work",
130+
description: "",
131+
timeZone: "America/Denver",
132+
foregroundColor: "#ffffff",
133+
backgroundColor: "#5b6cff",
134+
access: "writer",
135+
isPrimary: false,
136+
isVisible: true,
137+
isActive: true,
138+
source: { provider: "google", calendarId: "gcal-1", etag: "etag-1" },
139+
createdAt: new Date(),
140+
updatedAt: null,
141+
...overrides,
142+
});
143+
144+
it("produces output that parses with CalendarSchema", () => {
145+
const record = buildRecord();
146+
const calendar = mapCalendarRecord(record);
147+
expect(() => CalendarSchema.parse(calendar)).not.toThrow();
148+
expect(calendar.id).toBe(record._id.toHexString());
149+
expect(calendar.provider).toBe("google");
150+
});
151+
152+
it("derives capabilities from the access role", () => {
153+
const record = buildRecord({ access: "freeBusyReader" });
154+
const calendar = mapCalendarRecord(record);
155+
expect(calendar.capabilities).toEqual(
156+
getCalendarCapabilities("freeBusyReader"),
157+
);
158+
});
159+
160+
it("does not leak provider ids, etags, or userId", () => {
161+
const record = buildRecord();
162+
const calendar = mapCalendarRecord(record) as unknown as Record<
163+
string,
164+
unknown
165+
>;
166+
expect(calendar).not.toHaveProperty("userId");
167+
expect(calendar).not.toHaveProperty("source");
168+
expect(calendar).not.toHaveProperty("etag");
169+
});
170+
});
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { type calendar_v3 } from "@googleapis/calendar";
2+
import { ObjectId } from "mongodb";
3+
import {
4+
type Calendar,
5+
CalendarAccessSchema,
6+
getCalendarCapabilities,
7+
} from "@core/types/calendar.contracts";
8+
import { type CalendarId, type TimeZone } from "@core/types/domain-primitives";
9+
import { type CalendarRecord } from "@backend/calendar/calendar.record";
10+
11+
export const mapGoogleCalendar = (
12+
entry: calendar_v3.Schema$CalendarListEntry,
13+
context: {
14+
userId: ObjectId;
15+
existing?: Pick<CalendarRecord, "_id" | "isVisible">;
16+
},
17+
): CalendarRecord => {
18+
if (!entry.id) {
19+
throw new Error("Google calendar entry is missing an id");
20+
}
21+
if (!entry.etag) {
22+
throw new Error("Google calendar entry is missing an etag");
23+
}
24+
25+
const accessResult = CalendarAccessSchema.safeParse(entry.accessRole);
26+
if (!accessResult.success) {
27+
throw new Error(
28+
`Google calendar entry has an invalid accessRole: ${String(entry.accessRole)}`,
29+
);
30+
}
31+
32+
return {
33+
_id: context.existing?._id ?? new ObjectId(),
34+
userId: context.userId,
35+
name: entry.summaryOverride ?? entry.summary ?? "",
36+
description: entry.description ?? "",
37+
timeZone: (entry.timeZone ?? null) as TimeZone | null,
38+
// Match the legacy Google calendar mapper's defaults (map.calendar.ts).
39+
backgroundColor: entry.backgroundColor ?? "#9e9e9e",
40+
foregroundColor: entry.foregroundColor ?? "#000000",
41+
access: accessResult.data,
42+
isPrimary: entry.primary ?? false,
43+
// Google's `selected` only seeds visibility on first insert; an existing
44+
// record's user-controlled visibility must never be overwritten by sync.
45+
isVisible: context.existing?.isVisible ?? entry.selected ?? true,
46+
isActive: true,
47+
source: {
48+
provider: "google",
49+
calendarId: entry.id,
50+
etag: entry.etag,
51+
},
52+
createdAt: new Date(),
53+
updatedAt: null,
54+
};
55+
};
56+
57+
export const mapCalendarRecord = (record: CalendarRecord): Calendar => ({
58+
id: record._id.toHexString() as CalendarId,
59+
name: record.name,
60+
description: record.description,
61+
timeZone: record.timeZone,
62+
foregroundColor: record.foregroundColor,
63+
backgroundColor: record.backgroundColor,
64+
provider: record.source.provider,
65+
access: record.access,
66+
capabilities: getCalendarCapabilities(record.access),
67+
isPrimary: record.isPrimary,
68+
isVisible: record.isVisible,
69+
isActive: record.isActive,
70+
});
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { ObjectId as BsonObjectId } from "bson";
2+
import { ObjectId } from "mongodb";
3+
import {
4+
CalendarRecordSchema,
5+
CalendarSourceRecordSchema,
6+
GoogleCalendarSourceRecordSchema,
7+
LocalCalendarSourceRecordSchema,
8+
} from "@backend/calendar/calendar.record";
9+
10+
const baseRecord = () => ({
11+
_id: new ObjectId(),
12+
userId: new ObjectId(),
13+
name: "Work",
14+
description: "",
15+
timeZone: "America/Denver",
16+
foregroundColor: "#ffffff",
17+
backgroundColor: "#5b6cff",
18+
access: "owner" as const,
19+
isPrimary: false,
20+
isVisible: true,
21+
isActive: true,
22+
source: { provider: "local" as const },
23+
createdAt: new Date(),
24+
updatedAt: null,
25+
});
26+
27+
describe("CalendarSourceRecordSchema", () => {
28+
it("parses a local source", () => {
29+
const result = LocalCalendarSourceRecordSchema.safeParse({
30+
provider: "local",
31+
});
32+
expect(result.success).toBe(true);
33+
});
34+
35+
it("parses a google source", () => {
36+
const result = GoogleCalendarSourceRecordSchema.safeParse({
37+
provider: "google",
38+
calendarId: "gcal-1",
39+
etag: "etag-1",
40+
});
41+
expect(result.success).toBe(true);
42+
});
43+
44+
it("rejects unknown keys", () => {
45+
const result = CalendarSourceRecordSchema.safeParse({
46+
provider: "local",
47+
extra: true,
48+
});
49+
expect(result.success).toBe(false);
50+
});
51+
});
52+
53+
describe("CalendarRecordSchema", () => {
54+
it("parses a valid local calendar record", () => {
55+
const result = CalendarRecordSchema.safeParse(baseRecord());
56+
expect(result.success).toBe(true);
57+
});
58+
59+
it("parses a valid google calendar record", () => {
60+
const result = CalendarRecordSchema.safeParse({
61+
...baseRecord(),
62+
access: "writer",
63+
source: {
64+
provider: "google",
65+
calendarId: "gcal-1",
66+
etag: "etag-1",
67+
},
68+
});
69+
expect(result.success).toBe(true);
70+
});
71+
72+
it("rejects a local-source calendar with non-owner access", () => {
73+
const result = CalendarRecordSchema.safeParse({
74+
...baseRecord(),
75+
access: "writer",
76+
});
77+
expect(result.success).toBe(false);
78+
if (!result.success) {
79+
expect(result.error.issues[0]?.path).toEqual(["access"]);
80+
}
81+
});
82+
83+
it("rejects unknown keys", () => {
84+
const result = CalendarRecordSchema.safeParse({
85+
...baseRecord(),
86+
extra: "nope",
87+
});
88+
expect(result.success).toBe(false);
89+
});
90+
91+
it("transforms a 24-hex string _id into an ObjectId instance", () => {
92+
const hex = new ObjectId().toHexString();
93+
const result = CalendarRecordSchema.safeParse({
94+
...baseRecord(),
95+
_id: hex,
96+
});
97+
expect(result.success).toBe(true);
98+
if (result.success) {
99+
expect(result.data._id).toBeInstanceOf(BsonObjectId);
100+
}
101+
});
102+
103+
it("accepts an ObjectId instance directly for _id", () => {
104+
const result = CalendarRecordSchema.safeParse(baseRecord());
105+
expect(result.success).toBe(true);
106+
if (result.success) {
107+
expect(result.data._id).toBeInstanceOf(BsonObjectId);
108+
}
109+
});
110+
});

0 commit comments

Comments
 (0)