domma-cms 0.105.2 → 0.106.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.
@@ -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,6 +26,8 @@
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';
@@ -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.5.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,13 @@ 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
+ calls = mountCallRoutes(fastify, {prefix: '/call', ns: 'booking', ...(chatStore ? {chat: chatStore} : {}), resolve: async (request, room, body) => {
792
830
  const b = await getBooking(String(body?.b || '')).catch(() => null);
793
831
  // A class opened early ("Call now") is open for everyone in the session - someone who booked in
794
832
  // after the click too: take the session's latest opening when this booking has none of its own.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "booking",
3
3
  "displayName": "Booking",
4
- "version": "1.4.1",
4
+ "version": "1.5.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",
@@ -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);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "note": "Written by `make sync-free-tier` in dcms-marketplace. Do not edit these plugins here - edit them there and sync.",
3
- "source": "dcms-marketplace@25153e0",
3
+ "source": "dcms-marketplace@ba58fcb",
4
4
  "plugins": {
5
5
  "blog": {
6
6
  "version": "1.9.4",
@@ -73,19 +73,19 @@
73
73
  }
74
74
  },
75
75
  "booking": {
76
- "version": "1.4.1",
76
+ "version": "1.5.0",
77
77
  "files": {
78
- "CLAUDE.md": "c4401810011ed98fe488e89e03d32ee601025818d1f354ec07fcd9f9add1115a",
78
+ "CLAUDE.md": "258f342c1acdd1d7562a3409bc7c8622828308c243549255d2ac75832be6d84e",
79
79
  "admin/css/index.css": "4f7731921c0c5038cb1566a6ab2d43ffc35bf9b161e58f87a0cff423e81b47fa",
80
80
  "admin/lib/details.js": "8b37536bfa9ccfcc5265cc957ba8266d3f5f777133e7c0946ae49540f1c80e3c",
81
81
  "admin/lib/diary-layout.js": "d818c79001fb9a9ecc15c96239ba961b0a1d98b9e8fce903eefb57d85adf2a73",
82
82
  "admin/lib/extension-sections.js": "0588ad47660e511b0dd8077bb9c3999c775d97597333a75e87ee12d915bbf00b",
83
- "admin/lib/kit.js": "661e9c407bf398694dfa08105590065a6352594d526f3c09c31b180c840045bf",
83
+ "admin/lib/kit.js": "b69b02172d76ef80879da3f003cc1e687c6149626c9e58af51729c2e4a3ff959",
84
84
  "admin/templates/booking.html": "20b9a3bfe3e5d5bbee613f29be99551b12790fb38f80d119585360cc38e9ec57",
85
85
  "admin/views/booking.js": "4eddf6c0b60dbbba4c6e35352df247a3d34b66ee1d43c51fe2d53f894707348f",
86
86
  "admin/views/bookings.js": "bf17db82ed4d2f5c29ac924dd60dc0b84c8bd9c8ce32da6cbb011527699a56a6",
87
87
  "admin/views/diary.js": "9e8976e67c2232d353b3d9a1ef0d0503cc4d06b710d54d0cba84fb5b796fdfe9",
88
- "admin/views/panels.js": "9d1c45e56d2a7a8caa12e9af29545c808ffb88da3ea48397d55aae19e802ec69",
88
+ "admin/views/panels.js": "1d269d90553a5482fd6ebd18180cb736011770f0204d5c1306948abcb4b676b2",
89
89
  "admin/views/people.js": "58cb9cc550b48c959a5572a4d25650ea2b32b658c9d0bf4c244e4abe0277c211",
90
90
  "admin/views/services.js": "122326d222ed16dc8ec903fef6f62f6a7638c31d73c4612b7a8c2ced38358068",
91
91
  "admin/views/settings.js": "299297b47a6509986276a859807a33ea225681d28e0e9cd06cdf36b0f6ec0c11",
@@ -93,14 +93,14 @@
93
93
  "collections/booking-bookings.json": "fa0b15cc81b4a2114734a0bc8adc4b4d25049d16cf62549b83634cdbeb543b52",
94
94
  "collections/booking-services.json": "cecb500a9215a0eba894e6b4c9ae78f5fbeb209830d07d47050c56c75143f66a",
95
95
  "config.js": "5565fd627481cf809322257cc300a7cbb7ccbf6cae076b4cf8a7453fcf554bd3",
96
- "docs/guide.md": "545b050500a09ffdf7bc620e01f0b63bf59e92dfe855ba3201419b0bcc92a1fc",
96
+ "docs/guide.md": "dd6a8250ac25914dc8ac6922167c92e815f69f464d61b424dfab42770af59626",
97
97
  "inject/body.html": "ab55ce945eec2302eb0c836a4626cffa5e091d050c6a59c6f2af332b05570777",
98
98
  "lib/call.js": "7dfa0ae60be87d8f7f26b4360d5c1144322abe66eb61b233ed812e35bb8e59bd",
99
99
  "lib/extensions.js": "8cec28ff8685daad8083b23f034f1c3bf82fd2322943dd19d46570ccdc311105",
100
100
  "lib/mail.js": "cad8f409df62d6e42f0f4024ec364bbe018a62b842fa3f7c38786fd4e7f92408",
101
101
  "lib/store.js": "270161f907c68de39d32758931e86c290582524af4650e722c2c3be209acc875",
102
- "plugin.js": "963e8190dbcaec71915d73402648899bb38135b25ec869a659d0938d03868682",
103
- "plugin.json": "704402863b0aec563046b347d4341680e2dc9739db967bec0a933c2bf0e4e5c9",
102
+ "plugin.js": "afe2c3dd15c69515828f513478bb40f2ea3788a8b67ace571c01198acd79dc3b",
103
+ "plugin.json": "be0576b42a5fa60c04da8131a5d20113c020eb8088db228892f0715e9b1808f8",
104
104
  "plugin.public.js": "5f2dc4f01202b16ae804364a33c547509d1fa4026eadebac440730234aa266b5",
105
105
  "public/booking.css": "8b88989fdd04e50efc1688a2f32fe2e422299d7d13b81bb8fce0cc162fa99df6",
106
106
  "public/booking.js": "7dfc3d205696967013886567f67a867443c2e370bac29019d69911d695fdbade",
@@ -110,7 +110,7 @@
110
110
  "public/lib/time.js": "48e13a23e3d7a4916e2097e8f87c03a87d37b71c4ef037a5a16143c6a4185c41",
111
111
  "templates/booking.html": "a4995de46dfdab7e692b56f21347f29c76320cf3b0477889dc3c5d14e0b6dcd7",
112
112
  "tests/admin-diary.test.js": "4e34b447d62b41272ceec49bad9d491ae00dd04f4760d8a7a3046be1912f4abe",
113
- "tests/api.test.js": "57ce73f48776e15454794405bae5b9674baeda46a96e4a11d0e253c6a977a764",
113
+ "tests/api.test.js": "937a79e5af9af6b3f12b7ae15dabc470a91c7fe8ca03e3f7f261e4e641dfe3dc",
114
114
  "tests/public.test.js": "0b7b626d60df5a3d73d7c0077b73f59b62a9fded98ce84a8d93cd32cd0333159",
115
115
  "tests/pure.test.js": "8ba0c8f76b4c9efcc422f39ff29509612544d81f760025a4b9a5594af14e7bcf"
116
116
  }
@@ -10,7 +10,8 @@
10
10
  * GET /api/calls/contacts?q= contacts the caller may READ (own + shared), to pick (calls.create)
11
11
  * GET /api/calls/contact/:id one contact the caller may read, to fill in (calls.create)
12
12
  * POST /api/calls {name, email?, contactId?, notify} -> 201 {id, hostUrl, joinUrl, emailed, emailLimited?, expiresAt}
13
- * GET /api/calls?contact=<id> the caller's recent calls with that contact (calls.read)
13
+ * GET /api/calls?contact=<id> the caller's recent calls with that contact, with `messages` (calls.read)
14
+ * GET /api/calls/:id/chat the call's saved chat (read-only transcript) - its HOST or an admin (calls.read)
14
15
  *
15
16
  * Contacts are read through `readableContactIds` (services/hooks.js): the
16
17
  * caller's own, plus whatever a sharing plugin (Contacts Pro) lets them read -
@@ -29,13 +30,14 @@ import {authenticate as defaultAuthenticate, requirePermission as defaultRequire
29
30
  import {getEntry, listEntries} from '../../services/collections.js';
30
31
  import {readableContactIds} from '../../services/hooks.js';
31
32
  import {getPermissionsFor} from '../../services/roles.js';
32
- import {getEffectiveRoles} from '../../services/userRoles.js';
33
+ import {getEffectiveLevel, getEffectiveRoles} from '../../services/userRoles.js';
33
34
  import {isToolEnabled} from '../../services/tools.js';
34
35
  import {mountCallRoutes} from '../../../plugins/_lib/call/routes.js';
36
+ import {whoOf} from '../../../plugins/_lib/call/rooms.js';
35
37
  import {getBaseUrl} from '../public.js';
36
38
  import {
37
- EMAIL_RE, callMailReady, callPaths, callsOfContact, createCall, getCall, instantCallsEnabled, isOpen,
38
- mayEmailAddress, mayStartCall, sendCallEmail, whoIsCalling
39
+ EMAIL_RE, callChat, callMailReady, callPaths, callsOfContact, chatCountOf, createCall, getCall, instantCallsEnabled, isOpen,
40
+ mayEmailAddress, mayStartCall, saveCallChat, sendCallEmail, whoIsCalling
39
41
  } from '../../services/instantCalls.js';
40
42
 
41
43
  const CONTACTS_SLUG = 'contacts-contacts';
@@ -122,13 +124,31 @@ export async function callsRoutes(fastify, opts = {}) {
122
124
  if (!contact) return fail(reply, 400, 'contact is required');
123
125
  const now = Date.now();
124
126
  const list = (await callsOfContact(contact, uidOf(request))).slice(0, 20)
125
- .map(c => ({id: c.id, name: c.name, createdAt: c.createdAt, expiresAt: c.expiresAt, open: isOpen(c, now)}));
127
+ .map(c => ({id: c.id, name: c.name, createdAt: c.createdAt, expiresAt: c.expiresAt, open: isOpen(c, now), messages: chatCountOf(c)}));
126
128
  return reply.send(list);
127
129
  });
128
130
 
131
+ // The call's chat, saved against it: for the host who made the call, or an admin. Anyone else: 404 (not 403 -
132
+ // whether a call exists is the host's business). `mine` marks the host's own lines for the bubbles.
133
+ fastify.get('/:id/chat', can('read'), async (request, reply) => {
134
+ const rec = await getCall(String(request.params.id));
135
+ const uid = uidOf(request);
136
+ let admin = false;
137
+ try { admin = getEffectiveLevel(request.user || {}) <= 1; } catch { admin = false; }
138
+ if (!rec || !uid || (rec.hostId !== uid && !admin)) return fail(reply, 404, 'That call could not be found.');
139
+ const hostWho = whoOf(`${rec.id}:host`, '');
140
+ const mineToo = rec.hostId === uid;
141
+ const items = (await callChat(rec.id)).map(({from, ...i}) => ({...i, mine: mineToo && i.kind === 'chat' && i.who === hostWho}));
142
+ reply.header('Cache-Control', 'no-store');
143
+ return reply.send({id: rec.id, name: rec.name, hostName: rec.hostName, createdAt: rec.createdAt, expiresAt: rec.expiresAt,
144
+ messages: chatCountOf(rec), items});
145
+ });
146
+
129
147
  // The call engine: a room per call, guests knock, the host lets them in, open createdAt..expiresAt.
130
148
  mountCallRoutes(fastify, {
131
149
  prefix: '/room', ns: 'instant', ...(opts.rooms ? {rooms: opts.rooms} : {}),
132
- resolve: async (request, room, body) => whoIsCalling(await getCall(String(room)), {t: body?.t, h: body?.h})
150
+ resolve: async (request, room, body) => whoIsCalling(await getCall(String(room)), {t: body?.t, h: body?.h}),
151
+ // The chat is saved against the call record, so a call reopened (or after a restart) shows it again.
152
+ chat: {load: (room) => callChat(String(room)), save: (room, item) => saveCallChat(String(room), item)}
133
153
  });
134
154
  }
@@ -19,7 +19,10 @@
19
19
  * actions and list stand aside (checked per request), so nobody sees two.
20
20
  *
21
21
  * A record: {id, name, email, contactId, hostId, hostName, token, hostKey,
22
- * createdAt, expiresAt}. Kept in memory and in one JSON file with the sign-in
22
+ * createdAt, expiresAt, chat?}. `chat` is the call's chat, saved against the
23
+ * call (the engine's items - typed messages plus joined/left lines - the last
24
+ * CHAT_MAX); the engine seeds a reopened room from it, and the host (or an
25
+ * admin) reads it afterwards through GET /api/calls/:id/chat. Kept in memory and in one JSON file with the sign-in
23
26
  * sessions (content/sessions/ - runtime state, never committed; see
24
27
  * siteGitignore.js), so a restart keeps the links working. The call itself (who
25
28
  * is in it) is the call engine's, in memory. Records are forgotten KEEP_DAYS
@@ -44,6 +47,8 @@ import {createTransport, escapeHtml, isSmtpConfigured, sendEmail} from './email.
44
47
  export const INSTANT_HOURS = 3;
45
48
  export const KEEP_DAYS = 30;
46
49
  export const MAX_KEPT = 2000;
50
+ /** Chat items kept on one call record (the engine's HISTORY_MAX). */
51
+ export const CHAT_MAX = 500;
47
52
  export const CALL_PATH = '/_call';
48
53
  export const ROOM_API = '/api/calls/room';
49
54
  export const ID_RE = /^[\w-]{16,40}$/;
@@ -141,9 +146,14 @@ function registerActions() {
141
146
  async list({id, user}) {
142
147
  if (!user?.id || !mayCall(user, 'read')) return [];
143
148
  const now = Date.now();
144
- return (await callsOfContact(id, String(user.id))).slice(0, 5).map(c => ({
145
- id: c.id, title: 'Video call', startAt: c.createdAt, past: !isOpen(c, now), meta: isOpen(c, now) ? 'open now' : ''
146
- }));
149
+ return (await callsOfContact(id, String(user.id))).slice(0, 5).map((c) => {
150
+ const n = chatCountOf(c);
151
+ return {
152
+ id: c.id, title: 'Video call', startAt: c.createdAt, past: !isOpen(c, now), meta: isOpen(c, now) ? 'open now' : '',
153
+ // The admin's Calls list offers "N messages" - a read-only transcript (GET /api/calls/:id/chat).
154
+ ...(n ? {transcript: {url: `/calls/${encodeURIComponent(c.id)}/chat`, count: n, title: `Video call with ${c.name || 'a guest'}`}} : {})
155
+ };
156
+ });
147
157
  }
148
158
  });
