@agent-native/scheduling 0.1.22 → 0.1.24

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/README.md +16 -0
  2. package/agent-native.package.json +154 -0
  3. package/dist/manifest.d.ts +5 -1
  4. package/dist/manifest.d.ts.map +1 -1
  5. package/dist/manifest.js +6 -1
  6. package/dist/manifest.js.map +1 -1
  7. package/dist/server/availability-engine.d.ts +20 -0
  8. package/dist/server/availability-engine.d.ts.map +1 -1
  9. package/dist/server/availability-engine.js +73 -23
  10. package/dist/server/availability-engine.js.map +1 -1
  11. package/dist/server/booking-service.d.ts.map +1 -1
  12. package/dist/server/booking-service.js +16 -5
  13. package/dist/server/booking-service.js.map +1 -1
  14. package/dist/server/bookings-repo.d.ts.map +1 -1
  15. package/dist/server/bookings-repo.js +50 -48
  16. package/dist/server/bookings-repo.js.map +1 -1
  17. package/dist/server/providers/index.d.ts +2 -0
  18. package/dist/server/providers/index.d.ts.map +1 -1
  19. package/dist/server/providers/index.js +1 -0
  20. package/dist/server/providers/index.js.map +1 -1
  21. package/dist/server/providers/teams.d.ts +22 -0
  22. package/dist/server/providers/teams.d.ts.map +1 -0
  23. package/dist/server/providers/teams.js +112 -0
  24. package/dist/server/providers/teams.js.map +1 -0
  25. package/docs/eject.md +11 -10
  26. package/docs/llms-full.txt +75 -14
  27. package/docs/llms.txt +15 -1
  28. package/docs/providers.md +30 -2
  29. package/docs/skills/integrations/SKILL.md +19 -1
  30. package/package.json +7 -6
  31. package/src/manifest.test.ts +19 -0
  32. package/src/manifest.ts +9 -7
  33. package/src/server/availability-engine.test.ts +344 -0
  34. package/src/server/availability-engine.ts +100 -22
  35. package/src/server/booking-service.spec.ts +557 -0
  36. package/src/server/booking-service.ts +17 -6
  37. package/src/server/bookings-repo.ts +54 -48
  38. package/src/server/providers/index.ts +2 -0
  39. package/src/server/providers/teams.test.ts +246 -0
  40. package/src/server/providers/teams.ts +183 -0
