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
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Feedback - the settings cog (1.1.7). Each side shows its own settings only:
3
+ *
4
+ * a site how often it sends what waits and looks for replies (administrators)
5
+ * the manager how many reports one site may send in how many minutes (`manage`)
6
+ *
7
+ * They used to be reachable only through Marketplace > Configure and were read
8
+ * at boot; the server now reads them when used, so a save here is live.
9
+ *
10
+ * @module feedback/admin/lib/settings
11
+ */
12
+ import {BASE, esc, openPanel} from './kit.js';
13
+
14
+ /** Ranges - lib/shape.js SETTING_RULES, which the server applies again. */
15
+ const RULES = {
16
+ syncMinutes: {min: 5, max: 1440},
17
+ perSiteMax: {min: 1, max: 1000},
18
+ perSiteMinutes: {min: 1, max: 1440}
19
+ };
20
+
21
+ const FIELDS = {
22
+ sender: [
23
+ {key: 'syncMinutes', label: 'Check for replies every (minutes)',
24
+ help: 'How often this site sends any feedback still waiting and looks for Domma\'s replies. Opening Feedback always checks at once; this is the background check. 5 to 1440 (a day).'}
25
+ ],
26
+ receiver: [
27
+ {key: 'perSiteMax', label: 'Reports per site',
28
+ help: 'The most reports one site may send within the window below; more are refused with "try again in a few minutes" and the site retries later. Files have their own allowance: this many reports\' worth.'},
29
+ {key: 'perSiteMinutes', label: 'Window (minutes)',
30
+ help: 'The window the limit above counts in, in minutes.'}
31
+ ]
32
+ };
33
+
34
+ const wholeIn = (v, {min, max}) => { const n = Number(v); return String(v).trim() !== '' && Number.isInteger(n) && n >= min && n <= max; };
35
+
36
+ /**
37
+ * Add the cog when this user may change this side's settings (asked once).
38
+ *
39
+ * @param {object} $container
40
+ * @param {object} kit - loadKit()
41
+ */
42
+ export async function addSettingsCog($container, kit) {
43
+ let got = null;
44
+ // Silent: a site user who is not an administrator gets 403 here, and needs no cog and no error.
45
+ try { got = await H.request('GET', `${BASE}/settings`, null, {silent: true}); } catch { return; }
46
+ if (!got?.side) return;
47
+ kit.addBannerAction($container, {id: 'feedback-settings', icon: 'settings', title: 'Feedback settings', onClick: () => openSettings()});
48
+ }
49
+
50
+ export async function openSettings() {
51
+ let got;
52
+ try { got = await H.get(`${BASE}/settings`); } catch (err) { E.toast(`Settings not loaded: ${esc(err?.message || err)}`, {type: 'error'}); return; }
53
+ const fields = FIELDS[got.side] || [];
54
+ const f = Object.fromEntries(fields.map(x => [x.key, M.observable(String(got.settings?.[x.key] ?? ''))]));
55
+ const values = () => Object.fromEntries(fields.map(x => [x.key, Number(f[x.key].peek())]));
56
+ const opened = JSON.stringify(values());
57
+ const saving = M.observable(false);
58
+ const bad = Object.fromEntries(fields.map(x => [`${x.key}Bad`, M.computed(() => !wholeIn(f[x.key].value, RULES[x.key]))]));
59
+ const model = {...f, ...bad, saving, cannot: M.computed(() => saving.value || Object.values(bad).some(b => b.value))};
60
+ const html = `
61
+ <div class="fb-form">
62
+ ${fields.map(x => `
63
+ <label class="form-label" for="fb-set-${x.key}">${x.label} <span data-help="${esc(x.help)}"></span></label>
64
+ <input id="fb-set-${x.key}" type="number" class="form-input" min="${RULES[x.key].min}" max="${RULES[x.key].max}" step="1" data-model="${x.key}.value">
65
+ <small class="fb-note is-bad" data-bind-hidden="!${x.key}Bad.value">A whole number from ${RULES[x.key].min} to ${RULES[x.key].max}.</small>`).join('')}
66
+ <p class="fb-note">Saved settings take effect at once - no restart.</p>
67
+ <div class="fb-actions">
68
+ <span class="fb-spacer"></span>
69
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
70
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="cannot.value">Save</button>
71
+ </div>
72
+ </div>`;
73
+ let panel = null;
74
+ let closed = false;
75
+ let asking = false;
76
+ const methods = {
77
+ cancel: () => panel?.so.close(),
78
+ async save() {
79
+ if (model.cannot.peek()) return;
80
+ saving.value = true;
81
+ try {
82
+ await H.put(`${BASE}/settings`, values());
83
+ E.toast('Feedback settings saved.', {type: 'success'});
84
+ panel.so.close(true);
85
+ } catch (err) {
86
+ E.toast(`Not saved: ${esc(err?.message || err)}`, {type: 'error'});
87
+ } finally {
88
+ saving.value = false;
89
+ }
90
+ }
91
+ };
92
+ panel = await openPanel({title: 'Feedback settings', size: 'sm', html, model, methods});
93
+ // Closing with a change asks first - once (onceClosed calls close() again, argument-less).
94
+ const close = panel.so.close.bind(panel.so);
95
+ panel.so.close = async (force) => {
96
+ if (!closed && force !== true && JSON.stringify(values()) !== opened) {
97
+ if (asking) return undefined;
98
+ asking = true;
99
+ const ok = await E.confirm('Discard what you have entered?');
100
+ asking = false;
101
+ if (!ok) return undefined;
102
+ }
103
+ closed = true;
104
+ return close();
105
+ };
106
+ panel.panel.addEventListener('keydown', (e) => { if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); methods.save(); } });
107
+ }
@@ -14,9 +14,10 @@
14
14
  */
