@volter/twin-calcom 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +90 -0
  3. package/client/calcom-mirror.css +29 -0
  4. package/client/calcom-mirror.tsx +125 -0
  5. package/dist/client/calcom-mirror.bundle.js +321 -0
  6. package/dist/client/calcom-mirror.css +29 -0
  7. package/dist/client/calcom-mirror.d.ts +13 -0
  8. package/dist/client/calcom-mirror.js +61 -0
  9. package/dist/client/calcom-mirror.tsx +125 -0
  10. package/dist/src/calcom-budget.d.ts +52 -0
  11. package/dist/src/calcom-budget.js +145 -0
  12. package/dist/src/calcom-capabilities.d.ts +3 -0
  13. package/dist/src/calcom-capabilities.js +521 -0
  14. package/dist/src/calcom-conformance.d.ts +15 -0
  15. package/dist/src/calcom-conformance.js +86 -0
  16. package/dist/src/calcom-connector.d.ts +90 -0
  17. package/dist/src/calcom-connector.js +229 -0
  18. package/dist/src/calcom-mirror-ui.d.ts +23 -0
  19. package/dist/src/calcom-mirror-ui.js +100 -0
  20. package/dist/src/calcom-perform-harness.d.ts +5 -0
  21. package/dist/src/calcom-perform-harness.js +29 -0
  22. package/dist/src/calcom-server.d.ts +14 -0
  23. package/dist/src/calcom-server.js +31 -0
  24. package/dist/src/calcom-twin.d.ts +16 -0
  25. package/dist/src/calcom-twin.js +718 -0
  26. package/dist/src/cli.d.ts +2 -0
  27. package/dist/src/cli.js +31 -0
  28. package/dist/src/index.d.ts +11 -0
  29. package/dist/src/index.js +69 -0
  30. package/package.json +72 -0
  31. package/src/calcom-budget.ts +171 -0
  32. package/src/calcom-capabilities.ts +529 -0
  33. package/src/calcom-conformance.ts +92 -0
  34. package/src/calcom-connector.ts +258 -0
  35. package/src/calcom-mirror-ui.ts +110 -0
  36. package/src/calcom-perform-harness.ts +24 -0
  37. package/src/calcom-server.ts +39 -0
  38. package/src/calcom-twin.ts +675 -0
  39. package/src/cli.ts +29 -0
  40. package/src/index.ts +101 -0
