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.
Files changed (54) 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 +232 -0
  6. package/plugins/_lib/call/routes.js +88 -0
  7. package/plugins/_lib/public/call/call.css +1 -0
  8. package/plugins/_lib/public/call/call.js +468 -0
  9. package/plugins/blog/lib/layouts.js +4 -2
  10. package/plugins/blog/plugin.json +1 -1
  11. package/plugins/blog/tests/layouts.test.js +6 -0
  12. package/plugins/booking/CLAUDE.md +123 -0
  13. package/plugins/booking/admin/css/index.css +192 -0
  14. package/plugins/booking/admin/lib/details.js +58 -0
  15. package/plugins/booking/admin/lib/diary-layout.js +140 -0
  16. package/plugins/booking/admin/lib/extension-sections.js +160 -0
  17. package/plugins/booking/admin/lib/kit.js +249 -0
  18. package/plugins/booking/admin/templates/booking.html +1 -0
  19. package/plugins/booking/admin/views/booking.js +177 -0
  20. package/plugins/booking/admin/views/bookings.js +127 -0
  21. package/plugins/booking/admin/views/diary.js +374 -0
  22. package/plugins/booking/admin/views/panels.js +516 -0
  23. package/plugins/booking/admin/views/people.js +281 -0
  24. package/plugins/booking/admin/views/services.js +222 -0
  25. package/plugins/booking/admin/views/settings.js +203 -0
  26. package/plugins/booking/collections/booking-bookables.json +17 -0
  27. package/plugins/booking/collections/booking-bookings.json +18 -0
  28. package/plugins/booking/collections/booking-services.json +17 -0
  29. package/plugins/booking/config.js +36 -0
  30. package/plugins/booking/docs/guide.md +69 -0
  31. package/plugins/booking/inject/body.html +1 -0
  32. package/plugins/booking/lib/call.js +58 -0
  33. package/plugins/booking/lib/extensions.js +150 -0
  34. package/plugins/booking/lib/mail.js +113 -0
  35. package/plugins/booking/lib/store.js +384 -0
  36. package/plugins/booking/plugin.js +760 -0
  37. package/plugins/booking/plugin.json +80 -0
  38. package/plugins/booking/plugin.public.js +157 -0
  39. package/plugins/booking/public/booking.css +159 -0
  40. package/plugins/booking/public/booking.js +808 -0
  41. package/plugins/booking/public/lib/clean.js +141 -0
  42. package/plugins/booking/public/lib/page-format.js +121 -0
  43. package/plugins/booking/public/lib/slots.js +247 -0
  44. package/plugins/booking/public/lib/time.js +131 -0
  45. package/plugins/booking/templates/booking.html +5 -0
  46. package/plugins/booking/tests/admin-diary.test.js +73 -0
  47. package/plugins/booking/tests/api.test.js +356 -0
  48. package/plugins/booking/tests/public.test.js +71 -0
  49. package/plugins/booking/tests/pure.test.js +189 -0
  50. package/plugins/free-tier.lock.json +48 -5
  51. package/server/routes/api/related.js +20 -5
  52. package/server/server.js +3 -3
  53. package/server/services/hooks.js +35 -8
  54. package/server/services/markdown.js +10 -12
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The Diary's geometry (admin/lib/diary-layout.js).
3
+ */
4
+ import {test} from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import {datesFor, gridHours, layoutColumn, minuteAt, placeOf, weekStart, dayLabel} from '../admin/lib/diary-layout.js';
7
+
8
+ test('weeks start on Monday', () => {
9
+ assert.equal(weekStart('2031-07-15'), '2031-07-14');
10
+ assert.equal(weekStart('2031-07-20'), '2031-07-14', 'Sunday belongs to the week before');
11
+ assert.deepEqual(datesFor('day', '2031-07-15'), ['2031-07-15']);
12
+ assert.equal(datesFor('week', '2031-07-15').length, 7);
13
+ assert.equal(dayLabel('2031-07-15'), 'Tue 15/07');
14
+ });
15
+
16
+ test('grid hours: opening hours, widened by bookings, at least 8 hours', () => {
17
+ const bk = {hours: {tue: [{from: '09:30', to: '12:00'}]}};
18
+ assert.deepEqual(gridHours([bk], ['2031-07-15']), {from: 540, to: 1020});
19
+ assert.deepEqual(gridHours([bk], ['2031-07-15'], [{startMin: 1140, endMin: 1200}]), {from: 540, to: 1200});
20
+ assert.deepEqual(gridHours([], ['2031-07-15']), {from: 480, to: 1080});
21
+ assert.deepEqual(gridHours([{hours: {tue: [{from: '18:00', to: '23:30'}]}}], ['2031-07-15']), {from: 960, to: 1440});
22
+ });
23
+
24
+ test('placing: London time, clipped at midnight', () => {
25
+ assert.deepEqual(placeOf({startAt: '2031-07-15T08:00:00Z', endAt: '2031-07-15T09:00:00Z'}, 'Europe/London'), {date: '2031-07-15', startMin: 540, endMin: 600});
26
+ assert.deepEqual(placeOf({startAt: '2031-07-15T22:30:00Z', endAt: '2031-07-16T00:30:00Z'}, 'Europe/London'), {date: '2031-07-15', startMin: 1410, endMin: 1440});
27
+ });
28
+
29
+ test('lanes: overlapping items side by side, separate clusters apart', () => {
30
+ const out = layoutColumn([
31
+ {id: 'a', startMin: 540, endMin: 600},
32
+ {id: 'b', startMin: 570, endMin: 630},
33
+ {id: 'c', startMin: 600, endMin: 660},
34
+ {id: 'd', startMin: 700, endMin: 760}
35
+ ]);
36
+ const by = Object.fromEntries(out.map(x => [x.id, [x.lane, x.lanes]]));
37
+ assert.deepEqual(by, {a: [0, 2], b: [1, 2], c: [0, 2], d: [0, 1]});
38
+ });
39
+
40
+ test('click to minute', () => {
41
+ assert.equal(minuteAt(95, {from: 480, pxPerMin: 1}), 570);
42
+ assert.equal(minuteAt(-5, {from: 480, pxPerMin: 1}), 480);
43
+ });
44
+
45
+ import {columnItems} from '../admin/lib/diary-layout.js';
46
+ import {answerRows, placesLabel, sourceLabel, withNames} from '../admin/lib/details.js';
47
+
48
+ test('diary columns: companions shown in every held column; a class is one item', () => {
49
+ const at = '2031-07-15T09:00:00.000Z';
50
+ const bs = [
51
+ {id: '1', serviceId: 'lesson', bookableId: 'tr', holds: ['tr', 'c1'], startAt: at, status: 'confirmed'},
52
+ {id: '2', serviceId: 'yoga', bookableId: 'tr2', startAt: at, status: 'confirmed'},
53
+ {id: '3', serviceId: 'yoga', bookableId: 'tr2', startAt: at, status: 'confirmed'},
54
+ {id: '4', serviceId: 'yoga', bookableId: 'tr2', startAt: at, status: 'cancelled'}
55
+ ];
56
+ assert.deepEqual(columnItems(bs, {bookableId: 'c1'}).map(i => [i.b.id, i.companion, i.session]), [['1', true, false]]);
57
+ assert.deepEqual(columnItems(bs, {bookableId: 'tr'}).map(i => [i.b.id, i.companion]), [['1', false]]);
58
+ const yoga = columnItems(bs, {bookableId: 'tr2'});
59
+ assert.deepEqual(yoga.map(i => [i.bookings.map(b => b.id), i.session]), [[['2', '3'], true], [['4'], false]]);
60
+ assert.equal(columnItems(bs).length, 3, 'week view: no companion copies, the class once, the cancelled one alone');
61
+ });
62
+
63
+ test('booking details: who, where from, answers, places', () => {
64
+ assert.equal(withNames({bookableName: 'Alex'}), 'Alex');
65
+ assert.equal(withNames({bookableName: 'Alex', alsoNames: ['Court 1']}), 'Alex and Court 1');
66
+ assert.equal(withNames({bookableName: 'Alex', alsoNames: ['Court 1', 'Balls']}), 'Alex, Court 1 and Balls');
67
+ assert.equal(sourceLabel('waitlist'), 'From the waiting list');
68
+ const svc = {ext: {'booking-pro': {questions: [{id: 'level', label: 'Level'}, {id: 'ok', label: 'Agreed'}]}}};
69
+ assert.deepEqual(answerRows({ok: true, level: 'Improver', gone: 3, empty: ''}, svc), [['Level', 'Improver'], ['Agreed', 'Yes'], ['gone', '3']]);
70
+ assert.deepEqual(answerRows(null), []);
71
+ assert.equal(placesLabel(1), '1 place left');
72
+ assert.equal(placesLabel(3), '3 places left');
73
+ });
@@ -0,0 +1,356 @@
1
+ /**
2
+ * Booking's API against the real collection service, inside the domma-cms
3
+ * checkout `make test` runs in. Its collections are made for the test and
4
+ * removed after; config/plugins.json is restored byte for byte.
5
+ */
6
+ import {after, before, test} from 'node:test';
7
+ import assert from 'node:assert/strict';
8
+ import fs from 'node:fs/promises';
9
+ import path from 'node:path';
10
+ import Fastify from 'fastify';
11
+ import booking from '../plugin.js';
12
+ import {setMailer} from '../lib/mail.js';
13
+ import {bookingRegistry} from '../lib/extensions.js';
14
+ import {addDays, atWallClock, wallClock} from '../public/lib/time.js';
15
+
16
+ const PLUGINS = path.resolve('config/plugins.json');
17
+ const CONTACTS = path.resolve('content/collections/contacts-contacts/data.json');
18
+ let savedContacts;
19
+ const DIRS = ['booking-bookables', 'booking-services', 'booking-bookings'].map(c => path.resolve('content/collections', c));
20
+ const TZ = 'Europe/London';
21
+ let savedPlugins;
22
+ let app;
23
+ const sent = [];
24
+ const notices = [];
25
+ const related = {};
26
+
27
+ before(async () => {
28
+ savedPlugins = await fs.readFile(PLUGINS, 'utf8').catch(() => null);
29
+ savedContacts = await fs.readFile(CONTACTS, 'utf8').catch(() => null);
30
+ for (const d of DIRS) await fs.rm(d, {recursive: true, force: true});
31
+ setMailer(async (msg) => { sent.push(msg); });
32
+ app = Fastify();
33
+ const authenticate = async (req) => { req.user = {id: 'u1', name: 'Sam', role: 'admin'}; };
34
+ await app.register(booking, {
35
+ prefix: '',
36
+ auth: {authenticate, requirePermission: () => async () => {}},
37
+ settings: {},
38
+ hooks: {
39
+ notify: (n) => notices.push(n), registerShortcode() {},
40
+ registerRelatedItems: (kind, p) => { related[kind] = p; return true; }
41
+ }
42
+ });
43
+ });
44
+
45
+ after(async () => {
46
+ setMailer(null);
47
+ await app?.close();
48
+ for (const d of DIRS) await fs.rm(d, {recursive: true, force: true});
49
+ if (savedPlugins === null) await fs.rm(PLUGINS, {force: true});
50
+ else await fs.writeFile(PLUGINS, savedPlugins);
51
+ if (savedContacts !== null) await fs.writeFile(CONTACTS, savedContacts);
52
+ });
53
+
54
+ const call = async (method, url, body) => {
55
+ const res = await app.inject({method, url, payload: body});
56
+ let json = null;
57
+ try { json = res.json(); } catch { /* not JSON */ }
58
+ return {status: res.statusCode, body: json};
59
+ };
60
+
61
+ /** A weekday a fortnight ahead, so notice rules never bite. */
62
+ function aheadWeekday() {
63
+ let d = addDays(wallClock(Date.now(), TZ).date, 14);
64
+ while ([0, 6].includes(new Date(`${d}T12:00:00Z`).getUTCDay())) d = addDays(d, 1);
65
+ return d;
66
+ }
67
+
68
+ test('set up, book from the page, clash, move, cancel', async () => {
69
+ let r = await call('PUT', '/settings', {timeZone: TZ, reminderHours: 24, changeNoticeHours: 24});
70
+ assert.equal(r.status, 200, JSON.stringify(r.body));
71
+
72
+ r = await call('POST', '/bookables', {name: 'Alex', kind: 'person', email: 'alex@example.com'});
73
+ assert.equal(r.status, 201, JSON.stringify(r.body));
74
+ const alex = r.body;
75
+ r = await call('POST', '/bookables', {name: 'Room 1', kind: 'resource'});
76
+ const room = r.body;
77
+
78
+ r = await call('POST', '/services', {name: 'Consultation', duration: 60, bufferAfter: 15, step: 60, bookables: [alex.id]});
79
+ assert.equal(r.status, 201, JSON.stringify(r.body));
80
+ const svc = r.body;
81
+ r = await call('POST', '/services', {name: 'Consultation', duration: 30, bookables: [room.id], public: false});
82
+ assert.equal(r.body.slug, 'consultation-2', 'slugs stay unique');
83
+
84
+ r = await call('GET', '/public/services');
85
+ assert.deepEqual(r.body.services.map(s => s.name), ['Consultation'], 'hidden services are not on the page');
86
+
87
+ const day = aheadWeekday();
88
+ r = await call('GET', `/public/slots?service=${svc.id}&from=${day}&to=${day}`);
89
+ assert.equal(r.status, 200);
90
+ const slots = r.body.days[0].slots;
91
+ assert.deepEqual(slots.map(s => s.time), ['09:00', '10:00', '11:00', '12:00', '13:00', '14:00', '15:00', '16:00']);
92
+ assert.equal(slots[0].bookableIds, undefined, 'who is free is not sent to the page');
93
+
94
+ // A bot fills the honeypot: told it worked, nothing is booked.
95
+ r = await call('POST', '/public/book', {serviceId: svc.id, startAt: slots[0].start, customer: {name: 'Bot', email: 'b@b.co'}, website: 'x'});
96
+ assert.equal(r.status, 201);
97
+ assert.equal((await call('GET', '/bookings')).body.length, 0);
98
+
99
+ r = await call('POST', '/public/book', {serviceId: svc.id, startAt: slots[1].start, customer: {name: 'Jo Bloggs', email: 'JO@example.com', phone: '07700 900123'}});
100
+ assert.equal(r.status, 201, JSON.stringify(r.body));
101
+ const made = r.body.booking;
102
+ assert.equal(made.time, '10:00');
103
+ assert.match(r.body.manage, /^\/booking\/manage\/[\w-]+\?t=[\w-]+$/);
104
+ const token = r.body.manage.split('t=')[1];
105
+ assert.equal(made.email, 'jo@example.com');
106
+ assert.ok(made.canCancel && made.canMove);
107
+
108
+ // The same time again: gone. 09:00 goes too - its own 15 minutes after would run into 10:00 - and so does 11:00 (10:00's run to 11:15).
109
+ r = await call('POST', '/public/book', {serviceId: svc.id, startAt: slots[1].start, customer: {name: 'Kim', email: 'kim@example.com'}});
110
+ assert.equal(r.status, 409);
111
+ assert.equal(r.body.code, 'slot-taken');
112
+ r = await call('GET', `/public/slots?service=${svc.id}&from=${day}&to=${day}`);
113
+ assert.deepEqual(r.body.days[0].slots.map(s => s.time), ['12:00', '13:00', '14:00', '15:00', '16:00']);
114
+
115
+ // Not a time the page offers (off the hour's steps).
116
+ r = await call('POST', '/public/book', {serviceId: svc.id, startAt: atWallClock(day, '09:30', TZ).toISOString(), customer: {name: 'Kim', email: 'kim@example.com'}});
117
+ assert.equal(r.status, 409);
118
+
119
+ // Emails: the customer's confirmation with an .ics, and the owner's copy.
120
+ await new Promise(res => setTimeout(res, 50));
121
+ const toJo = sent.find(m => m.to === 'jo@example.com');
122
+ assert.match(toJo.subject, /^Booking confirmed: Consultation/);
123
+ assert.match(toJo.text, /Change or cancel: http.*\/booking\/manage\//);
124
+ assert.match(toJo.attachments[0].content, /DTSTART:\d{8}T\d{6}Z/);
125
+ assert.ok(sent.some(m => m.to === 'alex@example.com' && /New booking: Jo Bloggs/.test(m.subject)));
126
+ assert.ok(notices.some(n => n.source === 'bookings' && /New booking/.test(n.title)));
127
+
128
+ // The customer's own link.
129
+ r = await call('GET', `/public/booking/${made.id}?t=wrong`);
130
+ assert.equal(r.status, 404);
131
+ r = await call('GET', `/public/booking/${made.id}?t=${token}`);
132
+ assert.equal(r.body.serviceName, 'Consultation');
133
+ r = await call('GET', `/public/booking/${made.id}/ics?t=${token}`);
134
+ assert.equal(r.status, 200);
135
+
136
+ // They move it to 14:00.
137
+ r = await call('POST', `/public/booking/${made.id}/move`, {t: token, startAt: atWallClock(day, '14:00', TZ).toISOString()});
138
+ assert.equal(r.status, 200, JSON.stringify(r.body));
139
+ assert.equal(r.body.time, '14:00');
140
+ r = await call('GET', `/public/slots?service=${svc.id}&from=${day}&to=${day}`);
141
+ assert.ok(r.body.days[0].slots.some(s => s.time === '10:00'), 'the old time is free again');
142
+ assert.ok(!r.body.days[0].slots.some(s => s.time === '14:00'));
143
+
144
+ // Admin: a clash is refused unless forced.
145
+ r = await call('POST', '/bookings', {serviceId: svc.id, startAt: atWallClock(day, '14:30', TZ).toISOString(), customer: {name: 'Lee', email: 'lee@example.com'}});
146
+ assert.equal(r.status, 409);
147
+ r = await call('POST', '/bookings', {serviceId: svc.id, startAt: atWallClock(day, '14:30', TZ).toISOString(), customer: {name: 'Lee', email: 'lee@example.com'}, force: true, notify: false});
148
+ assert.equal(r.status, 201, JSON.stringify(r.body));
149
+ assert.equal(r.body.token, undefined, 'the admin never sees the customer\'s link token');
150
+ assert.equal(r.body.source, 'admin');
151
+
152
+ // They cancel.
153
+ r = await call('POST', `/public/booking/${made.id}/cancel`, {t: token});
154
+ assert.equal(r.status, 200);
155
+ assert.equal(r.body.status, 'cancelled');
156
+ assert.ok(!r.body.canCancel);
157
+ r = await call('POST', `/public/booking/${made.id}/cancel`, {t: token});
158
+ assert.equal(r.status, 403, 'only once');
159
+
160
+ // A person with bookings to come cannot be deleted; a service neither.
161
+ r = await call('DELETE', `/bookables/${alex.id}`);
162
+ assert.equal(r.status, 409);
163
+ r = await call('DELETE', `/services/${svc.id}`);
164
+ assert.equal(r.status, 409);
165
+
166
+ // Badge shape; the email menu action is registered with its permission.
167
+ r = await call('GET', '/count');
168
+ assert.equal(typeof r.body.count, 'number');
169
+ assert.equal(related.message.actions[0].permission.resource, 'booking');
170
+ assert.match(related.message.actions[0].url, /\{email\}/);
171
+ });
172
+
173
+ test('changing notice: inside the window the customer cannot change it', async () => {
174
+ const all = (await call('GET', '/bookings')).body;
175
+ const lee = all.find(b => b.name === 'Lee');
176
+ let r = await call('PUT', '/settings', {changeNoticeHours: 24 * 30});
177
+ assert.equal(r.status, 200);
178
+ // Lee's token is not in the admin's answer; the rule itself is checked via the public view of Jo's (cancelled) and a fresh one.
179
+ const svc = (await call('GET', '/services')).body.find(s => s.slug === 'consultation');
180
+ const day = aheadWeekday();
181
+ r = await call('POST', '/public/book', {serviceId: svc.id, startAt: atWallClock(day, '09:00', TZ).toISOString(), customer: {name: 'Ann', email: 'ann@example.com'}});
182
+ assert.equal(r.status, 201, JSON.stringify(r.body));
183
+ assert.equal(r.body.booking.canCancel, false, 'a fortnight away, 30 days notice needed');
184
+ const [id, t] = [r.body.booking.id, r.body.manage.split('t=')[1]];
185
+ r = await call('POST', `/public/booking/${id}/cancel`, {t});
186
+ assert.equal(r.status, 403);
187
+ assert.match(r.body.error, /720 hours/);
188
+ assert.ok(lee);
189
+ await call('PUT', '/settings', {changeNoticeHours: 24});
190
+ });
191
+
192
+ test('a switched-off person is never picked for "anyone"; the .ics escapes', async () => {
193
+ const svc = (await call('GET', '/services')).body.find(s => s.slug === 'consultation');
194
+ let r = await call('POST', '/bookables', {name: 'Off', kind: 'person', active: false});
195
+ const off = r.body;
196
+ await call('PUT', `/services/${svc.id}`, {bookables: [...svc.bookables, off.id]});
197
+ const day = addDays(aheadWeekday(), 1);
198
+ r = await call('POST', '/bookings', {serviceId: svc.id, startAt: atWallClock(day, '12:00', TZ).toISOString(), customer: {name: 'Any', email: 'any@example.com'}, notify: false});
199
+ assert.equal(r.status, 201, JSON.stringify(r.body));
200
+ assert.notEqual(r.body.bookableId, off.id);
201
+ r = await call('POST', '/bookings', {serviceId: svc.id, bookableId: off.id, startAt: atWallClock(day, '12:00', TZ).toISOString(), customer: {name: 'Named', email: 'n@example.com'}, notify: false});
202
+ assert.equal(r.body.bookableId, off.id, 'chosen by name, staff may still book them');
203
+ await call('PUT', `/services/${svc.id}`, {bookables: svc.bookables});
204
+ const {icsFor} = await import('../lib/mail.js');
205
+ assert.match(icsFor({id: 'x', startAt: '2031-01-01T09:00:00Z', endAt: '2031-01-01T10:00:00Z'}, {title: 'A; B, C'}), /SUMMARY:A\\; B\\, C/);
206
+ });
207
+
208
+ test('admin status changes, reminders, bad input', async () => {
209
+ const svc = (await call('GET', '/services')).body.find(s => s.slug === 'consultation');
210
+ let r = await call('POST', '/bookings', {serviceId: svc.id, startAt: 'nonsense', customer: {name: 'X', email: 'x@example.com'}});
211
+ assert.equal(r.status, 400);
212
+ r = await call('POST', '/bookings', {serviceId: svc.id, startAt: new Date().toISOString(), customer: {name: '', email: 'x'}});
213
+ assert.equal(r.status, 400);
214
+ assert.ok(r.body.errors.name && r.body.errors.email);
215
+
216
+ // 20 hours ahead, reminders 12 hours before: made outside the window, so one is due at 9 hours from now.
217
+ const start = new Date(Date.now() + 20 * 3600000);
218
+ start.setUTCMinutes(0, 0, 0);
219
+ r = await call('POST', '/bookings', {serviceId: svc.id, startAt: start.toISOString(), customer: {name: 'Rem', email: 'rem@example.com'}, force: true, notify: false});
220
+ assert.equal(r.status, 201);
221
+ const rem = r.body;
222
+ await call('PUT', '/settings', {reminderHours: 12});
223
+ const reg = bookingRegistry();
224
+ sent.length = 0;
225
+ assert.equal(await reg.services.sendReminders(Date.now()), 0, 'not yet');
226
+ assert.equal(await reg.services.sendReminders(Date.now() + 9 * 3600000), 1);
227
+ assert.equal(await reg.services.sendReminders(Date.now() + 10 * 3600000), 0, 'only once');
228
+ await new Promise(res => setTimeout(res, 50));
229
+ assert.ok(sent.some(m => m.to === 'rem@example.com' && /^Reminder: Consultation/.test(m.subject)));
230
+ // With a 24-hour window the same booking was made inside it: its confirmation did the job.
231
+ await call('PUT', `/bookings/${rem.id}/move`, {startAt: new Date(start.getTime() + 3600000).toISOString(), force: true, notify: false});
232
+ await call('PUT', '/settings', {reminderHours: 24});
233
+ assert.equal(await reg.services.sendReminders(Date.now() + 3600000), 0);
234
+
235
+ r = await call('PUT', `/bookings/${rem.id}`, {status: 'no-show'});
236
+ assert.equal(r.body.status, 'no-show');
237
+ r = await call('PUT', `/bookings/${rem.id}`, {status: 'teleported'});
238
+ assert.equal(r.status, 400);
239
+ r = await call('PUT', `/bookings/${rem.id}`, {status: 'cancelled', notify: false});
240
+ assert.equal(r.body.status, 'cancelled');
241
+ r = await call('PUT', `/bookings/${rem.id}/move`, {startAt: start.toISOString()});
242
+ assert.equal(r.status, 400, 'a cancelled booking is not moved');
243
+ r = await call('PUT', `/bookings/${rem.id}`, {status: 'confirmed'});
244
+ assert.equal(r.status, 400, 'nor brought back unchecked');
245
+ r = await call('DELETE', `/bookings/${rem.id}`);
246
+ assert.equal(r.status, 200);
247
+ });
248
+
249
+ test('a sender already in the caller\'s Contacts is found by email and booked as that contact', async () => {
250
+ const {isToolEnabled} = await import('../../../server/services/tools.js');
251
+ if (!isToolEnabled('contacts')) return;
252
+ const {createEntry, listEntries} = await import('../../../server/services/collections.js');
253
+ const mine = await createEntry('contacts-contacts', {name: 'Known Person', email: 'known@example.com', phone: '0123', userId: 'u1', groups: [], favourite: false,
254
+ emails: [{address: 'work@known.example.com'}]});
255
+ await createEntry('contacts-contacts', {name: 'Not Mine', email: 'theirs@example.com', userId: 'u2', groups: [], favourite: false});
256
+
257
+ let r = await call('GET', '/contact?email=KNOWN@example.com');
258
+ assert.equal(r.status, 200);
259
+ assert.equal(r.body.id, mine.id);
260
+ assert.equal(r.body.phone, '0123');
261
+ r = await call('GET', '/contact?email=work%40known.example.com');
262
+ assert.equal(r.body.id, mine.id, 'a second address (Contacts Pro) finds them too');
263
+ assert.equal((await call('GET', '/contact?email=theirs@example.com')).status, 404, 'another user\'s contact is not the caller\'s');
264
+ assert.equal((await call('GET', '/contact?email=nobody@example.com')).status, 404);
265
+
266
+ // No contactId sent (a typed address, or an older reader): the caller's contact is linked, none is made.
267
+ const count = async () => (await listEntries('contacts-contacts', {limit: 100000})).entries.length;
268
+ const before = await count();
269
+ const svc = (await call('GET', '/services')).body.find(s => s.slug === 'consultation');
270
+ const start = atWallClock(aheadWeekday(), '11:00', TZ).toISOString();
271
+ r = await call('POST', '/bookings', {serviceId: svc.id, startAt: start, customer: {name: 'Known', email: 'known@example.com'}, force: true, notify: false});
272
+ assert.equal(r.status, 201);
273
+ assert.equal(r.body.contactId, mine.id);
274
+ assert.equal(await count(), before);
275
+ await call('DELETE', `/bookings/${r.body.id}`);
276
+ });
277
+
278
+ test('online: a service an extension makes online gives a call - the customer and the host join, nobody else', async () => {
279
+ const reg = bookingRegistry();
280
+ reg.register('test-online', {rules: (s) => (s?.slug === 'video-chat' ? {online: true, recordingAllowed: true} : {})});
281
+ try {
282
+ let r = await call('POST', '/bookables', {name: 'Vic', kind: 'person', email: 'vic@example.com', hours: {mon: [{from: '00:00', to: '24:00'}], tue: [{from: '00:00', to: '24:00'}],
283
+ wed: [{from: '00:00', to: '24:00'}], thu: [{from: '00:00', to: '24:00'}], fri: [{from: '00:00', to: '24:00'}], sat: [{from: '00:00', to: '24:00'}], sun: [{from: '00:00', to: '24:00'}]}});
284
+ const vic = r.body;
285
+ r = await call('POST', '/services', {name: 'Video chat', duration: 30, minNoticeMinutes: 0, bookables: [vic.id]});
286
+ const svc = r.body;
287
+ r = await call('GET', '/public/services');
288
+ assert.equal(r.body.services.find(s => s.id === svc.id).online, true, 'the page says it is online');
289
+
290
+ // Starts in 5 minutes: the call is already open (10 minutes before).
291
+ const start = new Date(Math.ceil((Date.now() + 5 * 60000) / 60000) * 60000).toISOString();
292
+ sent.length = 0;
293
+ r = await call('POST', '/bookings', {serviceId: svc.id, startAt: start, customer: {name: 'Cal Customer', email: 'cal@example.com'}, force: true});
294
+ assert.equal(r.status, 201, JSON.stringify(r.body));
295
+ const b = r.body;
296
+ assert.equal(b.online, true);
297
+ assert.equal(b.recordingAllowed, true);
298
+ assert.equal(b.token, undefined);
299
+ assert.equal(b.hostKey, undefined, 'the raw host key never leaves the server');
300
+ assert.match(b.hostUrl, /^\/booking\/call\/[\w-]+\?b=.+&h=.+/);
301
+ const room = b.hostUrl.split('/call/')[1].split('?')[0];
302
+ const hostKey = new URL(`http://x${b.hostUrl}`).searchParams.get('h');
303
+
304
+ await new Promise(res => setTimeout(res, 50));
305
+ const toCustomer = sent.find(m => m.to === 'cal@example.com');
306
+ assert.ok(toCustomer, 'the customer is emailed');
307
+ const join = toCustomer.text.match(/Join the call: (\S+)/)?.[1] || '';
308
+ assert.match(join, new RegExp(`/booking/call/${room}\\?b=${b.id}&t=`));
309
+ const token = new URL(join).searchParams.get('t');
310
+ assert.doesNotMatch(toCustomer.text, /&h=/, 'the customer never gets the host link');
311
+
312
+ r = await call('GET', `/public/booking/${b.id}?t=${token}`);
313
+ assert.equal(r.body.online, true);
314
+ assert.match(r.body.callUrl, new RegExp(`/booking/call/${room}\\?b=`));
315
+
316
+ const joinAs = (cred, at = room) => call('POST', `/call/${at}/join`, {b: b.id, ...cred});
317
+ r = await joinAs({h: hostKey});
318
+ assert.equal(r.status, 200, JSON.stringify(r.body));
319
+ const hostIn = r.body;
320
+ assert.equal(r.body.host, true);
321
+ assert.equal(r.body.name, 'Vic');
322
+ r = await joinAs({t: token});
323
+ assert.equal(r.body.host, false);
324
+ assert.equal(r.body.name, 'Cal Customer');
325
+ assert.equal(r.body.state, 'waiting');
326
+ assert.equal((await joinAs({t: 'wrong'})).status, 404);
327
+ assert.equal((await joinAs({h: token})).status, 404, "the customer's token is not a host key");
328
+ assert.equal((await joinAs({t: token}, 'another-room-00000')).status, 404);
329
+
330
+ await call('PUT', `/bookings/${b.id}`, {status: 'cancelled', notify: false});
331
+ assert.equal((await joinAs({t: token})).status, 404, 'a cancelled booking has no call');
332
+ await new Promise(res => setTimeout(res, 100));
333
+ const polled = await app.inject({method: 'GET', url: `/call/${room}/poll?since=0`, headers: {'x-call-id': hostIn.id, 'x-call-secret': hostIn.secret}});
334
+ assert.equal(polled.statusCode, 410, 'the call already running ended with it');
335
+ await call('DELETE', `/bookings/${b.id}`);
336
+
337
+ // Booked from the page: the person booked is emailed the host link.
338
+ sent.length = 0;
339
+ const day = aheadWeekday();
340
+ const slot = (await call('GET', `/public/slots?service=${svc.id}&from=${day}&to=${day}`)).body.days[0].slots[4];
341
+ r = await call('POST', '/public/book', {serviceId: svc.id, startAt: slot.start, customer: {name: 'Web Customer', email: 'web@example.com'}});
342
+ assert.equal(r.status, 201, JSON.stringify(r.body));
343
+ assert.match(r.body.booking.callUrl, /\/booking\/call\//);
344
+ await new Promise(res => setTimeout(res, 50));
345
+ const toHost = sent.find(m => m.to === 'vic@example.com');
346
+ assert.match(toHost?.text || '', /Start the call: \S+&h=/, 'the person booked gets the host link');
347
+ assert.doesNotMatch(sent.find(m => m.to === 'web@example.com').text, /&h=/);
348
+ const early = await call('POST', `/call/${r.body.booking.callUrl.split('/call/')[1].split('?')[0]}/join`,
349
+ {b: r.body.booking.id, t: new URL(`http://x${r.body.booking.callUrl}`).searchParams.get('t')});
350
+ assert.equal(early.status, 425, 'not open days ahead');
351
+ assert.ok(early.body.opensAt);
352
+ await call('DELETE', `/bookings/${r.body.booking.id}`);
353
+ } finally {
354
+ reg.unregister('test-online');
355
+ }
356
+ });
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The booking page: which address is which page, and the pure formatting it
3
+ * shares with the browser.
4
+ */
5
+ import {test} from 'node:test';
6
+ import assert from 'node:assert/strict';
7
+ import {esc, matchBookingPath} from '../plugin.public.js';
8
+ import {dayLabel, dayParts, ddmmyyyy, icsUrl, lengthLabel, tokenFrom, weekMoves, weekOf, zoneNote} from '../public/lib/page-format.js';
9
+
10
+ test('addresses under the booking page', () => {
11
+ const b = '/booking';
12
+ assert.deepEqual(matchBookingPath('/booking', b), {name: 'index', params: {}});
13
+ assert.deepEqual(matchBookingPath('/booking/', b), {name: 'slash', params: {}});
14
+ assert.deepEqual(matchBookingPath('/booking/hair-cut', b), {name: 'service', params: {slug: 'hair-cut'}});
15
+ assert.deepEqual(matchBookingPath('/booking/manage/abc123def', b), {name: 'manage', params: {id: 'abc123def'}});
16
+ assert.equal(matchBookingPath('/booking/manage', b), null, '"manage" alone is not a service');
17
+ assert.equal(matchBookingPath('/booking/manage/short', b), null);
18
+ assert.equal(matchBookingPath('/booking/a/b', b), null);
19
+ assert.equal(matchBookingPath('/booking/Hair_Cut', b), null);
20
+ assert.equal(matchBookingPath('/bookings', b), null, 'a neighbour address is not ours');
21
+ assert.equal(matchBookingPath('/booking/%E0%A4%A', b), null, 'bad encoding');
22
+ assert.deepEqual(matchBookingPath('/appointments/x', '/appointments'), {name: 'service', params: {slug: 'x'}});
23
+ });
24
+
25
+ test('formatting', () => {
26
+ assert.equal(ddmmyyyy('2031-07-15'), '15/07/2031');
27
+ assert.equal(dayLabel('2031-07-15'), 'Tuesday 15/07/2031');
28
+ assert.equal(dayLabel('2031-07-15', {short: true}), 'Tue 15/07/2031');
29
+ assert.equal(dayLabel('nope'), '');
30
+ assert.deepEqual(dayParts('2031-07-05'), {weekday: 'Sat', day: '5', month: '07'});
31
+ assert.equal(lengthLabel(45), '45 minutes');
32
+ assert.equal(lengthLabel(60), '1 hour');
33
+ assert.equal(lengthLabel(90), '1 hour 30 minutes');
34
+ assert.equal(lengthLabel(121), '2 hours 1 minute');
35
+ assert.equal(esc(`<a href="x">'`), '&lt;a href=&quot;x&quot;&gt;&#39;');
36
+ });
37
+
38
+ test('the week strip stays inside the bookable range', () => {
39
+ assert.deepEqual(weekOf('2031-12-29'), ['2031-12-29', '2031-12-30', '2031-12-31', '2032-01-01', '2032-01-02', '2032-01-03', '2032-01-04']);
40
+ const range = {first: '2031-07-03', last: '2031-07-20'};
41
+ assert.deepEqual(weekMoves('2031-07-03', range), {prev: '', next: '2031-07-10'});
42
+ assert.deepEqual(weekMoves('2031-07-10', range), {prev: '2031-07-03', next: '2031-07-17'});
43
+ assert.deepEqual(weekMoves('2031-07-17', range), {prev: '2031-07-10', next: ''});
44
+ assert.deepEqual(weekMoves('2031-07-06', range), {prev: '2031-07-03', next: '2031-07-13'}, 'back never goes before the first day');
45
+ });
46
+
47
+ test('zone note, tokens, calendar link', () => {
48
+ assert.equal(zoneNote('Europe/London', 'Europe/London'), '');
49
+ assert.equal(zoneNote('America/New_York', 'Europe/London'), 'Times are America/New York time.');
50
+ assert.equal(zoneNote('Europe/London', ''), '');
51
+ assert.equal(tokenFrom('/booking/manage/abc?t=Xy_-9'), 'Xy_-9');
52
+ assert.equal(tokenFrom('/booking/manage/abc'), '');
53
+ assert.equal(icsUrl('a b', 't&1'), '/api/plugins/booking/public/booking/a%20b/ics?t=t%261');
54
+ });
55
+
56
+ import {answersFor, placesText} from '../public/lib/page-format.js';
57
+
58
+ test('places and the extra questions', () => {
59
+ assert.equal(placesText(3), '3 places left');
60
+ assert.equal(placesText(1), '1 place left');
61
+ assert.equal(placesText(0), 'Full');
62
+ const fields = [
63
+ {id: 'level', label: 'Level', kind: 'choice', required: true, options: ['A', 'B']},
64
+ {id: 'age', label: 'Age', kind: 'number'},
65
+ {id: 'ok', label: 'I agree', kind: 'yesno', required: true},
66
+ {id: 'more', label: 'More', kind: 'longtext'}
67
+ ];
68
+ assert.deepEqual(answersFor(fields, {level: 'B', age: '42', ok: true, more: ' '}), {answers: {level: 'B', age: 42, ok: true}, errors: {}});
69
+ assert.deepEqual(Object.keys(answersFor(fields, {age: 'x'}).errors).sort(), ['answers.age', 'answers.level', 'answers.ok']);
70
+ assert.deepEqual(answersFor([], {}), {answers: {}, errors: {}});
71
+ });