domma-cms 1.6.37 → 1.6.38

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 (70) 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/blog/CLAUDE.md +11 -1
  5. package/plugins/blog/lib/cache.js +43 -0
  6. package/plugins/blog/lib/page.js +28 -9
  7. package/plugins/blog/plugin.js +14 -13
  8. package/plugins/blog/plugin.json +1 -1
  9. package/plugins/blog/plugin.public.js +1 -1
  10. package/plugins/blog/tests/cache.test.js +65 -0
  11. package/plugins/booking/CLAUDE.md +14 -2
  12. package/plugins/booking/admin/lib/diary-layout.js +18 -0
  13. package/plugins/booking/admin/views/diary.js +153 -152
  14. package/plugins/booking/admin/views/people.js +64 -66
  15. package/plugins/booking/admin/views/settings.js +31 -32
  16. package/plugins/booking/plugin.json +1 -1
  17. package/plugins/booking/tests/admin-diary.test.js +9 -1
  18. package/plugins/feedback/CLAUDE.md +55 -0
  19. package/plugins/feedback/admin/css/index.css +16 -1
  20. package/plugins/feedback/admin/lib/kit.js +106 -0
  21. package/plugins/feedback/admin/lib/settings.js +107 -0
  22. package/plugins/feedback/admin/views/feedback.js +4 -1
  23. package/plugins/feedback/admin/views/inbox.js +11 -2
  24. package/plugins/feedback/admin/views/send.js +74 -7
  25. package/plugins/feedback/docs/guide.md +9 -9
  26. package/plugins/feedback/lib/attachments.js +133 -0
  27. package/plugins/feedback/lib/cache.js +43 -0
  28. package/plugins/feedback/lib/receiver.js +36 -6
  29. package/plugins/feedback/lib/sender.js +70 -6
  30. package/plugins/feedback/lib/shape.js +111 -6
  31. package/plugins/feedback/plugin.js +171 -17
  32. package/plugins/feedback/plugin.json +2 -2
  33. package/plugins/feedback/tests/api.test.js +125 -3
  34. package/plugins/feedback/tests/settings.test.js +59 -0
  35. package/plugins/feedback/tests/shape.test.js +64 -0
  36. package/plugins/free-tier.lock.json +73 -61
  37. package/plugins/mail-reader/CLAUDE.md +15 -4
  38. package/plugins/mail-reader/plugin.json +1 -1
  39. package/plugins/shopping-cart/CLAUDE.md +28 -2
  40. package/plugins/shopping-cart/admin/css/index.css +3 -0
  41. package/plugins/shopping-cart/admin/views/orders.js +42 -10
  42. package/plugins/shopping-cart/admin/views/shop.js +1 -1
  43. package/plugins/shopping-cart/docs/guide.md +7 -3
  44. package/plugins/shopping-cart/lib/payments.js +15 -2
  45. package/plugins/shopping-cart/lib/refunds.js +51 -0
  46. package/plugins/shopping-cart/lib/sales.js +15 -8
  47. package/plugins/shopping-cart/lib/shop.js +38 -3
  48. package/plugins/shopping-cart/plugin.js +47 -19
  49. package/plugins/shopping-cart/plugin.json +2 -2
  50. package/plugins/shopping-cart/tests/api.test.js +52 -0
  51. package/plugins/shopping-cart/tests/commission.test.js +1 -1
  52. package/plugins/shopping-cart/tests/refunds.test.js +42 -0
  53. package/plugins/support/CLAUDE.md +51 -0
  54. package/plugins/support/admin/css/index.css +16 -1
  55. package/plugins/support/admin/lib/kit.js +192 -8
  56. package/plugins/support/admin/lib/settings.js +123 -0
  57. package/plugins/support/admin/views/desk.js +55 -12
  58. package/plugins/support/admin/views/inbox.js +17 -4
  59. package/plugins/support/admin/views/support.js +4 -1
  60. package/plugins/support/docs/guide.md +7 -9
  61. package/plugins/support/lib/attachments.js +133 -0
  62. package/plugins/support/lib/cache.js +43 -0
  63. package/plugins/support/lib/receiver.js +45 -10
  64. package/plugins/support/lib/sender.js +81 -10
  65. package/plugins/support/lib/shape.js +153 -12
  66. package/plugins/support/plugin.js +159 -29
  67. package/plugins/support/plugin.json +2 -2
  68. package/plugins/support/tests/api.test.js +125 -3
  69. package/plugins/support/tests/settings.test.js +68 -0
  70. package/plugins/support/tests/shape.test.js +51 -1
@@ -13,7 +13,10 @@
13
13
  * Domma brings it straight back. Removing one that never reached Domma cancels it.
14
14
  */
15
15
 
16
- import {THREAD, TONE, api, copyText, esc, listOf, openPanel, refreshBadge, rowKeys, stamp, telHref, threadRows, when} from '../lib/kit.js';
16
+ import {
17
+ FILE_TYPES, THREAD, TONE, api, copyText, esc, fileKeys, filePicker, fileSize, findFile, listOf, openPanel, refreshBadge, rowKeys, stamp, telHref,
18
+ threadFiles, threadRows, when
19
+ } from '../lib/kit.js';
17
20
 
