@acms-subir/core 0.2.0 → 0.3.1

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 (254) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/cli/common.mjs +35 -0
  3. package/cli/legacy.mjs +317 -0
  4. package/cli/main.mjs +66 -0
  5. package/cli/palette.mjs +4 -0
  6. package/cli/preview.mjs +50 -0
  7. package/cli/remote.mjs +55 -0
  8. package/cli/themes.mjs +127 -0
  9. package/cli.mjs +2 -369
  10. package/deploy/Caddyfile +8 -0
  11. package/deploy/Dockerfile +34 -0
  12. package/deploy/compose.https.yml +21 -0
  13. package/deploy/compose.yml +46 -0
  14. package/docs/live-video.md +91 -0
  15. package/docs/theme-authoring.md +103 -0
  16. package/email-runner.mjs +32 -0
  17. package/integration.mjs +189 -2
  18. package/ops-check.mjs +27 -0
  19. package/package.json +27 -5
  20. package/src/actions/index.ts +578 -49
  21. package/src/components/AiCleaner.tsx +291 -0
  22. package/src/components/AiSettings.tsx +575 -0
  23. package/src/components/CommandPalette.tsx +200 -0
  24. package/src/components/ContentList.astro +246 -0
  25. package/src/components/DesignAccess.tsx +182 -0
  26. package/src/components/DesignManager.tsx +418 -46
  27. package/src/components/EmailSettings.tsx +521 -0
  28. package/src/components/EventPage.astro +178 -0
  29. package/src/components/LegalPage.astro +27 -0
  30. package/src/components/LinksManager.tsx +119 -58
  31. package/src/components/LiveCard.astro +174 -0
  32. package/src/components/LiveManager.tsx +910 -0
  33. package/src/components/NavManager.tsx +42 -43
  34. package/src/components/ThemeScript.astro +50 -0
  35. package/src/components/ThemeSections.astro +58 -0
  36. package/src/components/ThemeSectionsEditor.tsx +513 -0
  37. package/src/components/WidgetRenderer.astro +3 -0
  38. package/src/components/booking/BookingFlow.astro +150 -0
  39. package/src/components/booking/HoursEditor.astro +63 -0
  40. package/src/components/builder/PageBuilder.tsx +10 -2
  41. package/src/components/editor/EventPanel.tsx +401 -0
  42. package/src/components/editor/FormatDock.tsx +81 -0
  43. package/src/components/editor/LivePane.tsx +122 -0
  44. package/src/components/editor/MobileToolbar.tsx +93 -0
  45. package/src/components/editor/PostEditor.tsx +605 -223
  46. package/src/components/editor/PreviewOverlay.tsx +242 -43
  47. package/src/components/editor/extensions/calendar.tsx +51 -7
  48. package/src/components/editor/extensions/events.tsx +153 -0
  49. package/src/components/editor/extensions/slash.tsx +7 -1
  50. package/src/components/site/Pager.astro +24 -0
  51. package/src/components/site/PostCard.astro +64 -0
  52. package/src/components/site/TagLabel.astro +11 -0
  53. package/src/layouts/Admin.astro +289 -49
  54. package/src/layouts/Site.astro +352 -59
  55. package/src/lib/agent-api.ts +13 -0
  56. package/src/lib/agent-discovery.ts +40 -0
  57. package/src/lib/agent.ts +114 -0
  58. package/src/lib/ai-config.ts +58 -0
  59. package/src/lib/ai-schema.ts +22 -0
  60. package/src/lib/ai.ts +90 -0
  61. package/src/lib/api-keys.ts +65 -0
  62. package/src/lib/api.ts +56 -0
  63. package/src/lib/auth.ts +25 -5
  64. package/src/lib/backup/content.ts +36 -0
  65. package/src/lib/backup/unzip.ts +12 -1
  66. package/src/lib/backup/zip.ts +1 -1
  67. package/src/lib/booking/agent.ts +108 -0
  68. package/src/lib/booking/availability.ts +221 -0
  69. package/src/lib/booking/emails.ts +115 -0
  70. package/src/lib/booking/forms.ts +77 -0
  71. package/src/lib/booking/service.ts +277 -0
  72. package/src/lib/booking/view.ts +54 -0
  73. package/src/lib/calendar/agent-schema.ts +48 -0
  74. package/src/lib/calendar/agent.ts +136 -0
  75. package/src/lib/calendar/api.ts +81 -0
  76. package/src/lib/calendar/cards.ts +102 -0
  77. package/src/lib/calendar/config.ts +52 -0
  78. package/src/lib/calendar/data.ts +130 -0
  79. package/src/lib/calendar/events.ts +261 -0
  80. package/src/lib/calendar/feeds.ts +43 -0
  81. package/src/lib/calendar/hooks.ts +38 -0
  82. package/src/lib/calendar/http.ts +30 -0
  83. package/src/lib/calendar/ics-write.ts +120 -0
  84. package/src/lib/calendar/ics.ts +15 -5
  85. package/src/lib/calendar/import.ts +203 -0
  86. package/src/lib/calendar/render.ts +120 -37
  87. package/src/lib/calendar/rrule.ts +149 -0
  88. package/src/lib/calendar/seo.ts +48 -0
  89. package/src/lib/design-service.ts +58 -0
  90. package/src/lib/designDiff.ts +145 -0
  91. package/src/lib/designPreview.ts +110 -12
  92. package/src/lib/documents/cleanup.ts +31 -0
  93. package/src/lib/documents/extract.ts +154 -0
  94. package/src/lib/documents/vendor.d.ts +4 -0
  95. package/src/lib/drafts.ts +60 -6
  96. package/src/lib/dynamicBlocks.ts +40 -8
  97. package/src/lib/email/accounts.ts +55 -0
  98. package/src/lib/email/config.ts +86 -0
  99. package/src/lib/email/migration.ts +24 -0
  100. package/src/lib/email/providers.ts +89 -0
  101. package/src/lib/email/service.ts +78 -0
  102. package/src/lib/email/store.ts +167 -0
  103. package/src/lib/email/templates.ts +18 -0
  104. package/src/lib/email/types.ts +32 -0
  105. package/src/lib/email/webhooks.ts +87 -0
  106. package/src/lib/email.ts +15 -0
  107. package/src/lib/env.ts +1 -1
  108. package/src/lib/legal/index.ts +25 -0
  109. package/src/lib/live/client.ts +88 -0
  110. package/src/lib/live/crypto.ts +28 -0
  111. package/src/lib/live/migration.ts +15 -0
  112. package/src/lib/live/providers.ts +152 -0
  113. package/src/lib/live/service.ts +170 -0
  114. package/src/lib/live/signals.ts +89 -0
  115. package/src/lib/live/store.ts +49 -0
  116. package/src/lib/live/types.ts +52 -0
  117. package/src/lib/live/view.ts +78 -0
  118. package/src/lib/mobile/bookings.ts +73 -0
  119. package/src/lib/mobile/calendar.ts +85 -0
  120. package/src/lib/mobile/content.ts +85 -0
  121. package/src/lib/mobile/http.ts +63 -0
  122. package/src/lib/mobile/route.ts +20 -0
  123. package/src/lib/ops.ts +8 -0
  124. package/src/lib/preview.ts +21 -1
  125. package/src/lib/repo/migrations.ts +221 -0
  126. package/src/lib/repo/sql.ts +907 -68
  127. package/src/lib/repo/types.ts +125 -5
  128. package/src/lib/scheduler.ts +27 -0
  129. package/src/lib/schema.ts +316 -1
  130. package/src/lib/search-i18n.ts +3 -0
  131. package/src/lib/socialIcons.ts +32 -0
  132. package/src/lib/tagColor.ts +11 -0
  133. package/src/lib/theme.ts +59 -40
  134. package/src/lib/themeTokens.ts +183 -0
  135. package/src/lib/umami.ts +108 -0
  136. package/src/lib/webhook-sql.ts +68 -0
  137. package/src/lib/webhooks.ts +147 -0
  138. package/src/lib/widgets.ts +1 -0
  139. package/src/middleware.ts +45 -3
  140. package/src/pages/404.astro +23 -0
  141. package/src/pages/[...slug].astro +14 -5
  142. package/src/pages/account/[action].astro +53 -0
  143. package/src/pages/admin/ai.astro +12 -0
  144. package/src/pages/admin/c/[collection]/[id].astro +4 -1
  145. package/src/pages/admin/c/[collection]/index.astro +127 -92
  146. package/src/pages/admin/calendar/bookings.astro +400 -0
  147. package/src/pages/admin/calendar/calendars.astro +265 -0
  148. package/src/pages/admin/calendar/events/[id].astro +56 -0
  149. package/src/pages/admin/calendar/events/index.astro +408 -0
  150. package/src/pages/admin/calendar/index.ts +4 -0
  151. package/src/pages/admin/calendar/integrations.astro +4 -0
  152. package/src/pages/admin/calendar/resources/[id].astro +180 -0
  153. package/src/pages/admin/calendar/resources/index.astro +106 -0
  154. package/src/pages/admin/calendar/resources/link.astro +196 -0
  155. package/src/pages/admin/design.astro +10 -3
  156. package/src/pages/admin/email.astro +13 -0
  157. package/src/pages/admin/index.astro +232 -85
  158. package/src/pages/admin/integrations.astro +237 -0
  159. package/src/pages/admin/links.astro +3 -3
  160. package/src/pages/admin/live.astro +12 -0
  161. package/src/pages/admin/login.astro +14 -10
  162. package/src/pages/admin/navigation.astro +8 -2
  163. package/src/pages/admin/pages/[id].astro +4 -1
  164. package/src/pages/admin/pages/index.astro +41 -48
  165. package/src/pages/admin/settings.astro +418 -367
  166. package/src/pages/admin/tags.astro +4 -4
  167. package/src/pages/admin/users.astro +38 -28
  168. package/src/pages/api/agent/docs.ts +33 -0
  169. package/src/pages/api/agent/index.ts +21 -0
  170. package/src/pages/api/ai/clean.ts +13 -0
  171. package/src/pages/api/ai/draft.ts +14 -0
  172. package/src/pages/api/ai/settings.ts +53 -0
  173. package/src/pages/api/ai/test.ts +12 -0
  174. package/src/pages/api/backup.ts +8 -21
  175. package/src/pages/api/calendar/availability.ts +35 -0
  176. package/src/pages/api/calendar/bookings/[id].ts +49 -0
  177. package/src/pages/api/calendar/bookings/index.ts +73 -0
  178. package/src/pages/api/calendar/calendars.ts +35 -0
  179. package/src/pages/api/calendar/docs.ts +66 -0
  180. package/src/pages/api/calendar/events/[id].ts +63 -0
  181. package/src/pages/api/calendar/events/index.ts +112 -0
  182. package/src/pages/api/calendar/resources.ts +34 -0
  183. package/src/pages/api/design/capabilities.ts +10 -0
  184. package/src/pages/api/design/index.ts +58 -0
  185. package/src/pages/api/design/keys.ts +24 -0
  186. package/src/pages/api/design/media.ts +32 -0
  187. package/src/pages/api/email/run.ts +11 -0
  188. package/src/pages/api/email/settings.ts +61 -0
  189. package/src/pages/api/email/webhooks/[provider].ts +4 -0
  190. package/src/pages/api/live/oauth/youtube/callback.ts +19 -0
  191. package/src/pages/api/live/settings.ts +45 -0
  192. package/src/pages/api/live/status.ts +7 -0
  193. package/src/pages/api/live/thumb.ts +48 -0
  194. package/src/pages/api/mcp.ts +59 -0
  195. package/src/pages/api/media/upload.ts +2 -2
  196. package/src/pages/api/ops/status.ts +39 -0
  197. package/src/pages/api/restore.ts +27 -37
  198. package/src/pages/api/search.ts +18 -4
  199. package/src/pages/api/v1/availability.ts +7 -0
  200. package/src/pages/api/v1/bookings.ts +17 -0
  201. package/src/pages/api/v1/calendars.ts +7 -0
  202. package/src/pages/api/v1/content/[id].ts +7 -0
  203. package/src/pages/api/v1/content/index.ts +7 -0
  204. package/src/pages/api/v1/events.ts +7 -0
  205. package/src/pages/api/v1/resources.ts +7 -0
  206. package/src/pages/api/webhooks/docs.ts +61 -0
  207. package/src/pages/author/[slug].astro +43 -35
  208. package/src/pages/blog/[slug].astro +137 -66
  209. package/src/pages/blog/index.astro +95 -30
  210. package/src/pages/blog/tag/[slug].astro +45 -40
  211. package/src/pages/book/[resource]/[link].astro +70 -0
  212. package/src/pages/book/[resource]/index.astro +63 -0
  213. package/src/pages/book/[resource].ics.ts +38 -0
  214. package/src/pages/book/index.astro +47 -0
  215. package/src/pages/book/manage/[token].astro +108 -0
  216. package/src/pages/calendar/[slug].ics.ts +27 -0
  217. package/src/pages/calendar/feed.ics.ts +15 -0
  218. package/src/pages/calendar/index.astro +191 -0
  219. package/src/pages/embed/book/[resource]/[link].astro +33 -0
  220. package/src/pages/embed/calendar/[slug].astro +43 -0
  221. package/src/pages/events/[slug].astro +74 -0
  222. package/src/pages/events/[slug].ics.ts +25 -0
  223. package/src/pages/index.astro +12 -3
  224. package/src/pages/legal/privacy.astro +4 -0
  225. package/src/pages/legal/terms.astro +4 -0
  226. package/src/pages/links.astro +29 -3
  227. package/src/pages/llms.txt.ts +4 -0
  228. package/src/pages/preview/[type]/[id].astro +27 -6
  229. package/src/pages/search.astro +40 -20
  230. package/src/pages/sitemap.xml.ts +16 -1
  231. package/src/styles/admin.css +924 -105
  232. package/src/styles/cards.css +92 -74
  233. package/src/styles/editor.css +492 -114
  234. package/src/styles/email-settings.css +117 -0
  235. package/src/styles/live-admin.css +126 -0
  236. package/src/styles/live.css +199 -0
  237. package/src/styles/sections.css +12 -0
  238. package/src/styles/site.css +938 -281
  239. package/src/styles/theme-editor.css +135 -0
  240. package/src/theme-sdk/contract.d.mts +619 -0
  241. package/src/theme-sdk/contract.mjs +81 -0
  242. package/src/theme-sdk/index.ts +15 -0
  243. package/src/theme-sdk/tokens.d.mts +148 -0
  244. package/src/theme-sdk/tokens.mjs +55 -0
  245. package/src/themes/editorial/templates/home.astro +4 -4
  246. package/src/themes/editorial/templates/post-minimal.astro +2 -2
  247. package/src/themes/editorial/theme.css +27 -37
  248. package/src/themes/editorial/theme.json +25 -3
  249. package/src/themes/field/theme.json +27 -9
  250. package/src/themes/ink/theme.json +30 -12
  251. package/src/themes/night/theme.css +4 -7
  252. package/src/themes/night/theme.json +28 -10
  253. package/src/virtual.d.ts +14 -0
  254. package/src/worker.ts +5 -1
