domma-cms 1.8.32 → 1.8.34

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.
Files changed (110) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/docs/DommaFeatures.md +13 -1
  3. package/package.json +1 -1
  4. package/plugins/_lib/admin/games/games.css +1 -1
  5. package/plugins/_lib/admin/games/kit.js +329 -124
  6. package/plugins/_lib/games/http.js +11 -1
  7. package/plugins/_lib/games/plays.js +28 -7
  8. package/plugins/crossword/CLAUDE.md +37 -0
  9. package/plugins/crossword/admin/css/index.css +18 -1
  10. package/plugins/crossword/admin/views/crossword.js +337 -205
  11. package/plugins/crossword/docs/guide.md +53 -0
  12. package/plugins/crossword/inject/body.html +1 -1
  13. package/plugins/crossword/plugin.js +38 -5
  14. package/plugins/crossword/plugin.json +4 -3
  15. package/plugins/crossword/public/crossword.css +36 -1
  16. package/plugins/crossword/public/crossword.js +468 -6
  17. package/plugins/docs/CLAUDE.md +318 -0
  18. package/plugins/docs/admin/css/docs.css +783 -0
  19. package/plugins/docs/admin/lib/api.js +340 -0
  20. package/plugins/docs/admin/lib/convert.js +482 -0
  21. package/plugins/docs/admin/lib/drafts.js +115 -0
  22. package/plugins/docs/admin/lib/editor.js +851 -0
  23. package/plugins/docs/admin/lib/find-replace.js +426 -0
  24. package/plugins/docs/admin/lib/folders.js +203 -0
  25. package/plugins/docs/admin/lib/panel-guard.js +73 -0
  26. package/plugins/docs/admin/lib/pdf-view.js +126 -0
  27. package/plugins/docs/admin/lib/send.js +156 -0
  28. package/plugins/docs/admin/lib/settings.js +142 -0
  29. package/plugins/docs/admin/lib/share.js +323 -0
  30. package/plugins/docs/admin/lib/shortcuts.js +44 -0
  31. package/plugins/docs/admin/lib/templates.js +194 -0
  32. package/plugins/docs/admin/lib/versions.js +192 -0
  33. package/plugins/docs/admin/templates/docs.html +92 -0
  34. package/plugins/docs/admin/templates/editor.html +86 -0
  35. package/plugins/docs/admin/views/docs.js +777 -0
  36. package/plugins/docs/admin/views/editor.js +978 -0
  37. package/plugins/docs/config.js +24 -0
  38. package/plugins/docs/docs/guide.md +270 -0
  39. package/plugins/docs/lib/extensions.js +213 -0
  40. package/plugins/docs/lib/importer.js +72 -0
  41. package/plugins/docs/lib/pdf-worker.js +48 -0
  42. package/plugins/docs/lib/pdf.js +222 -0
  43. package/plugins/docs/lib/store.js +748 -0
  44. package/plugins/docs/plugin.js +1108 -0
  45. package/plugins/docs/plugin.json +89 -0
  46. package/plugins/docs/plugin.public.js +114 -0
  47. package/plugins/docs/templates/public-doc.html +36 -0
  48. package/plugins/docs/tests/_env.js +10 -0
  49. package/plugins/docs/tests/_pdf.js +73 -0
  50. package/plugins/docs/tests/convert.test.js +95 -0
  51. package/plugins/docs/tests/drafts.test.js +82 -0
  52. package/plugins/docs/tests/pdf.test.js +86 -0
  53. package/plugins/docs/tests/plugin.test.js +816 -0
  54. package/plugins/docs/tests/shortcuts.test.js +30 -0
  55. package/plugins/free-tier.lock.json +147 -1
  56. package/plugins/garage/CLAUDE.md +51 -0
  57. package/plugins/garage/admin/css/garage.css +73 -0
  58. package/plugins/garage/admin/lib/garage.js +236 -0
  59. package/plugins/garage/admin/templates/garage.html +86 -0
  60. package/plugins/garage/admin/views/garage.js +581 -0
  61. package/plugins/garage/collections/garage-vehicles/schema.json +101 -0
  62. package/plugins/garage/config.js +18 -0
  63. package/plugins/garage/docs/guide.md +118 -0
  64. package/plugins/garage/plugin.js +471 -0
  65. package/plugins/garage/plugin.json +68 -0
  66. package/plugins/garage/tests/access.test.js +134 -0
  67. package/plugins/garage/tests/garage.test.js +106 -0
  68. package/plugins/messages/CLAUDE.md +230 -0
  69. package/plugins/messages/admin/css/messages.css +117 -0
  70. package/plugins/messages/admin/lib/messages.js +452 -0
  71. package/plugins/messages/admin/templates/messages.html +76 -0
  72. package/plugins/messages/admin/views/messages.js +1219 -0
  73. package/plugins/messages/collections/user-message-threads/schema.json +22 -0
  74. package/plugins/messages/collections/user-messages/schema.json +23 -0
  75. package/plugins/messages/config.js +14 -0
  76. package/plugins/messages/docs/guide.md +176 -0
  77. package/plugins/messages/lib/call.js +138 -0
  78. package/plugins/messages/plugin.js +994 -0
  79. package/plugins/messages/plugin.json +59 -0
  80. package/plugins/messages/plugin.public.js +52 -0
  81. package/plugins/messages/templates/call.html +6 -0
  82. package/plugins/messages/tests/after-send.test.js +22 -0
  83. package/plugins/messages/tests/api.test.js +187 -0
  84. package/plugins/messages/tests/call.test.js +497 -0
  85. package/plugins/messages/tests/drafts.test.js +25 -0
  86. package/plugins/messages/tests/messages.test.js +167 -0
  87. package/plugins/quiz/CLAUDE.md +43 -0
  88. package/plugins/quiz/admin/css/index.css +13 -1
  89. package/plugins/quiz/admin/views/quiz.js +423 -245
  90. package/plugins/quiz/docs/guide.md +61 -0
  91. package/plugins/quiz/inject/body.html +1 -1
  92. package/plugins/quiz/plugin.js +39 -5
  93. package/plugins/quiz/plugin.json +4 -3
  94. package/plugins/quiz/public/quiz.css +36 -1
  95. package/plugins/quiz/public/quiz.js +298 -6
  96. package/plugins/theme-roller/CLAUDE.md +65 -0
  97. package/plugins/theme-roller/admin/css/theme-roller.css +43 -0
  98. package/plugins/theme-roller/admin/lib/prompt.js +22 -0
  99. package/plugins/theme-roller/admin/lib/themes.js +196 -0
  100. package/plugins/theme-roller/admin/lib/view.js +88 -0
  101. package/plugins/theme-roller/admin/templates/theme-roller.html +73 -0
  102. package/plugins/theme-roller/admin/views/theme-roller-view.js +639 -0
  103. package/plugins/theme-roller/config.js +1 -0
  104. package/plugins/theme-roller/docs/guide.md +104 -0
  105. package/plugins/theme-roller/plugin.js +263 -0
  106. package/plugins/theme-roller/plugin.json +42 -0
  107. package/plugins/theme-roller/tests/api.test.js +161 -0
  108. package/plugins/theme-roller/tests/prompt-values.test.js +22 -0
  109. package/plugins/theme-roller/tests/themes.test.js +77 -0
  110. package/plugins/theme-roller/tests/view.test.js +55 -0
