domma-cms 1.6.36 → 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 (84) 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/compose-window.js +4 -0
  5. package/plugins/_lib/admin/mail/mail.css +1 -1
  6. package/plugins/_lib/admin/mail/reader-view.js +15 -6
  7. package/plugins/_lib/mail/compose.js +7 -2
  8. package/plugins/_lib/mail/message-action.js +56 -0
  9. package/plugins/_lib/mail/scheduler.js +2 -0
  10. package/plugins/_lib/mail/send.js +12 -3
  11. package/plugins/_lib/mail/store.js +16 -6
  12. package/plugins/_lib/outreach/index.js +1 -1
  13. package/plugins/_lib/outreach/ledger.js +10 -3
  14. package/plugins/_lib/outreach/sending.js +9 -3
  15. package/plugins/_lib/outreach/suppression.js +14 -3
  16. package/plugins/_lib/outreach/unsubscribe.js +13 -0
  17. package/plugins/blog/CLAUDE.md +11 -1
  18. package/plugins/blog/lib/cache.js +43 -0
  19. package/plugins/blog/lib/page.js +28 -9
  20. package/plugins/blog/plugin.js +14 -13
  21. package/plugins/blog/plugin.json +1 -1
  22. package/plugins/blog/plugin.public.js +1 -1
  23. package/plugins/blog/tests/cache.test.js +65 -0
  24. package/plugins/booking/CLAUDE.md +14 -2
  25. package/plugins/booking/admin/lib/diary-layout.js +18 -0
  26. package/plugins/booking/admin/views/diary.js +153 -152
  27. package/plugins/booking/admin/views/people.js +64 -66
  28. package/plugins/booking/admin/views/settings.js +31 -32
  29. package/plugins/booking/plugin.json +1 -1
  30. package/plugins/booking/tests/admin-diary.test.js +9 -1
  31. package/plugins/feedback/CLAUDE.md +55 -0
  32. package/plugins/feedback/admin/css/index.css +16 -1
  33. package/plugins/feedback/admin/lib/kit.js +106 -0
  34. package/plugins/feedback/admin/lib/settings.js +107 -0
  35. package/plugins/feedback/admin/views/feedback.js +4 -1
  36. package/plugins/feedback/admin/views/inbox.js +11 -2
  37. package/plugins/feedback/admin/views/send.js +74 -7
  38. package/plugins/feedback/docs/guide.md +9 -9
  39. package/plugins/feedback/lib/attachments.js +133 -0
  40. package/plugins/feedback/lib/cache.js +43 -0
  41. package/plugins/feedback/lib/receiver.js +36 -6
  42. package/plugins/feedback/lib/sender.js +70 -6
  43. package/plugins/feedback/lib/shape.js +111 -6
  44. package/plugins/feedback/plugin.js +171 -17
  45. package/plugins/feedback/plugin.json +2 -2
  46. package/plugins/feedback/tests/api.test.js +125 -3
  47. package/plugins/feedback/tests/settings.test.js +59 -0
  48. package/plugins/feedback/tests/shape.test.js +64 -0
  49. package/plugins/free-tier.lock.json +73 -61
  50. package/plugins/mail-reader/CLAUDE.md +15 -4
  51. package/plugins/mail-reader/plugin.json +1 -1
  52. package/plugins/shopping-cart/CLAUDE.md +28 -2
  53. package/plugins/shopping-cart/admin/css/index.css +3 -0
  54. package/plugins/shopping-cart/admin/views/orders.js +42 -10
  55. package/plugins/shopping-cart/admin/views/shop.js +1 -1
  56. package/plugins/shopping-cart/docs/guide.md +7 -3
  57. package/plugins/shopping-cart/lib/payments.js +15 -2
  58. package/plugins/shopping-cart/lib/refunds.js +51 -0
  59. package/plugins/shopping-cart/lib/sales.js +15 -8
  60. package/plugins/shopping-cart/lib/shop.js +38 -3
  61. package/plugins/shopping-cart/plugin.js +47 -19
  62. package/plugins/shopping-cart/plugin.json +2 -2
  63. package/plugins/shopping-cart/tests/api.test.js +52 -0
  64. package/plugins/shopping-cart/tests/commission.test.js +1 -1
  65. package/plugins/shopping-cart/tests/refunds.test.js +42 -0
  66. package/plugins/support/CLAUDE.md +51 -0
  67. package/plugins/support/admin/css/index.css +16 -1
  68. package/plugins/support/admin/lib/kit.js +192 -8
  69. package/plugins/support/admin/lib/settings.js +123 -0
  70. package/plugins/support/admin/views/desk.js +55 -12
  71. package/plugins/support/admin/views/inbox.js +17 -4
  72. package/plugins/support/admin/views/support.js +4 -1
  73. package/plugins/support/docs/guide.md +7 -9
  74. package/plugins/support/lib/attachments.js +133 -0
  75. package/plugins/support/lib/cache.js +43 -0
  76. package/plugins/support/lib/receiver.js +45 -10
  77. package/plugins/support/lib/sender.js +81 -10
  78. package/plugins/support/lib/shape.js +153 -12
  79. package/plugins/support/plugin.js +159 -29
  80. package/plugins/support/plugin.json +2 -2
  81. package/plugins/support/tests/api.test.js +125 -3
  82. package/plugins/support/tests/settings.test.js +68 -0
  83. package/plugins/support/tests/shape.test.js +51 -1
  84. package/server/routes/outreach-public.js +54 -10
