domma-cms 0.102.0 → 0.103.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.
- package/package.json +1 -1
- package/plugins/_lib/admin/mail/message-actions.js +56 -0
- package/plugins/_lib/admin/mail/reader-view.js +30 -1
- package/plugins/_lib/call/ice.js +58 -0
- package/plugins/_lib/call/rooms.js +232 -0
- package/plugins/_lib/call/routes.js +88 -0
- package/plugins/_lib/public/call/call.css +1 -0
- package/plugins/_lib/public/call/call.js +468 -0
- package/plugins/blog/lib/layouts.js +4 -2
- package/plugins/blog/plugin.json +1 -1
- package/plugins/blog/tests/layouts.test.js +6 -0
- package/plugins/booking/CLAUDE.md +123 -0
- package/plugins/booking/admin/css/index.css +192 -0
- package/plugins/booking/admin/lib/details.js +58 -0
- package/plugins/booking/admin/lib/diary-layout.js +140 -0
- package/plugins/booking/admin/lib/extension-sections.js +160 -0
- package/plugins/booking/admin/lib/kit.js +249 -0
- package/plugins/booking/admin/templates/booking.html +1 -0
- package/plugins/booking/admin/views/booking.js +177 -0
- package/plugins/booking/admin/views/bookings.js +127 -0
- package/plugins/booking/admin/views/diary.js +374 -0
- package/plugins/booking/admin/views/panels.js +516 -0
- package/plugins/booking/admin/views/people.js +281 -0
- package/plugins/booking/admin/views/services.js +222 -0
- package/plugins/booking/admin/views/settings.js +203 -0
- package/plugins/booking/collections/booking-bookables.json +17 -0
- package/plugins/booking/collections/booking-bookings.json +18 -0
- package/plugins/booking/collections/booking-services.json +17 -0
- package/plugins/booking/config.js +36 -0
- package/plugins/booking/docs/guide.md +69 -0
- package/plugins/booking/inject/body.html +1 -0
- package/plugins/booking/lib/call.js +58 -0
- package/plugins/booking/lib/extensions.js +150 -0
- package/plugins/booking/lib/mail.js +113 -0
- package/plugins/booking/lib/store.js +384 -0
- package/plugins/booking/plugin.js +760 -0
- package/plugins/booking/plugin.json +80 -0
- package/plugins/booking/plugin.public.js +157 -0
- package/plugins/booking/public/booking.css +159 -0
- package/plugins/booking/public/booking.js +808 -0
- package/plugins/booking/public/lib/clean.js +141 -0
- package/plugins/booking/public/lib/page-format.js +121 -0
- package/plugins/booking/public/lib/slots.js +247 -0
- package/plugins/booking/public/lib/time.js +131 -0
- package/plugins/booking/templates/booking.html +5 -0
- package/plugins/booking/tests/admin-diary.test.js +73 -0
- package/plugins/booking/tests/api.test.js +356 -0
- package/plugins/booking/tests/public.test.js +71 -0
- package/plugins/booking/tests/pure.test.js +189 -0
- package/plugins/free-tier.lock.json +48 -5
- package/server/routes/api/related.js +20 -5
- package/server/server.js +3 -3
- package/server/services/hooks.js +35 -8
- package/server/services/markdown.js +10 -12
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Booking's emails: one way out, and never a pretend one.
|
|
3
|
+
*
|
|
4
|
+
* Without an SMTP host the CMS falls back to Ethereal and "succeeds" while
|
|
5
|
+
* delivering nothing, so with no SMTP these are simply not sent (`mailReady()`
|
|
6
|
+
* says so, and the settings screen shows it). Copied from Waypoint Pro's
|
|
7
|
+
* lib/mail.js - plugins cannot import each other.
|
|
8
|
+
*
|
|
9
|
+
* Tests swap the sender with `setMailer()`.
|
|
10
|
+
*
|
|
11
|
+
* @module booking/lib/mail
|
|
12
|
+
*/
|
|
13
|
+
import {createTransport, escapeHtml, isSmtpConfigured, sendEmail} from '../../../server/services/email.js';
|
|
14
|
+
import {getConfig} from '../../../server/config.js';
|
|
15
|
+
|
|
16
|
+
let override = null;
|
|
17
|
+
let transport = null;
|
|
18
|
+
let transportFor = '';
|
|
19
|
+
|
|
20
|
+
/** Tests only: `fn(msg)` receives every message instead. Pass null to restore. */
|
|
21
|
+
export function setMailer(fn) { override = fn; }
|
|
22
|
+
|
|
23
|
+
const site = () => { try { return getConfig('site') || {}; } catch { return {}; } };
|
|
24
|
+
|
|
25
|
+
/** Can mail really leave this site? */
|
|
26
|
+
export function mailReady() {
|
|
27
|
+
return Boolean(override) || isSmtpConfigured(site().smtp || {});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The site's own address, for mail that has nobody better to go to. */
|
|
31
|
+
export function siteAddress() {
|
|
32
|
+
const s = site();
|
|
33
|
+
return s.smtp?.fromAddress || s.contactEmail || '';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const siteTitle = () => site().title || 'Our site';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Send one message. Returns false (and logs) rather than throwing: a lost
|
|
40
|
+
* email must never fail the booking that caused it, and callers do not await.
|
|
41
|
+
*
|
|
42
|
+
* @param {{to: string, subject: string, html: string, text: string, attachments?: object[], replyTo?: string}} msg
|
|
43
|
+
*/
|
|
44
|
+
export async function send(msg, {log} = {}) {
|
|
45
|
+
try {
|
|
46
|
+
if (!msg?.to) return false;
|
|
47
|
+
if (override) { await override(msg); return true; }
|
|
48
|
+
const s = site();
|
|
49
|
+
const smtp = s.smtp || {};
|
|
50
|
+
if (!isSmtpConfigured(smtp)) return false;
|
|
51
|
+
const key = JSON.stringify([smtp.host, smtp.port, smtp.user, smtp.secure]);
|
|
52
|
+
if (!transport || transportFor !== key) { transport = await createTransport(smtp); transportFor = key; }
|
|
53
|
+
await sendEmail(transport, {from: smtp.fromAddress || 'noreply@example.com', fromName: smtp.fromName || s.title || 'Bookings', ...msg});
|
|
54
|
+
return true;
|
|
55
|
+
} catch (err) {
|
|
56
|
+
const line = `[booking] Email to ${msg?.to} not sent: ${err?.message || err}`;
|
|
57
|
+
try { if (typeof log?.error === 'function') log.error(line); else console.error(line); } catch { /* never */ }
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A short message: paragraphs, a table of details and an optional button. Everything is escaped.
|
|
64
|
+
*
|
|
65
|
+
* @param {{subject: string, lines?: string[], details?: [string, string][], link?: string, linkText?: string, after?: string[]}} m
|
|
66
|
+
*/
|
|
67
|
+
export function compose({subject, lines = [], details = [], link = '', linkText = 'Open', after = []}) {
|
|
68
|
+
const rows = details.filter(([, v]) => v).map(([k, v]) =>
|
|
69
|
+
`<tr><td style="padding:4px 12px 4px 0;color:#666;vertical-align:top">${escapeHtml(k)}</td><td style="padding:4px 0">${escapeHtml(v)}</td></tr>`).join('');
|
|
70
|
+
const html = [
|
|
71
|
+
...lines.map(l => `<p>${escapeHtml(l)}</p>`),
|
|
72
|
+
rows ? `<table style="border-collapse:collapse;margin:8px 0 16px">${rows}</table>` : '',
|
|
73
|
+
link ? `<p><a href="${escapeHtml(link)}" style="display:inline-block;padding:10px 18px;background:#2563eb;color:#fff;border-radius:6px;text-decoration:none">${escapeHtml(linkText)}</a></p>
|
|
74
|
+
<p style="color:#666;font-size:13px">Or paste this into your browser: ${escapeHtml(link)}</p>` : '',
|
|
75
|
+
...after.map(l => `<p style="color:#444">${escapeHtml(l)}</p>`)
|
|
76
|
+
].join('\n');
|
|
77
|
+
const text = [
|
|
78
|
+
...lines,
|
|
79
|
+
details.filter(([, v]) => v).map(([k, v]) => `${k}: ${v}`).join('\n'),
|
|
80
|
+
link ? `${linkText}: ${link}` : '',
|
|
81
|
+
...after
|
|
82
|
+
].filter(Boolean).join('\n\n');
|
|
83
|
+
return {subject, html, text};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** RFC 5545 text: escape \ ; , and newlines; fold at 75 octets (approximated by characters). */
|
|
87
|
+
const icsText = (s) => String(s ?? '').replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\r?\n/g, '\\n');
|
|
88
|
+
const icsTime = (iso) => new Date(iso).toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
|
89
|
+
const fold = (line) => line.length <= 74 ? line : line.match(/.{1,73}/g).join('\r\n ');
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* An .ics for one booking - METHOD:REQUEST adds it, METHOD:CANCEL takes it off.
|
|
93
|
+
* The UID is stable per booking, so a move replaces the entry in the customer's calendar.
|
|
94
|
+
*/
|
|
95
|
+
export function icsFor(booking, {host = 'booking', title, location = '', description = '', cancel = false, sequence = 0}) {
|
|
96
|
+
const lines = [
|
|
97
|
+
'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Domma CMS//Booking//EN', `METHOD:${cancel ? 'CANCEL' : 'REQUEST'}`,
|
|
98
|
+
'BEGIN:VEVENT',
|
|
99
|
+
`UID:booking-${booking.id}@${host}`,
|
|
100
|
+
`SEQUENCE:${sequence}`,
|
|
101
|
+
`DTSTAMP:${icsTime(new Date().toISOString())}`,
|
|
102
|
+
`DTSTART:${icsTime(booking.startAt)}`,
|
|
103
|
+
`DTEND:${icsTime(booking.endAt)}`,
|
|
104
|
+
`SUMMARY:${icsText(title)}`,
|
|
105
|
+
location ? `LOCATION:${icsText(location)}` : '',
|
|
106
|
+
description ? `DESCRIPTION:${icsText(description)}` : '',
|
|
107
|
+
`STATUS:${cancel ? 'CANCELLED' : 'CONFIRMED'}`,
|
|
108
|
+
'END:VEVENT', 'END:VCALENDAR'
|
|
109
|
+
].filter(Boolean).map(fold);
|
|
110
|
+
return lines.join('\r\n') + '\r\n';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export {escapeHtml};
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Booking's data: the three collections, the live settings, and the moves a
|
|
3
|
+
* booking makes (made, moved, cancelled, marked done or no-show).
|
|
4
|
+
*
|
|
5
|
+
* Every write to a booking goes through `withLock`, one at a time, and
|
|
6
|
+
* re-checks the slot inside it. Two people choosing the same time in the same
|
|
7
|
+
* second: the second finds the first's booking and is told the time has gone.
|
|
8
|
+
*
|
|
9
|
+
* @module booking/lib/store
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {randomBytes} from 'crypto';
|
|
13
|
+
import {createCollection, createEntry, deleteEntry, getCollection, getEntry, listEntries, updateEntry} from '../../../server/services/collections.js';
|
|
14
|
+
import {getPluginSettings, getPluginStates, savePluginState} from '../../../server/services/plugins.js';
|
|
15
|
+
import {cleanDaysOff, EMAIL_RE, STATUSES} from '../public/lib/clean.js';
|
|
16
|
+
import {PLAIN, pickBookable, spanOf, whoCanTake} from '../public/lib/slots.js';
|
|
17
|
+
import {atWallClock, cleanZone, isDate, wallClock} from '../public/lib/time.js';
|
|
18
|
+
import defaults from '../config.js';
|
|
19
|
+
import {joinPath} from './call.js';
|
|
20
|
+
|
|
21
|
+
export const NAME = 'booking';
|
|
22
|
+
export const BOOKABLES = 'booking-bookables';
|
|
23
|
+
export const SERVICES = 'booking-services';
|
|
24
|
+
export const BOOKINGS = 'booking-bookings';
|
|
25
|
+
|
|
26
|
+
// ---- Setup ---------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
/** Make the collections that are missing. Every boot: a plugin switched on by copy never has onEnable run. */
|
|
29
|
+
export async function ensureCollections(log = console) {
|
|
30
|
+
const fs = await import('fs/promises');
|
|
31
|
+
for (const slug of [BOOKABLES, SERVICES, BOOKINGS]) {
|
|
32
|
+
if (await getCollection(slug)) continue;
|
|
33
|
+
const schema = JSON.parse(await fs.readFile(new URL(`../collections/${slug}.json`, import.meta.url), 'utf8'));
|
|
34
|
+
await createCollection(schema);
|
|
35
|
+
log.info?.(`[booking] Created collection: ${slug}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ---- Settings ------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
const RESERVED = /^\/(admin|api|plugins|dist|media|assets|login|shop|blog|calendar)(\/|$)/;
|
|
42
|
+
|
|
43
|
+
/** "/booking" from "booking/", "/Booking//" - one leading slash, no trailing one, never an engine address. */
|
|
44
|
+
export function cleanPath(p) {
|
|
45
|
+
const t = '/' + String(p || '/booking').toLowerCase().replace(/[^a-z0-9/_-]/g, '').replace(/\/+/g, '/').replace(/^\/|\/$/g, '');
|
|
46
|
+
return t === '/' || RESERVED.test(t) ? '/booking' : t;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const withDefaults = (s) => ({...defaults, ...s, bookingPath: cleanPath(s.bookingPath), timeZone: cleanZone(s.timeZone)});
|
|
50
|
+
|
|
51
|
+
/** The settings as they are NOW - read per request, so a change needs no restart. */
|
|
52
|
+
export async function liveSettings() {
|
|
53
|
+
return withDefaults(await getPluginSettings(NAME) || {});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The same, synchronously - for shortcodes, which cannot wait. */
|
|
57
|
+
export function settingsNow() {
|
|
58
|
+
return withDefaults(getPluginStates()[NAME]?.settings || {});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** @returns {{settings: object, errors: Object<string, string>}} */
|
|
62
|
+
export function cleanSettings(input, current) {
|
|
63
|
+
const errors = {};
|
|
64
|
+
const v = {...current, ...input};
|
|
65
|
+
const str = (k, max = 300) => String(v[k] ?? '').trim().slice(0, max);
|
|
66
|
+
const out = {
|
|
67
|
+
pageTitle: str('pageTitle', 80) || 'Book',
|
|
68
|
+
bookingPath: cleanPath(v.bookingPath),
|
|
69
|
+
intro: str('intro', 2000),
|
|
70
|
+
timeZone: cleanZone(v.timeZone),
|
|
71
|
+
askPhone: v.askPhone !== false,
|
|
72
|
+
phoneRequired: v.phoneRequired === true,
|
|
73
|
+
termsUrl: str('termsUrl'),
|
|
74
|
+
confirmationNote: str('confirmationNote', 2000),
|
|
75
|
+
allowCancel: v.allowCancel !== false,
|
|
76
|
+
allowMove: v.allowMove !== false,
|
|
77
|
+
changeNoticeHours: Math.max(0, Math.min(24 * 30, Math.round(Number(v.changeNoticeHours) || 0))),
|
|
78
|
+
sendConfirmation: v.sendConfirmation !== false,
|
|
79
|
+
reminderHours: Math.max(0, Math.min(24 * 14, Math.round(Number(v.reminderHours) || 0))),
|
|
80
|
+
notifyAddress: str('notifyAddress', 200),
|
|
81
|
+
addToContacts: v.addToContacts !== false,
|
|
82
|
+
contactsOwnerId: str('contactsOwnerId', 64),
|
|
83
|
+
calendarBusy: v.calendarBusy !== false,
|
|
84
|
+
closures: cleanDaysOff(v.closures)
|
|
85
|
+
};
|
|
86
|
+
if (input.timeZone !== undefined && cleanZone(input.timeZone) !== String(input.timeZone).trim()) errors.timeZone = 'A time zone such as Europe/London.';
|
|
87
|
+
if (out.termsUrl && !/^(\/(?!\/)|https?:\/\/)/.test(out.termsUrl)) errors.termsUrl = 'A page on this site (/terms) or a full https:// address.';
|
|
88
|
+
if (out.notifyAddress && !EMAIL_RE.test(out.notifyAddress)) errors.notifyAddress = 'An email address, or leave it empty.';
|
|
89
|
+
if (input.bookingPath !== undefined && cleanPath(input.bookingPath) !== '/' + String(input.bookingPath).trim().replace(/^\/+|\/+$/g, '').toLowerCase()) {
|
|
90
|
+
errors.bookingPath = 'Letters, numbers and hyphens, and not an address the CMS uses (admin, api, shop, blog...).';
|
|
91
|
+
}
|
|
92
|
+
return {settings: out, errors};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function saveSettings(next) {
|
|
96
|
+
const state = getPluginStates()[NAME] || {};
|
|
97
|
+
savePluginState(NAME, {settings: {...(state.settings || {}), ...next}});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---- Reading -------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
const all = async (slug) => (await listEntries(slug, {limit: 100000}).catch(() => ({entries: []}))).entries || [];
|
|
103
|
+
const flat = (e) => (e ? {id: e.id, ...e.data, createdAt: e.meta?.createdAt, updatedAt: e.meta?.updatedAt} : null);
|
|
104
|
+
const byOrder = (a, b) => (a.order || 0) - (b.order || 0) || String(a.name).localeCompare(String(b.name));
|
|
105
|
+
|
|
106
|
+
export const allBookables = async () => (await all(BOOKABLES)).map(flat).sort(byOrder);
|
|
107
|
+
export const allServices = async () => (await all(SERVICES)).map(flat).sort(byOrder);
|
|
108
|
+
export const allBookings = async () => (await all(BOOKINGS)).map(flat);
|
|
109
|
+
export const getBooking = async (id) => flat(await getEntry(BOOKINGS, id).catch(() => null));
|
|
110
|
+
export const getService = async (id) => flat(await getEntry(SERVICES, id).catch(() => null));
|
|
111
|
+
export const getBookable = async (id) => flat(await getEntry(BOOKABLES, id).catch(() => null));
|
|
112
|
+
|
|
113
|
+
/** The snapshot shortcodes render from (they are synchronous). */
|
|
114
|
+
let shelf = {services: [], bookables: []};
|
|
115
|
+
export async function refreshShelf() {
|
|
116
|
+
shelf = {services: await allServices(), bookables: await allBookables()};
|
|
117
|
+
return shelf;
|
|
118
|
+
}
|
|
119
|
+
export const shelfNow = () => shelf;
|
|
120
|
+
|
|
121
|
+
// ---- Writing: services and bookables -------------------------------------------
|
|
122
|
+
|
|
123
|
+
export async function saveThing(slug, id, data) {
|
|
124
|
+
const saved = id ? await updateEntry(slug, id, data) : await createEntry(slug, data);
|
|
125
|
+
await refreshShelf();
|
|
126
|
+
return flat(saved);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function removeThing(slug, id) {
|
|
130
|
+
await deleteEntry(slug, id);
|
|
131
|
+
await refreshShelf();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** A service's slug, unique among services. */
|
|
135
|
+
export function uniqueSlug(slug, services, selfId = '') {
|
|
136
|
+
// 'manage' is the customer's own page (<base>/manage/<id>), never a service.
|
|
137
|
+
const taken = new Set(['manage', ...services.filter(s => s.id !== selfId).map(s => s.slug)]);
|
|
138
|
+
if (!taken.has(slug)) return slug;
|
|
139
|
+
for (let n = 2; ; n++) if (!taken.has(`${slug}-${n}`)) return `${slug}-${n}`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---- The lock ------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
let queue = Promise.resolve();
|
|
145
|
+
/** Run `fn` after every write before it has finished. */
|
|
146
|
+
export function withLock(fn) {
|
|
147
|
+
const run = queue.then(fn, fn);
|
|
148
|
+
queue = run.catch(() => {});
|
|
149
|
+
return run;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---- Busy time from the Calendar -----------------------------------------------
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* A person's commitments in the Calendar, when the Calendar is here: the
|
|
156
|
+
* active occurrences of events they organise. Read through the collections
|
|
157
|
+
* service (no import of the Calendar's code - it is a separate, paid Tool).
|
|
158
|
+
*
|
|
159
|
+
* An all-day event is stored as UTC midnights of its dates (end exclusive);
|
|
160
|
+
* it blocks the business's own midnight to midnight, not UTC's.
|
|
161
|
+
*
|
|
162
|
+
* @param {object[]} bookables
|
|
163
|
+
* @param {{from: string, to: string}} range - ISO instants
|
|
164
|
+
* @param {string} [tz] - the business zone
|
|
165
|
+
* @returns {Promise<Object<string, {startAt: string, endAt: string}[]>>}
|
|
166
|
+
*/
|
|
167
|
+
export async function calendarBusy(bookables, {from, to}, tz = 'Europe/London') {
|
|
168
|
+
const out = {};
|
|
169
|
+
const people = bookables.filter(b => b.kind === 'person' && b.userId && b.calendarBusy !== false);
|
|
170
|
+
if (!people.length || !getPluginStates().calendar?.enabled) return out;
|
|
171
|
+
const [events, occurrences] = await Promise.all([all('calendar-events'), all('calendar-occurrences')]);
|
|
172
|
+
const organiser = new Map(events.map(e => [e.id, e.data?.organiserId]));
|
|
173
|
+
for (const bk of people) out[bk.id] = [];
|
|
174
|
+
for (const o of occurrences) {
|
|
175
|
+
const d = o.data || {};
|
|
176
|
+
if (d.status && d.status !== 'active') continue;
|
|
177
|
+
if (!d.startAt) continue;
|
|
178
|
+
let startAt = new Date(d.startAt).toISOString();
|
|
179
|
+
let endAt = new Date(d.endAt || d.startAt).toISOString();
|
|
180
|
+
if (d.allDay) {
|
|
181
|
+
const first = startAt.slice(0, 10);
|
|
182
|
+
const end = endAt.slice(0, 10) > first ? endAt.slice(0, 10) : first;
|
|
183
|
+
startAt = atWallClock(first, 0, tz).toISOString();
|
|
184
|
+
endAt = atWallClock(end === first ? addDay(first) : end, 0, tz).toISOString();
|
|
185
|
+
}
|
|
186
|
+
if (startAt >= to || endAt <= from) continue;
|
|
187
|
+
const who = organiser.get(d.eventId);
|
|
188
|
+
for (const bk of people) if (bk.userId === who) out[bk.id].push({startAt, endAt});
|
|
189
|
+
}
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Calendar busy time when the site's setting wants it, else none. */
|
|
194
|
+
export async function settingsBusy(bookables, range, tz) {
|
|
195
|
+
const s = await liveSettings();
|
|
196
|
+
return s.calendarBusy ? calendarBusy(bookables, range, tz) : {};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const addDay = (date) => new Date(Date.parse(`${date}T00:00:00Z`) + 86400000).toISOString().slice(0, 10);
|
|
200
|
+
|
|
201
|
+
// ---- Bookings ------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
export const newToken = () => randomBytes(18).toString('base64url');
|
|
204
|
+
|
|
205
|
+
/** Is this the booking's own link? Constant-time is overkill for 24 random chars; equality of type and value is not. */
|
|
206
|
+
export const tokenOk = (booking, t) => Boolean(booking?.token) && typeof t === 'string' && t === booking.token;
|
|
207
|
+
|
|
208
|
+
/** Why a booking cannot be made at a time, or '' when it can. */
|
|
209
|
+
export class SlotTaken extends Error {
|
|
210
|
+
constructor(message = 'That time has just been taken. Please pick another.') { super(message); this.code = 'slot-taken'; }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Make (or move) a booking, inside the lock, after checking the time again.
|
|
215
|
+
*
|
|
216
|
+
* @param {object} p
|
|
217
|
+
* @param {object} p.service
|
|
218
|
+
* @param {object[]} p.candidates - bookables that may take it (one when chosen)
|
|
219
|
+
* @param {string} p.startAt - ISO
|
|
220
|
+
* @param {object} p.fields - everything else stored on the booking
|
|
221
|
+
* @param {string} [p.moveId] - move this booking instead of making one
|
|
222
|
+
* @param {boolean} [p.force] - staff booking over a clash (admin only)
|
|
223
|
+
* @param {string} p.tz
|
|
224
|
+
* @param {Function} [p.busyFor] - (bookables, range) => busy map
|
|
225
|
+
* @param {object} [p.rules] - capacity, companions, maxPerDay (extensions'; plain without)
|
|
226
|
+
* @returns {Promise<object>} the booking
|
|
227
|
+
*/
|
|
228
|
+
export function placeBooking({service, candidates, startAt, fields, moveId = '', force = false, tz, busyFor = settingsBusy, rules = PLAIN}) {
|
|
229
|
+
return withLock(async () => {
|
|
230
|
+
const span = spanOf(service, startAt);
|
|
231
|
+
const bookings = await allBookings();
|
|
232
|
+
const range = {from: new Date(span.blockStart).toISOString(), to: new Date(span.blockEnd).toISOString()};
|
|
233
|
+
const companions = rules.companions || [];
|
|
234
|
+
const everyone = [...candidates, ...companions.flat()];
|
|
235
|
+
const busy = force ? {} : await busyFor(everyone, range, tz).catch(() => ({}));
|
|
236
|
+
const dayOf = (ms) => wallClock(ms, tz).date;
|
|
237
|
+
let able = whoCanTake({service, candidates, companions, span, bookings, busy, ignoreId: moveId, rules, dayOf});
|
|
238
|
+
if (!able.length && force) {
|
|
239
|
+
// Staff booking over a clash: the chosen (or first) provider, and the first of each companion group.
|
|
240
|
+
able = candidates.slice(0, 1).map(bk => ({bookableId: bk.id, holds: [bk.id, ...companions.map(g => g.find(c => c.id !== bk.id)?.id).filter(Boolean)], left: 1}));
|
|
241
|
+
}
|
|
242
|
+
if (!able.length) throw new SlotTaken();
|
|
243
|
+
const date = dayOf(span.start);
|
|
244
|
+
const bookableId = pickBookable(able.map(a => a.bookableId), bookings.filter(b => b.id !== moveId), date, tz, {serviceId: service.id, start: span.start});
|
|
245
|
+
const chosen = able.find(a => a.bookableId === bookableId);
|
|
246
|
+
const byId = new Map(everyone.map(b => [b.id, b]));
|
|
247
|
+
const times = {
|
|
248
|
+
startAt: new Date(span.start).toISOString(),
|
|
249
|
+
endAt: new Date(span.end).toISOString(),
|
|
250
|
+
blockStart: new Date(span.blockStart).toISOString(),
|
|
251
|
+
blockEnd: new Date(span.blockEnd).toISOString(),
|
|
252
|
+
bookableId,
|
|
253
|
+
bookableName: byId.get(bookableId)?.name || '',
|
|
254
|
+
holds: chosen.holds,
|
|
255
|
+
// What it took along (a court with the trainer), by name, for the diary and the emails.
|
|
256
|
+
alsoNames: chosen.holds.slice(1).map(id => byId.get(id)?.name || '').filter(Boolean),
|
|
257
|
+
reminderSentAt: ''
|
|
258
|
+
};
|
|
259
|
+
if (moveId) {
|
|
260
|
+
const current = await getEntry(BOOKINGS, moveId);
|
|
261
|
+
if (!current) throw new Error('That booking no longer exists.');
|
|
262
|
+
const history = [...(current.data.history || []), {at: new Date().toISOString(), what: 'moved', from: current.data.startAt, by: fields.by || ''}].slice(-50);
|
|
263
|
+
return flat(await updateEntry(BOOKINGS, moveId, {...current.data, ...times, history}));
|
|
264
|
+
}
|
|
265
|
+
const {by, ...rest} = fields;
|
|
266
|
+
return flat(await createEntry(BOOKINGS, {
|
|
267
|
+
serviceId: service.id,
|
|
268
|
+
serviceName: service.name,
|
|
269
|
+
duration: service.duration,
|
|
270
|
+
status: 'confirmed',
|
|
271
|
+
token: newToken(),
|
|
272
|
+
...rest,
|
|
273
|
+
...times,
|
|
274
|
+
history: [{at: new Date().toISOString(), what: 'made', by: by || ''}],
|
|
275
|
+
ext: rest.ext && typeof rest.ext === 'object' ? rest.ext : {},
|
|
276
|
+
// Held as a video call (Pro said so when it was made). Kept on the booking, so the call still
|
|
277
|
+
// happens if Pro lapses; the host key is the person booked's link and never reaches the customer.
|
|
278
|
+
...(rules.online ? {online: true, hostKey: newToken(), recordingAllowed: rules.recordingAllowed === true} : {})
|
|
279
|
+
}));
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Change a booking's status or note (no time change), inside the lock.
|
|
285
|
+
*
|
|
286
|
+
* @returns {Promise<{booking: object, from: string}>}
|
|
287
|
+
*/
|
|
288
|
+
export function changeBooking(id, patch, by = '') {
|
|
289
|
+
return withLock(async () => {
|
|
290
|
+
const current = await getEntry(BOOKINGS, id);
|
|
291
|
+
if (!current) return null;
|
|
292
|
+
const data = {...current.data};
|
|
293
|
+
const from = data.status;
|
|
294
|
+
if (patch.status !== undefined) {
|
|
295
|
+
if (!STATUSES.includes(patch.status)) throw new Error('Not a status a booking can have.');
|
|
296
|
+
// Its time may be someone else's by now: bringing it back would be a double booking nobody checked.
|
|
297
|
+
if (from === 'cancelled' && patch.status !== 'cancelled') throw new Error('A cancelled booking cannot be brought back. Make a new booking for that time instead.');
|
|
298
|
+
data.status = patch.status;
|
|
299
|
+
if (patch.status !== from) {
|
|
300
|
+
data.history = [...(data.history || []), {at: new Date().toISOString(), what: patch.status, by, reason: String(patch.reason || '').slice(0, 300)}].slice(-50);
|
|
301
|
+
if (patch.status === 'cancelled') data.cancelledAt = new Date().toISOString();
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (patch.staffNote !== undefined) data.staffNote = String(patch.staffNote).slice(0, 4000);
|
|
305
|
+
if (patch.customer) {
|
|
306
|
+
for (const k of ['name', 'email', 'phone', 'notes']) if (patch.customer[k] !== undefined) data[k] = patch.customer[k];
|
|
307
|
+
}
|
|
308
|
+
if (patch.reminderSentAt !== undefined) data.reminderSentAt = patch.reminderSentAt;
|
|
309
|
+
if (patch.contactId !== undefined) data.contactId = patch.contactId;
|
|
310
|
+
if (patch.ext && typeof patch.ext === 'object') data.ext = {...(data.ext || {}), ...patch.ext};
|
|
311
|
+
return {booking: flat(await updateEntry(BOOKINGS, id, data)), from};
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export async function removeBooking(id) {
|
|
316
|
+
return withLock(() => deleteEntry(BOOKINGS, id));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** May the customer still change it themselves? */
|
|
320
|
+
export function customerMayChange(booking, settings, now = Date.now()) {
|
|
321
|
+
if (booking.status !== 'confirmed') return false;
|
|
322
|
+
return new Date(booking.startAt).getTime() - now >= (Number(settings.changeNoticeHours) || 0) * 3600000;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** What the customer's own page may see of a booking. */
|
|
326
|
+
export function publicBooking(b, settings, now = Date.now()) {
|
|
327
|
+
const wall = wallClock(b.startAt, settings.timeZone);
|
|
328
|
+
const may = customerMayChange(b, settings, now);
|
|
329
|
+
return {
|
|
330
|
+
id: b.id, serviceId: b.serviceId, serviceName: b.serviceName, bookableId: b.bookableId, bookableName: b.bookableName,
|
|
331
|
+
startAt: b.startAt, endAt: b.endAt, date: wall.date, time: wall.time, status: b.status,
|
|
332
|
+
name: b.name, email: b.email, phone: b.phone, notes: b.notes,
|
|
333
|
+
canCancel: may && settings.allowCancel !== false,
|
|
334
|
+
canMove: may && settings.allowMove !== false,
|
|
335
|
+
online: b.online === true,
|
|
336
|
+
callUrl: b.online === true && b.status !== 'cancelled' ? joinPath(settings, b) : ''
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ---- Contacts ------------------------------------------------------------------
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Find or make the customer's contact in the owner's address book. Contacts
|
|
344
|
+
* are per user; the owner is the person being booked (when they are a user),
|
|
345
|
+
* else the setting, else whoever made the booking. '' when Contacts is off or
|
|
346
|
+
* nobody owns it.
|
|
347
|
+
*
|
|
348
|
+
* @returns {Promise<string>} contact id
|
|
349
|
+
*/
|
|
350
|
+
export async function linkContact({name, email, phone}, ownerId, contactsOn) {
|
|
351
|
+
if (!contactsOn || !ownerId || !email) return '';
|
|
352
|
+
const want = String(email).trim().toLowerCase();
|
|
353
|
+
const entries = await all('contacts-contacts');
|
|
354
|
+
const same = entries.find(c => c.data?.userId === ownerId && [c.data?.email, ...(Array.isArray(c.data?.emails) ? c.data.emails.map(x => x?.address) : [])]
|
|
355
|
+
.some(a => String(a || '').trim().toLowerCase() === want));
|
|
356
|
+
if (same) return same.id;
|
|
357
|
+
const made = await createEntry('contacts-contacts', {
|
|
358
|
+
name: String(name || email).trim(), email: String(email).trim(), phone: String(phone || '').trim(),
|
|
359
|
+
userId: ownerId, groups: [], favourite: false
|
|
360
|
+
});
|
|
361
|
+
return made.id;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** A contact of this user's, or null. */
|
|
365
|
+
export async function ownContact(id, userId) {
|
|
366
|
+
const e = await getEntry('contacts-contacts', id).catch(() => null);
|
|
367
|
+
if (!e || (userId && e.data?.userId !== userId)) return null;
|
|
368
|
+
return {id: e.id, name: e.data?.name || '', email: e.data?.email || '', phone: e.data?.phone || ''};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* The caller's own contact with this email (main address or any of Contacts Pro's), or null - so an email's
|
|
373
|
+
* "Book Sam…" and a typed address both find the contact they already have instead of making another.
|
|
374
|
+
*/
|
|
375
|
+
export async function contactByEmail(email, userId) {
|
|
376
|
+
const want = String(email || '').trim().toLowerCase();
|
|
377
|
+
if (!want || !userId) return null;
|
|
378
|
+
const e = (await all('contacts-contacts')).find(c => c.data?.userId === userId
|
|
379
|
+
&& [c.data?.email, ...(Array.isArray(c.data?.emails) ? c.data.emails.map(x => x?.address) : [])]
|
|
380
|
+
.some(a => String(a || '').trim().toLowerCase() === want));
|
|
381
|
+
return e ? {id: e.id, name: e.data?.name || '', email: e.data?.email || want, phone: e.data?.phone || ''} : null;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export {isDate};
|