domma-cms 0.105.2 → 0.107.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.
@@ -20,6 +20,31 @@ service - no import of the Calendar, which is a separate paid Tool).
20
20
  Booking Pro (not yet built) is an ADD-ON that registers into this one (`lib/extensions.js`), the Cart / Cart Pro
21
21
  pattern - it does not supersede it. Payment is Pro only.
22
22
 
23
+ 1.6.0 (2026-09-30): "Add people" in an online booking's call (core with `server/services/callInvites.js` and
24
+ `_lib/call/invite.js`; both imported dynamically - lib/call.js by top-level `await import`, plugin.js beside
25
+ `mountCallRoutes` - so an older core simply has no button). The HOST (the person's host link) searches the contacts
26
+ of the person's linked ACTIVE account (`hostUserOf`) and the team, and invites contacts, team members or any name +
27
+ email. With NO linked account the host link may be held by an outsider (it goes to the person's email address and
28
+ the .ics), so resolve answers `inviteOptions: {search: false, typed: false, copyLink: true}` - Copy link only: no
29
+ directory, no contacts, nothing sent (the engine and `send` both refuse people). Links use core's `inviteOrigin`
30
+ (`site.baseUrl` first).
31
+ Each invitee gets their OWN pass `?b=<booking>&g=<nonce>.<hmac>` signed with the session's `callExtraKey` - made on the
32
+ first invite and written to EVERY booking of the session (`changeBooking` patch, online only, never replaced; the
33
+ stored key is re-read after writing, so two sends at once agree), never the customer's token or the host key (a
34
+ customer token carries their name and identity: an invitee would replace them). `whoIsCalling` answers `{host: false,
35
+ name: '', identity: '<session>:inv:<nonce>', invited: true}` for a pass; the page asks their name; they knock. Core
36
+ sends (Join email, a notification + a DM from the person's user for team members). `adminBooking` strips
37
+ `callExtraKey`. Tests: tests/api.test.js "1.6 Add people" (a linked host; a host with no account).
38
+
39
+ 1.5.0 (2026-09-30): the call's chat is saved per SESSION (`sessionOf` - a class's shared call has one chat) in
40
+ `data/call-chat.json` (`options.dataDir` in tests) through core's `_lib/call/chatStore.js` (dynamic import beside
41
+ `mountCallRoutes`; an older core has none and nothing changes), passed as the engine's `chat` - so the call reopened
42
+ shows it. `GET /bookings/:id` adds `callChat` (typed messages) on online bookings; `GET /bookings/:id/call-chat`
43
+ (booking.read; 404 unless online) answers `{messages, items}` without participant ids, the session's host lines
44
+ (`whoOf('<bookingId>:host')` for each booking of the session) marked `mine`. The panel (`openBooking`) shows "Call chat
45
+ (N messages)" when N > 0 and opens `/plugins/_lib/admin/ui/transcript.js` (read-only, domma-reactive). The customer
46
+ has no admin view of it. Kept: 500 items a session, the 1000 most recent sessions. Tests: tests/api.test.js.
47
+
23
48
  1.4.0 (2026-09-30): "Call now" hooks for Booking Pro. Registry `version: 4`: `actions: [{id, icon, title, module}]`
24
49
  (module under `/plugins/<owner>/`; ids `[a-z0-9-]`, never a screen's or tab's key, first owner keeps an id) ->
25
50
  `registry.actions()` -> bootstrap `actions` -> a banner button each (`booking-x-<id>`) that imports the module and calls
@@ -101,7 +126,8 @@ all off - bookings hold customers' details). Settings: `config/plugins.json` `bo
101
126
  A booking: `serviceId`, `serviceName`, `duration`, `bookableId`, `bookableName`, `startAt`, `endAt`, `blockStart`,
102
127
  `blockEnd`, `status`, `name`, `email`, `phone`, `notes`, `staffNote`, `contactId`, `source` (public | admin),
103
128
  `createdBy`, `token` (the customer's link; never sent to the admin), `reminderSentAt`, `history` [{at, what, by,
104
- from?, reason?}], `cancelledAt`, `ext` (Pro's); online ones also `online`, `hostKey`, `recordingAllowed`, `callOpenedAt`. Services and bookables carry `ext` too (Pro's fields, kept by the
129
+ from?, reason?}], `cancelledAt`, `ext` (Pro's); online ones also `online`, `hostKey`, `recordingAllowed`, `callOpenedAt`, `callExtraKey` (1.6, the session's key for
130
+ Add people passes; never sent to the admin). Services and bookables carry `ext` too (Pro's fields, kept by the
105
131
  free Tool untouched).
106
132
 
107
133
  ## Extensions (Booking Pro)