@@ -15,6 +15,15 @@ const SECTIONS = [
15
15
  {key: 'tools', label: 'Other Tools', icon: 'plug'},
16
16
  {key: 'closed', label: 'Closed days', icon: 'ban'}
17
17
  ];
18
+ // One spell of closed days.
19
+ const CLOSURE_ROW = `
20
+ <div class="bk-off">
21
+ <input type="date" class="form-input" data-model="from.value" aria-label="From" data-on-change="$parent.closureFrom($data)"><span>to</span>
22
+ <input type="date" class="form-input" data-model="to.value" aria-label="To">
23
+ <input class="form-input" data-model="label.value" placeholder="Bank holiday…" maxlength="80" aria-label="Why">
24
+ <button type="button" class="btn btn-xs btn-ghost bk-x" title="Remove" aria-label="Remove" data-on-click="$parent.dropClosure($data)"><span data-icon="x" data-icon-size="12"></span></button>
25
+ </div>`;
26
+
18
27
  const BOOL_KEYS = ['askPhone', 'phoneRequired', 'allowCancel', 'allowMove', 'sendConfirmation', 'addToContacts', 'calendarBusy'];
19
28
  const FIELD_SECTION = {pageTitle: 'page', bookingPath: 'page', timeZone: 'page', termsUrl: 'page', notifyAddress: 'emails'};
20
29
 
