domma-cms 0.106.0 → 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.
- package/admin/js/templates/docs/usage-tools.html +7 -0
- package/package.json +1 -1
- package/plugins/_lib/call/invite.js +172 -0
- package/plugins/_lib/call/rooms.js +19 -5
- package/plugins/_lib/call/routes.js +78 -3
- package/plugins/_lib/public/call/call.css +1 -1
- package/plugins/_lib/public/call/call.js +287 -23
- package/plugins/booking/CLAUDE.md +18 -1
- package/plugins/booking/docs/guide.md +1 -0
- package/plugins/booking/lib/call.js +18 -2
- package/plugins/booking/lib/store.js +2 -0
- package/plugins/booking/plugin.js +59 -6
- package/plugins/booking/plugin.json +1 -1
- package/plugins/booking/plugin.public.js +4 -4
- package/plugins/booking/tests/api.test.js +114 -0
- package/plugins/free-tier.lock.json +10 -10
- package/server/routes/api/calls.js +25 -3
- package/server/routes/call-public.js +4 -3
- package/server/services/callInvites.js +238 -0
- package/server/services/hooks.js +55 -0
- package/server/services/instantCalls.js +30 -3
- package/server/services/plugins.js +6 -1
|
@@ -32,13 +32,13 @@ import {getConfig} from '../../server/config.js';
|
|
|
32
32
|
import {getPluginStates} from '../../server/services/plugins.js';
|
|
33
33
|
import {getPermissionsFor} from '../../server/services/roles.js';
|
|
34
34
|
import {getEffectiveRoles} from '../../server/services/userRoles.js';
|
|
35
|
-
import {listUsers} from '../../server/services/users.js';
|
|
35
|
+
import {getUserById, listUsers} from '../../server/services/users.js';
|
|
36
36
|
import {isToolEnabled} from '../../server/services/tools.js';
|
|
37
37
|
import {cleanBookable, cleanCustomer, cleanService, whenLabel} from './public/lib/clean.js';
|
|
38
38
|
import {bookingRange, HOLDING, slotsFor} from './public/lib/slots.js';
|
|
39
39
|
import {addDays, atWallClock, isDate, wallClock} from './public/lib/time.js';
|
|
40
40
|
import {bookingRegistry} from './lib/extensions.js';
|
|
41
|
-
import {hostPath, joinPath, sessionOf, whoIsCalling} from './lib/call.js';
|
|
41
|
+
import {callWindow, hostPath, invitePath, joinPath, sessionOf, whoIsCalling} from './lib/call.js';
|
|
42
42
|
import {compose, icsFor, mailReady, send, siteAddress, siteTitle} from './lib/mail.js';
|
|
43
43
|
import {
|
|
44
44
|
BOOKABLES, BOOKINGS, NAME, SERVICES, SlotTaken, allBookables, allBookings, allServices, calendarBusy, changeBooking,
|
|
@@ -87,7 +87,7 @@ const publicFields = (list) => list.map(f => ({
|
|
|
87
87
|
*/
|
|
88
88
|
const adminBooking = (b) => {
|
|
89
89
|
if (!b) return b;
|
|
90
|
-
const {token, hostKey, ...rest} = b;
|
|
90
|
+
const {token, hostKey, callExtraKey, ...rest} = b;
|
|
91
91
|
return b.online === true && hostKey ? {...rest, hostUrl: hostPath(settingsNow(), b)} : rest;
|
|
92
92
|
};
|
|
93
93
|
|
|
@@ -123,7 +123,7 @@ function registerShortcodes(hooks) {
|
|
|
123
123
|
});
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
export const ASSET_V = '1.
|
|
126
|
+
export const ASSET_V = '1.6.0';
|
|
127
127
|
|
|
128
128
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
129
129
|
|
|
@@ -826,7 +826,57 @@ export default async function booking(fastify, options) {
|
|
|
826
826
|
chatStore = createChatStore(path.join(dataDir, 'call-chat.json'));
|
|
827
827
|
whoOf = (await import('../_lib/call/rooms.js')).whoOf || null;
|
|
828
828
|
} catch { chatStore = null; }
|
|
829
|
-
|
|
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) => {
|
|
830
880
|
const b = await getBooking(String(body?.b || '')).catch(() => null);
|
|
831
881
|
// A class opened early ("Call now") is open for everyone in the session - someone who booked in
|
|
832
882
|
// after the click too: take the session's latest opening when this booking has none of its own.
|
|
@@ -836,7 +886,10 @@ export default async function booking(fastify, options) {
|
|
|
836
886
|
.map(x => x.callOpenedAt).sort().at(-1);
|
|
837
887
|
if (opened) b.callOpenedAt = opened;
|
|
838
888
|
}
|
|
839
|
-
|
|
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;
|
|
840
893
|
}});
|
|
841
894
|
} catch (err) {
|
|
842
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
|
+
"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.
|
|
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:
|
|
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>`
|
|
@@ -506,3 +506,117 @@ test('an early-opened call lasts two hours from the click, not all the way to a
|
|
|
506
506
|
const soon = {...b, callOpenedAt: '2031-01-10T09:00:00.000Z'};
|
|
507
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');
|
|
508
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
|
+
});
|
|
@@ -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@f582d78",
|
|
4
4
|
"plugins": {
|
|
5
5
|
"blog": {
|
|
6
6
|
"version": "1.9.4",
|
|
@@ -73,9 +73,9 @@
|
|
|
73
73
|
}
|
|
74
74
|
},
|
|
75
75
|
"booking": {
|
|
76
|
-
"version": "1.
|
|
76
|
+
"version": "1.6.0",
|
|
77
77
|
"files": {
|
|
78
|
-
"CLAUDE.md": "
|
|
78
|
+
"CLAUDE.md": "9bcab41375a2aaecbdd332766ceca5b18bf9dafa99d4c8315549e38033c1f5c5",
|
|
79
79
|
"admin/css/index.css": "4f7731921c0c5038cb1566a6ab2d43ffc35bf9b161e58f87a0cff423e81b47fa",
|
|
80
80
|
"admin/lib/details.js": "8b37536bfa9ccfcc5265cc957ba8266d3f5f777133e7c0946ae49540f1c80e3c",
|
|
81
81
|
"admin/lib/diary-layout.js": "d818c79001fb9a9ecc15c96239ba961b0a1d98b9e8fce903eefb57d85adf2a73",
|
|
@@ -93,15 +93,15 @@
|
|
|
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": "186e2419f44ff5b13dfbcea709dc8616f3817c260717839a9fa51834ab73f718",
|
|
97
97
|
"inject/body.html": "ab55ce945eec2302eb0c836a4626cffa5e091d050c6a59c6f2af332b05570777",
|
|
98
|
-
"lib/call.js": "
|
|
98
|
+
"lib/call.js": "87c11b8531e1a6d247d52d4e03ea8e5aa06a1af49cbd7cbd0af97e2586237f7b",
|
|
99
99
|
"lib/extensions.js": "8cec28ff8685daad8083b23f034f1c3bf82fd2322943dd19d46570ccdc311105",
|
|
100
100
|
"lib/mail.js": "cad8f409df62d6e42f0f4024ec364bbe018a62b842fa3f7c38786fd4e7f92408",
|
|
101
|
-
"lib/store.js": "
|
|
102
|
-
"plugin.js": "
|
|
103
|
-
"plugin.json": "
|
|
104
|
-
"plugin.public.js": "
|
|
101
|
+
"lib/store.js": "7aa7967e4694e5a3b85378f60302e5a47394b1d90c7dd86e09578156f1c13e03",
|
|
102
|
+
"plugin.js": "90424e681de6c086dcc316c734ddb907c7572e452949ced4cc756205a4798f6e",
|
|
103
|
+
"plugin.json": "8f7a5b1ed1e0bbfb1e933906b2c63bff4fbfc62868244b2077b1090d87fc3cc0",
|
|
104
|
+
"plugin.public.js": "4d5b4e8ad6943182ee3b6c6e6f6762a2d3f676d8d7ee363fcb07bd47d3d6532e",
|
|
105
105
|
"public/booking.css": "8b88989fdd04e50efc1688a2f32fe2e422299d7d13b81bb8fce0cc162fa99df6",
|
|
106
106
|
"public/booking.js": "7dfc3d205696967013886567f67a867443c2e370bac29019d69911d695fdbade",
|
|
107
107
|
"public/lib/clean.js": "d591be9ea8462705b7bc8806670027fdd4d6d4764777da08e91a010d27ec1651",
|
|
@@ -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": "43ae40477a43a03d21096038f317d745bc08cd51767c97548bf0601e99ebdd97",
|
|
114
114
|
"tests/public.test.js": "0b7b626d60df5a3d73d7c0077b73f59b62a9fded98ce84a8d93cd32cd0333159",
|
|
115
115
|
"tests/pure.test.js": "8ba0c8f76b4c9efcc422f39ff29509612544d81f760025a4b9a5594af14e7bcf"
|
|
116
116
|
}
|
|
@@ -25,6 +25,9 @@
|
|
|
25
25
|
* The call itself (no sign-in - the link is the credential):
|
|
26
26
|
* /api/calls/room/:id/{join,poll,signal,chat,answer,recording,leave} (plugins/_lib/call/routes.js)
|
|
27
27
|
* join takes {t} (the guest) or {h} (the host); guests knock and the host lets them in.
|
|
28
|
+
* "Add people" (the host, in the call): GET /api/calls/room/:id/people?q= - the host's readable contacts and the
|
|
29
|
+
* team; POST /api/calls/room/:id/invite - each person gets their own Join link `?g=<pass>` (never the guest's
|
|
30
|
+
* token), by email, notification and Messages as chosen (services/callInvites.js). They knock like any guest.
|
|
28
31
|
*/
|
|
29
32
|
import {authenticate as defaultAuthenticate, requirePermission as defaultRequirePermission} from '../../middleware/auth.js';
|
|
30
33
|
import {getEntry, listEntries} from '../../services/collections.js';
|
|
@@ -35,9 +38,10 @@ import {isToolEnabled} from '../../services/tools.js';
|
|
|
35
38
|
import {mountCallRoutes} from '../../../plugins/_lib/call/routes.js';
|
|
36
39
|
import {whoOf} from '../../../plugins/_lib/call/rooms.js';
|
|
37
40
|
import {getBaseUrl} from '../public.js';
|
|
41
|
+
import {deliverInvites, resolveInvitees, searchInvitees} from '../../services/callInvites.js';
|
|
38
42
|
import {
|
|
39
43
|
EMAIL_RE, callChat, callMailReady, callPaths, callsOfContact, chatCountOf, createCall, getCall, instantCallsEnabled, isOpen,
|
|
40
|
-
mayEmailAddress, mayStartCall, saveCallChat, sendCallEmail, whoIsCalling
|
|
44
|
+
invitePath, mayEmailAddress, mayStartCall, saveCallChat, sendCallEmail, whoIsCalling
|
|
41
45
|
} from '../../services/instantCalls.js';
|
|
42
46
|
|
|
43
47
|
const CONTACTS_SLUG = 'contacts-contacts';
|
|
@@ -147,8 +151,26 @@ export async function callsRoutes(fastify, opts = {}) {
|
|
|
147
151
|
// The call engine: a room per call, guests knock, the host lets them in, open createdAt..expiresAt.
|
|
148
152
|
mountCallRoutes(fastify, {
|
|
149
153
|
prefix: '/room', ns: 'instant', ...(opts.rooms ? {rooms: opts.rooms} : {}),
|
|
150
|
-
resolve: async (request, room, body) => whoIsCalling(await getCall(String(room)), {t: body?.t, h: body?.h}),
|
|
154
|
+
resolve: async (request, room, body) => whoIsCalling(await getCall(String(room)), {t: body?.t, h: body?.h, g: body?.g}),
|
|
151
155
|
// 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)}
|
|
156
|
+
chat: {load: (room) => callChat(String(room)), save: (room, item) => saveCallChat(String(room), item)},
|
|
157
|
+
// Add people (the host): the host user's contacts and the team; each invitee gets their own `?g=` link.
|
|
158
|
+
invite: {
|
|
159
|
+
search: async (room, q) => {
|
|
160
|
+
const rec = await getCall(String(room));
|
|
161
|
+
if (!rec || !isOpen(rec)) return [];
|
|
162
|
+
return searchInvitees({hostUserId: rec.hostId, q});
|
|
163
|
+
},
|
|
164
|
+
send: async (room, people, {via, request}) => {
|
|
165
|
+
const rec = await getCall(String(room));
|
|
166
|
+
if (!rec || !isOpen(rec)) return {error: 'This call has ended.'};
|
|
167
|
+
const base = getBaseUrl(request);
|
|
168
|
+
const newLink = async () => `${base}${await invitePath(rec.id)}`;
|
|
169
|
+
const targets = await resolveInvitees(people, {hostUserId: rec.hostId});
|
|
170
|
+
const results = await deliverInvites(targets, {linkFor: newLink, hostName: rec.hostName, hostUserId: rec.hostId,
|
|
171
|
+
title: `Video call: ${rec.hostName} and ${rec.name || 'a guest'}`, via, key: `instant:${rec.id}`, expiresAt: rec.expiresAt, log: request.log});
|
|
172
|
+
return {link: await newLink(), results};
|
|
173
|
+
}
|
|
174
|
+
}
|
|
153
175
|
});
|
|
154
176
|
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* /_call/<id>?t=<guest token> the person called
|
|
5
5
|
* /_call/<id>?h=<host key> the host
|
|
6
|
+
* /_call/<id>?g=<invite pass> someone the host added ("Add people") - names themselves, knocks
|
|
6
7
|
*
|
|
7
8
|
* Under `/_call`, like `/_preview`. Answered from an onRequest hook, and ONLY
|
|
8
9
|
* while instant calls are on: with no Pro Tool with calls the request goes on
|
|
@@ -53,14 +54,14 @@ export const recoverScript = '<script>(function(){try{if(location.search)return;
|
|
|
53
54
|
|
|
54
55
|
export function callPage(rec, who, cred, now = Date.now()) {
|
|
55
56
|
if (!rec || !who) {
|
|
56
|
-
const bare = !cred?.t && !cred?.h;
|
|
57
|
+
const bare = !cred?.t && !cred?.h && !cred?.g;
|
|
57
58
|
return {status: 404, title: 'Call', body: '<h1>This call link is not valid</h1><p>Ask the person who sent it for a new one.</p>'
|
|
58
59
|
+ (bare ? recoverScript : '')};
|
|
59
60
|
}
|
|
60
61
|
if (!(Date.parse(rec.expiresAt) > now)) {
|
|
61
62
|
return {status: 410, title: 'Call', body: '<h1>This call has ended</h1><p>Ask the person who sent the link for a new one.</p>'};
|
|
62
63
|
}
|
|
63
|
-
const cfg = {api: ROOM_API, room: rec.id, cred, title: who.title, askName: !who.host && !rec.name, recordingAllowed: false, closesAt: rec.expiresAt};
|
|
64
|
+
const cfg = {api: ROOM_API, room: rec.id, cred, title: who.title, askName: !who.host && (!rec.name || who.invited === true), recordingAllowed: false, closesAt: rec.expiresAt};
|
|
64
65
|
return {
|
|
65
66
|
status: 200,
|
|
66
67
|
title: who.title,
|
|
@@ -89,7 +90,7 @@ export async function callPublicRoutes(fastify, opts = {}) {
|
|
|
89
90
|
reply.header('cache-control', 'no-store, private');
|
|
90
91
|
reply.header('x-robots-tag', 'noindex, nofollow');
|
|
91
92
|
const q = new URLSearchParams(search);
|
|
92
|
-
const cred = q.get('h') ? {h: q.get('h').slice(0, 128)} : {t: String(q.get('t') || '').slice(0, 128)};
|
|
93
|
+
const cred = q.get('h') ? {h: q.get('h').slice(0, 128)} : q.get('g') ? {g: q.get('g').slice(0, 128)} : {t: String(q.get('t') || '').slice(0, 128)};
|
|
93
94
|
const rec = await getCall(id);
|
|
94
95
|
const page = callPage(rec, whoIsCalling(rec, cred), cred);
|
|
95
96
|
const html = await renderBlogPage(TEMPLATE, {body: page.body}, {
|