15
15
 
16
16
  import {api, loadKit, store} from '../lib/kit.js';
17
+ import {addSettingsCog} from '../lib/settings.js';
17
18
 
18
19
  const ROUTE = /^#\/plugins\/feedback(\/|$)/;
19
- const V = '1.1.4';
20
+ const V = '1.1.8';
20
21
  const TABS = [
21
22
  {key: 'inbox', label: 'Inbox', icon: 'inbox'},
22
23
  {key: 'mine', label: 'My feedback', icon: 'send'}
@@ -56,6 +57,8 @@ export const feedbackView = {
56
57
  return;
57
58
  }
58
59
  if (!here()) return;
60
+ // The cog (1.1.7) - this side's settings, for those who may change them. Never blocks the screen.
61
+ addSettingsCog($container, kit).catch(() => {});
59
62
 
60
63
  const load = async (screen, into, ctx) => {
61
64
  const mod = await import(`/plugins/feedback/admin/views/${screen}.js?v=${V}`);
@@ -8,7 +8,7 @@
8
8
  * Right-click (or the row's kebab, or Shift+F10) for the quick moves.
9
9
  */
10
10
 
11
- import {api, esc, openPanel, refreshBadge, stamp, store, when} from '../lib/kit.js';
11
+ import {FILES, api, esc, openPanel, refreshBadge, showFiles, stamp, store, when} from '../lib/kit.js';
12
12
 
13
13
  const ROW = `
14
14
  <div class="fb-row" data-id="{{id}}" tabindex="0" role="button">
@@ -67,7 +67,8 @@ export async function mount(root, ctx) {
67
67
  title: r.title,
68
68
  icon: iconOf(r.type),
69
69
  typeLabel: labelOf(r.type),
70
- meta: [r.siteName, r.userName, when(r.createdAt), r.severity === 'blocker' ? 'Blocker' : ''].filter(Boolean).join(' · '),
70
+ meta: [r.siteName, r.userName, when(r.createdAt), r.severity === 'blocker' ? 'Blocker' : '',
71
+ (r.attachments || []).length ? `${r.attachments.length} file${r.attachments.length === 1 ? '' : 's'}` : ''].filter(Boolean).join(' · '),
71
72
  statusLabel: statusOf(r.status),
72
73
  tone: `is-${TONE[r.status] || 'muted'}`,
73
74
  issueKey: r.issueKey || ''
@@ -189,14 +190,17 @@ export async function mount(root, ctx) {
189
190
  ...(r.cmsVersion ? [['CMS', r.cmsVersion]] : [])
190
191
  ];
191
192
  let panel = null;
193
+ let filesView = null;
192
194
  panel = await openPanel({
193
195
  title: r.title,
194
196
  size: 'lg',
195
197
  model: f,
198
+ cleanup: () => filesView?.dispose(),
196
199
  html: `<div class="fb-detail">
197
200
  <dl class="fb-facts">${facts.map(([a, b]) => `<div><dt>${esc(a)}</dt><dd>${esc(b)}</dd></div>`).join('')}</dl>
198
201
  <h4>What they sent</h4>
199
202
  <div class="fb-text">${esc(r.description)}</div>
203
+ ${(r.attachments || []).length ? `<h4>Files <span data-help="What the sender attached. Click one to open it in a new tab. A file still on its way follows its report within the site's next sync."></span></h4><div class="fb-files-host" data-files></div>` : ''}
200
204
 
201
205
  <div class="fb-form">
202
206
  <label class="form-label" for="fb-status">Status <span data-help="What the site sees. Once a Waypoint issue is linked, the status follows it - except Not planned, which stays until you change it."></span></label>
@@ -272,6 +276,11 @@ export async function mount(root, ctx) {
272
276
  }
273
277
  }
274
278
  });
