domma-cms 0.102.1 → 0.104.0

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 (50) hide show
  1. package/package.json +1 -1
  2. package/plugins/_lib/admin/mail/message-actions.js +56 -0
  3. package/plugins/_lib/admin/mail/reader-view.js +30 -1
  4. package/plugins/_lib/call/ice.js +58 -0
  5. package/plugins/_lib/call/rooms.js +249 -0
  6. package/plugins/_lib/call/routes.js +89 -0
  7. package/plugins/_lib/public/call/call.css +1 -0
  8. package/plugins/_lib/public/call/call.js +477 -0
  9. package/plugins/booking/CLAUDE.md +137 -0
  10. package/plugins/booking/admin/css/index.css +192 -0
  11. package/plugins/booking/admin/lib/details.js +58 -0
  12. package/plugins/booking/admin/lib/diary-layout.js +140 -0
  13. package/plugins/booking/admin/lib/extension-sections.js +160 -0
  14. package/plugins/booking/admin/lib/kit.js +249 -0
  15. package/plugins/booking/admin/templates/booking.html +1 -0
  16. package/plugins/booking/admin/views/booking.js +207 -0
  17. package/plugins/booking/admin/views/bookings.js +127 -0
  18. package/plugins/booking/admin/views/diary.js +374 -0
  19. package/plugins/booking/admin/views/panels.js +516 -0
  20. package/plugins/booking/admin/views/people.js +281 -0
  21. package/plugins/booking/admin/views/services.js +222 -0
  22. package/plugins/booking/admin/views/settings.js +203 -0
  23. package/plugins/booking/collections/booking-bookables.json +17 -0
  24. package/plugins/booking/collections/booking-bookings.json +18 -0
  25. package/plugins/booking/collections/booking-services.json +17 -0
  26. package/plugins/booking/config.js +36 -0
  27. package/plugins/booking/docs/guide.md +70 -0
  28. package/plugins/booking/inject/body.html +1 -0
  29. package/plugins/booking/lib/call.js +72 -0
  30. package/plugins/booking/lib/extensions.js +169 -0
  31. package/plugins/booking/lib/mail.js +113 -0
  32. package/plugins/booking/lib/store.js +390 -0
  33. package/plugins/booking/plugin.js +806 -0
  34. package/plugins/booking/plugin.json +80 -0
  35. package/plugins/booking/plugin.public.js +157 -0
  36. package/plugins/booking/public/booking.css +159 -0
  37. package/plugins/booking/public/booking.js +808 -0
  38. package/plugins/booking/public/lib/clean.js +141 -0
  39. package/plugins/booking/public/lib/page-format.js +121 -0
  40. package/plugins/booking/public/lib/slots.js +247 -0
  41. package/plugins/booking/public/lib/time.js +131 -0
  42. package/plugins/booking/templates/booking.html +5 -0
  43. package/plugins/booking/tests/admin-diary.test.js +73 -0
  44. package/plugins/booking/tests/api.test.js +445 -0
  45. package/plugins/booking/tests/public.test.js +72 -0
  46. package/plugins/booking/tests/pure.test.js +189 -0
  47. package/plugins/free-tier.lock.json +44 -1
  48. package/server/routes/api/related.js +20 -5
  49. package/server/server.js +3 -3
  50. package/server/services/hooks.js +35 -8