149
159
  // An email in the mail reader: "Call Sam now…" with the sender filled in.
@@ -234,6 +244,46 @@ export async function getCall(id) {
234
244
  return (await load()).get(id) || null;
235
245
  }
236
246
 
247
+ /** The chat item as it is kept on a record; null for junk. System lines other than joined/left are not kept. */
248
+ function chatItem(raw) {
249
+ if (!raw || typeof raw !== 'object' || typeof raw.text !== 'string' || !raw.id) return null;
250
+ const kind = raw.kind === 'system' ? 'system' : 'chat';
251
+ if (kind === 'system' && raw.event !== 'joined' && raw.event !== 'left') return null;
252
+ const item = {id: String(raw.id).slice(0, 64), seq: Number(raw.seq) || 0, at: String(raw.at || ''), kind,
253
+ from: String(raw.from || '').slice(0, 64), who: String(raw.who || '').slice(0, 64),
254
+ name: String(raw.name || '').slice(0, 60), text: raw.text.slice(0, 1000)};
255
+ if (kind === 'system') item.event = raw.event;
256
+ return item;
257
+ }
258
+
259
+ /**
260
+ * Save one chat item against its call (the engine's `chat.save`). Queued with
261
+ * every other write to the file; the last CHAT_MAX are kept. A call that is no
262
+ * longer on record (forgotten, or never) keeps nothing.
263
+ */
264
+ export async function saveCallChat(id, raw) {
265
+ const item = chatItem(raw);
266
+ if (!item || typeof id !== 'string' || !ID_RE.test(id)) return;
267
+ const all = await load();
268
+ const rec = all.get(id);
269
+ if (!rec) return;
270
+ const chat = Array.isArray(rec.chat) ? rec.chat : [];
271
+ if (chat.some(c => c?.id === item.id)) return;
272
+ chat.push(item);
273
+ if (chat.length > CHAT_MAX) chat.splice(0, chat.length - CHAT_MAX);
274
+ rec.chat = chat;
275
+ await save(all);
276
+ }
277
+
278
+ /** A call's saved chat, oldest first ([] for none). */
279
+ export async function callChat(id) {
280
+ const rec = await getCall(id);
281
+ return Array.isArray(rec?.chat) ? rec.chat.map(c => ({...c})) : [];
282
+ }
283
+
284
+ /** How many typed messages a call's chat holds (not the joined/left lines). */
285
+ export const chatCountOf = (rec) => (Array.isArray(rec?.chat) ? rec.chat.filter(c => c?.kind === 'chat').length : 0);
286
+
237
287
  /** One host's calls with one contact, newest first. */
238
288
  export async function callsOfContact(contactId, hostId) {
239
289
  if (!contactId || !hostId) return [];