@@ -0,0 +1,277 @@
1
+ // Booking workflows shared by the public booking pages, the admin, the REST
2
+ // API and agent tools, so every path validates, notifies and fires webhooks
3
+ // the same way. Auto-confirm links confirm on the spot (and show on the
4
+ // calendar immediately); the rest wait as "pending" for staff.
5
+ import type { ContentRepository } from '../repo/types';
6
+ import type { Booking, BookingLink, BookingStatus, CalEvent, Resource, Settings } from '../schema';
7
+ import { loadFeed, expandVEvents } from '../calendar/ics';
8
+ import { emitWebhook } from '../webhooks';
9
+ import { parseFrom } from '../email/config';
10
+ import { kickEmail } from '../email/service';
11
+ import { manageUrl as manageUrlFor, eventUrl } from '../calendar/config';
12
+ import { eventTimeZone, locationLine } from '../calendar/events';
13
+ import { bookingEmail, whenText, type BookingEmail } from './emails';
14
+ import { holdOf, isBookable, resourceZone, type AvailabilityContext, type Interval } from './availability';
15
+
16
+ export interface FlowOptions {
17
+ origin: string;
18
+ locals?: unknown;
19
+ waitUntil?: (p: Promise<unknown>) => void;
20
+ now?: number;
21
+ idempotency?: { keyHash: string; requestHash: string };
22
+ }
23
+
24
+ export class BookingError extends Error {
25
+ constructor(public status: number, message: string) { super(message); }
26
+ }
27
+
28
+ const token = () => {
29
+ const bytes = crypto.getRandomValues(new Uint8Array(24));
30
+ return btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
31
+ };
32
+
33
+ // ---- availability context ------------------------------------------------
34
+
35
+ const busyCache = new Map<string, { at: number; intervals: Interval[] }>();
36
+
37
+ async function busyIntervals(resource: Resource, from: number, to: number, now: number): Promise<Interval[]> {
38
+ if (!resource.busyFeeds.length) return [];
39
+ const key = `${resource.id}:${resource.busyFeeds.join('|')}:${Math.floor(from / 86_400_000)}`;
40
+ const hit = busyCache.get(key);
41
+ if (hit && now - hit.at < 5 * 60_000) return hit.intervals;
42
+ const intervals: Interval[] = [];
43
+ await Promise.all(resource.busyFeeds.map(async (url) => {
44
+ try {
45
+ const feed = await loadFeed(url, now);
46
+ for (const e of expandVEvents(feed.vevents, feed.calTz, { displayTz: resource.timezone || 'UTC', now, windowStartMs: from, windowEndMs: to }, 0)) {
47
+ intervals.push({ start: e.start, end: e.end });
48
+ }
49
+ } catch { /* an unreachable busy feed must not block bookings */ }
50
+ }));
51
+ busyCache.set(key, { at: now, intervals });
52
+ if (busyCache.size > 50) busyCache.delete(busyCache.keys().next().value!);
53
+ return intervals;
54
+ }
55
+
56
+ export async function availabilityContext(
57
+ repo: ContentRepository,
58
+ resource: Resource,
59
+ link: BookingLink,
60
+ settings: Pick<Settings, 'timezone'>,
61
+ now = Date.now(),
62
+ ignoreBookingId?: string
63
+ ): Promise<AvailabilityContext> {
64
+ const to = now + (link.maxAdvance + 2) * 86_400_000;
65
+ const [bookings, busy] = await Promise.all([
66
+ repo.listBookings({ resourceId: resource.id, from: now - 86_400_000, to, statuses: ['pending', 'confirmed'] }),
67
+ busyIntervals(resource, now - 86_400_000, to, now),
68
+ ]);
69
+ return { resource, link, bookings, busy, siteTimezone: settings.timezone, now, ignoreBookingId };
70
+ }
71
+
72
+ // ---- notifications ---------------------------------------------------------
73
+
74
+ async function describe(repo: ContentRepository, b: Booking, settings: Settings) {
75
+ if (b.kind === 'rsvp') {
76
+ const ev = b.eventId ? await repo.getEvent(b.eventId) : null;
77
+ const tz = ev ? eventTimeZone(ev, settings) : settings.timezone || 'UTC';
78
+ return {
79
+ resource: null, link: null, event: ev,
80
+ title: ev?.title ?? 'Event',
81
+ location: ev ? locationLine(ev.location) : '',
82
+ when: whenText(b, tz, settings, ev?.allDay),
83
+ };
84
+ }
85
+ const [resource, link] = await Promise.all([repo.getResource(b.resourceId), b.linkId ? repo.getBookingLink(b.linkId) : Promise.resolve(null)]);
86
+ const tz = resource ? resourceZone(resource, settings.timezone) : settings.timezone || 'UTC';
87
+ return {
88
+ resource, link, event: null as CalEvent | null,
89
+ title: [resource?.name, link?.name].filter(Boolean).join(' · ') || 'Booking',
90
+ location: resource?.name ?? '',
91
+ when: whenText(b, tz, settings, link?.mode === 'day'),
92
+ };
93
+ }
94
+
95
+ export function bookingJson(b: Booking, extra: { title?: string; resourceSlug?: string; linkSlug?: string } = {}) {
96
+ return {
97
+ id: b.id, kind: b.kind, status: b.status,
98
+ start: new Date(b.start).toISOString(), end: new Date(b.end).toISOString(),
99
+ resourceId: b.resourceId || null, linkId: b.linkId || null, eventId: b.eventId || null,
100
+ name: b.name, email: b.email, phone: b.phone, seats: b.seats, answers: b.answers,
101
+ source: b.source, createdAt: new Date(b.createdAt).toISOString(), ...extra,
102
+ };
103
+ }
104
+
105
+ export async function bookingMail(repo: ContentRepository, b: Booking, kind: BookingEmail, origin: string, from = '', snapshot?: { message?: string; eventTitle?: string; eventLocation?: string; eventAllDay?: number }) {
106
+ const settings = await repo.getSettings();
107
+ const d = await describe(repo,b,settings);
108
+ if (b.kind === 'rsvp' && !d.event && snapshot?.eventTitle) {
109
+ d.title = snapshot.eventTitle;
110
+ try { d.location = locationLine(JSON.parse(snapshot.eventLocation || '{}')); } catch { d.location = ''; }
111
+ d.when = whenText(b,b.timezone || settings.timezone || 'UTC',settings,!!snapshot.eventAllDay);
112
+ }
113
+ return bookingEmail(kind, { booking:b,resource:d.resource,link:d.link,title:d.title,location:d.location,when:d.when,
114
+ manageUrl:kind === 'staff-new' ? `${origin}/admin/calendar/bookings?b=${b.id}` : origin + manageUrlFor(b.manageToken),
115
+ siteTitle:settings.siteTitle,host:new URL(origin).host,organizer:parseFrom(from)?.email,message:snapshot?.message,
116
+ allDay:d.link?.mode==='day'||!!d.event?.allDay||(!d.event&&!!snapshot?.eventAllDay) });
117
+ }
118
+ async function notify(repo: ContentRepository, _b: Booking, _kinds: BookingEmail[], opts: FlowOptions, _message?: string) {
119
+ // Repository mutations already committed their outbox jobs atomically.
120
+ await kickEmail(repo,opts.locals,opts.waitUntil);
121
+ }
122
+
123
+ async function hook(repo: ContentRepository, type: 'booking.created' | 'booking.confirmed' | 'booking.declined' | 'booking.cancelled' | 'booking.rescheduled', b: Booking, opts: FlowOptions) {
124
+ const settings = await repo.getSettings();
125
+ const d = await describe(repo, b, settings);
126
+ await emitWebhook(repo, type, {
127
+ booking: bookingJson(b, { title: d.title, resourceSlug: d.resource?.slug, linkSlug: d.link?.slug }),
128
+ ...(d.event ? { eventUrl: opts.origin + eventUrl(d.event.slug) } : {}),
129
+ }, { waitUntil: opts.waitUntil }).catch(() => {});
130
+ }
131
+
132
+ // ---- validation --------------------------------------------------------------
133
+
134
+ export interface BookerInput {
135
+ name: string;
136
+ email: string;
137
+ phone?: string;
138
+ answers?: Record<string, string>;
139
+ }
140
+
141
+ export function validateBooker(link: Pick<BookingLink, 'questions' | 'askPhone'> | null, input: BookerInput): { name: string; email: string; phone: string; answers: Record<string, string> } {
142
+ const name = input.name.trim().slice(0, 120);
143
+ const email = input.email.trim().toLowerCase().slice(0, 200);
144
+ if (!name) throw new BookingError(400, 'Please add your name.');
145
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) throw new BookingError(400, 'Please add a valid email address.');
146
+ const phone = (input.phone ?? '').trim().slice(0, 40);
147
+ const answers: Record<string, string> = {};
148
+ for (const q of link?.questions ?? []) {
149
+ const raw = (input.answers?.[q.id] ?? '').toString().trim().slice(0, 2000);
150
+ if (q.required && (!raw || (q.type === 'checkbox' && raw !== 'yes'))) throw new BookingError(400, `Please answer “${q.label}”.`);
151
+ if (!raw) continue;
152
+ if (q.type === 'select' && !q.options.includes(raw)) throw new BookingError(400, `Pick one of the options for “${q.label}”.`);
153
+ if (q.type === 'number' && Number.isNaN(Number(raw))) throw new BookingError(400, `“${q.label}” needs a number.`);
154
+ if (q.type === 'email' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(raw)) throw new BookingError(400, `“${q.label}” needs an email address.`);
155
+ answers[q.label] = q.type === 'checkbox' ? 'Yes' : raw;
156
+ }
157
+ return { name, email, phone, answers };
158
+ }
159
+
160
+ // ---- workflows ---------------------------------------------------------------
161
+
162
+ export async function createResourceBooking(
163
+ repo: ContentRepository,
164
+ args: { resource: Resource; link: BookingLink; start: number; end: number; booker: BookerInput; source?: Booking['source']; status?: BookingStatus },
165
+ opts: FlowOptions
166
+ ): Promise<Booking> {
167
+ const { resource, link } = args;
168
+ if (!link.active) throw new BookingError(410, 'This booking link is turned off.');
169
+ const settings = await repo.getSettings();
170
+ const booker = validateBooker(link, args.booker);
171
+ // staff adding a booking by hand may ignore notice windows, never overlaps
172
+ const ctx = await availabilityContext(repo, resource, link, settings, opts.now ?? Date.now());
173
+ if (args.source !== 'admin' && !isBookable(ctx, args.start, args.end)) throw new BookingError(409, 'That time was just taken or isn’t available. Please pick another.');
174
+ const status: BookingStatus = args.status ?? (link.autoConfirm ? 'confirmed' : 'pending');
175
+ const b = await repo.createBooking({
176
+ kind: 'booking', resourceId: resource.id, linkId: link.id, eventId: '', status,
177
+ start: args.start, end: args.end, ...holdOf(link, args.start, args.end),
178
+ timezone: resourceZone(resource, settings.timezone), ...booker, seats: 1, notes: '',
179
+ manageToken: token(), source: args.source ?? 'web',
180
+ }, { idempotency: opts.idempotency });
181
+ if (!b) throw new BookingError(409, 'That time was just taken. Please pick another.');
182
+ await notify(repo, b, [status === 'confirmed' ? 'confirmed' : 'received', 'staff-new'], opts);
183
+ await hook(repo, 'booking.created', b, opts);
184
+ if (status === 'confirmed') await hook(repo, 'booking.confirmed', b, opts);
185
+ return b;
186
+ }
187
+
188
+ export async function createBlock(repo: ContentRepository, resource: Resource, start: number, end: number, note: string): Promise<Booking | null> {
189
+ if (end <= start) throw new BookingError(400, 'The block must end after it starts.');
190
+ return repo.createBooking({
191
+ kind: 'block', resourceId: resource.id, linkId: '', eventId: '', status: 'confirmed', start, end, holdStart: start, holdEnd: end,
192
+ timezone: resource.timezone, name: '', email: '', phone: '', seats: 1, answers: {}, notes: note.slice(0, 500), manageToken: '', source: 'admin',
193
+ });
194
+ }
195
+
196
+ export async function seatsTaken(repo: ContentRepository, eventId: string, start: number): Promise<number> {
197
+ const list = await repo.listBookings({ eventId, from: start - 1, to: start + 1, statuses: ['pending', 'confirmed'], kinds: ['rsvp'] });
198
+ return list.filter((b) => b.start === start).reduce((n, b) => n + b.seats, 0);
199
+ }
200
+
201
+ export async function createRsvp(
202
+ repo: ContentRepository,
203
+ args: { event: CalEvent; start: number; end: number; seats: number; booker: BookerInput; source?: Booking['source'] },
204
+ opts: FlowOptions
205
+ ): Promise<Booking> {
206
+ const e = args.event;
207
+ if (!e.rsvp.enabled) throw new BookingError(400, 'This event doesn’t take RSVPs.');
208
+ if (e.cancelled) throw new BookingError(410, 'This event is cancelled.');
209
+ if (args.end <= (opts.now ?? Date.now())) throw new BookingError(410, 'This event has already happened.');
210
+ const seats = Math.max(1, Math.min(e.rsvp.maxSeats || 1, Math.floor(args.seats) || 1));
211
+ const booker = validateBooker(null, args.booker);
212
+ const b = await repo.createBooking({
213
+ kind: 'rsvp', resourceId: '', linkId: '', eventId: e.id, status: e.rsvp.autoConfirm ? 'confirmed' : 'pending',
214
+ start: args.start, end: args.end, holdStart: args.start, holdEnd: args.end, timezone: e.timezone, ...booker, seats,
215
+ notes: '', manageToken: token(), source: args.source ?? 'web',
216
+ }, { capacity: e.rsvp.capacity, idempotency: opts.idempotency });
217
+ if (!b) throw new BookingError(409, e.rsvp.capacity ? 'Sorry — that’s more seats than are left.' : 'Couldn’t save your RSVP.');
218
+ await notify(repo, b, [b.status === 'confirmed' ? 'confirmed' : 'received'], opts);
219
+ await hook(repo, 'booking.created', b, opts);
220
+ if (b.status === 'confirmed') await hook(repo, 'booking.confirmed', b, opts);
221
+ return b;
222
+ }
223
+
224
+ const TRANSITIONS: Record<BookingStatus, BookingStatus[]> = {
225
+ pending: ['confirmed', 'declined', 'cancelled'],
226
+ confirmed: ['cancelled'],
227
+ declined: ['confirmed'],
228
+ cancelled: ['confirmed'],
229
+ };
230
+
231
+ export async function setBookingStatus(repo: ContentRepository, b: Booking, status: BookingStatus, opts: FlowOptions, message?: string): Promise<Booking> {
232
+ if (b.status === status) return b;
233
+ if (!TRANSITIONS[b.status].includes(status)) throw new BookingError(409, `A ${b.status} booking can’t become ${status}.`);
234
+ // re-confirming a declined/cancelled booking must re-check the slot
235
+ if (status === 'confirmed' && (b.status === 'declined' || b.status === 'cancelled') && b.kind !== 'rsvp') {
236
+ const clash = (await repo.listBookings({ resourceId: b.resourceId, from: b.holdStart, to: b.holdEnd, statuses: ['pending', 'confirmed'] }))
237
+ .some((x) => x.id !== b.id && x.kind !== 'rsvp' && x.holdStart < b.holdEnd && x.holdEnd > b.holdStart);
238
+ if (clash) throw new BookingError(409, 'That time has been booked since — it can’t be restored.');
239
+ }
240
+ await repo.updateBooking(b.id, { status, notificationMessage: message });
241
+ const next = (await repo.getBooking(b.id))!;
242
+ if (!next || next.status !== status) throw new BookingError(409, 'This booking changed. Refresh and try again.');
243
+ if (b.kind !== 'block') {
244
+ await notify(repo, next, [status === 'confirmed' ? 'confirmed' : status === 'declined' ? 'declined' : 'cancelled'], opts, message);
245
+ await hook(repo, status === 'confirmed' ? 'booking.confirmed' : status === 'declined' ? 'booking.declined' : 'booking.cancelled', next, opts);
246
+ }
247
+ return next;
248
+ }
249
+
250
+ export async function rescheduleBooking(repo: ContentRepository, b: Booking, start: number, end: number, opts: FlowOptions & { skipRules?: boolean }): Promise<Booking> {
251
+ if (b.kind !== 'booking') throw new BookingError(400, 'Only bookings can be moved.');
252
+ if (b.status !== 'pending' && b.status !== 'confirmed') throw new BookingError(409, 'Only active bookings can be moved.');
253
+ const [resource, link, settings] = await Promise.all([repo.getResource(b.resourceId), repo.getBookingLink(b.linkId), repo.getSettings()]);
254
+ if (!resource || !link) throw new BookingError(404, 'This booking’s room or link no longer exists.');
255
+ if (!opts.skipRules) {
256
+ const ctx = await availabilityContext(repo, resource, link, settings, opts.now ?? Date.now(), b.id);
257
+ if (!isBookable(ctx, start, end)) throw new BookingError(409, 'That time isn’t available.');
258
+ }
259
+ const ok = await repo.moveBooking(b.id, { start, end, ...holdOf(link, start, end) });
260
+ if (!ok) throw new BookingError(409, 'That time was just taken.');
261
+ const next = (await repo.getBooking(b.id))!;
262
+ await notify(repo, next, ['rescheduled'], opts);
263
+ await hook(repo, 'booking.rescheduled', next, opts);
264
+ return next;
265
+ }
266
+
267
+ // cron: reminders ~24h ahead (window catches up if a sweep was missed)
268
+ export async function sendDueReminders(repo: ContentRepository, opts: FlowOptions): Promise<number> {
269
+ const now = opts.now ?? Date.now();
270
+ const due = await repo.listDueReminders(now + 60 * 60_000, now + 24 * 3600_000);
271
+ for (const b of due) {
272
+ await repo.email.enqueue({dedupe:`${b.id}:${b.notificationVersion}:reminder:${b.email}`,kind:'booking',recipient:b.email,
273
+ payload:JSON.stringify({bookingId:b.id,version:b.notificationVersion,kind:'reminder'}),configVersion:(await repo.email.config())?.version||0,expiresAt:b.start});
274
+ }
275
+ await kickEmail(repo,opts.locals,opts.waitUntil);
276
+ return due.length;
277
+ }
@@ -0,0 +1,54 @@
1
+ // Display helpers for the public booking pages.
2
+ import type { BookingLink, Resource, Settings } from '../schema';
3
+ import { siteLocale } from '../dates';
4
+ import { addDaysYmd, localMidnight, resourceZone, ymd, type Slot } from './availability';
5
+
6
+ export function fmt(settings: Settings, tz: string) {
7
+ const locale = siteLocale(settings);
8
+ const f = (o: Intl.DateTimeFormatOptions) => {
9
+ try { return new Intl.DateTimeFormat(locale, { ...o, timeZone: tz }); }
10
+ catch { return new Intl.DateTimeFormat('en-US', { ...o, timeZone: 'UTC' }); }
11
+ };
12
+ return {
13
+ time: f({ hour: 'numeric', minute: '2-digit' }),
14
+ day: f({ weekday: 'short', month: 'short', day: 'numeric' }),
15
+ dayLong: f({ weekday: 'long', month: 'long', day: 'numeric' }),
16
+ month: f({ month: 'long', year: 'numeric' }),
17
+ dow: f({ weekday: 'narrow' }),
18
+ zone: f({ timeZoneName: 'long' }).formatToParts(Date.now()).find((p) => p.type === 'timeZoneName')?.value ?? tz,
19
+ };
20
+ }
21
+
22
+ export function slotLabel(s: Slot, link: BookingLink, f: ReturnType<typeof fmt>): string {
23
+ if (link.mode === 'day') return s.minutes === 1440 ? f.dayLong.format(s.start) : `${f.day.format(s.start)} – ${f.day.format(s.end - 1)}`;
24
+ if (link.mode === 'range') return `${f.time.format(s.start)} – ${f.time.format(s.end)}`;
25
+ return f.time.format(s.start);
26
+ }
27
+
28
+ export interface MonthCell { date: string; day: number; inMonth: boolean; open: boolean; past: boolean }
29
+
30
+ // a Sunday-first month grid for YYYY-MM in the resource zone
31
+ export function monthCells(month: string, open: Set<string>, today: string): MonthCell[] {
32
+ const [y, m] = month.split('-').map(Number);
33
+ const first = `${y}-${String(m).padStart(2, '0')}-01`;
34
+ const dow = new Date(Date.UTC(y, m - 1, 1)).getUTCDay();
35
+ const cells: MonthCell[] = [];
36
+ let date = addDaysYmd(first, -dow);
37
+ for (let i = 0; i < 42; i++, date = addDaysYmd(date, 1)) {
38
+ const [, mm, dd] = date.split('-').map(Number);
39
+ cells.push({ date, day: dd, inMonth: mm === m, open: open.has(date), past: date < today });
40
+ }
41
+ // drop a trailing week that belongs entirely to the next month
42
+ return cells.slice(35).every((c) => !c.inMonth) ? cells.slice(0, 35) : cells;
43
+ }
44
+
45
+ export function addMonths(month: string, n: number): string {
46
+ const [y, m] = month.split('-').map(Number);
47
+ const t = y * 12 + (m - 1) + n;
48
+ return `${Math.floor(t / 12)}-${String((t % 12) + 1).padStart(2, '0')}`;
49
+ }
50
+
51
+ export function todayIn(resource: Resource, settings: Settings, now: number) {
52
+ const tz = resourceZone(resource, settings.timezone);
53
+ return { tz, today: ymd(now, tz), midnight: localMidnight(ymd(now, tz), tz) };
54
+ }
@@ -0,0 +1,48 @@
1
+ // Agent / MCP tool inputs for events. Kept apart from lib/agent.ts so that
2
+ // file can compose them into its discriminated union without an import cycle.
3
+ import { z } from 'zod';
4
+
5
+ const wall = z.string().regex(/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2})?$/, 'Use YYYY-MM-DD or YYYY-MM-DDTHH:mm (wall-clock time in the event timezone).');
6
+ const eventFields = {
7
+ title: z.string().trim().min(1).max(300).optional(),
8
+ content: z.string().max(100_000).optional(),
9
+ excerpt: z.string().max(1000).optional(),
10
+ start: wall.optional(),
11
+ end: wall.optional(),
12
+ allDay: z.boolean().optional(),
13
+ timezone: z.string().max(80).optional(),
14
+ rrule: z.string().max(500).optional(),
15
+ location: z.object({ name: z.string().max(300).default(''), address: z.string().max(500).default(''), url: z.string().max(1000).default('') }).strict().optional(),
16
+ cta: z.object({ label: z.string().max(120).default(''), href: z.string().max(1000).default(''), style: z.enum(['primary', 'outline']).default('primary') }).strict().optional(),
17
+ calendars: z.array(z.string().max(100)).max(20).optional(),
18
+ tags: z.array(z.string().trim().min(1).max(100)).max(50).optional(),
19
+ cancelled: z.boolean().optional(),
20
+ seoTitle: z.string().max(300).optional(),
21
+ seoDesc: z.string().max(2000).optional(),
22
+ };
23
+
24
+ export const EventToolInputs = [
25
+ z.object({ tool: z.literal('list_events'), arguments: z.object({
26
+ scope: z.enum(['upcoming', 'past', 'all']).default('upcoming'),
27
+ calendar: z.string().optional(),
28
+ query: z.string().max(200).optional(),
29
+ offset: z.number().int().min(0).default(0),
30
+ limit: z.number().int().min(1).max(100).default(25),
31
+ }).strict() }).strict(),
32
+ z.object({ tool: z.literal('get_event'), arguments: z.object({ id: z.string().min(1) }).strict() }).strict(),
33
+ z.object({ tool: z.literal('list_calendars'), arguments: z.object({}).strict() }).strict(),
34
+ z.object({ tool: z.literal('create_event'), arguments: z.object({ ...eventFields, title: z.string().trim().min(1).max(300), start: wall, slug: z.string().max(300).optional() }).strict() }).strict(),
35
+ z.object({ tool: z.literal('update_event'), arguments: z.object({ ...eventFields, id: z.string().min(1) }).strict() }).strict(),
36
+ z.object({ tool: z.literal('publish_event'), arguments: z.object({ id: z.string().min(1) }).strict() }).strict(),
37
+ ] as const;
38
+
39
+ export const EVENT_TOOL_NAMES = ['list_events', 'get_event', 'list_calendars', 'create_event', 'update_event', 'publish_event'] as const;
40
+
41
+ export const EVENT_TOOL_DESCRIPTIONS: Record<(typeof EVENT_TOOL_NAMES)[number], string> = {
42
+ list_events: 'List events (drafts included) with their next date. scope: upcoming (default), past, all. Filter by calendar slug or a title search.',
43
+ get_event: 'Read one event including any pending draft. Times are wall-clock strings in the event timezone.',
44
+ list_calendars: 'List calendars (slug, name, visibility, kind). Use slugs in create_event/update_event "calendars".',
45
+ create_event: 'Create a DRAFT event. start/end are wall-clock "YYYY-MM-DDTHH:mm" (or "YYYY-MM-DD" with allDay) in the event timezone (site timezone when omitted). content is Markdown. rrule is an RFC 5545 rule like "FREQ=WEEKLY;BYDAY=TU". Never publishes.',
46
+ update_event: 'Update supplied event fields. Published/scheduled events stage a pending draft that goes live on publish/Update. calendars replaces the list.',
47
+ publish_event: 'Publish an event (applying any pending draft). Requires owner-enabled publishing permission.',
48
+ };
@@ -0,0 +1,136 @@
1
+ // Agent / MCP tool runner for events. Same contract as the content tools:
2
+ // drafts by default, live events stage edits, publishing needs the owner's
3
+ // permission, content is Markdown sanitized to safe HTML.
4
+ import { z } from 'zod';
5
+ import type { ContentRepository } from '../repo/types';
6
+ import type { CalEvent } from '../schema';
7
+ import { slugify } from '../schema';
8
+ import { ApiError } from '../api';
9
+ import { renderAgentMarkdown } from '../agent';
10
+ import { CALENDAR } from './config';
11
+ import { EventToolInputs } from './agent-schema';
12
+ import { featuredOccurrence, formatOccurrence, isValidTimeZone, normalizeEventTimes } from './events';
13
+ import { mergeEventDraft } from '../drafts';
14
+ import { eventChanged } from './hooks';
15
+ import { eventToJson } from './api';
16
+ import { siteLocale } from '../dates';
17
+
18
+ export type EventToolInput = z.infer<(typeof EventToolInputs)[number]>;
19
+
20
+ export interface AgentContext {
21
+ origin: string;
22
+ waitUntil?: (p: Promise<unknown>) => void;
23
+ }
24
+
25
+ const editUrl = (id: string) => `/admin/calendar/events/${id}`;
26
+
27
+ async function resolveCalendars(repo: ContentRepository, slugs: string[] | undefined) {
28
+ if (slugs === undefined) return undefined;
29
+ const all = await repo.listCalendars();
30
+ return slugs.map((s) => {
31
+ const c = all.find((x) => x.slug === s || x.id === s);
32
+ if (!c) throw new ApiError(400, `Unknown calendar "${s}". Call list_calendars for valid slugs.`);
33
+ if (c.kind !== 'native') throw new ApiError(400, `"${s}" mirrors an external feed and can't hold events.`);
34
+ return c.id;
35
+ });
36
+ }
37
+
38
+ function checkLinks(a: { cta?: { href: string }; location?: { url: string }; timezone?: string }) {
39
+ if (a.cta?.href && !/^(https?:\/\/|\/(?!\/)|mailto:|tel:)/i.test(a.cta.href)) throw new ApiError(400, 'cta.href must start with https://, /, mailto: or tel:.');
40
+ if (a.location?.url && !/^https?:\/\//i.test(a.location.url)) throw new ApiError(400, 'location.url must start with https://.');
41
+ if (a.timezone && !isValidTimeZone(a.timezone)) throw new ApiError(400, `Unknown timezone "${a.timezone}".`);
42
+ }
43
+
44
+ export async function runEventTool(repo: ContentRepository, input: EventToolInput, allowPublish: boolean, ctx: AgentContext) {
45
+ if (!CALENDAR.enabled) throw new ApiError(404, 'The calendar module is disabled on this site.');
46
+ const settings = await repo.getSettings();
47
+ const calendars = await repo.listCalendars();
48
+ const calMap = new Map(calendars.map((c) => [c.id, c]));
49
+ const now = Date.now();
50
+ const summary = (e: CalEvent) => {
51
+ const o = featuredOccurrence(e, settings, now);
52
+ return {
53
+ id: e.id, title: e.title, slug: e.slug, status: e.status, cancelled: e.cancelled,
54
+ start: e.start, end: e.end, allDay: e.allDay, rrule: e.rrule,
55
+ next: o ? formatOccurrence(o, settings.timezone || 'UTC', siteLocale(settings)).range : '',
56
+ calendars: e.calendarIds.map((id) => calMap.get(id)?.slug).filter(Boolean),
57
+ hasPendingDraft: Boolean(e.draft), editUrl: editUrl(e.id),
58
+ };
59
+ };
60
+
61
+ switch (input.tool) {
62
+ case 'list_calendars':
63
+ return { calendars: calendars.map((c) => ({ id: c.id, slug: c.slug, name: c.name, kind: c.kind, visibility: c.visibility, color: c.color })) };
64
+ case 'list_events': {
65
+ const a = input.arguments;
66
+ const cal = a.calendar ? calendars.find((c) => c.slug === a.calendar) : null;
67
+ if (a.calendar && !cal) throw new ApiError(400, 'Unknown calendar.');
68
+ let rows = (await repo.listEvents()).map((e) => ({ e, o: featuredOccurrence(e, settings, now) }));
69
+ if (cal) rows = rows.filter((r) => r.e.calendarIds.includes(cal.id));
70
+ if (a.query) rows = rows.filter((r) => `${r.e.title} ${r.e.slug}`.toLowerCase().includes(a.query!.toLowerCase()));
71
+ if (a.scope !== 'all') rows = rows.filter((r) => (r.o ? r.o.end > now : true) === (a.scope === 'upcoming'));
72
+ rows.sort((x, y) => ((x.o?.start ?? 0) - (y.o?.start ?? 0)) * (a.scope === 'past' ? -1 : 1));
73
+ return { total: rows.length, offset: a.offset, items: rows.slice(a.offset, a.offset + a.limit).map((r) => summary(r.e)) };
74
+ }
75
+ case 'get_event': {
76
+ const e = await repo.getEvent(input.arguments.id);
77
+ if (!e) throw new ApiError(404, 'Event not found.');
78
+ return { ...eventToJson(mergeEventDraft(e), ctx.origin, calMap, settings.timezone), bodyHtml: mergeEventDraft(e).bodyHtml, hasPendingDraft: Boolean(e.draft), editUrl: editUrl(e.id) };
79
+ }
80
+ case 'create_event': {
81
+ const a = input.arguments;
82
+ checkLinks(a);
83
+ let times: { start: string; end: string };
84
+ try { times = normalizeEventTimes({ start: a.start, end: a.end, allDay: a.allDay ?? a.start.length === 10 }); }
85
+ catch (err) { throw new ApiError(400, err instanceof Error ? err.message : 'Invalid time.'); }
86
+ const base = slugify(a.slug || a.title);
87
+ let slug = base;
88
+ for (let n = 2; await repo.getEventBySlug(slug); n++) slug = `${base}-${n}`;
89
+ const e = await repo.createEvent({
90
+ title: a.title, slug, ...times, allDay: a.allDay ?? a.start.length === 10, timezone: a.timezone ?? '',
91
+ rrule: a.rrule ?? '', excerpt: a.excerpt ?? '', location: a.location, cta: a.cta, tags: a.tags,
92
+ cancelled: a.cancelled ?? false, seoTitle: a.seoTitle, seoDesc: a.seoDesc,
93
+ calendarIds: (await resolveCalendars(repo, a.calendars)) ?? [],
94
+ ...(a.content !== undefined ? { bodyHtml: renderAgentMarkdown(a.content) } : {}),
95
+ });
96
+ if (a.tags?.length) await repo.ensureTags(a.tags);
97
+ return { ...summary(e) };
98
+ }
99
+ case 'update_event': {
100
+ const { id, content, calendars: slugs, ...rest } = input.arguments;
101
+ checkLinks(rest);
102
+ const before = await repo.getEvent(id);
103
+ if (!before) throw new ApiError(404, 'Event not found.');
104
+ const data: Partial<CalEvent> = { ...rest };
105
+ if (content !== undefined) {
106
+ data.bodyHtml = renderAgentMarkdown(content);
107
+ data.bodyJson = '';
108
+ }
109
+ const calendarIds = await resolveCalendars(repo, slugs);
110
+ if (calendarIds) data.calendarIds = calendarIds;
111
+ if (data.start !== undefined || data.end !== undefined || data.allDay !== undefined) {
112
+ const merged = mergeEventDraft(before);
113
+ try {
114
+ const t = normalizeEventTimes({ start: data.start ?? merged.start, end: data.end ?? merged.end, allDay: data.allDay ?? merged.allDay });
115
+ data.start = t.start; data.end = t.end;
116
+ } catch (err) { throw new ApiError(400, err instanceof Error ? err.message : 'Invalid time.'); }
117
+ }
118
+ if (!Object.keys(data).length) throw new ApiError(400, 'Provide at least one field to update.');
119
+ const staged = before.status === 'published' || before.status === 'scheduled';
120
+ if (staged) await repo.saveEventDraft(id, data);
121
+ else await repo.updateEvent(id, data);
122
+ if (data.tags?.length) await repo.ensureTags(data.tags);
123
+ return { ...summary((await repo.getEvent(id))!), staged };
124
+ }
125
+ case 'publish_event': {
126
+ if (!allowPublish) throw new ApiError(403, 'Agent publishing is disabled. An owner can enable it in Settings.');
127
+ const before = await repo.getEvent(input.arguments.id);
128
+ if (!before) throw new ApiError(404, 'Event not found.');
129
+ if (before.draft) await repo.applyEventDraft(before.id);
130
+ await repo.updateEvent(before.id, { status: 'published', scheduledAt: null });
131
+ const after = (await repo.getEvent(before.id))!;
132
+ await eventChanged(repo, before, after, { origin: ctx.origin, waitUntil: ctx.waitUntil, applied: Boolean(before.draft) });
133
+ return { ...summary(after) };
134
+ }
135
+ }
136
+ }
@@ -0,0 +1,81 @@
1
+ // The public JSON shape of an event — one definition shared by the REST API,
2
+ // webhook payloads and agent tools, so integrations see the same fields
3
+ // everywhere. Drafts, internal ids of private calendars and staging data
4
+ // never appear here.
5
+ import type { CalEvent, Calendar } from '../schema';
6
+ import { calendarFeedUrl, eventIcsUrl, eventUrl } from './config';
7
+ import { describeRepeat } from './rrule';
8
+
9
+ export interface EventJson {
10
+ id: string;
11
+ slug: string;
12
+ url: string;
13
+ icsUrl: string;
14
+ title: string;
15
+ excerpt: string;
16
+ status: string;
17
+ cancelled: boolean;
18
+ featured: boolean;
19
+ start: string;
20
+ end: string;
21
+ allDay: boolean;
22
+ timezone: string;
23
+ rrule: string;
24
+ repeats: string;
25
+ exdates: string[];
26
+ location: CalEvent['location'];
27
+ cta: CalEvent['cta'];
28
+ image: string;
29
+ imageAlt: string;
30
+ tags: string[];
31
+ calendars: { id: string; slug: string; name: string; color: string }[];
32
+ fields: Record<string, string>;
33
+ updatedAt: string;
34
+ }
35
+
36
+ const abs = (origin: string, path: string) => (/^https?:\/\//.test(path) ? path : origin + path);
37
+
38
+ export function eventToJson(e: CalEvent, origin: string, calendars: Map<string, Calendar>, siteTz = ''): EventJson {
39
+ return {
40
+ id: e.id,
41
+ slug: e.slug,
42
+ url: abs(origin, eventUrl(e.slug)),
43
+ icsUrl: abs(origin, eventIcsUrl(e.slug)),
44
+ title: e.title,
45
+ excerpt: e.excerpt,
46
+ status: e.status,
47
+ cancelled: e.cancelled,
48
+ featured: e.featured,
49
+ start: e.start,
50
+ end: e.end,
51
+ allDay: e.allDay,
52
+ timezone: e.timezone || siteTz || 'UTC',
53
+ rrule: e.rrule,
54
+ repeats: describeRepeat(e.rrule, e.start),
55
+ exdates: e.exdates,
56
+ location: e.location,
57
+ cta: e.cta,
58
+ image: e.cover ? abs(origin, e.cover) : '',
59
+ imageAlt: e.coverAlt,
60
+ tags: e.tags.filter((t) => !t.startsWith('#')),
61
+ calendars: e.calendarIds
62
+ .map((id) => calendars.get(id))
63
+ .filter((c): c is Calendar => Boolean(c) && c!.visibility !== 'private')
64
+ .map((c) => ({ id: c.id, slug: c.slug, name: c.name, color: c.color })),
65
+ fields: e.fields,
66
+ updatedAt: new Date(e.updatedAt).toISOString(),
67
+ };
68
+ }
69
+
70
+ export function calendarToJson(c: Calendar, origin: string) {
71
+ return {
72
+ id: c.id,
73
+ slug: c.slug,
74
+ name: c.name,
75
+ color: c.color,
76
+ description: c.description,
77
+ kind: c.kind,
78
+ visibility: c.visibility,
79
+ feedUrl: c.visibility === 'public' || c.visibility === 'unlisted' ? abs(origin, calendarFeedUrl(c.slug)) : '',
80
+ };
81
+ }