279
+ const host = panel.panel.querySelector('[data-files]');
280
+ if (host) {
281
+ host.innerHTML = FILES;
282
+ filesView = showFiles(host, r.attachments, (a) => api.reportFileUrl(r.id, a.id), 'on its way from the site');
283
+ }
275
284
  // Opening a new report is seeing it.
276
285
  if (r.status === 'new') {
277
286
  try { const next = await api.update(r.id, {status: 'acknowledged'}); replace(next); f.status.value = next.status; refreshBadge(); }
@@ -10,7 +10,7 @@
10
10
  * the same screen files straight into the inbox: nothing waits, nothing travels.
11
11
  */
12
12
 
13
- import {api, esc, openPanel, refreshBadge, stamp, when} from '../lib/kit.js';
13
+ import {FILES, FILE_TYPES, api, esc, fileRow, fileSize, isImage, openPanel, refreshBadge, showFiles, stamp, uploadFile, when} from '../lib/kit.js';
14
14
 
15
15
  const ROW = `
16
16
  <div class="fb-row" data-id="{{key}}" tabindex="0" role="button">
@@ -44,6 +44,7 @@ export async function mount(root, ctx) {
44
44
  noReply: 'No reply yet. The status above moves as Domma works on it.'
45
45
  };
46
46
  const types = meta.types || [];
47
+ const caps = meta.attachments || {max: 3, maxBytes: 2 * 1024 * 1024};
47
48
  const iconOf = (t) => types.find(x => x.key === t)?.icon || 'message-square';
48
49
  const labelOf = (t) => types.find(x => x.key === t)?.short || t;
49
50
 
@@ -59,7 +60,8 @@ export async function mount(root, ctx) {
59
60
  title: r.title,
60
61
  icon: iconOf(r.type),
61
62
  typeLabel: labelOf(r.type),
62
- meta: [when(r.createdAt), r.userName, r.issueKey ? `Tracked as ${r.issueKey}` : ''].filter(Boolean).join(' · '),
63
+ meta: [when(r.createdAt), r.userName, r.issueKey ? `Tracked as ${r.issueKey}` : '',
64
+ (r.attachments || []).length ? `${r.attachments.length} file${r.attachments.length === 1 ? '' : 's'}` : ''].filter(Boolean).join(' · '),
63
65
  statusLabel: r.statusLabel,
64
66
  tone: `is-${TONE[r.status] || 'muted'}`,
65
67
  unread: Boolean(r.unread)
@@ -135,11 +137,18 @@ export async function mount(root, ctx) {
135
137
  where: M.observable(''), severity: M.observable('minor'), busy: M.observable(false)
136
138
  };
137
139
  const isBug = M.computed(() => f.type.value === 'bug');
140
+ // The files picked so far: uploaded as soon as they are chosen, sent by id with the report.
141
+ const picked = M.observable([]);
142
+ const room = M.computed(() => picked.value.length < caps.max);
143
+ const uploading = M.computed(() => picked.value.some(x => x.busy));
144
+ const localUrls = [];
145
+ let filesHandle = null;
138
146
  let panel = null;
139
147
  openPanel({
140
148
  title: COPY.title,
141
149
  size: 'md',
142
- model: {...f, isBug},
150
+ model: {...f, isBug, room, uploading},
151
+ cleanup: () => { filesHandle?.dispose(); for (const u of localUrls.splice(0)) URL.revokeObjectURL(u); },
143
152
  html: `<div class="fb-form">
144
153
  <label class="form-label">What is it?</label>
145
154
  <div class="fb-types" role="radiogroup" aria-label="Type">
@@ -157,20 +166,29 @@ export async function mount(root, ctx) {
157
166
  </div>
158
167
  <label class="form-label" for="fb-where">Where <span data-help="The screen or page it is about, if there is one - e.g. Pages, the Blog editor, /contact. Optional."></span></label>
159
168
  <input id="fb-where" class="form-input" maxlength="300" data-model="where.value" placeholder="Optional">
169
+ <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 most. They are seen only by the Domma team.`)}"></span></label>
170
+ <div class="fb-files-host" data-files></div>
171
+ <div class="fb-attach" data-bind-hidden="!room.value">
172
+ <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>
173
+ <span class="fb-hint">Or paste a screenshot into the description.</span>
174
+ <input type="file" class="fb-file-input" accept="${FILE_TYPES}" multiple hidden>
175
+ </div>
160
176
  <p class="fb-hint">${esc(COPY.hint)}</p>
161
177
  <div class="fb-actions"><span class="fb-spacer"></span>
162
178
  <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
163
- <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>
179
+ <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>
164
180
  </div>
165
181
  </div>`,
166
182
  methods: {
167
183
  cancel: () => panel?.close(),
184
+ pickFiles: () => panel?.panel.querySelector('.fb-file-input')?.click(),
168
185
  async send() {
169
- if (f.busy.peek()) return;
186
+ if (f.busy.peek() || uploading.peek()) return;
170
187
  f.busy.value = true;
171
188
  try {
172
189
  const r = await api.send({type: f.type.peek(), title: f.title.peek(), description: f.description.peek(),
173
- where: f.where.peek(), severity: f.type.peek() === 'bug' ? f.severity.peek() : ''});
190
+ where: f.where.peek(), severity: f.type.peek() === 'bug' ? f.severity.peek() : '',
191
+ attachments: picked.peek().filter(x => x.id && !x.busy).map(x => ({id: x.id, name: x.name, type: x.type, size: x.size}))});
174
192
  E.toast(r.message || 'Sent.', {type: r.state === 'sent' ? 'success' : 'warning'});
175
193
  panel?.close();
176
194
  refreshBadge();
@@ -188,6 +206,45 @@ export async function mount(root, ctx) {
188
206
  const b = e.target.closest('[data-type]');
189
207
  if (b) f.type.value = b.dataset.type;
190
208
  });
209
+ // The file list is bound on its own, after the panel: the panel's model has no `files`.
210
+ const host = p.panel.querySelector('[data-files]');
211
+ host.innerHTML = FILES;
212
+ const files = M.computed(() => picked.value.map(x => fileRow(x, {key: x.key, thumb: x.thumb, removable: !x.busy,
213
+ meta: x.busy ? 'Uploading...' : fileSize(x.size), title: x.name})));
214
+ const bound = M.applyBindings({files}, host);
215
+ filesHandle = {dispose: () => { bound.dispose(); files.dispose?.(); }};
216
+ host.addEventListener('click', (e) => {
217
+ const x = e.target.closest('.fb-file-x');
218
+ if (x) picked.value = picked.peek().filter(r => r.key !== x.closest('.fb-file').dataset.key);
219
+ });
220
+ const add = async (list) => {
221
+ for (const file of list) {
222
+ if (picked.peek().length >= caps.max) { E.toast(`Up to ${caps.max} files.`, {type: 'warning'}); break; }
223
+ if (file.size > caps.maxBytes) { E.toast(`${esc(file.name)} is too big - up to ${fileSize(caps.maxBytes)} each.`, {type: 'warning'}); continue; }
224
+ if (!FILE_TYPES.split(',').includes(file.type)) { E.toast(`${esc(file.name)} cannot be attached - images, PDF or text only.`, {type: 'warning'}); continue; }
225
+ const key = `k${Date.now()}${Math.random().toString(16).slice(2)}`;
226
+ let thumb = '';
227
+ if (isImage(file.type)) { thumb = URL.createObjectURL(file); localUrls.push(thumb); }
228
+ picked.value = [...picked.peek(), {key, name: file.name || 'screenshot.png', type: file.type, size: file.size, thumb, busy: true}];
229
+ try {
230
+ const a = await uploadFile(file);
231
+ picked.value = picked.peek().map(r => (r.key === key ? {...r, ...a, key, busy: false} : r));
232
+ } catch (err) {
233
+ picked.value = picked.peek().filter(r => r.key !== key);
234
+ E.toast(esc(err.message || String(err)), {type: 'error'});
235
+ }
236
+ }
237
+ };
238
+ const input = p.panel.querySelector('.fb-file-input');
239
+ input.addEventListener('change', () => { const list = [...input.files]; input.value = ''; add(list); });
240
+ // A screenshot pasted into the description is the quickest way there is.
241
+ p.panel.querySelector('#fb-desc')?.addEventListener('paste', (e) => {
242
+ const list = [...(e.clipboardData?.files || [])];
243
+ if (!list.length) return;
244
+ e.preventDefault();
245
+ add(list.map(file => (file.name && file.name !== 'image.png' ? file
246
+ : new File([file], `screenshot-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.png`, {type: file.type}))));
247
+ });
191
248
  p.panel.querySelector('#fb-title')?.focus();
192
249
  });
