domma-cms 1.6.37 → 1.6.39

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 (82) hide show
  1. package/admin/dist/domma/domma-tools.css +3 -3
  2. package/admin/dist/domma/domma-tools.min.js +3 -3
  3. package/package.json +2 -2
  4. package/plugins/_lib/admin/mail/mail.css +1 -1
  5. package/plugins/_lib/admin/mail/oauth-panel.js +303 -0
  6. package/plugins/_lib/admin/mail/reader-view.js +126 -13
  7. package/plugins/_lib/mail/accounts.js +548 -225
  8. package/plugins/_lib/mail/imapPool.js +63 -13
  9. package/plugins/_lib/mail/oauth.js +453 -0
  10. package/plugins/_lib/mail/oauthRoutes.js +294 -0
  11. package/plugins/_lib/mail/readRoutes.js +23 -2
  12. package/plugins/_lib/mail/scheduler.js +30 -6
  13. package/plugins/blog/CLAUDE.md +11 -1
  14. package/plugins/blog/lib/cache.js +43 -0
  15. package/plugins/blog/lib/page.js +28 -9
  16. package/plugins/blog/plugin.js +14 -13
  17. package/plugins/blog/plugin.json +1 -1
  18. package/plugins/blog/plugin.public.js +1 -1
  19. package/plugins/blog/tests/cache.test.js +65 -0
  20. package/plugins/booking/CLAUDE.md +14 -2
  21. package/plugins/booking/admin/lib/diary-layout.js +18 -0
  22. package/plugins/booking/admin/views/diary.js +153 -152
  23. package/plugins/booking/admin/views/people.js +64 -66
  24. package/plugins/booking/admin/views/settings.js +31 -32
  25. package/plugins/booking/plugin.json +1 -1
  26. package/plugins/booking/tests/admin-diary.test.js +9 -1
  27. package/plugins/feedback/CLAUDE.md +62 -0
  28. package/plugins/feedback/admin/css/index.css +16 -1
  29. package/plugins/feedback/admin/lib/kit.js +106 -0
  30. package/plugins/feedback/admin/lib/settings.js +107 -0
  31. package/plugins/feedback/admin/views/feedback.js +4 -1
  32. package/plugins/feedback/admin/views/inbox.js +11 -2
  33. package/plugins/feedback/admin/views/send.js +74 -7
  34. package/plugins/feedback/docs/guide.md +9 -9
  35. package/plugins/feedback/lib/attachments.js +133 -0
  36. package/plugins/feedback/lib/cache.js +43 -0
  37. package/plugins/feedback/lib/receiver.js +36 -6
  38. package/plugins/feedback/lib/sender.js +89 -6
  39. package/plugins/feedback/lib/shape.js +111 -6
  40. package/plugins/feedback/plugin.js +173 -17
  41. package/plugins/feedback/plugin.json +2 -2
  42. package/plugins/feedback/tests/api.test.js +125 -3
  43. package/plugins/feedback/tests/settings.test.js +59 -0
  44. package/plugins/feedback/tests/shape.test.js +87 -1
  45. package/plugins/free-tier.lock.json +74 -62
  46. package/plugins/mail-reader/CLAUDE.md +15 -4
  47. package/plugins/mail-reader/plugin.json +1 -1
  48. package/plugins/shopping-cart/CLAUDE.md +35 -2
  49. package/plugins/shopping-cart/admin/css/index.css +3 -0
  50. package/plugins/shopping-cart/admin/views/orders.js +75 -23
  51. package/plugins/shopping-cart/admin/views/shop.js +1 -1
  52. package/plugins/shopping-cart/docs/guide.md +7 -3
  53. package/plugins/shopping-cart/lib/payments.js +15 -2
  54. package/plugins/shopping-cart/lib/refunds.js +51 -0
  55. package/plugins/shopping-cart/lib/sales.js +15 -8
  56. package/plugins/shopping-cart/lib/shop.js +38 -3
  57. package/plugins/shopping-cart/plugin.js +47 -19
  58. package/plugins/shopping-cart/plugin.json +2 -2
  59. package/plugins/shopping-cart/tests/api.test.js +52 -0
  60. package/plugins/shopping-cart/tests/commission.test.js +1 -1
  61. package/plugins/shopping-cart/tests/pure.test.js +16 -0
  62. package/plugins/shopping-cart/tests/refunds.test.js +42 -0
  63. package/plugins/support/CLAUDE.md +58 -0
  64. package/plugins/support/admin/css/index.css +16 -1
  65. package/plugins/support/admin/lib/kit.js +192 -8
  66. package/plugins/support/admin/lib/settings.js +123 -0
  67. package/plugins/support/admin/views/desk.js +55 -12
  68. package/plugins/support/admin/views/inbox.js +17 -4
  69. package/plugins/support/admin/views/support.js +4 -1
  70. package/plugins/support/docs/guide.md +7 -9
  71. package/plugins/support/lib/attachments.js +133 -0
  72. package/plugins/support/lib/cache.js +43 -0
  73. package/plugins/support/lib/receiver.js +45 -10
  74. package/plugins/support/lib/sender.js +96 -10
  75. package/plugins/support/lib/shape.js +153 -12
  76. package/plugins/support/plugin.js +159 -29
  77. package/plugins/support/plugin.json +2 -2
  78. package/plugins/support/tests/api.test.js +125 -3
  79. package/plugins/support/tests/settings.test.js +68 -0
  80. package/plugins/support/tests/shape.test.js +76 -2
  81. package/server/routes/outreach-public.js +2 -2
  82. package/server/services/request-log.js +3 -1
@@ -12,16 +12,20 @@
12
12
  * POST /tickets/:ref/messages write again
13
13
  * POST /tickets/:ref/close mark resolved
14
14
  * PUT /tickets/:ref/seen Domma's latest reply has been read
15
+ * POST /attachments a file for the message being written (multipart, kept here)
16
+ * GET /attachments/:id one of this site's attached files
15
17
  * PUT /tickets/:ref/shelf archive it, remove it from this site's list, or put it back
16
18
  * ({state: archived|removed|null}); removing one that never
17
19
  * reached Domma cancels it. Domma keeps its copy either way.
18
20
  *
19
21
  * manager (Site Manager is installed beside it)
