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.
- package/admin/js/lib/related.js +1 -1
- package/admin/js/templates/docs/usage-tools.html +4 -0
- package/package.json +1 -1
- package/plugins/_lib/admin/ui/transcript.js +154 -0
- package/plugins/_lib/call/chatStore.js +103 -0
- package/plugins/_lib/call/rooms.js +104 -21
- package/plugins/_lib/call/routes.js +55 -5
- package/plugins/_lib/public/call/call.css +1 -1
- package/plugins/_lib/public/call/call.js +229 -62
- package/plugins/booking/CLAUDE.md +9 -0
- package/plugins/booking/admin/lib/kit.js +1 -0
- package/plugins/booking/admin/views/panels.js +10 -0
- package/plugins/booking/docs/guide.md +1 -1
- package/plugins/booking/plugin.js +41 -3
- package/plugins/booking/plugin.json +1 -1
- package/plugins/booking/tests/api.test.js +64 -1
- package/plugins/free-tier.lock.json +9 -9
- package/server/routes/api/calls.js +26 -6
- package/server/services/instantCalls.js +54 -4
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
"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
|
-
|
|
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@
|
|
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.
|
|
76
|
+
"version": "1.5.0",
|
|
77
77
|
"files": {
|
|
78
|
-
"CLAUDE.md": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
103
|
-
"plugin.json": "
|
|
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": "
|
|
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
|
|
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}.
|
|
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
|
-
|
|
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 [];
|