@agent-native/core 0.192.0 → 0.192.1-nightly-20260926144920

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 (82) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/calendar/actions/cancel-booking.ts +12 -4
  3. package/corpus/templates/calendar/actions/list-bookings.ts +23 -2
  4. package/corpus/templates/calendar/actions/list-events.ts +42 -29
  5. package/corpus/templates/calendar/actions/update-booking-link.ts +27 -1
  6. package/corpus/templates/calendar/app/hooks/use-bookings.ts +9 -2
  7. package/corpus/templates/calendar/app/i18n/en-US.ts +8 -0
  8. package/corpus/templates/calendar/app/lib/booking-sorting.ts +7 -0
  9. package/corpus/templates/calendar/app/lib/event-colors.ts +29 -4
  10. package/corpus/templates/calendar/app/pages/BookingsList.tsx +99 -19
  11. package/corpus/templates/calendar/app/pages/CalendarView.tsx +5 -11
  12. package/corpus/templates/calendar/app/pages/ManageBookingPage.tsx +5 -0
  13. package/corpus/templates/calendar/changelog/2026-09-25-ambiguous-zoom-bookings-need-review.md +6 -0
  14. package/corpus/templates/calendar/changelog/2026-09-25-bookings-list-shows-newest-bookings-first.md +6 -0
  15. package/corpus/templates/calendar/changelog/2026-09-25-canceling-a-booking-now-resolves-its-zoom-meeting-first.md +6 -0
  16. package/corpus/templates/calendar/changelog/2026-09-25-colleague-events-use-sidebar-colors.md +6 -0
  17. package/corpus/templates/calendar/changelog/2026-09-25-public-booking-pages-reject-malformed-meeting-settings-befor.md +6 -0
  18. package/corpus/templates/calendar/changelog/2026-09-25-shared-booking-cancellations-require-editor-access-and-unres.md +6 -0
  19. package/corpus/templates/calendar/server/db/schema.ts +5 -0
  20. package/corpus/templates/calendar/server/handlers/availability.ts +32 -29
  21. package/corpus/templates/calendar/server/handlers/bookings.ts +189 -62
  22. package/corpus/templates/calendar/server/lib/booking-link-utils.ts +46 -1
  23. package/corpus/templates/calendar/server/lib/zoom.ts +66 -1
  24. package/corpus/templates/calendar/server/plugins/db.ts +13 -0
  25. package/corpus/templates/calendar/shared/api.ts +4 -0
  26. package/corpus/templates/clips/app/components/capture-install-options.tsx +9 -6
  27. package/corpus/templates/clips/app/components/clips-command-menu.tsx +4 -0
  28. package/corpus/templates/clips/app/i18n/en-US.ts +5 -4
  29. package/corpus/templates/clips/app/routes/_app.settings._index.tsx +4 -0
  30. package/corpus/templates/clips/app/routes/share.$shareId.tsx +7 -33
  31. package/corpus/templates/clips/changelog/2026-09-26-desktop-platform-icon.md +6 -0
  32. package/corpus/templates/clips/changelog/2026-09-26-the-empty-comments-state-now-explains-how-screen-recordings-.md +6 -0
  33. package/corpus/templates/clips/changelog/2026-09-26-the-empty-comments-view-has-a-clearer-headline-and-a-focused.md +6 -0
  34. package/corpus/templates/design/.generated/bridge/editor-chrome.generated.ts +26 -2
  35. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +74 -40
  36. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +64 -42
  37. package/corpus/templates/design/app/components/design/design-canvas/creation.ts +1 -0
  38. package/corpus/templates/design/app/components/design/multi-screen/types.ts +5 -2
  39. package/corpus/templates/design/app/pages/design-editor/commands/duplicate-screen.ts +56 -25
  40. package/corpus/templates/design/app/routes/design-systems.tsx +2 -4
  41. package/corpus/templates/design/app/routes/design.$id.tsx +2 -1
  42. package/corpus/templates/design/app/routes/public-design-meta.ts +0 -39
  43. package/corpus/templates/design/app/routes/templates.tsx +2 -4
  44. package/corpus/templates/design/app/routes/visual-edit_.$id.tsx +2 -1
  45. package/corpus/templates/design/changelog/2026-09-25-escape-finishes-open-pen-paths.md +7 -0
  46. package/corpus/templates/design/changelog/2026-09-25-pen-paths-remain-available-after-save-failures-and-retries-k.md +6 -0
  47. package/corpus/templates/design/changelog/2026-09-25-pen-retries-keep-extending-the-selected-vector-after-a-save-.md +6 -0
  48. package/corpus/templates/design/changelog/2026-09-25-pressing-enter-finishes-a-new-pen-path-and-selects-its-vector-on-move.md +6 -0
  49. package/corpus/templates/design/changelog/2026-09-26-cmd-d-places-screen-duplicates-in-the-next-open-slot.md +6 -0
  50. package/corpus/templates/design/changelog/2026-09-26-dropping-a-layer-beside-a-sibling-keeps-that-insertion-point.md +6 -0
  51. package/corpus/templates/design/changelog/2026-09-26-layers-dragged-out-of-frames-now-land-at-the-board-level-and.md +6 -0
  52. package/corpus/templates/design/changelog/2026-09-26-opening-shared-designs-no-longer-fails-during-client-startup.md +6 -0
  53. package/corpus/templates/design/package.json +1 -1
  54. package/corpus/templates/design/scripts/check-client-server-boundary.ts +55 -0
  55. package/corpus/templates/design/server/lib/public-design-meta.server.ts +40 -0
  56. package/corpus/templates/mail/app/components/email/EmailListItem.tsx +11 -9
  57. package/corpus/templates/mail/app/components/email/EmailThread.tsx +9 -1
  58. package/corpus/templates/mail/app/components/layout/AppLayout.tsx +2 -2
  59. package/corpus/templates/mail/app/components/settings/AiFilterSection.tsx +25 -2
  60. package/corpus/templates/mail/app/hooks/use-emails.ts +159 -27
  61. package/corpus/templates/mail/app/i18n/en-US.ts +2 -1
  62. package/corpus/templates/mail/app/lib/label-display.ts +6 -0
  63. package/corpus/templates/mail/app/pages/InboxPage.tsx +5 -0
  64. package/corpus/templates/mail/changelog/2026-09-26-fixed-importance-actions-label-display-and-triage-loading-fe.md +6 -0
  65. package/corpus/templates/mail/changelog/2026-09-26-mail-cancels-stale-thread-read-cooldown-retries-after-a-newe.md +6 -0
  66. package/corpus/templates/mail/server/lib/google-api.ts +12 -7
  67. package/corpus/templates/slides/app/i18n/en-US.ts +0 -1
  68. package/corpus/templates/slides/app/pages/Index.tsx +1 -29
  69. package/corpus/templates/slides/changelog/2026-09-26-create-decks-from-the-prompt-without-a-separate-new-deck-button.md +6 -0
  70. package/corpus/templates/tasks/.agents/skills/agent-watchdog/SKILL.md +7 -2
  71. package/dist/client/settings/UsageSection.js +18 -5
  72. package/dist/client/settings/recent-prompt-groups.d.ts +12 -0
  73. package/dist/client/settings/recent-prompt-groups.js +20 -0
  74. package/dist/client/use-action.js +7 -0
  75. package/dist/observability/routes.d.ts +5 -5
  76. package/dist/progress/routes.d.ts +1 -1
  77. package/dist/provider-api/actions/custom-provider-registration.d.ts +14 -14
  78. package/dist/provider-api/actions/provider-api.d.ts +13 -13
  79. package/dist/secrets/routes.d.ts +3 -3
  80. package/dist/server/action-routes.js +25 -2
  81. package/dist/server/agent-engine-ollama-models-route.d.ts +2 -2
  82. package/package.json +3 -3