20
22
  * POST /fleet/tickets · POST /fleet/messages · GET /fleet/mine from the sites (Bearer fleet token)
23
+ * POST /fleet/attach/:ref/:att one of a request's announced files, raw bytes (Bearer fleet token)
21
24
  * GET /mode · /meta · /count the badge counts requests waiting on Domma, red while one is urgent
22
25
  * GET /tickets · GET /tickets/:id
23
26
  * POST /tickets/:id/reply answer (and set the status), emailing the customer
24
27
  * PUT /tickets/:id status, priority, internal notes, archived
28
+ * GET /tickets/:id/attachments/:att a file the customer attached
25
29
  * DELETE /tickets/:id
26
30
  *
27
31
  * A standalone site (not started by a manager) still gets the contact card -
@@ -36,9 +40,11 @@ import path from 'node:path';
36
40
  import {fileURLToPath} from 'node:url';
37
41
  import defaults from './config.js';
38
42
  import {
39
- CATEGORIES, PRIORITIES, STATUSES, buildMessagePayload, buildTicketPayload, cleanMessage, cleanRequest, cleanUpdate,
40
- contactCard, createThrottle, lastDommaAt, senderView, toCustomerEmail, toDommaEmail
43
+ ATTACHMENTS, CATEGORIES, PRIORITIES, STATUSES, attachmentsOf, buildMessagePayload, buildTicketPayload, cleanAttachment, cleanMessage,
44
+ cleanRequest, cleanSettings, cleanUpdate, contactCard, createThrottle, settingsErrors, lastDommaAt, senderView, toCustomerEmail, toDommaEmail
41
45
  } from './lib/shape.js';
46
+ import {createStore, readUpload, sendFile} from './lib/attachments.js';
47
+ import {getPluginSettings, getPluginStates, savePluginState} from '../../server/services/plugins.js';
42
48
  import {applyShelf, createOutbox, flush, managerApi, managerOf, mergeTickets, newRef} from './lib/sender.js';
43
49
 
44
50
  const HERE = path.dirname(fileURLToPath(import.meta.url));