@@ -32,13 +41,26 @@ export async function openSettings(ctx, section = 'page') {
32
41
  const s = info.settings;
33
42
  const f = {};
34
43
  for (const [k, v] of Object.entries(s)) if (k !== 'closures') f[k] = M.observable(v);
35
- let closures = (s.closures || []).map(c => ({...c}));
44
+ // Closed days are a keyed list (CLOSURE_ROW); each field is an observable the inputs write through.
45
+ let seq = 0;
46
+ const closureRow = (c) => ({id: `c${++seq}`, from: M.observable(c.from || ''), to: M.observable(c.to || ''), label: M.observable(c.label || '')});
47
+ const closures = M.observable((s.closures || []).map(closureRow));
48
+ const closuresOut = () => closures.peek().map(o => ({from: o.from.peek(), to: o.to.peek(), label: o.label.peek()}));
36
49
  const tab = M.observable(SECTIONS.some(x => x.key === section) ? section : 'page');
37
50
  const saving = M.observable(false);
38
- const snapshot = () => JSON.stringify([Object.entries(f).map(([k, o]) => [k, o.peek()]), closures]);
51
+ const snapshot = () => JSON.stringify([Object.entries(f).map(([k, o]) => [k, o.peek()]), closuresOut()]);
39
52
  const opened = snapshot();
53
+ let panel = null;
54
+ // New rows carry icons; they are drawn once the list has rendered.
55
+ const rescan = () => { M.flushSync?.(); if (panel) { I.scan(panel.panel); requestAnimationFrame(() => panel?.panel.isConnected && I.scan(panel.panel)); } };
40
56
  const model = {
41
- ...f, tab, saving,
57
+ ...f, tab, saving, closures,
58
+ dropClosure(o) { closures.value = closures.peek().filter(x => x !== o); },
59
+ // An end before the start is never meant: it follows the start.
60
+ closureFrom(o, e) {
61
+ const v = e?.target?.value ?? o.from.peek();
62
+ if (!o.to.peek() || o.to.peek() < v) o.to.value = v;
63
+ },
42
64
  pageUrl: M.computed(() => `${location.origin}${f.bookingPath.value || '/booking'}`),
43
65
  noPhone: M.computed(() => !f.askPhone.value),
44
66
  remindText: M.computed(() => (Number(f.reminderHours.value) > 0 ? `${f.reminderHours.value} hours before` : 'off'))
@@ -110,7 +132,8 @@ export async function openSettings(ctx, section = 'page') {
110
132
 
111
133
  <section data-bind-hidden="!is_closed.value">
112
134
  <p class="bk-note">Days the whole business is closed - bank holidays, a closure. Nobody can be booked. (For one person's holiday, use their days off.)</p>
113
- <div data-part="closures"></div>
135
+ <p class="bk-note" data-bind-hidden="closures.value.length">None yet.</p>
136
+ <div data-each="closures.value key=id">${CLOSURE_ROW}</div>
114
137
  <div class="bk-actions bk-actions-start"><button type="button" class="btn btn-xs btn-outline" data-on-click="addClosure"><span data-icon="plus" data-icon-size="12"></span> Add closed days</button></div>
115
138
  </section>
116
139
 
@@ -121,18 +144,6 @@ export async function openSettings(ctx, section = 'page') {
121
144
  </div>
122
145
  </div>`;
123
146
 
124
- const drawClosures = (el) => {
125
- const box = el.querySelector('[data-part="closures"]');
126
- box.innerHTML = closures.length ? closures.map((o, i) => `<div class="bk-off" data-i="${i}">
127
- <input type="date" class="form-input" value="${esc(o.from)}" data-k="from" aria-label="From"><span>to</span>
128
- <input type="date" class="form-input" value="${esc(o.to)}" data-k="to" aria-label="To">
129
- <input class="form-input" value="${esc(o.label)}" data-k="label" placeholder="Bank holiday…" maxlength="80" aria-label="Why">
130
- <button type="button" class="btn btn-xs btn-ghost bk-x" data-drop title="Remove" aria-label="Remove"><span data-icon="x" data-icon-size="12"></span></button></div>`).join('')
131
- : '<p class="bk-note">None yet.</p>';
132
- I.scan(box);
133
- };
134
-
135
- let panel = null;
136
147
  panel = await openPanel({
137
148
  title: 'Booking settings',
138
149
  size: 'md',
@@ -148,27 +159,15 @@ export async function openSettings(ctx, section = 'page') {
148
159
  const b = e.target.closest('[data-tab]');
149
160
  if (b) tab.value = b.dataset.tab;
150
161
  });
151
- drawClosures(el);
152
- const box = el.querySelector('[data-part="closures"]');
153
- box.addEventListener('input', (e) => {
154
- const row = e.target.closest('[data-i]');
155
- if (!row) return;
156
- const o = closures[Number(row.dataset.i)];
157
- o[e.target.dataset.k] = e.target.value;
158
- if (e.target.dataset.k === 'from' && (!o.to || o.to < o.from)) { o.to = o.from; row.querySelector('[data-k="to"]').value = o.from; }
159
- });
160
- box.addEventListener('click', (e) => {
161
- const row = e.target.closest('[data-i]');
162
- if (row && e.target.closest('[data-drop]')) { closures.splice(Number(row.dataset.i), 1); drawClosures(el); }
163
- });
164
162
  },
165
163
  methods: {
166
164
  cancel: () => panel?.close(),
167
165
  copyUrl: () => copy(model.pageUrl.peek(), 'Booking page address copied'),
168
- addClosure() { closures = [...closures, {from: ctx.boot.today, to: ctx.boot.today, label: ''}]; drawClosures(panel.panel); },
166
+ addClosure() { closures.value = [...closures.peek(), closureRow({from: ctx.boot.today, to: ctx.boot.today, label: ''})]; rescan(); },
169
167
  async save() {
170
168
  if (saving.peek()) return;
171
- for (const o of closures) {
169
+ const closed = closuresOut();
170
+ for (const o of closed) {
172
171
  if (!isDate(o.from) || !isDate(o.to) || o.to < o.from) { tab.value = 'closed'; E.toast('Closed days: each needs a start date, and an end on or after it.', {type: 'warning'}); return; }
173
172
  }
174
173
  const out = {};
@@ -176,7 +175,7 @@ export async function openSettings(ctx, section = 'page') {
176
175
  for (const k of BOOL_KEYS) out[k] = Boolean(out[k]);
177
176
  out.changeNoticeHours = Number(out.changeNoticeHours) || 0;
178
177
  out.reminderHours = Number(out.reminderHours) || 0;
179
- out.closures = closures;
178
+ out.closures = closed;
180
179
  saving.value = true;
181
180
  const el = panel.panel;
182
181
  try {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "booking",
3
3
  "displayName": "Booking",
4
- "version": "1.6.3",
4
+ "version": "1.6.4",
5
5
  "tier": "free",
6
6
  "description": "Let people book appointments with you or your team, and rooms or equipment, from a booking page on your site. Opening hours, days off and buffers; confirmation and reminder emails with a link to change or cancel; a diary in the admin. Book straight from a contact or an email.",
7
7
  "author": "Domma CMS",
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import {test} from 'node:test';
5
5
  import assert from 'node:assert/strict';
6
- import {datesFor, gridHours, layoutColumn, minuteAt, placeOf, weekStart, dayLabel} from '../admin/lib/diary-layout.js';
6
+ import {datesFor, gridHours, layoutColumn, mergeWindows, minuteAt, placeOf, weekStart, dayLabel} from '../admin/lib/diary-layout.js';
7
7
 
8
8
  test('weeks start on Monday', () => {
9
9
  assert.equal(weekStart('2031-07-15'), '2031-07-14');
@@ -71,3 +71,11 @@ test('booking details: who, where from, answers, places', () => {
71
71
  assert.equal(placesLabel(1), '1 place left');
72
72
  assert.equal(placesLabel(3), '3 places left');
73
73
  });
74
+
75
+ test('week bands: windows merged where they touch or overlap, sorted, inputs untouched', () => {
76
+ const input = [{from: 780, to: 1020}, {from: 540, to: 750}, {from: 600, to: 780}, {from: 1080, to: 1140}];
77
+ assert.deepEqual(mergeWindows(input), [{from: 540, to: 1020}, {from: 1080, to: 1140}]);
78
+ assert.deepEqual(input[0], {from: 780, to: 1020}, 'no input is changed');
79
+ assert.deepEqual(mergeWindows([]), []);
80
+ assert.deepEqual(mergeWindows(undefined), []);
81
+ });
@@ -118,3 +118,58 @@ Replying in the inbox to a `_manager` report you filed yourself marks it seen fo
118
118
 
119
119
  - Cache-busters follow the manifest: `feedback.js?v=1.1.4` in plugin.json and `V = '1.1.4'` for the sub-views - bump
120
120
  both with the version. Fleet-token note above corrected (tokens are on disk since 2026-10-04).
121
+
122
+ ## 1.1.5 (2026-10-13 Tool sweep - caching)
123
+
124
+ - **`allReports()` is cached 30 s** (`lib/cache.js`; promise kept, concurrent reads share one). It served the
125
+ manager badge (every minute per `manage` tab), every site's `/fleet/mine` and `receive()`'s dedupe. Every write
126
+ in lib/receiver.js (`receive`, `applyPatch` - and so `issueMoved` - `removeReport`) calls `forget()` after the
127
+ write, so the inbox and badge show it on the next read. A test that writes the collection directly must call
128
+ `forget()` itself. Test: `tests/api.test.js` "the inbox memory".
129
+
130
+ ## 1.1.6 (2026-10-13) - attachments
131
+
132
+ Up to 3 files per report, 2 MB each: PNG, JPEG, GIF, WebP, PDF, plain text (`ATTACHMENTS` / `ATTACHMENT_TYPES` in
133
+ lib/shape.js; no SVG or HTML - they can carry script). Chosen with "Attach a file" or pasted into the description.
134
+
135
+ - **Not the CMS media API, on purpose.** `/api/media` saves into the PUBLIC media folder (anyone with the URL reads
136
+ it) and needs `media.create`, which a `feedback.send` user may lack. These are screenshots of admin screens, so the
137
+ plugin has its own upload route that mirrors media.js's checks (mime whitelist, `request.file()` from the engine's
138
+ @fastify/multipart, size cap, sanitised name) and keeps files in its own `data/attachments/` (0600 files, 0700
139
+ folders, `lib/attachments.js createStore`). Every path is built from a cleaned attachment (`cleanAttachment`: id
140
+ `[A-Za-z0-9-]{8,64}`, made by `randomUUID()` on upload) - nothing from a request reaches `path.join` raw.
141
+ - **Flow.** Site: `POST /attachments` (multipart) -> `{id, name, type, size}` saved with a `<id>.json` sidecar.
142
+ `POST /send {..., attachments: [{id}]}` takes each file's description from the SIDECAR, never the form, and the
143
+ payload announces them. `flush()` (now single-flight, like Support's) submits the report, then posts each file RAW to
144
+ `POST /fleet/attach/:reportId/:attId` (`application/octet-stream`; a plugin-scoped parser, bodyLimit 2 MB + 1 KB;
145
+ its own throttle, perSiteMax x 3). The manager takes only a file the report announced, at the announced size, on a
146
+ report of the token's site (else 404), saves it under `data/attachments/<report id>/` and sets `stored: true`
147
+ (`rx.markStored`). Outbox items record `filesSent` / `filesRefused` (a 4xx other than 401/408/429, or a file this
148
+ site no longer has), so a file is retried until it lands or is refused for good.
149
+ - **Reading them.** Inbox: `GET /reports/:id/attachments/:att` (`manage`). Site: `GET /attachments/:id` - its own
150
+ copy, so a file shows before Domma's copy lands. Served with the stored type, `nosniff`, inline, private cache.
151
+ The admin fetches with the session token into a `blob:` URL (`kit.blobUrl`) for thumbnails and "open in a new tab".
152
+ - The level-0 role's own feedback: uploads stage in `data/attachments/_staging/`, `POST /send` moves them into the
153
+ report's folder and marks them stored; `GET /attachments/:id` finds staged or filed ones.
154
+ - Deleting a report deletes its folder. A site drops files never sent (no outbox item names them) after a day, on the
155
+ sync timer; the manager prunes `_staging` on each upload.
156
+ - UI: `kit.FILES` (data-each row template, bound on its own host AFTER the panel's bindings - the panel model has no
157
+ `files`; no icons in rows because they land after the icon scan) and `kit.showFiles()` for detail panels.
158
+ - Also: `GET /meta` asks for `manage` on the manager (a manage-only role used to 403 and the screen failed), and
159
+ answers the attachment caps; `text()` strips all control characters (as Support's).
160
+ - Tests: shape.test.js (cleanAttachment, caps, store perms and prune), api.test.js (upload -> travel -> inbox
161
+ download, refusals, offline wait then both go). Not covered by tests: the level-0 role's own attachments.
162
+
163
+ ## 1.1.7 (2026-10-13) - settings cog
164
+
165
+ - Settings are read when used, per side (`cleanSettings(raw, current, side)`, `SIDE_SETTINGS` in lib/shape.js):
166
+ the site's `syncMinutes`, the manager's `perSiteMax` / `perSiteMinutes`. `GET/PUT /settings` answers this side
167
+ only - a site's for `requireAdmin` (from `options.auth`; `send` on an engine without it), the manager's for
168
+ `manage`; the save merges over the stored object (the other side's keys and `storage` survive). Tests inject
169
+ `options.readSettings` / `options.writeSettings` (tests/api.test.js passes the test's limits that way).
170
+ - The sync timer is a self-re-arming `setTimeout` that asks for `syncMinutes` before each wait, and a save
171
+ re-arms it. The two throttles (`createThrottle()` now takes `{max, windowMs}` per call) get the live limits on
172
+ each report or file. Before, all three were read at boot and needed a restart.
173
+ - `admin/lib/settings.js`: the banner cog, added by `feedback.js` once the mode is known, only when the silent
174
+ `GET /settings` answers; a slideover through kit's `openPanel` with M bindings, data-help per field, discard
175
+ guard, Ctrl+Enter. Tests: tests/settings.test.js, and "the per-site throttle" in tests/api.test.js (live limit).
@@ -1,7 +1,7 @@
1
1
  /* Feedback - admin styles. Theme tokens only (every one below is defined by every Domma theme). */
2
2
 
3
3
  /* data-bind-hidden sets [hidden]; any display rule below would otherwise beat it. */
4
- .fb [hidden], .fb-detail [hidden], .fb-form [hidden] { display: none !important; }
4
+ .fb [hidden], .fb-detail [hidden], .fb-form [hidden], .fb-files-host [hidden] { display: none !important; }
5
5
 
6
6
  .fb { max-width: 60rem; }
7
7
  .fb-card { background: var(--dm-surface); border: 1px solid var(--dm-border); border-radius: var(--dm-radius-lg); padding: 1rem 1.1rem; }
@@ -74,6 +74,21 @@
74
74
  .fb-history li { display: flex; justify-content: space-between; gap: 1rem; padding: .3rem 0; border-top: 1px solid var(--dm-border); }
75
75
  .fb-history small { color: var(--dm-text-secondary); }
76
76
 
77
+ /* Attached files: a thumbnail or the file's kind, its name and size */
78
+ .fb-attach { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
79
+ .fb-files { list-style: none; margin: .3rem 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr)); gap: .4rem; }
80
+ .fb-file { display: flex; align-items: center; gap: .55rem; padding: .35rem .5rem; border: 1px solid var(--dm-border); border-radius: var(--dm-radius-md);
81
+ background: var(--dm-background); cursor: pointer; min-width: 0; }
82
+ .fb-file:hover, .fb-file:focus-visible { border-color: var(--dm-primary); outline: none; }
83
+ .fb-thumb { width: 2.6rem; height: 2.6rem; object-fit: cover; border-radius: var(--dm-radius-md); flex: none; }
84
+ .fb-file-ext { width: 2.6rem; height: 2.6rem; display: inline-flex; align-items: center; justify-content: center; flex: none; font-size: var(--dm-font-size-xs);
85
+ font-weight: 700; color: var(--dm-text-secondary); background: var(--dm-hover-bg); border-radius: var(--dm-radius-md); }
86
+ .fb-file-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
87
+ .fb-file-main strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--dm-font-size-sm); }
88
+ .fb-file-main small { color: var(--dm-text-secondary); font-size: var(--dm-font-size-xs); }
89
+ .fb-file-x { border: 0; background: none; color: var(--dm-text-secondary); cursor: pointer; font-size: 1.1rem; line-height: 1; padding: .1rem .35rem; border-radius: var(--dm-radius-md); }
90
+ .fb-file-x:hover { background: var(--dm-hover-bg); color: var(--dm-danger); }
91
+
77
92
  @media (max-width: 640px) {
78
93
  .fb-grid3 { grid-template-columns: 1fr; }
79
94
  .fb-row { flex-wrap: wrap; }
@@ -46,9 +46,115 @@ export const api = {
46
46
  update: (id, d) => H.put(`${BASE}/reports/${seg(id)}`, d),
47
47
  remove: (id) => H.delete(`${BASE}/reports/${seg(id)}`),
48
48
  waypoint: () => H.get(`${BASE}/waypoint`),
49
+ /** Where a file is read from: this site's (or the manager's own) copy, or a report's in the inbox. */
50
+ ownFileUrl: (id) => `${BASE}/attachments/${seg(id)}`,
51
+ reportFileUrl: (id, att) => `${BASE}/reports/${seg(id)}/attachments/${seg(att)}`,
49
52
  promote: (id, d) => H.post(`${BASE}/reports/${seg(id)}/promote`, d)
50
53
  };
51
54
 
55
+ // ---------------------------------------------------------------------------------
56
+ // Attachments
57
+ // ---------------------------------------------------------------------------------
58
+
59
+ const authHeaders = () => { const t = (() => { try { return S.get('auth_token'); } catch { return ''; } })(); return t ? {Authorization: `Bearer ${t}`} : {}; };
60
+
61
+ /** Upload one file for the report being written. H has no multipart helper, so this is the admin's own upload pattern. */
62
+ export async function uploadFile(file) {
63
+ const fd = new FormData();
64
+ fd.append('file', file, file.name);
65
+ const res = await fetch(`${BASE}/attachments`, {method: 'POST', headers: authHeaders(), body: fd});
66
+ const body = await res.json().catch(() => ({}));
67
+ if (!res.ok) throw new Error(body.error || `Upload failed (${res.status}).`);
68
+ return body;
69
+ }
70
+
71
+ /** A file behind the session as a blob: URL (an <img src> or a new tab cannot send the token). Revoke it when done. */
72
+ export async function blobUrl(url) {
73
+ const res = await fetch(url, {headers: authHeaders()});
74
+ if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || `Could not open the file (${res.status}).`);
75
+ return URL.createObjectURL(await res.blob());
76
+ }
77
+
78
+ export const FILE_TYPES = 'image/png,image/jpeg,image/gif,image/webp,application/pdf,text/plain';
79
+ export const isImage = (type) => /^image\//.test(String(type || ''));
80
+ const EXT = {'image/png': 'PNG', 'image/jpeg': 'JPG', 'image/gif': 'GIF', 'image/webp': 'WEBP', 'application/pdf': 'PDF', 'text/plain': 'TXT'};
81
+
82
+ /** 812 B, 14 KB, 1.2 MB. */
83
+ export function fileSize(n) {
84
+ const b = Number(n) || 0;
85
+ if (b < 1024) return `${b} B`;
86
+ if (b < 1024 * 1024) return `${Math.round(b / 1024)} KB`;
87
+ return `${(b / 1024 / 1024).toFixed(1)} MB`;
88
+ }
89
+
90
+ /**
91
+ * A list of files, bound to a `files` observable of fileRow()s. Text bindings only;
92
+ * the thumbnail is a blob: URL or nothing. No icons in the row - rows arrive after
93
+ * the panel's icon scan.
94
+ */
95
+ export const FILES = `
96
+ <ul class="fb-files" data-bind-hidden="!files.value.length" data-each="files.value key=key"><li class="fb-file" data-key="{{key}}" tabindex="0" role="button" data-bind-title="title">
97
+ <img class="fb-thumb" alt="" data-bind-src="thumb || false" data-bind-hidden="!thumb">
98
+ <span class="fb-file-ext" data-bind-hidden="thumb" data-bind-text="ext"></span>
99
+ <span class="fb-file-main"><strong data-bind-text="name"></strong><small data-bind-text="meta"></small></span>
100
+ <button type="button" class="fb-file-x" data-bind-hidden="!removable" aria-label="Remove this file" title="Remove">&times;</button>
101
+ </li></ul>`;
102
+
103
+ /** One row for FILES. */
104
+ export function fileRow(a, extra = {}) {
105
+ return {key: a.id || a.key, id: a.id || '', name: a.name, type: a.type, ext: EXT[a.type] || 'FILE', thumb: '',
106
+ meta: fileSize(a.size), title: `Open ${a.name}`, removable: false, ...extra};
107
+ }
108
+
109
+ /**
110
+ * Show a report's attached files in a panel: thumbnails for the images that are
111
+ * there, and click (or Enter) to open one in a new tab.
112
+ *
113
+ * @param {HTMLElement} host - an element holding FILES
114
+ * @param {object[]} list - attachments {id, name, type, size, stored?}
115
+ * @param {(a) => string} urlOf - where the file is fetched from
116
+ * @param {string} [missing] - said of a file that has not arrived yet
117
+ * @returns {{files, dispose: () => void}}
118
+ */
119
+ export function showFiles(host, list, urlOf, missing = 'not arrived yet') {
120
+ const urls = [];
121
+ const files = M.observable((list || []).map(a => fileRow(a, {meta: [fileSize(a.size), a.stored === false ? missing : ''].filter(Boolean).join(' · ')})));
122
+ const handle = M.applyBindings({files}, host);
123
+ let live = true;
124
+ for (const a of list || []) {
125
+ if (!isImage(a.type) || a.stored === false) continue;
126
+ blobUrl(urlOf(a)).then((u) => {
127
+ if (!live) { URL.revokeObjectURL(u); return; }
128
+ urls.push(u);
129
+ files.value = files.peek().map(r => (r.key === a.id ? {...r, thumb: u} : r));
130
+ }).catch(() => { /* no thumbnail; the row still opens */ });
131
+ }
132
+ const openOne = async (key) => {
133
+ const a = (list || []).find(x => x.id === key);
134
+ if (!a) return;
135
+ if (a.stored === false) { E.toast(`${a.name} has not arrived yet - it follows its report.`, {type: 'info'}); return; }
136
+ try {
137
+ const u = await blobUrl(urlOf(a));
138
+ urls.push(u);
139
+ window.open(u, '_blank', 'noopener');
140
+ } catch (err) { E.toast(err.message || String(err), {type: 'error'}); }
141
+ };
142
+ const onClick = (e) => { const li = e.target.closest('.fb-file'); if (li) openOne(li.dataset.key); };
143
+ const onKey = (e) => { const li = e.target.closest('.fb-file'); if (li && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); openOne(li.dataset.key); } };
144
+ host.addEventListener('click', onClick);
145
+ host.addEventListener('keydown', onKey);
146
+ return {
147
+ files,
148
+ dispose() {
149
+ live = false;
150
+ host.removeEventListener('click', onClick);
151
+ host.removeEventListener('keydown', onKey);
152
+ handle.dispose();
153
+ for (const u of urls.splice(0)) URL.revokeObjectURL(u);
154
+ }
155
+ };
156
+ }
157
+
52
158
  /** E.confirm and slideover titles render HTML: user text goes in escaped. */
53
159
  export const esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
54
160
 
@@ -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.7';
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(); }