@agent-native/scheduling 0.2.4 → 0.2.5-nightly-20260929152122
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.
- package/dist/actions/assign-round-robin-host.js +3 -1
- package/dist/actions/assign-round-robin-host.js.map +1 -1
- package/dist/actions/create-booking.js +3 -3
- package/dist/actions/create-booking.js.map +1 -1
- package/dist/actions/submit-routing-form-response.js +4 -4
- package/dist/actions/submit-routing-form-response.js.map +1 -1
- package/dist/server/booking-service.d.ts.map +1 -1
- package/dist/server/booking-service.js +27 -20
- package/dist/server/booking-service.js.map +1 -1
- package/dist/server/bookings-repo.d.ts +1 -1
- package/dist/server/bookings-repo.d.ts.map +1 -1
- package/dist/server/bookings-repo.js +8 -3
- package/dist/server/bookings-repo.js.map +1 -1
- package/dist/server/event-types-repo.d.ts.map +1 -1
- package/dist/server/event-types-repo.js +6 -2
- package/dist/server/event-types-repo.js.map +1 -1
- package/dist/server/hooks.d.ts.map +1 -1
- package/dist/server/hooks.js +68 -20
- package/dist/server/hooks.js.map +1 -1
- package/dist/shared/index.d.ts +4 -0
- package/dist/shared/index.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/actions/assign-round-robin-host.ts +8 -0
- package/src/actions/create-booking.ts +3 -4
- package/src/actions/event-type-authz.spec.ts +35 -0
- package/src/actions/submit-routing-form-response.ts +10 -4
- package/src/server/booking-service.spec.ts +458 -1
- package/src/server/booking-service.ts +27 -20
- package/src/server/bookings-repo.ts +21 -1
- package/src/server/event-types-repo.ts +16 -2
- package/src/server/hooks.ts +124 -23
- package/src/shared/index.ts +4 -0
|
@@ -15,6 +15,11 @@ import {
|
|
|
15
15
|
} from "./booking-service.js";
|
|
16
16
|
import { getBookingByUid, insertBooking } from "./bookings-repo.js";
|
|
17
17
|
import { setSchedulingContext } from "./context.js";
|
|
18
|
+
import {
|
|
19
|
+
onBookingCreated,
|
|
20
|
+
onBookingNoShow,
|
|
21
|
+
onBookingRescheduled,
|
|
22
|
+
} from "./hooks.js";
|
|
18
23
|
import {
|
|
19
24
|
registerCalendarProvider,
|
|
20
25
|
registerVideoProvider,
|
|
@@ -218,6 +223,34 @@ beforeEach(async () => {
|
|
|
218
223
|
visibility TEXT NOT NULL DEFAULT 'private'
|
|
219
224
|
);
|
|
220
225
|
`);
|
|
226
|
+
await execute(`
|
|
227
|
+
CREATE TABLE workflow_shares (
|
|
228
|
+
id TEXT PRIMARY KEY,
|
|
229
|
+
resource_id TEXT NOT NULL,
|
|
230
|
+
principal_type TEXT NOT NULL,
|
|
231
|
+
principal_id TEXT NOT NULL,
|
|
232
|
+
role TEXT NOT NULL DEFAULT 'viewer',
|
|
233
|
+
created_by TEXT NOT NULL DEFAULT '',
|
|
234
|
+
created_at TEXT NOT NULL,
|
|
235
|
+
notified_at TEXT
|
|
236
|
+
);
|
|
237
|
+
`);
|
|
238
|
+
await execute(`
|
|
239
|
+
CREATE TABLE workflow_steps (
|
|
240
|
+
id TEXT PRIMARY KEY,
|
|
241
|
+
workflow_id TEXT NOT NULL,
|
|
242
|
+
"order" INTEGER NOT NULL DEFAULT 0,
|
|
243
|
+
action TEXT NOT NULL,
|
|
244
|
+
offset_minutes INTEGER NOT NULL DEFAULT 0,
|
|
245
|
+
send_to TEXT,
|
|
246
|
+
email_subject TEXT,
|
|
247
|
+
email_body TEXT,
|
|
248
|
+
sms_body TEXT,
|
|
249
|
+
webhook_url TEXT,
|
|
250
|
+
template TEXT,
|
|
251
|
+
created_at TEXT NOT NULL
|
|
252
|
+
);
|
|
253
|
+
`);
|
|
221
254
|
await execute(`
|
|
222
255
|
CREATE TABLE webhooks (
|
|
223
256
|
id TEXT PRIMARY KEY,
|
|
@@ -235,6 +268,18 @@ beforeEach(async () => {
|
|
|
235
268
|
visibility TEXT NOT NULL DEFAULT 'private'
|
|
236
269
|
);
|
|
237
270
|
`);
|
|
271
|
+
await execute(`
|
|
272
|
+
CREATE TABLE webhook_deliveries (
|
|
273
|
+
id TEXT PRIMARY KEY,
|
|
274
|
+
webhook_id TEXT NOT NULL,
|
|
275
|
+
triggered_at TEXT NOT NULL,
|
|
276
|
+
payload TEXT NOT NULL,
|
|
277
|
+
response_status INTEGER,
|
|
278
|
+
response_body TEXT,
|
|
279
|
+
success BOOLEAN NOT NULL DEFAULT false,
|
|
280
|
+
attempts INTEGER NOT NULL DEFAULT 0
|
|
281
|
+
);
|
|
282
|
+
`);
|
|
238
283
|
await execute(`
|
|
239
284
|
CREATE TABLE scheduled_reminders (
|
|
240
285
|
id TEXT PRIMARY KEY,
|
|
@@ -354,7 +399,7 @@ async function seedEventType(eventType: EventType): Promise<void> {
|
|
|
354
399
|
eventType.createdAt,
|
|
355
400
|
eventType.updatedAt,
|
|
356
401
|
eventType.ownerEmail ?? "local@localhost",
|
|
357
|
-
null,
|
|
402
|
+
eventType.orgId ?? null,
|
|
358
403
|
"private",
|
|
359
404
|
],
|
|
360
405
|
});
|
|
@@ -419,7 +464,419 @@ describe("insertBooking", () => {
|
|
|
419
464
|
});
|
|
420
465
|
});
|
|
421
466
|
|
|
467
|
+
describe("booking webhook dispatch", () => {
|
|
468
|
+
it("runs private workflows scoped to the booking team", async () => {
|
|
469
|
+
const now = new Date().toISOString();
|
|
470
|
+
await execute({
|
|
471
|
+
sql: `INSERT INTO workflows (
|
|
472
|
+
id, name, trigger, team_id, disabled, active_on_event_type_ids,
|
|
473
|
+
created_at, updated_at
|
|
474
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
475
|
+
args: [
|
|
476
|
+
"workflow-team-private",
|
|
477
|
+
"Team booking workflow",
|
|
478
|
+
"new-booking",
|
|
479
|
+
"team-b",
|
|
480
|
+
false,
|
|
481
|
+
'["event-type-1"]',
|
|
482
|
+
now,
|
|
483
|
+
now,
|
|
484
|
+
],
|
|
485
|
+
});
|
|
486
|
+
await execute({
|
|
487
|
+
sql: `INSERT INTO workflow_steps (
|
|
488
|
+
id, workflow_id, action, offset_minutes, created_at
|
|
489
|
+
) VALUES (?, ?, ?, ?, ?)`,
|
|
490
|
+
args: [
|
|
491
|
+
"workflow-step-team",
|
|
492
|
+
"workflow-team-private",
|
|
493
|
+
"email-host",
|
|
494
|
+
0,
|
|
495
|
+
now,
|
|
496
|
+
],
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
await createBooking({
|
|
500
|
+
eventType: makeEventType({
|
|
501
|
+
schedulingType: "round-robin",
|
|
502
|
+
teamId: "team-b",
|
|
503
|
+
orgId: "org-b",
|
|
504
|
+
}),
|
|
505
|
+
hostEmail: "team-host@example.com",
|
|
506
|
+
startTime: "2026-08-03T11:00:00.000Z",
|
|
507
|
+
endTime: "2026-08-03T11:30:00.000Z",
|
|
508
|
+
timezone: "UTC",
|
|
509
|
+
attendee: { email: ATTENDEE_EMAIL, name: "Attendee One" },
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
const { rows } = await execute(
|
|
513
|
+
"SELECT workflow_step_id FROM scheduled_reminders",
|
|
514
|
+
);
|
|
515
|
+
expect(rows.map((row) => row.workflow_step_id)).toEqual([
|
|
516
|
+
"workflow-step-team",
|
|
517
|
+
]);
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
it("excludes another member's private webhook but preserves org-visible webhooks", async () => {
|
|
521
|
+
const now = new Date().toISOString();
|
|
522
|
+
const eventTypes = JSON.stringify(["event-type-1"]);
|
|
523
|
+
await execute({
|
|
524
|
+
sql: `INSERT INTO workflows (
|
|
525
|
+
id, name, trigger, disabled, active_on_event_type_ids,
|
|
526
|
+
created_at, updated_at, owner_email, org_id
|
|
527
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
528
|
+
args: [
|
|
529
|
+
"workflow-user-a",
|
|
530
|
+
"Booking workflow",
|
|
531
|
+
"new-booking",
|
|
532
|
+
false,
|
|
533
|
+
eventTypes,
|
|
534
|
+
now,
|
|
535
|
+
now,
|
|
536
|
+
"user-a@example.com",
|
|
537
|
+
"org-a",
|
|
538
|
+
"workflow-user-b",
|
|
539
|
+
"Booking workflow",
|
|
540
|
+
"new-booking",
|
|
541
|
+
false,
|
|
542
|
+
eventTypes,
|
|
543
|
+
now,
|
|
544
|
+
now,
|
|
545
|
+
"user-b@example.com",
|
|
546
|
+
"org-b",
|
|
547
|
+
],
|
|
548
|
+
});
|
|
549
|
+
await execute({
|
|
550
|
+
sql: `INSERT INTO workflow_steps (
|
|
551
|
+
id, workflow_id, action, offset_minutes, created_at
|
|
552
|
+
) VALUES (?, ?, ?, ?, ?), (?, ?, ?, ?, ?)`,
|
|
553
|
+
args: [
|
|
554
|
+
"workflow-step-user-a",
|
|
555
|
+
"workflow-user-a",
|
|
556
|
+
"email-host",
|
|
557
|
+
0,
|
|
558
|
+
now,
|
|
559
|
+
"workflow-step-user-b",
|
|
560
|
+
"workflow-user-b",
|
|
561
|
+
"email-host",
|
|
562
|
+
0,
|
|
563
|
+
now,
|
|
564
|
+
],
|
|
565
|
+
});
|
|
566
|
+
await execute({
|
|
567
|
+
sql: `INSERT INTO webhooks (
|
|
568
|
+
id, subscriber_url, event_triggers, created_at, updated_at,
|
|
569
|
+
owner_email, org_id, visibility
|
|
570
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?, ?),
|
|
571
|
+
(?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
572
|
+
args: [
|
|
573
|
+
"webhook-user-a-private",
|
|
574
|
+
"https://example.test/webhook",
|
|
575
|
+
'["BOOKING_CREATED"]',
|
|
576
|
+
now,
|
|
577
|
+
now,
|
|
578
|
+
"user-a@example.com",
|
|
579
|
+
"org-b",
|
|
580
|
+
"private",
|
|
581
|
+
"webhook-user-a-org",
|
|
582
|
+
"https://example.test/webhook",
|
|
583
|
+
'["BOOKING_CREATED"]',
|
|
584
|
+
now,
|
|
585
|
+
now,
|
|
586
|
+
"user-a@example.com",
|
|
587
|
+
"org-b",
|
|
588
|
+
"org",
|
|
589
|
+
"webhook-user-b",
|
|
590
|
+
"https://example.test/webhook",
|
|
591
|
+
'["BOOKING_CREATED"]',
|
|
592
|
+
now,
|
|
593
|
+
now,
|
|
594
|
+
"user-b@example.com",
|
|
595
|
+
"org-b",
|
|
596
|
+
"private",
|
|
597
|
+
],
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
const booking = await insertBooking({
|
|
601
|
+
eventTypeId: "event-type-1",
|
|
602
|
+
hostEmail: "user-b@example.com",
|
|
603
|
+
ownerEmail: "user-b@example.com",
|
|
604
|
+
orgId: "org-b",
|
|
605
|
+
title: "Private booking",
|
|
606
|
+
startTime: "2026-08-03T10:00:00.000Z",
|
|
607
|
+
endTime: "2026-08-03T10:30:00.000Z",
|
|
608
|
+
timezone: "UTC",
|
|
609
|
+
attendees: [{ email: ATTENDEE_EMAIL, name: "Attendee One" }],
|
|
610
|
+
});
|
|
611
|
+
expect(booking.cancelToken).toBeTruthy();
|
|
612
|
+
expect(booking.rescheduleToken).toBeTruthy();
|
|
613
|
+
|
|
614
|
+
await onBookingCreated(booking);
|
|
615
|
+
|
|
616
|
+
const { rows } = await execute(
|
|
617
|
+
"SELECT webhook_id FROM webhook_deliveries ORDER BY webhook_id",
|
|
618
|
+
);
|
|
619
|
+
expect(rows.map((row) => row.webhook_id)).toEqual([
|
|
620
|
+
"webhook-user-a-org",
|
|
621
|
+
"webhook-user-b",
|
|
622
|
+
]);
|
|
623
|
+
const { rows: deliveries } = await execute(
|
|
624
|
+
"SELECT payload FROM webhook_deliveries",
|
|
625
|
+
);
|
|
626
|
+
for (const delivery of deliveries) {
|
|
627
|
+
const payload = JSON.parse(String(delivery.payload));
|
|
628
|
+
expect(payload.booking).not.toHaveProperty("cancelToken");
|
|
629
|
+
expect(payload.booking).not.toHaveProperty("rescheduleToken");
|
|
630
|
+
}
|
|
631
|
+
const { rows: reminders } = await execute(
|
|
632
|
+
"SELECT workflow_step_id FROM scheduled_reminders ORDER BY workflow_step_id",
|
|
633
|
+
);
|
|
634
|
+
expect(reminders.map((row) => row.workflow_step_id)).toEqual([
|
|
635
|
+
"workflow-step-user-b",
|
|
636
|
+
]);
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
it("keeps team scope when a persisted booking is rescheduled", async () => {
|
|
640
|
+
const now = new Date().toISOString();
|
|
641
|
+
const eventType = makeEventType({
|
|
642
|
+
id: "team-event",
|
|
643
|
+
orgId: "org-b",
|
|
644
|
+
teamId: "team-b",
|
|
645
|
+
});
|
|
646
|
+
await seedEventType(eventType);
|
|
647
|
+
await execute({
|
|
648
|
+
sql: `INSERT INTO webhooks (
|
|
649
|
+
id, subscriber_url, event_triggers, created_at, updated_at,
|
|
650
|
+
owner_email, org_id, visibility, team_id
|
|
651
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
652
|
+
args: [
|
|
653
|
+
"team-webhook",
|
|
654
|
+
"https://example.test/webhook",
|
|
655
|
+
'["BOOKING_RESCHEDULED","BOOKING_NO_SHOW"]',
|
|
656
|
+
now,
|
|
657
|
+
now,
|
|
658
|
+
"team-owner@example.com",
|
|
659
|
+
"org-b",
|
|
660
|
+
"org",
|
|
661
|
+
"team-b",
|
|
662
|
+
],
|
|
663
|
+
});
|
|
664
|
+
await execute({
|
|
665
|
+
sql: `INSERT INTO workflows (
|
|
666
|
+
id, name, trigger, disabled, active_on_event_type_ids,
|
|
667
|
+
created_at, updated_at, owner_email, org_id, team_id
|
|
668
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
669
|
+
args: [
|
|
670
|
+
"team-no-show-workflow",
|
|
671
|
+
"Team no-show workflow",
|
|
672
|
+
"no-show",
|
|
673
|
+
false,
|
|
674
|
+
'["team-event"]',
|
|
675
|
+
now,
|
|
676
|
+
now,
|
|
677
|
+
"team-owner@example.com",
|
|
678
|
+
"org-b",
|
|
679
|
+
"team-b",
|
|
680
|
+
],
|
|
681
|
+
});
|
|
682
|
+
await execute({
|
|
683
|
+
sql: `INSERT INTO workflow_steps (
|
|
684
|
+
id, workflow_id, action, offset_minutes, created_at
|
|
685
|
+
) VALUES (?, ?, ?, ?, ?)`,
|
|
686
|
+
args: [
|
|
687
|
+
"team-no-show-step",
|
|
688
|
+
"team-no-show-workflow",
|
|
689
|
+
"email-host",
|
|
690
|
+
0,
|
|
691
|
+
now,
|
|
692
|
+
],
|
|
693
|
+
});
|
|
694
|
+
const created = await createBooking({
|
|
695
|
+
eventType,
|
|
696
|
+
hostEmail: "user-b@example.com",
|
|
697
|
+
startTime: "2026-08-03T10:00:00.000Z",
|
|
698
|
+
endTime: "2026-08-03T10:30:00.000Z",
|
|
699
|
+
timezone: "UTC",
|
|
700
|
+
attendee: { email: ATTENDEE_EMAIL, name: "Attendee One" },
|
|
701
|
+
});
|
|
702
|
+
const persisted = await getBookingByUid(created.uid);
|
|
703
|
+
|
|
704
|
+
expect(created.teamId).toBe("team-b");
|
|
705
|
+
expect(persisted?.teamId).toBeUndefined();
|
|
706
|
+
await onBookingRescheduled(persisted!, created);
|
|
707
|
+
await onBookingNoShow(persisted!);
|
|
708
|
+
|
|
709
|
+
const { rows } = await execute("SELECT webhook_id FROM webhook_deliveries");
|
|
710
|
+
expect(rows.map((row) => row.webhook_id)).toEqual([
|
|
711
|
+
"team-webhook",
|
|
712
|
+
"team-webhook",
|
|
713
|
+
]);
|
|
714
|
+
const { rows: reminders } = await execute(
|
|
715
|
+
"SELECT workflow_step_id FROM scheduled_reminders",
|
|
716
|
+
);
|
|
717
|
+
expect(reminders.map((row) => row.workflow_step_id)).toEqual([
|
|
718
|
+
"team-no-show-step",
|
|
719
|
+
]);
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
it("keeps a booking in its event type org and excludes another member's private hooks", async () => {
|
|
723
|
+
const now = new Date().toISOString();
|
|
724
|
+
const eventTypes = JSON.stringify(["event-type-1"]);
|
|
725
|
+
await execute({
|
|
726
|
+
sql: `INSERT INTO workflows (
|
|
727
|
+
id, name, trigger, disabled, active_on_event_type_ids,
|
|
728
|
+
created_at, updated_at, owner_email, org_id, visibility
|
|
729
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
|
|
730
|
+
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
731
|
+
args: [
|
|
732
|
+
"workflow-user-a-private",
|
|
733
|
+
"Private workflow",
|
|
734
|
+
"new-booking",
|
|
735
|
+
false,
|
|
736
|
+
eventTypes,
|
|
737
|
+
now,
|
|
738
|
+
now,
|
|
739
|
+
"user-a@example.com",
|
|
740
|
+
"org-b",
|
|
741
|
+
"private",
|
|
742
|
+
"workflow-user-a-org",
|
|
743
|
+
"Org workflow",
|
|
744
|
+
"new-booking",
|
|
745
|
+
false,
|
|
746
|
+
eventTypes,
|
|
747
|
+
now,
|
|
748
|
+
now,
|
|
749
|
+
"user-a@example.com",
|
|
750
|
+
"org-b",
|
|
751
|
+
"org",
|
|
752
|
+
"workflow-user-b",
|
|
753
|
+
"User B workflow",
|
|
754
|
+
"new-booking",
|
|
755
|
+
false,
|
|
756
|
+
eventTypes,
|
|
757
|
+
now,
|
|
758
|
+
now,
|
|
759
|
+
"user-b@example.com",
|
|
760
|
+
"org-b",
|
|
761
|
+
"private",
|
|
762
|
+
],
|
|
763
|
+
});
|
|
764
|
+
await execute({
|
|
765
|
+
sql: `INSERT INTO workflow_steps (
|
|
766
|
+
id, workflow_id, action, offset_minutes, created_at
|
|
767
|
+
) VALUES (?, ?, ?, ?, ?), (?, ?, ?, ?, ?), (?, ?, ?, ?, ?)`,
|
|
768
|
+
args: [
|
|
769
|
+
"workflow-step-user-a-private",
|
|
770
|
+
"workflow-user-a-private",
|
|
771
|
+
"email-host",
|
|
772
|
+
0,
|
|
773
|
+
now,
|
|
774
|
+
"workflow-step-user-a-org",
|
|
775
|
+
"workflow-user-a-org",
|
|
776
|
+
"email-host",
|
|
777
|
+
0,
|
|
778
|
+
now,
|
|
779
|
+
"workflow-step-user-b",
|
|
780
|
+
"workflow-user-b",
|
|
781
|
+
"email-host",
|
|
782
|
+
0,
|
|
783
|
+
now,
|
|
784
|
+
],
|
|
785
|
+
});
|
|
786
|
+
await execute({
|
|
787
|
+
sql: `INSERT INTO webhooks (
|
|
788
|
+
id, subscriber_url, event_triggers, created_at, updated_at,
|
|
789
|
+
owner_email, org_id, visibility
|
|
790
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?, ?),
|
|
791
|
+
(?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
792
|
+
args: [
|
|
793
|
+
"webhook-user-a-private",
|
|
794
|
+
"https://example.test/webhook",
|
|
795
|
+
'["BOOKING_CREATED"]',
|
|
796
|
+
now,
|
|
797
|
+
now,
|
|
798
|
+
"user-a@example.com",
|
|
799
|
+
"org-b",
|
|
800
|
+
"private",
|
|
801
|
+
"webhook-user-a-other-org",
|
|
802
|
+
"https://example.test/webhook",
|
|
803
|
+
'["BOOKING_CREATED"]',
|
|
804
|
+
now,
|
|
805
|
+
now,
|
|
806
|
+
"user-a@example.com",
|
|
807
|
+
"org-a",
|
|
808
|
+
"org",
|
|
809
|
+
"webhook-user-b",
|
|
810
|
+
"https://example.test/webhook",
|
|
811
|
+
'["BOOKING_CREATED"]',
|
|
812
|
+
now,
|
|
813
|
+
now,
|
|
814
|
+
"user-b@example.com",
|
|
815
|
+
"org-b",
|
|
816
|
+
"private",
|
|
817
|
+
],
|
|
818
|
+
});
|
|
819
|
+
await execute({
|
|
820
|
+
sql: `INSERT INTO webhooks (
|
|
821
|
+
id, subscriber_url, event_triggers, created_at, updated_at,
|
|
822
|
+
owner_email, org_id, visibility, team_id
|
|
823
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
824
|
+
args: [
|
|
825
|
+
"webhook-user-b-other-team",
|
|
826
|
+
"https://example.test/webhook",
|
|
827
|
+
'["BOOKING_CREATED"]',
|
|
828
|
+
now,
|
|
829
|
+
now,
|
|
830
|
+
"user-b@example.com",
|
|
831
|
+
"org-b",
|
|
832
|
+
"private",
|
|
833
|
+
"team-a",
|
|
834
|
+
],
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
await createBooking({
|
|
838
|
+
eventType: makeEventType({ orgId: "org-b", teamId: "team-b" }),
|
|
839
|
+
orgId: "org-b",
|
|
840
|
+
hostEmail: "user-b@example.com",
|
|
841
|
+
startTime: "2026-08-03T10:00:00.000Z",
|
|
842
|
+
endTime: "2026-08-03T10:30:00.000Z",
|
|
843
|
+
timezone: "UTC",
|
|
844
|
+
attendee: { email: ATTENDEE_EMAIL, name: "Attendee One" },
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
const { rows: bookingRows } = await execute(
|
|
848
|
+
"SELECT org_id FROM bookings WHERE host_email = 'user-b@example.com'",
|
|
849
|
+
);
|
|
850
|
+
expect(bookingRows.map((row) => row.org_id)).toEqual(["org-b"]);
|
|
851
|
+
const { rows: deliveries } = await execute(
|
|
852
|
+
"SELECT webhook_id FROM webhook_deliveries ORDER BY webhook_id",
|
|
853
|
+
);
|
|
854
|
+
expect(deliveries.map((row) => row.webhook_id)).toEqual(["webhook-user-b"]);
|
|
855
|
+
const { rows: reminders } = await execute(
|
|
856
|
+
"SELECT workflow_step_id FROM scheduled_reminders ORDER BY workflow_step_id",
|
|
857
|
+
);
|
|
858
|
+
expect(reminders.map((row) => row.workflow_step_id)).toEqual([
|
|
859
|
+
"workflow-step-user-a-org",
|
|
860
|
+
"workflow-step-user-b",
|
|
861
|
+
]);
|
|
862
|
+
});
|
|
863
|
+
});
|
|
864
|
+
|
|
422
865
|
describe("createBooking", () => {
|
|
866
|
+
it("rejects a legacy org id absent from the event type", async () => {
|
|
867
|
+
await expect(
|
|
868
|
+
createBooking({
|
|
869
|
+
eventType: makeEventType(),
|
|
870
|
+
orgId: "org-b",
|
|
871
|
+
hostEmail: HOST_EMAIL,
|
|
872
|
+
startTime: "2026-08-03T10:00:00.000Z",
|
|
873
|
+
endTime: "2026-08-03T10:30:00.000Z",
|
|
874
|
+
timezone: "UTC",
|
|
875
|
+
attendee: { email: ATTENDEE_EMAIL, name: "Attendee One" },
|
|
876
|
+
}),
|
|
877
|
+
).rejects.toThrow("Booking orgId must match the event type organization");
|
|
878
|
+
});
|
|
879
|
+
|
|
423
880
|
it("creates a booking with attendees and references on a free slot", async () => {
|
|
424
881
|
const now = new Date().toISOString();
|
|
425
882
|
await execute({
|
|
@@ -59,6 +59,9 @@ export async function createBooking(
|
|
|
59
59
|
input: CreateBookingInput,
|
|
60
60
|
): Promise<Booking> {
|
|
61
61
|
const eventType = input.eventType;
|
|
62
|
+
if (input.orgId !== undefined && input.orgId !== eventType.orgId) {
|
|
63
|
+
throw new Error("Booking orgId must match the event type organization");
|
|
64
|
+
}
|
|
62
65
|
const title =
|
|
63
66
|
input.title ??
|
|
64
67
|
eventType.eventName?.replace("{attendeeName}", input.attendee.name) ??
|
|
@@ -72,24 +75,27 @@ export async function createBooking(
|
|
|
72
75
|
afterEventBuffer: eventType.afterEventBuffer,
|
|
73
76
|
excludeBookingUid: input.fromReschedule,
|
|
74
77
|
});
|
|
75
|
-
const booking =
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
78
|
+
const booking = {
|
|
79
|
+
...(await insertBooking({
|
|
80
|
+
eventTypeId: eventType.id,
|
|
81
|
+
hostEmail: input.hostEmail,
|
|
82
|
+
title,
|
|
83
|
+
description: input.description,
|
|
84
|
+
startTime: input.startTime,
|
|
85
|
+
endTime: input.endTime,
|
|
86
|
+
timezone: input.timezone,
|
|
87
|
+
status: eventType.requiresConfirmation ? "pending" : "confirmed",
|
|
88
|
+
location: input.location ?? eventType.locations[0],
|
|
89
|
+
attendees,
|
|
90
|
+
customResponses: input.customResponses,
|
|
91
|
+
iCalUid: input.iCalUid,
|
|
92
|
+
iCalSequence: input.iCalSequence,
|
|
93
|
+
fromReschedule: input.fromReschedule,
|
|
94
|
+
ownerEmail: input.hostEmail,
|
|
95
|
+
orgId: eventType.orgId,
|
|
96
|
+
})),
|
|
97
|
+
teamId: eventType.teamId,
|
|
98
|
+
};
|
|
93
99
|
|
|
94
100
|
let usableZoomMeeting = false;
|
|
95
101
|
|
|
@@ -141,7 +147,7 @@ export async function createBooking(
|
|
|
141
147
|
|
|
142
148
|
const final = await getBookingByUid(booking.uid);
|
|
143
149
|
if (!final) throw new Error("Booking disappeared after creation");
|
|
144
|
-
return final;
|
|
150
|
+
return { ...final, teamId: eventType.teamId };
|
|
145
151
|
}
|
|
146
152
|
|
|
147
153
|
export async function rescheduleBooking(input: {
|
|
@@ -247,7 +253,8 @@ export async function cancelBooking(input: {
|
|
|
247
253
|
}
|
|
248
254
|
}
|
|
249
255
|
|
|
250
|
-
await
|
|
256
|
+
const eventType = await getEventTypeById(booking.eventTypeId);
|
|
257
|
+
await onBookingCancelled({ ...booking, teamId: eventType?.teamId });
|
|
251
258
|
return (await getBookingByUid(input.uid))!;
|
|
252
259
|
}
|
|
253
260
|
|
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import { accessFilter } from "@agent-native/core/sharing";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
eq,
|
|
4
|
+
and,
|
|
5
|
+
gte,
|
|
6
|
+
lt,
|
|
7
|
+
or,
|
|
8
|
+
desc,
|
|
9
|
+
asc,
|
|
10
|
+
isNotNull,
|
|
11
|
+
isNull,
|
|
12
|
+
} from "drizzle-orm";
|
|
3
13
|
import { nanoid } from "nanoid";
|
|
4
14
|
|
|
5
15
|
import type {
|
|
@@ -21,6 +31,8 @@ function rowToBooking(
|
|
|
21
31
|
uid: row.uid,
|
|
22
32
|
eventTypeId: row.eventTypeId,
|
|
23
33
|
hostEmail: row.hostEmail,
|
|
34
|
+
ownerEmail: row.ownerEmail ?? row.hostEmail,
|
|
35
|
+
orgId: row.orgId ?? null,
|
|
24
36
|
title: row.title,
|
|
25
37
|
description: row.description ?? undefined,
|
|
26
38
|
startTime: row.startTime,
|
|
@@ -74,6 +86,7 @@ function parseJson<T = any>(s: string | null | undefined): T | undefined {
|
|
|
74
86
|
export async function getBookingByUid(uid: string): Promise<Booking | null> {
|
|
75
87
|
const { getDb, schema } = getSchedulingContext();
|
|
76
88
|
const db = getDb();
|
|
89
|
+
// guard:allow-unscoped — opaque booking UID is an authorization lookup; all public callers verify host or bearer token before exposing or mutating the row
|
|
77
90
|
const rows = await db
|
|
78
91
|
.select()
|
|
79
92
|
.from(schema.bookings)
|
|
@@ -167,9 +180,11 @@ export async function listBookings(
|
|
|
167
180
|
}
|
|
168
181
|
|
|
169
182
|
export async function countBookingsByHostInRange(
|
|
183
|
+
eventTypeId: string,
|
|
170
184
|
hostEmail: string,
|
|
171
185
|
fromIso: string,
|
|
172
186
|
toIso: string,
|
|
187
|
+
orgId?: string | null,
|
|
173
188
|
): Promise<number> {
|
|
174
189
|
const { getDb, schema } = getSchedulingContext();
|
|
175
190
|
const rows = await getDb()
|
|
@@ -178,6 +193,11 @@ export async function countBookingsByHostInRange(
|
|
|
178
193
|
.where(
|
|
179
194
|
and(
|
|
180
195
|
eq(schema.bookings.hostEmail, hostEmail),
|
|
196
|
+
eq(schema.bookings.ownerEmail, hostEmail),
|
|
197
|
+
orgId
|
|
198
|
+
? eq(schema.bookings.orgId, orgId)
|
|
199
|
+
: isNull(schema.bookings.orgId),
|
|
200
|
+
eq(schema.bookings.eventTypeId, eventTypeId),
|
|
181
201
|
gte(schema.bookings.startTime, fromIso),
|
|
182
202
|
lt(schema.bookings.startTime, toIso),
|
|
183
203
|
eq(schema.bookings.status, "confirmed"),
|
|
@@ -31,6 +31,7 @@ function rowToEventType(row: any): EventType {
|
|
|
31
31
|
position: row.position,
|
|
32
32
|
schedulingType: row.schedulingType,
|
|
33
33
|
ownerEmail: row.ownerEmail ?? undefined,
|
|
34
|
+
orgId: row.orgId ?? undefined,
|
|
34
35
|
teamId: row.teamId ?? undefined,
|
|
35
36
|
scheduleId: row.scheduleId ?? undefined,
|
|
36
37
|
locations: parseJson<Location[]>(row.locations) ?? [],
|
|
@@ -297,10 +298,23 @@ export async function updateEventType(
|
|
|
297
298
|
|
|
298
299
|
dbPatch.updatedAt = new Date().toISOString();
|
|
299
300
|
|
|
300
|
-
await db
|
|
301
|
+
const updatedRows = await db
|
|
301
302
|
.update(schema.eventTypes)
|
|
302
303
|
.set(dbPatch)
|
|
303
|
-
.where(
|
|
304
|
+
.where(
|
|
305
|
+
and(
|
|
306
|
+
eq(schema.eventTypes.id, id),
|
|
307
|
+
accessFilter(
|
|
308
|
+
schema.eventTypes,
|
|
309
|
+
schema.eventTypeShares,
|
|
310
|
+
undefined,
|
|
311
|
+
"editor",
|
|
312
|
+
),
|
|
313
|
+
),
|
|
314
|
+
)
|
|
315
|
+
.returning({ id: schema.eventTypes.id });
|
|
316
|
+
if (updatedRows.length === 0)
|
|
317
|
+
throw new Error("Event type not found or not editable");
|
|
304
318
|
const updated = await getEventTypeById(id);
|
|
305
319
|
if (!updated) throw new Error("Failed to update event type");
|
|
306
320
|
return updated;
|