@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.
- package/CHANGELOG.md +42 -0
- package/cli/preview.mjs +8 -4
- package/cli/sections.mjs +4 -2
- package/docs/mobile.md +1 -1
- package/docs/theme-authoring.md +19 -3
- package/integration.mjs +21 -4
- package/package.json +14 -12
- package/src/actions/index.ts +186 -3
- package/src/components/booking/BookingFlow.astro +6 -3
- package/src/components/calendar/BookingsHead.astro +51 -0
- package/src/components/calendar/CalendarBoard.tsx +618 -0
- package/src/components/calendar/MiniMonth.tsx +54 -0
- package/src/components/calendar/NewEventDialog.tsx +44 -0
- package/src/components/calendar/QuickAdd.tsx +263 -0
- package/src/components/calendar/RoomHolds.tsx +203 -0
- package/src/components/calendar/WhereItLands.tsx +51 -0
- package/src/components/editor/EventPanel.tsx +23 -2
- package/src/components/editor/PostEditor.tsx +32 -4
- package/src/components/import/ImportExport.tsx +4 -4
- package/src/components/import/runner.ts +20 -3
- package/src/layouts/Admin.astro +5 -6
- package/src/layouts/Site.astro +29 -2
- package/src/lib/backup/content.ts +6 -1
- package/src/lib/booking/agent.ts +3 -2
- package/src/lib/booking/approval.ts +70 -0
- package/src/lib/booking/holds.ts +120 -0
- package/src/lib/booking/service.ts +37 -3
- package/src/lib/calendar/agent-schema.ts +9 -1
- package/src/lib/calendar/agent.ts +16 -1
- package/src/lib/calendar/board.ts +171 -0
- package/src/lib/calendar/config.ts +3 -0
- package/src/lib/calendar/grid.ts +169 -0
- package/src/lib/calendar/migration.ts +16 -0
- package/src/lib/calendar/render.ts +9 -7
- package/src/lib/calendar/schedule.ts +166 -0
- package/src/lib/content-service.ts +5 -0
- package/src/lib/drafts.ts +1 -1
- package/src/lib/import/bundle.ts +2 -1
- package/src/lib/mobile/calendar.ts +10 -6
- package/src/lib/mobile/http.ts +6 -0
- package/src/lib/repo/migrations.ts +3 -0
- package/src/lib/repo/sql.ts +28 -16
- package/src/lib/repo/types.ts +2 -2
- package/src/lib/scheduler.ts +3 -1
- package/src/lib/schema.ts +49 -0
- package/src/lib/sections.ts +33 -8
- package/src/lib/site-chrome.ts +34 -0
- package/src/lib/theme-assets.ts +16 -0
- package/src/lib/theme.ts +17 -3
- package/src/middleware.ts +6 -2
- package/src/pages/admin/calendar/bookings.astro +32 -26
- package/src/pages/admin/calendar/calendars.astro +4 -263
- package/src/pages/admin/calendar/events/index.astro +41 -83
- package/src/pages/admin/calendar/index.astro +43 -0
- package/src/pages/admin/calendar/resources/[id].astro +27 -5
- package/src/pages/admin/calendar/resources/index.astro +16 -17
- package/src/pages/admin/calendar/resources/link.astro +52 -12
- package/src/pages/admin/calendar/settings.astro +458 -0
- package/src/pages/admin/integrations.astro +5 -11
- package/src/pages/book/[resource].ics.ts +7 -1
- package/src/styles/admin.css +48 -0
- package/src/styles/cards.css +5 -1
- package/src/styles/editor.css +36 -0
- package/src/styles/schedule.css +325 -0
- package/src/styles/sections.css +19 -16
- package/src/theme-sdk/fields.mjs +5 -1
- package/src/theme-sdk/index.ts +5 -1
- package/src/virtual.d.ts +3 -0
- 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];
|
package/src/lib/import/bundle.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
-
|
|
33
|
-
const
|
|
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
|
|
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
|
|
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
|
})),
|
package/src/lib/mobile/http.ts
CHANGED
|
@@ -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;
|
package/src/lib/repo/sql.ts
CHANGED
|
@@ -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));
|
package/src/lib/repo/types.ts
CHANGED
|
@@ -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(
|
package/src/lib/scheduler.ts
CHANGED
|
@@ -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'),
|