@@ -0,0 +1,557 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ import { createClient, type Client } from "@libsql/client";
7
+ import { drizzle } from "drizzle-orm/libsql";
8
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
9
+
10
+ import * as schema from "../schema/index.js";
11
+ import type { EventType } from "../shared/index.js";
12
+ import { SlotConflictError } from "./availability-engine.js";
13
+ import { createBooking, rescheduleBooking } from "./booking-service.js";
14
+ import { getBookingByUid, insertBooking } from "./bookings-repo.js";
15
+ import { setSchedulingContext } from "./context.js";
16
+ import { registerCalendarProvider } from "./providers/registry.js";
17
+ import type { CalendarProvider } from "./providers/types.js";
18
+
19
+ const HOST_EMAIL = "host@example.com";
20
+ const ATTENDEE_EMAIL = "attendee@example.com";
21
+ const GUEST_EMAIL = "guest@example.com";
22
+
23
+ let client: Client;
24
+ let dbDir: string;
25
+
26
+ function makeEventType(overrides: Partial<EventType> = {}): EventType {
27
+ const now = new Date().toISOString();
28
+ return {
29
+ id: "event-type-1",
30
+ title: "30 Min Meeting",
31
+ slug: "30min",
32
+ length: 30,
33
+ hidden: false,
34
+ position: 0,
35
+ schedulingType: "personal",
36
+ ownerEmail: HOST_EMAIL,
37
+ locations: [],
38
+ customFields: [],
39
+ minimumBookingNotice: 0,
40
+ beforeEventBuffer: 0,
41
+ afterEventBuffer: 0,
42
+ slotInterval: null,
43
+ periodType: "unlimited",
44
+ requiresConfirmation: false,
45
+ disableGuests: false,
46
+ hideCalendarNotes: false,
47
+ lockTimeZoneToggle: false,
48
+ createdAt: now,
49
+ updatedAt: now,
50
+ ...overrides,
51
+ };
52
+ }
53
+
54
+ function makeCalendarProvider(
55
+ kind: string,
56
+ createEvent: CalendarProvider["createEvent"],
57
+ ): CalendarProvider {
58
+ return {
59
+ kind,
60
+ label: `Test provider ${kind}`,
61
+ startOAuth: async () => ({ authUrl: "https://example.test/oauth" }),
62
+ completeOAuth: async () => ({
63
+ externalEmail: "provider-test@example.com",
64
+ calendars: [],
65
+ }),
66
+ listCalendars: async () => [],
67
+ getBusy: async () => [],
68
+ createEvent,
69
+ updateEvent: async () => ({ iCalSequence: 1 }),
70
+ deleteEvent: async () => {},
71
+ };
72
+ }
73
+
74
+ beforeEach(async () => {
75
+ dbDir = mkdtempSync(join(tmpdir(), "scheduling-booking-test-"));
76
+ client = createClient({ url: `file:${join(dbDir, `${randomUUID()}.db`)}` });
77
+ await client.execute(`
78
+ CREATE TABLE bookings (
79
+ id TEXT PRIMARY KEY,
80
+ uid TEXT NOT NULL UNIQUE,
81
+ event_type_id TEXT NOT NULL,
82
+ host_email TEXT NOT NULL,
83
+ title TEXT NOT NULL,
84
+ description TEXT,
85
+ start_time TEXT NOT NULL,
86
+ end_time TEXT NOT NULL,
87
+ timezone TEXT NOT NULL DEFAULT 'UTC',
88
+ status TEXT NOT NULL DEFAULT 'confirmed',
89
+ location TEXT,
90
+ custom_responses TEXT,
91
+ cancel_token TEXT,
92
+ reschedule_token TEXT,
93
+ from_reschedule TEXT,
94
+ cancellation_reason TEXT,
95
+ rescheduling_reason TEXT,
96
+ ical_uid TEXT NOT NULL,
97
+ ical_sequence INTEGER NOT NULL DEFAULT 0,
98
+ recurring_event_id TEXT,
99
+ paid INTEGER NOT NULL DEFAULT 0,
100
+ no_show_host INTEGER NOT NULL DEFAULT 0,
101
+ metadata TEXT,
102
+ created_at TEXT NOT NULL,
103
+ updated_at TEXT NOT NULL,
104
+ owner_email TEXT NOT NULL DEFAULT 'local@localhost',
105
+ org_id TEXT,
106
+ visibility TEXT NOT NULL DEFAULT 'private'
107
+ );
108
+ `);
109
+ await client.execute(`
110
+ CREATE TABLE booking_attendees (
111
+ id TEXT PRIMARY KEY,
112
+ booking_id TEXT NOT NULL,
113
+ email TEXT NOT NULL,
114
+ name TEXT NOT NULL,
115
+ timezone TEXT,
116
+ locale TEXT,
117
+ no_show INTEGER NOT NULL DEFAULT 0,
118
+ created_at TEXT NOT NULL
119
+ );
120
+ `);
121
+ await client.execute(`
122
+ CREATE TABLE booking_references (
123
+ id TEXT PRIMARY KEY,
124
+ booking_id TEXT NOT NULL,
125
+ type TEXT NOT NULL,
126
+ external_id TEXT NOT NULL,
127
+ meeting_url TEXT,
128
+ meeting_password TEXT,
129
+ credential_id TEXT,
130
+ created_at TEXT NOT NULL
131
+ );
132
+ `);
133
+ await client.execute(`
134
+ CREATE TABLE destination_calendars (
135
+ id TEXT PRIMARY KEY,
136
+ credential_id TEXT NOT NULL,
137
+ user_email TEXT NOT NULL,
138
+ integration TEXT NOT NULL,
139
+ external_id TEXT NOT NULL,
140
+ primary_email TEXT,
141
+ event_type_id TEXT,
142
+ created_at TEXT NOT NULL
143
+ );
144
+ `);
145
+ await client.execute(`
146
+ CREATE TABLE scheduling_credentials (
147
+ id TEXT PRIMARY KEY,
148
+ type TEXT NOT NULL,
149
+ user_email TEXT,
150
+ team_id TEXT,
151
+ app_id TEXT,
152
+ oauth_token_id TEXT,
153
+ display_name TEXT,
154
+ external_email TEXT,
155
+ invalid INTEGER NOT NULL DEFAULT 0,
156
+ is_default INTEGER NOT NULL DEFAULT 0,
157
+ created_at TEXT NOT NULL,
158
+ updated_at TEXT NOT NULL
159
+ );
160
+ `);
161
+ await client.execute(`
162
+ CREATE TABLE selected_calendars (
163
+ id TEXT PRIMARY KEY,
164
+ credential_id TEXT NOT NULL,
165
+ user_email TEXT NOT NULL,
166
+ external_id TEXT NOT NULL,
167
+ integration TEXT NOT NULL,
168
+ event_type_id TEXT,
169
+ created_at TEXT NOT NULL
170
+ );
171
+ `);
172
+ await client.execute(`
173
+ CREATE TABLE workflows (
174
+ id TEXT PRIMARY KEY,
175
+ name TEXT NOT NULL,
176
+ trigger TEXT NOT NULL,
177
+ team_id TEXT,
178
+ disabled INTEGER NOT NULL DEFAULT 0,
179
+ active_on_event_type_ids TEXT NOT NULL DEFAULT '[]',
180
+ created_at TEXT NOT NULL,
181
+ updated_at TEXT NOT NULL,
182
+ owner_email TEXT NOT NULL DEFAULT 'local@localhost',
183
+ org_id TEXT,
184
+ visibility TEXT NOT NULL DEFAULT 'private'
185
+ );
186
+ `);
187
+ await client.execute(`
188
+ CREATE TABLE webhooks (
189
+ id TEXT PRIMARY KEY,
190
+ name TEXT,
191
+ subscriber_url TEXT NOT NULL,
192
+ secret TEXT,
193
+ active INTEGER NOT NULL DEFAULT 1,
194
+ event_triggers TEXT NOT NULL DEFAULT '[]',
195
+ team_id TEXT,
196
+ event_type_id TEXT,
197
+ created_at TEXT NOT NULL,
198
+ updated_at TEXT NOT NULL,
199
+ owner_email TEXT NOT NULL DEFAULT 'local@localhost',
200
+ org_id TEXT,
201
+ visibility TEXT NOT NULL DEFAULT 'private'
202
+ );
203
+ `);
204
+ await client.execute(`
205
+ CREATE TABLE event_types (
206
+ id TEXT PRIMARY KEY,
207
+ title TEXT NOT NULL,
208
+ slug TEXT NOT NULL,
209
+ description TEXT,
210
+ length INTEGER NOT NULL DEFAULT 30,
211
+ durations TEXT,
212
+ position INTEGER NOT NULL DEFAULT 0,
213
+ hidden INTEGER NOT NULL DEFAULT 0,
214
+ color TEXT,
215
+ scheduling_type TEXT NOT NULL DEFAULT 'personal',
216
+ team_id TEXT,
217
+ locations TEXT,
218
+ custom_fields TEXT,
219
+ schedule_id TEXT,
220
+ minimum_booking_notice INTEGER NOT NULL DEFAULT 0,
221
+ before_event_buffer INTEGER NOT NULL DEFAULT 0,
222
+ after_event_buffer INTEGER NOT NULL DEFAULT 0,
223
+ slot_interval INTEGER,
224
+ period_type TEXT NOT NULL DEFAULT 'rolling',
225
+ period_days INTEGER DEFAULT 60,
226
+ period_start_date TEXT,
227
+ period_end_date TEXT,
228
+ seats_per_time_slot INTEGER,
229
+ requires_confirmation INTEGER NOT NULL DEFAULT 0,
230
+ disable_guests INTEGER NOT NULL DEFAULT 0,
231
+ hide_calendar_notes INTEGER NOT NULL DEFAULT 0,
232
+ success_redirect_url TEXT,
233
+ booking_limits TEXT,
234
+ lock_time_zone_toggle INTEGER NOT NULL DEFAULT 0,
235
+ recurring_event TEXT,
236
+ event_name TEXT,
237
+ metadata TEXT,
238
+ created_at TEXT NOT NULL,
239
+ updated_at TEXT NOT NULL,
240
+ owner_email TEXT NOT NULL DEFAULT 'local@localhost',
241
+ org_id TEXT,
242
+ visibility TEXT NOT NULL DEFAULT 'private'
243
+ );
244
+ `);
245
+
246
+ const db = drizzle(client, { schema });
247
+ setSchedulingContext({
248
+ getDb: () => db,
249
+ schema,
250
+ getCurrentUserEmail: () => HOST_EMAIL,
251
+ });
252
+ });
253
+
254
+ afterEach(() => {
255
+ client.close();
256
+ rmSync(dbDir, { recursive: true, force: true });
257
+ });
258
+
259
+ /** `rescheduleBooking` re-loads the event type by id, so it must exist. */
260
+ async function seedEventType(eventType: EventType): Promise<void> {
261
+ await client.execute({
262
+ sql: `INSERT INTO event_types (
263
+ id, title, slug, description, length, durations, position, hidden, color,
264
+ scheduling_type, team_id, locations, custom_fields, schedule_id,
265
+ minimum_booking_notice, before_event_buffer, after_event_buffer, slot_interval,
266
+ period_type, period_days, period_start_date, period_end_date,
267
+ seats_per_time_slot, requires_confirmation, disable_guests, hide_calendar_notes,
268
+ success_redirect_url, booking_limits, lock_time_zone_toggle, recurring_event,
269
+ event_name, metadata, created_at, updated_at, owner_email, org_id, visibility
270
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
271
+ args: [
272
+ eventType.id,
273
+ eventType.title,
274
+ eventType.slug,
275
+ eventType.description ?? null,
276
+ eventType.length,
277
+ eventType.durations ? JSON.stringify(eventType.durations) : null,
278
+ eventType.position,
279
+ eventType.hidden ? 1 : 0,
280
+ eventType.color ?? null,
281
+ eventType.schedulingType,
282
+ eventType.teamId ?? null,
283
+ JSON.stringify(eventType.locations),
284
+ JSON.stringify(eventType.customFields),
285
+ eventType.scheduleId ?? null,
286
+ eventType.minimumBookingNotice,
287
+ eventType.beforeEventBuffer,
288
+ eventType.afterEventBuffer,
289
+ eventType.slotInterval,
290
+ eventType.periodType,
291
+ eventType.periodDays ?? null,
292
+ eventType.periodStartDate ?? null,
293
+ eventType.periodEndDate ?? null,
294
+ eventType.seatsPerTimeSlot ?? null,
295
+ eventType.requiresConfirmation ? 1 : 0,
296
+ eventType.disableGuests ? 1 : 0,
297
+ eventType.hideCalendarNotes ? 1 : 0,
298
+ eventType.successRedirectUrl ?? null,
299
+ eventType.bookingLimits ? JSON.stringify(eventType.bookingLimits) : null,
300
+ eventType.lockTimeZoneToggle ? 1 : 0,
301
+ eventType.recurringEvent
302
+ ? JSON.stringify(eventType.recurringEvent)
303
+ : null,
304
+ eventType.eventName ?? null,
305
+ eventType.metadata ? JSON.stringify(eventType.metadata) : null,
306
+ eventType.createdAt,
307
+ eventType.updatedAt,
308
+ eventType.ownerEmail ?? "local@localhost",
309
+ null,
310
+ "private",
311
+ ],
312
+ });
313
+ }
314
+
315
+ describe("insertBooking", () => {
316
+ it("writes the booking, attendees, and references together", async () => {
317
+ const booking = await insertBooking({
318
+ eventTypeId: "event-type-1",
319
+ hostEmail: HOST_EMAIL,
320
+ title: "Round trip meeting",
321
+ startTime: "2026-08-03T10:00:00.000Z",
322
+ endTime: "2026-08-03T10:30:00.000Z",
323
+ timezone: "UTC",
324
+ attendees: [
325
+ { email: ATTENDEE_EMAIL, name: "Attendee One" },
326
+ { email: GUEST_EMAIL, name: "Guest" },
327
+ ],
328
+ references: [{ type: "google_calendar", externalId: "gcal-event-1" }],
329
+ ownerEmail: HOST_EMAIL,
330
+ });
331
+
332
+ expect(booking.hostEmail).toBe(HOST_EMAIL);
333
+ expect(booking.attendees.map((a) => a.email).sort()).toEqual(
334
+ [ATTENDEE_EMAIL, GUEST_EMAIL].sort(),
335
+ );
336
+ expect(booking.references).toEqual([
337
+ expect.objectContaining({
338
+ type: "google_calendar",
339
+ externalId: "gcal-event-1",
340
+ }),
341
+ ]);
342
+
343
+ const reloaded = await getBookingByUid(booking.uid);
344
+ expect(reloaded?.attendees).toHaveLength(2);
345
+ expect(reloaded?.references).toHaveLength(1);
346
+ });
347
+
348
+ it("rolls back the entire booking when an attendee write fails midway", async () => {
349
+ await expect(
350
+ insertBooking({
351
+ eventTypeId: "event-type-1",
352
+ hostEmail: HOST_EMAIL,
353
+ title: "Doomed meeting",
354
+ startTime: "2026-08-04T10:00:00.000Z",
355
+ endTime: "2026-08-04T10:30:00.000Z",
356
+ timezone: "UTC",
357
+ attendees: [
358
+ { email: ATTENDEE_EMAIL, name: "Attendee One" },
359
+ // A null email violates the NOT NULL constraint after the first
360
+ // attendee (and the booking row) have already been written —
361
+ // proving the whole write is one atomic unit.
362
+ { email: null as unknown as string, name: "Bad Attendee" },
363
+ ],
364
+ ownerEmail: HOST_EMAIL,
365
+ }),
366
+ ).rejects.toThrow();
367
+
368
+ const { rows } = await client.execute("SELECT * FROM bookings");
369
+ expect(rows).toHaveLength(0);
370
+ const { rows: attendeeRows } = await client.execute(
371
+ "SELECT * FROM booking_attendees",
372
+ );
373
+ expect(attendeeRows).toHaveLength(0);
374
+ });
375
+ });
376
+
377
+ describe("createBooking", () => {
378
+ it("creates a booking with attendees and references on a free slot", async () => {
379
+ const now = new Date().toISOString();
380
+ await client.execute({
381
+ sql: `INSERT INTO destination_calendars
382
+ (id, credential_id, user_email, integration, external_id, created_at)
383
+ VALUES (?, ?, ?, ?, ?, ?)`,
384
+ args: ["dest-1", "cred-1", HOST_EMAIL, "google_calendar", "primary", now],
385
+ });
386
+ registerCalendarProvider(
387
+ makeCalendarProvider("google_calendar", async () => ({
388
+ externalId: "gcal-event-free-slot",
389
+ })),
390
+ );
391
+
392
+ const booking = await createBooking({
393
+ eventType: makeEventType(),
394
+ hostEmail: HOST_EMAIL,
395
+ startTime: "2026-08-03T10:00:00.000Z",
396
+ endTime: "2026-08-03T10:30:00.000Z",
397
+ timezone: "UTC",
398
+ attendee: { email: ATTENDEE_EMAIL, name: "Attendee One" },
399
+ guests: [{ email: GUEST_EMAIL, name: "Guest" }],
400
+ });
401
+
402
+ expect(booking.status).toBe("confirmed");
403
+ expect(booking.attendees.map((a) => a.email).sort()).toEqual(
404
+ [ATTENDEE_EMAIL, GUEST_EMAIL].sort(),
405
+ );
406
+ expect(booking.references).toEqual([
407
+ expect.objectContaining({
408
+ type: "google_calendar",
409
+ externalId: "gcal-event-free-slot",
410
+ }),
411
+ ]);
412
+ });
413
+
414
+ it("rejects a second booking that conflicts with an existing one for the same host", async () => {
415
+ const eventType = makeEventType();
416
+ await createBooking({
417
+ eventType,
418
+ hostEmail: HOST_EMAIL,
419
+ startTime: "2026-08-05T10:00:00.000Z",
420
+ endTime: "2026-08-05T10:30:00.000Z",
421
+ timezone: "UTC",
422
+ attendee: { email: ATTENDEE_EMAIL, name: "Attendee One" },
423
+ });
424
+
425
+ await expect(
426
+ createBooking({
427
+ eventType,
428
+ hostEmail: HOST_EMAIL,
429
+ startTime: "2026-08-05T10:00:00.000Z",
430
+ endTime: "2026-08-05T10:30:00.000Z",
431
+ timezone: "UTC",
432
+ attendee: { email: GUEST_EMAIL, name: "Someone Else" },
433
+ }),
434
+ ).rejects.toBeInstanceOf(SlotConflictError);
435
+
436
+ const { rows } = await client.execute(
437
+ "SELECT * FROM bookings WHERE host_email = 'host@example.com'",
438
+ );
439
+ expect(rows).toHaveLength(1);
440
+ });
441
+
442
+ it("rejects a request that only conflicts once the event type's buffer is applied", async () => {
443
+ const plainEventType = makeEventType({ id: "event-type-plain" });
444
+ await createBooking({
445
+ eventType: plainEventType,
446
+ hostEmail: HOST_EMAIL,
447
+ startTime: "2026-08-06T10:00:00.000Z",
448
+ endTime: "2026-08-06T10:30:00.000Z",
449
+ timezone: "UTC",
450
+ attendee: { email: ATTENDEE_EMAIL, name: "Attendee One" },
451
+ });
452
+
453
+ // Starts exactly when the first booking ends, so the raw windows don't
454
+ // overlap — only the buffered event type's 15-minute lead-in collides.
455
+ const bufferedEventType = makeEventType({
456
+ id: "event-type-buffered",
457
+ beforeEventBuffer: 15,
458
+ });
459
+ await expect(
460
+ createBooking({
461
+ eventType: bufferedEventType,
462
+ hostEmail: HOST_EMAIL,
463
+ startTime: "2026-08-06T10:30:00.000Z",
464
+ endTime: "2026-08-06T11:00:00.000Z",
465
+ timezone: "UTC",
466
+ attendee: { email: GUEST_EMAIL, name: "Someone Else" },
467
+ }),
468
+ ).rejects.toBeInstanceOf(SlotConflictError);
469
+ });
470
+
471
+ it("allows an out-of-availability free slot once the conflicting booking is cancelled", async () => {
472
+ // Sanity check that the conflict guard is scoped to the requested
473
+ // window, not a blanket rejection — a later, non-overlapping slot for
474
+ // the same host must still succeed.
475
+ const eventType = makeEventType();
476
+ await createBooking({
477
+ eventType,
478
+ hostEmail: HOST_EMAIL,
479
+ startTime: "2026-08-07T10:00:00.000Z",
480
+ endTime: "2026-08-07T10:30:00.000Z",
481
+ timezone: "UTC",
482
+ attendee: { email: ATTENDEE_EMAIL, name: "Attendee One" },
483
+ });
484
+
485
+ const booking = await createBooking({
486
+ eventType,
487
+ hostEmail: HOST_EMAIL,
488
+ startTime: "2026-08-07T11:00:00.000Z",
489
+ endTime: "2026-08-07T11:30:00.000Z",
490
+ timezone: "UTC",
491
+ attendee: { email: GUEST_EMAIL, name: "Someone Else" },
492
+ });
493
+ expect(booking.status).toBe("confirmed");
494
+ });
495
+ });
496
+
497
+ describe("rescheduleBooking", () => {
498
+ it("ignores the booking's own original slot when re-validating availability", async () => {
499
+ const eventType = makeEventType();
500
+ await seedEventType(eventType);
501
+ const original = await createBooking({
502
+ eventType,
503
+ hostEmail: HOST_EMAIL,
504
+ startTime: "2026-08-08T10:00:00.000Z",
505
+ endTime: "2026-08-08T10:30:00.000Z",
506
+ timezone: "UTC",
507
+ attendee: { email: ATTENDEE_EMAIL, name: "Attendee One" },
508
+ });
509
+
510
+ // Reschedule to an overlapping-but-shifted slot; if the original
511
+ // booking's own busy interval weren't excluded, this would always
512
+ // conflict with itself.
513
+ const rescheduled = await rescheduleBooking({
514
+ uid: original.uid,
515
+ newStartTime: "2026-08-08T10:15:00.000Z",
516
+ newEndTime: "2026-08-08T10:45:00.000Z",
517
+ });
518
+
519
+ expect(rescheduled.startTime).toBe("2026-08-08T10:15:00.000Z");
520
+ expect(rescheduled.fromReschedule).toBe(original.uid);
521
+ });
522
+
523
+ it("still rejects a reschedule that collides with a different booking", async () => {
524
+ const eventType = makeEventType();
525
+ await seedEventType(eventType);
526
+ const original = await createBooking({
527
+ eventType,
528
+ hostEmail: HOST_EMAIL,
529
+ startTime: "2026-08-09T10:00:00.000Z",
530
+ endTime: "2026-08-09T10:30:00.000Z",
531
+ timezone: "UTC",
532
+ attendee: { email: ATTENDEE_EMAIL, name: "Attendee One" },
533
+ });
534
+ await createBooking({
535
+ eventType,
536
+ hostEmail: HOST_EMAIL,
537
+ startTime: "2026-08-09T14:00:00.000Z",
538
+ endTime: "2026-08-09T14:30:00.000Z",
539
+ timezone: "UTC",
540
+ attendee: { email: GUEST_EMAIL, name: "Someone Else" },
541
+ });
542
+
543
+ await expect(
544
+ rescheduleBooking({
545
+ uid: original.uid,
546
+ newStartTime: "2026-08-09T14:00:00.000Z",
547
+ newEndTime: "2026-08-09T14:30:00.000Z",
548
+ }),
549
+ ).rejects.toBeInstanceOf(SlotConflictError);
550
+
551
+ // The original booking must stay intact after a failed reschedule
552
+ // attempt — it should never be left marked "rescheduled" with no
553
+ // successor.
554
+ const stillOriginal = await getBookingByUid(original.uid);
555
+ expect(stillOriginal?.status).toBe("confirmed");
556
+ });
557
+ });
@@ -18,6 +18,7 @@ import type {
18
18
  Location,
19
19
  EventType,
20
20
  } from "../shared/index.js";
21
+ import { assertSlotAvailable } from "./availability-engine.js";
21
22
  import {
22
23
  insertBooking,
23
24
  getBookingByUid,
@@ -62,6 +63,14 @@ export async function createBooking(
62
63
  eventType.eventName?.replace("{attendeeName}", input.attendee.name) ??
63
64
  `${eventType.title} with ${input.attendee.name}`;
64
65
  const attendees: Attendee[] = [input.attendee, ...(input.guests ?? [])];
66
+ await assertSlotAvailable({
67
+ hostEmail: input.hostEmail,
68
+ startTime: input.startTime,
69
+ endTime: input.endTime,
70
+ beforeEventBuffer: eventType.beforeEventBuffer,
71
+ afterEventBuffer: eventType.afterEventBuffer,
72
+ excludeBookingUid: input.fromReschedule,
73
+ });
65
74
  const booking = await insertBooking({
66
75
  eventTypeId: eventType.id,
67
76
  hostEmail: input.hostEmail,
@@ -130,12 +139,9 @@ export async function rescheduleBooking(input: {
130
139
  const eventType = await getEventTypeById(original.eventTypeId);
131
140
  if (!eventType) throw new Error("Event type missing");
132
141
 
133
- // Mark old as rescheduled
134
- await updateBookingStatus(input.uid, "rescheduled", {
135
- reschedulingReason: input.reason,
136
- });
137
-
138
- // Create new booking linked back to the original
142
+ // Create the new booking (validated against availability, ignoring the
143
+ // original's own slot) before touching the original, so a slot conflict
144
+ // never leaves the original marked "rescheduled" with no successor.
139
145
  const attendee = original.attendees[0];
140
146
  const guests = original.attendees.slice(1);
141
147
  const newBooking = await createBooking({
@@ -155,6 +161,11 @@ export async function rescheduleBooking(input: {
155
161
  fromReschedule: input.uid,
156
162
  });
157
163
 
164
+ // Mark old as rescheduled now that the replacement exists.
165
+ await updateBookingStatus(input.uid, "rescheduled", {
166
+ reschedulingReason: input.reason,
167
+ });
168
+
158
169
  // Update external calendar events (PATCH, not delete+create)
159
170
  for (const ref of original.references) {
160
171
  const provider = getCalendarProvider(ref.type);