18
21
  const ROW = `
19
22
  <div class="sp-row" data-id="{{key}}" tabindex="0" role="button" aria-haspopup="menu">
@@ -37,6 +40,21 @@ export async function mount(root, ctx) {
37
40
  const {kit, scope, here, meta, id} = ctx;
38
41
  const categories = meta.categories || [];
39
42
  const priorities = meta.priorities || [];
43
+ const caps = meta.attachments || {max: 3, maxBytes: 2 * 1024 * 1024};
44
+ /** The attach button and its hidden input; the picker's list goes in [data-files]. */
45
+ const attachUi = (pasteHint) => `<div class="sp-files-host" data-files></div>
46
+ <div class="sp-attach" data-bind-hidden="!room.value">
47
+ <button type="button" class="btn btn-sm btn-outline" data-on-click="pickFiles"><span data-icon="paperclip" data-icon-size="13"></span> Attach a file</button>
48
+ <span class="sp-hint">${esc(pasteHint)}</span>
49
+ <input type="file" class="sp-file-input" accept="${FILE_TYPES}" multiple hidden>
50
+ </div>`;
51
+ /** Wire a panel's picker, mirroring its state into the panel's own observables (bound before it existed). */
52
+ const wirePicker = (p, pasteSel, room, uploading) => {
53
+ const picker = filePicker({host: p.panel.querySelector('[data-files]'), input: p.panel.querySelector('.sp-file-input'),
54
+ paste: p.panel.querySelector(pasteSel), caps});
55
+ const stop = M.effect(() => { room.value = picker.room.value; uploading.value = picker.uploading.value; });
56
+ return {picker, dispose: () => { stop(); picker.dispose(); }};
57
+ };
40
58
  const iconOf = (c) => categories.find(x => x.key === c)?.icon || 'message-square';
41
59
  const labelOf = (c) => categories.find(x => x.key === c)?.short || c;
42
60
  const plural = (n, w) => (n ? `${n} ${w}${n === 1 ? '' : 's'}` : '');
@@ -58,7 +76,8 @@ export async function mount(root, ctx) {
58
76
  subject: t.subject,
59
77
  icon: iconOf(t.category),
60
78
  categoryLabel: labelOf(t.category),
61
- meta: [when(t.lastAt || t.createdAt), t.userName, plural((t.messages || []).length, 'message')].filter(Boolean).join(' · '),
79
+ meta: [when(t.lastAt || t.createdAt), t.userName, plural((t.messages || []).length, 'message'),
80
+ plural((t.messages || []).reduce((n, m) => n + (m.attachments || []).length, 0), 'file')].filter(Boolean).join(' · '),
62
81
  statusLabel: t.statusLabel || t.status,
63
82
  tone: `is-${TONE[t.status] || 'muted'}`,
64
83
  urgent: t.priority === 'urgent' && t.status !== 'closed',
@@ -212,11 +231,13 @@ export async function mount(root, ctx) {
212
231
 
213
232
  function compose() {
214
233
  const f = {category: M.observable('help'), priority: M.observable('normal'), subject: M.observable(''), body: M.observable(''),
215
- where: M.observable(''), busy: M.observable(false)};
234
+ where: M.observable(''), busy: M.observable(false), room: M.observable(true), uploading: M.observable(false)};
216
235
  let panel = null;
236
+ let wired = null;
217
237
  openPanel({
218
238
  title: 'Ask Domma for help',
219
239
  model: f,
240
+ cleanup: () => wired?.dispose(),
220
241
  html: `<div class="sp-form">
221
242
  <label class="form-label">What is it about?</label>
222
243
  <div class="sp-choices" role="radiogroup" aria-label="Category">
