@acms-subir/core 0.3.3 → 0.3.5

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 (69) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/cli/preview.mjs +8 -4
  3. package/cli/sections.mjs +4 -2
  4. package/docs/mobile.md +1 -1
  5. package/docs/theme-authoring.md +19 -3
  6. package/integration.mjs +21 -4
  7. package/package.json +14 -12
  8. package/src/actions/index.ts +186 -3
  9. package/src/components/booking/BookingFlow.astro +6 -3
  10. package/src/components/calendar/BookingsHead.astro +51 -0
  11. package/src/components/calendar/CalendarBoard.tsx +618 -0
  12. package/src/components/calendar/MiniMonth.tsx +54 -0
  13. package/src/components/calendar/NewEventDialog.tsx +44 -0
  14. package/src/components/calendar/QuickAdd.tsx +263 -0
  15. package/src/components/calendar/RoomHolds.tsx +203 -0
  16. package/src/components/calendar/WhereItLands.tsx +51 -0
  17. package/src/components/editor/EventPanel.tsx +23 -2
  18. package/src/components/editor/PostEditor.tsx +32 -4
  19. package/src/components/import/ImportExport.tsx +4 -4
  20. package/src/components/import/runner.ts +20 -3
  21. package/src/layouts/Admin.astro +5 -6
  22. package/src/layouts/Site.astro +29 -2
  23. package/src/lib/backup/content.ts +6 -1
  24. package/src/lib/booking/agent.ts +3 -2
  25. package/src/lib/booking/approval.ts +70 -0
  26. package/src/lib/booking/holds.ts +120 -0
  27. package/src/lib/booking/service.ts +37 -3
  28. package/src/lib/calendar/agent-schema.ts +9 -1
  29. package/src/lib/calendar/agent.ts +16 -1
  30. package/src/lib/calendar/board.ts +171 -0
  31. package/src/lib/calendar/config.ts +3 -0
  32. package/src/lib/calendar/grid.ts +169 -0
  33. package/src/lib/calendar/migration.ts +16 -0
  34. package/src/lib/calendar/render.ts +9 -7
  35. package/src/lib/calendar/schedule.ts +166 -0
  36. package/src/lib/content-service.ts +5 -0
  37. package/src/lib/drafts.ts +1 -1
  38. package/src/lib/import/bundle.ts +2 -1
  39. package/src/lib/mobile/calendar.ts +10 -6
  40. package/src/lib/mobile/http.ts +6 -0
  41. package/src/lib/repo/migrations.ts +3 -0
  42. package/src/lib/repo/sql.ts +28 -16
  43. package/src/lib/repo/types.ts +2 -2
  44. package/src/lib/scheduler.ts +3 -1
  45. package/src/lib/schema.ts +49 -0
  46. package/src/lib/sections.ts +33 -8
  47. package/src/lib/site-chrome.ts +34 -0
  48. package/src/lib/theme-assets.ts +16 -0
  49. package/src/lib/theme.ts +17 -3
  50. package/src/middleware.ts +6 -2
  51. package/src/pages/admin/calendar/bookings.astro +32 -26
  52. package/src/pages/admin/calendar/calendars.astro +4 -263
  53. package/src/pages/admin/calendar/events/index.astro +41 -83
  54. package/src/pages/admin/calendar/index.astro +43 -0
  55. package/src/pages/admin/calendar/resources/[id].astro +27 -5
  56. package/src/pages/admin/calendar/resources/index.astro +16 -17
  57. package/src/pages/admin/calendar/resources/link.astro +52 -12
  58. package/src/pages/admin/calendar/settings.astro +458 -0
  59. package/src/pages/admin/integrations.astro +5 -11
  60. package/src/pages/book/[resource].ics.ts +7 -1
  61. package/src/styles/admin.css +48 -0
  62. package/src/styles/cards.css +5 -1
  63. package/src/styles/editor.css +36 -0
  64. package/src/styles/schedule.css +325 -0
  65. package/src/styles/sections.css +19 -16
  66. package/src/theme-sdk/fields.mjs +5 -1
  67. package/src/theme-sdk/index.ts +5 -1
  68. package/src/virtual.d.ts +3 -0
  69. package/src/pages/admin/calendar/index.ts +0 -4