@@ -59,9 +65,22 @@ export default async function supportPlugin(fastify, options) {
59
65
  const {authenticate, requirePermission} = options.auth;
60
66
  const hooks = options.hooks || {};
61
67
  const can = (action) => ({preHandler: [authenticate, requirePermission(NAME, action)]});
62
- const settings = {...defaults, ...(options.settings || {})};
63
68
  const receiverMod = await import('./lib/receiver.js');
64
69
  const mode = options.mode || (receiverMod.isManager() ? 'receiver' : 'sender');
70
+ // Settings are read when used (1.1.5), not once at boot: the cog's save, and
71
+ // Marketplace > Configure, take effect without a restart. `allSettings` is the
72
+ // whole merged object (a site's own contact card is its fallback before the
73
+ // manager's arrives); `liveSettings` is this side's keys, checked
74
+ // (lib/shape.js SIDE_SETTINGS). Tests inject a reader and writer.
75
+ const side = mode === 'receiver' ? 'receiver' : 'sender';
76
+ const readSettings = typeof options.readSettings === 'function' ? options.readSettings
77
+ : async () => getPluginSettings(NAME).catch(() => options.settings || {});
78
+ const writeSettings = typeof options.writeSettings === 'function' ? options.writeSettings
79
+ // Merged over what is stored: the object holds the other side's keys too.
80
+ : (next) => savePluginState(NAME, {settings: {...(getPluginStates()[NAME]?.settings || {}), ...next}});
81
+ const allSettings = async () => ({...defaults, ...(await readSettings().catch(() => ({})))});
82
+ const liveSettings = async () => cleanSettings(await allSettings(), defaults, side);
83
+ const settingsHooks = {saved: () => {}};
65
84
  const notify = typeof hooks.notify === 'function' ? (n) => { try { hooks.notify(n); } catch { /* a notice is a courtesy */ } } : () => {};
66
85
  const mail = options.mail || await import('./lib/mail.js');
67
86
  if (typeof hooks.registerNotificationSource === 'function') {
@@ -77,23 +96,51 @@ export default async function supportPlugin(fastify, options) {
77
96
  }
78
97
 
79
98
  // On the manager the inbox needs it, so `manage` is enough there.
80
- fastify.get('/meta', can(mode === 'receiver' ? 'manage' : 'send'), async () => ({categories: CATEGORIES, priorities: PRIORITIES, statuses: STATUSES}));
99
+ fastify.get('/meta', can(mode === 'receiver' ? 'manage' : 'send'), async () => ({categories: CATEGORIES, priorities: PRIORITIES, statuses: STATUSES,
100
+ attachments: {max: ATTACHMENTS.max, maxBytes: ATTACHMENTS.maxBytes}}));
101
+
102
+ // ---- The settings cog (1.1.5) ----
103
+ // A site's sync interval is its administrators' (requireAdmin); the manager's card,
104
+ // notify address and per-site brake are the inbox's (`manage`).
105
+ const settingsGuard = mode === 'receiver' ? can('manage')
106
+ : {preHandler: [authenticate, typeof options.auth.requireAdmin === 'function' ? options.auth.requireAdmin : requirePermission(NAME, 'send')]};
107
+ fastify.get('/settings', settingsGuard, async () => ({side, settings: await liveSettings()}));
108
+ fastify.put('/settings', settingsGuard, async (request, reply) => {
109
+ const errors = settingsErrors(request.body, side);
110
+ if (Object.keys(errors).length) return reply.code(400).send({error: Object.values(errors).join(' '), errors});
111
+ const next = cleanSettings(request.body, await liveSettings(), side);
112
+ await writeSettings(next);
113
+ settingsHooks.saved(next);
114
+ return {side, settings: next};
115
+ });
81
116
 
82
- if (mode === 'receiver') await receiverRoutes(fastify, {can, notify, mail, receiverMod, settings, options});
83
- else await senderRoutes(fastify, {can, notify, settings, options});
117
+ if (mode === 'receiver') await receiverRoutes(fastify, {can, notify, mail, receiverMod, liveSettings, options});
118
+ else await senderRoutes(fastify, {can, notify, allSettings, liveSettings, settingsHooks, options});
84
119
  }
85
120
 
86
121
  // ---------------------------------------------------------------------------------
87
122
  // Site
88
123
  // ---------------------------------------------------------------------------------
89
124
 
90
- async function senderRoutes(fastify, {can, notify, settings, options}) {
125
+ async function senderRoutes(fastify, {can, notify, allSettings, liveSettings, settingsHooks, options}) {
91
126
  const env = options.env || process.env;
92
127
  const manager = managerOf(env);
93
- const outbox = createOutbox(options.dataDir || path.join(HERE, 'data'));
128
+ const dataDir = options.dataDir || path.join(HERE, 'data');
129
+ const outbox = createOutbox(dataDir);
94
130
  const api = manager ? managerApi(manager, options.fetch) : null;
131
+ // Files attached to messages, kept here as well: they go after their message, a message waiting
132
+ // to send must still have them, and the thread shows them from here. Never under the public
133
+ // media folder - they are screenshots of this site's admin.
134
+ const files = createStore(path.join(dataDir, 'attachments'));
135
+ /** Files picked in a panel that was then closed: gone after a day. Those a message names stay. */
136
+ const pruneFiles = () => { try { files.prune({keep: outbox.fileIds()}); } catch { /* tidying only */ } };
137
+ /** What each attached file is comes from what was uploaded here, never from the form's say-so. */
138
+ const ownFiles = (list) => {
139
+ const out = list.map(a => files.meta(a.id));
140
+ return out.some(a => !a) ? null : out;
141
+ };
95
142
  // The manager's card wins once heard; until then (and standalone) this site's settings.
96
- const contact = () => outbox.contact() || contactCard(settings);
143
+ const contact = async () => outbox.contact() || contactCard(await allSettings());
97
144
 
98
145
  let lastSync = 0;
99
146
  let syncing = null;
@@ -107,7 +154,7 @@ async function senderRoutes(fastify, {can, notify, settings, options}) {
107
154
  lastSync = Date.now();
108
155
  const seen = outbox.seen();
109
156
  if (!api) return {tickets: mergeTickets(outbox.all(), [], seen), reachable: false, error: ''};
110
- const pushed = await flush(outbox, api);
157
+ const pushed = await flush(outbox, api, files);
111
158
  const mine = await api.mine();
112
159
  const remote = mine.ok ? (mine.body?.tickets || []) : null;
113
160
  if (remote) {
@@ -129,15 +176,26 @@ async function senderRoutes(fastify, {can, notify, settings, options}) {
129
176
  }
130
177
 
131
178
  // Every few minutes: waiting messages go, new replies are noticed. Off in tests.
179
+ // Each wait asks for syncMinutes again, and a save re-arms it (1.1.5).
132
180
  if (api && options.timer !== false) {
133
- const t = setInterval(() => { sync().catch(() => {}); }, Math.max(1, Number(settings.syncMinutes) || 5) * 60_000);
134
- t.unref?.();
181
+ let t = null;
182
+ let closed = false;
183
+ const arm = async () => {
184
+ clearTimeout(t);
185
+ if (closed) return;
186
+ const minutes = Math.max(1, Number((await liveSettings().catch(() => ({}))).syncMinutes) || 5);
187
+ if (closed) return;
188
+ t = setTimeout(() => { pruneFiles(); sync().catch(() => {}).finally(() => arm()); }, minutes * 60_000);
189
+ t.unref?.();
190
+ };
191
+ arm();
192
+ settingsHooks.saved = () => { arm(); };
135
193
  const first = setTimeout(() => { sync().catch(() => {}); }, 30_000);
136
194
  first.unref?.();
137
- fastify.addHook('onClose', async () => { clearInterval(t); clearTimeout(first); });
195
+ fastify.addHook('onClose', async () => { closed = true; clearTimeout(t); clearTimeout(first); });
138
196
  }
139
197
 
140
- fastify.get('/mode', can('send'), async () => ({mode: 'sender', connected: Boolean(manager), site: manager?.slug || '', contact: contact()}));
198
+ fastify.get('/mode', can('send'), async () => ({mode: 'sender', connected: Boolean(manager), site: manager?.slug || '', contact: await contact()}));
141
199
 
142
200
  fastify.get('/count', can('send'), async () => {
143
201
  // The badge reads the last sync's copy and never waits on the manager - but it is
@@ -174,18 +232,36 @@ async function senderRoutes(fastify, {can, notify, settings, options}) {
174
232
 
175
233
  fastify.get('/tickets', can('send'), async () => {
176
234
  const r = await withDeadline(sync(), () => offlineView('Domma is slow to answer - showing what we last heard.'));
177
- return {connected: Boolean(manager), reachable: r.reachable, error: r.error, contact: contact(), tickets: applyShelf(r.tickets, outbox.shelf())};
235
+ return {connected: Boolean(manager), reachable: r.reachable, error: r.error, contact: await contact(), tickets: applyShelf(r.tickets, outbox.shelf())};
178
236
  });
179
237
 
180
238
  const notConnected = (reply) => reply.code(409).send({error: 'This site is not connected to Domma, so a request cannot be sent from here. Email us instead.'});
181
239
 
240
+ fastify.post('/attachments', can('send'), async (request, reply) => {
241
+ if (!manager) return notConnected(reply);
242
+ const up = await readUpload(request);
243
+ if (up.error) return reply.code(up.code || 400).send({error: up.error});
244
+ return reply.code(201).send(files.save({id: randomUUID(), name: up.file.name, type: up.file.type, size: up.file.size}, up.file.buffer));
245
+ });
246
+
247
+ fastify.get('/attachments/:id', can('send'), async (request, reply) => {
248
+ const a = files.meta(String(request.params.id));
249
+ const buffer = a && files.read(a);
250
+ if (!buffer) return reply.code(404).send({error: 'That file is not here.'});
251
+ return sendFile(reply, a, buffer);
252
+ });
253
+
254
+ const missingFile = (reply) => reply.code(400).send({error: 'An attached file is no longer here - attach it again.'});
255
+
182
256
  fastify.post('/tickets', can('send'), async (request, reply) => {
183
257
  if (!manager) return notConnected(reply);
184
258
  const {request: req, error} = cleanRequest(request.body || {});
185
259
  if (error) return reply.code(400).send({error});
260
+ req.attachments = ownFiles(req.attachments);
261
+ if (!req.attachments) return missingFile(reply);
186
262
  const payload = buildTicketPayload({request: req, user: request.user, cmsVersion: cmsVersion(), site: manager.slug, ref: newRef()});
187
263
  outbox.add('ticket', payload);
188
- const r = await withDeadline(flush(outbox, api), () => ({error: 'Domma is slow to answer'}));
264
+ const r = await withDeadline(flush(outbox, api, files), () => ({error: 'Domma is slow to answer'}));
189
265
  const item = outbox.all().find(i => i.ref === payload.ref);
190
266
  return reply.code(201).send({ref: payload.ref, state: item?.state || 'waiting',
191
267
  message: item?.state === 'sent' ? 'Sent to Domma Support. We will reply here, and by email.'
@@ -198,16 +274,19 @@ async function senderRoutes(fastify, {can, notify, settings, options}) {
198
274
  const known = outbox.tickets().some(t => t.ref === ticketRef) || outbox.all().some(i => i.kind === 'ticket' && i.ref === ticketRef);
199
275
  if (!known) return reply.code(404).send({error: 'No such request on this site.'});
200
276
  let body = '';
277
+ let attachments = [];
201
278
  if (!close) {
202
279
  const {message, error} = cleanMessage(request.body || {});
203
280
  if (error) return reply.code(400).send({error});
204
281
  body = message.body;
282
+ attachments = ownFiles(message.attachments);
283
+ if (!attachments) return missingFile(reply);
205
284
  }
206
- const payload = buildMessagePayload({ticketRef, ref: newRef(), body, close, user: request.user});
285
+ const payload = buildMessagePayload({ticketRef, ref: newRef(), body, close, user: request.user, attachments});
207
286
  outbox.add('message', payload, ticketRef);
208
287
  // Writing to an archived request brings it back to the list.
209
288
  if (body) outbox.shelve(ticketRef, null);
210
- await withDeadline(flush(outbox, api), () => ({}));
289
+ await withDeadline(flush(outbox, api, files), () => ({}));
211
290
  const item = outbox.all().find(i => i.ref === payload.ref);
212
291
  return reply.code(201).send({ref: payload.ref, state: item?.state || 'waiting',
213
292
  message: item?.state === 'sent' ? (close ? 'Marked as resolved. Thank you.' : 'Sent.') : 'Kept here and sent as soon as Domma answers.'});
@@ -242,11 +321,31 @@ async function senderRoutes(fastify, {can, notify, settings, options}) {
242
321
  // Manager
243
322
  // ---------------------------------------------------------------------------------
244
323
 
245
- async function receiverRoutes(fastify, {can, notify, mail, receiverMod, settings, options}) {
324
+ async function receiverRoutes(fastify, {can, notify, mail, receiverMod, liveSettings, options}) {
246
325
  const rx = receiverMod;
247
326
  const slugOf = options.slugFromToken || rx.slugFromToken;
248
327
  const directory = options.siteDirectory || rx.siteDirectory;
249
- const allowed = createThrottle({max: Number(settings.perSiteMax) || 20, windowMs: (Number(settings.perSiteMinutes) || 10) * 60_000});
328
+ // The card every site shows, as set now.
329
+ const card = async () => contactCard(await liveSettings());
330
+ // The per-site brakes take their limits at each call (the cog changes them live).
331
+ const messageThrottle = createThrottle();
332
+ const fileThrottle = createThrottle();
333
+ const limits = async () => {
334
+ const s = await liveSettings().catch(() => ({}));
335
+ return {max: Number(s.perSiteMax) || 20, windowMs: (Number(s.perSiteMinutes) || 10) * 60_000};
336
+ };
337
+ const allowed = async (slug) => messageThrottle(slug, Date.now(), await limits());
338
+ // Files have their own brake: up to ATTACHMENTS.max per message allowed in the same window.
339
+ const allowedFile = async (slug) => {
340
+ const l = await limits();
341
+ return fileThrottle(slug, Date.now(), {max: l.max * ATTACHMENTS.max, windowMs: l.windowMs});
342
+ };
343
+ // A request's files live in data/attachments/<request entry id>/.
344
+ const files = createStore(path.join(options.dataDir || path.join(HERE, 'data'), 'attachments'));
345
+ // A site sends a file's bytes raw. Only inside this plugin's scope; the engine has no parser for it.
346
+ if (!fastify.hasContentTypeParser('application/octet-stream')) {
347
+ fastify.addContentTypeParser('application/octet-stream', {parseAs: 'buffer', bodyLimit: ATTACHMENTS.maxBytes + 1024}, (req, body, done) => done(null, body));
348
+ }
250
349
  const log = fastify.log;
251
350
  if (options.dataDir) rx.setDataDir(options.dataDir);
252
351
  try { await rx.ensureCollection(); } catch (err) { log.error(`[support] collection: ${err.message}`); }
@@ -280,9 +379,10 @@ async function receiverRoutes(fastify, {can, notify, mail, receiverMod, settings
280
379
  notify({title: `${d.priority === 'urgent' ? 'URGENT - ' : ''}${isNew ? 'Support request' : 'Reply'} from ${name}: ${d.subject}`,
281
380
  body: body.slice(0, 300), severity: d.priority === 'urgent' ? 'warning' : 'info', source: 'new-request',
282
381
  link: `#/plugins/support/${entry.id}`, dedupeKey: `support:${entry.id}:${d.lastAt}`});
283
- if (settings.notifyEmail) {
284
- mail.send({to: settings.notifyEmail, ...toDommaEmail({ticket: d, siteName: name, link: inboxLink(entry.id), body, isNew})}, {log});
285
- }
382
+ // The address as set now; read without holding up the request that caused it.
383
+ liveSettings().then((s) => {
384
+ if (s.notifyEmail) mail.send({to: s.notifyEmail, ...toDommaEmail({ticket: d, siteName: name, link: inboxLink(entry.id), body, isNew})}, {log});
385
+ }).catch(() => {});
286
386
  }
287
387
 
288
388
  // ---- From the sites (no session: the fleet token IS the identity) ----
@@ -296,7 +396,7 @@ async function receiverRoutes(fastify, {can, notify, mail, receiverMod, settings
296
396
  fastify.post('/fleet/tickets', {bodyLimit: 32 * 1024}, async (request, reply) => {
297
397
  const slug = await fleet(request, reply);
298
398
  if (!slug) return reply;
299
- if (!allowed(slug)) return reply.code(429).send({error: 'That is a lot of requests at once - try again in a few minutes, or email us.'});
399
+ if (!await allowed(slug)) return reply.code(429).send({error: 'That is a lot of requests at once - try again in a few minutes, or email us.'});
300
400
  const r = await rx.open(slug, request.body || {}, undefined, siteOf(slug));
301
401
  if (r.missing) return reply.code(404).send({error: r.error});
302
402
  if (r.error) return reply.code(400).send({error: r.error});
@@ -307,7 +407,7 @@ async function receiverRoutes(fastify, {can, notify, mail, receiverMod, settings
307
407
  fastify.post('/fleet/messages', {bodyLimit: 32 * 1024}, async (request, reply) => {
308
408
  const slug = await fleet(request, reply);
309
409
  if (!slug) return reply;
310
- if (!allowed(slug)) return reply.code(429).send({error: 'That is a lot of messages at once - try again in a few minutes.'});
410
+ if (!await allowed(slug)) return reply.code(429).send({error: 'That is a lot of messages at once - try again in a few minutes.'});
311
411
  const r = await rx.customerTurn(slug, request.body || {});
312
412
  if (r.missing) return reply.code(404).send({error: r.error});
313
413
  if (r.error) return reply.code(400).send({error: r.error});
@@ -316,10 +416,29 @@ async function receiverRoutes(fastify, {can, notify, mail, receiverMod, settings
316
416
  return {id: r.entry.id, status: r.entry.data.status};
317
417
  });
318
418
 
419
+ fastify.post('/fleet/attach/:ref/:att', {bodyLimit: ATTACHMENTS.maxBytes + 1024}, async (request, reply) => {
420
+ const slug = await fleet(request, reply);
421
+ if (!slug) return reply;
422
+ if (!await allowedFile(slug)) return reply.code(429).send({error: 'That is a lot of files at once - try again in a few minutes.'});
423
+ // Looked up in THAT site's requests only: another site's ref is "not found".
424
+ const e = await rx.ticketByRef(slug, request.params.ref);
425
+ if (!e) return reply.code(404).send({error: 'No such request.'});
426
+ // Only a file a message announced, at the size and type it announced.
427
+ const announced = attachmentsOf(e.data).find(x => x.id === String(request.params.att));
428
+ const a = cleanAttachment(announced);
429
+ if (!a) return reply.code(404).send({error: 'That request has no such file.'});
430
+ if (announced.stored) return {id: a.id, stored: true};
431
+ const body = request.body;
432
+ if (!Buffer.isBuffer(body) || body.length !== a.size) return reply.code(400).send({error: 'That file is not the size the message said.'});
433
+ files.save(a, body, e.id);
434
+ await rx.markStored(e.id, a.id);
435
+ return reply.code(201).send({id: a.id, stored: true});
436
+ });
437
+
319
438
  fastify.get('/fleet/mine', async (request, reply) => {
320
439
  const slug = await fleet(request, reply);
321
440
  if (!slug) return reply;
322
- return {tickets: (await rx.ticketsOf(slug)).map(senderView), contact: contactCard(settings)};
441
+ return {tickets: (await rx.ticketsOf(slug)).map(senderView), contact: await card()};
323
442
  });
324
443
 
325
444
  // ---- The inbox ----
@@ -329,8 +448,10 @@ async function receiverRoutes(fastify, {can, notify, mail, receiverMod, settings
329
448
  /** One request for the panel: plus where a reply will be emailed, and why. */
330
449
  const detail = async (e) => ({...row(e), replyTo: await replyTarget(e.data)});
331
450
 
332
- fastify.get('/mode', can('manage'), async () => ({mode: 'receiver', connected: true, contact: contactCard(settings),
333
- notifyEmail: settings.notifyEmail || ''}));
451
+ fastify.get('/mode', can('manage'), async () => {
452
+ const s = await liveSettings();
453
+ return {mode: 'receiver', connected: true, contact: contactCard(s), notifyEmail: s.notifyEmail || ''};
454
+ });
334
455
 
335
456
  fastify.get('/count', can('manage'), async () => {
336
457
  // The archive is out of sight: it counts for nothing (a customer writing again takes it out).
@@ -378,7 +499,7 @@ async function receiverRoutes(fastify, {can, notify, mail, receiverMod, settings
378
499
  const target = await replyTarget(d);
379
500
  if (target.email && (body || (d.status === 'closed' && e.data.status !== 'closed'))) {
380
501
  // From the support mailbox and replying to it: a customer who answers the email reaches Domma Support.
381
- const support = contactCard(settings).email;
502
+ const support = (await card()).email;
382
503
  mail.send({to: target.email, ...(support && {from: support, replyTo: support}),
383
504
  ...toCustomerEmail({ticket: d, siteName: siteOf(d.site).name, link: siteLink(d.site, d.ref), body, resolved: d.status === 'closed'})}, {log});
384
505
  }
@@ -393,10 +514,19 @@ async function receiverRoutes(fastify, {can, notify, mail, receiverMod, settings
393
514
  return row(await rx.patch(e.id, patch, String(request.user?.name || 'Domma Support')));
394
515
  });
395
516
 
517
+ fastify.get('/tickets/:id/attachments/:att', can('manage'), async (request, reply) => {
518
+ const e = await rx.getTicket(request.params.id);
519
+ const a = e && cleanAttachment(attachmentsOf(e.data).find(x => x.id === String(request.params.att)));
520
+ const buffer = a && files.read(a, e.id);
521
+ if (!buffer) return reply.code(404).send({error: 'That file has not arrived, or is gone.'});
522
+ return sendFile(reply, a, buffer);
523
+ });
524
+
396
525
  fastify.delete('/tickets/:id', can('manage'), async (request, reply) => {
397
526
  const e = await rx.getTicket(request.params.id);
398
527
  if (!e) return reply.code(404).send({error: 'No such request.'});
399
528
  await rx.removeTicket(e.id);
529
+ files.removeAll(e.id);
400
530
  return {ok: true};
401
531
  });
402
532
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "support",
3
3
  "displayName": "Support",
4
- "version": "1.1.2",
4
+ "version": "1.1.6",
5
5
  "tier": "free",
6
6
  "description": "Get help from Domma straight from your admin: our email, phone and hours always to hand, and a conversation with the Domma team for anything you need - urgent requests first. Right-click a request to archive, resolve or remove it.",
7
7
  "author": "Domma CMS",
@@ -61,7 +61,7 @@
61
61
  ],
62
62
  "views": {
63
63
  "plugin-support": {
64
- "entry": "support/admin/views/support.js?v=1.1.2",
64
+ "entry": "support/admin/views/support.js?v=1.1.6",
65
65
  "exportName": "supportView"
66
66
  }
67
67
  }
@@ -11,6 +11,7 @@ import os from 'node:os';
11
11
  import path from 'node:path';
12
12
  import Fastify from 'fastify';
13
13
  import support from '../plugin.js';
14
+ import {allTickets, forget} from '../lib/receiver.js';
14
15
  import {getEntry, updateEntry} from '../../../server/services/collections.js';
15
16
 
16
17
  const COLL = path.resolve('content/collections/support-tickets');
@@ -34,12 +35,15 @@ const fetchInto = (app) => async (url, init = {}) => {
34
35
 
35
36
  const ANN = {id: 'u1', name: 'Ann', email: 'ann@acme.test'};
36
37
 
38
+ // The manager's settings as stored; the plugin reads them per request (1.1.5).
39
+ const managerSettings = {perSiteMax: 30, perSiteMinutes: 10, phone: '+44 1234 567890', hours: 'Always', notifyEmail: 'support@dommajs.test'};
40
+
37
41
  async function site({token, user = ANN, settings = {}, env, screenWaitMs} = {}) {
38
42
  const app = Fastify();
39
43
  await app.register(support, {
40
44
  ...(screenWaitMs && {screenWaitMs}),
41
45
  auth: {authenticate: async (req) => { req.user = user; }, requirePermission: () => async () => {}},
42
- settings, mode: 'sender', timer: false, dataDir: await fs.mkdtemp(path.join(dataDir, 'site-')),
46
+ settings, readSettings: async () => ({...settings}), mode: 'sender', timer: false, dataDir: await fs.mkdtemp(path.join(dataDir, 'site-')),
43
47
  env: env ?? {MANAGER_URL: 'http://127.0.0.1:8192/', FLEET_TOKEN: token, SITE_SLUG: TOKENS[token] || 'x'},
44
48
  fetch: fetchInto(manager),
45
49
  hooks: {notify: (n) => siteNotices.push(n), registerNotificationSource() {}}
@@ -63,7 +67,7 @@ before(async () => {
63
67
  prefix: '/api/plugins/support', mode: 'receiver', mail,
64
68
  // acme was bought (an owner on the manager); beta was made by hand.
65
69
  ownerEmail: async (slug) => (slug === 'acme' ? 'owner@acme.test' : null), dataDir: await fs.mkdtemp(path.join(dataDir, 'manager-')),
66
- settings: {perSiteMax: 30, perSiteMinutes: 10, phone: '+44 1234 567890', hours: 'Always', notifyEmail: 'support@dommajs.test'},
70
+ settings: {...managerSettings}, readSettings: async () => ({...managerSettings}),
67
71
  slugFromToken: async (h) => TOKENS[String(h || '').replace(/^Bearer\s+/i, '')] || null,
68
72
  siteDirectory: () => SITES,
69
73
  hooks: {notify: (n) => notices.push(n), registerNotificationSource() {}}
@@ -197,6 +201,11 @@ test('one site cannot flood the inbox', async () => {
197
201
  codes.push(r.status);
198
202
  }
199
203
  assert.deepEqual(codes.filter(c => c === 429).length, 2);
204
+ // A higher limit saved in the cog applies to the next message - no restart.
205
+ managerSettings.perSiteMax = 31;
206
+ const more = await call(manager, 'POST', '/api/plugins/support/fleet/tickets', {ref: 'f-live', subject: 's', body: 'b'}, {authorization: 'Bearer tok-flood'});
207
+ assert.equal(more.status, 201, 'live limit');
208
+ managerSettings.perSiteMax = 30;
200
209
  void flood;
201
210
  });
202
211
 
@@ -218,9 +227,10 @@ test('review: a customer message lost on the manager is sent again', async () =>
218
227
  const s = (await call(acme, 'POST', '/tickets', {subject: 'Lost one', body: 'first'})).body;
219
228
  await call(acme, 'POST', `/tickets/${s.ref}/messages`, {body: 'second'});
220
229
  const t = (await call(manager, 'GET', '/api/plugins/support/tickets')).body.tickets.find(x => x.ref === s.ref);
221
- // Simulate a concurrent write that dropped it.
230
+ // Simulate a concurrent write that dropped it - behind the plugin's back, so its memory is retired by hand.
222
231
  const e = await getEntry('support-tickets', t.id);
223
232
  await updateEntry('support-tickets', t.id, {...e.data, messages: e.data.messages.slice(0, 1)});
233
+ forget();
224
234
  await call(acme, 'GET', '/tickets'); // notices it is missing, marks it waiting
225
235
  await call(acme, 'GET', '/tickets'); // sends it again
226
236
  const after = (await call(manager, 'GET', `/api/plugins/support/tickets/${t.id}`)).body;
@@ -379,3 +389,115 @@ test('review: archiving measures against the reply the customer SAW - a newer on
379
389
  assert.equal(now.archived, false, 'the newer reply keeps it in the list');
380
390
  assert.equal(now.unread, true);
381
391
  });
392
+
393
+ test('the inbox memory: polls share one read, and every write is seen by the very next read', async () => {
394
+ const P = '/api/plugins/support';
395
+ const acme = await site({token: 'tok-acme'});
396
+ forget();
397
+ const first = await allTickets();
398
+ assert.equal(await allTickets(), first, 'a second read inside the window is the same read');
399
+ await call(manager, 'GET', `${P}/count`);
400
+ // A site opens a request: the badge and the inbox show it at once, not in 30 s.
401
+ const s = (await call(acme, 'POST', '/tickets', {subject: 'Cache me', body: 'A fresh read, please.', priority: 'urgent'})).body;
402
+ assert.notEqual(await allTickets(), first, 'the write retired the memory');
403
+ const t = (await call(manager, 'GET', `${P}/tickets`)).body.tickets.find(x => x.ref === s.ref);
404
+ assert.ok(t, 'in the inbox straight away');
405
+ assert.ok((await call(manager, 'GET', `${P}/count`)).body.details.items.some(i => i.text === 'Cache me'), 'and on the badge');
406
+ // Domma's reply, a status change and a delete: each on the very next read.
407
+ await call(manager, 'POST', `${P}/tickets/${t.id}/reply`, {body: 'Here you are.'});
408
+ assert.equal((await call(manager, 'GET', `${P}/tickets`)).body.tickets.find(x => x.id === t.id).messages.length, 2);
409
+ await call(manager, 'PUT', `${P}/tickets/${t.id}`, {status: 'closed'});
410
+ assert.equal((await call(manager, 'GET', `${P}/tickets`)).body.tickets.find(x => x.id === t.id).status, 'closed');
411
+ assert.equal((await call(acme, 'GET', '/tickets')).body.tickets.find(x => x.ref === s.ref).status, 'closed', 'the site hears it on its next sync');
412
+ assert.equal((await call(manager, 'DELETE', `${P}/tickets/${t.id}`)).status, 200);
413
+ assert.ok(!(await call(manager, 'GET', `${P}/tickets`)).body.tickets.some(x => x.id === t.id), 'gone from the very next read');
414
+ });
415
+
416
+ // ---- Attachments (1.1.4) ----
417
+
418
+ import multipart from '@fastify/multipart';
419
+
420
+ /** One file as a multipart form. */
421
+ function form(name, type, content) {
422
+ const b = '----sp' + Math.random().toString(16).slice(2);
423
+ return {
424
+ headers: {'content-type': `multipart/form-data; boundary=${b}`},
425
+ payload: Buffer.concat([Buffer.from(`--${b}\r\nContent-Disposition: form-data; name="file"; filename="${name}"\r\nContent-Type: ${type}\r\n\r\n`),
426
+ Buffer.from(content), Buffer.from(`\r\n--${b}--\r\n`)])
427
+ };
428
+ }
429
+
430
+ /** A site app that can take uploads (the engine registers multipart for every plugin). */
431
+ async function siteWithUploads(token) {
432
+ const app = Fastify();
433
+ await app.register(multipart, {limits: {fileSize: 10 * 1024 * 1024}});
434
+ await app.register(support, {
435
+ auth: {authenticate: async (req) => { req.user = ANN; }, requirePermission: () => async () => {}},
436
+ settings: {}, readSettings: async () => ({}), mode: 'sender', timer: false, dataDir: await fs.mkdtemp(path.join(dataDir, 'site-')),
437
+ env: {MANAGER_URL: 'http://127.0.0.1:8192/', FLEET_TOKEN: token, SITE_SLUG: TOKENS[token]},
438
+ fetch: fetchInto(manager), hooks: {}
439
+ });
440
+ return app;
441
+ }
442
+
443
+ test('a screenshot goes with a request, and a log with a follow-up; Domma opens both', async () => {
444
+ const P = '/api/plugins/support';
445
+ const acme = await siteWithUploads('tok-acme');
446
+ const png = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47]), Buffer.alloc(5000, 1)]);
447
+ const up = await acme.inject({method: 'POST', url: '/attachments', ...form('checkout.png', 'image/png', png)});
448
+ assert.equal(up.statusCode, 201, up.body);
449
+ const shot = up.json();
450
+ assert.equal((await acme.inject({method: 'POST', url: '/attachments', ...form('x.html', 'text/html', '<script>')})).statusCode, 400);
451
+ const big = await acme.inject({method: 'POST', url: '/attachments', ...form('big.png', 'image/png', Buffer.alloc(2 * 1024 * 1024 + 1))});
452
+ assert.equal(big.statusCode, 413);
453
+
454
+ const sent = await call(acme, 'POST', '/tickets', {subject: 'Checkout looks wrong', body: 'See the screenshot.', attachments: [shot]});
455
+ assert.equal(sent.status, 201, JSON.stringify(sent.body));
456
+ let t = (await call(manager, 'GET', `${P}/tickets`)).body.tickets.find(x => x.ref === sent.body.ref);
457
+ assert.deepEqual(t.messages[0].attachments.map(a => [a.name, a.size, a.stored]), [['checkout.png', png.length, true]]);
458
+ const got = await manager.inject({method: 'GET', url: `${P}/tickets/${t.id}/attachments/${shot.id}`});
459
+ assert.equal(got.statusCode, 200);
460
+ assert.equal(got.headers['content-type'], 'image/png');
461
+ assert.ok(Buffer.compare(got.rawPayload, png) === 0);
462
+
463
+ // A follow-up with a log file.
464
+ const log = (await acme.inject({method: 'POST', url: '/attachments', ...form('console.txt', 'text/plain', 'TypeError: x is undefined')})).json();
465
+ assert.equal((await call(acme, 'POST', `/tickets/${sent.body.ref}/messages`, {body: 'And the console.', attachments: [log]})).status, 201);
466
+ t = (await call(manager, 'GET', `${P}/tickets/${t.id}`)).body;
467
+ assert.equal(t.messages[1].attachments[0].name, 'console.txt');
468
+ assert.equal((await manager.inject({method: 'GET', url: `${P}/tickets/${t.id}/attachments/${log.id}`})).body, 'TypeError: x is undefined');
469
+
470
+ // The site sees its files in the thread and opens its own copy.
471
+ const mine = (await call(acme, 'GET', '/tickets')).body.tickets.find(x => x.ref === sent.body.ref);
472
+ assert.equal(mine.messages[0].attachments[0].stored, true);
473
+ assert.equal((await acme.inject({method: 'GET', url: `/attachments/${shot.id}`})).statusCode, 200);
474
+
475
+ // The fleet route: another site's token and an unannounced file are refused; a retry is fine.
476
+ const raw = (tok, att, body) => manager.inject({method: 'POST', url: `${P}/fleet/attach/${sent.body.ref}/${att}`,
477
+ headers: {authorization: `Bearer ${tok}`, 'content-type': 'application/octet-stream'}, payload: body});
478
+ assert.equal((await raw('tok-beta', shot.id, png)).statusCode, 404);
479
+ assert.equal((await raw('tok-acme', '0f8fad5b-d9cb-469f-a165-000000000000', png)).statusCode, 404);
480
+ assert.equal((await raw('tok-acme', shot.id, png)).statusCode, 200);
481
+
482
+ // Delete the request: its files go too.
483
+ assert.equal((await call(manager, 'DELETE', `${P}/tickets/${t.id}`)).status, 200);
484
+ assert.equal((await manager.inject({method: 'GET', url: `${P}/tickets/${t.id}/attachments/${shot.id}`})).statusCode, 404);
485
+ await acme.close();
486
+ });
487
+
488
+ test('a file attached while the manager restarts waits with its request, then both go', async () => {
489
+ const P = '/api/plugins/support';
490
+ const acme = await siteWithUploads('tok-acme');
491
+ const shot = (await acme.inject({method: 'POST', url: '/attachments', ...form('down.png', 'image/png', Buffer.alloc(900, 3))})).json();
492
+ managerUp = false;
493
+ let ref;
494
+ try {
495
+ const r = await call(acme, 'POST', '/tickets', {subject: 'Offline with a picture', body: 'b', attachments: [shot]});
496
+ assert.equal(r.body.state, 'waiting');
497
+ ref = r.body.ref;
498
+ } finally { managerUp = true; }
499
+ await call(acme, 'GET', '/tickets');
500
+ const t = (await call(manager, 'GET', `${P}/tickets`)).body.tickets.find(x => x.ref === ref);
501
+ assert.equal(t.messages[0].attachments[0].stored, true);
502
+ await acme.close();
503
+ });
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The settings cog (1.1.5): each side keeps its own keys; emails and the phone
3
+ * are checked as the contact card checks them; the manager's routes save live.
4
+ */
5
+ import {test, after} from 'node:test';
6
+ import assert from 'node:assert/strict';
7
+ import fs from 'node:fs/promises';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+ import Fastify from 'fastify';
11
+ import support from '../plugin.js';
12
+ import {cleanSettings, settingsErrors} from '../lib/shape.js';
13
+
14
+ const dirs = [];
15
+ after(async () => { for (const d of dirs) await fs.rm(d, {recursive: true, force: true}); });
16
+ const tmp = async () => { const d = await fs.mkdtemp(path.join(os.tmpdir(), 'support-set-')); dirs.push(d); return d; };
17
+
18
+ test('cleanSettings: a side\'s own keys, checked, else current, else default', () => {
19
+ assert.deepEqual(cleanSettings({syncMinutes: '15', email: 'x@y.z'}, {}, 'sender'), {syncMinutes: 15});
20
+ const cur = {email: 'help@domma.test', phone: '', hours: 'Weekdays', responseTime: 'A day', notifyEmail: 'in@domma.test', perSiteMax: 20, perSiteMinutes: 10};
21
+ const out = cleanSettings({email: 'not an email', phone: '+44 (0)1234 567890', hours: 'Mon\nto Fri', notifyEmail: '', perSiteMax: 0}, cur, 'receiver');
22
+ assert.equal(out.email, 'help@domma.test', 'a bad address keeps the current one');
23
+ assert.equal(out.phone, '+44 (0)1234 567890');
24
+ assert.equal(out.hours, 'Mon to Fri', 'one line');
25
+ assert.equal(out.notifyEmail, '', 'empty is allowed: email nobody');
26
+ assert.equal(out.perSiteMax, 20);
27
+ assert.deepEqual(Object.keys(settingsErrors({email: 'nope', phone: 'call me', perSiteMinutes: 0, notifyEmail: ''}, 'receiver')).sort(),
28
+ ['email', 'perSiteMinutes', 'phone']);
29
+ });
30
+
31
+ async function receiver(store) {
32
+ const app = Fastify();
33
+ await app.register(support, {
34
+ auth: {authenticate: async (req) => { req.user = {id: 'v1', name: 'D'}; }, requirePermission: () => async () => {}},
35
+ prefix: '/api/plugins/support', mode: 'receiver', mail: {send() {}}, dataDir: await tmp(),
36
+ readSettings: async () => ({...store}), writeSettings: async (next) => { Object.assign(store, next); },
37
+ slugFromToken: async () => null, siteDirectory: () => ({}), hooks: {}
38
+ });
39
+ return app;
40
+ }
41
+
42
+ test('the manager saves its card; /mode shows it at once; a bad phone is 400', async () => {
43
+ const store = {syncMinutes: 5, email: 'a@domma.test', hours: 'Old', notifyEmail: 'in@domma.test', perSiteMax: 20, perSiteMinutes: 10};
44
+ const app = await receiver(store);
45
+ const got = (await app.inject({url: '/api/plugins/support/settings'})).json();
46
+ assert.equal(got.side, 'receiver');
47
+ assert.equal('syncMinutes' in got.settings, false, 'the site\'s key is not the manager\'s');
48
+ const put = await app.inject({method: 'PUT', url: '/api/plugins/support/settings', payload: {...got.settings, hours: 'Nine to five'}});
49
+ assert.equal(put.statusCode, 200);
50
+ assert.equal(store.hours, 'Nine to five');
51
+ assert.equal(store.syncMinutes, 5);
52
+ assert.equal((await app.inject({url: '/api/plugins/support/mode'})).json().contact.hours, 'Nine to five');
53
+ const bad = await app.inject({method: 'PUT', url: '/api/plugins/support/settings', payload: {phone: 'ring us'}});
54
+ assert.equal(bad.statusCode, 400);
55
+ assert.ok(bad.json().errors.phone);
56
+ await app.close();
57
+ });
58
+
59
+ test('a site\'s settings route is for administrators', async () => {
60
+ const app = Fastify();
61
+ await app.register(support, {
62
+ auth: {authenticate: async (req) => { req.user = {id: 'u'}; }, requirePermission: () => async () => {},
63
+ requireAdmin: async (req, reply) => reply.code(403).send({error: 'Forbidden'})},
64
+ mode: 'sender', timer: false, env: {}, dataDir: await tmp(), readSettings: async () => ({}), hooks: {}
65
+ });
66
+ assert.equal((await app.inject({url: '/settings'})).statusCode, 403);
67
+ await app.close();
68
+ });