@@ -61,6 +61,7 @@ export const api = {
61
61
  bookings: {
62
62
  list: (q = {}) => request('GET', `${P}/bookings?${qs(q)}`),
63
63
  get: (id) => request('GET', `${P}/bookings/${seg(id)}`),
64
+ callChat: (id) => request('GET', `${P}/bookings/${seg(id)}/call-chat`),
64
65
  create: (d) => request('POST', `${P}/bookings`, d),
65
66
  update: (id, d) => request('PUT', `${P}/bookings/${seg(id)}`, d),
66
67
  move: (id, d) => request('PUT', `${P}/bookings/${seg(id)}/move`, d),
@@ -478,6 +478,9 @@ export async function openBooking(ctx, id) {
478
478
  ${b.hostUrl && live ? `<div class="bk-actions bk-actions-start bk-call-row">
479
479
  <a class="btn btn-sm btn-primary" href="${esc(b.hostUrl)}" target="_blank" rel="noopener"><span data-icon="video" data-icon-size="13"></span> Start the call</a>
480
480
  <span class="bk-muted">You are the host: let ${esc(b.name || 'them')} in when they arrive. ${Date.parse(b.callOpenedAt || '') > Date.now() - 2 * 3600000 ? 'It was opened early, so it is open now.' : 'It opens 10 minutes before the start.'}</span></div>` : ''}
481
+ ${b.online && Number(b.callChat) > 0 ? `<div class="bk-actions bk-actions-start bk-call-chat">
482
+ <button type="button" class="btn btn-sm btn-outline" data-on-click="callChat"><span data-icon="message-circle" data-icon-size="13"></span> Call chat (${Number(b.callChat)} message${Number(b.callChat) === 1 ? '' : 's'})</button>
483
+ <span class="bk-muted">What was typed in the call's chat, read-only.</span></div>` : ''}
481
484
  <div class="bk-actions bk-actions-start">
482
485
  <button type="button" class="btn btn-sm btn-outline" data-on-click="move" ${live ? '' : 'disabled'}><span data-icon="move" data-icon-size="13"></span> Move</button>
483
486
  <button type="button" class="btn btn-sm btn-outline" data-on-click="done" ${live && b.status !== 'completed' ? '' : 'disabled'}><span data-icon="check" data-icon-size="13"></span> Done</button>
@@ -505,6 +508,13 @@ export async function openBooking(ctx, id) {
505
508
  E.toast(`Not saved: ${errText(err)}`, {type: 'error'});
506
509
  }
507
510
  },
511
+ async callChat() {
512
+ // The call's chat, read-only (1.5) - one per session, so a class's shared call has one.
513
+ try {
514
+ const [r, ui] = await Promise.all([api.bookings.callChat(b.id), import('/plugins/_lib/admin/ui/transcript.js')]);
515
+ await ui.openTranscript({title: `Call chat: ${b.serviceName}`, subtitle: timeRange(b, ctx.tz), items: r?.items || []});
516
+ } catch (err) { E.toast(`The chat could not be opened: ${errText(err)}`, {type: 'error'}); }
517
+ },
508
518
  move: act(() => moveBooking(ctx, b)),
509
519
  done: act(() => setStatus(ctx, b, 'completed')),
510
520
  noshow: act(() => setStatus(ctx, b, 'no-show')),
@@ -57,7 +57,8 @@ Booking has its own permission, **Booking**, which admins have by default. **See
57
57
  ## Booking Pro
58
58
 
59
59
  Booking Pro adds the following to the free Tool:
60
- - Online appointments, held as a video call on your site.
60
+ - Online appointments, held as a video call on your site. What is typed in the call's chat is kept: open the booking and click **Call chat** to read it afterwards (a class shares one chat).
61
+ - **Add people** during an online call: the person being booked clicks **Add people** in the call, searches their contacts and the team (or types a name and email), and sends each person their own Join link - by email, and for team members also as a notification and a message in Messages. They knock and you let them in. **Copy link** gives you a link to pass on yourself. This needs the person to be linked to their account on the site (**People & rooms**); without one, their call offers **Copy link** only.
61
62
  - **Call now**: open an online booking's call early, or start a video call with anyone straight away - from Booking, a contact or an email.
62
63
  - Services that need more than one person or room at once.
63
64
  - Classes and group sessions with places and a waiting list.
@@ -6,7 +6,10 @@
6
6
  * same service, start and person - so everyone booked into an online class
7
7
  * shares one call, and a one-to-one appointment is a session of one. Links
8
8
  * name the session and the booking: `?b=<booking>&t=<customer token>` for the
9
- * customer, `&h=<host key>` for the person they booked. Booking (free) serves
9
+ * customer, `&h=<host key>` for the person they booked, `&g=<invite pass>` for
10
+ * someone the host added while the call runs ("Add people", 1.6: a pass under
11
+ * the session's `callExtraKey` - never the customer's token or the host key -
12
+ * who names themselves and knocks). Booking (free) serves
10
13
  * these for any booking that is already online, so a lapsed Pro licence
11
14
  * strands nobody; only Pro can make a service online in the first place.
12
15
  *
@@ -15,6 +18,12 @@
15
18
 
16
19
  import {createHash, timingSafeEqual} from 'crypto';
17
20
 
21
+ // Add people (1.6) needs a core with _lib/call/invite.js; an older one has no invite passes.
22
+ let invite = null;
23
+ try { invite = await import('../../_lib/call/invite.js'); } catch { invite = null; }
24
+ /** Does this core have "Add people"? */
25
+ export const invitesAvailable = () => Boolean(invite);
26
+
18
27
  export const OPENS_BEFORE_MIN = 10;
19
28
  export const CLOSES_AFTER_MIN = 60;
20
29
  const HOLDING = new Set(['pending', 'confirmed', 'completed', 'no-show']);
@@ -33,6 +42,10 @@ export const joinPath = (settings, b) => `${settings.bookingPath}/call/${session
33
42
  /** The host's (the person booked) - never shown to the customer. */
34
43
  export const hostPath = (settings, b) => `${settings.bookingPath}/call/${sessionOf(b)}?b=${encodeURIComponent(b.id)}&h=${encodeURIComponent(b.hostKey)}`;
35
44
 
45
+ /** A Join link for someone added to the call (1.6): a new pass under the session's extra key. */
46
+ export const invitePath = (settings, b, key) => (invite && key
47
+ ? `${settings.bookingPath}/call/${sessionOf(b)}?b=${encodeURIComponent(b.id)}&g=${encodeURIComponent(invite.invitePass(key, sessionOf(b)))}` : '');
48
+
36
49
  /** How long a call opened early ("Call now", Booking 1.4) stays open at least. */
37
50
  export const OPENED_EARLY_HOURS = 2;
38
51
 
@@ -60,7 +73,7 @@ export function callWindow(b, now = Date.now()) {
60
73
  *
61
74
  * @param {object|null} b - the booking named by `cred.b`
62
75
  * @param {string} room - the room in the address
63
- * @param {{t?: string, h?: string}} cred
76
+ * @param {{t?: string, h?: string, g?: string}} cred
64
77
  */
65
78
  export function whoIsCalling(b, room, cred = {}, now = Date.now()) {
66
79
  if (!b || b.online !== true || !HOLDING.has(b.status) || sessionOf(b) !== room) return null;
@@ -68,5 +81,8 @@ export function whoIsCalling(b, room, cred = {}, now = Date.now()) {
68
81
  // identity: a link the host turns away cannot knock again (the engine remembers it per room).
69
82
  if (cred.h && same(cred.h, b.hostKey)) return {...base, host: true, name: b.bookableName || 'Host', identity: `${b.id}:host`};
70
83
  if (cred.t && same(cred.t, b.token)) return {...base, host: false, name: b.name || 'Guest', identity: `${b.id}:guest`};
84
+ // Someone the host added (1.6): their own pass, their own name, and they knock.
85
+ const nonce = cred.g && b.callExtraKey && invite ? invite.checkInvitePass(b.callExtraKey, room, cred.g) : null;
86
+ if (nonce) return {...base, host: false, name: '', identity: `${room}:inv:${nonce}`, invited: true};
71
87
  return null;
72
88
  }
@@ -313,6 +313,8 @@ export function changeBooking(id, patch, by = '') {
313
313
  data.callOpenedAt = String(patch.callOpenedAt || '');
314
314
  if (data.callOpenedAt) data.history = [...(data.history || []), {at: data.callOpenedAt, what: 'call-opened', by}].slice(-50);
315
315
  }
316
+ // "Add people" (1.6): the session's key for invite passes - set once, never replaced.
317
+ if (patch.callExtraKey !== undefined && data.online === true && !data.callExtraKey) data.callExtraKey = String(patch.callExtraKey || '').slice(0, 64);
316
318
  if (patch.ext && typeof patch.ext === 'object') data.ext = {...(data.ext || {}), ...patch.ext};
317
319
  return {booking: flat(await updateEntry(BOOKINGS, id, data)), from};
318
320
  });
@@ -6,6 +6,8 @@
6
6
  * GET /count sidebar badge: bookings still to come today
7
7
  * GET /bookings?from=&to= bookings starting in a range (ISO instants)
8
8
  * GET /bookings/:id · POST /bookings · PUT /bookings/:id · PUT /bookings/:id/move · DELETE /bookings/:id
9
+ * GET /bookings/:id/call-chat an online booking's call chat, read-only (booking.read; 1.5) - saved per
10
+ * session (a class's shared call has one) in data/call-chat.json
9
11
  * GET /slots?service=&from=&to=&with=&ignore= free times (dates in the business zone)
10
12
  * GET/POST /services · PUT/DELETE /services/:id
11
13
  * GET/POST /bookables · PUT/DELETE /bookables/:id
@@ -24,17 +26,19 @@
24
26
  * The booking page itself is plugin.public.js.
25
27
  */
26
28
 
29
+ import path from 'node:path';
30
+ import {fileURLToPath} from 'node:url';
27
31
  import {getConfig} from '../../server/config.js';
28
32
  import {getPluginStates} from '../../server/services/plugins.js';
29
33
  import {getPermissionsFor} from '../../server/services/roles.js';
30
34
  import {getEffectiveRoles} from '../../server/services/userRoles.js';
31
- import {listUsers} from '../../server/services/users.js';
35
+ import {getUserById, listUsers} from '../../server/services/users.js';
32
36
  import {isToolEnabled} from '../../server/services/tools.js';
33
37
  import {cleanBookable, cleanCustomer, cleanService, whenLabel} from './public/lib/clean.js';
34
38
  import {bookingRange, HOLDING, slotsFor} from './public/lib/slots.js';
35
39
  import {addDays, atWallClock, isDate, wallClock} from './public/lib/time.js';
36
40
  import {bookingRegistry} from './lib/extensions.js';
37
- import {hostPath, joinPath, sessionOf, whoIsCalling} from './lib/call.js';
41
+ import {callWindow, hostPath, invitePath, joinPath, sessionOf, whoIsCalling} from './lib/call.js';
38
42
  import {compose, icsFor, mailReady, send, siteAddress, siteTitle} from './lib/mail.js';
39
43
  import {
40
44
  BOOKABLES, BOOKINGS, NAME, SERVICES, SlotTaken, allBookables, allBookings, allServices, calendarBusy, changeBooking,
@@ -83,7 +87,7 @@ const publicFields = (list) => list.map(f => ({
83
87
  */
84
88
  const adminBooking = (b) => {
85
89
  if (!b) return b;
86
- const {token, hostKey, ...rest} = b;
90
+ const {token, hostKey, callExtraKey, ...rest} = b;
87
91
  return b.online === true && hostKey ? {...rest, hostUrl: hostPath(settingsNow(), b)} : rest;
88
92
  };
89
93
 
@@ -119,7 +123,9 @@ function registerShortcodes(hooks) {
119
123
  });
120
124
  }
121
125
 
122
- export const ASSET_V = '1.4.0';
126
+ export const ASSET_V = '1.6.0';
127
+
128
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
123
129
 
124
130
  export default async function booking(fastify, options) {
125
131
  const {authenticate, requirePermission} = options.auth;
@@ -135,6 +141,10 @@ export default async function booking(fastify, options) {
135
141
  // The online call's rooms (set once the engine is mounted, at the end). A booking cancelled or moved away
136
142
  // ends the call it was in, unless someone else is still booked into that session (a class).
137
143
  let calls = null;
144
+ // The call's chat (1.5), saved per session - set with the engine, at the end; null on an older core.
145
+ let chatStore = null;
146
+ let whoOf = null;
147
+ const dataDir = options.dataDir || path.join(HERE, 'data');
138
148
  async function endCall(old) {
139
149
  if (!calls || old?.online !== true) return;
140
150
  const room = sessionOf(old);
@@ -432,7 +442,24 @@ export default async function booking(fastify, options) {
432
442
 
433
443
  fastify.get('/bookings/:id', can('read'), async (request, reply) => {
434
444
  const b = await getBooking(request.params.id);
435
- return b ? adminBooking(b) : fail(reply, 404, 'No such booking.');
445
+ if (!b) return fail(reply, 404, 'No such booking.');
446
+ // An online booking says how many messages its call's chat holds (the panel offers "Call chat").
447
+ const callChat = b.online === true && chatStore ? (await chatStore.load(sessionOf(b))).filter(i => i.kind === 'chat').length : undefined;
448
+ return reply.send({...adminBooking(b), ...(callChat !== undefined ? {callChat} : {})});
449
+ });
450
+
451
+ // The call's chat, read-only (1.5): the session's transcript - a class's shared call has one. Staff with
452
+ // booking read only; the customer saw it in the call. Staff (host) lines are marked `mine`.
453
+ fastify.get('/bookings/:id/call-chat', can('read'), async (request, reply) => {
454
+ const b = await getBooking(request.params.id);
455
+ if (!b || b.online !== true) return fail(reply, 404, 'No such online booking.');
456
+ const room = sessionOf(b);
457
+ const items = chatStore ? await chatStore.load(room) : [];
458
+ const hosts = new Set(whoOf ? (await allBookings()).filter(x => x.online === true && sessionOf(x) === room)
459
+ .map(x => whoOf(`${x.id}:host`, '')) : []);
460
+ reply.header('Cache-Control', 'no-store');
461
+ return reply.send({messages: items.filter(i => i.kind === 'chat').length,
462
+ items: items.map(({from, ...i}) => ({...i, mine: i.kind === 'chat' && hosts.has(i.who)}))});
436
463
  });
437
464
 
438
465
  /** Service + the bookables that may take a booking of it (`with` narrows to one). */
@@ -536,6 +563,11 @@ export default async function booking(fastify, options) {
536
563
  const b = await getBooking(request.params.id);
537
564
  if (!b) return fail(reply, 404, 'No such booking.');
538
565
  await removeBooking(b.id);
566
+ // Its call's chat goes with it - unless someone else is still booked into that session (a class).
567
+ if (b.online === true && chatStore) {
568
+ const room = sessionOf(b);
569
+ if (!(await allBookings()).some(x => x.online === true && sessionOf(x) === room)) await chatStore.remove(room)?.catch?.(() => {});
570
+ }
539
571
  return {ok: true};
540
572
  });
541
573
 
@@ -788,7 +820,63 @@ export default async function booking(fastify, options) {
788
820
  // The engine is core's (_lib/call); an older core without it simply has no calls.
789
821
  try {
790
822
  const {mountCallRoutes} = await import('../_lib/call/routes.js');
791
- calls = mountCallRoutes(fastify, {prefix: '/call', ns: 'booking', resolve: async (request, room, body) => {
823
+ // The chat is saved per session (1.5; a core with the chat store) so a call reopened later shows it.
824
+ try {
825
+ const {createChatStore} = await import('../_lib/call/chatStore.js');
826
+ chatStore = createChatStore(path.join(dataDir, 'call-chat.json'));
827
+ whoOf = (await import('../_lib/call/rooms.js')).whoOf || null;
828
+ } catch { chatStore = null; }
829
+ // Add people (1.6; a core with services/callInvites.js): the host searches their contacts and the team, and
830
+ // each person gets their own `&g=` pass under the session's callExtraKey (never the customer's token or the
831
+ // host key). They knock like the customer. Without that core the button stays hidden.
832
+ let invites = null;
833
+ try { invites = await import('../../server/services/callInvites.js'); } catch { invites = null; }
834
+ /** The host's booking of this session (their identity is `<booking>:host`), or null. */
835
+ const hostBooking = async (room, identity) => {
836
+ const id = String(identity || '').endsWith(':host') ? String(identity).slice(0, -5) : '';
837
+ const b = id ? await getBooking(id).catch(() => null) : null;
838
+ return b && b.online === true && HOLDING.has(b.status) && sessionOf(b) === room ? b : null;
839
+ };
840
+ /** The session's extra key: the one a booking of it already has, else a new one - on every booking of it. */
841
+ const extraKeyOf = async (b, room) => {
842
+ const session = (await allBookings()).filter(x => x.online === true && HOLDING.has(x.status) && sessionOf(x) === room);
843
+ const key = session.map(x => x.callExtraKey).find(Boolean) || (await import('../_lib/call/invite.js')).newInviteKey();
844
+ for (const x of session) if (!x.callExtraKey) await changeBooking(x.id, {callExtraKey: key}, 'call');
845
+ // Two sends at once: the first key written stays (changeBooking never replaces one) - use what is stored.
846
+ return (await getBooking(b.id).catch(() => null))?.callExtraKey || key;
847
+ };
848
+ /**
849
+ * The host's user on the site: the person's linked, active account - else '' (the host link then may be held
850
+ * by someone from outside - it is emailed to the person's address and sits in the .ics - so they get no team
851
+ * directory, no contacts and nothing sent in the site's name: Copy link only).
852
+ */
853
+ const hostUserOf = async (b) => {
854
+ const bk = b?.bookableId ? await getBookable(b.bookableId).catch(() => null) : null;
855
+ const u = bk?.userId ? await getUserById(bk.userId).catch(() => null) : null;
856
+ return u && u.disabled !== true && u.active !== false && u.isActive !== false ? u.id : '';
857
+ };
858
+ const invite = invites ? {
859
+ search: async (room, q, {identity}) => {
860
+ const b = await hostBooking(room, identity);
861
+ const owner = b ? await hostUserOf(b) : '';
862
+ return owner ? invites.searchInvitees({hostUserId: owner, q}) : [];
863
+ },
864
+ send: async (room, people, {identity, name, via, request}) => {
865
+ const b = await hostBooking(room, identity);
866
+ if (!b) return {error: 'This booking has no call now.'};
867
+ const owner = await hostUserOf(b);
868
+ if (!owner && people.length) return {error: 'Copy the link and pass it on yourself.'};
869
+ const key = await extraKeyOf(b, room);
870
+ const s = await liveSettings();
871
+ const base = invites.inviteOrigin ? invites.inviteOrigin(request) : siteBase(request);
872
+ const newLink = () => `${base}${invitePath(s, b, key)}`;
873
+ const targets = await invites.resolveInvitees(people, {hostUserId: owner});
874
+ const results = await invites.deliverInvites(targets, {linkFor: newLink, hostName: name || b.bookableName || 'Your host',
875
+ hostUserId: owner, title: b.serviceName, via, key: `booking:${room}`, expiresAt: callWindow(b).closesAt, log});
876
+ return {link: newLink(), results};
877
+ }
878
+ } : null;
879
+ calls = mountCallRoutes(fastify, {prefix: '/call', ns: 'booking', ...(chatStore ? {chat: chatStore} : {}), ...(invite ? {invite} : {}), resolve: async (request, room, body) => {
792
880
  const b = await getBooking(String(body?.b || '')).catch(() => null);
793
881
  // A class opened early ("Call now") is open for everyone in the session - someone who booked in
794
882
  // after the click too: take the session's latest opening when this booking has none of its own.
@@ -798,7 +886,10 @@ export default async function booking(fastify, options) {
798
886
  .map(x => x.callOpenedAt).sort().at(-1);
799
887
  if (opened) b.callOpenedAt = opened;
800
888
  }
801
- return whoIsCalling(b, room, {t: body?.t, h: body?.h});
889
+ const who = whoIsCalling(b, room, {t: body?.t, h: body?.h, g: body?.g});
890
+ // Add people for a host with no account here: Copy link only (see hostUserOf).
891
+ if (who?.host && invite && !(await hostUserOf(b))) return {...who, inviteOptions: {search: false, typed: false, copyLink: true}};
892
+ return who;
802
893
  }});
803
894
  } catch (err) {
804
895
  log.warn?.(`[booking] online calls unavailable on this CMS: ${err?.message || err}`);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "booking",
3
3
  "displayName": "Booking",
4
- "version": "1.4.1",
4
+ "version": "1.6.0",
5
5
  "tier": "free",
6
6
  "description": "Let people book appointments with you or your team, and rooms or equipment, from a booking page on your site. Opening hours, days off and buffers; confirmation and reminder emails with a link to change or cancel; a diary in the admin. Book straight from a contact or an email.",
7
7
  "author": "Domma CMS",
@@ -20,7 +20,7 @@ import {callWindow, whoIsCalling} from './lib/call.js';
20
20
  import {dayLabel, lengthLabel} from './public/lib/page-format.js';
21
21
 
22
22
  const TEMPLATE = path.join(fileURLToPath(new URL('.', import.meta.url)), 'templates', 'booking.html');
23
- export const ASSET_V = '1.3.1';
23
+ export const ASSET_V = '1.6.0';
24
24
 
25
25
  /** Which page a path is, under the booking page's base. */
26
26
  export function matchBookingPath(pathname, base) {
@@ -128,7 +128,7 @@ export default async function bookingPublic(fastify) {
128
128
  */
129
129
  pages.call = async function call(request, reply, s, {room}) {
130
130
  const q = request.query || {};
131
- const cred = {b: String(q.b || '').slice(0, 64), ...(q.h ? {h: String(q.h).slice(0, 128)} : {t: String(q.t || '').slice(0, 128)})};
131
+ const cred = {b: String(q.b || '').slice(0, 64), ...(q.h ? {h: String(q.h).slice(0, 128)} : q.g ? {g: String(q.g).slice(0, 128)} : {t: String(q.t || '').slice(0, 128)})};
132
132
  const b = await getBooking(cred.b).catch(() => null);
133
133
  const who = whoIsCalling(b, room, cred);
134
134
  const head = `<header class="bk-hero"><p class="bk-crumbs"><a href="${esc(s.bookingPath)}">${esc(s.pageTitle || 'Book')}</a></p>`;
@@ -136,11 +136,11 @@ export default async function bookingPublic(fastify) {
136
136
  return send(reply, request, s, {page: 'call', title: `Call - ${s.pageTitle || 'Book'}`, urlPath: `${s.bookingPath}/call/${room}`, noindex: true,
137
137
  body: `${head}<h1>This call link is not valid</h1></header><p class="bk-note">The booking may have been cancelled or moved. Check the latest email we sent you.</p>`
138
138
  // A reload of the bare address (the call screen took the link out of the address bar): back to it.
139
- + (cred.t || cred.h ? '' : RECOVER_CALL)});
139
+ + (cred.t || cred.h || cred.g ? '' : RECOVER_CALL)});
140
140
  }
141
141
  const {closesAt} = callWindow(b);
142
142
  const cfg = {api: '/api/plugins/booking/call', room, cred, title: `${b.serviceName}${who.host ? `: ${b.name}` : b.bookableName ? ` with ${b.bookableName}` : ''}`,
143
- askName: false, recordingAllowed: who.recordingAllowed, closesAt};
143
+ askName: who.invited === true, recordingAllowed: who.recordingAllowed, closesAt};
144
144
  return send(reply, request, s, {page: 'call', title: `${b.serviceName} - online`, urlPath: `${s.bookingPath}/call/${room}`, noindex: true,
145
145
  body: `<link rel="stylesheet" href="/plugins/_lib/public/call/call.css?v=${ASSET_V}">`
146
146
  + `<div data-dm-call='${esc(JSON.stringify(cfg))}'><noscript><p class="bk-note">The call needs JavaScript switched on.</p></noscript></div>`
@@ -6,6 +6,7 @@
6
6
  import {after, before, test} from 'node:test';
7
7
  import assert from 'node:assert/strict';
8
8
  import fs from 'node:fs/promises';
9
+ import os from 'node:os';
9
10
  import path from 'node:path';
10
11
  import Fastify from 'fastify';
11
12
  import booking from '../plugin.js';
@@ -20,6 +21,7 @@ const DIRS = ['booking-bookables', 'booking-services', 'booking-bookings'].map(c
20
21
  const TZ = 'Europe/London';
21
22
  let savedPlugins;
22
23
  let app;
24
+ let dataDir;
23
25
  const sent = [];
24
26
  const notices = [];
25
27
  const related = {};
@@ -30,11 +32,14 @@ before(async () => {
30
32
  for (const d of DIRS) await fs.rm(d, {recursive: true, force: true});
31
33
  setMailer(async (msg) => { sent.push(msg); });
32
34
  app = Fastify();
35
+ dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'booking-data-'));
33
36
  const authenticate = async (req) => { req.user = {id: 'u1', name: 'Sam', role: 'admin'}; };
34
37
  await app.register(booking, {
35
38
  prefix: '',
36
- auth: {authenticate, requirePermission: () => async () => {}},
39
+ // `x-deny` stands for a role without the permission.
40
+ auth: {authenticate, requirePermission: () => async (req, reply) => { if (req.headers['x-deny']) return reply.code(403).send({error: 'Forbidden'}); }},
37
41
  settings: {},
42
+ dataDir,
38
43
  hooks: {
39
44
  notify: (n) => notices.push(n), registerShortcode() {},
40
45
  registerRelatedItems: (kind, p) => { related[kind] = p; return true; }
@@ -49,6 +54,7 @@ after(async () => {
49
54
  if (savedPlugins === null) await fs.rm(PLUGINS, {force: true});
50
55
  else await fs.writeFile(PLUGINS, savedPlugins);
51
56
  if (savedContacts !== null) await fs.writeFile(CONTACTS, savedContacts);
57
+ if (dataDir) await fs.rm(dataDir, {recursive: true, force: true});
52
58
  });
53
59
 
54
60
  const call = async (method, url, body) => {
@@ -355,6 +361,63 @@ test('online: a service an extension makes online gives a call - the customer an
355
361
  }
356
362
  });
357
363
 
364
+ test('online: the call\'s chat is saved per session - counted on the booking, read by staff, seeds the reopened call', async () => {
365
+ const reg = bookingRegistry();
366
+ reg.register('test-online', {rules: (s) => (s?.slug === 'chat-call' ? {online: true} : {})});
367
+ try {
368
+ const all = Object.fromEntries(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'].map(d => [d, [{from: '00:00', to: '24:00'}]]));
369
+ let r = await call('POST', '/bookables', {name: 'Cara', kind: 'person', hours: all});
370
+ const cara = r.body;
371
+ r = await call('POST', '/services', {name: 'Chat call', duration: 30, minNoticeMinutes: 0, bookables: [cara.id]});
372
+ const svc = r.body;
373
+ const start = new Date(Math.ceil((Date.now() + 5 * 60000) / 60000) * 60000).toISOString();
374
+ r = await call('POST', '/bookings', {serviceId: svc.id, startAt: start, customer: {name: 'Gus Guest', email: 'gus@example.com'}, force: true});
375
+ assert.equal(r.status, 201, JSON.stringify(r.body));
376
+ const b = r.body;
377
+ const raw = JSON.parse(await fs.readFile(path.resolve('content/collections/booking-bookings/data.json'), 'utf8'))
378
+ .map(e => e.data ? {id: e.id, ...e.data} : e).find(x => x.id === b.id);
379
+ const room = b.hostUrl.split('/call/')[1].split('?')[0];
380
+ const hostKey = new URL(`http://x${b.hostUrl}`).searchParams.get('h');
381
+ const as = (p) => ({'x-call-id': p.id, 'x-call-secret': p.secret});
382
+ const post = (u, payload, headers = {}) => app.inject({method: 'POST', url: u, payload, headers});
383
+
384
+ assert.equal((await call('GET', `/bookings/${b.id}`)).body.callChat, 0, 'no chat yet');
385
+ const host = (await post(`/call/${room}/join`, {b: b.id, h: hostKey})).json();
386
+ const guest = (await post(`/call/${room}/join`, {b: b.id, t: raw.token})).json();
387
+ assert.equal(guest.state, 'waiting');
388
+ await post(`/call/${room}/answer`, {who: guest.id, yes: true}, as(host));
389
+ assert.equal((await post(`/call/${room}/chat`, {text: 'Hello Gus'}, as(host))).statusCode, 200);
390
+ assert.equal((await post(`/call/${room}/chat`, {text: 'Hi <Cara>'}, as(guest))).statusCode, 200);
391
+ await post(`/call/${room}/leave`, {}, as(guest));
392
+ await post(`/call/${room}/leave`, {}, as(host));
393
+ await new Promise(res => setTimeout(res, 30));
394
+
395
+ // Saved in the plugin's data folder, keyed by the session.
396
+ const file = JSON.parse(await fs.readFile(path.join(dataDir, 'call-chat.json'), 'utf8'));
397
+ assert.equal(file.rooms[room].items.filter(i => i.kind === 'chat').length, 2);
398
+ assert.equal((await call('GET', `/bookings/${b.id}`)).body.callChat, 2);
399
+ r = await call('GET', `/bookings/${b.id}/call-chat`);
400
+ assert.equal(r.status, 200);
401
+ assert.equal(r.body.messages, 2);
402
+ assert.deepEqual(r.body.items.filter(i => i.kind === 'chat').map(i => [i.name, i.text, i.mine]), [['Cara', 'Hello Gus', true], ['Gus Guest', 'Hi <Cara>', false]]);
403
+ assert.ok(r.body.items.every(i => !('from' in i)));
404
+ assert.ok(r.body.items.some(i => i.kind === 'system' && i.text === 'Gus Guest left'));
405
+ // Staff without booking read: refused. Not online / missing: 404.
406
+ assert.equal((await app.inject({method: 'GET', url: `/bookings/${b.id}/call-chat`, headers: {'x-deny': '1'}})).statusCode, 403);
407
+ assert.equal((await call('GET', '/bookings/nope/call-chat')).status, 404);
408
+
409
+ // Everyone left, the room is gone: rejoining shows the earlier chat.
410
+ const again = (await post(`/call/${room}/join`, {b: b.id, h: hostKey})).json();
411
+ const polled = (await app.inject({method: 'GET', url: `/call/${room}/poll?since=0`, headers: as(again)})).json();
412
+ const admitted = polled.events.find(e => e.type === 'admitted');
413
+ assert.ok(admitted.chat.some(i => i.text === 'Hello Gus') && admitted.chat.some(i => i.text === 'Hi <Cara>'));
414
+ await post(`/call/${room}/leave`, {}, as(again));
415
+ await call('DELETE', `/bookings/${b.id}`);
416
+ } finally {
417
+ reg.unregister('test-online');
418
+ }
419
+ });
420
+
358
421
  test('extensions: banner actions reach the bootstrap, only under their owner, one per id', async () => {
359
422
  const reg = bookingRegistry();
360
423
  assert.ok(reg.version >= 4);
@@ -443,3 +506,117 @@ test('an early-opened call lasts two hours from the click, not all the way to a
443
506
  const soon = {...b, callOpenedAt: '2031-01-10T09:00:00.000Z'};
444
507
  assert.deepEqual(callWindow(soon, at('2031-01-10T09:05:00.000Z')), {opensAt: '2031-01-10T09:00:00.000Z', closesAt: '2031-01-10T12:00:00.000Z'}, 'joined up when they meet');
445
508
  });
509
+
510
+ test('1.6 Add people: the host adds a team member and an address; each gets their own &g= pass (never the customer token or host key); they knock', async () => {
511
+ const reg = bookingRegistry();
512
+ reg.register('test-online', {rules: (s) => (s?.slug === 'team-call' ? {online: true} : {})});
513
+ const {createUser, deleteUser} = await import('../../../server/services/users.js');
514
+ await (await import('../../../server/services/roles.js')).load();
515
+ const {setInviteMailer, resetInviteLimits} = await import('../../../server/services/callInvites.js');
516
+ const {whoIsCalling} = await import('../lib/call.js');
517
+ const tag = Math.random().toString(36).slice(2, 7);
518
+ const tess = await createUser({name: `Tess Host ${tag}`, email: `tess-${tag}@example.test`, password: 'password123', role: 'user'});
519
+ const mate = await createUser({name: `Milo Mate ${tag}`, email: `milo-${tag}@example.test`, password: 'password123', role: 'user'});
520
+ const mails = [];
521
+ setInviteMailer(async (m) => { mails.push(m); });
522
+ resetInviteLimits();
523
+ try {
524
+ const all = Object.fromEntries(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'].map(d => [d, [{from: '00:00', to: '24:00'}]]));
525
+ let r = await call('POST', '/bookables', {name: 'Tess', kind: 'person', hours: all, userId: tess.id});
526
+ const bk = r.body;
527
+ assert.equal(bk.userId, tess.id, 'the person is a user of the site');
528
+ const svc = (await call('POST', '/services', {name: 'Team call', duration: 30, minNoticeMinutes: 0, bookables: [bk.id]})).body;
529
+ const start = new Date(Math.ceil((Date.now() + 5 * 60000) / 60000) * 60000).toISOString();
530
+ r = await call('POST', '/bookings', {serviceId: svc.id, startAt: start, customer: {name: 'Gus Guest', email: 'gus@example.com'}, force: true, notify: false});
531
+ assert.equal(r.status, 201, JSON.stringify(r.body));
532
+ const b = r.body;
533
+ const raw = await reg.services.getBooking(b.id);
534
+ const room = b.hostUrl.split('/call/')[1].split('?')[0];
535
+ const as = (p) => ({'x-call-id': p.id, 'x-call-secret': p.secret});
536
+ const post = (u, payload, headers = {}) => app.inject({method: 'POST', url: u, payload, headers});
537
+ const host = (await post(`/call/${room}/join`, {b: b.id, h: raw.hostKey})).json();
538
+ const guest = (await post(`/call/${room}/join`, {b: b.id, t: raw.token})).json();
539
+ assert.equal(host.canInvite, true);
540
+ assert.equal(guest.canInvite, false);
541
+
542
+ let s = await app.inject({method: 'GET', url: `/call/${room}/people?q=Milo`, headers: as(host)});
543
+ assert.equal(s.statusCode, 200);
544
+ assert.ok(s.json().people.some(p => p.kind === 'user' && p.id === mate.id));
545
+ assert.ok(!(await app.inject({method: 'GET', url: `/call/${room}/people?q=Tess`, headers: as(host)})).json().people.some(p => p.id === tess.id), 'not the host');
546
+ assert.equal((await app.inject({method: 'GET', url: `/call/${room}/people?q=Milo`, headers: as(guest)})).statusCode, 403, 'the customer cannot');
547
+
548
+ r = await post(`/call/${room}/invite`, {people: [{kind: 'user', id: mate.id}, {name: 'Ext Person', email: 'ext@example.com'}], via: {email: true}}, {...as(host), host: 'site.test'});
549
+ assert.equal(r.statusCode, 200, r.body);
550
+ assert.deepEqual(r.json().results.map(x => [x.name, x.emailed]), [[`Milo Mate ${tag}`, true], ['Ext Person', true]]);
551
+ const linkTo = (to) => new URL(mails.find(m => m.to === to).text.match(/https?:\/\/\S+/)[0]);
552
+ const milo = linkTo(mate.email);
553
+ const ext = linkTo('ext@example.com');
554
+ assert.equal(milo.pathname, `/booking/call/${room}`);
555
+ assert.equal(milo.searchParams.get('b'), b.id);
556
+ assert.ok(milo.searchParams.get('g') && !milo.searchParams.get('t') && !milo.searchParams.get('h'));
557
+ assert.notEqual(milo.searchParams.get('g'), ext.searchParams.get('g'));
558
+ for (const u of [milo, ext]) assert.ok(!u.href.includes(raw.token) && !u.href.includes(raw.hostKey), 'no token or host key reused');
559
+
560
+ const kept = await reg.services.getBooking(b.id);
561
+ assert.ok(kept.callExtraKey && kept.callExtraKey !== raw.token && kept.callExtraKey !== raw.hostKey);
562
+ assert.equal((await call('GET', `/bookings/${b.id}`)).body.callExtraKey, undefined, 'never shown in the admin');
563
+
564
+ // They knock with their own name; the customer keeps their place.
565
+ const m1 = (await post(`/call/${room}/join`, {b: b.id, g: milo.searchParams.get('g'), name: 'Milo'})).json();
566
+ const e1 = (await post(`/call/${room}/join`, {b: b.id, g: ext.searchParams.get('g'), name: 'Ext'})).json();
567
+ assert.equal(m1.state, 'waiting');
568
+ assert.equal(e1.state, 'waiting');
569
+ assert.equal(m1.canInvite, false);
570
+ assert.deepEqual((await app.inject({method: 'GET', url: `/call/${room}/poll?since=0`, headers: as(host)})).json().events
571
+ .filter(e => e.type === 'waiting').at(-1).waiting.map(w => w.name), ['Gus Guest', 'Milo', 'Ext']);
572
+ assert.equal((await post(`/call/${room}/join`, {b: b.id, g: 'abcdefgh.AAAAAAAAAAAAAAAAAAAAAAAA', name: 'X'})).statusCode, 404, 'forged');
573
+ const who = whoIsCalling(kept, room, {g: milo.searchParams.get('g')});
574
+ assert.equal(who.invited, true);
575
+ assert.equal(who.host, false);
576
+
577
+ // Copy link: a new pass each time.
578
+ const copy = (await post(`/call/${room}/invite`, {people: []}, {...as(host), host: 'site.test'})).json();
579
+ assert.match(copy.link, new RegExp(`/booking/call/${room}\\?b=${b.id}&g=`));
580
+ for (const p of [host, guest, m1, e1]) await post(`/call/${room}/leave`, {}, as(p));
581
+ await call('DELETE', `/bookings/${b.id}`);
582
+ } finally {
583
+ setInviteMailer(null);
584
+ reg.unregister('test-online');
585
+ for (const u of [tess, mate]) await deleteUser(u.id).catch(() => {});
586
+ }
587
+ });
588
+
589
+ test('1.6 Add people: a person with no account on the site (the host link may be anyone\'s) gets Copy link only - no directory, nothing sent', async () => {
590
+ const reg = bookingRegistry();
591
+ reg.register('test-online', {rules: (s) => (s?.slug === 'outside-call' ? {online: true} : {})});
592
+ const {setInviteMailer} = await import('../../../server/services/callInvites.js');
593
+ const mails = [];
594
+ setInviteMailer(async (m) => { mails.push(m); });
595
+ try {
596
+ const all = Object.fromEntries(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'].map(d => [d, [{from: '00:00', to: '24:00'}]]));
597
+ const olga = (await call('POST', '/bookables', {name: 'Olga Outside', kind: 'person', hours: all, email: 'olga@elsewhere.example'})).body;
598
+ const svc = (await call('POST', '/services', {name: 'Outside call', duration: 30, minNoticeMinutes: 0, bookables: [olga.id]})).body;
599
+ const start = new Date(Math.ceil((Date.now() + 5 * 60000) / 60000) * 60000).toISOString();
600
+ const b = (await call('POST', '/bookings', {serviceId: svc.id, startAt: start, customer: {name: 'Gus', email: 'gus2@example.com'}, force: true, notify: false})).body;
601
+ const raw = await reg.services.getBooking(b.id);
602
+ const room = b.hostUrl.split('/call/')[1].split('?')[0];
603
+ const post = (u, payload, headers = {}) => app.inject({method: 'POST', url: u, payload, headers});
604
+ const host = (await post(`/call/${room}/join`, {b: b.id, h: raw.hostKey})).json();
605
+ assert.equal(host.canInvite, true);
606
+ assert.deepEqual(host.inviteOptions, {search: false, typed: false, copyLink: true, external: true});
607
+ const as = {'x-call-id': host.id, 'x-call-secret': host.secret};
608
+ assert.deepEqual((await app.inject({method: 'GET', url: `/call/${room}/people?q=a`, headers: as})).json(), {people: []}, 'no team directory, no contacts');
609
+ for (const person of [{name: 'Spam', email: 'victim@example.com'}, {kind: 'user', id: 'someone'}, {kind: 'contact', id: 'c1'}]) {
610
+ assert.equal((await post(`/call/${room}/invite`, {people: [person], via: {email: true}}, as)).statusCode, 400);
611
+ }
612
+ assert.equal(mails.length, 0, 'nothing sent in the site\'s name');
613
+ const copy = (await post(`/call/${room}/invite`, {people: []}, as)).json();
614
+ assert.match(copy.link, new RegExp(`/booking/call/${room}\\?b=${b.id}&g=`), 'Copy link: their own pass');
615
+ const knock = (await post(`/call/${room}/join`, {b: b.id, g: new URL(copy.link).searchParams.get('g'), name: 'Friend'})).json();
616
+ assert.equal(knock.state, 'waiting');
617
+ await call('DELETE', `/bookings/${b.id}`);
618
+ } finally {
619
+ setInviteMailer(null);
620
+ reg.unregister('test-online');
621
+ }
622
+ });