@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,73 @@
1
+ import { z } from 'zod';
2
+ import type { ContentRepository } from '../repo/types';
3
+ import type { Booking } from '../schema';
4
+ import { ApiError, readJson } from '../api';
5
+ import { sha256Hex } from '../api-keys';
6
+ import { siteOrigin } from '../seo';
7
+ import { eventIsPublic, expandEvents } from '../calendar/events';
8
+ import { createResourceBooking, createRsvp, bookingJson } from '../booking/service';
9
+ import { waitUntilFrom } from '../webhooks';
10
+ import { requireCalendar } from './calendar';
11
+
12
+ const common = {
13
+ start: z.string().datetime({ offset: true }),
14
+ name: z.string().trim().min(1).max(120), email: z.string().trim().email().max(200).transform(v => v.toLowerCase()),
15
+ phone: z.string().trim().max(40).default(''),
16
+ };
17
+ export const bookingInput = z.union([
18
+ z.object({ ...common, resource: z.string().min(1).max(200), link: z.string().min(1).max(200),
19
+ minutes: z.number().int().min(1).max(44640).optional(), answers: z.record(z.string().max(2000)).default({}),
20
+ }).strict(),
21
+ z.object({ ...common, event: z.string().min(1).max(200), seats: z.number().int().min(1).max(1000).default(1) }).strict(),
22
+ ]);
23
+ function canonical(value: unknown): string {
24
+ if (value && typeof value === 'object' && !Array.isArray(value)) return `{${Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k,v]) => `${JSON.stringify(k)}:${canonical(v)}`).join(',')}}`;
25
+ return JSON.stringify(value);
26
+ }
27
+ const bookingData = (booking: Booking) => ({ booking: bookingJson(booking), manageToken: booking.manageToken });
28
+ export async function submitBooking(repo: ContentRepository, request: Request, locals?: unknown) {
29
+ requireCalendar(true);
30
+ const key = request.headers.get('idempotency-key') ?? '';
31
+ // Treat the key as a secret receipt. Require a fresh UUID per user intent.
32
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(key)) throw new ApiError(400, 'Send a random UUID v4 in Idempotency-Key and reuse it only for retries.');
33
+ const body = bookingInput.parse(await readJson(request, 50000));
34
+ const idempotency = { keyHash: await sha256Hex(key.toLowerCase()), requestHash: await sha256Hex(canonical(body)) };
35
+ const replay = async () => {
36
+ const record = await repo.getBookingRequest(idempotency.keyHash);
37
+ if (!record) return null;
38
+ if (record.requestHash !== idempotency.requestHash) throw new ApiError(409, 'This Idempotency-Key was already used with different details.');
39
+ const booking = await repo.getBooking(record.bookingId);
40
+ if (!booking) throw new ApiError(410, 'The original booking was removed. This key cannot be reused.');
41
+ return { data: bookingData(booking), replayed: true };
42
+ };
43
+ const existing = await replay();
44
+ if (existing) return existing;
45
+ try {
46
+ const settings = await repo.getSettings();
47
+ const flow = { origin: siteOrigin(settings, new URL(request.url)), locals, waitUntil: waitUntilFrom(locals), idempotency };
48
+ const start = Date.parse(body.start);
49
+ let booking: Booking;
50
+ if ('event' in body) {
51
+ const event = await repo.getEventBySlug(body.event) ?? await repo.getEvent(body.event);
52
+ const calendars = new Map((await repo.listCalendars()).map(c => [c.id, c]));
53
+ if (!event || !eventIsPublic(event, calendars)) throw new ApiError(404, 'Event not found.');
54
+ if (body.seats > (event.rsvp.maxSeats || 1)) throw new ApiError(400, 'Too many seats for one RSVP.');
55
+ const occurrence = expandEvents([event], settings, { displayTz: settings.timezone || 'UTC', now: Date.now(), windowStartMs: start, windowEndMs: start + 1 }).find(o => o.start === start);
56
+ if (!occurrence) throw new ApiError(400, 'Start must match an event occurrence exactly.');
57
+ booking = await createRsvp(repo, { event, start, end: occurrence.end, seats: body.seats, booker: body, source: 'web' }, flow);
58
+ } else {
59
+ const resource = await repo.getResourceBySlug(body.resource);
60
+ const link = resource ? await repo.getBookingLinkBySlug(resource.id, body.link) : null;
61
+ if (!resource || !link) throw new ApiError(404, 'Resource or booking link not found.');
62
+ const minutes = body.minutes ?? (link.mode === 'day' ? 1440 : link.mode === 'range' ? link.lengths[0] ?? link.duration : link.duration);
63
+ booking = await createResourceBooking(repo, { resource, link, start, end: start + minutes * 60000, booker: body, source: 'web' }, flow);
64
+ }
65
+ return { data: bookingData(booking), replayed: false };
66
+ } catch (error) {
67
+ // Another request may have committed while this one checked availability,
68
+ // or delivery may have failed after commit. The ledger is authoritative.
69
+ const existing = await replay();
70
+ if (existing) return existing;
71
+ throw error;
72
+ }
73
+ }
@@ -0,0 +1,85 @@
1
+ import { z } from 'zod';
2
+ import type { ContentRepository } from '../repo/types';
3
+ import { ApiError } from '../api';
4
+ import { CALENDAR, bookingsEnabled, resourceUrl, bookingLinkUrl } from '../calendar/config';
5
+ import { calendarToJson, eventToJson } from '../calendar/api';
6
+ import { eventIsListed, eventIsPublic, expandEvents } from '../calendar/events';
7
+ import { availabilityContext } from '../booking/service';
8
+ import { addDaysYmd, describeLink, linkLengths, slotsForDay, ymd } from '../booking/availability';
9
+ import { siteOrigin } from '../seo';
10
+ import { safeUrl } from './content';
11
+ import { pageQuery, isoDay, queryParams } from './http';
12
+
13
+ export function requireCalendar(bookings = false) {
14
+ if (!CALENDAR.enabled || (bookings && !bookingsEnabled())) throw new ApiError(404, 'This feature is disabled.');
15
+ }
16
+ export async function listCalendars(repo: ContentRepository, url: URL) {
17
+ requireCalendar();
18
+ z.object({}).strict().parse(queryParams(url));
19
+ const origin = siteOrigin(await repo.getSettings(), url);
20
+ return { calendars: (await repo.listCalendars()).filter(c => c.visibility === 'public').map(c => calendarToJson(c, origin)) };
21
+ }
22
+ export async function listEvents(repo: ContentRepository, url: URL) {
23
+ requireCalendar();
24
+ const q = z.object({ ...pageQuery, from: isoDay.optional(), to: isoDay.optional(), calendar: z.string().min(1).max(200).optional(), q: z.string().max(100).optional() }).strict().parse(queryParams(url));
25
+ const from = Date.parse(q.from ?? new Date().toISOString().slice(0, 10));
26
+ const to = q.to ? Date.parse(q.to) : from + 30 * 86400000;
27
+ if (to <= from || to - from > 90 * 86400000) throw new ApiError(400, 'Use an event window of 1 to 90 days.');
28
+ const [settings, calendars, events] = await Promise.all([repo.getSettings(), repo.listCalendars(), repo.listPublishedEvents()]);
29
+ const all = new Map(calendars.map(c => [c.id, c]));
30
+ const cal = q.calendar ? calendars.find(c => c.slug === q.calendar && c.visibility !== 'private') : null;
31
+ if (q.calendar && !cal) throw new ApiError(404, 'Calendar not found.');
32
+ const publicCalendars = new Map(calendars.filter(c => c.visibility === 'public' || c.id === cal?.id).map(c => [c.id, c]));
33
+ const visible = events.filter(e => (cal ? e.calendarIds.includes(cal.id) && eventIsPublic(e, all) : eventIsListed(e, all))
34
+ && (!q.q || `${e.title} ${e.excerpt} ${e.location.name}`.toLowerCase().includes(q.q.toLowerCase())));
35
+ const occurrences = expandEvents(visible, settings, { displayTz: settings.timezone || 'UTC', now: Date.now(), windowStartMs: from, windowEndMs: to });
36
+ occurrences.sort((a, b) => a.start - b.start || (a.event.id < b.event.id ? -1 : a.event.id > b.event.id ? 1 : 0));
37
+ const start = (q.page - 1) * q.limit;
38
+ const origin = siteOrigin(settings, url);
39
+ return {
40
+ timezone: settings.timezone || 'UTC', from: new Date(from).toISOString(), to: new Date(to).toISOString(),
41
+ occurrences: occurrences.slice(start, start + q.limit).map(o => {
42
+ const event = eventToJson(o.event, origin, publicCalendars, settings.timezone);
43
+ // Only configured public event fields; no undeclared imported metadata.
44
+ event.fields = Object.fromEntries(CALENDAR.eventFields.filter(f => Object.hasOwn(o.event.fields, f.key)).map(f => [f.key, o.event.fields[f.key]]));
45
+ event.image = safeUrl(o.event.cover, origin);
46
+ event.cta = { ...event.cta, href: safeUrl(event.cta.href, origin, true) };
47
+ return { id: `${o.event.id}:${o.start}`, start: new Date(o.start).toISOString(), end: new Date(o.end).toISOString(), allDay: o.allDay, event };
48
+ }),
49
+ pagination: { page: q.page, limit: q.limit, nextPage: occurrences.length > start + q.limit ? q.page + 1 : null },
50
+ };
51
+ }
52
+ export async function listResources(repo: ContentRepository, url: URL) {
53
+ requireCalendar(true);
54
+ const q = z.object(pageQuery).strict().parse(queryParams(url));
55
+ const [settings, resources, links] = await Promise.all([repo.getSettings(), repo.listResources(), repo.listBookingLinks()]);
56
+ const origin = siteOrigin(settings, url), start = (q.page - 1) * q.limit;
57
+ const visible = resources.filter(r => r.visibility === 'public');
58
+ return { resources: visible.slice(start, start + q.limit).map(r => ({
59
+ id: r.id, slug: r.slug, name: r.name, kind: r.kind, description: r.description, capacity: r.capacity, amenities: r.amenities,
60
+ photo: safeUrl(r.photo, origin), timezone: r.timezone || settings.timezone || 'UTC', url: origin + resourceUrl(r.slug),
61
+ links: links.filter(l => l.resourceId === r.id && l.active && l.visibility === 'public').map(l => ({
62
+ id: l.id, slug: l.slug, name: l.name, description: l.description, mode: l.mode, lengths: linkLengths(l), rules: describeLink(l),
63
+ askPhone: l.askPhone, autoConfirm: l.autoConfirm, url: origin + bookingLinkUrl(r.slug, l.slug),
64
+ questions: l.questions.map(({ id, label, type, required, options }) => ({ id, label, type, required, options })),
65
+ })),
66
+ })), pagination: { page: q.page, limit: q.limit, nextPage: visible.length > start + q.limit ? q.page + 1 : null } };
67
+ }
68
+ export async function getAvailability(repo: ContentRepository, url: URL) {
69
+ requireCalendar(true);
70
+ const q = z.object({ resource: z.string().min(1).max(200), link: z.string().min(1).max(200), from: isoDay.optional(),
71
+ days: z.coerce.number().int().min(1).max(31).default(7), minutes: z.coerce.number().int().min(1).max(44640).optional(),
72
+ }).strict().parse(queryParams(url));
73
+ const resource = await repo.getResourceBySlug(q.resource);
74
+ const link = resource ? await repo.getBookingLinkBySlug(resource.id, q.link) : null;
75
+ if (!resource || !link || !link.active) throw new ApiError(404, 'Resource or booking link not found.');
76
+ const settings = await repo.getSettings();
77
+ const ctx = await availabilityContext(repo, resource, link, settings);
78
+ const timezone = resource.timezone || settings.timezone || 'UTC';
79
+ const from = q.from ?? ymd(Date.now(), timezone);
80
+ return { timezone, mode: link.mode, lengths: linkLengths(link), autoConfirm: link.autoConfirm,
81
+ days: Array.from({ length: q.days }, (_, i) => addDaysYmd(from, i)).map(date => ({ date,
82
+ slots: slotsForDay(ctx, date, q.minutes).map(s => ({ start: new Date(s.start).toISOString(), end: new Date(s.end).toISOString(), minutes: s.minutes })),
83
+ })),
84
+ };
85
+ }
@@ -0,0 +1,85 @@
1
+ import { z } from 'zod';
2
+ import sanitizeHtml from 'sanitize-html';
3
+ import type { ContentRepository } from '../repo/types';
4
+ import type { Page, Post } from '../schema';
5
+ import { publicTags } from '../schema';
6
+ import { CONTENT, collectionUrl } from '../content';
7
+ import { I18N, localeHomeUrl } from '../i18n';
8
+ import { siteOrigin } from '../seo';
9
+ import { renderMarkdown } from '../markdown';
10
+ import { blocksToHtml } from '../blocksToHtml';
11
+ import { ApiError } from '../api';
12
+ import { pageQuery, queryParams } from './http';
13
+
14
+ export const contentQuery = z.object({
15
+ ...pageQuery, kind: z.enum(['post', 'page']).optional(),
16
+ collection: z.string().min(1).max(80).optional(), locale: z.string().min(1).max(35).optional(),
17
+ }).strict();
18
+ export function safeUrl(value: string, origin: string, link = false): string {
19
+ if (!value) return '';
20
+ try {
21
+ const url = new URL(value, origin);
22
+ return (['http:', 'https:', ...(link ? ['mailto:', 'tel:'] : [])].includes(url.protocol) && !url.username && !url.password) ? url.href : '';
23
+ } catch { return ''; }
24
+ }
25
+ export function mobileHtml(html: string, origin: string) {
26
+ return sanitizeHtml(html, {
27
+ allowedTags: [...sanitizeHtml.defaults.allowedTags, 'img'],
28
+ allowedAttributes: { a: ['href', 'title'], img: ['src', 'alt', 'title', 'width', 'height'], th: ['colspan', 'rowspan'], td: ['colspan', 'rowspan'] },
29
+ allowedSchemes: ['https', 'http', 'mailto', 'tel'],
30
+ allowedSchemesByTag: { img: ['https', 'http'] },
31
+ allowProtocolRelative: false,
32
+ transformTags: {
33
+ a: (_tag, attrs) => ({ tagName: 'a', attribs: { ...attrs, href: safeUrl(attrs.href ?? '', origin, true) } }),
34
+ img: (_tag, attrs) => ({ tagName: 'img', attribs: { ...attrs, src: safeUrl(attrs.src ?? '', origin) } }),
35
+ },
36
+ });
37
+ }
38
+ const iso = (n: number | null) => n === null ? null : new Date(n).toISOString();
39
+ function dto(doc: Page | Post, kind: 'page' | 'post', origin: string, detail: boolean) {
40
+ const post = kind === 'post' ? doc as Post : null;
41
+ const page = kind === 'page' ? doc as Page : null;
42
+ const result = {
43
+ id: doc.id, kind, slug: doc.slug, title: doc.title,
44
+ collection: post?.collection ?? null,
45
+ locale: page?.locale || I18N.defaultLocale || null,
46
+ url: safeUrl(post ? collectionUrl(post.collection, post.slug) : page!.isHome ? localeHomeUrl(page!.locale) : `/${page!.slug}`, origin),
47
+ excerpt: post?.excerpt ?? page!.seoDesc,
48
+ tags: post ? publicTags(post.tags) : [],
49
+ image: post ? safeUrl(post.cover, origin) : '', imageAlt: post?.coverAlt ?? '',
50
+ publishedAt: post ? iso(post.publishedAt) : null,
51
+ createdAt: iso(doc.createdAt), updatedAt: iso(doc.updatedAt),
52
+ };
53
+ if (!detail) return result;
54
+ // An intentionally empty canonical editor body must not revive legacy text.
55
+ const html = doc.bodyJson ? doc.bodyHtml : doc.bodyHtml || (post ? renderMarkdown(post.body) : blocksToHtml(page!.blocks));
56
+ return { ...result, content: { format: 'sanitized-html' as const, html: mobileHtml(html, result.url), dynamicWidgets: 'website-only' as const } };
57
+ }
58
+ function visible(doc: Page | Post, kind: 'page' | 'post') {
59
+ return doc.status === 'published' && (kind === 'page' || Object.hasOwn(CONTENT.collections, (doc as Post).collection));
60
+ }
61
+ export async function listContent(repo: ContentRepository, url: URL) {
62
+ const q = contentQuery.parse(queryParams(url));
63
+ if (q.collection && !Object.hasOwn(CONTENT.collections, q.collection)) throw new ApiError(400, 'Unknown collection.');
64
+ if (q.collection && q.kind === 'page') throw new ApiError(400, 'Pages do not belong to post collections.');
65
+ if (q.locale && !I18N.locales.includes(q.locale)) throw new ApiError(400, 'Unknown locale.');
66
+ const origin = siteOrigin(await repo.getSettings(), url);
67
+ const rows = await repo.listPublicContent({ ...q, collections: Object.keys(CONTENT.collections), defaultLocale: I18N.defaultLocale });
68
+ const items = await Promise.all(rows.slice(0, q.limit).map(async (r) => {
69
+ const doc = r.kind === 'post' ? await repo.getPost(r.id) : await repo.getPage(r.id);
70
+ return doc && visible(doc, r.kind) ? dto(doc, r.kind, origin, false) : null;
71
+ }));
72
+ return { items: items.filter((x) => x !== null), pagination: { page: q.page, limit: q.limit, nextPage: rows.length > q.limit ? q.page + 1 : null } };
73
+ }
74
+ export async function getContent(repo: ContentRepository, url: URL, id: string) {
75
+ const q = z.object({ kind: z.enum(['post', 'page']).optional() }).strict().parse(queryParams(url));
76
+ // IDs are UUIDs, but restored databases may contain arbitrary legacy IDs.
77
+ if (!id || id.length > 200) throw new ApiError(404, 'Content not found.');
78
+ const post = q.kind === 'page' ? null : await repo.getPost(id);
79
+ const page = q.kind === 'post' ? null : await repo.getPage(id);
80
+ if (!q.kind && post && page) throw new ApiError(400, 'Specify kind for this content ID.');
81
+ const kind = post ? 'post' : 'page';
82
+ const doc = post ?? page;
83
+ if (!doc || !visible(doc, kind)) throw new ApiError(404, 'Content not found.');
84
+ return dto(doc, kind, siteOrigin(await repo.getSettings(), url), true);
85
+ }
@@ -0,0 +1,63 @@
1
+ import { z } from 'zod';
2
+ import { ApiError } from '../api';
3
+ import { BookingError } from '../booking/service';
4
+ import { sha256Hex } from '../api-keys';
5
+ import type { ContentRepository } from '../repo/types';
6
+
7
+ const headers = {
8
+ 'Access-Control-Allow-Origin': '*',
9
+ 'Access-Control-Allow-Methods': 'GET, HEAD, POST, OPTIONS',
10
+ 'Access-Control-Allow-Headers': 'Content-Type, Idempotency-Key, If-None-Match',
11
+ 'Access-Control-Expose-Headers': 'ETag, Retry-After, Idempotency-Replayed',
12
+ 'X-Content-Type-Options': 'nosniff',
13
+ 'Cache-Control': 'no-store',
14
+ };
15
+ export const preflight = () => new Response(null, { status: 204, headers });
16
+ export const response = (data: unknown, status = 200, extra: HeadersInit = {}) => {
17
+ const h = new Headers(headers);
18
+ new Headers(extra).forEach((value, key) => h.set(key, value));
19
+ return Response.json({ version: 1, data }, { status, headers: h });
20
+ };
21
+ export async function publicResponse(request: Request, data: unknown) {
22
+ const res = response(data);
23
+ const etag = `"${await sha256Hex(await res.clone().text())}"`;
24
+ res.headers.set('ETag', etag);
25
+ // Revalidate even fresh-looking content: unpublishing must take effect.
26
+ res.headers.set('Cache-Control', 'public, max-age=0, must-revalidate');
27
+ const match = request.headers.get('if-none-match')?.split(',').some((s) => s.trim().replace(/^W\//, '') === etag || s.trim() === '*');
28
+ return match ? new Response(null, { status: 304, headers: res.headers }) : res;
29
+ }
30
+ export function failure(error: unknown) {
31
+ const status = error instanceof ApiError || error instanceof BookingError ? error.status : error instanceof z.ZodError ? 400 : 500;
32
+ const codes: Record<number, string> = { 400: 'invalid_request', 404: 'not_found', 405: 'method_not_allowed', 409: 'conflict', 410: 'gone', 413: 'payload_too_large', 415: 'unsupported_media_type', 429: 'rate_limited' };
33
+ return Response.json({ version: 1, error: {
34
+ code: codes[status] ?? 'internal_error',
35
+ message: status === 500 ? 'Request failed. Please try again.' : error instanceof z.ZodError ? 'Invalid request parameters.' : (error as Error).message,
36
+ ...(error instanceof z.ZodError ? { issues: error.issues.map(({ path, message }) => ({ path, message })) } : {}),
37
+ } }, { status, headers: { ...headers, ...(status === 429 ? { 'Retry-After': '60' } : {}) } });
38
+ }
39
+ export async function limitRequest(repo: ContentRepository, address: string, write = false) {
40
+ const key = `mobile:${write ? 'write' : 'read'}:${await sha256Hex(address)}`;
41
+ if (!await repo.email.rateLimit(key, write ? 20 : 300, 60_000)) throw new ApiError(429, 'Too many requests. Try again in a minute.');
42
+ }
43
+ export function queryParams(url: URL) {
44
+ const result: Record<string, string> = {};
45
+ for (const [key, value] of url.searchParams) {
46
+ if (key in result) throw new ApiError(400, `Duplicate query parameter: ${key}.`);
47
+ result[key] = value;
48
+ }
49
+ return result;
50
+ }
51
+ export const pageQuery = {
52
+ page: z.coerce.number().int().min(1).max(10000).default(1),
53
+ limit: z.coerce.number().int().min(1).max(50).default(20),
54
+ };
55
+ export const isoDay = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine((s) => {
56
+ const n = Date.parse(s); return Number.isFinite(n) && new Date(n).toISOString().slice(0, 10) === s;
57
+ }, 'Use a valid calendar date.');
58
+
59
+ export function methodNotAllowed(allow = 'GET, HEAD, OPTIONS') {
60
+ const res = failure(new ApiError(405, 'Method not allowed.'));
61
+ res.headers.set('Allow', allow);
62
+ return res;
63
+ }
@@ -0,0 +1,20 @@
1
+ import type { APIRoute } from 'astro';
2
+ import type { ContentRepository } from '../repo/types';
3
+ import { getRepo } from '../repo';
4
+ import { limitRequest, failure, publicResponse, response } from './http';
5
+ export function readRoute(load: (repo: ContentRepository, url: URL, id: string) => Promise<unknown>, cache = true): APIRoute {
6
+ return async context => {
7
+ try {
8
+ const repo = await getRepo(context.locals);
9
+ let address = 'unknown';
10
+ try { address = context.clientAddress || address; } catch { /* adapters without a client address share a bucket */ }
11
+ await limitRequest(repo, address);
12
+ const data = await load(repo, context.url, context.params.id ?? '');
13
+ const res = cache ? await publicResponse(context.request, data) : response(data);
14
+ return context.request.method === 'HEAD' ? new Response(null, { status: res.status, headers: res.headers }) : res;
15
+ } catch (error) {
16
+ const res = failure(error);
17
+ return context.request.method === 'HEAD' ? new Response(null, { status: res.status, headers: res.headers }) : res;
18
+ }
19
+ };
20
+ }
package/src/lib/ops.ts ADDED
@@ -0,0 +1,8 @@
1
+ import { timingSafeEqual } from 'node:crypto';
2
+ import { runtimeValue } from './email/config';
3
+ export function authorizedRunner(request: Request, locals?: unknown): boolean {
4
+ const expected = runtimeValue('EMAIL_RUNNER_SECRET', locals);
5
+ const supplied = request.headers.get('authorization')?.match(/^Bearer (.+)$/)?.[1] ?? '';
6
+ const a = Buffer.from(expected), b = Buffer.from(supplied);
7
+ return expected.length >= 32 && a.length === b.length && timingSafeEqual(a, b);
8
+ }
@@ -12,7 +12,7 @@ function sig(payload: string): string {
12
12
  return createHmac('sha256', secret).update(`preview.${payload}`).digest('hex');
13
13
  }
14
14
 
15
- export function createPreviewToken(type: 'post' | 'page', id: string, ttlMs = WEEK_MS): string {
15
+ export function createPreviewToken(type: 'post' | 'page' | 'event', id: string, ttlMs = WEEK_MS): string {
16
16
  const exp = Date.now() + ttlMs;
17
17
  return `${exp}.${sig(`${type}.${id}.${exp}`)}`;
18
18
  }
@@ -26,3 +26,23 @@ export function verifyPreviewToken(type: string, id: string, token: string | und
26
26
  if (s.length !== expected.length) return false;
27
27
  return timingSafeEqual(Buffer.from(s), Buffer.from(expected));
28
28
  }
29
+
30
+ // Design share links: ?preview=<token> opens preview mode for anyone holding
31
+ // it. Token = <epoch>.<exp>.<sig>; `epoch` is the draft's shareEpoch, so
32
+ // "Revoke all" (a fresh UUID) kills every earlier link, and publishing or
33
+ // discarding the draft ends them all (no draft left to show).
34
+ export function createDesignToken(epoch: string | number, ttlMs = WEEK_MS): { token: string; expiresAt: number } {
35
+ const exp = Date.now() + ttlMs;
36
+ return { token: `${epoch}.${exp}.${sig(`design.${epoch}.${exp}`)}`, expiresAt: exp };
37
+ }
38
+
39
+ // the token's expiry when valid for `epoch`, else null
40
+ export function verifyDesignToken(token: string | undefined, epoch: string | number): number | null {
41
+ if (!token) return null;
42
+ const [epochStr, expStr, s] = token.split('.');
43
+ if (!epochStr || !expStr || !s) return null;
44
+ if (epochStr !== String(epoch) || !Number.isFinite(Number(expStr)) || Number(expStr) < Date.now() || epoch === 'legacy-disabled') return null;
45
+ const expected = sig(`design.${epochStr}.${expStr}`);
46
+ if (s.length !== expected.length) return null;
47
+ return timingSafeEqual(Buffer.from(s), Buffer.from(expected)) ? Number(expStr) : null;
48
+ }
@@ -1,3 +1,6 @@
1
+ import { LIVE_MIGRATION } from '../live/migration';
2
+ import { EMAIL_MIGRATION } from '../email/migration';
3
+ import { WEBHOOK_MIGRATION } from '../webhook-sql';
1
4
  // Versioned schema migrations shared by the two SQLite-dialect adapters
2
5
  // (D1 and libSQL). Applied versions are recorded in the `migrations` table;
3
6
  // each adapter's runner applies whatever is newer, in order, at startup.
@@ -278,6 +281,224 @@ export const MIGRATIONS: Migration[] = [
278
281
  version: 25,
279
282
  statements: [`ALTER TABLE settings ADD COLUMN analytics TEXT DEFAULT '{}'`],
280
283
  },
284
+ {
285
+ version: 26,
286
+ statements: [
287
+ `ALTER TABLE settings ADD COLUMN ai TEXT NOT NULL DEFAULT '{}'`,
288
+ `ALTER TABLE settings ADD COLUMN agent TEXT NOT NULL DEFAULT '{}'`,
289
+ ],
290
+ },
291
+ {
292
+ // Calendar module: calendars (native + live ICS feeds), events with
293
+ // wall-clock times + recurrence, and the integration plumbing shared by
294
+ // events and (later) bookings: scoped API keys and signed webhooks with
295
+ // a retry queue. calendar_ids / tags / exdates / location / cta / fields
296
+ // are JSON columns, same as posts.tags. Event search rows reuse the
297
+ // search_index FTS table with kind = 'event'.
298
+ version: 27,
299
+ statements: [
300
+ `CREATE TABLE IF NOT EXISTS calendars (
301
+ id TEXT PRIMARY KEY,
302
+ slug TEXT UNIQUE NOT NULL,
303
+ name TEXT NOT NULL,
304
+ color TEXT NOT NULL DEFAULT '#4f46e5',
305
+ description TEXT NOT NULL DEFAULT '',
306
+ kind TEXT NOT NULL DEFAULT 'native',
307
+ feed_url TEXT NOT NULL DEFAULT '',
308
+ visibility TEXT NOT NULL DEFAULT 'public',
309
+ feed_token TEXT NOT NULL DEFAULT '',
310
+ ord INTEGER NOT NULL DEFAULT 0,
311
+ created_at INTEGER NOT NULL,
312
+ updated_at INTEGER NOT NULL
313
+ )`,
314
+ `CREATE TABLE IF NOT EXISTS events (
315
+ id TEXT PRIMARY KEY,
316
+ slug TEXT UNIQUE NOT NULL,
317
+ title TEXT NOT NULL,
318
+ body_json TEXT NOT NULL DEFAULT '',
319
+ body_html TEXT NOT NULL DEFAULT '',
320
+ excerpt TEXT NOT NULL DEFAULT '',
321
+ cover TEXT NOT NULL DEFAULT '',
322
+ cover_alt TEXT NOT NULL DEFAULT '',
323
+ seo_title TEXT NOT NULL DEFAULT '',
324
+ seo_desc TEXT NOT NULL DEFAULT '',
325
+ status TEXT NOT NULL DEFAULT 'draft',
326
+ featured INTEGER NOT NULL DEFAULT 0,
327
+ tags TEXT NOT NULL DEFAULT '[]',
328
+ template TEXT NOT NULL DEFAULT '',
329
+ starts TEXT NOT NULL,
330
+ ends TEXT NOT NULL DEFAULT '',
331
+ all_day INTEGER NOT NULL DEFAULT 0,
332
+ timezone TEXT NOT NULL DEFAULT '',
333
+ rrule TEXT NOT NULL DEFAULT '',
334
+ exdates TEXT NOT NULL DEFAULT '[]',
335
+ cancelled INTEGER NOT NULL DEFAULT 0,
336
+ location TEXT NOT NULL DEFAULT '{}',
337
+ cta TEXT NOT NULL DEFAULT '{}',
338
+ calendar_ids TEXT NOT NULL DEFAULT '[]',
339
+ fields TEXT NOT NULL DEFAULT '{}',
340
+ source_uid TEXT NOT NULL DEFAULT '',
341
+ start_at INTEGER NOT NULL,
342
+ end_at INTEGER NOT NULL,
343
+ draft TEXT,
344
+ published_at INTEGER,
345
+ scheduled_at INTEGER,
346
+ created_at INTEGER NOT NULL,
347
+ updated_at INTEGER NOT NULL
348
+ )`,
349
+ `CREATE INDEX IF NOT EXISTS events_start_at ON events (start_at)`,
350
+ `CREATE INDEX IF NOT EXISTS events_status ON events (status)`,
351
+ `CREATE TABLE IF NOT EXISTS api_keys (
352
+ id TEXT PRIMARY KEY,
353
+ name TEXT NOT NULL,
354
+ prefix TEXT NOT NULL,
355
+ key_hash TEXT UNIQUE NOT NULL,
356
+ scopes TEXT NOT NULL DEFAULT '[]',
357
+ last_used_at INTEGER,
358
+ created_at INTEGER NOT NULL
359
+ )`,
360
+ `CREATE TABLE IF NOT EXISTS webhooks (
361
+ id TEXT PRIMARY KEY,
362
+ url TEXT NOT NULL,
363
+ secret TEXT NOT NULL,
364
+ events TEXT NOT NULL DEFAULT '[]',
365
+ active INTEGER NOT NULL DEFAULT 1,
366
+ last_status INTEGER,
367
+ last_at INTEGER,
368
+ created_at INTEGER NOT NULL
369
+ )`,
370
+ `CREATE TABLE IF NOT EXISTS webhook_deliveries (
371
+ id TEXT PRIMARY KEY,
372
+ webhook_id TEXT NOT NULL,
373
+ event TEXT NOT NULL,
374
+ payload TEXT NOT NULL,
375
+ status TEXT NOT NULL DEFAULT 'pending',
376
+ attempts INTEGER NOT NULL DEFAULT 0,
377
+ next_at INTEGER,
378
+ response_code INTEGER,
379
+ created_at INTEGER NOT NULL,
380
+ delivered_at INTEGER
381
+ )`,
382
+ `CREATE INDEX IF NOT EXISTS webhook_deliveries_due ON webhook_deliveries (status, next_at)`,
383
+ ],
384
+ },
385
+ {
386
+ // Bookings: resources (rooms, people, vans…), their booking links, and the
387
+ // bookings themselves (also staff time blocks and event RSVPs). Weekly
388
+ // hours / overrides / questions are JSON. The (resource_id, start_at)
389
+ // index serves the overlap check every booking insert runs; hold_start /
390
+ // hold_end are the booked time widened by the link's buffers, so the
391
+ // overlap test is a single range comparison.
392
+ version: 28,
393
+ statements: [
394
+ `CREATE TABLE IF NOT EXISTS resources (
395
+ id TEXT PRIMARY KEY,
396
+ slug TEXT UNIQUE NOT NULL,
397
+ name TEXT NOT NULL,
398
+ kind TEXT NOT NULL DEFAULT 'Room',
399
+ description TEXT NOT NULL DEFAULT '',
400
+ photo TEXT NOT NULL DEFAULT '',
401
+ capacity INTEGER,
402
+ amenities TEXT NOT NULL DEFAULT '[]',
403
+ timezone TEXT NOT NULL DEFAULT '',
404
+ hours TEXT NOT NULL DEFAULT '{}',
405
+ overrides TEXT NOT NULL DEFAULT '[]',
406
+ busy_feeds TEXT NOT NULL DEFAULT '[]',
407
+ notify_emails TEXT NOT NULL DEFAULT '[]',
408
+ visibility TEXT NOT NULL DEFAULT 'public',
409
+ show_on_calendar INTEGER NOT NULL DEFAULT 0,
410
+ feed_token TEXT NOT NULL DEFAULT '',
411
+ ord INTEGER NOT NULL DEFAULT 0,
412
+ created_at INTEGER NOT NULL,
413
+ updated_at INTEGER NOT NULL
414
+ )`,
415
+ `CREATE TABLE IF NOT EXISTS booking_links (
416
+ id TEXT PRIMARY KEY,
417
+ resource_id TEXT NOT NULL,
418
+ slug TEXT NOT NULL,
419
+ name TEXT NOT NULL,
420
+ description TEXT NOT NULL DEFAULT '',
421
+ mode TEXT NOT NULL DEFAULT 'slots',
422
+ duration INTEGER NOT NULL DEFAULT 60,
423
+ lengths TEXT NOT NULL DEFAULT '[]',
424
+ min_days INTEGER NOT NULL DEFAULT 1,
425
+ max_days INTEGER NOT NULL DEFAULT 1,
426
+ step INTEGER NOT NULL DEFAULT 30,
427
+ buffer_before INTEGER NOT NULL DEFAULT 0,
428
+ buffer_after INTEGER NOT NULL DEFAULT 0,
429
+ min_notice INTEGER NOT NULL DEFAULT 120,
430
+ max_advance INTEGER NOT NULL DEFAULT 90,
431
+ per_day_cap INTEGER NOT NULL DEFAULT 0,
432
+ hours TEXT,
433
+ auto_confirm INTEGER NOT NULL DEFAULT 1,
434
+ visibility TEXT NOT NULL DEFAULT 'public',
435
+ ask_phone INTEGER NOT NULL DEFAULT 0,
436
+ questions TEXT NOT NULL DEFAULT '[]',
437
+ confirm_message TEXT NOT NULL DEFAULT '',
438
+ redirect_url TEXT NOT NULL DEFAULT '',
439
+ active INTEGER NOT NULL DEFAULT 1,
440
+ ord INTEGER NOT NULL DEFAULT 0,
441
+ created_at INTEGER NOT NULL,
442
+ updated_at INTEGER NOT NULL,
443
+ UNIQUE (resource_id, slug)
444
+ )`,
445
+ `CREATE TABLE IF NOT EXISTS bookings (
446
+ id TEXT PRIMARY KEY,
447
+ kind TEXT NOT NULL DEFAULT 'booking',
448
+ resource_id TEXT NOT NULL DEFAULT '',
449
+ link_id TEXT NOT NULL DEFAULT '',
450
+ event_id TEXT NOT NULL DEFAULT '',
451
+ status TEXT NOT NULL,
452
+ start_at INTEGER NOT NULL,
453
+ end_at INTEGER NOT NULL,
454
+ hold_start INTEGER NOT NULL,
455
+ hold_end INTEGER NOT NULL,
456
+ timezone TEXT NOT NULL DEFAULT '',
457
+ name TEXT NOT NULL DEFAULT '',
458
+ email TEXT NOT NULL DEFAULT '',
459
+ phone TEXT NOT NULL DEFAULT '',
460
+ seats INTEGER NOT NULL DEFAULT 1,
461
+ answers TEXT NOT NULL DEFAULT '{}',
462
+ notes TEXT NOT NULL DEFAULT '',
463
+ manage_token TEXT NOT NULL DEFAULT '',
464
+ source TEXT NOT NULL DEFAULT 'web',
465
+ reminder_sent_at INTEGER,
466
+ created_at INTEGER NOT NULL,
467
+ updated_at INTEGER NOT NULL
468
+ )`,
469
+ `CREATE INDEX IF NOT EXISTS bookings_resource_start ON bookings (resource_id, start_at)`,
470
+ `CREATE INDEX IF NOT EXISTS bookings_event_start ON bookings (event_id, start_at)`,
471
+ `CREATE INDEX IF NOT EXISTS bookings_manage ON bookings (manage_token)`,
472
+ `ALTER TABLE events ADD COLUMN rsvp TEXT NOT NULL DEFAULT '{}'`,
473
+ ],
474
+ },
475
+ EMAIL_MIGRATION,
476
+ { version: 30, statements: [
477
+ `CREATE TABLE mobile_booking_requests (key_hash TEXT PRIMARY KEY, request_hash TEXT NOT NULL, booking_id TEXT NOT NULL, created_at INTEGER NOT NULL)`,
478
+ ] },
479
+ // design history: every Publish design snapshots the outgoing live design
480
+ // (theme, overrides, brand images, navigation) so it can be restored
481
+ { version: 31, statements: [
482
+ `CREATE TABLE IF NOT EXISTS design_versions (
483
+ id TEXT PRIMARY KEY,
484
+ created_at INTEGER NOT NULL,
485
+ author TEXT NOT NULL DEFAULT '',
486
+ summary TEXT NOT NULL DEFAULT '',
487
+ snapshot TEXT NOT NULL
488
+ )`,
489
+ `CREATE INDEX IF NOT EXISTS design_versions_created ON design_versions (created_at)`,
490
+ ] },
491
+ { version: 32, statements: [
492
+ `ALTER TABLE settings ADD COLUMN section_pages TEXT NOT NULL DEFAULT '{}'`,
493
+ `ALTER TABLE settings ADD COLUMN design_revision INTEGER NOT NULL DEFAULT 0`,
494
+ `CREATE TABLE IF NOT EXISTS design_audit (
495
+ id TEXT PRIMARY KEY, created_at INTEGER NOT NULL, actor TEXT NOT NULL,
496
+ action TEXT NOT NULL, expected_revision INTEGER NOT NULL,
497
+ actual_revision INTEGER NOT NULL CHECK (expected_revision = actual_revision)
498
+ )`,
499
+ ] },
500
+ WEBHOOK_MIGRATION,
501
+ LIVE_MIGRATION,
281
502
  ];
282
503
 
283
504
  export const LATEST_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;