@@ -0,0 +1,675 @@
1
+ // Cal.com twin REQUEST HANDLER — the canonical Cal.com v2 API surface for the twin and
2
+ // the QA stack's authoritative Cal.com. Contract: handleCalcomTwinRequest({method, path,
3
+ // body}) -> {status, body}. Backed by the @volter/world-core event/action-log kernel; response
4
+ // SHAPES are faithful to real Cal.com v2 (the `{ status, data }` envelope), not invented.
5
+ // HTTP wrapper: calcom-server.ts → createCalcomTwinServer.
6
+ //
7
+ // State lives in the action log (R18): writes are local actions, reads fold the projection.
8
+ // No real Cal.com is ever called. Auth is a faked-locally Bearer API key (`cal_...`); there
9
+ // is NO JWT / JWKS / webhook signing — Cal.com's API auth is a plain bearer key.
10
+ import { applyTwinWrite, projectResources, resolveSubjectId } from '@volter/world-core';
11
+
12
+ const SERVICE = 'calcom';
13
+
14
+ export type CalcomRequest = { method: string; path: string; body?: string; occurredAt?: string; root?: string; readOnly?: boolean; apiVersion?: string };
15
+ export type CalcomResponse = { status: number; body: unknown };
16
+
17
+ // Cal.com v2 pins behavior with a per-endpoint `cal-api-version` (YYYY-MM-DD) header. The
18
+ // twin pins the versions its shapes were built against; a request may echo its own.
19
+ export const TWIN_API_VERSION = '2024-08-13';
20
+
21
+ // ── the v2 envelope ──────────────────────────────────────────────────────────
22
+ // Every v2 response is { status: "success" | "error", data } (errors add { error }).
23
+ function success(data: unknown, status = 200): CalcomResponse {
24
+ return { status, body: { status: 'success', data } };
25
+ }
26
+ function failure(message: string, status = 400, code?: string): CalcomResponse {
27
+ return { status, body: { status: 'error', error: { code: code ?? errCode(status), message } } };
28
+ }
29
+ function errCode(status: number): string {
30
+ if (status === 404) return 'NotFoundException';
31
+ if (status === 401) return 'UnauthorizedException';
32
+ if (status === 409) return 'ConflictException';
33
+ return 'BadRequestException';
34
+ }
35
+
36
+ // ── id helpers (type-prefixed subject ids) ─────────────────────────────────────
37
+ // Subject ids are type-prefixed to stay self-describing across resource types (a
38
+ // convention — the kernel resolves a write's returned resource by (type, id)).
39
+ // Booking uid is the public handle; the numeric `id` and
40
+ // `uid` Cal.com returns are derived deterministically from a monotonic per-root counter.
41
+ function nextSeq(type: string, root?: string): number {
42
+ // derive a stable next number from how many of this type already exist + a base offset
43
+ // event_type base is 7100 so created ids never collide with DEFAULT_EVENT_TYPE.id (7001).
44
+ const base: Record<string, number> = { booking: 9000, event_type: 7100, schedule: 8000, team: 6000, membership: 4000, webhook: 3000 };
45
+ const count = list(type, root).length;
46
+ return (base[type] ?? 1000) + count + 1;
47
+ }
48
+
49
+ function list(type: string, root?: string): Record<string, unknown>[] {
50
+ return (projectResources(SERVICE, root).filter((r) => r.type === type) as unknown as Record<string, unknown>[])
51
+ .filter((r) => r.deleted !== true);
52
+ }
53
+ function getById(type: string, id: string, root?: string): Record<string, unknown> | undefined {
54
+ // protocol 2: after a landing adopted Cal.com's id for this subject, a caller on a branch may still name it
55
+ // as it was — the alias map is asked once, here, before any row is read
56
+ const subject = resolveSubjectId(SERVICE, type, id, root);
57
+ return list(type, root).find((r) => (r as { id: string }).id === subject);
58
+ }
59
+
60
+ // The twin's single seeded user (the API key owner) — Cal.com's GET /v2/me.
61
+ // Faithful to the QA stack's `/v2/me` shape.
62
+ const ME = {
63
+ id: 5000,
64
+ username: 'twin-calcom-admin',
65
+ email: 'admin@calcom.twin',
66
+ name: 'Cal.com Twin Admin',
67
+ avatarUrl: null,
68
+ bio: null,
69
+ timeFormat: 12,
70
+ defaultScheduleId: 8000,
71
+ weekStart: 'Monday',
72
+ timeZone: 'America/Los_Angeles',
73
+ organizationId: 6000,
74
+ organization: { id: 6000, isPlatform: true },
75
+ };
76
+
77
+ // The live profile = the seeded ME merged with any persisted PATCH /v2/me overlay (kernel-folded).
78
+ function meProfile(root?: string): Record<string, unknown> {
79
+ const overlay = getById('profile', 'profile_me', root);
80
+ if (!overlay) return ME;
81
+ const { type, updatedAt, id, ...rest } = overlay as Record<string, unknown>;
82
+ void type; void updatedAt; void id;
83
+ return { ...ME, ...rest };
84
+ }
85
+
86
+ // The twin ships one implicit default event type (id 7001) so a booking can be created out of
87
+ // the box (Cal.com's demo "30 Min Meeting"). It never requiresConfirmation.
88
+ const DEFAULT_EVENT_TYPE = { id: 7001, title: '30 Min Meeting', slug: 'consultation', lengthInMinutes: 30, requiresConfirmation: false } as Record<string, unknown>;
89
+
90
+ function parseBody(body?: string): Record<string, unknown> {
91
+ if (!body) return {};
92
+ try { return JSON.parse(body) as Record<string, unknown>; } catch { return {}; }
93
+ }
94
+
95
+ async function write(type: string, id: string, fields: Record<string, unknown>, root?: string, occurredAt?: string): Promise<void> {
96
+ await applyTwinWrite(SERVICE, { operation: `set.${type}`, subjectType: type, subjectId: id, fields, ...(occurredAt ? { occurredAt } : {}), actor: { kind: 'agent' } }, root);
97
+ }
98
+
99
+ // Strip the kernel's projection-internal fields (id/type/updatedAt) and re-key the public id.
100
+ function emit(r: Record<string, unknown>, publicIdKey = 'id'): Record<string, unknown> {
101
+ const { type, updatedAt, id, ...rest } = r as { type?: unknown; updatedAt?: unknown; id?: unknown } & Record<string, unknown>;
102
+ void type; void updatedAt; void publicIdKey;
103
+ return rest;
104
+ }
105
+
106
+ // ── take/skip pagination (Cal.com v2 list shape) ──────────────────────────────
107
+ // Cal.com list endpoints page with `take` (page size) + `skip` (offset). When neither is
108
+ // present we return the full array (back-compat with non-paginated callers). When present we
109
+ // slice and attach a sibling `pagination` object: { totalCount, take, skip, hasMore }.
110
+ function paginated(rows: unknown[], query: URLSearchParams): CalcomResponse {
111
+ const takeRaw = query.get('take');
112
+ const skipRaw = query.get('skip');
113
+ if (takeRaw === null && skipRaw === null) return success(rows);
114
+ const skip = Math.max(0, Number.isFinite(Number(skipRaw)) ? Number(skipRaw) : 0);
115
+ const take = takeRaw === null ? rows.length : Math.max(0, Number(takeRaw) || 0);
116
+ const page = rows.slice(skip, skip + take);
117
+ const body = { status: 'success', data: page, pagination: { totalCount: rows.length, take, skip, hasMore: skip + take < rows.length } };
118
+ return { status: 200, body };
119
+ }
120
+
121
+ // ── slots availability (deterministic, derived) ───────────────────────────────
122
+ // Cal.com's GET /v2/slots returns availability keyed by date: { "<YYYY-MM-DD>": [{ start }] }.
123
+ // We derive a deterministic two-slot day (16:00 + 17:00 UTC) from a base date so the verify is
124
+ // reproducible offline. `start` (or `startTime`) query param picks the base day; default = a
125
+ // fixed reference date so the response is stable without a clock.
126
+ const SLOT_REF = '2026-07-01T00:00:00.000Z';
127
+ function computeSlots(query: URLSearchParams): Record<string, { start: string }[]> {
128
+ const startParam = query.get('start') ?? query.get('startTime');
129
+ const base = new Date(startParam && !Number.isNaN(Date.parse(startParam)) ? startParam : SLOT_REF);
130
+ base.setUTCHours(16, 0, 0, 0);
131
+ const second = new Date(base.getTime() + 60 * 60 * 1000);
132
+ const dateKey = base.toISOString().slice(0, 10);
133
+ return { [dateKey]: [{ start: base.toISOString() }, { start: second.toISOString() }] };
134
+ }
135
+
136
+ // ── booking shape (faithful to v2 BookingOutput + the QA stack's shape) ────────
137
+ function bookingView(r: Record<string, unknown>): Record<string, unknown> {
138
+ return emit(r);
139
+ }
140
+
141
+ export async function handleCalcomTwinRequest(req: CalcomRequest): Promise<CalcomResponse> {
142
+ const { method, root, occurredAt } = req;
143
+ const readOnly = req.readOnly ?? false;
144
+ const isWrite = method !== 'GET' && method !== 'HEAD';
145
+ let url: URL;
146
+ try { url = new URL(req.path, 'http://twin.local'); } catch { return failure('invalid path', 400); }
147
+ // Normalize: tolerate both /v2/<x> and /<x> (the QA sandbox hits both forms).
148
+ const pathname = url.pathname.replace(/^\/v2(\/|$)/, '/');
149
+ const parts = pathname.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean);
150
+ const query = url.searchParams;
151
+ const body = parseBody(req.body);
152
+
153
+ if (readOnly && isWrite) return failure('this twin was started read-only; omit readOnly to accept writes', 405, 'ReadOnlyException');
154
+
155
+ // GET /v2/me — the profile is ME merged with any persisted profile overlay (PATCH /v2/me)
156
+ if (parts[0] === 'me' && parts.length === 1) {
157
+ if (method === 'GET') return success(meProfile(root));
158
+ // PATCH /v2/me — update a small set of mutable profile fields (Cal.com's UpdateOrgMembership-ish)
159
+ if (method === 'PATCH') {
160
+ const allowed = ['name', 'email', 'timeZone', 'weekStart', 'timeFormat', 'bio', 'avatarUrl', 'locale', 'username'] as const;
161
+ const patch: Record<string, unknown> = {};
162
+ for (const k of allowed) if (body[k] !== undefined) patch[k] = body[k];
163
+ if (Object.keys(patch).length === 0) return failure('at least one updatable profile field is required', 400);
164
+ await write('profile', 'profile_me', patch, root, occurredAt);
165
+ return success(meProfile(root));
166
+ }
167
+ }
168
+
169
+ // GET /v2/slots (availability)
170
+ if (method === 'GET' && parts[0] === 'slots' && parts.length === 1) {
171
+ return success(computeSlots(query));
172
+ }
173
+ // POST /v2/slots/reservations (reserve a slot)
174
+ if (parts[0] === 'slots' && parts[1] === 'reservations') {
175
+ if (method === 'POST' && parts.length === 2) {
176
+ const eventTypeId = body.eventTypeId;
177
+ const slotStart = body.slotStart ?? body.start;
178
+ if (eventTypeId === undefined) return failure('eventTypeId is required', 400);
179
+ if (slotStart === undefined) return failure('slotStart is required', 400);
180
+ const uid = `slot_${seqHex('slot', root)}`;
181
+ await write('slot', uid, { reservationUid: uid, eventTypeId, slotStart, slotDuration: body.slotDuration ?? 30 }, root, occurredAt);
182
+ return success({ reservationUid: uid, eventTypeId, slotStart, slotEnd: addMinutes(String(slotStart), Number(body.slotDuration ?? 30)) }, 201);
183
+ }
184
+ // GET/DELETE /v2/slots/reservations/{uid}
185
+ if (parts.length === 3) {
186
+ const slotId = `slot_${stripPrefix(parts[2]!, 'slot_')}`;
187
+ const found = getById('slot', slotId, root) ?? getById('slot', parts[2]!, root);
188
+ if (method === 'GET') {
189
+ if (!found) return failure(`No reservation found for uid ${parts[2]}`, 404);
190
+ return success(emit(found));
191
+ }
192
+ // DELETE /v2/slots/reservations/{uid} — release a held slot (then GET → 404)
193
+ if (method === 'DELETE') {
194
+ if (!found) return failure(`No reservation found for uid ${parts[2]}`, 404);
195
+ await write('slot', (found.id as string), { deleted: true }, root, occurredAt);
196
+ return success({ reservationUid: parts[2] });
197
+ }
198
+ }
199
+ }
200
+
201
+ // ── Bookings ────────────────────────────────────────────────────────────────
202
+ if (parts[0] === 'bookings') {
203
+ // POST /v2/bookings (create)
204
+ if (method === 'POST' && parts.length === 1) {
205
+ const start = body.start;
206
+ if (start === undefined || typeof start !== 'string' || Number.isNaN(Date.parse(start))) {
207
+ return failure('start is required and must be an ISO 8601 datetime', 400);
208
+ }
209
+ const attendee = (body.attendee as Record<string, unknown> | undefined) ?? {};
210
+ if (!attendee.name || !attendee.email) return failure('attendee.name and attendee.email are required', 400);
211
+ const eventTypeId = (body.eventTypeId as number | undefined) ?? DEFAULT_EVENT_TYPE.id;
212
+ // Faithful: a provided eventTypeId that resolves to no event type is a 404 (the twin ships
213
+ // one implicit default event type, id 7001, that always exists — Cal.com's demo flow).
214
+ const evt = eventTypeId === DEFAULT_EVENT_TYPE.id ? DEFAULT_EVENT_TYPE : getById('event_type', `evt_${eventTypeId}`, root);
215
+ if (!evt) return failure(`No event type found for id ${eventTypeId}`, 404);
216
+ const duration = (body.lengthInMinutes as number | undefined) ?? (evt.lengthInMinutes as number | undefined) ?? 30;
217
+ const numId = nextSeq('booking', root);
218
+ const uid = `booking_${seqHex('booking', root)}`;
219
+ const end = addMinutes(start, duration);
220
+ // Faithful to Cal.com: a booking on an event type that requiresConfirmation starts
221
+ // `pending` (the host must confirm/decline it); otherwise it is immediately `accepted`.
222
+ const initialStatus = evt?.requiresConfirmation === true ? 'pending' : 'accepted';
223
+ const record = {
224
+ bid: numId,
225
+ uid,
226
+ title: (body.title as string | undefined) ?? (evt?.title as string | undefined) ?? 'Consultation between Cal.com Twin Admin and ' + attendee.name,
227
+ status: initialStatus,
228
+ start,
229
+ end,
230
+ duration,
231
+ eventTypeId,
232
+ eventType: { id: eventTypeId, slug: (evt?.slug as string | undefined) ?? 'consultation' },
233
+ attendees: [{
234
+ name: attendee.name,
235
+ email: attendee.email,
236
+ timeZone: attendee.timeZone ?? 'America/Los_Angeles',
237
+ language: attendee.language ?? 'en',
238
+ absent: false,
239
+ }],
240
+ hosts: [{ id: ME.id, name: ME.name, timeZone: ME.timeZone, mandatory: true }],
241
+ guests: (body.guests as unknown[] | undefined) ?? [],
242
+ location: (body.location as unknown) ?? null,
243
+ meetingUrl: (body.meetingUrl as unknown) ?? null,
244
+ metadata: (body.metadata as Record<string, unknown> | undefined) ?? {},
245
+ };
246
+ await write('booking', uid, record as Record<string, unknown>, root, occurredAt);
247
+ const out = await renderBooking(uid, root);
248
+ return success(out, 201);
249
+ }
250
+ // GET /v2/bookings (list — with status/attendeeEmail/eventTypeId filters + take/skip)
251
+ if (method === 'GET' && parts.length === 1) {
252
+ const all = list('booking', root).filter((b) => (b.uid as string | undefined)); // bookings only
253
+ let filtered = all;
254
+ const status = query.get('status');
255
+ if (status) filtered = filtered.filter((b) => b.status === status);
256
+ const attendeeEmail = query.get('attendeeEmail');
257
+ if (attendeeEmail) filtered = filtered.filter((b) => Array.isArray(b.attendees) && (b.attendees as Record<string, unknown>[]).some((a) => a.email === attendeeEmail));
258
+ const eventTypeId = query.get('eventTypeId');
259
+ if (eventTypeId) filtered = filtered.filter((b) => String(b.eventTypeId) === eventTypeId);
260
+ const rendered = await Promise.all(filtered.map((b) => renderBooking(b.id as string, root)));
261
+ return paginated(rendered, query);
262
+ }
263
+ // GET /v2/bookings/{uid}
264
+ if (method === 'GET' && parts.length === 2) {
265
+ const uid = canonicalBookingId(parts[1]!);
266
+ const found = getById('booking', uid, root);
267
+ if (!found) return failure(`No booking found for uid ${parts[1]}`, 404);
268
+ return success(await renderBooking(uid, root));
269
+ }
270
+ // GET /v2/bookings/{uid}/references | /recordings | /transcripts (read sub-resources)
271
+ if (method === 'GET' && parts.length === 3) {
272
+ const uid = canonicalBookingId(parts[1]!);
273
+ const sub = parts[2]!;
274
+ const found = getById('booking', uid, root);
275
+ if (!found) return failure(`No booking found for uid ${parts[1]}`, 404);
276
+ if (sub === 'references') {
277
+ // Booking references = the per-integration external handles a booking accrues (calendar
278
+ // event id, conferencing meeting id). A local twin has no real providers, so a freshly
279
+ // created twin booking carries no external references → an empty array (faithful: the
280
+ // field exists and is queryable, it is simply empty without a real integration).
281
+ return success((found.references as unknown[] | undefined) ?? []);
282
+ }
283
+ if (sub === 'recordings') {
284
+ // Recordings are produced by the real video provider (Daily). A local twin never records,
285
+ // so the list is empty — but the endpoint exists and returns the v2 array shape.
286
+ return success((found.recordings as unknown[] | undefined) ?? []);
287
+ }
288
+ // unknown sub-resource → 404 like the vendor
289
+ return failure(`Cannot GET /v2/bookings/${parts[1]}/${sub}`, 404);
290
+ }
291
+ // POST /v2/bookings/{uid}/cancel | /reschedule | /confirm | /decline | /mark-absent
292
+ if (method === 'POST' && parts.length === 3) {
293
+ const uid = canonicalBookingId(parts[1]!);
294
+ const action = parts[2]!;
295
+ const found = getById('booking', uid, root);
296
+ if (!found) return failure(`No booking found for uid ${parts[1]}`, 404);
297
+ const cur = found.status as string;
298
+
299
+ if (action === 'mark-absent') {
300
+ // Mark host and/or attendees absent (no-show). Cal.com flags `absent` on the matching
301
+ // attendees and `noShowHost` on the booking; the booking status is unchanged.
302
+ if (cur === 'cancelled') return failure('Cannot mark a cancelled booking absent', 400);
303
+ const noShowHost = body.noShowHost === true;
304
+ const absentEmails = new Set((Array.isArray(body.attendees) ? body.attendees as Record<string, unknown>[] : []).filter((a) => a.absent === true).map((a) => String(a.email)));
305
+ const attendees = (found.attendees as Record<string, unknown>[]).map((a) => absentEmails.has(String(a.email)) ? { ...a, absent: true } : a);
306
+ await write('booking', uid, { attendees, noShowHost }, root, occurredAt);
307
+ return success({ ...(await renderBooking(uid, root)), noShowHost });
308
+ }
309
+
310
+ if (action === 'cancel') {
311
+ if (cur === 'cancelled') return failure(`Booking ${parts[1]} is already cancelled`, 400, 'ConflictException');
312
+ await write('booking', uid, { status: 'cancelled', cancellationReason: (body.cancellationReason as string | undefined) ?? null }, root, occurredAt);
313
+ return success({ ...(await renderBooking(uid, root)), status: 'cancelled' });
314
+ }
315
+ if (action === 'reschedule') {
316
+ if (cur === 'cancelled') return failure(`Cannot reschedule a cancelled booking`, 400);
317
+ const start = body.start;
318
+ if (start === undefined || typeof start !== 'string' || Number.isNaN(Date.parse(start))) return failure('start is required to reschedule', 400);
319
+ const newUid = `booking_${seqHex('booking', root)}`;
320
+ const duration = found.duration as number;
321
+ const rec = { ...found, uid: newUid, bid: nextSeq('booking', root), start, end: addMinutes(start, duration), status: 'accepted', reschedulingReason: (body.reschedulingReason as string | undefined) ?? null, fromReschedule: uid };
322
+ await write('booking', newUid, rec as Record<string, unknown>, root, occurredAt);
323
+ await write('booking', uid, { status: 'cancelled', rescheduled: true }, root, occurredAt);
324
+ return success(await renderBooking(newUid, root), 201);
325
+ }
326
+ if (action === 'confirm') {
327
+ if (cur !== 'pending') return failure(`Only a pending booking can be confirmed (current status: ${cur})`, 400);
328
+ await write('booking', uid, { status: 'accepted' }, root, occurredAt);
329
+ return success(await renderBooking(uid, root));
330
+ }
331
+ if (action === 'decline') {
332
+ if (cur !== 'pending') return failure(`Only a pending booking can be declined (current status: ${cur})`, 400);
333
+ await write('booking', uid, { status: 'rejected', rejectionReason: (body.reason as string | undefined) ?? null }, root, occurredAt);
334
+ return success(await renderBooking(uid, root));
335
+ }
336
+ return failure(`Unknown booking action: ${action}`, 404);
337
+ }
338
+ }
339
+
340
+ // ── Event types ───────────────────────────────────────────────────────────
341
+ if (parts[0] === 'event-types') {
342
+ if (method === 'POST' && parts.length === 1) {
343
+ if (!body.title || !body.slug || body.lengthInMinutes === undefined) return failure('title, slug and lengthInMinutes are required', 400);
344
+ const numId = nextSeq('event_type', root);
345
+ const id = `evt_${numId}`;
346
+ const rec = {
347
+ evtId: numId,
348
+ title: body.title,
349
+ slug: body.slug,
350
+ lengthInMinutes: body.lengthInMinutes,
351
+ description: body.description ?? null,
352
+ ownerId: ME.id,
353
+ hidden: body.hidden ?? false,
354
+ scheduleId: body.scheduleId ?? null,
355
+ requiresConfirmation: body.requiresConfirmation ?? false,
356
+ bookingUrl: `https://cal.com/${ME.username}/${body.slug}`,
357
+ locations: body.locations ?? [],
358
+ bookingFields: body.bookingFields ?? [],
359
+ seatsPerTimeSlot: body.seatsPerTimeSlot ?? null,
360
+ seatsShowAttendees: body.seatsShowAttendees ?? false,
361
+ seatsShowAvailabilityCount: body.seatsShowAvailabilityCount ?? true,
362
+ beforeEventBuffer: body.beforeEventBuffer ?? 0,
363
+ afterEventBuffer: body.afterEventBuffer ?? 0,
364
+ minimumBookingNotice: body.minimumBookingNotice ?? 0,
365
+ };
366
+ await write('event_type', id, rec as Record<string, unknown>, root, occurredAt);
367
+ return success(eventTypeView(getById('event_type', id, root)!), 201);
368
+ }
369
+ if (method === 'GET' && parts.length === 1) {
370
+ // Personal event types only (team event types live under /v2/teams/{id}/event-types).
371
+ let rows = list('event_type', root).filter((e) => e.teamId === undefined || e.teamId === null).map(eventTypeView);
372
+ // Public lookup: GET /v2/event-types?username=&eventSlug= — filter to one owner/slug. Cal.com
373
+ // requires username when eventSlug is given. The implicit default ("30 Min Meeting") belongs
374
+ // to the seeded admin and is included so a fresh account is still publicly bookable.
375
+ const username = query.get('username');
376
+ const eventSlug = query.get('eventSlug');
377
+ if (eventSlug !== null && username === null) return failure('username is required when eventSlug is provided', 400);
378
+ if (username !== null) {
379
+ if (username === ME.username) rows = [defaultEventTypeView(), ...rows];
380
+ else rows = []; // unknown user has no event types
381
+ if (eventSlug !== null) rows = rows.filter((e) => e.slug === eventSlug);
382
+ }
383
+ return paginated(rows, query);
384
+ }
385
+ if (parts.length === 2) {
386
+ const id = parts[1]!.startsWith('evt_') ? parts[1]! : `evt_${parts[1]}`;
387
+ const found = getById('event_type', id, root);
388
+ if (method === 'GET') {
389
+ if (!found) return failure(`No event type found for id ${parts[1]}`, 404);
390
+ return success(eventTypeView(found));
391
+ }
392
+ if (method === 'PATCH') {
393
+ if (!found) return failure(`No event type found for id ${parts[1]}`, 404);
394
+ await write('event_type', id, body, root, occurredAt);
395
+ return success(eventTypeView(getById('event_type', id, root)!));
396
+ }
397
+ if (method === 'DELETE') {
398
+ if (!found) return failure(`No event type found for id ${parts[1]}`, 404);
399
+ await write('event_type', id, { deleted: true }, root, occurredAt);
400
+ return success({ id: found.evtId, lengthInMinutes: found.lengthInMinutes, title: found.title, slug: found.slug });
401
+ }
402
+ }
403
+ }
404
+
405
+ // ── Schedules / availability ─────────────────────────────────────────────────
406
+ if (parts[0] === 'schedules') {
407
+ if (method === 'POST' && parts.length === 1) {
408
+ if (!body.name || !body.timeZone || body.isDefault === undefined) return failure('name, timeZone and isDefault are required', 400);
409
+ const numId = nextSeq('schedule', root);
410
+ const id = `sched_${numId}`;
411
+ const rec = {
412
+ schedId: numId,
413
+ ownerId: ME.id,
414
+ name: body.name,
415
+ timeZone: body.timeZone,
416
+ availability: body.availability ?? [{ days: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'], startTime: '09:00', endTime: '17:00' }],
417
+ isDefault: body.isDefault,
418
+ overrides: body.overrides ?? [],
419
+ };
420
+ await write('schedule', id, rec as Record<string, unknown>, root, occurredAt);
421
+ return success(scheduleView(getById('schedule', id, root)!), 201);
422
+ }
423
+ if (method === 'GET' && parts.length === 1) {
424
+ return success(list('schedule', root).map(scheduleView));
425
+ }
426
+ if (parts.length === 2) {
427
+ const id = parts[1]!.startsWith('sched_') ? parts[1]! : `sched_${parts[1]}`;
428
+ const found = getById('schedule', id, root);
429
+ if (method === 'GET') {
430
+ if (!found) return failure(`No schedule found for id ${parts[1]}`, 404);
431
+ return success(scheduleView(found));
432
+ }
433
+ if (method === 'PATCH') {
434
+ if (!found) return failure(`No schedule found for id ${parts[1]}`, 404);
435
+ await write('schedule', id, body, root, occurredAt);
436
+ return success(scheduleView(getById('schedule', id, root)!));
437
+ }
438
+ if (method === 'DELETE') {
439
+ if (!found) return failure(`No schedule found for id ${parts[1]}`, 404);
440
+ await write('schedule', id, { deleted: true }, root, occurredAt);
441
+ return success({ id: found.schedId });
442
+ }
443
+ }
444
+ }
445
+
446
+ // ── Teams ────────────────────────────────────────────────────────────────────
447
+ if (parts[0] === 'teams') {
448
+ if (method === 'POST' && parts.length === 1) {
449
+ if (!body.name) return failure('name is required', 400);
450
+ const numId = nextSeq('team', root);
451
+ const id = `team_${numId}`;
452
+ await write('team', id, { teamId: numId, name: body.name, slug: body.slug ?? slugify(String(body.name)), isOrganization: body.isOrganization ?? false }, root, occurredAt);
453
+ return success(teamView(getById('team', id, root)!), 201);
454
+ }
455
+ if (method === 'GET' && parts.length === 1) return success(list('team', root).map(teamView));
456
+ if (parts.length === 2) {
457
+ const id = parts[1]!.startsWith('team_') ? parts[1]! : `team_${parts[1]}`;
458
+ const found = getById('team', id, root);
459
+ if (method === 'GET') {
460
+ if (!found) return failure(`No team found for id ${parts[1]}`, 404);
461
+ return success(teamView(found));
462
+ }
463
+ // PATCH /v2/teams/{id} — update mutable team fields
464
+ if (method === 'PATCH') {
465
+ if (!found) return failure(`No team found for id ${parts[1]}`, 404);
466
+ const patch: Record<string, unknown> = {};
467
+ for (const k of ['name', 'slug', 'bio', 'hideBranding', 'isPrivate'] as const) if (body[k] !== undefined) patch[k] = body[k];
468
+ if (Object.keys(patch).length === 0) return failure('at least one updatable team field is required', 400);
469
+ await write('team', id, patch, root, occurredAt);
470
+ return success(teamView(getById('team', id, root)!));
471
+ }
472
+ // DELETE /v2/teams/{id} — delete (then 404)
473
+ if (method === 'DELETE') {
474
+ if (!found) return failure(`No team found for id ${parts[1]}`, 404);
475
+ await write('team', id, { deleted: true }, root, occurredAt);
476
+ return success(teamView(found));
477
+ }
478
+ }
479
+ // Team event types: /v2/teams/{teamId}/event-types ...
480
+ if (parts[2] === 'event-types') {
481
+ const teamId = parts[1]!.startsWith('team_') ? parts[1]! : `team_${parts[1]}`;
482
+ const team = getById('team', teamId, root);
483
+ if (!team) return failure(`No team found for id ${parts[1]}`, 404);
484
+ const teamNumId = team.teamId as number;
485
+ if (method === 'POST' && parts.length === 3) {
486
+ if (!body.title || !body.slug || body.lengthInMinutes === undefined) return failure('title, slug and lengthInMinutes are required', 400);
487
+ const numId = nextSeq('event_type', root);
488
+ const id = `evt_${numId}`;
489
+ const rec = {
490
+ evtId: numId, title: body.title, slug: body.slug, lengthInMinutes: body.lengthInMinutes,
491
+ description: body.description ?? null, ownerId: ME.id, hidden: body.hidden ?? false,
492
+ scheduleId: body.scheduleId ?? null, requiresConfirmation: body.requiresConfirmation ?? false,
493
+ bookingUrl: `https://cal.com/team/${team.slug}/${body.slug}`,
494
+ teamId: teamNumId, schedulingType: body.schedulingType ?? 'collective',
495
+ locations: body.locations ?? [], bookingFields: body.bookingFields ?? [],
496
+ };
497
+ await write('event_type', id, rec as Record<string, unknown>, root, occurredAt);
498
+ return success(eventTypeView(getById('event_type', id, root)!), 201);
499
+ }
500
+ if (method === 'GET' && parts.length === 3) {
501
+ return success(list('event_type', root).filter((e) => e.teamId === teamNumId).map(eventTypeView));
502
+ }
503
+ }
504
+ // Memberships: /teams/{teamId}/memberships ...
505
+ if (parts[2] === 'memberships') {
506
+ const teamId = parts[1]!.startsWith('team_') ? parts[1]! : `team_${parts[1]}`;
507
+ if (!getById('team', teamId, root)) return failure(`No team found for id ${parts[1]}`, 404);
508
+ if (method === 'POST' && parts.length === 3) {
509
+ if (body.userId === undefined) return failure('userId is required', 400);
510
+ const numId = nextSeq('membership', root);
511
+ const id = `mem_${numId}`;
512
+ await write('membership', id, { memId: numId, teamId, userId: body.userId, role: body.role ?? 'MEMBER', accepted: body.accepted ?? true }, root, occurredAt);
513
+ return success(membershipView(getById('membership', id, root)!), 201);
514
+ }
515
+ if (method === 'GET' && parts.length === 3) {
516
+ return success(list('membership', root).filter((m) => m.teamId === teamId).map(membershipView));
517
+ }
518
+ // /teams/{teamId}/memberships/{membershipId} (read/update/delete one)
519
+ if (parts.length === 4) {
520
+ const memId = parts[3]!.startsWith('mem_') ? parts[3]! : `mem_${parts[3]}`;
521
+ const found = getById('membership', memId, root);
522
+ if (!found || found.teamId !== teamId) return failure(`No membership found for id ${parts[3]}`, 404);
523
+ if (method === 'GET') return success(membershipView(found));
524
+ if (method === 'PATCH') {
525
+ const patch: Record<string, unknown> = {};
526
+ for (const k of ['role', 'accepted', 'disableImpersonation'] as const) if (body[k] !== undefined) patch[k] = body[k];
527
+ if (Object.keys(patch).length === 0) return failure('at least one updatable membership field is required', 400);
528
+ await write('membership', memId, patch, root, occurredAt);
529
+ return success(membershipView(getById('membership', memId, root)!));
530
+ }
531
+ if (method === 'DELETE') {
532
+ await write('membership', memId, { deleted: true }, root, occurredAt);
533
+ return success(membershipView(found));
534
+ }
535
+ }
536
+ }
537
+ }
538
+
539
+ // ── Webhooks (registration CRUD only — NO signing) ────────────────────────────
540
+ if (parts[0] === 'webhooks') {
541
+ if (method === 'POST' && parts.length === 1) {
542
+ if (!body.subscriberUrl || !Array.isArray(body.triggers) || body.triggers.length === 0) {
543
+ return failure('subscriberUrl and a non-empty triggers[] are required', 400);
544
+ }
545
+ const numId = nextSeq('webhook', root);
546
+ const id = `wh_${numId}`;
547
+ await write('webhook', id, { whId: id, subscriberUrl: body.subscriberUrl, triggers: body.triggers, active: body.active ?? true, payloadTemplate: body.payloadTemplate ?? null }, root, occurredAt);
548
+ return success(webhookView(getById('webhook', id, root)!), 201);
549
+ }
550
+ if (method === 'GET' && parts.length === 1) return success(list('webhook', root).map(webhookView));
551
+ if (parts.length === 2) {
552
+ const id = parts[1]!.startsWith('wh_') ? parts[1]! : `wh_${parts[1]}`;
553
+ const found = getById('webhook', id, root);
554
+ if (method === 'GET') {
555
+ if (!found) return failure(`No webhook found for id ${parts[1]}`, 404);
556
+ return success(webhookView(found));
557
+ }
558
+ if (method === 'PATCH') {
559
+ if (!found) return failure(`No webhook found for id ${parts[1]}`, 404);
560
+ await write('webhook', id, body, root, occurredAt);
561
+ return success(webhookView(getById('webhook', id, root)!));
562
+ }
563
+ if (method === 'DELETE') {
564
+ if (!found) return failure(`No webhook found for id ${parts[1]}`, 404);
565
+ await write('webhook', id, { deleted: true }, root, occurredAt);
566
+ return success({ id });
567
+ }
568
+ }
569
+ }
570
+
571
+ // Unmodeled op → 404, like the vendor (never a fake success).
572
+ return failure(`Cannot ${method} ${url.pathname}`, 404);
573
+ }
574
+
575
+ // ── view projections (kernel record → public v2 data object) ───────────────────
576
+ async function renderBooking(uid: string, root?: string): Promise<Record<string, unknown>> {
577
+ const r = getById('booking', uid, root)!;
578
+ const v = bookingView(r);
579
+ // public `id` is the numeric Cal.com booking id; `uid` is the string handle
580
+ return {
581
+ id: r.bid,
582
+ uid: r.uid,
583
+ title: v.title,
584
+ status: v.status,
585
+ start: v.start,
586
+ end: v.end,
587
+ duration: v.duration,
588
+ eventTypeId: v.eventTypeId,
589
+ eventType: v.eventType,
590
+ attendees: v.attendees,
591
+ hosts: v.hosts,
592
+ guests: v.guests ?? [],
593
+ location: v.location ?? null,
594
+ meetingUrl: v.meetingUrl ?? null,
595
+ metadata: v.metadata ?? {},
596
+ ...(v.noShowHost !== undefined ? { noShowHost: v.noShowHost } : {}),
597
+ ...(v.cancellationReason !== undefined && v.cancellationReason !== null ? { cancellationReason: v.cancellationReason } : {}),
598
+ ...(v.reschedulingReason !== undefined && v.reschedulingReason !== null ? { reschedulingReason: v.reschedulingReason } : {}),
599
+ };
600
+ }
601
+
602
+ function eventTypeView(r: Record<string, unknown>): Record<string, unknown> {
603
+ return {
604
+ id: r.evtId,
605
+ lengthInMinutes: r.lengthInMinutes,
606
+ title: r.title,
607
+ slug: r.slug,
608
+ description: r.description ?? null,
609
+ ownerId: r.ownerId,
610
+ hidden: r.hidden ?? false,
611
+ scheduleId: r.scheduleId ?? null,
612
+ requiresConfirmation: r.requiresConfirmation ?? false,
613
+ bookingUrl: r.bookingUrl,
614
+ locations: r.locations ?? [],
615
+ bookingFields: r.bookingFields ?? [],
616
+ seatsPerTimeSlot: r.seatsPerTimeSlot ?? null,
617
+ seatsShowAttendees: r.seatsShowAttendees ?? false,
618
+ seatsShowAvailabilityCount: r.seatsShowAvailabilityCount ?? true,
619
+ beforeEventBuffer: r.beforeEventBuffer ?? 0,
620
+ afterEventBuffer: r.afterEventBuffer ?? 0,
621
+ minimumBookingNotice: r.minimumBookingNotice ?? 0,
622
+ ...(r.teamId !== undefined && r.teamId !== null ? { teamId: r.teamId, schedulingType: r.schedulingType ?? 'collective' } : {}),
623
+ };
624
+ }
625
+ // The implicit default event type rendered through the public-lookup view.
626
+ function defaultEventTypeView(): Record<string, unknown> {
627
+ return eventTypeView({ evtId: DEFAULT_EVENT_TYPE.id, ...DEFAULT_EVENT_TYPE, ownerId: ME.id, bookingUrl: `https://cal.com/${ME.username}/${DEFAULT_EVENT_TYPE.slug}` });
628
+ }
629
+ function scheduleView(r: Record<string, unknown>): Record<string, unknown> {
630
+ return {
631
+ id: r.schedId,
632
+ ownerId: r.ownerId,
633
+ name: r.name,
634
+ timeZone: r.timeZone,
635
+ availability: r.availability ?? [],
636
+ isDefault: r.isDefault ?? false,
637
+ overrides: r.overrides ?? [],
638
+ };
639
+ }
640
+ function teamView(r: Record<string, unknown>): Record<string, unknown> {
641
+ return { id: r.teamId, name: r.name, slug: r.slug, isOrganization: r.isOrganization ?? false };
642
+ }
643
+ function membershipView(r: Record<string, unknown>): Record<string, unknown> {
644
+ return { id: r.memId, userId: r.userId, role: r.role, accepted: r.accepted ?? true, teamId: teamNum(r.teamId as string), disableImpersonation: r.disableImpersonation ?? false };
645
+ }
646
+ function webhookView(r: Record<string, unknown>): Record<string, unknown> {
647
+ return { id: r.whId, subscriberUrl: r.subscriberUrl, triggers: r.triggers, active: r.active ?? true, payloadTemplate: r.payloadTemplate ?? null };
648
+ }
649
+
650
+ // ── small utilities ────────────────────────────────────────────────────────────
651
+ function addMinutes(iso: string, minutes: number): string {
652
+ return new Date(new Date(iso).getTime() + minutes * 60 * 1000).toISOString();
653
+ }
654
+ function slugify(s: string): string {
655
+ return s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
656
+ }
657
+ function stripPrefix(s: string, p: string): string {
658
+ return s.startsWith(p) ? s.slice(p.length) : s;
659
+ }
660
+ function teamNum(id: string): number {
661
+ return Number(stripPrefix(id, 'team_'));
662
+ }
663
+ // A booking can be addressed by its string uid (booking_xxx) OR — once we returned a public
664
+ // numeric `id` — only the uid is the stable path handle, mirroring Cal.com (paths use {uid}).
665
+ function canonicalBookingId(raw: string): string {
666
+ return raw.startsWith('booking_') ? raw : `booking_${raw}`;
667
+ }
668
+ // Monotonic per-type hex sequence for stable string handles (deterministic given root state).
669
+ function seqHex(type: string, root?: string): string {
670
+ const n = list(type, root).length + 1;
671
+ return n.toString(16).padStart(6, '0');
672
+ }
673
+
674
+ // The subject types this twin projects (for the pack descriptor / conformance).
675
+ export const RESOURCE_TYPES = ['booking', 'event_type', 'schedule', 'team', 'membership', 'webhook', 'slot'] as const;