193
250
  }
@@ -204,16 +261,26 @@ export async function mount(root, ctx) {
204
261
  ...(r.issueKey ? [['Tracked as', `${r.issueKey}${r.issueStatus ? ` (${r.issueStatus})` : ''}`]] : []),
205
262
  ...(r.lastError ? [['Last try', r.lastError]] : [])
206
263
  ];
207
- await openPanel({
264
+ let filesView = null;
265
+ const p = await openPanel({
208
266
  title: r.title,
209
267
  size: 'md',
268
+ cleanup: () => filesView?.dispose(),
210
269
  html: `<div class="fb-detail">
211
270
  <dl class="fb-facts">${lines.map(([a, b]) => `<div><dt>${esc(a)}</dt><dd>${esc(b)}</dd></div>`).join('')}</dl>
212
271
  <h4>What was sent</h4>
213
272
  <div class="fb-text">${esc(r.description)}</div>
273
+ ${(r.attachments || []).length ? '<h4>Files</h4><div class="fb-files-host" data-files></div>' : ''}
214
274
  ${r.reply ? `<h4><span data-icon="corner-up-left" data-icon-size="14"></span> ${esc(COPY.replied)} <small>${esc(stamp(r.replyAt))}</small></h4><div class="fb-text fb-reply">${esc(r.reply)}</div>` : `<p class="fb-note">${esc(COPY.noReply)}</p>`}
215
275
  </div>`
216
276
  });
277
+ const host = p.panel.querySelector('[data-files]');
278
+ if (host) {
279
+ host.innerHTML = FILES;
280
+ // This site kept its own copy of every file it sent, so they open from here even while
281
+ // Domma's copy is still on its way. The manager's own reports read theirs in place.
282
+ filesView = showFiles(host, (r.attachments || []).map(a => ({...a, stored: true})), (a) => api.ownFileUrl(a.id));
283
+ }
217
284
  if (r.unread && r.id) {
218
285
  try {
219
286
  await api.seen(r.id, r.replyAt);
@@ -20,7 +20,8 @@ Feedback lets you tell Domma what is broken, missing or good, straight from your
20
20
  4. Type a **Title** (one line) and a **Description**.
21
21
  5. For something broken, choose **How bad is it?**: Minor - a nuisance, Major - gets in the way, or Blocker - cannot carry on.
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 description. 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
  A good bug report says what you did, what you expected and what happened instead. For a request, say what you are trying to get done.
26
27
 
@@ -41,7 +42,9 @@ Each row shows the type, title, when it was sent and by whom, and a status:
41
42
  | Waiting to send | Domma could not be reached yet; it goes automatically |
42
43
  | Not accepted | Domma's server turned the report down |
43
44
 
44
- A dot on a row means a new reply. Click a row to open it: you see the details, **What was sent**, and **Domma replied** with the reply, or "No reply yet".
45
+ A dot on a row means a new reply. Click a row to open it: you see the details, **What was sent**, any **Files** you attached (click one to open it), and **Domma replied** with the reply, or "No reply yet".
46
+
47
+ Attached files go to Domma straight after the report. Only the Domma team sees them - they are never put in your Media library or on your website.
45
48
 
46
49
  If "Tracked as" appears, Domma has filed the report in its own issue tracker, and the status follows that issue.
47
50
 
@@ -53,11 +56,7 @@ If the screen says "This site is not connected to Domma", your site was not star
53
56
 
54
57
  ## Settings
55
58
 
56
- There is no settings screen. Whoever runs the site can change one option in the plugin's configuration file:
57
-
58
- | Option | Default | Meaning |
59
- |---|---|---|
60
- | `syncMinutes` | 60 | How often the site sends waiting reports and checks for replies |
59
+ Administrators see a **Feedback 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 Domma's replies in the background (default 60, from 5 to 1440). A change takes effect at once, no restart.
61
60
 
62
61
  Opening the Feedback screen also sends anything waiting and checks for replies straight away.
63
62
 
@@ -81,15 +80,16 @@ Only the **admin** role has them by default. Everyone with **Send** sees every r
81
80
  On the Domma server itself (the Manager), the same screen is the **Inbox** of every site's reports, for holders of **Feedback > Manage**:
82
81
 
83
82
  - Filter by **Open**, each status, or **All**; by site and type; and search title, text, person and site.
84
- - Open a report to set its **Status**, write a **Reply to the site** (the site sees it and is notified) and keep **Internal notes** (never sent to the site). Click **Save**.
83
+ - Open a report to see any **Files** the site attached (a file still on its way says so), set its **Status**, write a **Reply to the site** (the site sees it and is notified) and keep **Internal notes** (never sent to the site). Click **Save**.
85
84
  - With Waypoint Pro installed, **Make a Waypoint issue** files the report in a project with a type and priority. The report then follows the issue: planned, in progress, done. "Not planned" set by hand stays until you change it.
86
85
  - Right-click a row to open it, change its status quickly, or **Delete** it. Deleting removes it for the site too.
87
86
  - Holders of the most senior role also get **My feedback**, to file their own reports. These appear in the Inbox as "Manager (this server)".
87
+ - The **Feedback settings** cog sets how many reports one site may send in a window (default 10 in 10 minutes); more are turned away for now and the site sends them later. Changes apply to the next report.
88
88
 
89
89
  ## Limitations
90
90
 
91
91
  - Only sites started by a Domma server can send. A standalone install cannot.
92
- - No attachments or screenshots. Describe the problem in words, and use **Where** for the page.
92
+ - Up to 3 files per report, 2 MB each, and only images (PNG, JPEG, GIF, WebP), PDFs and text files.
93
93
  - Titles are limited to 140 characters and descriptions to 8,000.
94
94
  - The Domma server accepts up to 10 reports from one site in 10 minutes. Any more show as **Waiting to send** and go later.
95
95
  - You cannot edit or withdraw a report once sent. Send a follow-up instead.
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Feedback - 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 report's files
6
+ * under data/attachments/<report 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 feedback/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
+ /** A staged file becomes a report's (the manager's own feedback). */
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 report 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 no outbox item 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 hourly `/fleet/mine` and the dedupe in
6
+ * `receive()` each read EVERY report. The data changes only when someone
7
+ * 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 feedback/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
+ }
@@ -17,6 +17,7 @@ import fs from 'node:fs';
17
17
  import path from 'node:path';
18
18
  import {fileURLToPath} from 'node:url';
19
19
  import {createCollection, createEntry, deleteEntry, getCollection, getEntry, listEntries, updateEntry} from '../../../server/services/collections.js';
20
+ import {createCache} from './cache.js';
20
21
  import {receivedReport, statusFromIssue, statusLabel} from './shape.js';
21
22
 
22
23
  export const COLLECTION = 'feedback-reports';
@@ -59,14 +60,25 @@ export async function ensureCollection() {
59
60
  return true;
60
61
  }
61
62
 
62
- /** Every report, newest first. */
63
- export async function allReports() {
64
- const {entries} = await listEntries(COLLECTION, {limit: 100000, sort: 'createdAt', order: 'desc'});
65
- return entries || [];
63
+ // The whole-collection read, kept a short while (lib/cache.js). Every write
64
+ // below calls forget() so the writer's own next read - and the badge - is fresh.
65
+ const remembered = createCache();
66
+ export const forget = () => remembered.forget();
67
+
68
+ /** Every report, newest first - the SAME array to every caller inside the cache window: filter, never change it. */
69
+ export function allReports() {
70
+ return remembered.get('reports', async () => {
71
+ const {entries} = await listEntries(COLLECTION, {limit: 100000, sort: 'createdAt', order: 'desc'});
72
+ return entries || [];
73
+ });
66
74
  }
67
75
 
68
76
  export const getReport = (id) => getEntry(COLLECTION, id);
69
- export const removeReport = (id) => deleteEntry(COLLECTION, id);
77
+ export async function removeReport(id) {
78
+ const r = await deleteEntry(COLLECTION, id);
79
+ forget();
80
+ return r;
81
+ }
70
82
 
71
83
  /**
72
84
  * A site's report arrived. The same site sending the same `ref` again (a retry
@@ -86,6 +98,7 @@ export async function receive(slug, payload, source = 'fleet') {
86
98
  }
87
99
  data.history = [{at: new Date().toISOString(), by: data.userName, what: 'Sent'}];
88
100
  const entry = await createEntry(COLLECTION, data, {source});
101
+ forget();
89
102
  return {entry, created: true};
90
103
  }
91
104
 
@@ -112,7 +125,24 @@ export async function applyPatch(id, patch, by) {
112
125
  if (patch.reply !== undefined) { d.replyAt = patch.reply ? now : ''; history.push({at: now, by, what: patch.reply ? 'Replied' : 'Reply removed'}); }
113
126
  if (patch.issueKey !== undefined && patch.issueKey !== d.issueKey) history.push({at: now, by, what: `Waypoint ${patch.issueKey}`});
114
127
  Object.assign(d, patch, {history: history.slice(-100)});
115
- return updateEntry(COLLECTION, id, d);
128
+ const next = await updateEntry(COLLECTION, id, d);
129
+ forget();
130
+ return next;
131
+ }
132
+
133
+ /**
134
+ * A file announced by a report has arrived: mark it stored. Touches nothing else
135
+ * on the record, so it adds no history line.
136
+ *
137
+ * @returns {Promise<object|null>} the updated entry
138
+ */
139
+ export async function markStored(id, attId) {
140
+ const e = await getReport(id);
141
+ if (!e) return null;
142
+ const attachments = (e.data.attachments || []).map(a => (a.id === attId ? {...a, stored: true} : a));
143
+ const next = await updateEntry(COLLECTION, id, {...e.data, attachments});
144
+ forget();
145
+ return next;
116
146
  }
117
147
 
118
148
  /**