domma-cms 0.105.1 → 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.
@@ -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 [];
@@ -154,7 +154,7 @@
154
154
  <script src="/public/js/forms.js?v=20260927-submit-btn" type="module"></script>
155
155
 
156
156
  <!-- Core effects runtime -->
157
- <script src="/public/js/effects.js?v=20260930-festive"></script>
157
+ <script src="/public/js/effects.js?v=20260930-snowman"></script>
158
158
 
159
159
  <!-- Plugin body-end injection -->
160
160
  {{bodyEndInject}}