@@ -0,0 +1,166 @@
1
+ // Writes behind the admin Schedule: quick add, drag to move (a whole series
2
+ // or one occurrence), undo, duplicate. Everything goes through the same
3
+ // save/apply path as the event editor, so hooks, feeds, search and webhooks
4
+ // see the same changes. A move applies at once, even on a published event —
5
+ // the editor's "stage until Update" flow is for content edits, not dates.
6
+ import type { ContentRepository } from '../repo/types';
7
+ import type { CalEvent } from '../schema';
8
+ import { slugify } from '../schema';
9
+ import { ApiError } from '../api';
10
+ import { applyEventUpdate, saveEvent, type SaveHooks } from '../content-service';
11
+ import { addWallDays, formatWall, parseWall } from './events';
12
+ import { rruleMap, WEEKDAY_CODES, weekdayOf } from './rrule';
13
+ import { eventDeleted } from './hooks';
14
+ import { waitUntilFrom } from '../webhooks';
15
+
16
+ async function uniqueSlug(repo: ContentRepository, title: string) {
17
+ const base = slugify(title) || 'event';
18
+ let slug = base;
19
+ for (let n = 2; await repo.getEventBySlug(slug); n++) slug = `${base}-${n}`;
20
+ return slug;
21
+ }
22
+
23
+ const live = (s: CalEvent['status']) => s === 'published' || s === 'scheduled';
24
+
25
+ // Save on a live event without staging: stage, then apply just this change.
26
+ // Refuses when the event already has unrelated unpublished edits, so a drag
27
+ // never publishes someone's half-finished copy.
28
+ async function saveNow(repo: ContentRepository, e: CalEvent, data: Parameters<typeof saveEvent>[2], hooks: SaveHooks) {
29
+ if (live(e.status) && e.draft) {
30
+ throw new ApiError(409, 'This event has unpublished changes. Open it and update or discard them first.');
31
+ }
32
+ const saved = await saveEvent(repo, e.id, data, hooks);
33
+ return live(e.status) ? applyEventUpdate(repo, e.id, hooks) : saved;
34
+ }
35
+
36
+ export interface QuickEvent {
37
+ title: string;
38
+ start: string; // YYYY-MM-DD or YYYY-MM-DDTHH:mm
39
+ end: string;
40
+ allDay: boolean;
41
+ calendarId: string;
42
+ rrule: string;
43
+ location: string;
44
+ publish: boolean;
45
+ // bookable rooms/people this event uses (held while it happens)
46
+ roomIds?: string[];
47
+ }
48
+
49
+ export async function quickCreate(repo: ContentRepository, q: QuickEvent, hooks: SaveHooks): Promise<CalEvent> {
50
+ const title = q.title.trim().slice(0, 300) || 'Untitled event';
51
+ const cal = q.calendarId ? await repo.getCalendar(q.calendarId) : null;
52
+ const created = await repo.createEvent({
53
+ title, slug: await uniqueSlug(repo, title), start: q.start, allDay: q.allDay, status: 'draft',
54
+ calendarIds: cal?.kind === 'native' ? [cal.id] : [],
55
+ location: { name: q.location.trim().slice(0, 300), address: '', url: '' },
56
+ });
57
+ if (q.roomIds?.length) await saveEvent(repo, created.id, { roomHold: { resourceIds: q.roomIds, span: 'event', before: 0, after: 0, whileDraft: false, keep: [] } }, hooks);
58
+ // times + repeat go through the editor's validation (end defaults, pairs)
59
+ await saveEvent(repo, created.id, { start: q.start, end: q.end || undefined, allDay: q.allDay, rrule: q.rrule }, hooks);
60
+ return q.publish ? saveEvent(repo, created.id, { status: 'published' }, hooks) : (await repo.getEvent(created.id))!;
61
+ }
62
+
63
+ // Shift a recurrence rule by whole days so "weekly on Wednesday" moved two
64
+ // days later becomes "weekly on Friday" (and nth-weekday / day-of-month
65
+ // rules follow the new first date). UNTIL moves with the series.
66
+ export function shiftRrule(rrule: string, days: number, newStart: string): string {
67
+ if (!rrule || !days) return rrule;
68
+ const p = rruleMap(rrule);
69
+ if (p.FREQ === 'WEEKLY' && p.BYDAY) {
70
+ p.BYDAY = p.BYDAY.split(',').map((c) => {
71
+ const i = (WEEKDAY_CODES as readonly string[]).indexOf(c);
72
+ return i < 0 ? c : WEEKDAY_CODES[(((i + days) % 7) + 7) % 7];
73
+ }).join(',');
74
+ }
75
+ if (p.FREQ === 'MONTHLY') {
76
+ const wd = weekdayOf(newStart);
77
+ if (p.BYDAY && /^-?\d(SU|MO|TU|WE|TH|FR|SA)$/.test(p.BYDAY)) p.BYDAY = `${wd.nth >= 5 || p.BYDAY.startsWith('-') ? -1 : wd.nth}${WEEKDAY_CODES[wd.dow]}`;
78
+ else if (p.BYMONTHDAY && !p.BYMONTHDAY.includes(',')) p.BYMONTHDAY = String(wd.day);
79
+ }
80
+ if (p.UNTIL && /^\d{8}$/.test(p.UNTIL)) {
81
+ const u = parseWall(`${p.UNTIL.slice(0, 4)}-${p.UNTIL.slice(4, 6)}-${p.UNTIL.slice(6, 8)}`);
82
+ if (u) p.UNTIL = formatWall(addWallDays(u, days)).replace(/-/g, '');
83
+ }
84
+ const order = ['FREQ', 'INTERVAL', 'BYDAY', 'BYMONTHDAY', 'BYMONTH', 'COUNT', 'UNTIL', 'WKST'];
85
+ return Object.entries(p).sort(([a], [b]) => order.indexOf(a) - order.indexOf(b)).map(([k, v]) => `${k}=${v}`).join(';');
86
+ }
87
+
88
+ const shift = (wall: string, days: number) => {
89
+ const w = parseWall(wall);
90
+ return w ? formatWall(addWallDays(w, days)) : wall;
91
+ };
92
+
93
+ export type MoveUndo =
94
+ | { kind: 'series'; id: string; days: number }
95
+ | { kind: 'split'; id: string; seriesId: string; occurrence: string };
96
+
97
+ // Move a whole event (and every repeat) by `days`
98
+ export async function moveSeries(repo: ContentRepository, id: string, days: number, hooks: SaveHooks): Promise<{ event: CalEvent; undo: MoveUndo }> {
99
+ const e = await repo.getEvent(id);
100
+ if (!e) throw new ApiError(404, 'Event not found.');
101
+ if (!Number.isInteger(days) || Math.abs(days) > 3660) throw new ApiError(400, 'Pick a day to move it to.');
102
+ const start = shift(e.start, days);
103
+ const event = await saveNow(repo, e, {
104
+ start, end: shift(e.end, days), allDay: e.allDay,
105
+ ...(e.rrule ? { rrule: shiftRrule(e.rrule, days, start), exdates: e.exdates.map((x) => shift(x, days)) } : {}),
106
+ }, hooks);
107
+ return { event, undo: { kind: 'series', id, days: -days } };
108
+ }
109
+
110
+ // Move one occurrence of a repeating event: skip that date in the series and
111
+ // add a one-off copy on the new date. No schema change, and the copy is a
112
+ // normal event the editor can open.
113
+ export async function moveOccurrence(repo: ContentRepository, id: string, occurrence: string, days: number, hooks: SaveHooks): Promise<{ event: CalEvent; undo: MoveUndo }> {
114
+ const e = await repo.getEvent(id);
115
+ if (!e) throw new ApiError(404, 'Event not found.');
116
+ if (!e.rrule) return moveSeries(repo, id, days, hooks);
117
+ const occ = parseWall(occurrence);
118
+ const first = parseWall(e.start);
119
+ if (!occ || !first) throw new ApiError(400, 'That date isn’t part of this event.');
120
+ if (e.exdates.includes(occurrence)) throw new ApiError(409, 'That date was already removed from the series.');
121
+ // keep the occurrence's own length (first occurrence's start → end)
122
+ const toMs = (w: { y: number; m: number; d: number; hh: number; mm: number }) => Date.UTC(w.y, w.m - 1, w.d, w.hh, w.mm);
123
+ const lenMs = toMs(parseWall(e.end) ?? first) - toMs(first);
124
+ const newStart = addWallDays(occ, days);
125
+ const t = new Date(toMs(newStart) + lenMs);
126
+ const pad = (n: number) => String(n).padStart(2, '0');
127
+ const endDay = `${t.getUTCFullYear()}-${pad(t.getUTCMonth() + 1)}-${pad(t.getUTCDate())}`;
128
+ const endStr = e.allDay ? endDay : `${endDay}T${pad(t.getUTCHours())}:${pad(t.getUTCMinutes())}`;
129
+
130
+ await saveNow(repo, e, { exdates: [...e.exdates, occurrence] }, hooks);
131
+ const copy = await repo.createEvent({
132
+ title: e.title, slug: await uniqueSlug(repo, e.title), start: formatWall(newStart), end: endStr, allDay: e.allDay,
133
+ timezone: e.timezone, status: 'draft', excerpt: e.excerpt, bodyJson: e.bodyJson, bodyHtml: e.bodyHtml, cover: e.cover, coverAlt: e.coverAlt,
134
+ location: e.location, cta: e.cta, calendarIds: e.calendarIds, fields: e.fields, tags: e.tags, cancelled: e.cancelled, rsvp: e.rsvp,
135
+ template: e.template, featured: false,
136
+ });
137
+ const event = live(e.status) ? await saveEvent(repo, copy.id, { status: 'published' }, hooks) : copy;
138
+ return { event, undo: { kind: 'split', id: copy.id, seriesId: e.id, occurrence } };
139
+ }
140
+
141
+ export async function undoMove(repo: ContentRepository, undo: MoveUndo, hooks: SaveHooks): Promise<void> {
142
+ if (undo.kind === 'series') {
143
+ await moveSeries(repo, undo.id, undo.days, hooks);
144
+ return;
145
+ }
146
+ const series = await repo.getEvent(undo.seriesId);
147
+ const copy = await repo.getEvent(undo.id);
148
+ if (copy) {
149
+ await repo.deleteEvent(copy.id);
150
+ await eventDeleted(repo, copy, { waitUntil: waitUntilFrom(hooks.locals) });
151
+ }
152
+ if (series) await saveNow(repo, series, { exdates: series.exdates.filter((x) => x !== undo.occurrence) }, hooks);
153
+ }
154
+
155
+ export async function duplicateEvent(repo: ContentRepository, id: string): Promise<CalEvent> {
156
+ const e = await repo.getEvent(id);
157
+ if (!e) throw new ApiError(404, 'Event not found.');
158
+ const src = e.draft ? { ...e, ...(e.draft as Partial<CalEvent>) } : e;
159
+ const title = `${src.title} (copy)`;
160
+ return repo.createEvent({
161
+ title, slug: await uniqueSlug(repo, title), start: src.start, end: src.end, allDay: src.allDay, timezone: src.timezone,
162
+ rrule: src.rrule, exdates: src.exdates, status: 'draft', excerpt: src.excerpt, bodyJson: src.bodyJson, bodyHtml: src.bodyHtml,
163
+ cover: src.cover, coverAlt: src.coverAlt, location: src.location, cta: src.cta, calendarIds: src.calendarIds, fields: src.fields,
164
+ tags: src.tags, rsvp: src.rsvp, template: src.template,
165
+ });
166
+ }
@@ -132,6 +132,11 @@ export async function cleanEventInput(repo: ContentRepository, data: EventSave,
132
132
  throw new ApiError(400, err instanceof Error ? err.message : 'Invalid time.');
133
133
  }