@@ -75,36 +75,38 @@ export const getPublicAvailability = defineEventHandler(
75
75
  .from(schema.bookingLinks)
76
76
  .where(eq(schema.bookingLinks.slug, slug))
77
77
  .then((rows) => rows[0]);
78
- if (link?.ownerEmail) {
79
- const ownerConfig = (await getUserSetting(
80
- link.ownerEmail,
81
- "calendar-availability",
82
- )) as unknown as AvailabilityConfig | null;
83
- if (ownerConfig) return ownerConfig;
84
- const ownerSettings = (await getUserSetting(
85
- link.ownerEmail,
86
- "calendar-settings",
87
- )) as { timezone?: string } | null;
88
- return createDefaultAvailability(
89
- ownerSettings?.timezone || "America/New_York",
90
- );
78
+ const usernameOwnerEmail = username
79
+ ? await getBookingUsernameOwner(username)
80
+ : null;
81
+ if (
82
+ username &&
83
+ (!usernameOwnerEmail ||
84
+ (link && link.ownerEmail !== usernameOwnerEmail))
85
+ ) {
86
+ throw createError({
87
+ statusCode: 404,
88
+ statusMessage: "Booking page not found",
89
+ });
91
90
  }
92
91
 
93
- if (username) {
94
- const ownerEmail = await getBookingUsernameOwner(username);
95
- if (!ownerEmail) {
96
- throw createError({
97
- statusCode: 404,
98
- statusMessage: "Booking page not found",
99
- });
100
- }
101
-
92
+ const ownerEmail = link?.ownerEmail || usernameOwnerEmail;
93
+ if (ownerEmail) {
102
94
  const ownerConfig = (await getUserSetting(
103
95
  ownerEmail,
104
96
  "calendar-availability",
105
97
  )) as unknown as AvailabilityConfig | null;
106
- if (ownerConfig?.bookingPageSlug === slug) return ownerConfig;
107
- if (!ownerConfig && slug === "book") {
98
+ if (
99
+ ownerConfig &&
100
+ (link?.ownerEmail ||
101
+ !username ||
102
+ ownerConfig.bookingPageSlug === slug)
103
+ ) {
104
+ return ownerConfig;
105
+ }
106
+ if (
107
+ !ownerConfig &&
108
+ (link?.ownerEmail || !username || slug === "book")
109
+ ) {
108
110
  const ownerSettings = (await getUserSetting(
109
111
  ownerEmail,
110
112
  "calendar-settings",
@@ -113,11 +115,12 @@ export const getPublicAvailability = defineEventHandler(
113
115
  ownerSettings?.timezone || "America/New_York",
114
116
  );
115
117
  }
116
-
117
- throw createError({
118
- statusCode: 404,
119
- statusMessage: "Booking page not found",
120
- });
118
+ if (username) {
119
+ throw createError({
120
+ statusCode: 404,
121
+ statusMessage: "Booking page not found",
122
+ });
123
+ }
121
124
  }
122
125
  }
123
126
 
@@ -3,6 +3,7 @@ import { getOrgContext, orgMembers } from "@agent-native/core/org";
3
3
  import {
4
4
  getSession,
5
5
  getAppProductionUrl,
6
+ getRequestContext,
6
7
  recordChange,
7
8
  readBody,
8
9
  runWithRequestContext,
@@ -29,7 +30,6 @@ import type {
29
30
  Booking,
30
31
  CalendarEvent,
31
32
  AvailabilityConfig,
32
- ConferencingConfig,
33
33
  CustomField,
34
34
  TimeSlot,
35
35
  } from "../../shared/api.js";
@@ -55,12 +55,18 @@ import {
55
55
  import {
56
56
  getBookingLinkCoHostEmails,
57
57
  getBookingLinkRequiredHostEmails,
58
+ isBookingLinkHost,
58
59
  normalizeBookingHosts,
60
+ parseBookingConferencingConfig,
59
61
  } from "../lib/booking-link-utils.js";
60
62
  import { getOwnerBookingTimeZone } from "../lib/booking-timezone.js";
61
63
  import { eventBlocksAvailability } from "../lib/calendar-availability.js";
62
64
  import * as googleCalendar from "../lib/google-calendar.js";
63
- import { createZoomMeeting } from "../lib/zoom.js";
65
+ import {
66
+ createZoomMeeting,
67
+ deleteZoomMeeting,
68
+ needsZoomCancellationReview,
69
+ } from "../lib/zoom.js";
64
70
  import { getBookingUsernameOwner } from "./booking-usernames.js";
65
71
 
66
72
  async function requireRequestContext<T>(
@@ -89,16 +95,23 @@ async function getBookingLinkSlugsForOwners(
89
95
  return Array.from(new Set(rows.map((row) => row.slug)));
90
96
  }
91
97
 
92
- async function getBookingLinkOwnerEmail(
93
- slug: string,
94
- ): Promise<string | undefined> {
98
+ async function getBookingLinkDetails(slug: string) {
95
99
  if (!slug) return undefined;
96
- const row = await getDb()
97
- .select({ ownerEmail: schema.bookingLinks.ownerEmail })
100
+ return getDb()
101
+ .select({
102
+ ownerEmail: schema.bookingLinks.ownerEmail,
103
+ hosts: schema.bookingLinks.hosts,
104
+ conferencing: schema.bookingLinks.conferencing,
105
+ })
98
106
  .from(schema.bookingLinks)
99
107
  .where(eq(schema.bookingLinks.slug, slug))
100
108
  .then((rows) => rows[0]);
101
- return row?.ownerEmail;
109
+ }
110
+
111
+ async function getBookingLinkOwnerEmail(
112
+ slug: string,
113
+ ): Promise<string | undefined> {
114
+ return (await getBookingLinkDetails(slug))?.ownerEmail;
102
115
  }
103
116
 
104
117
  function stripCrlf(value: unknown): string {
@@ -608,7 +621,7 @@ async function resolveAvailabilityContext({
608
621
  draft?: BookingAvailabilityDraft;
609
622
  db?: ConflictDb;
610
623
  }): Promise<AvailabilityContext> {
611
- const [configRaw, bookingLink] = await Promise.all([
624
+ const [configRaw, bookingLink, usernameOwnerEmail] = await Promise.all([
612
625
  getSetting("calendar-availability"),
613
626
  slug
614
627
  ? db
@@ -629,6 +642,9 @@ async function resolveAvailabilityContext({
629
642
  )
630
643
  .then((rows) => rows[0])
631
644
  : Promise.resolve(undefined),
645
+ username && !draft
646
+ ? getBookingUsernameOwner(username)
647
+ : Promise.resolve(null),
632
648
  ]);
633
649
  if (draft && !bookingLink) {
634
650
  throw createError({
@@ -636,47 +652,57 @@ async function resolveAvailabilityContext({
636
652
  statusMessage: "Booking link not found",
637
653
  });
638
654
  }
655
+ if (username && !draft && !usernameOwnerEmail) {
656
+ throw createError({
657
+ statusCode: 404,
658
+ statusMessage: "Booking page not found",
659
+ });
660
+ }
661
+ if (username && !draft && bookingLink) {
662
+ if (bookingLink.ownerEmail !== usernameOwnerEmail) {
663
+ throw createError({
664
+ statusCode: 404,
665
+ statusMessage: "Booking page not found",
666
+ });
667
+ }
668
+ } else if (username && !draft) {
669
+ return {
670
+ effectiveConfig: null,
671
+ ownerEmail: undefined,
672
+ hostEmails: [],
673
+ eligibleHosts: [],
674
+ slug,
675
+ bookingLink: undefined,
676
+ durationSource: undefined,
677
+ conflictSlugs: [],
678
+ };
679
+ }
639
680
  const config = configRaw as unknown as AvailabilityConfig | null;
640
- const usernameOwnerEmail =
641
- !bookingLink && username && !draft
642
- ? await getBookingUsernameOwner(username)
643
- : null;
644
- const candidateOwnerEmail = bookingLink?.ownerEmail || usernameOwnerEmail;
645
- const [candidateOwnerConfigRaw, candidateOwnerSettingsRaw] =
646
- candidateOwnerEmail
647
- ? await Promise.all([
648
- getUserSetting(candidateOwnerEmail, "calendar-availability"),
649
- getUserSetting(candidateOwnerEmail, "calendar-settings"),
650
- ])
651
- : [null, null];
652
- const candidateOwnerConfig =
653
- candidateOwnerConfigRaw as AvailabilityConfig | null;
654
- const usernameSlugMatches =
655
- !usernameOwnerEmail ||
656
- candidateOwnerConfig?.bookingPageSlug === slug ||
657
- (!candidateOwnerConfig && slug === "book");
658
- const ownerEmail =
659
- bookingLink?.ownerEmail ||
660
- (usernameSlugMatches ? usernameOwnerEmail || undefined : undefined);
681
+ const ownerEmail = bookingLink?.ownerEmail;
661
682
  const overrides = bookingLink
662
683
  ? resolveBookingLinkAvailabilityOverrides({ bookingLink, draft })
663
684
  : undefined;
664
685
  const hostEmails = overrides?.hostEmails ?? (ownerEmail ? [ownerEmail] : []);
665
- const ownerConfig = ownerEmail ? candidateOwnerConfig : null;
666
- const ownerSettings = ownerEmail
667
- ? (candidateOwnerSettingsRaw as { timezone?: string } | null)
668
- : null;
686
+ const [ownerConfigRaw, ownerSettingsRaw, ownerLinkSlugs, eligibleHosts] =
687
+ await Promise.all([
688
+ ownerEmail
689
+ ? getUserSetting(ownerEmail, "calendar-availability")
690
+ : Promise.resolve(null),
691
+ ownerEmail
692
+ ? getUserSetting(ownerEmail, "calendar-settings")
693
+ : Promise.resolve(null),
694
+ ownerEmail
695
+ ? getBookingLinkSlugsForOwners(hostEmails, db)
696
+ : Promise.resolve([]),
697
+ getEligibleHostAvailability(ownerEmail, hostEmails),
698
+ ]);
699
+ const ownerConfig = ownerConfigRaw as AvailabilityConfig | null;
700
+ const ownerSettings = ownerSettingsRaw as { timezone?: string } | null;
669
701
  const conflictSlugs = ownerEmail
670
- ? await getBookingLinkSlugsForOwners(hostEmails, db).then((slugs) =>
671
- Array.from(new Set([slug, ...slugs])),
672
- )
702
+ ? Array.from(new Set([slug, ...ownerLinkSlugs]))
673
703
  : slug
674
704
  ? [slug]
675
705
  : [];
676
- const eligibleHosts = await getEligibleHostAvailability(
677
- ownerEmail,
678
- hostEmails,
679
- );
680
706
 
681
707
  return {
682
708
  effectiveConfig:
@@ -685,9 +711,7 @@ async function resolveAvailabilityContext({
685
711
  ? createDefaultAvailability(
686
712
  ownerSettings?.timezone || "America/New_York",
687
713
  )
688
- : username
689
- ? null
690
- : config),
714
+ : config),
691
715
  ownerEmail,
692
716
  hostEmails,
693
717
  eligibleHosts,
@@ -1387,6 +1411,20 @@ export const createBooking = defineEventHandler(async (event: H3Event) => {
1387
1411
  }
1388
1412
 
1389
1413
  // Check for conflicts + insert atomically in a transaction
1414
+ const parsedConferencing = parseBookingConferencingConfig(
1415
+ link?.conferencing,
1416
+ );
1417
+ if (parsedConferencing.status === "invalid") {
1418
+ setResponseStatus(event, 422);
1419
+ return {
1420
+ error: "Failed to create booking",
1421
+ code: "invalid_conferencing_config",
1422
+ };
1423
+ }
1424
+ const conferencing =
1425
+ parsedConferencing.status === "valid"
1426
+ ? parsedConferencing.config
1427
+ : undefined;
1390
1428
  const db = getDb();
1391
1429
  const insertResult = await db.transaction(async (tx) => {
1392
1430
  if (viewer) {
@@ -1469,6 +1507,7 @@ export const createBooking = defineEventHandler(async (event: H3Event) => {
1469
1507
  ? JSON.stringify(fieldResponses)
1470
1508
  : null,
1471
1509
  cancelToken,
1510
+ zoomNeedsReview: conferencing?.type === "zoom",
1472
1511
  status: "confirmed",
1473
1512
  createdAt: now,
1474
1513
  ownerEmail: hostEmail,
@@ -1486,17 +1525,12 @@ export const createBooking = defineEventHandler(async (event: H3Event) => {
1486
1525
  return { error: "This time slot is no longer available" };
1487
1526
  }
1488
1527
 
1489
- // Resolve conferencing config
1490
- let conferencing: ConferencingConfig | undefined;
1491
- if (link?.conferencing) {
1492
- try {
1493
- conferencing = JSON.parse(link.conferencing);
1494
- } catch {}
1495
- }
1496
1528
  let meetingLink: string | undefined;
1497
1529
  let googleEventId: string | undefined;
1498
1530
  let calendarAccountId: string | undefined;
1499
1531
  let meetingLinkPending = false;
1532
+ let zoomMeetingId: string | undefined;
1533
+ let zoomAccountId: string | undefined;
1500
1534
 
1501
1535
  // For custom-URL conferencing, use the static URL — only http(s).
1502
1536
  if (conferencing?.type === "custom" && conferencing.url) {
@@ -1536,6 +1570,8 @@ export const createBooking = defineEventHandler(async (event: H3Event) => {
1536
1570
  throw new Error("Zoom meeting was not created");
1537
1571
  }
1538
1572
  meetingLink = zoomResult.meetingUrl;
1573
+ zoomMeetingId = zoomResult.meetingId;
1574
+ zoomAccountId = zoomResult.accountId;
1539
1575
  } catch (error) {
1540
1576
  console.error(
1541
1577
  `[bookings] Failed to create Zoom meeting for ${hostEmail}:`,
@@ -1543,8 +1579,7 @@ export const createBooking = defineEventHandler(async (event: H3Event) => {
1543
1579
  );
1544
1580
  // Zoom may have created the meeting even if its response was lost or
1545
1581
  // unreadable, so keep the booking to reserve the slot and prevent a
1546
- // retry from silently creating a duplicate meeting. Finish the
1547
- // independent calendar write before returning the provider error.
1582
+ // retry from silently creating a duplicate meeting.
1548
1583
  meetingLinkPending = true;
1549
1584
  }
1550
1585
  }
@@ -1634,10 +1669,23 @@ export const createBooking = defineEventHandler(async (event: H3Event) => {
1634
1669
  meetingLink?: string;
1635
1670
  googleEventId?: string;
1636
1671
  calendarAccountId?: string;
1672
+ zoomNeedsReview?: boolean;
1673
+ zoomMeetingId?: string;
1674
+ zoomAccountId?: string;
1637
1675
  meetingLinkPending: boolean;
1638
1676
  } = { meetingLinkPending };
1639
1677
  if (meetingLink) providerUpdates.meetingLink = meetingLink;
1640
1678
  if (googleEventId) providerUpdates.googleEventId = googleEventId;
1679
+ if (zoomMeetingId) providerUpdates.zoomMeetingId = zoomMeetingId;
1680
+ if (zoomAccountId) providerUpdates.zoomAccountId = zoomAccountId;
1681
+ if (
1682
+ conferencing?.type === "zoom" &&
1683
+ meetingLink &&
1684
+ zoomMeetingId &&
1685
+ zoomAccountId
1686
+ ) {
1687
+ providerUpdates.zoomNeedsReview = false;
1688
+ }
1641
1689
  if (googleEventId && calendarAccountId) {
1642
1690
  providerUpdates.calendarAccountId = calendarAccountId;
1643
1691
  }
@@ -1664,6 +1712,7 @@ export const createBooking = defineEventHandler(async (event: H3Event) => {
1664
1712
  ...(meetingLinkPending ? { meetingLinkPending: true } : {}),
1665
1713
  googleEventId,
1666
1714
  cancelToken,
1715
+ zoomNeedsReview: conferencing?.type === "zoom" && meetingLinkPending,
1667
1716
  status: "confirmed",
1668
1717
  createdAt: now,
1669
1718
  };
@@ -1897,6 +1946,7 @@ export const getAvailableSlots = defineEventHandler(async (event: H3Event) => {
1897
1946
  export async function cancelBookingById(
1898
1947
  id: string,
1899
1948
  origin = getAppProductionUrl(),
1949
+ options: { zoomMeetingResolved?: boolean } = {},
1900
1950
  ) {
1901
1951
  if (!id)
1902
1952
  throw createError({ statusCode: 400, statusMessage: "id is required" });
@@ -1914,11 +1964,23 @@ export async function cancelBookingById(
1914
1964
 
1915
1965
  // Bookings have no ownerEmail of their own — scope through the booking link.
1916
1966
  const accessibleLinks = await db
1917
- .select({ slug: schema.bookingLinks.slug })
1967
+ .select({
1968
+ slug: schema.bookingLinks.slug,
1969
+ ownerEmail: schema.bookingLinks.ownerEmail,
1970
+ hosts: schema.bookingLinks.hosts,
1971
+ conferencing: schema.bookingLinks.conferencing,
1972
+ })
1918
1973
  .from(schema.bookingLinks)
1919
- .where(accessFilter(schema.bookingLinks, schema.bookingLinkShares));
1920
- const accessibleSlugs = new Set(accessibleLinks.map((link) => link.slug));
1921
- if (!accessibleSlugs.has(existing.slug)) {
1974
+ .where(
1975
+ accessFilter(
1976
+ schema.bookingLinks,
1977
+ schema.bookingLinkShares,
1978
+ undefined,
1979
+ "editor",
1980
+ ),
1981
+ );
1982
+ const link = accessibleLinks.find((item) => item.slug === existing.slug);
1983
+ if (!link) {
1922
1984
  throw createError({ statusCode: 403, statusMessage: "Access denied" });
1923
1985
  }
1924
1986
 
@@ -1926,7 +1988,35 @@ export async function cancelBookingById(
1926
1988
  return { success: true, alreadyCancelled: true };
1927
1989
  }
1928
1990
 
1929
- const hostEmail = await getBookingLinkOwnerEmail(existing.slug);
1991
+ if (
1992
+ options.zoomMeetingResolved === true &&
1993
+ !isBookingLinkHost(link, getRequestContext()?.userEmail)
1994
+ ) {
1995
+ throw createError({ statusCode: 403, statusMessage: "Access denied" });
1996
+ }
1997
+
1998
+ if (
1999
+ needsZoomCancellationReview({
2000
+ ...existing,
2001
+ conferencing: link.conferencing,
2002
+ }) &&
2003
+ options.zoomMeetingResolved !== true
2004
+ ) {
2005
+ throw createError({
2006
+ statusCode: 409,
2007
+ statusMessage:
2008
+ "Check the Zoom meeting and resolve it before canceling this booking",
2009
+ });
2010
+ }
2011
+
2012
+ if (existing.zoomMeetingId && existing.zoomAccountId) {
2013
+ await deleteZoomMeeting({
2014
+ accountId: existing.zoomAccountId,
2015
+ meetingId: existing.zoomMeetingId,
2016
+ });
2017
+ }
2018
+
2019
+ const hostEmail = link.ownerEmail;
1930
2020
  const bookingTimeZone = await getOwnerBookingTimeZone(hostEmail);
1931
2021
  const bookAgainUrl = existing.slug
1932
2022
  ? `${origin}/book/${existing.slug}`
@@ -1940,7 +2030,7 @@ export async function cancelBookingById(
1940
2030
  await deleteGoogleEventForBooking({ booking: existing, hostEmail });
1941
2031
  await db
1942
2032
  .update(schema.bookings)
1943
- .set({ status: "cancelled" })
2033
+ .set({ status: "cancelled", zoomNeedsReview: false })
1944
2034
  .where(eq(schema.bookings.id, id));
1945
2035
  recordBookingsChanged(hostEmail);
1946
2036
  return { success: true };
@@ -1949,9 +2039,19 @@ export async function cancelBookingById(
1949
2039
  export const deleteBooking = defineEventHandler(async (event: H3Event) => {
1950
2040
  return requireRequestContext(event, async () => {
1951
2041
  try {
2042
+ const body = await readBody(event);
2043
+ const parsed = z
2044
+ .object({ zoomMeetingResolved: z.boolean().optional() })
2045
+ .strict()
2046
+ .safeParse(body ?? {});
2047
+ if (!parsed.success) {
2048
+ setResponseStatus(event, 400);
2049
+ return { error: "Invalid cancellation options" };
2050
+ }
1952
2051
  return await cancelBookingById(
1953
2052
  getRouterParam(event, "id") as string,
1954
2053
  getRequestURL(event).origin,
2054
+ parsed.data,
1955
2055
  );
1956
2056
  } catch (error: any) {
1957
2057
  setResponseStatus(event, error?.statusCode ?? 500);
@@ -1982,6 +2082,7 @@ export const getBookingByToken = defineEventHandler(async (event: H3Event) => {
1982
2082
 
1983
2083
  // Return limited info — don't expose internal IDs
1984
2084
  const booking = rowToBooking(row);
2085
+ const link = await getBookingLinkDetails(row.slug);
1985
2086
  return {
1986
2087
  eventTitle: booking.eventTitle,
1987
2088
  name: booking.name,
@@ -1989,6 +2090,10 @@ export const getBookingByToken = defineEventHandler(async (event: H3Event) => {
1989
2090
  end: booking.end,
1990
2091
  slug: booking.slug,
1991
2092
  meetingLink: booking.meetingLink,
2093
+ zoomCancellationNeedsReview: needsZoomCancellationReview({
2094
+ ...row,
2095
+ conferencing: link?.conferencing,
2096
+ }),
1992
2097
  meetingLinkPending: booking.meetingLinkPending,
1993
2098
  status: booking.status,
1994
2099
  };
@@ -2024,12 +2129,34 @@ export const cancelBookingByToken = defineEventHandler(
2024
2129
  return { success: true, alreadyCancelled: true };
2025
2130
  }
2026
2131
 
2132
+ const link = await getBookingLinkDetails(row.slug);
2133
+ if (
2134
+ needsZoomCancellationReview({
2135
+ ...row,
2136
+ conferencing: link?.conferencing,
2137
+ })
2138
+ ) {
2139
+ setResponseStatus(event, 409);
2140
+ return {
2141
+ error:
2142
+ "The organizer must review the Zoom meeting before this booking can be canceled",
2143
+ code: "zoom_meeting_review_required",
2144
+ };
2145
+ }
2146
+
2147
+ if (row.zoomMeetingId && row.zoomAccountId) {
2148
+ await deleteZoomMeeting({
2149
+ accountId: row.zoomAccountId,
2150
+ meetingId: row.zoomMeetingId,
2151
+ });
2152
+ }
2153
+
2027
2154
  await db
2028
2155
  .update(schema.bookings)
2029
- .set({ status: "cancelled" })
2156
+ .set({ status: "cancelled", zoomNeedsReview: false })
2030
2157
  .where(eq(schema.bookings.id, row.id));
2031
2158
 
2032
- const hostEmail = await getBookingLinkOwnerEmail(row.slug);
2159
+ const hostEmail = link?.ownerEmail;
2033
2160
  const bookingTimeZone = await getOwnerBookingTimeZone(hostEmail);
2034
2161
  const reqUrl = getRequestURL(event);
2035
2162
  const bookAgainUrl = row.slug
@@ -2046,7 +2173,7 @@ export const cancelBookingByToken = defineEventHandler(
2046
2173
 
2047
2174
  return { success: true, slug: row.slug };
2048
2175
  } catch (error: any) {
2049
- setResponseStatus(event, 500);
2176
+ setResponseStatus(event, error?.statusCode ?? 500);
2050
2177
  return { error: error.message };
2051
2178
  }
2052
2179
  },
@@ -1,7 +1,17 @@
1
- import type { BookingHost, BookingLink } from "../../shared/api.js";
1
+ import { z } from "zod";
2
+
3
+ import type {
4
+ BookingHost,
5
+ BookingLink,
6
+ ConferencingConfig,
7
+ } from "../../shared/api.js";
2
8
  import { schema } from "../db/index.js";
3
9
 
4
10
  const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
11
+ const httpUrlSchema = z
12
+ .string()
13
+ .url()
14
+ .refine((value) => /^https?:\/\//i.test(value));
5
15
 
6
16
  function stripCrlf(value: unknown): string {
7
17
  return (
@@ -29,6 +39,33 @@ function parseJson<T>(value: string | null, fallback: T): T {
29
39
  }
30
40
  }
31
41
 
42
+ const conferencingConfigSchema = z.discriminatedUnion("type", [
43
+ z.object({ type: z.literal("none"), url: z.string().optional() }),
44
+ z.object({ type: z.literal("google_meet"), url: z.string().optional() }),
45
+ z.object({ type: z.literal("zoom"), url: z.string().optional() }),
46
+ z.object({
47
+ type: z.literal("custom"),
48
+ url: httpUrlSchema,
49
+ }),
50
+ ]);
51
+
52
+ export function parseBookingConferencingConfig(
53
+ value: string | null | undefined,
54
+ ):
55
+ | { status: "absent" }
56
+ | { status: "invalid" }
57
+ | { status: "valid"; config: ConferencingConfig } {
58
+ if (value == null) return { status: "absent" };
59
+ try {
60
+ const parsed = conferencingConfigSchema.safeParse(JSON.parse(value));
61
+ return parsed.success
62
+ ? { status: "valid", config: parsed.data }
63
+ : { status: "invalid" };
64
+ } catch {
65
+ return { status: "invalid" };
66
+ }
67
+ }
68
+
32
69
  export function normalizeBookingHosts(
33
70
  input: unknown,
34
71
  ownerEmail?: string | null,
@@ -100,6 +137,14 @@ export function getBookingLinkRequiredHostEmails(
100
137
  return emails;
101
138
  }
102
139
 
140
+ export function isBookingLinkHost(
141
+ row: Pick<typeof schema.bookingLinks.$inferSelect, "hosts" | "ownerEmail">,
142
+ userEmail: unknown,
143
+ ): boolean {
144
+ const email = normalizeBookingHostEmail(userEmail);
145
+ return !!email && getBookingLinkRequiredHostEmails(row).includes(email);
146
+ }
147
+
103
148
  export function rowToBookingLink(
104
149
  row: typeof schema.bookingLinks.$inferSelect,
105
150
  ): BookingLink {
@@ -24,6 +24,8 @@ import {
24
24
  */
25
25
  import { nanoid } from "nanoid";
26
26
 
27
+ import { parseBookingConferencingConfig } from "./booking-link-utils.js";
28
+
27
29
  const PROVIDER = "zoom_video";
28
30
  const SCOPES = [
29
31
  "meeting:write:meeting",
@@ -56,6 +58,51 @@ export function getZoomAuthUrl(redirectUri: string, state: string) {
56
58
  return `https://zoom.us/oauth/authorize?${params}`;
57
59
  }
58
60
 
61
+ function createProvider(creds: { clientId: string; clientSecret: string }) {
62
+ return createZoomProvider({
63
+ clientId: creds.clientId,
64
+ clientSecret: creds.clientSecret,
65
+ getAccessToken: (credentialId) => resolveAccessToken(credentialId),
66
+ updateTokens: async (credentialId, tokens) => {
67
+ const existing = await getOAuthTokens(PROVIDER, credentialId);
68
+ await saveOAuthTokens(PROVIDER, credentialId, {
69
+ ...existing,
70
+ accessToken: tokens.accessToken,
71
+ refreshToken: tokens.refreshToken ?? (existing as any)?.refreshToken,
72
+ expiresAt: tokens.expiresAt?.getTime(),
73
+ });
74
+ },
75
+ });
76
+ }
77
+
78
+ export function needsZoomCancellationReview(booking: {
79
+ zoomNeedsReview?: boolean;
80
+ meetingLink?: string | null;
81
+ zoomMeetingId?: string | null;
82
+ zoomAccountId?: string | null;
83
+ conferencing?: string | null;
84
+ status?: string;
85
+ }): boolean {
86
+ if (booking.status === "cancelled") return false;
87
+ if (booking.zoomMeetingId && booking.zoomAccountId) return false;
88
+ if (booking.zoomNeedsReview) return true;
89
+ const conferencing = parseBookingConferencingConfig(booking.conferencing);
90
+ if (conferencing.status === "invalid") return true;
91
+ if (conferencing.status === "valid" && conferencing.config.type === "zoom") {
92
+ return true;
93
+ }
94
+ if (!booking.meetingLink) return false;
95
+
96
+ try {
97
+ const hostname = new URL(booking.meetingLink).hostname.toLowerCase();
98
+ return ["zoom.us", "zoom.com", "zoomgov.com"].some(
99
+ (domain) => hostname === domain || hostname.endsWith(`.${domain}`),
100
+ );
101
+ } catch {
102
+ return true;
103
+ }
104
+ }
105
+
59
106
  /**
60
107
  * Exchange an authorization code for Zoom tokens. Stores them in
61
108
  * `oauth_tokens(provider="zoom_video", account_id=<zoom_user_id>, owner=<ownerEmail>)`.
@@ -168,7 +215,12 @@ export async function disconnectZoom(ownerEmail: string) {
168
215
  * owned by the host. `not_started` means the meeting creation request was not sent.
169
216
  */
170
217
  export type ZoomMeetingResult =
171
- | { status: "created"; meetingUrl: string; meetingId: string }
218
+ | {
219
+ status: "created";
220
+ meetingUrl: string;
221
+ meetingId: string;
222
+ accountId: string;
223
+ }
172
224
  | { status: "not_started" }
173
225
  | { status: "rejected" };
174
226
 
@@ -248,9 +300,22 @@ export async function createZoomMeeting(opts: {
248
300
  status: "created",
249
301
  meetingUrl: result.meetingUrl,
250
302
  meetingId: result.meetingId,
303
+ accountId: credentialId,
251
304
  };
252
305
  }
253
306
 
307
+ export async function deleteZoomMeeting(opts: {
308
+ accountId: string;
309
+ meetingId: string;
310
+ }): Promise<void> {
311
+ const creds = getZoomCreds();
312
+ if (!creds) throw new Error("Zoom OAuth is not configured");
313
+ await createProvider(creds).deleteMeeting!({
314
+ credentialId: opts.accountId,
315
+ meetingId: opts.meetingId,
316
+ });
317
+ }
318
+
254
319
  /**
255
320
  * Resolve a fresh access token for a Zoom credential, refreshing it if it's
256
321
  * expired (or near-expiry). Persists the refreshed tokens back to oauth_tokens.