@@ -0,0 +1,806 @@
1
+ /**
2
+ * Booking - the API (mounted at /api/plugins/booking).
3
+ *
4
+ * Admin (the `booking` permission; read to look, manage to change):
5
+ * GET /bootstrap settings, services, people & rooms, users, what else is here
6
+ * GET /count sidebar badge: bookings still to come today
7
+ * GET /bookings?from=&to= bookings starting in a range (ISO instants)
8
+ * GET /bookings/:id · POST /bookings · PUT /bookings/:id · PUT /bookings/:id/move · DELETE /bookings/:id
9
+ * GET /slots?service=&from=&to=&with=&ignore= free times (dates in the business zone)
10
+ * GET/POST /services · PUT/DELETE /services/:id
11
+ * GET/POST /bookables · PUT/DELETE /bookables/:id
12
+ * GET /settings · PUT /settings
13
+ * GET /contact/:id a contact of the caller's, to fill a new booking
14
+ * GET /contact?email= the caller's contact with that email (an email's "Book…")
15
+ *
16
+ * Customers (no sign-in), under /public:
17
+ * GET /public/services what can be booked
18
+ * GET /public/slots free times for a service
19
+ * POST /public/book make a booking (honeypot, rate limited)
20
+ * GET /public/booking/:id?t= the customer's own booking (token-gated)
21
+ * POST /public/booking/:id/cancel · POST /public/booking/:id/move (token in the body)
22
+ * GET /public/booking/:id/ics?t=
23
+ *
24
+ * The booking page itself is plugin.public.js.
25
+ */
26
+
27
+ import {getConfig} from '../../server/config.js';
28
+ import {getPluginStates} from '../../server/services/plugins.js';
29
+ import {getPermissionsFor} from '../../server/services/roles.js';
30
+ import {getEffectiveRoles} from '../../server/services/userRoles.js';
31
+ import {listUsers} from '../../server/services/users.js';
32
+ import {isToolEnabled} from '../../server/services/tools.js';
33
+ import {cleanBookable, cleanCustomer, cleanService, whenLabel} from './public/lib/clean.js';
34
+ import {bookingRange, HOLDING, slotsFor} from './public/lib/slots.js';
35
+ import {addDays, atWallClock, isDate, wallClock} from './public/lib/time.js';
36
+ import {bookingRegistry} from './lib/extensions.js';
37
+ import {hostPath, joinPath, sessionOf, whoIsCalling} from './lib/call.js';
38
+ import {compose, icsFor, mailReady, send, siteAddress, siteTitle} from './lib/mail.js';
39
+ import {
40
+ BOOKABLES, BOOKINGS, NAME, SERVICES, SlotTaken, allBookables, allBookings, allServices, calendarBusy, changeBooking,
41
+ cleanSettings, contactByEmail, customerMayChange, ensureCollections, getBookable, getBooking, getService, linkContact, liveSettings,
42
+ ownContact, placeBooking, publicBooking, refreshShelf, removeBooking, removeThing, saveSettings, saveThing,
43
+ settingsNow, shelfNow, tokenOk, uniqueSlug
44
+ } from './lib/store.js';
45
+
46
+ /**
47
+ * The origin staff last used the admin on - for emails sent with no request
48
+ * (reminders) when `site.baseUrl` is not set. Only signed-in requests set it:
49
+ * a customer's Host header is theirs to choose, and must not decide where
50
+ * everyone else's reminder links point.
51
+ */
52
+ let lastOrigin = '';
53
+
54
+ function siteBase(request) {
55
+ let base = '';
56
+ try { base = String(getConfig('site')?.baseUrl || ''); } catch { /* no site config */ }
57
+ if (/^https?:\/\/[^/\s]+/i.test(base)) return base.replace(/\/+$/, '');
58
+ if (!request) return lastOrigin;
59
+ const origin = `${request.protocol}://${request.host || request.headers.host || request.hostname}`;
60
+ if (request.user) lastOrigin = origin;
61
+ return origin;
62
+ }
63
+
64
+ const manageUrl = (s, b, base) => `${base}${s.bookingPath}/manage/${b.id}?t=${b.token}`;
65
+ const fail = (reply, status, error, extra = {}) => reply.code(status).send({error, ...extra});
66
+ const uidOf = (request) => request.user?.id ?? request.user?.sub ?? '';
67
+ const contactsOn = () => { try { return isToolEnabled('contacts'); } catch { return false; } };
68
+
69
+ /** Extension data sent by the admin: {owner: {...}} objects only. */
70
+ const extOf = (v) => (v && typeof v === 'object' && !Array.isArray(v)
71
+ ? Object.fromEntries(Object.entries(v).filter(([k, x]) => /^[a-z0-9-]{1,64}$/.test(k) && x && typeof x === 'object' && !Array.isArray(x)))
72
+ : {});
73
+
74
+ /** Extra booking-form questions as the page may see them. */
75
+ const publicFields = (list) => list.map(f => ({
76
+ id: String(f.id).slice(0, 64), label: String(f.label).slice(0, 200), kind: String(f.kind || 'text'), required: f.required === true,
77
+ help: String(f.help || '').slice(0, 300), options: Array.isArray(f.options) ? f.options.map(o => String(o).slice(0, 100)).slice(0, 50) : []
78
+ }));
79
+
80
+ /**
81
+ * What the admin sees of a booking: everything but the customer's link token.
82
+ * An online booking carries the host's call address instead of the raw key.
83
+ */
84
+ const adminBooking = (b) => {
85
+ if (!b) return b;
86
+ const {token, hostKey, ...rest} = b;
87
+ return b.online === true && hostKey ? {...rest, hostUrl: hostPath(settingsNow(), b)} : rest;
88
+ };
89
+
90
+ /** A service as the booking page sees it. */
91
+ function publicService(svc, bookables) {
92
+ const mine = bookables.filter(b => b.active !== false && svc.bookables.includes(b.id));
93
+ return {
94
+ id: svc.id, slug: svc.slug, name: svc.name, description: svc.description, duration: svc.duration, colour: svc.colour,
95
+ chooseBookable: svc.chooseBookable !== false && mine.length > 1,
96
+ bookables: mine.map(b => ({id: b.id, name: b.name, kind: b.kind, description: b.description}))
97
+ };
98
+ }
99
+
100
+ /**
101
+ * The booking page, anywhere:
102
+ * [booking /] the whole flow, every service
103
+ * [booking service="hair-cut" /] straight to one service
104
+ * [booking service="hair-cut" with="<bookable id>" /]
105
+ * Self-closing: a bare [booking] is left as text. The page's script
106
+ * (public/booking.js, on every page via inject/body.html - the sanitiser strips
107
+ * a shortcode's own <script>) mounts on [data-booking] and adds its stylesheet.
108
+ */
109
+ function registerShortcodes(hooks) {
110
+ if (typeof hooks.registerShortcode !== 'function') return;
111
+ const esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
112
+ hooks.registerShortcode('booking', (attrStr, _body, ctx) => {
113
+ const a = ctx.parseShortcodeAttrs(attrStr || '');
114
+ const s = settingsNow();
115
+ const svc = a.service ? shelfNow().services.find(x => x.slug === String(a.service) && x.active !== false && x.public !== false) : null;
116
+ if (a.service && !svc) return '<p class="bk-muted">That service cannot be booked just now.</p>';
117
+ const cfg = {base: s.bookingPath, service: svc?.id || '', with: String(a.with || '').slice(0, 64), embedded: true};
118
+ return `<div class="bk-app" data-booking='${esc(JSON.stringify(cfg))}'><p class="bk-muted">Loading the booking form… If it does not appear, <a href="${esc(s.bookingPath)}">book here</a>.</p></div>`;
119
+ });
120
+ }
121
+
122
+ export const ASSET_V = '1.4.0';
123
+
124
+ export default async function booking(fastify, options) {
125
+ const {authenticate, requirePermission} = options.auth;
126
+ const hooks = options.hooks || {};
127
+ const can = (action) => ({preHandler: [authenticate, requirePermission('booking', action)]});
128
+ const registry = bookingRegistry();
129
+ const log = fastify.log || console;
130
+
131
+ await ensureCollections(log);
132
+ await refreshShelf();
133
+ registerShortcodes(hooks);
134
+
135
+ // The online call's rooms (set once the engine is mounted, at the end). A booking cancelled or moved away
136
+ // ends the call it was in, unless someone else is still booked into that session (a class).
137
+ let calls = null;
138
+ async function endCall(old) {
139
+ if (!calls || old?.online !== true) return;
140
+ const room = sessionOf(old);
141
+ const others = (await allBookings()).some(x => x.id !== old.id && x.online === true && HOLDING.has(x.status) && sessionOf(x) === room);
142
+ if (!others) calls.closeRoom(room);
143
+ }
144
+
145
+ const mayBook = (user, action = 'read') => {
146
+ // Core's related items and the Calendar pass every role the viewer has in `roles`.
147
+ const roles = Array.isArray(user?.roles) ? user.roles : getEffectiveRoles(user || {});
148
+ return roles.some(r => getPermissionsFor('booking', action).includes(r));
149
+ };
150
+
151
+ // ---- Telling people -------------------------------------------------------------
152
+
153
+ if (typeof hooks.registerNotificationSource === 'function') {
154
+ try {
155
+ hooks.registerNotificationSource({id: 'bookings', label: 'Bookings', severity: 'info', defaultOn: true,
156
+ description: 'A booking made, moved or cancelled from the booking page.'});
157
+ } catch { /* older engine */ }
158
+ }
159
+
160
+ function notice(b, what) {
161
+ const s = settingsNow();
162
+ const when = whenLabel(wallClock(b.startAt, s.timeZone));
163
+ const titles = {made: 'New booking', moved: 'Booking moved', cancelled: 'Booking cancelled'};
164
+ try {
165
+ hooks.notify?.({
166
+ title: `${titles[what]}: ${b.serviceName}, ${when}`,
167
+ body: `${b.name} (${b.email})${b.bookableName ? ` with ${b.bookableName}` : ''}`.slice(0, 300),
168
+ severity: what === 'cancelled' ? 'warning' : 'info',
169
+ link: `#/plugins/booking/bookings/${b.id}`,
170
+ source: 'bookings',
171
+ dedupeKey: `booking-${what}:${b.id}:${b.startAt}`
172
+ });
173
+ } catch { /* a notice never breaks a booking */ }
174
+ }
175
+
176
+ /** Where the owner's copy goes: the person booked, else the setting, else the site. */
177
+ async function ownerAddress(b) {
178
+ const bk = await getBookable(b.bookableId).catch(() => null);
179
+ return bk?.email || settingsNow().notifyAddress || siteAddress();
180
+ }
181
+
182
+ function detailsOf(b, s) {
183
+ return [
184
+ ['What', b.serviceName],
185
+ ['Where', b.online === true ? 'Online - a video call in your browser' : ''],
186
+ ['When', `${whenLabel(wallClock(b.startAt, s.timeZone))} (${b.duration || Math.round((Date.parse(b.endAt) - Date.parse(b.startAt)) / 60000)} minutes)`],
187
+ ['With', [b.bookableName, ...(b.alsoNames || [])].filter(Boolean).join(', ')],
188
+ ['Name', b.name],
189
+ ['Email', b.email],
190
+ ['Phone', b.phone],
191
+ ['Notes', b.notes]
192
+ ];
193
+ }
194
+
195
+ /**
196
+ * Email the customer (and the owner) about a booking. Never awaited by a
197
+ * request, never throws. `what`: made | moved | cancelled | reminder.
198
+ */
199
+ async function tell(b, what, {request = null, toCustomer = true, toOwner = true} = {}) {
200
+ try {
201
+ const s = await liveSettings();
202
+ const base = siteBase(request);
203
+ const title = siteTitle();
204
+ const link = base && b.token ? manageUrl(s, b, base) : '';
205
+ const may = customerMayChange(b, s) && (s.allowCancel || s.allowMove);
206
+ const host = (() => { try { return new URL(base).host; } catch { return 'booking'; } })();
207
+ const join = b.online === true && base ? `${base}${joinPath(s, b)}` : '';
208
+ const ics = icsFor(b, {host, title: `${b.serviceName} - ${title}`, location: join ? `Online: ${join}` : '',
209
+ description: [b.bookableName ? `With ${b.bookableName}` : '', join ? `Join the call: ${join}` : ''].filter(Boolean).join('\n'),
210
+ cancel: what === 'cancelled', sequence: (b.history || []).length});
211
+ const attachments = [{filename: what === 'cancelled' ? 'cancelled.ics' : 'booking.ics', content: ics,
212
+ contentType: `text/calendar; charset=utf-8; method=${what === 'cancelled' ? 'CANCEL' : 'REQUEST'}`}];
213
+ const heads = {
214
+ made: [`Booking confirmed: ${b.serviceName}`, `Thank you, ${b.name}. Your booking is confirmed.`],
215
+ moved: [`Booking moved: ${b.serviceName}`, `Your booking has moved. Here are the new details.`],
216
+ cancelled: [`Booking cancelled: ${b.serviceName}`, `Your booking has been cancelled.`],
217
+ reminder: [`Reminder: ${b.serviceName}`, `A reminder of your booking.`]
218
+ };
219
+ const [subject, line] = heads[what];
220
+ if (toCustomer && (what !== 'made' || s.sendConfirmation)) {
221
+ const after = [];
222
+ if (what !== 'cancelled' && s.confirmationNote) after.push(s.confirmationNote);
223
+ // Online: the call is the one link that matters; changing it is offered underneath.
224
+ const online = what !== 'cancelled' && join;
225
+ if (online && may) after.unshift(`To change or cancel: ${link}`);
226
+ const msg = compose({subject, lines: online ? [line, 'This is an online appointment. Use the button below at the time - the call opens 10 minutes before the start. You need a camera and microphone, or you can join with just one.'] : [line],
227
+ details: detailsOf(b, s),
228
+ link: online ? join : what !== 'cancelled' && may ? link : '', linkText: online ? 'Join the call' : 'Change or cancel', after});
229
+ send({to: b.email, replyTo: await ownerAddress(b), ...msg, attachments: what === 'reminder' ? [] : attachments}, {log});
230
+ }
231
+ if (toOwner && what !== 'reminder') {
232
+ const to = await ownerAddress(b);
233
+ const admin = base ? `${base}/admin/#/plugins/booking/bookings/${b.id}` : '';
234
+ const hostLink = b.online === true && b.hostKey && base && what !== 'cancelled' ? `${base}${hostPath(s, b)}` : '';
235
+ const msg = compose({subject: `${{made: 'New booking', moved: 'Booking moved', cancelled: 'Booking cancelled'}[what]}: ${b.name}, ${whenLabel(wallClock(b.startAt, s.timeZone))}`,
236
+ lines: hostLink ? ['An online appointment. You are the host: start the call from the button below, and let them in when they arrive.'] : [],
237
+ details: detailsOf(b, s), link: hostLink || admin, linkText: hostLink ? 'Start the call' : 'Open in Booking',
238
+ after: hostLink && admin ? [`Open in Booking: ${admin}`] : []});
239
+ // The host's calendar entry opens the call as host, never as the customer.
240
+ const ownerAttachments = hostLink ? [{...attachments[0], content: icsFor(b, {host, title: `${b.serviceName} - ${title}`,
241
+ location: `Online: ${hostLink}`, description: `With ${b.name}\nStart the call: ${hostLink}`, cancel: what === 'cancelled',
242
+ sequence: (b.history || []).length})}] : attachments;
243
+ if (to && to !== b.email) send({to, replyTo: b.email, ...msg, attachments: ownerAttachments}, {log});
244
+ }
245
+ } catch (err) {
246
+ log.error?.(`[booking] could not prepare an email: ${err?.message || err}`);
247
+ }
248
+ }
249
+
250
+ // ---- Reminders ------------------------------------------------------------------
251
+
252
+ /** Send the reminders that are due. Exported through the registry for tests and Pro. */
253
+ async function sendReminders(now = Date.now()) {
254
+ const s = await liveSettings();
255
+ if (!s.reminderHours) return 0;
256
+ const due = (await allBookings()).filter(b => b.status === 'confirmed' && !b.reminderSentAt
257
+ && Date.parse(b.startAt) > now && Date.parse(b.startAt) - now <= s.reminderHours * 3600000
258
+ // A booking made inside the reminder window already had its confirmation.
259
+ && Date.parse(b.createdAt || b.history?.[0]?.at || 0) < Date.parse(b.startAt) - s.reminderHours * 3600000);
260
+ for (const b of due) {
261
+ await changeBooking(b.id, {reminderSentAt: new Date(now).toISOString()});
262
+ tell(b, 'reminder', {toOwner: false});
263
+ }
264
+ return due.length;
265
+ }
266
+ const timer = setInterval(() => { sendReminders().catch(err => log.warn?.(`[booking] reminders: ${err?.message || err}`)); }, 5 * 60000);
267
+ timer.unref?.();
268
+ fastify.addHook('onClose', async () => clearInterval(timer));
269
+
270
+ // ---- What extensions may use ------------------------------------------------------
271
+
272
+ registry.services = {
273
+ name: NAME, liveSettings, allBookings, allServices, allBookables, getBooking, getService, getBookable,
274
+ changeBooking, sendReminders,
275
+ /** The site's address: `site.baseUrl`, else the request's origin (a signed-in one), else the last admin's. */
276
+ siteBase: (request = null) => siteBase(request),
277
+ /** A booking as the admin may see it (no customer token; an online one's host key as `hostUrl`). */
278
+ adminBooking,
279
+ /** The contact `id` when it is `userId`'s own (Contacts on), else null. */
280
+ ownContact: async (id, userId) => (contactsOn() && id && userId ? ownContact(String(id), String(userId)).catch(() => null) : null),
281
+ manageUrl: (b) => liveSettings().then(s => manageUrl(s, b, siteBase(null))),
282
+ /** Make a booking the whole way (contacts, emails, notices, events) - a waiting list's place, a series. Throws SlotTaken. */
283
+ book: (p) => bookForExtension(p),
284
+ /** Cancel one, telling the customer when `notify`. */
285
+ cancel: async (id, {notify = true, reason = '', by = ''} = {}) => {
286
+ const res = await changeBooking(id, {status: 'cancelled', reason}, by);
287
+ if (!res) return null;
288
+ if (res.from !== 'cancelled') {
289
+ if (notify) tell(res.booking, 'cancelled', {toOwner: false});
290
+ registry.emit('cancelled', res.booking, res.from);
291
+ endCall(res.booking).catch(() => {});
292
+ }
293
+ return res.booking;
294
+ },
295
+ /**
296
+ * "Call now" (1.4): open an online booking's call now, even when it is booked for later - for everyone
297
+ * booked into that session (a class shares one call). The customer's own link then works at once.
298
+ * Throws with `code`: 'none' | 'not-online' | 'cancelled'.
299
+ */
300
+ openCall: async (id, {by = ''} = {}) => {
301
+ const b = await getBooking(String(id || ''));
302
+ const refuse = (code, message) => Object.assign(new Error(message), {code});
303
+ if (!b) throw refuse('none', 'No such booking.');
304
+ if (b.online !== true || !b.hostKey) throw refuse('not-online', 'That booking is not an online one, so it has no call.');
305
+ if (!HOLDING.has(b.status) || b.status === 'cancelled') throw refuse('cancelled', 'That booking is cancelled, so it has no call.');
306
+ const now = new Date().toISOString();
307
+ const room = sessionOf(b);
308
+ const session = (await allBookings()).filter(x => x.online === true && HOLDING.has(x.status) && sessionOf(x) === room);
309
+ for (const x of session) await changeBooking(x.id, {callOpenedAt: now}, by);
310
+ return getBooking(b.id);
311
+ },
312
+ /** An online booking's call addresses, whole: the customer's (`joinUrl`) and the host's (`hostUrl`). */
313
+ callLinks: (b, request = null) => {
314
+ if (b?.online !== true) return {joinUrl: '', hostUrl: ''};
315
+ const s = settingsNow();
316
+ const base = siteBase(request);
317
+ return {joinUrl: b.token ? `${base}${joinPath(s, b)}` : '', hostUrl: b.hostKey ? `${base}${hostPath(s, b)}` : ''};
318
+ },
319
+ SlotTaken,
320
+ // Booking's own helpers, so an extension never imports Booking's files.
321
+ mail: {send: (msg) => send(msg, {log}), compose, mailReady},
322
+ time: {wallClock, atWallClock, whenLabel, addDays},
323
+ timeZone: () => settingsNow().timeZone
324
+ };
325
+
326
+ /**
327
+ * `contactId` (1.4): the caller's own contact (`userId` = the caller) to book as - checked, dropped when it is
328
+ * not theirs. `request`: the admin request it came from, for the links in the emails.
329
+ */
330
+ async function bookForExtension({serviceId, bookableId = '', startAt, customer: raw = {}, source = 'extension', ext = {}, force = false, notify = true, by = '',
331
+ contactId: askedContact = '', userId = '', request = null}) {
332
+ const s = await liveSettings();
333
+ const {service, candidates, rules} = await candidatesFor(String(serviceId || ''), String(bookableId || ''), {includeInactive: true});
334
+ if (!service || !candidates.length) throw new Error('That service cannot be booked.');
335
+ const {data: customer, errors} = cleanCustomer(raw, {askPhone: true});
336
+ if (Object.keys(errors).length) throw new Error(Object.values(errors)[0]);
337
+ const contactId = askedContact && userId && await ownContact(String(askedContact), String(userId)).catch(() => null) ? String(askedContact) : '';
338
+ const b = await placeBooking({service, candidates, rules, startAt: new Date(startAt).toISOString(), tz: s.timeZone, force,
339
+ fields: {...customer, source, by: by || customer.name, ext: extOf(ext), ...(contactId ? {contactId} : {}), ...(userId ? {createdBy: String(userId)} : {})}});
340
+ if (!contactId && s.addToContacts) {
341
+ const bk = candidates.find(c => c.id === b.bookableId);
342
+ const id = await linkContact(customer, bk?.userId || s.contactsOwnerId, contactsOn()).catch(() => '');
343
+ if (id) Object.assign(b, (await changeBooking(b.id, {contactId: id})).booking);
344
+ }
345
+ if (request) siteBase(request);
346
+ if (notify) tell(b, 'made', {request, toOwner: source !== 'admin'});
347
+ registry.emit('made', b);
348
+ return b;
349
+ }
350
+
351
+ // ---- Links to other Tools -------------------------------------------------------
352
+
353
+ const bookingsOfContact = async (contactId) => (await allBookings()).filter(b => b.contactId === contactId);
354
+ hooks.registerRelatedItems?.('contact', {
355
+ id: 'booking', label: 'Bookings', icon: 'clipboard-check',
356
+ actions: [{label: 'Book {name}…', icon: 'calendar', url: '#/plugins/booking/new?contact={id}', permission: {resource: 'booking', action: 'manage'}}],
357
+ async list({id, user}) {
358
+ if (!mayBook(user)) return [];
359
+ const now = new Date().toISOString();
360
+ const rows = (await bookingsOfContact(id)).map(b => ({
361
+ id: b.id, title: `${b.serviceName}${b.bookableName ? ` with ${b.bookableName}` : ''}`, startAt: b.startAt,
362
+ past: b.startAt < now, meta: b.status === 'confirmed' ? '' : b.status, url: `#/plugins/booking/bookings/${b.id}`
363
+ }));
364
+ return [...rows.filter(r => !r.past).sort((a, b) => a.startAt.localeCompare(b.startAt)),
365
+ ...rows.filter(r => r.past).sort((a, b) => b.startAt.localeCompare(a.startAt)).slice(0, 5)];
366
+ }
367
+ });
368
+ // Core 0.103+: "Book Sam…" on an email's menu. An older core refuses the kind and nothing shows.
369
+ hooks.registerRelatedItems?.('message', {
370
+ id: 'booking', label: 'Booking', icon: 'calendar',
371
+ actions: [{label: 'Book {name}…', icon: 'calendar', url: '#/plugins/booking/new?email={email}&name={name}', permission: {resource: 'booking', action: 'manage'}}]
372
+ });
373
+
374
+ hooks.registerCalendarSource?.({
375
+ id: 'bookings', label: 'Bookings', icon: 'clipboard-check', tone: 'primary',
376
+ async list({from, to, user}) {
377
+ if (!mayBook(user)) return [];
378
+ return (await allBookings())
379
+ .filter(b => HOLDING.has(b.status) && b.startAt < to && b.endAt > from)
380
+ .map(b => ({id: b.id, title: `${b.serviceName}: ${b.name}`, startAt: b.startAt, endAt: b.endAt,
381
+ meta: b.bookableName, url: `#/plugins/booking/bookings/${b.id}`}));
382
+ }
383
+ });
384
+
385
+ // ---- Admin: bootstrap, badge ----------------------------------------------------
386
+
387
+ async function bootstrap() {
388
+ const [s, services, bookables] = await Promise.all([liveSettings(), allServices(), allBookables()]);
389
+ let users = [];
390
+ try { users = (await listUsers()).filter(u => u.isActive !== false).map(u => ({id: u.id, name: u.name || u.email, email: u.email})); } catch { /* none */ }
391
+ return {
392
+ settings: s, services, bookables, users,
393
+ mailReady: mailReady(),
394
+ contacts: contactsOn(),
395
+ calendar: Boolean(getPluginStates().calendar?.enabled),
396
+ tabs: registry.tabs(),
397
+ sections: registry.admin(),
398
+ actions: typeof registry.actions === 'function' ? registry.actions() : [],
399
+ now: new Date().toISOString(),
400
+ today: wallClock(Date.now(), s.timeZone).date
401
+ };
402
+ }
403
+
404
+ fastify.get('/bootstrap', can('read'), async (request) => { siteBase(request); return bootstrap(); });
405
+
406
+ fastify.get('/count', can('read'), async () => {
407
+ const s = await liveSettings();
408
+ const now = Date.now();
409
+ const today = wallClock(now, s.timeZone).date;
410
+ const left = (await allBookings())
411
+ .filter(b => b.status === 'confirmed' && Date.parse(b.endAt) > now && wallClock(b.startAt, s.timeZone).date === today)
412
+ .sort((a, b) => a.startAt.localeCompare(b.startAt));
413
+ return {
414
+ count: left.length,
415
+ label: left.length === 1 ? '1 booking still to come today' : `${left.length} bookings still to come today`,
416
+ details: left.slice(0, 8).map(b => `${wallClock(b.startAt, s.timeZone).time} ${b.serviceName}: ${b.name}`).join('\n')
417
+ };
418
+ });
419
+
420
+ // ---- Admin: bookings ------------------------------------------------------------
421
+
422
+ fastify.get('/bookings', can('read'), async (request) => {
423
+ const from = String(request.query?.from || '');
424
+ const to = String(request.query?.to || '');
425
+ const contact = String(request.query?.contact || '');
426
+ let list = await allBookings();
427
+ if (from) list = list.filter(b => b.endAt > from);
428
+ if (to) list = list.filter(b => b.startAt < to);
429
+ if (contact) list = list.filter(b => b.contactId === contact);
430
+ return list.sort((a, b) => a.startAt.localeCompare(b.startAt)).map(adminBooking);
431
+ });
432
+
433
+ fastify.get('/bookings/:id', can('read'), async (request, reply) => {
434
+ const b = await getBooking(request.params.id);
435
+ return b ? adminBooking(b) : fail(reply, 404, 'No such booking.');
436
+ });
437
+
438
+ /** Service + the bookables that may take a booking of it (`with` narrows to one). */
439
+ async function candidatesFor(serviceId, withId, {includeInactive = false} = {}) {
440
+ const service = await getService(serviceId);
441
+ if (!service || (!includeInactive && service.active === false)) return {service: null, candidates: [], rules: registry.rules({}, [])};
442
+ // A switched-off person or room is only ever booked by name (staff choosing them), never as "anyone".
443
+ const every = await allBookables();
444
+ const bookables = every.filter(b => service.bookables.includes(b.id)
445
+ && (b.active !== false || (includeInactive && withId && b.id === withId)));
446
+ const candidates = withId ? bookables.filter(b => b.id === withId) : bookables;
447
+ return {service, candidates, rules: registry.rules(service, every)};
448
+ }
449
+
450
+ fastify.post('/bookings', can('manage'), async (request, reply) => {
451
+ const body = request.body || {};
452
+ const s = await liveSettings();
453
+ const {service, candidates, rules} = await candidatesFor(String(body.serviceId || ''), String(body.bookableId || ''), {includeInactive: true});
454
+ if (!service) return fail(reply, 400, 'Pick a service.');
455
+ if (!candidates.length) return fail(reply, 400, 'Nobody provides that service. Add a person or room to it first.');
456
+ const startAt = new Date(String(body.startAt || ''));
457
+ if (Number.isNaN(startAt.getTime())) return fail(reply, 400, 'Pick a date and time.');
458
+ const {data: customer, errors} = cleanCustomer(body.customer || {}, {askPhone: true});
459
+ if (Object.keys(errors).length) return fail(reply, 400, Object.values(errors)[0], {errors});
460
+
461
+ let contactId = String(body.contactId || '');
462
+ if (contactId && !(await ownContact(contactId, uidOf(request)))) contactId = '';
463
+ try {
464
+ // Staff may leave extension questions unanswered; what they did answer is cleaned the same way.
465
+ const answers = registry.answers(service, body.answers || {}).data;
466
+ const b = await placeBooking({
467
+ service, candidates, rules, startAt: startAt.toISOString(), tz: s.timeZone, force: body.force === true,
468
+ fields: {...customer, staffNote: String(body.staffNote || '').slice(0, 4000), source: 'admin', createdBy: uidOf(request), contactId, by: request.user?.name || '',
469
+ ext: {...extOf(body.ext), ...(Object.keys(answers).length ? {answers} : {})}}
470
+ });
471
+ if (!contactId && s.addToContacts) {
472
+ const owner = candidates.find(c => c.id === b.bookableId)?.userId || uidOf(request);
473
+ const id = await linkContact(customer, owner, contactsOn()).catch(() => '');
474
+ if (id) Object.assign(b, (await changeBooking(b.id, {contactId: id})).booking);
475
+ }
476
+ siteBase(request);
477
+ if (body.notify !== false) tell(b, 'made', {request, toOwner: false});
478
+ registry.emit('made', b);
479
+ return reply.code(201).send(adminBooking(b));
480
+ } catch (err) {
481
+ if (err instanceof SlotTaken) return fail(reply, 409, 'That time clashes with another booking or busy time. Pick another, or book it anyway.', {code: 'slot-taken'});
482
+ throw err;
483
+ }
484
+ });
485
+
486
+ fastify.put('/bookings/:id/move', can('manage'), async (request, reply) => {
487
+ const body = request.body || {};
488
+ const s = await liveSettings();
489
+ const current = await getBooking(request.params.id);
490
+ if (!current) return fail(reply, 404, 'No such booking.');
491
+ if (!HOLDING.has(current.status) || current.status === 'cancelled') return fail(reply, 400, 'A cancelled booking cannot be moved.');
492
+ const {service, candidates, rules} = await candidatesFor(current.serviceId, String(body.bookableId || ''), {includeInactive: true});
493
+ if (!service) return fail(reply, 400, 'Its service no longer exists.');
494
+ const startAt = new Date(String(body.startAt || ''));
495
+ if (Number.isNaN(startAt.getTime())) return fail(reply, 400, 'Pick a date and time.');
496
+ try {
497
+ const b = await placeBooking({service, candidates, rules, startAt: startAt.toISOString(), tz: s.timeZone, moveId: current.id,
498
+ force: body.force === true, fields: {by: request.user?.name || ''}});
499
+ siteBase(request);
500
+ if (body.notify !== false) tell(b, 'moved', {request, toOwner: false});
501
+ registry.emit('moved', b, current);
502
+ endCall(current).catch(() => {});
503
+ return adminBooking(b);
504
+ } catch (err) {
505
+ if (err instanceof SlotTaken) return fail(reply, 409, 'That time clashes with another booking or busy time. Pick another, or move it anyway.', {code: 'slot-taken'});
506
+ throw err;
507
+ }
508
+ });
509
+
510
+ fastify.put('/bookings/:id', can('manage'), async (request, reply) => {
511
+ const body = request.body || {};
512
+ const patch = {};
513
+ if (body.status !== undefined) patch.status = String(body.status);
514
+ if (body.staffNote !== undefined) patch.staffNote = body.staffNote;
515
+ if (body.reason !== undefined) patch.reason = body.reason;
516
+ if (body.customer) {
517
+ const {data, errors} = cleanCustomer({...(await getBooking(request.params.id) || {}), ...body.customer}, {askPhone: true});
518
+ if (Object.keys(errors).length) return fail(reply, 400, Object.values(errors)[0], {errors});
519
+ patch.customer = data;
520
+ }
521
+ let res;
522
+ try { res = await changeBooking(request.params.id, patch, request.user?.name || ''); }
523
+ catch (err) { return fail(reply, 400, err.message); }
524
+ if (!res) return fail(reply, 404, 'No such booking.');
525
+ const {booking: b, from} = res;
526
+ if (b.status !== from) {
527
+ siteBase(request);
528
+ if (b.status === 'cancelled' && body.notify !== false) tell(b, 'cancelled', {request, toOwner: false});
529
+ registry.emit(b.status === 'cancelled' ? 'cancelled' : 'status', b, from);
530
+ if (b.status === 'cancelled') endCall(b).catch(() => {});
531
+ }
532
+ return adminBooking(b);
533
+ });
534
+
535
+ fastify.delete('/bookings/:id', can('manage'), async (request, reply) => {
536
+ const b = await getBooking(request.params.id);
537
+ if (!b) return fail(reply, 404, 'No such booking.');
538
+ await removeBooking(b.id);
539
+ return {ok: true};
540
+ });
541
+
542
+ // ---- Free times -------------------------------------------------------------------
543
+
544
+ /** Free times, for the admin (inactive services too) or the page (active, public ones). */
545
+ async function freeTimes({serviceId, withId, from, to, ignoreId = '', admin = false, includeFull = false}) {
546
+ const s = await liveSettings();
547
+ const {service, candidates, rules} = await candidatesFor(serviceId, withId, {includeInactive: admin});
548
+ if (!service || (!admin && service.public === false)) return null;
549
+ const now = Date.now();
550
+ // Staff may book at short notice or far ahead; the page keeps the service's rules.
551
+ const timing = admin ? {...service, minNoticeMinutes: 0, maxAdvanceDays: 0} : service;
552
+ const range = admin ? {first: from, last: to} : bookingRange(service, now, s.timeZone);
553
+ const first = isDate(from) && from > range.first ? from : range.first;
554
+ const lastWanted = isDate(to) ? to : addDays(first, 6);
555
+ const last = lastWanted < range.last ? lastWanted : range.last;
556
+ if (!isDate(first) || !isDate(last) || last < first) return {service, days: [], range, rules};
557
+ const span = {from: atWallClock(first, 0, s.timeZone).toISOString(), to: atWallClock(addDays(last, 1), 0, s.timeZone).toISOString()};
558
+ const everyone = [...candidates, ...rules.companions.flat()];
559
+ const busy = s.calendarBusy ? await calendarBusy(everyone, span, s.timeZone).catch(() => ({})) : {};
560
+ const days = slotsFor({service: timing, bookables: candidates, bookings: await allBookings(), busy, rules, includeFull: includeFull && rules.waitlist,
561
+ from: first, to: addDays(first, 41) < last ? addDays(first, 41) : last, now, tz: s.timeZone, closures: s.closures, ignoreId});
562
+ return {service, days, range, rules};
563
+ }
564
+
565
+ fastify.get('/slots', can('read'), async (request, reply) => {
566
+ const q = request.query || {};
567
+ const today = wallClock(Date.now(), settingsNow().timeZone).date;
568
+ const r = await freeTimes({serviceId: String(q.service || ''), withId: String(q.with || ''), from: String(q.from || today),
569
+ to: String(q.to || ''), ignoreId: String(q.ignore || ''), admin: true});
570
+ return r ? {days: r.days} : fail(reply, 404, 'No such service.');
571
+ });
572
+
573
+ // ---- Admin: services and people & rooms ---------------------------------------------
574
+
575
+ fastify.get('/services', can('read'), async () => allServices());
576
+ fastify.post('/services', can('manage'), async (request, reply) => {
577
+ const {data, errors} = cleanService(request.body || {});
578
+ if (Object.keys(errors).length) return fail(reply, 400, Object.values(errors)[0], {errors});
579
+ data.slug = uniqueSlug(data.slug, await allServices());
580
+ return reply.code(201).send(await saveThing(SERVICES, '', data));
581
+ });
582
+ fastify.put('/services/:id', can('manage'), async (request, reply) => {
583
+ const current = await getService(request.params.id);
584
+ if (!current) return fail(reply, 404, 'No such service.');
585
+ const {id, createdAt, updatedAt, ...stored} = current;
586
+ const {data, errors} = cleanService(request.body || {}, stored);
587
+ if (Object.keys(errors).length) return fail(reply, 400, Object.values(errors)[0], {errors});
588
+ data.slug = uniqueSlug(data.slug, await allServices(), id);
589
+ return saveThing(SERVICES, id, data);
590
+ });
591
+ fastify.delete('/services/:id', can('manage'), async (request, reply) => {
592
+ const current = await getService(request.params.id);
593
+ if (!current) return fail(reply, 404, 'No such service.');
594
+ const now = new Date().toISOString();
595
+ const ahead = (await allBookings()).filter(b => b.serviceId === current.id && b.status === 'confirmed' && b.startAt > now).length;
596
+ if (ahead) return fail(reply, 409, `${ahead} booking${ahead === 1 ? ' is' : 's are'} still to come for this service. Cancel or move ${ahead === 1 ? 'it' : 'them'} first, or switch the service off instead.`);
597
+ await removeThing(SERVICES, current.id);
598
+ return {ok: true};
599
+ });
600
+
601
+ fastify.get('/bookables', can('read'), async () => allBookables());
602
+ fastify.post('/bookables', can('manage'), async (request, reply) => {
603
+ const {data, errors} = cleanBookable(request.body || {});
604
+ if (Object.keys(errors).length) return fail(reply, 400, Object.values(errors)[0], {errors});
605
+ return reply.code(201).send(await saveThing(BOOKABLES, '', data));
606
+ });
607
+ fastify.put('/bookables/:id', can('manage'), async (request, reply) => {
608
+ const current = await getBookable(request.params.id);
609
+ if (!current) return fail(reply, 404, 'No such person or room.');
610
+ const {id, createdAt, updatedAt, ...stored} = current;
611
+ const {data, errors} = cleanBookable(request.body || {}, stored);
612
+ if (Object.keys(errors).length) return fail(reply, 400, Object.values(errors)[0], {errors});
613
+ return saveThing(BOOKABLES, id, data);
614
+ });
615
+ fastify.delete('/bookables/:id', can('manage'), async (request, reply) => {
616
+ const current = await getBookable(request.params.id);
617
+ if (!current) return fail(reply, 404, 'No such person or room.');
618
+ const now = new Date().toISOString();
619
+ const ahead = (await allBookings()).filter(b => b.bookableId === current.id && b.status === 'confirmed' && b.startAt > now).length;
620
+ if (ahead) return fail(reply, 409, `${ahead} booking${ahead === 1 ? ' is' : 's are'} still to come with ${current.name}. Move or cancel ${ahead === 1 ? 'it' : 'them'} first, or switch ${current.name} off instead.`);
621
+ await removeThing(BOOKABLES, current.id);
622
+ // Take it off every service that listed it.
623
+ for (const svc of await allServices()) {
624
+ if (!svc.bookables.includes(current.id)) continue;
625
+ const {id, createdAt, updatedAt, ...stored} = svc;
626
+ await saveThing(SERVICES, id, {...stored, bookables: svc.bookables.filter(x => x !== current.id)});
627
+ }
628
+ return {ok: true};
629
+ });
630
+
631
+ // ---- Admin: settings, contacts ----------------------------------------------------
632
+
633
+ fastify.get('/settings', can('read'), async () => ({settings: await liveSettings(), mailReady: mailReady()}));
634
+ fastify.put('/settings', can('manage'), async (request, reply) => {
635
+ const {settings, errors} = cleanSettings(request.body || {}, await liveSettings());
636
+ if (Object.keys(errors).length) return fail(reply, 400, Object.values(errors)[0], {errors});
637
+ saveSettings(settings);
638
+ return {settings: await liveSettings(), mailReady: mailReady()};
639
+ });
640
+
641
+ fastify.get('/contact', can('read'), async (request, reply) => {
642
+ if (!contactsOn()) return fail(reply, 404, 'Contacts is switched off.');
643
+ const c = await contactByEmail(request.query?.email, uidOf(request));
644
+ return c || fail(reply, 404, 'No such contact.');
645
+ });
646
+
647
+ fastify.get('/contact/:id', can('read'), async (request, reply) => {
648
+ if (!contactsOn()) return fail(reply, 404, 'Contacts is switched off.');
649
+ const c = await ownContact(request.params.id, uidOf(request));
650
+ return c || fail(reply, 404, 'No such contact.');
651
+ });
652
+
653
+ // ---- Customers --------------------------------------------------------------------
654
+
655
+ fastify.get('/public/services', async () => {
656
+ const {services, bookables} = await refreshShelf();
657
+ const s = await liveSettings();
658
+ return {
659
+ title: s.pageTitle, intro: s.intro, timeZone: s.timeZone, askPhone: s.askPhone, phoneRequired: s.phoneRequired,
660
+ termsUrl: s.termsUrl, today: wallClock(Date.now(), s.timeZone).date,
661
+ services: services.filter(x => x.active !== false && x.public !== false).map(x => publicService(x, bookables)).filter(x => x.bookables.length)
662
+ .map(x => {
663
+ const svc = services.find(y => y.id === x.id);
664
+ const r = registry.rules(svc, bookables);
665
+ return {...x, capacity: r.capacity, online: r.online === true, waitlist: r.waitlist && Boolean(registry.waitlistUrl()), fields: publicFields(registry.form(svc))};
666
+ }),
667
+ waitlistUrl: registry.waitlistUrl()
668
+ };
669
+ });
670
+
671
+ fastify.get('/public/slots', {config: {rateLimit: {max: 120, timeWindow: '1 minute'}}}, async (request, reply) => {
672
+ const q = request.query || {};
673
+ const r = await freeTimes({serviceId: String(q.service || ''), withId: String(q.with || ''), from: String(q.from || ''), to: String(q.to || ''),
674
+ ignoreId: String(q.ignore || '').slice(0, 64), includeFull: !q.ignore && Boolean(registry.waitlistUrl())});
675
+ if (!r) return fail(reply, 404, 'That service cannot be booked.');
676
+ // Who is free is ours to know; the page only needs whether someone is (and, for a class, how many places).
677
+ const classy = r.rules?.capacity > 1;
678
+ return {range: r.range, days: r.days.map(d => ({date: d.date, slots: d.slots.map(({start, end, time, left, full}) =>
679
+ ({start, end, time, ...(classy ? {left} : {}), ...(full ? {full: true} : {})}))}))};
680
+ });
681
+
682
+ fastify.post('/public/book', {config: {rateLimit: {max: 10, timeWindow: '10 minutes'}}}, async (request, reply) => {
683
+ const body = request.body || {};
684
+ // The honeypot: a field people never see. A bot that fills it is told it worked.
685
+ if (body.website) return reply.code(201).send({ok: true});
686
+ const s = await liveSettings();
687
+ const {service, candidates} = await candidatesFor(String(body.serviceId || ''), String(body.bookableId || ''));
688
+ if (!service || service.public === false || !candidates.length) return fail(reply, 400, 'That service cannot be booked.');
689
+ const {data: customer, errors: customerErrors} = cleanCustomer(body.customer || body, {askPhone: s.askPhone, phoneRequired: s.phoneRequired});
690
+ const {data: answers, errors: answerErrors} = registry.answers(service, body.answers || {});
691
+ const errors = {...customerErrors, ...Object.fromEntries(Object.entries(answerErrors).map(([k, v]) => [`answers.${k}`, v]))};
692
+ if (Object.keys(errors).length) return fail(reply, 400, Object.values(errors)[0], {errors});
693
+ // The time must be one the page would offer: in the service's range, on its steps, in opening hours.
694
+ const startAt = new Date(String(body.startAt || ''));
695
+ if (Number.isNaN(startAt.getTime())) return fail(reply, 400, 'Pick a time.');
696
+ const day = wallClock(startAt, s.timeZone).date;
697
+ const r = await freeTimes({serviceId: service.id, withId: String(body.bookableId || ''), from: day, to: day}).catch(() => null);
698
+ const offered = r?.days?.[0]?.slots?.find(x => x.start === startAt.toISOString() && !x.full);
699
+ if (!offered) return fail(reply, 409, 'That time is no longer free. Please pick another.', {code: 'slot-taken'});
700
+ const refused = await registry.check({service, customer, startAt: offered.start, source: 'public', bookings: await allBookings(), settings: s});
701
+ if (refused) return fail(reply, 409, refused, {code: 'refused'});
702
+ try {
703
+ const b = await placeBooking({
704
+ service, rules: r.rules, candidates: candidates.filter(c => offered.bookableIds.includes(c.id)), startAt: offered.start, tz: s.timeZone,
705
+ fields: {...customer, source: 'public', by: customer.name, ext: Object.keys(answers).length ? {answers} : {}}
706
+ });
707
+ if (s.addToContacts) {
708
+ const bk = candidates.find(c => c.id === b.bookableId);
709
+ const id = await linkContact(customer, bk?.userId || s.contactsOwnerId, contactsOn()).catch(() => '');
710
+ if (id) Object.assign(b, (await changeBooking(b.id, {contactId: id})).booking);
711
+ }
712
+ siteBase(request);
713
+ tell(b, 'made', {request});
714
+ notice(b, 'made');
715
+ registry.emit('made', b);
716
+ return reply.code(201).send({ok: true, booking: publicBooking(b, s), manage: `${s.bookingPath}/manage/${b.id}?t=${b.token}`});
717
+ } catch (err) {
718
+ if (err instanceof SlotTaken) return fail(reply, 409, err.message, {code: 'slot-taken'});
719
+ throw err;
720
+ }
721
+ });
722
+
723
+ /** The customer's booking, when the token is right; null otherwise (a 404 either way). */
724
+ async function customersBooking(id, t) {
725
+ const b = await getBooking(String(id));
726
+ return b && tokenOk(b, String(t || '')) ? b : null;
727
+ }
728
+
729
+ fastify.get('/public/booking/:id', async (request, reply) => {
730
+ const b = await customersBooking(request.params.id, request.query?.t);
731
+ if (!b) return fail(reply, 404, 'No such booking.');
732
+ return publicBooking(b, await liveSettings());
733
+ });
734
+
735
+ fastify.get('/public/booking/:id/ics', async (request, reply) => {
736
+ const b = await customersBooking(request.params.id, request.query?.t);
737
+ if (!b) return fail(reply, 404, 'No such booking.');
738
+ const host = (() => { try { return new URL(siteBase(request)).host; } catch { return 'booking'; } })();
739
+ const ics = icsFor(b, {host, title: `${b.serviceName} - ${siteTitle()}`, cancel: b.status === 'cancelled', sequence: (b.history || []).length});
740
+ return reply.type('text/calendar; charset=utf-8').header('Content-Disposition', 'attachment; filename="booking.ics"').send(ics);
741
+ });
742
+
743
+ fastify.post('/public/booking/:id/cancel', {config: {rateLimit: {max: 10, timeWindow: '10 minutes'}}}, async (request, reply) => {
744
+ const s = await liveSettings();
745
+ const b = await customersBooking(request.params.id, request.body?.t);
746
+ if (!b) return fail(reply, 404, 'No such booking.');
747
+ if (!s.allowCancel || !customerMayChange(b, s)) return fail(reply, 403, s.changeNoticeHours
748
+ ? `Bookings can be cancelled here up to ${s.changeNoticeHours} hours before. Please contact us instead.`
749
+ : 'This booking cannot be cancelled here. Please contact us instead.');
750
+ const {booking: done} = await changeBooking(b.id, {status: 'cancelled', reason: String(request.body?.reason || '').slice(0, 300)}, b.name);
751
+ siteBase(request);
752
+ tell(done, 'cancelled', {request});
753
+ notice(done, 'cancelled');
754
+ registry.emit('cancelled', done, 'confirmed');
755
+ endCall(done).catch(() => {});
756
+ return publicBooking(done, s);
757
+ });
758
+
759
+ fastify.post('/public/booking/:id/move', {config: {rateLimit: {max: 10, timeWindow: '10 minutes'}}}, async (request, reply) => {
760
+ const s = await liveSettings();
761
+ const b = await customersBooking(request.params.id, request.body?.t);
762
+ if (!b) return fail(reply, 404, 'No such booking.');
763
+ if (!s.allowMove || !customerMayChange(b, s)) return fail(reply, 403, s.changeNoticeHours
764
+ ? `Bookings can be moved here up to ${s.changeNoticeHours} hours before. Please contact us instead.`
765
+ : 'This booking cannot be moved here. Please contact us instead.');
766
+ const startAt = new Date(String(request.body?.startAt || ''));
767
+ if (Number.isNaN(startAt.getTime())) return fail(reply, 400, 'Pick a new time.');
768
+ // The same person or room: a customer moves their time, not who they see.
769
+ const r = await freeTimes({serviceId: b.serviceId, withId: b.bookableId, from: wallClock(startAt, s.timeZone).date, to: wallClock(startAt, s.timeZone).date, ignoreId: b.id});
770
+ const offered = r?.days?.[0]?.slots?.find(x => x.start === startAt.toISOString() && !x.full);
771
+ if (!offered) return fail(reply, 409, 'That time is no longer free. Please pick another.', {code: 'slot-taken'});
772
+ const {service, candidates, rules} = await candidatesFor(b.serviceId, b.bookableId);
773
+ try {
774
+ const moved = await placeBooking({service, candidates, rules, startAt: offered.start, tz: s.timeZone, moveId: b.id, fields: {by: b.name}});
775
+ siteBase(request);
776
+ tell(moved, 'moved', {request});
777
+ notice(moved, 'moved');
778
+ registry.emit('moved', moved, b);
779
+ endCall(b).catch(() => {});
780
+ return publicBooking(moved, s);
781
+ } catch (err) {
782
+ if (err instanceof SlotTaken) return fail(reply, 409, err.message, {code: 'slot-taken'});
783
+ throw err;
784
+ }
785
+ });
786
+
787
+ // ---- Online bookings: the call ------------------------------------------------------
788
+ // The engine is core's (_lib/call); an older core without it simply has no calls.
789
+ try {
790
+ const {mountCallRoutes} = await import('../_lib/call/routes.js');
791
+ calls = mountCallRoutes(fastify, {prefix: '/call', ns: 'booking', resolve: async (request, room, body) => {
792
+ const b = await getBooking(String(body?.b || '')).catch(() => null);
793
+ // A class opened early ("Call now") is open for everyone in the session - someone who booked in
794
+ // after the click too: take the session's latest opening when this booking has none of its own.
795
+ if (b && b.online === true && !b.callOpenedAt) {
796
+ const room0 = sessionOf(b);
797
+ const opened = (await allBookings()).filter(x => x.id !== b.id && x.online === true && x.callOpenedAt && sessionOf(x) === room0)
798
+ .map(x => x.callOpenedAt).sort().at(-1);
799
+ if (opened) b.callOpenedAt = opened;
800
+ }
801
+ return whoIsCalling(b, room, {t: body?.t, h: body?.h});
802
+ }});
803
+ } catch (err) {
804
+ log.warn?.(`[booking] online calls unavailable on this CMS: ${err?.message || err}`);
805
+ }
806
+ }