@@ -0,0 +1,994 @@
1
+ import path from 'node:path';
2
+ import {fileURLToPath} from 'node:url';
3
+ import defaultConfig from './config.js';
4
+ import {createEntry as rawCreate, deleteEntry as rawDelete, getEntry, listEntries, updateEntry as rawUpdate} from '../../server/services/collections.js';
5
+ import {getUserById, listUsers} from '../../server/services/users.js';
6
+ import {getEffectiveLevel} from '../../server/services/userRoles.js';
7
+ import {getConfig} from '../../server/config.js';
8
+ import {cleanAttachments, indexByThread, unreadBadge, unreadKey, unreadNotices} from './admin/lib/messages.js';
9
+ import {checkInvite, checkJoin, endedText, inviteLink, joinLink, loadSecret, MAX_IN_CALL, RING_MS, whoIsCalling} from './lib/call.js';
10
+
11
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
12
+
13
+ const THREADS_SLUG = 'user-message-threads';
14
+ const MESSAGES_SLUG = 'user-messages';
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // SSE subscriber bus — Map<userId, Set<res>>
18
+ // ---------------------------------------------------------------------------
19
+ const subscribers = new Map();
20
+
21
+ function subscribe(userId, res) {
22
+ if (!subscribers.has(userId)) subscribers.set(userId, new Set());
23
+ subscribers.get(userId).add(res);
24
+ }
25
+
26
+ function unsubscribe(userId, res) {
27
+ const set = subscribers.get(userId);
28
+ if (!set) return;
29
+ set.delete(res);
30
+ if (set.size === 0) subscribers.delete(userId);
31
+ }
32
+
33
+ /**
34
+ * Push an SSE event to every active connection for the listed user IDs.
35
+ * Broken sockets are evicted silently.
36
+ */
37
+ function broadcast(userIds, eventName, payload) {
38
+ const data = JSON.stringify(payload);
39
+ for (const uid of userIds) {
40
+ const set = subscribers.get(uid);
41
+ if (!set) continue;
42
+ for (const res of set) {
43
+ try {
44
+ res.write(`event: ${eventName}\ndata: ${data}\n\n`);
45
+ } catch {
46
+ set.delete(res);
47
+ }
48
+ }
49
+ if (set.size === 0) subscribers.delete(uid);
50
+ }
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Writes go through these, so the message cache below is dropped on every one
55
+ // ---------------------------------------------------------------------------
56
+
57
+ let messagesCache = null; // {at, entries, byThread} - every message, briefly, and the same list keyed by thread
58
+ const dropCache = () => { messagesCache = null; };
59
+ const createEntry = (...a) => rawCreate(...a).finally(dropCache);
60
+ const updateEntry = (...a) => rawUpdate(...a).finally(dropCache);
61
+ const deleteEntry = (...a) => rawDelete(...a).finally(dropCache);
62
+
63
+ /**
64
+ * Every message, kept 30 seconds: the sidebar asks for the unread badge every
65
+ * minute from every open admin tab, and each ask read the whole collection.
66
+ * Any write through this plugin drops it at once, so nothing stale is shown
67
+ * after a send, a read or a delete.
68
+ */
69
+ async function allMessages() {
70
+ if (messagesCache && Date.now() - messagesCache.at < 30_000) return messagesCache.entries;
71
+ const {entries} = await listEntries(MESSAGES_SLUG, {limit: 100000});
72
+ // Indexed once per fill (1.7.3): before, every thread read, mark-read, prune and the per-thread unread count
73
+ // in GET /threads filtered the whole site's messages again - O(threads x messages) per list of conversations.
74
+ messagesCache = {at: Date.now(), entries, byThread: indexByThread(entries)};
75
+ return entries;
76
+ }
77
+
78
+ /** Messages keyed by threadId (Map), from the same cache fill as allMessages(). */
79
+ async function messagesByThread() {
80
+ await allMessages();
81
+ return messagesCache.byThread;
82
+ }
83
+
84
+ /** One conversation's messages (every status, undeleted or not) without scanning the others'. */
85
+ async function threadMessages(threadId) {
86
+ return (await messagesByThread()).get(threadId) || [];
87
+ }
88
+
89
+ /** The site's address for links in notices and emails: `site.baseUrl`, else the request's. */
90
+ function siteOrigin(request) {
91
+ let base = '';
92
+ try { base = String(getConfig('site')?.baseUrl || ''); } catch { /* no site config */ }
93
+ if (/^https?:\/\/[^/\s]+/i.test(base)) return base.replace(/\/+$/, '');
94
+ return `${request.protocol}://${request.host || request.headers.host || request.hostname}`;
95
+ }
96
+
97
+ /** Admin by effective level (roles are data; the JWT carries no `level`). */
98
+ function isAdminUser(request) {
99
+ try { return getEffectiveLevel(request.user) <= 1; } catch { return false; }
100
+ }
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Helpers
104
+ // ---------------------------------------------------------------------------
105
+
106
+ function toThread(entry) {
107
+ return {id: entry.id, ...entry.data, createdAt: entry.meta?.createdAt};
108
+ }
109
+
110
+ function toMessage(entry) {
111
+ return {id: entry.id, ...entry.data};
112
+ }
113
+
114
+ /** The longest message, and conversation name, the API keeps (1.7.2 - before, only Fastify's 1 MB body limit). */
115
+ const MAX_BODY = 10000;
116
+ const MAX_NAME = 80;
117
+
118
+ /**
119
+ * Resolve minimal user summaries for a list of IDs: id, name, avatar - never the email (1.7.2). /people hides
120
+ * emails so a self-registered member cannot collect the site's addresses; /threads and /count handed them out
121
+ * for everyone in the member's conversations, which a managed group with staff in it made a directory.
122
+ */
123
+ async function resolveUsers(ids) {
124
+ const map = {};
125
+ await Promise.all(ids.map(async id => {
126
+ try {
127
+ const u = await getUserById(id);
128
+ if (u) map[id] = {id, name: String(u.name || '').trim() || 'Unnamed member', avatar: u.avatar ?? null};
129
+ } catch { /* user deleted — omit */ }
130
+ }));
131
+ return map;
132
+ }
133
+
134
+ /** Lazy retention pruning — deletes messages older than effectiveRetentionDays. */
135
+ async function pruneOldMessages(threadData, config) {
136
+ const ceiling = config.globalRetentionCeilingDays;
137
+ // A stored value below a day (written before 1.2 refused it) is read as the default.
138
+ const asked = Number(threadData.retentionDays);
139
+ const effective = Math.min(
140
+ Number.isFinite(asked) && asked >= 1 ? asked : config.defaultRetentionDays,
141
+ ceiling
142
+ );
143
+ const cutoff = new Date(Date.now() - effective * 86_400_000).toISOString();
144
+ const stale = (await threadMessages(threadData.id)).filter(e => e.data.createdAt < cutoff);
145
+ // One at a time: the file adapter rewrites the whole collection on each call,
146
+ // and parallel read-modify-writes lose all but one of them.
147
+ for (const e of stale) await deleteEntry(MESSAGES_SLUG, e.id);
148
+ }
149
+
150
+ /** Verify caller is a participant of a thread, returning the thread entry or null. */
151
+ async function getThreadForUser(threadId, userId) {
152
+ const entry = await getEntry(THREADS_SLUG, threadId);
153
+ if (!entry) return null;
154
+ if (!Array.isArray(entry.data.participants) || !entry.data.participants.includes(userId)) return null;
155
+ return entry;
156
+ }
157
+
158
+ // ---------------------------------------------------------------------------
159
+ // Plugin
160
+ // ---------------------------------------------------------------------------
161
+
162
+ export default async function messagesPlugin(fastify, options) {
163
+ const {authenticate: signedIn, requireAdmin, requirePermission} = options.auth;
164
+ // Every route: signed in AND holding the Messages permission (granted to
165
+ // admin and user; take it from a role under Roles). Until 1.2 any signed-in
166
+ // account could message - including roles made for people outside the
167
+ // business. An older core without requirePermission keeps the old rule.
168
+ const mayUse = typeof requirePermission === 'function' ? requirePermission('messages', 'read') : async () => {};
169
+ async function authenticate(request, reply) {
170
+ const out = await signedIn(request, reply);
171
+ if (reply.sent) return out;
172
+ return mayUse(request, reply);
173
+ }
174
+ const config = {...defaultConfig, ...(options.settings ?? {})};
175
+
176
+ function me(request) {
177
+ return request.user?.id ?? request.user?.sub ?? null;
178
+ }
179
+
180
+ // ---- Notifications (1.3; core 0.80+, feature-detected) ----
181
+ // A message left unread for 10 minutes: every 15 minutes, one notice per
182
+ // conversation to each person with one waiting - to THEM only. Reading the
183
+ // conversation clears it (and desktop notifications carry it off-screen).
184
+ const hooks = options.hooks || {};
185
+ const resolveNotice = (key) => { try { hooks.resolveNotification?.(key, 'unread'); } catch { /* never breaks messaging */ } };
186
+ if (typeof hooks.registerNotificationSource === 'function') {
187
+ try {
188
+ hooks.registerNotificationSource({
189
+ id: 'unread', label: 'Unread messages', severity: 'info', every: '15m',
190
+ description: 'A message left unread for 10 minutes - told to its recipient, once per conversation. Goes when it is read.',
191
+ check: async (ctx) => {
192
+ const {entries: threads} = await listEntries(THREADS_SLUG, {limit: 10000});
193
+ const msgs = await allMessages();
194
+ const names = Object.fromEntries((await listUsers().catch(() => [])).map(u => [u.id, u.name || 'Someone']));
195
+ const notices = unreadNotices(threads.map(toThread), msgs.map(toMessage), names, ctx.now?.getTime?.() ?? Date.now());
196
+ for (const p of notices) await ctx.notify(p);
197
+ }
198
+ });
199
+ } catch { /* older engine */ }
200
+ }
201
+
202
+ /**
203
+ * SSE-compatible auth: falls back to ?token= query param when the
204
+ * Authorization header is absent (native EventSource cannot set headers).
205
+ */
206
+ async function authenticateSSE(request, reply) {
207
+ if (!request.headers.authorization && request.query.token) {
208
+ request.headers.authorization = `Bearer ${request.query.token}`;
209
+ }
210
+ return authenticate(request, reply);
211
+ }
212
+
213
+ /**
214
+ * The people this user may start a conversation with - the one rule for
215
+ * the picker (/people) and for POST /threads, which until 1.2 took any id
216
+ * it was sent. An admin: everyone active. Anyone else: staff (admins),
217
+ * people who share one of their roles, and people they already talk to.
218
+ */
219
+ async function reachable(request) {
220
+ return reachableFor({...(request.user || {}), id: me(request)});
221
+ }
222
+ /** reachable() for any account ({id, role, additionalRoles}) - the call's "Add people" (1.7) has no JWT. */
223
+ async function reachableFor(who) {
224
+ const users = (await listUsers().catch(() => [])).filter(u => u.disabled !== true && u.active !== false && u.isActive !== false);
225
+ const uid = who?.id;
226
+ if (isAdminUser({user: who})) return users.filter(u => u.id !== uid);
227
+ const rolesOf = (u) => new Set([u.role, ...(Array.isArray(u.additionalRoles) ? u.additionalRoles : [])].filter(Boolean));
228
+ const mine = rolesOf(who || {});
229
+ const {entries} = await listEntries(THREADS_SLUG, {limit: 10000});
230
+ const known = new Set(entries.map(toThread)
231
+ .filter(t => Array.isArray(t.participants) && t.participants.includes(uid))
232
+ .flatMap(t => t.participants));
233
+ const staff = (u) => { try { return getEffectiveLevel(u) <= 1; } catch { return false; } };
234
+ return users.filter(u => u.id !== uid && (staff(u) || known.has(u.id) || [...rolesOf(u)].some(r => mine.has(r))));
235
+ }
236
+
237
+ // -----------------------------------------------------------------------
238
+ // Calls (1.4): a video call in a conversation. The engine is core's
239
+ // (_lib/call, 0.103+), mounted at the end; an older core has no calls.
240
+ // A room is a conversation. What is running is kept here, in memory, as
241
+ // the engine keeps its rooms: a restart ends every call.
242
+ // -----------------------------------------------------------------------
243
+
244
+ const dataDir = options.dataDir || path.join(HERE, 'data');
245
+ let secret = null;
246
+ const callSecret = () => (secret ??= loadSecret(dataDir));
247
+ /** threadId -> {startedAt, by, byName, messageId, joinedAt, in, participants} */
248
+ const live = new Map();
249
+ let calls = null;
250
+
251
+ if (typeof hooks.registerNotificationSource === 'function') {
252
+ try {
253
+ hooks.registerNotificationSource({id: 'calls', label: 'Calls', severity: 'info', defaultOn: true,
254
+ description: 'Someone starts a call in a conversation - told to the others in it. Goes when the call ends.'});
255
+ } catch { /* older engine */ }
256
+ }
257
+
258
+ async function nameOf(userId) {
259
+ try { return String((await getUserById(userId))?.name || '').trim() || 'Someone'; } catch { return 'Someone'; }
260
+ }
261
+
262
+ const callInfo = (id) => {
263
+ const st = live.get(id);
264
+ return st ? {by: st.by, byName: st.byName, startedAt: new Date(st.startedAt).toISOString(), in: st.in, messageId: st.messageId} : null;
265
+ };
266
+
267
+ /**
268
+ * A "call started / ended" line in the conversation. Read by everyone
269
+ * already: the live notice and the notification do the telling, so a
270
+ * call line never adds to the unread badge or the unread reminders.
271
+ */
272
+ /**
273
+ * Set a thread's last-message fields from a FRESH read - never write back a copy read before an await, or a
274
+ * member removed meanwhile would be put back (participants is the thread's own list).
275
+ */
276
+ async function touchThread(threadId, fields) {
277
+ const fresh = await getEntry(THREADS_SLUG, threadId).catch(() => null);
278
+ if (fresh) await updateEntry(THREADS_SLUG, threadId, {...fresh.data, ...fields});
279
+ }
280
+
281
+ async function postCallLine(threadId, senderId, body, call, bringBack) {
282
+ const entry = await getEntry(THREADS_SLUG, threadId).catch(() => null);
283
+ if (!entry) return null;
284
+ const participants = entry.data.participants ?? [];
285
+ const now = new Date().toISOString();
286
+ const msgEntry = await createEntry(MESSAGES_SLUG, {
287
+ threadId, senderId, kind: 'call', call, body, attachments: [],
288
+ createdAt: now, editedAt: null, deletedAt: null, readBy: [...participants]
289
+ }, {createdBy: senderId, source: 'messages'});
290
+ await touchThread(threadId, {...(bringBack ? {archivedBy: []} : {}), lastMessageAt: now, lastMessagePreview: body.slice(0, 120)});
291
+ const message = toMessage(msgEntry);
292
+ broadcast(participants, 'message:new', {threadId, message});
293
+ return message;
294
+ }
295
+
296
+ /** Start a conversation's call, or hand back the one running. One line, one notice, however many ask at once. */
297
+ async function startCall(entry, userId) {
298
+ const id = entry.id;
299
+ if (live.has(id)) return live.get(id);
300
+ const participants = [...(entry.data.participants ?? [])];
301
+ // Held before the first await: a second click, or a second person, finds it running.
302
+ const st = {startedAt: Date.now(), by: userId, byName: 'Someone', messageId: null, joinedAt: 0, in: 0, participants, invited: new Set()};
303
+ live.set(id, st);
304
+ st.byName = await nameOf(userId);
305
+ const line = await postCallLine(id, userId, `${st.byName} started a call`, {event: 'started'}, true).catch(err => {
306
+ fastify.log?.warn?.(`[messages] call line not written: ${err?.message || err}`);
307
+ return null;
308
+ });
309
+ st.messageId = line?.id ?? null;
310
+ broadcast(participants, 'call:started', {threadId: id, call: callInfo(id)});
311
+ const others = participants.filter(p => p !== userId);
312
+ if (others.length) {
313
+ const group = entry.data.kind !== 'dm';
314
+ try {
315
+ hooks.notify?.({
316
+ title: `${st.byName} is calling${group && entry.data.name ? ` - ${entry.data.name}` : ''}`.slice(0, 200),
317
+ body: 'Open Messages and click Join.',
318
+ severity: 'info', link: '#/plugins/messages', source: 'calls',
319
+ dedupeKey: `call:${id}`, audience: {userIds: others}
320
+ });
321
+ } catch { /* a notice never breaks a call */ }
322
+ }
323
+ return st;
324
+ }
325
+
326
+ async function endCall(id) {
327
+ const st = live.get(id);
328
+ if (!st) return;
329
+ live.delete(id);
330
+ // Nobody is "in the call" any more: the next call's lines are unread for everyone not in THAT one.
331
+ present.delete(id);
332
+ const now = Date.now();
333
+ await postCallLine(id, st.by, endedText(st.joinedAt || st.startedAt, now, Boolean(st.joinedAt)),
334
+ {event: 'ended', minutes: st.joinedAt ? Math.round((now - st.joinedAt) / 60e3) : 0}, false)
335
+ .catch(err => fastify.log?.warn?.(`[messages] call line not written: ${err?.message || err}`));
336
+ // A new call may have started while the line was written (someone clicked Call, or joined with their
337
+ // link): it has the same notice key, so this one's ending must not clear it or say "ended".
338
+ if (live.has(id)) return;
339
+ broadcast(st.participants, 'call:ended', {threadId: id});
340
+ try { hooks.resolveNotification?.(`call:${id}`, 'calls'); } catch { /* never breaks messaging */ }
341
+ }
342
+
343
+ /** Every few seconds: how many are in each call; an emptied call (or one nobody came to) ends. */
344
+ let sweeping = false;
345
+ async function sweepCalls(now = Date.now()) {
346
+ if (sweeping || !calls) return;
347
+ sweeping = true;
348
+ try {
349
+ for (const [id, st] of [...live]) {
350
+ const n = calls.countRoom(id).in;
351
+ if (n > 0 && !st.joinedAt) st.joinedAt = now;
352
+ if (n !== st.in) {
353
+ st.in = n;
354
+ broadcast(st.participants, 'call:updated', {threadId: id, call: callInfo(id)});
355
+ }
356
+ if (n === 0 && (st.joinedAt || now - st.startedAt > RING_MS)) await endCall(id);
357
+ }
358
+ } finally {
359
+ sweeping = false;
360
+ }
361
+ }
362
+
363
+ // -----------------------------------------------------------------------
364
+ // SSE stream
365
+ // -----------------------------------------------------------------------
366
+
367
+ fastify.get('/stream', {preHandler: [authenticateSSE]}, async (request, reply) => {
368
+ const userId = me(request);
369
+
370
+ reply.raw.setHeader('Content-Type', 'text/event-stream');
371
+ reply.raw.setHeader('Cache-Control', 'no-cache');
372
+ reply.raw.setHeader('Connection', 'keep-alive');
373
+ reply.raw.setHeader('X-Accel-Buffering', 'no');
374
+ reply.raw.flushHeaders();
375
+
376
+ subscribe(userId, reply.raw);
377
+
378
+ const keepalive = setInterval(() => {
379
+ try { reply.raw.write(': ping\n\n'); } catch { clearInterval(keepalive); }
380
+ }, 25_000);
381
+
382
+ request.raw.on('close', () => {
383
+ clearInterval(keepalive);
384
+ unsubscribe(userId, reply.raw);
385
+ });
386
+
387
+ // Keep the connection open — do NOT call reply.send()
388
+ await new Promise(resolve => request.raw.on('close', resolve));
389
+ });
390
+
391
+ // -----------------------------------------------------------------------
392
+ // Threads — list
393
+ // -----------------------------------------------------------------------
394
+
395
+ fastify.get('/threads', {preHandler: [authenticate]}, async (request, reply) => {
396
+ const userId = me(request);
397
+ const {entries} = await listEntries(THREADS_SLUG, {limit: 10000});
398
+
399
+ const threads = entries
400
+ .map(toThread)
401
+ .filter(t => Array.isArray(t.participants) && t.participants.includes(userId))
402
+ .sort((a, b) => (b.lastMessageAt ?? '') > (a.lastMessageAt ?? '') ? 1 : -1);
403
+
404
+ // Resolve participant names + per-thread unread counts
405
+ const allUserIds = [...new Set(threads.flatMap(t => t.participants ?? []))];
406
+ const userMap = await resolveUsers(allUserIds);
407
+
408
+ const byThread = await messagesByThread();
409
+ const withMeta = threads.map(t => {
410
+ const unread = (byThread.get(t.id) || []).filter(e =>
411
+ !e.data.deletedAt &&
412
+ !(e.data.readBy ?? []).includes(userId)
413
+ ).length;
414
+ return {...t, participants: (t.participants ?? []).map(id => userMap[id] ?? {id}), unread, call: callInfo(t.id)};
415
+ });
416
+
417
+ return reply.send(withMeta);
418
+ });
419
+
420
+ // -----------------------------------------------------------------------
421
+ // Threads — create
422
+ // -----------------------------------------------------------------------
423
+
424
+ fastify.post('/threads', {preHandler: [authenticate]}, async (request, reply) => {
425
+ const userId = me(request);
426
+ const {kind = 'dm', name, participants = []} = request.body ?? {};
427
+
428
+ if (!['dm', 'group-adhoc', 'group-managed'].includes(kind)) {
429
+ return reply.code(400).send({error: 'Invalid kind'});
430
+ }
431
+ if (kind === 'group-managed' && !isAdminUser(request)) {
432
+ return reply.code(403).send({error: 'Only admins can create managed groups'});
433
+ }
434
+
435
+ // Only real, active people this user may reach - never an id guessed or made up.
436
+ if (!Array.isArray(participants) || participants.some(p => typeof p !== 'string' || !p) || participants.length > 50) {
437
+ return reply.code(400).send({error: 'participants must be a list of user ids'});
438
+ }
439
+ const allowed = new Set((await reachable(request)).map(u => u.id));
440
+ if (participants.some(p => p !== userId && !allowed.has(p))) {
441
+ return reply.code(403).send({error: 'You cannot message one of those people'});
442
+ }
443
+
444
+ // Ensure current user is in the list
445
+ const allParticipants = [...new Set([userId, ...participants])];
446
+
447
+ if (kind === 'dm') {
448
+ if (allParticipants.length !== 2) {
449
+ return reply.code(400).send({error: 'DMs require exactly one other participant'});
450
+ }
451
+ // Deduplicate: return existing DM if one already exists
452
+ const sorted = [...allParticipants].sort();
453
+ const {entries} = await listEntries(THREADS_SLUG, {limit: 10000});
454
+ const existing = entries.find(e =>
455
+ e.data.kind === 'dm' &&
456
+ Array.isArray(e.data.participants) &&
457
+ [...e.data.participants].sort().join('|') === sorted.join('|')
458
+ );
459
+ if (existing) return reply.send(toThread(existing));
460
+ }
461
+
462
+ if ((kind === 'group-adhoc' || kind === 'group-managed') && !name?.trim()) {
463
+ return reply.code(400).send({error: 'Groups require a name'});
464
+ }
465
+
466
+ const now = new Date().toISOString();
467
+ const entry = await createEntry(THREADS_SLUG, {
468
+ kind,
469
+ name: name?.trim() ?? null,
470
+ participants: kind === 'dm' ? [...allParticipants].sort() : allParticipants,
471
+ createdBy: userId,
472
+ retentionDays: null,
473
+ lastMessageAt: now,
474
+ lastMessagePreview: ''
475
+ }, {createdBy: userId, source: 'messages'});
476
+
477
+ const thread = toThread(entry);
478
+
479
+ // Notify other participants
480
+ const others = allParticipants.filter(id => id !== userId);
481
+ broadcast(others, 'thread:created', thread);
482
+
483
+ return reply.code(201).send(thread);
484
+ });
485
+
486
+ // -----------------------------------------------------------------------
487
+ // Threads — update (rename / change retention)
488
+ // -----------------------------------------------------------------------
489
+
490
+ fastify.put('/threads/:id', {preHandler: [authenticate]}, async (request, reply) => {
491
+ const userId = me(request);
492
+ const entry = await getThreadForUser(request.params.id, userId);
493
+ if (!entry) return reply.code(404).send({error: 'Thread not found'});
494
+
495
+ const {name, retentionDays} = request.body ?? {};
496
+ const data = {...entry.data};
497
+
498
+ // Only thread creator can rename ad-hoc; admins can rename managed
499
+ if (name !== undefined) {
500
+ if (data.kind !== 'dm' && (data.createdBy === userId || isAdminUser(request))) {
501
+ data.name = String(name).trim().slice(0, MAX_NAME);
502
+ }
503
+ }
504
+
505
+ // How long messages are kept is the creator's (or an admin's) to say, and
506
+ // never less than a day: until 1.2 any participant could set it, and a
507
+ // negative number put the cut-off in the future - the next read deleted
508
+ // every message in the conversation, for everyone.
509
+ if (retentionDays !== undefined) {
510
+ if (data.createdBy !== userId && !isAdminUser(request)) {
511
+ return reply.code(403).send({error: 'Only whoever started this conversation can change how long it keeps messages'});
512
+ }
513
+ if (retentionDays === null || retentionDays === '') {
514
+ data.retentionDays = null;
515
+ } else {
516
+ const days = Number(retentionDays);
517
+ if (!Number.isInteger(days) || days < 1) return reply.code(400).send({error: 'retentionDays must be a whole number of days, 1 or more'});
518
+ data.retentionDays = Math.min(days, config.globalRetentionCeilingDays);
519
+ }
520
+ }
521
+
522
+ const updated = await updateEntry(THREADS_SLUG, entry.id, data);
523
+ const thread = toThread(updated);
524
+ broadcast(data.participants, 'thread:updated', thread);
525
+ return reply.send(thread);
526
+ });
527
+
528
+ // -----------------------------------------------------------------------
529
+ // Threads — leave / delete
530
+ // -----------------------------------------------------------------------
531
+
532
+ fastify.delete('/threads/:id', {preHandler: [authenticate]}, async (request, reply) => {
533
+ const userId = me(request);
534
+ const entry = await getThreadForUser(request.params.id, userId);
535
+ if (!entry) return reply.code(404).send({error: 'Thread not found'});
536
+
537
+ const data = {...entry.data};
538
+ data.participants = data.participants.filter(id => id !== userId);
539
+
540
+ if (data.participants.length === 0) {
541
+ // Last participant — purge thread and all its messages
542
+ for (const e of await threadMessages(entry.id)) await deleteEntry(MESSAGES_SLUG, e.id);
543
+ await deleteEntry(THREADS_SLUG, entry.id);
544
+ // Nobody left to call: the call (if any) ends, with no line to write it in.
545
+ live.delete(entry.id);
546
+ calls?.closeRoom(entry.id);
547
+ } else {
548
+ await updateEntry(THREADS_SLUG, entry.id, data);
549
+ broadcast(data.participants, 'thread:updated', toThread({id: entry.id, data}));
550
+ // Out of the conversation, out of its call - and no more news of it.
551
+ calls?.kickFromRoom?.(entry.id, `${entry.id}:${userId}`);
552
+ const st = live.get(entry.id);
553
+ if (st) st.participants = st.participants.filter(p => p !== userId);
554
+ }
555
+
556
+ return reply.send({success: true});
557
+ });
558
+
559
+ // -----------------------------------------------------------------------
560
+ // Messages — list (paginated)
561
+ // -----------------------------------------------------------------------
562
+
563
+ fastify.get('/threads/:id/messages', {preHandler: [authenticate]}, async (request, reply) => {
564
+ const userId = me(request);
565
+ const entry = await getThreadForUser(request.params.id, userId);
566
+ if (!entry) return reply.code(404).send({error: 'Thread not found'});
567
+
568
+ // Lazy retention pruning
569
+ await pruneOldMessages({id: entry.id, ...entry.data}, config);
570
+
571
+ const {before, limit = 50} = request.query;
572
+ let msgs = (await threadMessages(entry.id))
573
+ .map(toMessage)
574
+ .sort((a, b) => (a.createdAt ?? '') < (b.createdAt ?? '') ? -1 : 1);
575
+
576
+ if (before) msgs = msgs.filter(m => m.createdAt < before);
577
+ msgs = msgs.slice(-Math.min(Number(limit) || 50, 200));
578
+
579
+ return reply.send(msgs);
580
+ });
581
+
582
+ // -----------------------------------------------------------------------
583
+ // Messages — send
584
+ // -----------------------------------------------------------------------
585
+
586
+ fastify.post('/threads/:id/messages', {preHandler: [authenticate]}, async (request, reply) => {
587
+ const userId = me(request);
588
+ const threadEntry = await getThreadForUser(request.params.id, userId);
589
+ if (!threadEntry) return reply.code(404).send({error: 'Thread not found'});
590
+
591
+ const body = String(request.body?.body ?? '').slice(0, MAX_BODY);
592
+ // Only media names and file facts are kept - the address is rebuilt from the name,
593
+ // so a message can never carry a javascript: or off-site link into someone's screen.
594
+ const attachments = cleanAttachments(request.body?.attachments);
595
+ if (!body.trim() && attachments.length === 0) {
596
+ return reply.code(400).send({error: 'Message must have a body or attachment'});
597
+ }
598
+
599
+ const now = new Date().toISOString();
600
+ const msgEntry = await createEntry(MESSAGES_SLUG, {
601
+ threadId: threadEntry.id,
602
+ senderId: userId,
603
+ body: String(body).trim(),
604
+ attachments,
605
+ createdAt: now,
606
+ editedAt: null,
607
+ deletedAt: null,
608
+ readBy: [userId]
609
+ }, {createdBy: userId, source: 'messages'});
610
+
611
+ // Update thread preview
612
+ const preview = body.trim().slice(0, 120)
613
+ || (attachments.length ? `Attachment: ${attachments[0].filename}`.slice(0, 120) : '');
614
+ // A new message brings an archived conversation back to everyone's inbox, as mail does.
615
+ await updateEntry(THREADS_SLUG, threadEntry.id, {
616
+ ...threadEntry.data,
617
+ archivedBy: [],
618
+ lastMessageAt: now,
619
+ lastMessagePreview: preview
620
+ });
621
+
622
+ const message = toMessage(msgEntry);
623
+ const others = (threadEntry.data.participants ?? []).filter(id => id !== userId);
624
+ broadcast(others, 'message:new', {threadId: threadEntry.id, message});
625
+
626
+ return reply.code(201).send(message);
627
+ });
628
+
629
+ // -----------------------------------------------------------------------
630
+ // Messages — edit
631
+ // -----------------------------------------------------------------------
632
+
633
+ fastify.put('/threads/:id/messages/:msgId', {preHandler: [authenticate]}, async (request, reply) => {
634
+ const userId = me(request);
635
+ const threadEntry = await getThreadForUser(request.params.id, userId);
636
+ if (!threadEntry) return reply.code(404).send({error: 'Thread not found'});
637
+
638
+ const msgEntry = await getEntry(MESSAGES_SLUG, request.params.msgId);
639
+ if (!msgEntry || msgEntry.data.threadId !== threadEntry.id) {
640
+ return reply.code(404).send({error: 'Message not found'});
641
+ }
642
+ if (msgEntry.data.senderId !== userId) {
643
+ return reply.code(403).send({error: 'Cannot edit another user\'s message'});
644
+ }
645
+ if (msgEntry.data.deletedAt) {
646
+ return reply.code(403).send({error: 'Cannot edit a deleted message'});
647
+ }
648
+ if (msgEntry.data.kind === 'call') {
649
+ return reply.code(403).send({error: 'A call line cannot be edited'});
650
+ }
651
+
652
+ const ageMs = Date.now() - new Date(msgEntry.data.createdAt).getTime();
653
+ if (ageMs > config.editWindowMinutes * 60_000) {
654
+ return reply.code(403).send({error: `Edit window of ${config.editWindowMinutes} minutes has passed`});
655
+ }
656
+
657
+ const {body} = request.body ?? {};
658
+ const now = new Date().toISOString();
659
+ const updated = await updateEntry(MESSAGES_SLUG, msgEntry.id, {
660
+ ...msgEntry.data,
661
+ body: body !== undefined ? String(body).trim().slice(0, MAX_BODY) : msgEntry.data.body,
662
+ editedAt: now
663
+ });
664
+
665
+ const message = toMessage(updated);
666
+ const others = (threadEntry.data.participants ?? []).filter(id => id !== userId);
667
+ broadcast(others, 'message:edited', {threadId: threadEntry.id, message});
668
+
669
+ return reply.send(message);
670
+ });
671
+
672
+ // -----------------------------------------------------------------------
673
+ // Messages — soft delete
674
+ // -----------------------------------------------------------------------
675
+
676
+ fastify.delete('/threads/:id/messages/:msgId', {preHandler: [authenticate]}, async (request, reply) => {
677
+ const userId = me(request);
678
+ const threadEntry = await getThreadForUser(request.params.id, userId);
679
+ if (!threadEntry) return reply.code(404).send({error: 'Thread not found'});
680
+
681
+ const msgEntry = await getEntry(MESSAGES_SLUG, request.params.msgId);
682
+ if (!msgEntry || msgEntry.data.threadId !== threadEntry.id) {
683
+ return reply.code(404).send({error: 'Message not found'});
684
+ }
685
+
686
+ // The sender, an admin - or in a group, whoever started it. Never the other
687
+ // side of a one-to-one conversation: starting it first is not moderation.
688
+ const groupOwner = threadEntry.data.kind !== 'dm' && threadEntry.data.createdBy === userId;
689
+ if (msgEntry.data.senderId !== userId && !groupOwner && !isAdminUser(request)) {
690
+ return reply.code(403).send({error: 'Cannot delete this message'});
691
+ }
692
+
693
+ const now = new Date().toISOString();
694
+ const updated = await updateEntry(MESSAGES_SLUG, msgEntry.id, {
695
+ ...msgEntry.data,
696
+ body: '',
697
+ attachments: [],
698
+ deletedAt: now
699
+ });
700
+
701
+ const message = toMessage(updated);
702
+ const others = (threadEntry.data.participants ?? []).filter(id => id !== userId);
703
+ broadcast(others, 'message:deleted', {threadId: threadEntry.id, message});
704
+
705
+ return reply.send(message);
706
+ });
707
+
708
+ // -----------------------------------------------------------------------
709
+ // Calls (1.4) - start this conversation's call, or join the one running.
710
+ // Answers the caller's OWN short-lived link to the call page.
711
+ // -----------------------------------------------------------------------
712
+
713
+ fastify.post('/threads/:id/call', {preHandler: [authenticate], config: {rateLimit: {max: 30, timeWindow: '1 minute'}}}, async (request, reply) => {
714
+ const userId = me(request);
715
+ const entry = await getThreadForUser(request.params.id, userId);
716
+ if (!entry) return reply.code(404).send({error: 'Thread not found'});
717
+ if (!calls) return reply.code(501).send({error: 'Calls need Domma CMS 0.103 or later.'});
718
+ await startCall(entry, userId);
719
+ return reply.send({url: joinLink(callSecret(), entry.id, userId), call: callInfo(entry.id), max: MAX_IN_CALL});
720
+ });
721
+
722
+ // -----------------------------------------------------------------------
723
+ // Messages — mark read
724
+ // -----------------------------------------------------------------------
725
+
726
+ fastify.post('/threads/:id/read', {preHandler: [authenticate]}, async (request, reply) => {
727
+ const userId = me(request);
728
+ const threadEntry = await getThreadForUser(request.params.id, userId);
729
+ if (!threadEntry) return reply.code(404).send({error: 'Thread not found'});
730
+
731
+ const unread = (await threadMessages(threadEntry.id)).filter(e =>
732
+ !e.data.deletedAt &&
733
+ !(e.data.readBy ?? []).includes(userId)
734
+ );
735
+
736
+ // Sequential for the same reason as pruning: parallel updates lose all but one.
737
+ for (const e of unread) {
738
+ await updateEntry(MESSAGES_SLUG, e.id, {...e.data, readBy: [...(e.data.readBy ?? []), userId]});
739
+ }
740
+ resolveNotice(unreadKey(threadEntry.id, userId));
741
+
742
+ return reply.send({unread: 0});
743
+ });
744
+
745
+ // -----------------------------------------------------------------------
746
+ // Who am I here - the screen's rights come from the server, not from the
747
+ // stored user (which carries a role name, not a level).
748
+ // -----------------------------------------------------------------------
749
+
750
+ fastify.get('/me', {preHandler: [authenticate]}, async (request) => ({
751
+ id: me(request),
752
+ name: await nameOf(me(request)), // 1.5: Call… names a new group after everyone in it
753
+ isAdmin: isAdminUser(request),
754
+ editWindowMinutes: config.editWindowMinutes,
755
+ defaultRetentionDays: config.defaultRetentionDays,
756
+ globalRetentionCeilingDays: config.globalRetentionCeilingDays
757
+ }));
758
+
759
+ // People to start a conversation with. /api/users needs the users
760
+ // permission, which most people who message do not hold - so the picker
761
+ // was empty for them. Id and NAME only - never an email (a self-registered
762
+ // member must not be able to collect the site's addresses). An admin sees
763
+ // everyone; anyone else sees staff (admins), people who share one of their
764
+ // roles, and people they already have a conversation with.
765
+ fastify.get('/people', {preHandler: [authenticate]}, async (request) => {
766
+ return (await reachable(request)).map(u => ({id: u.id, name: String(u.name || '').trim() || 'Unnamed member'}));
767
+ });
768
+
769
+ // The sidebar badge (countUrl): unread in conversations this user has not archived.
770
+ fastify.get('/count', {preHandler: [authenticate]}, async (request) => {
771
+ const userId = me(request);
772
+ const {entries} = await listEntries(THREADS_SLUG, {limit: 10000});
773
+ const mine = entries.map(toThread).filter(t => Array.isArray(t.participants) && t.participants.includes(userId));
774
+ const users = await resolveUsers([...new Set(mine.flatMap(t => t.participants))]);
775
+ const msgs = await allMessages();
776
+ const unreadBy = {};
777
+ for (const e of msgs) {
778
+ if (e.data.deletedAt || (e.data.readBy ?? []).includes(userId)) continue;
779
+ unreadBy[e.data.threadId] = (unreadBy[e.data.threadId] || 0) + 1;
780
+ }
781
+ return unreadBadge(mine.map(t => ({...t, participants: t.participants.map(id => users[id] ?? {id}), unread: unreadBy[t.id] || 0})), userId);
782
+ });
783
+
784
+ // Mark unread: the newest message from someone else counts as unread again.
785
+ fastify.post('/threads/:id/unread', {preHandler: [authenticate]}, async (request, reply) => {
786
+ const userId = me(request);
787
+ const threadEntry = await getThreadForUser(request.params.id, userId);
788
+ if (!threadEntry) return reply.code(404).send({error: 'Thread not found'});
789
+ const latest = (await threadMessages(threadEntry.id))
790
+ .filter(e => !e.data.deletedAt && e.data.senderId !== userId)
791
+ .sort((a, b) => String(b.data.createdAt).localeCompare(String(a.data.createdAt)))[0];
792
+ if (!latest) return reply.code(409).send({error: 'Nothing from anyone else to mark unread'});
793
+ await updateEntry(MESSAGES_SLUG, latest.id, {...latest.data, readBy: (latest.data.readBy ?? []).filter(id => id !== userId)});
794
+ return reply.send({unread: 1});
795
+ });
796
+
797
+ // Archive / unarchive - per person: the others still see the conversation.
798
+ fastify.post('/threads/:id/archive', {preHandler: [authenticate]}, async (request, reply) => {
799
+ const userId = me(request);
800
+ const entry = await getThreadForUser(request.params.id, userId);
801
+ if (!entry) return reply.code(404).send({error: 'Thread not found'});
802
+ const archived = request.body?.archived !== false;
803
+ const others = (entry.data.archivedBy ?? []).filter(id => id !== userId);
804
+ const data = {...entry.data, archivedBy: archived ? [...others, userId] : others};
805
+ await updateEntry(THREADS_SLUG, entry.id, data);
806
+ if (archived) resolveNotice(unreadKey(entry.id, userId)); // put away: not waiting on them any more
807
+ return reply.send({archived});
808
+ });
809
+
810
+ // -----------------------------------------------------------------------
811
+ // Managed groups (admin only)
812
+ // -----------------------------------------------------------------------
813
+
814
+ fastify.get('/managed-groups', {preHandler: [authenticate, requireAdmin]}, async (request, reply) => {
815
+ const {entries} = await listEntries(THREADS_SLUG, {limit: 10000});
816
+ const groups = entries
817
+ .filter(e => e.data.kind === 'group-managed')
818
+ .map(toThread)
819
+ .sort((a, b) => (a.name ?? '') < (b.name ?? '') ? -1 : 1);
820
+ return reply.send(groups);
821
+ });
822
+
823
+ fastify.post('/managed-groups', {preHandler: [authenticate, requireAdmin]}, async (request, reply) => {
824
+ const userId = me(request);
825
+ const {name, participants = []} = request.body ?? {};
826
+
827
+ if (!name?.trim()) return reply.code(400).send({error: 'Group name is required'});
828
+
829
+ const allParticipants = [...new Set([userId, ...participants])];
830
+ const now = new Date().toISOString();
831
+
832
+ const entry = await createEntry(THREADS_SLUG, {
833
+ kind: 'group-managed',
834
+ name: name.trim(),
835
+ participants: allParticipants,
836
+ createdBy: userId,
837
+ retentionDays: null,
838
+ lastMessageAt: now,
839
+ lastMessagePreview: ''
840
+ }, {createdBy: userId, source: 'messages'});
841
+
842
+ const thread = toThread(entry);
843
+ const others = allParticipants.filter(id => id !== userId);
844
+ broadcast(others, 'thread:created', thread);
845
+
846
+ return reply.code(201).send(thread);
847
+ });
848
+
849
+ // -----------------------------------------------------------------------
850
+ // Calls (1.4): the engine's routes, under /call. Who may join: someone
851
+ // holding a link made for them (POST /threads/:id/call) who is still in
852
+ // the conversation. Joining a conversation whose call is not running
853
+ // (given up unanswered, or the site restarted) starts it again.
854
+ // -----------------------------------------------------------------------
855
+ // The call's chat (1.6) lives IN the conversation: what someone types in the call is posted as an ordinary
856
+ // message from them (saved with `callChat: {id, seq, who}` - the engine's item), so it is in the thread
857
+ // afterwards, once. System lines are not posted (the started/ended lines say it). The engine hands save()
858
+ // the joiner's identity, `<thread>:<user>` (never sent to a browser), which names the sender. Everyone in
859
+ // the call at the time (tracked from the joined/left lines) has already read it; the others get it unread.
860
+ // load() gives the call the conversation's earlier call messages, so a call reopened shows them.
861
+ const present = new Map(); // threadId -> Set<userId> in the call now
862
+ const callChat = {
863
+ async load(threadId) {
864
+ const msgs = (await threadMessages(threadId)).filter(e => e.data.callChat && !e.data.deletedAt)
865
+ .map(toMessage).sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt))).slice(-500);
866
+ const names = {};
867
+ for (const id of new Set(msgs.map(m => m.senderId))) names[id] = await nameOf(id);
868
+ return msgs.map(m => ({id: String(m.callChat.id || m.id), seq: Number(m.callChat.seq) || 0, at: m.createdAt, kind: 'chat',
869
+ from: '', who: String(m.callChat.who || ''), name: names[m.senderId] || 'Someone', text: String(m.body || '')}));
870
+ },
871
+ async save(threadId, item, meta = {}) {
872
+ const identity = String(meta.identity || '');
873
+ const userId = identity.startsWith(`${threadId}:`) ? identity.slice(threadId.length + 1) : '';
874
+ if (!userId) return;
875
+ if (item.kind === 'system') {
876
+ const inCall = present.get(threadId) || new Set();
877
+ if (item.event === 'joined') inCall.add(userId);
878
+ else if (item.event === 'left') inCall.delete(userId);
879
+ if (inCall.size) present.set(threadId, inCall); else present.delete(threadId);
880
+ return;
881
+ }
882
+ if (item.kind !== 'chat' || !String(item.text || '').trim()) return;
883
+ // Still in the conversation (someone taken out of it is taken out of its call too).
884
+ const threadEntry = await getThreadForUser(threadId, userId);
885
+ if (!threadEntry) return;
886
+ const now = new Date().toISOString();
887
+ const body = String(item.text).trim().slice(0, 1000);
888
+ const readBy = [...new Set([userId, ...(present.get(threadId) || [])])]
889
+ .filter(id => (threadEntry.data.participants ?? []).includes(id));
890
+ const msgEntry = await createEntry(MESSAGES_SLUG, {
891
+ threadId, senderId: userId, body, attachments: [], createdAt: now, editedAt: null, deletedAt: null, readBy,
892
+ callChat: {id: String(item.id).slice(0, 64), seq: Number(item.seq) || 0, who: String(item.who || '').slice(0, 64)}
893
+ }, {createdBy: userId, source: 'messages'});
894
+ await touchThread(threadId, {archivedBy: [], lastMessageAt: now, lastMessagePreview: body.slice(0, 120)});
895
+ // Everyone - the sender too: their Messages screen did not send it.
896
+ broadcast(threadEntry.data.participants ?? [], 'message:new', {threadId, message: toMessage(msgEntry)});
897
+ }
898
+ };
899
+
900
+ try {
901
+ const {mountCallRoutes} = await import('../_lib/call/routes.js');
902
+ // Add people (1.7; a core with services/callInvites.js): anyone in the call rings a team member who is not in
903
+ // the conversation - someone they may reach (reachableFor). The invitee gets a notification, a direct message
904
+ // (Messages itself, through core's messenger hook) and, when ticked, the email - each with their OWN
905
+ // invitation link, good for this call while it runs (`st.invited`). No contacts, no typed emails, no copied link.
906
+ let invites = null;
907
+ try { invites = await import('../../server/services/callInvites.js'); } catch { invites = null; }
908
+ const inviterOf = (room, identity) => (String(identity || '').startsWith(`${room}:`) ? String(identity).slice(room.length + 1) : '');
909
+ const invite = invites ? {
910
+ search: async (room, q, {identity}) => {
911
+ const uid = inviterOf(room, identity);
912
+ const entry = uid && live.has(room) ? await getThreadForUser(room, uid) : null;
913
+ if (!entry) return [];
914
+ const reach = new Set((await reachableFor(await getUserById(uid).catch(() => null))).map(u => u.id));
915
+ return invites.searchInvitees({hostUserId: uid, q, contacts: false, excludeUserIds: entry.data.participants ?? [], teamFilter: (u) => reach.has(u.id)});
916
+ },
917
+ send: async (room, people, {identity, name, via, request}) => {
918
+ const uid = inviterOf(room, identity);
919
+ const entry = uid ? await getThreadForUser(room, uid) : null;
920
+ const st = live.get(room);
921
+ if (!entry || !st) return {error: 'This call has ended.'};
922
+ if (!people.length) return {error: 'A call in Messages has no link to copy - add team members instead.'};
923
+ const inConversation = new Set(entry.data.participants ?? []);
924
+ const reach = new Set((await reachableFor(await getUserById(uid).catch(() => null))).map(u => u.id));
925
+ const targets = await invites.resolveInvitees(people, {hostUserId: uid, contacts: false, emails: false,
926
+ teamFilter: (u) => reach.has(u.id) && !inConversation.has(u.id)});
927
+ for (const t of targets) if (t.kind === 'user') st.invited.add(t.id);
928
+ const base = invites.inviteOrigin ? invites.inviteOrigin(request) : siteOrigin(request);
929
+ const results = await invites.deliverInvites(targets, {linkFor: (t) => `${base}${inviteLink(callSecret(), room, t.id)}`,
930
+ hostName: name || await nameOf(uid), hostUserId: uid, title: entry.data.kind === 'dm' ? '' : String(entry.data.name || ''),
931
+ via, key: `messages:${room}`, log: fastify.log, expiresAt: new Date(Date.now() + 2 * 3600e3).toISOString()});
932
+ return {results};
933
+ }
934
+ } : null;
935
+ calls = mountCallRoutes(fastify, {prefix: '/call', ns: 'messages', chat: callChat, ...(invite ? {invite} : {}), resolve: async (request, room, body) => {
936
+ const invited = String(body?.i ?? '') === '1';
937
+ const userId = invited ? checkInvite(callSecret(), room, body) : checkJoin(callSecret(), room, body);
938
+ if (!userId) return null;
939
+ const entry = await getEntry(THREADS_SLUG, room).catch(() => null);
940
+ if (!entry) return null;
941
+ const thread = {id: entry.id, ...entry.data};
942
+ // An invitation lets its person into THIS call only, while it runs.
943
+ if (invited && !live.get(entry.id)?.invited?.has(userId)) return null;
944
+ const user = await getUserById(userId).catch(() => null);
945
+ const names = {};
946
+ if (thread.kind === 'dm') for (const id of thread.participants ?? []) names[id] = await nameOf(id);
947
+ const who = whoIsCalling(thread, user, userId, names, {invited});
948
+ if (!who) return null;
949
+ // Someone rung in from outside the conversation sees the call's chat only from when they joined - never
950
+ // the conversation's earlier call messages (the engine's `history: false`).
951
+ if (invited && !(thread.participants ?? []).includes(userId)) who.history = false;
952
+ const st = live.get(entry.id) || await startCall(entry, userId);
953
+ if (!st.joinedAt) st.joinedAt = Date.now();
954
+ return who;
955
+ }});
956
+ const sweeper = setInterval(() => { sweepCalls().catch(() => {}); }, options.callSweepMs || 5000);
957
+ sweeper.unref?.();
958
+ fastify.addHook('onClose', async () => clearInterval(sweeper));
959
+ } catch (err) {
960
+ fastify.log?.warn?.(`[messages] calls unavailable on this CMS: ${err?.message || err}`);
961
+ }
962
+
963
+ // Direct messages for core (1.7; hooks.js 7c): a call's "Add people" in any Tool sends its Join link as a direct
964
+ // message from the host to a team member - the pair's DM (made when there is none), posted as the host.
965
+ if (typeof hooks.registerDirectMessenger === 'function') {
966
+ try { hooks.registerDirectMessenger((msg) => sendDirect(msg)); } catch { /* older engine */ }
967
+ }
968
+ async function sendDirect({fromUserId, toUserId, text}) {
969
+ const body = String(text || '').trim().slice(0, 4000);
970
+ if (!fromUserId || !toUserId || fromUserId === toUserId || !body) return {sent: false, reason: 'nobody to send it to'};
971
+ const [from, to] = await Promise.all([getUserById(fromUserId).catch(() => null), getUserById(toUserId).catch(() => null)]);
972
+ const active = (u) => u && u.disabled !== true && u.active !== false && u.isActive !== false;
973
+ if (!active(from) || !active(to)) return {sent: false, reason: 'the account is switched off'};
974
+ // Messages' own rule for who may message whom (the same as the picker and POST /threads).
975
+ if (!(await reachableFor(from)).some(u => u.id === to.id)) return {sent: false, reason: 'you cannot message them in Messages'};
976
+ const pair = [String(fromUserId), String(toUserId)].sort();
977
+ const {entries} = await listEntries(THREADS_SLUG, {limit: 10000});
978
+ let thread = entries.find(e => e.data.kind === 'dm' && Array.isArray(e.data.participants) && [...e.data.participants].sort().join('|') === pair.join('|'));
979
+ const now = new Date().toISOString();
980
+ if (!thread) {
981
+ thread = await createEntry(THREADS_SLUG, {kind: 'dm', name: null, participants: pair, createdBy: fromUserId, retentionDays: null,
982
+ lastMessageAt: now, lastMessagePreview: ''}, {createdBy: fromUserId, source: 'messages'});
983
+ broadcast([toUserId], 'thread:created', toThread(thread));
984
+ }
985
+ const msgEntry = await createEntry(MESSAGES_SLUG, {threadId: thread.id, senderId: fromUserId, body, attachments: [], createdAt: now,
986
+ editedAt: null, deletedAt: null, readBy: [fromUserId]}, {createdBy: fromUserId, source: 'messages'});
987
+ await touchThread(thread.id, {archivedBy: [], lastMessageAt: now, lastMessagePreview: body.slice(0, 120)});
988
+ broadcast(pair, 'message:new', {threadId: thread.id, message: toMessage(msgEntry)});
989
+ return {sent: true};
990
+ }
991
+
992
+ // For the tests: the sweep, run now, and what is running.
993
+ options.onCalls?.({sweep: (now) => sweepCalls(now), live, rooms: calls, present, sendDirect});
994
+ }