@@ -232,21 +253,24 @@ export async function mount(root, ctx) {
232
253
  </div>
233
254
  <label class="form-label" for="sp-where">Where <span data-help="The page or admin screen it is about, if there is one - e.g. /contact, or Pages. Optional."></span></label>
234
255
  <input id="sp-where" class="form-input" maxlength="300" data-model="where.value" placeholder="Optional">
256
+ <label class="form-label">Screenshots and files <span data-help="${esc(`Up to ${caps.max} files of ${fileSize(caps.maxBytes)} each: PNG, JPEG, GIF or WebP images, a PDF or a text file. A screenshot of what went wrong helps us most. Only the Domma team sees them.`)}"></span></label>
257
+ ${attachUi('Or paste a screenshot into the message.')}
235
258
  <p class="sp-hint">Sent with your name, email and this site, so we can reply here and by email.</p>
236
259
  <div class="sp-actions"><span class="sp-spacer"></span>
237
260
  <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
238
- <button type="button" class="btn btn-sm btn-primary" data-on-click="send" data-bind-disabled="busy.value"><span data-icon="send" data-icon-size="13"></span> Send</button>
261
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="send" data-bind-disabled="busy.value || uploading.value"><span data-icon="send" data-icon-size="13"></span> Send</button>
239
262
  </div>
240
263
  </div>`,
241
264
  methods: {
242
265
  pick: (group, value) => { if (f[group]) f[group].value = value; },
243
266
  cancel: () => panel?.close(),
267
+ pickFiles: () => panel?.panel.querySelector('.sp-file-input')?.click(),
244
268
  async send() {
245
- if (f.busy.peek()) return;
269
+ if (f.busy.peek() || f.uploading.peek()) return;
246
270
  f.busy.value = true;
247
271
  try {
248
272
  const r = await api.open({category: f.category.peek(), priority: f.priority.peek(), subject: f.subject.peek(),
249
- body: f.body.peek(), where: f.where.peek()});
273
+ body: f.body.peek(), where: f.where.peek(), attachments: wired?.picker.attachments() || []});
250
274
  E.toast(r.message || 'Sent.', {type: r.state === 'sent' ? 'success' : 'warning'});
251
275
  panel?.close();
252
276
  refreshBadge();
@@ -261,6 +285,7 @@ export async function mount(root, ctx) {
261
285
  }
262
286
  }).then(p => {
263
287
  panel = p;
288
+ wired = wirePicker(p, '#sp-body', f.room, f.uploading);
264
289
  p.panel.querySelector('#sp-subject')?.focus();
265
290
  });
266
291
  }
@@ -270,7 +295,14 @@ export async function mount(root, ctx) {
270
295
  if (!t0) return;
271
296
  // The panel follows the list's copy, so a reload or a move from the row menu shows here at once.
272
297
  const cur = M.computed(() => tickets.value.find(x => x.ref === ref) || t0);
273
- const thread = M.computed(() => threadRows(cur.value.messages || [], 'customer'));
298
+ // Every file in this site's requests was attached here, so this site's own copy shows them -
299
+ // even while Domma's copy is still on its way.
300
+ const shown = threadFiles((a) => api.ownFileUrl(a.id));
301
+ const thread = M.computed(() => threadRows(cur.value.messages || [], 'customer', {thumbs: shown.thumbs.value}));
302
+ const room = M.observable(true);
303
+ const uploading = M.observable(false);
304
+ let wired = null;
305
+ let stopFiles = () => {};
274
306
  const status = M.computed(() => cur.value.status);
275
307
  const statusLabel = M.computed(() => cur.value.statusLabel || cur.value.status);
276
308
  const tone = M.computed(() => `is-${TONE[cur.value.status] || 'muted'}`);
@@ -291,8 +323,11 @@ export async function mount(root, ctx) {
291
323
  panel = await openPanel({
292
324
  title: t0.subject,
293
325
  size: 'lg',
294
- model: {thread, status, statusLabel, tone, archived, draft, hasDraft, busy},
295
- cleanup: () => { stopScroll(); menu?.destroy?.(); [cur, thread, status, statusLabel, tone, archived, hasDraft].forEach(c => c.dispose?.()); },
326
+ model: {thread, status, statusLabel, tone, archived, draft, hasDraft, busy, room, uploading},
327
+ cleanup: () => {
328
+ stopScroll(); stopFiles(); menu?.destroy?.(); wired?.dispose(); shown.dispose();
329
+ [cur, thread, status, statusLabel, tone, archived, hasDraft].forEach(c => c.dispose?.());
330
+ },
296
331
  html: `<div class="sp-detail">
297
332
  <dl class="sp-facts">
298
333
  <div><dt>Status</dt><dd><span class="sp-chip" data-bind-class="tone.value" data-bind-text="statusLabel.value"></span>
@@ -306,21 +341,24 @@ export async function mount(root, ctx) {
306
341
  ${canWrite ? `<div class="sp-reply">
307
342
  <label class="form-label" for="sp-write" data-bind-text="status.value === 'closed' ? 'Write again (this reopens it)' : 'Reply'"></label>
308
343
  <textarea id="sp-write" class="form-textarea" rows="4" maxlength="8000" data-model="draft.value" placeholder="Write to Domma Support"></textarea>
344
+ ${attachUi('Files go with a message - paste a screenshot into it, or attach one.')}
309
345
  <div class="sp-actions">
310
346
  <button type="button" class="btn btn-sm btn-ghost" data-on-click="resolve" data-bind-hidden="status.value === 'closed'" data-bind-disabled="busy.value"><span data-icon="check-circle" data-icon-size="13"></span> Mark resolved</button>
311
347
  <button type="button" class="btn btn-sm btn-ghost" data-on-click="archive" data-bind-disabled="busy.value">
312
348
  <span data-icon="archive" data-icon-size="13"></span> <span data-bind-text="archived.value ? 'Back to your requests' : 'Archive'"></span></button>
313
349
  <span class="sp-spacer"></span>
314
- <button type="button" class="btn btn-sm btn-primary" data-on-click="send" data-bind-disabled="busy.value || !hasDraft.value"><span data-icon="send" data-icon-size="13"></span> Send</button>
350
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="send" data-bind-disabled="busy.value || uploading.value || !hasDraft.value"><span data-icon="send" data-icon-size="13"></span> Send</button>
315
351
  </div>
316
352
  </div>` : '<p class="sp-note">You can write again once this request reaches Domma.</p>'}
317
353
  </div>`,
318
354
  methods: {
355
+ pickFiles: () => panel?.panel.querySelector('.sp-file-input')?.click(),
319
356
  send: () => run(async () => {
320
- if (!draft.peek().trim()) return;
321
- const r = await api.write(ref, draft.peek());
357
+ if (!draft.peek().trim() || uploading.peek()) return;
358
+ const r = await api.write(ref, draft.peek(), wired?.picker.attachments() || []);
322
359
  if (r.state !== 'sent') E.toast(r.message, {type: 'warning'});
323
360
  draft.value = '';
361
+ wired?.picker.clear();
324
362
  await load();
325
363
  }),
326
364
  resolve: () => run(() => actions.resolve(cur.peek())),
@@ -333,6 +371,11 @@ export async function mount(root, ctx) {
333
371
  });
334
372
  const box = panel.panel.querySelector('.sp-thread-host');
335
373
  stopScroll = M.effect(() => { thread.value; Promise.resolve().then(() => { M.flush?.(); box.scrollTop = box.scrollHeight; }); });
374
+ if (canWrite) wired = wirePicker(panel, '#sp-write', room, uploading);
375
+ // Thumbnails for the images in the thread, as messages come; click a file to open it.
376
+ const fileStop = M.effect(() => { shown.load(cur.value.messages || []); });
377
+ const keysOff = fileKeys(box, (id) => shown.open(findFile(cur.peek().messages, id)));
378
+ stopFiles = () => { fileStop(); keysOff(); };
336
379
  menu = E.contextMenu(box, {
337
380
  cursor: 'glyph', match: '.sp-msg', inherit: false, density: 'compact',
338
381
  items: (li) => {
@@ -8,7 +8,9 @@
8
8
  * still sees the request - and a customer writing again takes it back out.
9
9
  */
10
10
 
11
- import {THREAD, TONE, api, copyText, esc, listOf, openPanel, refreshBadge, rowKeys, stamp, store, threadRows, when} from '../lib/kit.js';
11
+ import {
12
+ THREAD, TONE, api, copyText, esc, fileKeys, findFile, listOf, openPanel, refreshBadge, rowKeys, stamp, store, threadFiles, threadRows, when
13
+ } from '../lib/kit.js';
12
14
 
13
15
  const ROW = `
14
16
  <div class="sp-row" data-id="{{key}}" tabindex="0" role="button" aria-haspopup="menu">
@@ -64,7 +66,9 @@ export async function mount(root, ctx) {
64
66
  subject: t.subject,
65
67
  icon: iconOf(t.category),
66
68
  categoryLabel: labelOf(t.category),
67
- meta: [t.siteName, t.userName, `${t.lastFrom === 'domma' ? 'we wrote' : 'they wrote'} ${when(t.lastAt)}`].filter(Boolean).join(' · '),
69
+ meta: [t.siteName, t.userName, `${t.lastFrom === 'domma' ? 'we wrote' : 'they wrote'} ${when(t.lastAt)}`,
70
+ (() => { const n = (t.messages || []).reduce((k, m) => k + (m.attachments || []).length, 0); return n ? `${n} file${n === 1 ? '' : 's'}` : ''; })()]
71
+ .filter(Boolean).join(' · '),
68
72
  statusLabel: inboxLabel(t.status),
69
73
  tone: `is-${TONE[t.status] || 'muted'}`,
70
74
  urgent: t.priority === 'urgent' && t.status !== 'closed'
@@ -173,7 +177,12 @@ export async function mount(root, ctx) {
173
177
 
174
178
  // The panel follows the list's copy, so a move from the row menu shows here at once.
175
179
  const cur = M.computed(() => tickets.value.find(x => x.id === tid) || t);
176
- const thread = M.computed(() => threadRows(cur.value.messages || [], 'domma'));
180
+ // A file follows its message from the site; until it lands it is listed but cannot open.
181
+ const arrived = (a) => a.stored !== false;
182
+ const shown = threadFiles((a) => api.ticketFileUrl(tid, a.id), arrived);
183
+ const thread = M.computed(() => threadRows(cur.value.messages || [], 'domma',
184
+ {thumbs: shown.thumbs.value, available: arrived, missing: 'on its way from the site'}));
185
+ let stopFiles = () => {};
177
186
  const status = M.computed(() => cur.value.status);
178
187
  const statusLabel = M.computed(() => inboxLabel(cur.value.status));
179
188
  const tone = M.computed(() => `is-${TONE[cur.value.status] || 'muted'}`);
@@ -200,7 +209,7 @@ export async function mount(root, ctx) {
200
209
  size: 'lg',
201
210
  model: {thread, status, statusLabel, tone, urgent, archived, draft, hasDraft, after, notes, busy},
202
211
  cleanup: () => {
203
- stopScroll(); menu?.destroy?.();
212
+ stopScroll(); stopFiles(); shown.dispose(); menu?.destroy?.();
204
213
  [cur, thread, status, statusLabel, tone, urgent, archived, hasDraft].forEach(c => c.dispose?.());
205
214
  if (openPanel_?.tid === tid) openPanel_ = null;
206
215
  },
@@ -266,6 +275,10 @@ export async function mount(root, ctx) {
266
275
  // Keep the newest message in view as the thread grows.
267
276
  const box = panel.panel.querySelector('.sp-thread-host');
268
277
  stopScroll = M.effect(() => { thread.value; Promise.resolve().then(() => { M.flush?.(); box.scrollTop = box.scrollHeight; }); });
278
+ // Thumbnails for the images that have arrived; click a file to open it in a new tab.
279
+ const fileStop = M.effect(() => { shown.load(cur.value.messages || []); });
280
+ const keysOff = fileKeys(box, (fid) => shown.open(findFile(cur.peek().messages, fid)));
281
+ stopFiles = () => { fileStop(); keysOff(); };
269
282
  // A message: copy it, or quote it into the reply.
270
283
  menu = E.contextMenu(box, {
271
284
  cursor: 'glyph', match: '.sp-msg', inherit: false, density: 'compact',
@@ -12,9 +12,10 @@
12
12
  */
13
13
 
14
14
  import {api, loadKit} from '../lib/kit.js';
15
+ import {addSettingsCog} from '../lib/settings.js';
15
16
 
16
17
  const ROUTE = /^#\/plugins\/support(\/|$)/;
17
- const V = '1.1.2';
18
+ const V = '1.1.5';
18
19
  let lifetime = null;
19
20
 
20
21
  export const supportView = {
@@ -50,6 +51,8 @@ export const supportView = {
50
51
  return;
51
52
  }
52
53
  if (!here()) return;
54
+ // The cog (1.1.5) - this side's settings, for those who may change them. Never blocks the screen.
55
+ addSettingsCog($container, kit).catch(() => {});
53
56
  try {
54
57
  const screen = mode.mode === 'receiver' ? 'inbox' : 'desk';
55
58
  const mod = await import(`/plugins/support/admin/views/${screen}.js?v=${V}`);
@@ -20,7 +20,8 @@ Support gets you help from Domma without leaving your admin: our email, phone an
20
20
  4. Type a **Subject** (one line) and your **Message**.
21
21
  5. Choose **Normal** (we answer in order, usually within a working day) or **Urgent** (your site is down, or you cannot take orders, bookings or payments).
22
22
  6. Optional: fill in **Where** - the screen or page it is about, for example "Pages" or `/contact`.
23
- 7. Click **Send**.
23
+ 7. Optional: click **Attach a file**, or paste a screenshot straight into the message. Up to 3 files of 2 MB each: PNG, JPEG, GIF or WebP images, a PDF or a text file.
24
+ 8. Click **Send**.
24
25
 
25
26
  For something that is not working, say what you did, what you expected and what happened instead.
26
27
 
@@ -37,7 +38,7 @@ Each row shows the subject, when it was last written to, and a status:
37
38
  | Resolved | Finished. Writing again reopens it |
38
39
  | Waiting to send | Domma could not be reached yet; it goes automatically |
39
40
 
40
- A dot on a row means a new reply. Click a row to read the conversation and **Reply**. Right-click a row (or use the row's menu) to **Archive**, **Resolve** or **Remove** it:
41
+ A dot on a row means a new reply. Click a row to read the conversation and **Reply** - you can attach files to a reply too. Files show under the message they came with; click one to open it. Only the Domma team sees them - they are never put in your Media library or on your website. Right-click a row (or use the row's menu) to **Archive**, **Resolve** or **Remove** it:
41
42
 
42
43
  - **Archive** tidies a request away under **Archived**. It comes back by itself if Domma writes to it.
43
44
  - **Resolve** closes it. You can still write to it, which reopens it.
@@ -49,11 +50,7 @@ If the screen offers the contact card only and says to email us, your site was n
49
50
 
50
51
  ## Settings
51
52
 
52
- There is no settings screen. Whoever runs the site can change one option in the plugin's configuration file:
53
-
54
- | Option | Default | Meaning |
55
- |---|---|---|
56
- | `syncMinutes` | 5 | How often the site sends waiting requests and checks for replies |
53
+ Administrators see a **Support settings** cog in the banner. It has one setting, **Check for replies every (minutes)** - how often the site sends anything still waiting and looks for replies in the background (default 5). A change takes effect at once, no restart.
57
54
 
58
55
  Opening the Support screen also sends anything waiting and checks for replies straight away. The contact card is set by Domma and arrives with the first sync.
59
56
 
@@ -77,14 +74,15 @@ Only the **admin** role has them by default. Everyone with **Send** sees every r
77
74
  On the Domma server itself (the Manager), the same screen is the **Inbox** of every site's requests, for holders of **Support > Manage**:
78
75
 
79
76
  - Filter by **Open**, **Answered**, **Resolved**, **Archived** or **All**, urgent first; search subject, text, person and site.
80
- - Open a request to read the conversation, **Reply** (the site sees it and is notified, and the person is emailed), **Resolve**, change its priority, and keep **Internal notes** (never sent to the site).
77
+ - Open a request to read the conversation (with any files the customer attached - one still on its way says so), **Reply** (the site sees it and is notified, and the person is emailed), **Resolve**, change its priority, and keep **Internal notes** (never sent to the site).
81
78
  - Right-click a row to archive, resolve or **Delete** it. Archiving is Domma's own filing: the site does not see it, and a request comes out of the archive when the customer writes again.
82
79
  - A new request or a customer's reply is emailed to the Domma support address, with the customer as Reply-To and `[URGENT]` in the subject when it is urgent.
80
+ - The **Support settings** cog sets the contact card every site shows (email, phone, opening hours, how fast you answer), the address new requests are emailed to, and how many messages one site may send in a window. Changes apply at once; each site gets the new card with its next sync.
83
81
 
84
82
  ## Limitations
85
83
 
86
84
  - Only sites started by a Domma server can send. A standalone install gets the contact card only.
87
- - No attachments or screenshots. Describe the problem in words, and use **Where** for the page.
85
+ - Up to 3 files per message, 2 MB each, and only images (PNG, JPEG, GIF, WebP), PDFs and text files. A message with files still needs a few words.
88
86
  - Subjects are limited to 140 characters and messages to 8,000.
89
87
  - The Domma server accepts up to 20 requests and replies from one site in 10 minutes. Any more show as **Waiting to send** and go later.
90
88
  - You cannot edit or withdraw a message once sent. Write a follow-up instead.
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Support - attachments on disk, and reading one out of an upload.
3
+ *
4
+ * A site keeps the files it attached under data/attachments/<id>.<ext> (with a
5
+ * <id>.json beside each saying what it is); the manager keeps a request's files
6
+ * under data/attachments/<request entry id>/. Files are 0600 in 0700 folders:
7
+ * they are screenshots of admin screens, nothing a web server should ever list.
8
+ * Nothing here is served from a public path - a route reads and sends them.
9
+ *
10
+ * @module support/lib/attachments
11
+ */
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import {ATTACHMENTS, ATTACHMENT_TYPES, cleanAttachment, extOf} from './shape.js';
15
+
16
+ /** A folder name that is safe to join: a collection entry id, or our own staging name. */
17
+ const SUB_RE = /^[A-Za-z0-9_-]{1,80}$/;
18
+
19
+ /**
20
+ * Read ONE file out of a multipart request, within the caps. The whole file is
21
+ * held in memory - it is at most ATTACHMENTS.maxBytes.
22
+ *
23
+ * @returns {Promise<{file?: {name: string, type: string, size: number, buffer: Buffer}, error?: string, code?: number}>}
24
+ */
25
+ export async function readUpload(request) {
26
+ if (typeof request.isMultipart !== 'function' || !request.isMultipart()) return {error: 'Send the file as a form upload.', code: 400};
27
+ let part;
28
+ try { part = await request.file(); } catch (err) { return {error: `That upload could not be read: ${err.message}`, code: 400}; }
29
+ if (!part) return {error: 'No file was sent.', code: 400};
30
+ const type = String(part.mimetype || '').toLowerCase().split(';')[0].trim();
31
+ const drain = async () => { for await (const _ of part.file) { /* a refused part must still be consumed */ } };
32
+ if (!ATTACHMENT_TYPES[type]) {
33
+ await drain();
34
+ return {error: `That kind of file (${type || 'unknown'}) cannot be attached. Attach a PNG, JPEG, GIF or WebP image, a PDF or a text file.`, code: 400};
35
+ }
36
+ const chunks = [];
37
+ let size = 0;
38
+ let over = false;
39
+ for await (const chunk of part.file) {
40
+ size += chunk.length;
41
+ // Keep reading so the request completes, but keep nothing more.
42
+ if (size > ATTACHMENTS.maxBytes) { over = true; continue; }
43
+ chunks.push(chunk);
44
+ }
45
+ if (over || part.file.truncated) return {error: `That file is too big - up to ${Math.round(ATTACHMENTS.maxBytes / 1024 / 1024)} MB each.`, code: 413};
46
+ if (!size) return {error: 'That file is empty.', code: 400};
47
+ return {file: {name: part.filename, type, size, buffer: Buffer.concat(chunks)}};
48
+ }
49
+
50
+ /**
51
+ * Send a stored file: its own type, shown inline, never sniffed into anything else.
52
+ * Private cache only - it needs the session that fetched it.
53
+ */
54
+ export function sendFile(reply, a, buffer) {
55
+ return reply
56
+ .type(a.type)
57
+ .header('content-disposition', `inline; filename="${a.name.replace(/["\\]/g, '_')}"`)
58
+ .header('x-content-type-options', 'nosniff')
59
+ .header('cache-control', 'private, max-age=300')
60
+ .send(buffer);
61
+ }
62
+
63
+ /**
64
+ * The files under one root. Every path is built from a cleaned attachment and a
65
+ * checked sub-folder name - nothing from a request reaches `path.join` raw.
66
+ *
67
+ * @param {string} root - e.g. <plugin data dir>/attachments
68
+ */
69
+ export function createStore(root) {
70
+ const dirOf = (sub) => {
71
+ if (sub && !SUB_RE.test(sub)) throw new Error('bad attachment folder');
72
+ return sub ? path.join(root, sub) : root;
73
+ };
74
+ const ensure = (sub) => fs.mkdirSync(dirOf(sub), {recursive: true, mode: 0o700});
75
+ const clean = (a) => { const c = cleanAttachment(a); if (!c) throw new Error('bad attachment'); return c; };
76
+ const fileOf = (sub, a) => path.join(dirOf(sub), `${a.id}.${extOf(a.type)}`);
77
+ const metaOf = (sub, id) => path.join(dirOf(sub), `${id}.json`);
78
+ return {
79
+ root,
80
+ /** Keep a file and what it is. */
81
+ save(att, buffer, sub = '') {
82
+ const a = clean(att);
83
+ ensure(sub);
84
+ fs.writeFileSync(fileOf(sub, a), buffer, {mode: 0o600});
85
+ fs.writeFileSync(metaOf(sub, a.id), JSON.stringify({id: a.id, name: a.name, type: a.type, size: a.size, at: new Date().toISOString()}), {mode: 0o600});
86
+ return a;
87
+ },
88
+ /** What a kept file is, from its sidecar - null when unknown. */
89
+ meta(id, sub = '') {
90
+ if (!/^[A-Za-z0-9-]{8,64}$/.test(String(id))) return null;
91
+ try { return cleanAttachment(JSON.parse(fs.readFileSync(metaOf(sub, id), 'utf8'))); } catch { return null; }
92
+ },
93
+ /** The bytes, or null. */
94
+ read(att, sub = '') {
95
+ try { return fs.readFileSync(fileOf(sub, clean(att))); } catch { return null; }
96
+ },
97
+ /** Move a file between folders. */
98
+ move(att, from, to) {
99
+ const a = clean(att);
100
+ ensure(to);
101
+ fs.renameSync(fileOf(from, a), fileOf(to, a));
102
+ try { fs.unlinkSync(metaOf(from, a.id)); } catch { /* no sidecar */ }
103
+ },
104
+ /** A request went: so do its files. */
105
+ removeAll(sub) {
106
+ if (!sub) return;
107
+ try { fs.rmSync(dirOf(sub), {recursive: true, force: true}); } catch { /* already gone */ }
108
+ },
109
+ /**
110
+ * Files picked but never sent (the panel was closed) are dropped once they are older
111
+ * than `maxAgeMs` and nothing still names them.
112
+ */
113
+ prune({maxAgeMs = 24 * 3600_000, keep = new Set(), sub = ''} = {}) {
114
+ let dir;
115
+ try { dir = fs.readdirSync(dirOf(sub)); } catch { return 0; }
116
+ let dropped = 0;
117
+ const cutoff = Date.now() - maxAgeMs;
118
+ for (const f of dir) {
119
+ const m = f.match(/^([A-Za-z0-9-]{8,64})\.json$/);
120
+ if (!m || keep.has(m[1])) continue;
121
+ const a = this.meta(m[1], sub);
122
+ const at = new Date(a?.at || 0).getTime();
123
+ let stat;
124
+ try { stat = fs.statSync(metaOf(sub, m[1])); } catch { continue; }
125
+ if (Math.max(at || 0, stat.mtimeMs) > cutoff) continue;
126
+ try { if (a) fs.unlinkSync(fileOf(sub, a)); } catch { /* gone */ }
127
+ try { fs.unlinkSync(metaOf(sub, m[1])); } catch { /* gone */ }
128
+ dropped++;
129
+ }
130
+ return dropped;
131
+ }
132
+ };
133
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * A short memory for whole-collection reads.
3
+ *
4
+ * On the manager, the badge (polled once a minute from every open admin tab
5
+ * with `manage`), every site's `/fleet/mine` (nudged once a minute by its own
6
+ * badge) and `byRef()` each read EVERY request. The data changes only when
7
+ * someone writes, so a read is kept for a little while and every write in
8
+ * lib/receiver.js throws the memory away (`forget()`, right after the write,
9
+ * so the writer's next read is fresh). The TTL is the safety net for writes
10
+ * this plugin never sees (the Collections tool).
11
+ *
12
+ * PURE - no I/O of its own; the loader is handed in. Tested in tests/cache.test.js.
13
+ *
14
+ * @module support/lib/cache
15
+ */
16
+
17
+ export const CACHE_MS = 30_000;
18
+
19
+ /**
20
+ * @param {{ttl?: number, clock?: () => number}} [opts]
21
+ * @returns {{get: (key: string, load: () => Promise<any>) => Promise<any>, forget: () => void, size: number}}
22
+ */
23
+ export function createCache({ttl = CACHE_MS, clock = Date.now} = {}) {
24
+ // key -> {at, value: Promise}. The PROMISE is kept, not the result, so
25
+ // concurrent misses share one read, and a write landing while a read is in
26
+ // flight clears the map before that read can be stored - nothing stale
27
+ // survives a forget().
28
+ const kept = new Map();
29
+ return {
30
+ get(key, load) {
31
+ const hit = kept.get(key);
32
+ if (hit && clock() - hit.at < ttl) return hit.value;
33
+ const value = Promise.resolve().then(load);
34
+ const entry = {at: clock(), value};
35
+ kept.set(key, entry);
36
+ // A failed read is not remembered: the next caller tries again.
37
+ value.catch(() => { if (kept.get(key) === entry) kept.delete(key); });
38
+ return value;
39
+ },
40
+ forget() { kept.clear(); },
41
+ get size() { return kept.size; }
42
+ };
43
+ }
@@ -15,6 +15,7 @@ import fs from 'node:fs';
15
15
  import path from 'node:path';
16
16
  import {fileURLToPath} from 'node:url';
17
17
  import {createCollection, createEntry, deleteEntry, getCollection, getEntry, listEntries, updateEntry} from '../../../server/services/collections.js';
18
+ import {createCache} from './cache.js';
18
19
  import {addTurn, receivedTicket} from './shape.js';
19
20
 
20
21
  export const COLLECTION = 'support-tickets';
@@ -71,10 +72,17 @@ export async function ensureCollection() {
71
72
  return true;
72
73
  }
73
74
 
74
- /** Every request, newest first. */
75
- export async function allTickets() {
76
- const {entries} = await listEntries(COLLECTION, {limit: 100000, sort: 'createdAt', order: 'desc'});
77
- return entries || [];
75
+ // The whole-collection read, kept a short while (lib/cache.js). Every write
76
+ // below calls forget() so the writer's own next read - and the badge - is fresh.
77
+ const remembered = createCache();
78
+ export const forget = () => remembered.forget();
79
+
80
+ /** Every request, newest first - the SAME array to every caller inside the cache window: filter, never change it. */
81
+ export function allTickets() {
82
+ return remembered.get('tickets', async () => {
83
+ const {entries} = await listEntries(COLLECTION, {limit: 100000, sort: 'createdAt', order: 'desc'});
84
+ return entries || [];
85
+ });
78
86
  }
79
87
 
80
88
  export const getTicket = (id) => getEntry(COLLECTION, id);
@@ -95,7 +103,9 @@ export async function removeTicket(id) {
95
103
  fs.mkdirSync(path.dirname(DELETED), {recursive: true});
96
104
  fs.writeFileSync(DELETED, JSON.stringify(gone));
97
105
  }
98
- return deleteEntry(COLLECTION, id);
106
+ const r = await deleteEntry(COLLECTION, id);
107
+ forget();
108
+ return r;
99
109
  }
100
110
 
101
111
  /** One site's requests - never another's. */
@@ -127,7 +137,9 @@ async function openNow(slug, payload, now, site) {
127
137
  if (!data.ref) return {error: 'A request needs a reference.'};
128
138
  const same = await byRef(slug, data.ref);
129
139
  if (same) return {entry: same, created: false};
130
- return {entry: await createEntry(COLLECTION, data, {source: 'fleet'}), created: true};
140
+ const entry = await createEntry(COLLECTION, data, {source: 'fleet'});
141
+ forget();
142
+ return {entry, created: true};
131
143
  }
132
144
 
133
145
  /**
@@ -148,11 +160,30 @@ async function customerTurnNow(id, payload, now) {
148
160
  const body = String(payload.body || '').trim();
149
161
  if (!body && !payload.close) return {error: 'Write a message first.'};
150
162
  const name = String(payload.user?.name || '').trim() || e.data.userName;
151
- const {data, added} = addTurn(e.data, {from: 'customer', name, body, id: String(payload.ref || ''), at: now, close: Boolean(payload.close)});
163
+ const {data, added} = addTurn(e.data, {from: 'customer', name, body, id: String(payload.ref || ''), at: now, close: Boolean(payload.close),
164
+ attachments: payload.attachments});
152
165
  if (!added) return {entry: e, added: false};
153
- return {entry: await updateEntry(COLLECTION, e.id, data), added: true};
166
+ const entry = await updateEntry(COLLECTION, e.id, data);
167
+ forget();
168
+ return {entry, added: true};
169
+ }
170
+
171
+ /** A request's file has arrived: mark it stored on its message. No history line - nothing moved. */
172
+ export function markStored(id, attId) {
173
+ return serial(id, async () => {
174
+ const e = await getTicket(id);
175
+ if (!e) return null;
176
+ const messages = (e.data.messages || []).map(m => (m.attachments?.some(a => a.id === attId)
177
+ ? {...m, attachments: m.attachments.map(a => (a.id === attId ? {...a, stored: true} : a))} : m));
178
+ const next = await updateEntry(COLLECTION, id, {...e.data, messages});
179
+ forget();
180
+ return next;
181
+ });
154
182
  }
155
183
 
184
+ /** That site's request with this ref (a site knows its requests by ref, never by the manager's id). */
185
+ export const ticketByRef = (slug, ref) => byRef(slug, String(ref || ''));
186
+
156
187
  /** Domma replied (and/or moved the status). */
157
188
  export function dommaTurn(id, turn, now = new Date().toISOString()) {
158
189
  return serial(id, () => dommaTurnNow(id, turn, now));
@@ -162,7 +193,9 @@ async function dommaTurnNow(id, {body = '', status, by, msgId}, now) {
162
193
  const e = await getTicket(id);
163
194
  if (!e) return null;
164
195
  const {data} = addTurn(e.data, {from: 'domma', name: by, body, id: msgId, at: now, status});
165
- return updateEntry(COLLECTION, id, data);
196
+ const next = await updateEntry(COLLECTION, id, data);
197
+ forget();
198
+ return next;
166
199
  }
167
200
 
168
201
  /** Status, priority, notes or the archive, from the inbox - with a line of history for what moved. */
@@ -179,5 +212,7 @@ async function patchNow(id, change, by, now) {
179
212
  if (change.priority !== undefined && change.priority !== d.priority) history.push({at: now, by, what: `Priority: ${change.priority}`});
180
213
  if (change.archived !== undefined && change.archived !== Boolean(d.archived)) history.push({at: now, by, what: change.archived ? 'Archived' : 'Back from the archive'});
181
214
  Object.assign(d, change, {history: history.slice(-200)});
182
- return updateEntry(COLLECTION, id, d);
215
+ const next = await updateEntry(COLLECTION, id, d);
216
+ forget();
217
+ return next;
183
218
  }