134
134
  }
135
+ if (out.roomHold !== undefined) {
136
+ // only rooms/people that exist; order kept, duplicates dropped
137
+ const known = new Set((await repo.listResources()).map((r) => r.id));
138
+ out.roomHold = { ...out.roomHold, resourceIds: [...new Set(out.roomHold.resourceIds.filter((id) => known.has(id)))] };
139
+ }
135
140
  if (out.calendarIds !== undefined) {
136
141
  const known = new Set((await repo.listCalendars()).filter((c) => c.kind === 'native').map((c) => c.id));
137
142
  out.calendarIds = [...new Set(out.calendarIds.filter((c) => known.has(c)))];
package/src/lib/drafts.ts CHANGED
@@ -25,7 +25,7 @@ export const PAGE_DRAFT_KEYS = [
25
25
  export const EVENT_DRAFT_KEYS = [
26
26
  'title', 'slug', 'bodyJson', 'bodyHtml', 'excerpt', 'tags', 'cover', 'coverAlt',
27
27
  'seoTitle', 'seoDesc', 'featured', 'template', 'start', 'end', 'allDay',
28
- 'timezone', 'rrule', 'exdates', 'cancelled', 'location', 'cta', 'calendarIds', 'fields', 'rsvp', 'translations',
28
+ 'timezone', 'rrule', 'exdates', 'cancelled', 'location', 'cta', 'calendarIds', 'fields', 'rsvp', 'translations', 'roomHold',
29
29
  ] as const;
30
30
 
31
31
  type PostDraftKey = (typeof POST_DRAFT_KEYS)[number];
@@ -146,7 +146,8 @@ export const assetPlaceholder = (ref: string) => `${ASSET_SCHEME}${ref}`;
146
146
  // Swap every acms-asset:<ref> for its re-hosted URL. <img> tags built by the
147
147
  // importers carry only src/alt/width/height, so images that gained responsive
148
148
  // variants get srcset/sizes added here. Unresolved refs (a failed download)
149
- // fall back to the original URL, which is reported separately.
149
+ // fall back to an absolute source URL, or an empty string if none is known.
150
+ // Copy failures are reported separately by the runner.
150
151
  export interface ResolvedAsset { url: string; srcset?: string; sizes?: string }
151
152
  const PLACEHOLDER_RE = /acms-asset:([a-z0-9-]{1,40})/g;
152
153
  export function resolveAssets(value: string, resolved: Map<string, ResolvedAsset>, fallback: Map<string, string>): string {
@@ -8,9 +8,10 @@ import { availabilityContext } from '../booking/service';
8
8
  import { addDaysYmd, describeLink, linkLengths, localMidnight, slotsForDay, ymd } from '../booking/availability';
9
9
  import { siteOrigin } from '../seo';
10
10
  import { safeUrl } from './content';
11
- import { pageQuery, isoDay, queryParams } from './http';
11
+ import { pageQuery, isoDay, isoDayOrInstant, queryParams } from './http';
12
12
  import { localizeDoc } from '../translations';
13
13
  import { localeQuery, mobileLocale, localizedUrl } from './locale';
14
+ import { confirmsInstantly, linkApproval } from '../booking/approval';
14
15
 
15
16
  export function requireCalendar(bookings = false) {
16
17
  if (!CALENDAR.enabled || (bookings && !bookingsEnabled())) throw new ApiError(404, 'This feature is disabled.');
@@ -21,7 +22,7 @@ export async function listCalendars(repo: ContentRepository, url: URL) {
21
22
  const origin = siteOrigin(await repo.getSettings(), url);
22
23
  return { calendars: (await repo.listCalendars()).filter(c => c.visibility === 'public').map(c => calendarToJson(c, origin)) };
23
24
  }
24
- export const eventsQuery = z.object({ ...pageQuery, locale: localeQuery, from: isoDay.optional(), to: isoDay.optional(), calendar: z.string().min(1).max(200).optional(), q: z.string().max(100).optional() }).strict();
25
+ export const eventsQuery = z.object({ ...pageQuery, locale: localeQuery, from: isoDayOrInstant.optional(), to: isoDayOrInstant.optional(), calendar: z.string().min(1).max(200).optional(), q: z.string().max(100).optional() }).strict();
25
26
  export async function listEvents(repo: ContentRepository, url: URL) {
26
27
  requireCalendar();
27
28
  const q = eventsQuery.parse(queryParams(url));
@@ -29,8 +30,11 @@ export async function listEvents(repo: ContentRepository, url: URL) {
29
30
  const [settings, calendars, events] = await Promise.all([repo.getSettings(), repo.listCalendars(), repo.listPublishedEvents()]);
30
31
  // days are the site's calendar days (a New York site's "today" starts at midnight there, not UTC)
31
32
  const tz = settings.timezone || 'UTC';
32
- const from = localMidnight(q.from ?? ymd(Date.now(), tz), tz);
33
- const to = q.to ? localMidnight(q.to, tz) : localMidnight(addDaysYmd(q.from ?? ymd(Date.now(), tz), 30), tz);
33
+ // a plain day is the site's midnight; a date-time is that exact instant
34
+ const at = (v: string) => (v.length === 10 ? localMidnight(v, tz) : Date.parse(v));
35
+ const fromDay = q.from ?? ymd(Date.now(), tz);
36
+ const from = at(fromDay);
37
+ const to = q.to ? at(q.to) : fromDay.length === 10 ? localMidnight(addDaysYmd(fromDay, 30), tz) : from + 30 * 86400000;
34
38
  if (to <= from || to - from > 90 * 86400000 + 3600000) throw new ApiError(400, 'Use an event window of 1 to 90 days.');
35
39
  const all = new Map(calendars.map(c => [c.id, c]));
36
40
  const cal = q.calendar ? calendars.find(c => c.slug === q.calendar && c.visibility !== 'private') : null;
@@ -67,7 +71,7 @@ export async function listResources(repo: ContentRepository, url: URL) {
67
71
  photo: safeUrl(r.photo, origin), timezone: r.timezone || settings.timezone || 'UTC', url: origin + resourceUrl(r.slug),
68
72
  links: links.filter(l => l.resourceId === r.id && l.active && l.visibility === 'public').map(l => ({
69
73
  id: l.id, slug: l.slug, name: l.name, description: l.description, mode: l.mode, lengths: linkLengths(l), rules: describeLink(l),
70
- askPhone: l.askPhone, autoConfirm: l.autoConfirm, url: origin + bookingLinkUrl(r.slug, l.slug),
74
+ askPhone: l.askPhone, autoConfirm: confirmsInstantly(linkApproval(l, settings)), url: origin + bookingLinkUrl(r.slug, l.slug),
71
75
  questions: l.questions.map(({ id, label, type, required, options }) => ({ id, label, type, required, options })),
72
76
  })),
73
77
  })), pagination: { page: q.page, limit: q.limit, nextPage: visible.length > start + q.limit ? q.page + 1 : null } };
@@ -85,7 +89,7 @@ export async function getAvailability(repo: ContentRepository, url: URL) {
85
89
  const ctx = await availabilityContext(repo, resource, link, settings);
86
90
  const timezone = resource.timezone || settings.timezone || 'UTC';
87
91
  const from = q.from ?? ymd(Date.now(), timezone);
88
- return { timezone, mode: link.mode, lengths: linkLengths(link), autoConfirm: link.autoConfirm,
92
+ return { timezone, mode: link.mode, lengths: linkLengths(link), autoConfirm: confirmsInstantly(linkApproval(link, settings)),
89
93
  days: Array.from({ length: q.days }, (_, i) => addDaysYmd(from, i)).map(date => ({ date,
90
94
  slots: slotsForDay(ctx, date, q.minutes).map(s => ({ start: new Date(s.start).toISOString(), end: new Date(s.end).toISOString(), minutes: s.minutes })),
91
95
  })),
@@ -57,6 +57,12 @@ export const pageQuery = {
57
57
  export const isoDay = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine((s) => {
58
58
  const n = Date.parse(s); return Number.isFinite(n) && new Date(n).toISOString().slice(0, 10) === s;
59
59
  }, 'Use a valid calendar date.');
60
+ // a site-local day ("2026-10-01" = midnight in the site timezone) or an exact
61
+ // ISO 8601 instant with a zone ("2026-10-01T18:00:00Z", "…T13:00:00-05:00")
62
+ export const isoDayOrInstant = z.string().max(40).refine((s) => {
63
+ if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return isoDay.safeParse(s).success;
64
+ return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d{1,3})?)?(Z|[+-]\d{2}:\d{2})$/.test(s) && Number.isFinite(Date.parse(s));
65
+ }, 'Use a date (YYYY-MM-DD) or an ISO date-time with a timezone (2026-10-01T18:00:00Z).');
60
66
 
61
67
  export function methodNotAllowed(allow = 'GET, HEAD, OPTIONS') {
62
68
  const res = failure(new ApiError(405, 'Method not allowed.'));
@@ -4,6 +4,7 @@ import { EMAIL_MIGRATION } from '../email/migration';
4
4
  import { WEBHOOK_MIGRATION } from '../webhook-sql';
5
5
  import { PUSH_MIGRATION } from '../push/migration';
6
6
  import { EDITOR_MIGRATION, EDITOR_GRACE_MIGRATION } from '../editor/migration';
7
+ import { CALENDAR_SETTINGS_MIGRATION, ROOM_HOLD_MIGRATION } from '../calendar/migration';
7
8
  // Versioned schema migrations shared by the two SQLite-dialect adapters
8
9
  // (D1 and libSQL). Applied versions are recorded in the `migrations` table;
9
10
  // each adapter's runner applies whatever is newer, in order, at startup.
@@ -512,6 +513,8 @@ export const MIGRATIONS: Migration[] = [
512
513
  PUSH_MIGRATION,
513
514
  EDITOR_MIGRATION,
514
515
  EDITOR_GRACE_MIGRATION,
516
+ CALENDAR_SETTINGS_MIGRATION,
517
+ ROOM_HOLD_MIGRATION,
515
518
  ];
516
519
 
517
520
  export const LATEST_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
@@ -144,7 +144,7 @@ function rowToEvent(r: Row): CalEvent {
144
144
  start: r.starts, end: r.ends ?? '', allDay: Boolean(r.all_day), timezone: r.timezone ?? '',
145
145
  rrule: r.rrule ?? '', exdates: json(r.exdates, []), cancelled: Boolean(r.cancelled),
146
146
  location: json(r.location, {}), cta: json(r.cta, {}), calendarIds: json(r.calendar_ids, []),
147
- fields: json(r.fields, {}), sourceUid: r.source_uid ?? '', rsvp: json(r.rsvp, {}),
147
+ fields: json(r.fields, {}), sourceUid: r.source_uid ?? '', rsvp: json(r.rsvp, {}), roomHold: json(r.room_hold, {}),
148
148
  startAt: Number(r.start_at), endAt: Number(r.end_at), draft: parseDraft(r.draft), translations: parseTranslations(r.translations),
149
149
  publishedAt: r.published_at === null || r.published_at === undefined ? null : Number(r.published_at),
150
150
  scheduledAt: r.scheduled_at === null || r.scheduled_at === undefined ? null : Number(r.scheduled_at),
@@ -161,6 +161,14 @@ function rowToResource(r: Row): Resource {
161
161
  createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
162
162
  });
163
163
  }
164
+ // Callers that still speak the old on/off flag (REST, agent tools, older
165
+ // code) set autoConfirm without an approval mode: map it so "off" really
166
+ // holds bookings. On create, "on" follows the site default (which starts as
167
+ // automatic); on update it pins the link to automatic.
168
+ function legacyApproval<T extends { autoConfirm?: boolean; approval?: BookingLink['approval'] }>(data: T, update = false): T {
169
+ if (data.approval !== undefined || data.autoConfirm === undefined) return data;
170
+ return { ...data, approval: data.autoConfirm ? (update ? 'auto' : 'default') : 'manual' };
171
+ }
164
172
  function rowToLink2(r: Row): BookingLink {
165
173
  return BookingLink.parse({
166
174
  id: r.id, resourceId: r.resource_id, slug: r.slug, name: r.name, description: r.description ?? '', mode: r.mode ?? 'slots',
@@ -168,6 +176,7 @@ function rowToLink2(r: Row): BookingLink {
168
176
  step: Number(r.step), bufferBefore: Number(r.buffer_before), bufferAfter: Number(r.buffer_after),
169
177
  minNotice: Number(r.min_notice), maxAdvance: Number(r.max_advance), perDayCap: Number(r.per_day_cap),
170
178
  hours: r.hours ? json(r.hours, null) : null, autoConfirm: Boolean(r.auto_confirm), visibility: r.visibility ?? 'public',
179
+ approval: r.approval ?? 'default', approvalRules: json(r.approval_rules, {}),
171
180
  askPhone: Boolean(r.ask_phone), questions: json(r.questions, []), confirmMessage: r.confirm_message ?? '',
172
181
  redirectUrl: r.redirect_url ?? '', active: Boolean(r.active), order: Number(r.ord ?? 0),
173
182
  createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
@@ -811,8 +820,8 @@ export class SqlRepository implements ContentRepository {
811
820
  await this.driver.run(
812
821
  `INSERT INTO events (id, slug, title, body_json, body_html, excerpt, cover, cover_alt, seo_title, seo_desc, status, featured, tags, template,
813
822
  starts, ends, all_day, timezone, rrule, exdates, cancelled, location, cta, calendar_ids, fields, source_uid, start_at, end_at,
814
- published_at, scheduled_at, created_at, updated_at, rsvp)
815
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
823
+ published_at, scheduled_at, created_at, updated_at, rsvp, room_hold)
824
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
816
825
  [
817
826
  id, data.slug, data.title, data.bodyJson ?? '', data.bodyHtml ?? '', data.excerpt ?? '', data.cover ?? '', data.coverAlt ?? '',
818
827
  data.seoTitle ?? '', data.seoDesc ?? '', data.status ?? 'draft', data.featured ? 1 : 0, JSON.stringify(data.tags ?? []), data.template ?? '',
@@ -820,7 +829,7 @@ export class SqlRepository implements ContentRepository {
820
829
  data.cancelled ? 1 : 0, JSON.stringify(data.location ?? {}), JSON.stringify(data.cta ?? {}), JSON.stringify(data.calendarIds ?? []),
821
830
  JSON.stringify(data.fields ?? {}), data.sourceUid ?? '', startAt, endAt,
822
831
  data.publishedAt ?? (data.status === 'published' ? now : null), data.scheduledAt ?? null, now, now,
823
- JSON.stringify(data.rsvp ?? {}),
832
+ JSON.stringify(data.rsvp ?? {}), JSON.stringify(data.roomHold ?? {}),
824
833
  ]
825
834
  );
826
835
  await this.syncEventIndex(id);
@@ -845,7 +854,7 @@ export class SqlRepository implements ContentRepository {
845
854
  if (v !== undefined) { sets.push(`${col} = ?`); args.push(v); }
846
855
  }
847
856
  const jsonCols: Record<string, string> = {
848
- tags: 'tags', exdates: 'exdates', location: 'location', cta: 'cta', calendarIds: 'calendar_ids', fields: 'fields', rsvp: 'rsvp', translations: 'translations',
857
+ tags: 'tags', exdates: 'exdates', location: 'location', cta: 'cta', calendarIds: 'calendar_ids', fields: 'fields', rsvp: 'rsvp', translations: 'translations', roomHold: 'room_hold',
849
858
  };
850
859
  for (const [k, col] of Object.entries(jsonCols)) {
851
860
  const v = (data as Record<string, unknown>)[k];
@@ -936,10 +945,11 @@ export class SqlRepository implements ContentRepository {
936
945
  return {
937
946
  sql: `INSERT INTO booking_links (id, resource_id, slug, name, description, mode, duration, lengths, min_days, max_days, step, buffer_before,
938
947
  buffer_after, min_notice, max_advance, per_day_cap, hours, auto_confirm, visibility, ask_phone, questions, confirm_message, redirect_url,
939
- active, ord, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
948
+ active, ord, created_at, updated_at, approval, approval_rules) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
940
949
  args: [l.id, l.resourceId, l.slug, l.name, l.description, l.mode, l.duration, JSON.stringify(l.lengths), l.minDays, l.maxDays, l.step,
941
950
  l.bufferBefore, l.bufferAfter, l.minNotice, l.maxAdvance, l.perDayCap, l.hours ? JSON.stringify(l.hours) : null, l.autoConfirm ? 1 : 0,
942
- l.visibility, l.askPhone ? 1 : 0, JSON.stringify(l.questions), l.confirmMessage, l.redirectUrl, l.active ? 1 : 0, l.order, l.createdAt, l.updatedAt],
951
+ l.visibility, l.askPhone ? 1 : 0, JSON.stringify(l.questions), l.confirmMessage, l.redirectUrl, l.active ? 1 : 0, l.order, l.createdAt, l.updatedAt,
952
+ l.approval, JSON.stringify(l.approvalRules)],
943
953
  };
944
954
  }
945
955
  private bookingInsert(b: Booking): SqlStatement {
@@ -1017,7 +1027,7 @@ export class SqlRepository implements ContentRepository {
1017
1027
  await this.ready;
1018
1028
  const now = Date.now();
1019
1029
  const next = await this.first('SELECT COALESCE(MAX(ord), -1) + 1 AS n FROM booking_links WHERE resource_id = ?', [data.resourceId]);
1020
- const l = BookingLink.parse({ ...data, id: crypto.randomUUID(), order: Number(next?.n ?? 0), createdAt: now, updatedAt: now });
1030
+ const l = BookingLink.parse({ ...legacyApproval(data), id: crypto.randomUUID(), order: Number(next?.n ?? 0), createdAt: now, updatedAt: now });
1021
1031
  const st = this.linkInsert(l);
1022
1032
  await this.driver.run(st.sql, st.args);
1023
1033
  return l;
@@ -1026,14 +1036,14 @@ export class SqlRepository implements ContentRepository {
1026
1036
  await this.ready;
1027
1037
  const cur = await this.getBookingLink(id);
1028
1038
  if (!cur) return;
1029
- const l = BookingLink.parse({ ...cur, ...data, id, updatedAt: Date.now() });
1039
+ const l = BookingLink.parse({ ...cur, ...legacyApproval(data, true), id, updatedAt: Date.now() });
1030
1040
  await this.driver.run(
1031
1041
  `UPDATE booking_links SET slug = ?, name = ?, description = ?, mode = ?, duration = ?, lengths = ?, min_days = ?, max_days = ?, step = ?,
1032
1042
  buffer_before = ?, buffer_after = ?, min_notice = ?, max_advance = ?, per_day_cap = ?, hours = ?, auto_confirm = ?, visibility = ?,
1033
- ask_phone = ?, questions = ?, confirm_message = ?, redirect_url = ?, active = ?, ord = ?, updated_at = ? WHERE id = ?`,
1043
+ ask_phone = ?, questions = ?, confirm_message = ?, redirect_url = ?, active = ?, ord = ?, updated_at = ?, approval = ?, approval_rules = ? WHERE id = ?`,
1034
1044
  [l.slug, l.name, l.description, l.mode, l.duration, JSON.stringify(l.lengths), l.minDays, l.maxDays, l.step, l.bufferBefore, l.bufferAfter,
1035
1045
  l.minNotice, l.maxAdvance, l.perDayCap, l.hours ? JSON.stringify(l.hours) : null, l.autoConfirm ? 1 : 0, l.visibility, l.askPhone ? 1 : 0,
1036
- JSON.stringify(l.questions), l.confirmMessage, l.redirectUrl, l.active ? 1 : 0, l.order, l.updatedAt, id]
1046
+ JSON.stringify(l.questions), l.confirmMessage, l.redirectUrl, l.active ? 1 : 0, l.order, l.updatedAt, l.approval, JSON.stringify(l.approvalRules), id]
1037
1047
  );
1038
1048
  }
1039
1049
  async deleteBookingLink(id: string) {
@@ -1048,6 +1058,7 @@ export class SqlRepository implements ContentRepository {
1048
1058
  const args: unknown[] = [];
1049
1059
  if (filter.resourceId) { where.push('resource_id = ?'); args.push(filter.resourceId); }
1050
1060
  if (filter.eventId) { where.push('event_id = ?'); args.push(filter.eventId); }
1061
+ if (filter.email) { where.push('email = ?'); args.push(filter.email.toLowerCase()); }
1051
1062
  if (filter.from !== undefined) { where.push('end_at > ?'); args.push(filter.from); }
1052
1063
  if (filter.to !== undefined) { where.push('start_at < ?'); args.push(filter.to); }
1053
1064
  if (filter.statuses?.length) { where.push(`status IN (${filter.statuses.map(() => '?').join(', ')})`); args.push(...filter.statuses); }
@@ -1614,14 +1625,14 @@ export class SqlRepository implements ContentRepository {
1614
1625
  ...(content.events ?? []).map((e) => ({
1615
1626
  sql: `INSERT INTO events (id, slug, title, body_json, body_html, excerpt, cover, cover_alt, seo_title, seo_desc, status, featured, tags, template,
1616
1627
  starts, ends, all_day, timezone, rrule, exdates, cancelled, location, cta, calendar_ids, fields, source_uid, start_at, end_at,
1617
- draft, published_at, scheduled_at, created_at, updated_at, rsvp)
1618
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1628
+ draft, published_at, scheduled_at, created_at, updated_at, rsvp, room_hold)
1629
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1619
1630
  args: [
1620
1631
  e.id, e.slug, e.title, e.bodyJson, e.bodyHtml, e.excerpt, e.cover, e.coverAlt, e.seoTitle, e.seoDesc, e.status, e.featured ? 1 : 0,
1621
1632
  JSON.stringify(e.tags), e.template, e.start, e.end, e.allDay ? 1 : 0, e.timezone, e.rrule, JSON.stringify(e.exdates),
1622
1633
  e.cancelled ? 1 : 0, JSON.stringify(e.location), JSON.stringify(e.cta), JSON.stringify(e.calendarIds), JSON.stringify(e.fields),
1623
1634
  e.sourceUid, e.startAt, e.endAt, e.draft ? JSON.stringify(e.draft) : null, e.publishedAt, e.scheduledAt, e.createdAt, e.updatedAt,
1624
- JSON.stringify(e.rsvp ?? {}),
1635
+ JSON.stringify(e.rsvp ?? {}), JSON.stringify(e.roomHold ?? {}),
1625
1636
  ],
1626
1637
  })),
1627
1638
  ...(content.resources ?? []).map((r) => this.resourceInsert(r)),
@@ -1836,6 +1847,7 @@ export class SqlRepository implements ContentRepository {
1836
1847
  ai: JSON.parse(String(r?.ai ?? '{}')),
1837
1848
  live: JSON.parse(String(r?.live ?? '{}')),
1838
1849
  push: JSON.parse(String(r?.push ?? '{}')),
1850
+ calendar: JSON.parse(String(r?.calendar ?? '{}')),
1839
1851
  agent: JSON.parse(String(r?.agent ?? '{}')),
1840
1852
  draftsApiKey: r?.drafts_api_key ?? '',
1841
1853
  draftsAllowedOrigins: JSON.parse(String(r?.drafts_allowed_origins ?? '[]')),
@@ -1852,9 +1864,9 @@ export class SqlRepository implements ContentRepository {
1852
1864
  siteTitle:'site_title', tagline:'tagline', footerText:'footer_text', linksName:'links_name', linksBio:'links_bio', linksAvatar:'links_avatar',
1853
1865
  activeTheme:'active_theme', designOverrides:'design_overrides', sectionPages:'section_pages', siteLogo:'site_logo', siteUrl:'site_url', siteFavicon:'site_favicon', siteCover:'site_cover',
1854
1866
  announcement:'announcement', analytics:'analytics', headCode:'head_code', footerCode:'footer_code', timezone:'timezone', locale:'locale',
1855
- draftsApiKey:'drafts_api_key', draftsAllowedOrigins:'drafts_allowed_origins', draftDesign:'draft_design', ai:'ai', agent:'agent', live:'live', push:'push',
1867
+ draftsApiKey:'drafts_api_key', draftsAllowedOrigins:'drafts_allowed_origins', draftDesign:'draft_design', ai:'ai', agent:'agent', live:'live', push:'push', calendar:'calendar',
1856
1868
  };
1857
- const jsonKeys = new Set(['designOverrides','sectionPages','announcement','analytics','draftsAllowedOrigins','draftDesign','ai','agent','live','push']);
1869
+ const jsonKeys = new Set(['designOverrides','sectionPages','announcement','analytics','draftsAllowedOrigins','draftDesign','ai','agent','live','push','calendar']);
1858
1870
  const entries = Object.entries(data).filter(([k,v]) => columns[k] && v !== undefined);
1859
1871
  if (!entries.length) return;
1860
1872
  const design = entries.some(([k]) => ['activeTheme','designOverrides','sectionPages','siteLogo','siteFavicon','siteCover','draftDesign'].includes(k));
@@ -39,7 +39,7 @@ export type EventUpdate = Partial<
39
39
  | 'title' | 'slug' | 'bodyJson' | 'bodyHtml' | 'excerpt' | 'cover' | 'coverAlt'
40
40
  | 'seoTitle' | 'seoDesc' | 'status' | 'featured' | 'tags' | 'template'
41
41
  | 'start' | 'end' | 'allDay' | 'timezone' | 'rrule' | 'exdates' | 'cancelled'
42
- | 'location' | 'cta' | 'calendarIds' | 'fields' | 'sourceUid' | 'rsvp' | 'translations'
42
+ | 'location' | 'cta' | 'calendarIds' | 'fields' | 'sourceUid' | 'rsvp' | 'translations' | 'roomHold'
43
43
  | 'publishedAt' | 'scheduledAt'
44
44
  >
45
45
  >;
@@ -152,7 +152,7 @@ export interface ContentRepository {
152
152
  // write that can fail on a race: it inserts atomically only if the held
153
153
  // range is still free (resource bookings) or seats remain (RSVPs), and
154
154
  // returns null when it lost.
155
- listBookings(filter?: { resourceId?: string; eventId?: string; from?: number; to?: number; statuses?: BookingStatus[]; kinds?: Booking['kind'][] }): Promise<Booking[]>;
155
+ listBookings(filter?: { resourceId?: string; eventId?: string; email?: string; from?: number; to?: number; statuses?: BookingStatus[]; kinds?: Booking['kind'][] }): Promise<Booking[]>;
156
156
  getBooking(id: string): Promise<Booking | null>;
157
157
  getBookingByToken(token: string): Promise<Booking | null>;
158
158
  createBooking(
@@ -2,7 +2,7 @@ import { syncDueLive } from './live/service';
2
2
  import type { ContentRepository } from './repo/types';
3
3
  import { processDueDeliveries } from './webhooks';
4
4
  import { dispatchEmail } from './email/service';
5
- import { sendDueReminders } from './booking/service';
5
+ import { decideOverdue, sendDueReminders } from './booking/service';
6
6
  import { runPushJobs } from './push/service';
7
7
 
8
8
  // All work is awaited on both hosts. Traffic-triggered fallbacks do not mark
@@ -14,6 +14,8 @@ export async function runScheduledTasks(repo: ContentRepository, locals?: unknow
14
14
  publish: () => repo.publishDuePosts(startedAt),
15
15
  webhooks: () => processDueDeliveries(repo),
16
16
  reminders: () => sendDueReminders(repo, { origin: settings.siteUrl, locals, now: startedAt }),
17
+ // pending bookings past their approval deadline (Calendar settings → Bookings)
18
+ approvals: () => decideOverdue(repo, { origin: settings.siteUrl, locals, now: startedAt }),
17
19
  email: () => dispatchEmail(repo, locals, 10),
18
20
  live: () => syncDueLive(repo, locals),
19
21
  // queued first-publish notices, starting-soon reminders, receipts, retention
package/src/lib/schema.ts CHANGED
@@ -314,6 +314,17 @@ export const EventCta = z.object({
314
314
  });
315
315
  export type EventCta = z.infer<typeof EventCta>;
316
316
 
317
+ export const RoomHold = z.object({
318
+ resourceIds: z.array(z.string()).default([]),
319
+ span: z.enum(['event', 'day']).default('event'),
320
+ before: z.number().int().min(0).max(1440).default(0), // setup minutes
321
+ after: z.number().int().min(0).max(1440).default(0), // cleanup minutes
322
+ whileDraft: z.boolean().default(false),
323
+ // bookings you chose to keep (share the room): no longer flagged as conflicts
324
+ keep: z.array(z.string()).default([]),
325
+ });
326
+ export type RoomHold = z.infer<typeof RoomHold>;
327
+
317
328
  export const CalEvent = z.object({
318
329
  id: z.string(),
319
330
  slug: z.string(),
@@ -355,6 +366,10 @@ export const CalEvent = z.object({
355
366
  autoConfirm: z.boolean().default(true),
356
367
  maxSeats: z.number().int().default(4),
357
368
  }).default({}),
369
+ // rooms/people this event uses: they can't be booked while it happens (or
370
+ // all day). Computed into availability, never stored as bookings. Drafts
371
+ // only hold when whileDraft is on; cancelled events never hold.
372
+ roomHold: RoomHold.default({}),
358
373
  startAt: z.number(),
359
374
  endAt: z.number(),
360
375
  translations: DocTranslations,
@@ -444,6 +459,36 @@ export type BookingQuestion = z.infer<typeof BookingQuestion>;
444
459
  export const BookingMode = z.enum(['slots', 'range', 'day']);
445
460
  export type BookingMode = z.infer<typeof BookingMode>;
446
461
 
462
+ // Booking approval: "auto" confirms at once, "manual" holds every booking for
463
+ // a decision, "rules" confirms unless one of the rules flags it. A link can
464
+ // follow the site default ("default") or pick its own. The deadline decides
465
+ // pending bookings nobody answered (0 hours = keep waiting).
466
+ export const ApprovalMode = z.enum(['auto', 'rules', 'manual']);
467
+ export type ApprovalMode = z.infer<typeof ApprovalMode>;
468
+ export const ApprovalRules = z.object({
469
+ // 0 = rule off
470
+ longerThan: z.number().int().min(0).default(0), // minutes
471
+ firstTime: z.boolean().default(false),
472
+ moreSeats: z.number().int().min(0).default(0),
473
+ deadlineHours: z.number().int().min(0).max(24 * 60).default(0),
474
+ deadlineAction: z.enum(['approve', 'decline']).default('approve'),
475
+ });
476
+ export type ApprovalRules = z.infer<typeof ApprovalRules>;
477
+
478
+ // Site-wide calendar preferences (Calendar settings → Defaults / Bookings)
479
+ export const CalendarSettings = z.object({
480
+ // first column of every month grid: 0 = Sunday, 1 = Monday
481
+ weekStart: z.union([z.literal(0), z.literal(1)]).default(0),
482
+ // calendar new events land in (quick add, New event); '' = none
483
+ defaultCalendarId: z.string().default(''),
484
+ // '' = not chosen in the admin yet → acms({ calendar.bookings.approval }) → auto
485
+ approval: z.union([ApprovalMode, z.literal('')]).default(''),
486
+ approvalRules: ApprovalRules.default({}),
487
+ // show bookings on the Schedule grid
488
+ showBookings: z.boolean().default(true),
489
+ });
490
+ export type CalendarSettings = z.infer<typeof CalendarSettings>;
491
+
447
492
  export const BookingLink = z.object({
448
493
  id: z.string(),
449
494
  resourceId: z.string(),
@@ -466,6 +511,9 @@ export const BookingLink = z.object({
466
511
  hours: WeeklyHours.nullable().default(null),
467
512
  // confirmed at once (and on the calendar) vs. held as pending for approval
468
513
  autoConfirm: z.boolean().default(true),
514
+ // supersedes autoConfirm: 'default' follows Calendar settings → Bookings
515
+ approval: z.union([ApprovalMode, z.literal('default')]).default('default'),
516
+ approvalRules: ApprovalRules.default({}),
469
517
  visibility: z.enum(['public', 'unlisted']).default('public'),
470
518
  askPhone: z.boolean().default(false),
471
519
  questions: z.array(BookingQuestion).default([]),
@@ -584,6 +632,7 @@ export function normalizeRedirectPath(input: string): string {
584
632
 
585
633
  export const Settings = z.object({
586
634
  live: LiveConfig.default({}),
635
+ calendar: CalendarSettings.default({}),
587
636
  // push notification automation (lib/push); the feature itself is acms({ mobile: { push } })
588
637
  push: PushSettings.default({}),
589
638
  siteTitle: z.string().default('My Site'),