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
@@ -22,6 +22,31 @@ const ROW = `
22
22
  <button type="button" class="bk-kebab" aria-label="Actions" title="Actions">⋮</button>
23
23
  </div>`;
24
24
 
25
+ // One weekday in the editor: its windows (a keyed list of their own) or "Closed".
26
+ const DAY_ROW = `
27
+ <div class="bk-day">
28
+ <span class="bk-day-name" data-bind-text="name"></span>
29
+ <div class="bk-windows">
30
+ <span class="bk-muted" data-bind-hidden="windows.value.length">Closed</span>
31
+ {{#each windows.value key=id}}<span class="bk-window">
32
+ <input type="time" class="form-input" step="300" data-model="from.value" data-bind-aria-label="$parent.name + ' from'">
33
+ <span>-</span>
34
+ <input type="time" class="form-input" step="300" data-model="to.value" data-bind-aria-label="$parent.name + ' to'">
35
+ <button type="button" class="btn btn-xs btn-ghost bk-x" title="Remove these hours" aria-label="Remove these hours" data-on-click="$root.dropWindow($parent, $data)"><span data-icon="x" data-icon-size="12"></span></button></span>{{/each}}
36
+ </div>
37
+ <button type="button" class="btn btn-xs btn-ghost" data-bind-title="'Add hours on ' + name" data-on-click="$parent.addWindow($data)"><span data-icon="plus" data-icon-size="12"></span> Hours</button>
38
+ </div>`;
39
+
40
+ // One spell of days off.
41
+ const OFF_ROW = `
42
+ <div class="bk-off">
43
+ <input type="date" class="form-input" data-model="from.value" aria-label="From" data-on-change="$parent.offFrom($data)">
44
+ <span>to</span>
45
+ <input type="date" class="form-input" data-model="to.value" aria-label="To">
46
+ <input class="form-input" data-model="label.value" placeholder="Holiday, training…" maxlength="80" aria-label="Why">
47
+ <button type="button" class="btn btn-xs btn-ghost bk-x" title="Remove" aria-label="Remove" data-on-click="$parent.dropOff($data)"><span data-icon="x" data-icon-size="12"></span></button>
48
+ </div>`;
49
+
25
50
  /** "Mon-Fri 09:00-17:00" style summary of weekly hours. */
26
51
  export function hoursSummary(hours) {
27
52
  const txt = (d) => (hours?.[d] || []).map(w => `${w.from}-${w.to}`).join(', ');
@@ -123,43 +148,43 @@ export async function mount(root, ctx) {
123
148
  const f = {};
124
149
  for (const k of ['name', 'kind', 'description', 'userId', 'email', 'colour']) f[k] = M.observable(start[k] ?? '');
125
150
  const bools = {calendarBusy: M.observable(start.calendarBusy !== false), active: M.observable(start.active !== false)};
126
- const hours = Object.fromEntries(DAY_KEYS.map(d => [d, (start.hours?.[d] || []).map(w => ({...w}))]));
127
- let daysOff = (start.daysOff || []).map(o => ({...o}));
151
+ // Hours and days off are keyed lists (see DAY_ROW / OFF_ROW): a day's
152
+ // windows live in an observable so adding or removing one redraws only
153
+ // that day; each field is an observable the inputs write through.
154
+ let seq = 0;
155
+ const windowRow = (w) => ({id: `w${++seq}`, from: M.observable(w.from || ''), to: M.observable(w.to || '')});
156
+ const offRow = (o) => ({id: `o${++seq}`, from: M.observable(o.from || ''), to: M.observable(o.to || ''), label: M.observable(o.label || '')});
157
+ const days = M.observable(WEEK.map(d => ({id: d, name: DAY_NAMES[d], windows: M.observable((start.hours?.[d] || []).map(windowRow))})));
158
+ const daysOff = M.observable((start.daysOff || []).map(offRow));
159
+ const hoursOut = () => Object.fromEntries(days.peek().map(d => [d.id, d.windows.peek().map(w => ({from: w.from.peek(), to: w.to.peek()}))]));
160
+ const daysOffOut = () => daysOff.peek().map(o => ({from: o.from.peek(), to: o.to.peek(), label: o.label.peek()}));
128
161
  const isPerson = M.computed(() => f.kind.value === 'person');
129
162
  const saving = M.observable(false);
130
- const snapshot = () => JSON.stringify([Object.values(f).map(o => o.peek()), Object.values(bools).map(o => o.peek()), hours, daysOff]);
163
+ const snapshot = () => JSON.stringify([Object.values(f).map(o => o.peek()), Object.values(bools).map(o => o.peek()), hoursOut(), daysOffOut()]);
131
164
  const opened = snapshot();
132
165
  let panel = null;
133
-
134
- const drawHours = (el) => {
135
- const box = el.querySelector('[data-part="hours"]');
136
- box.innerHTML = WEEK.map(d => `<div class="bk-day" data-day="${d}">
137
- <span class="bk-day-name">${DAY_NAMES[d]}</span>
138
- <div class="bk-windows">${hours[d].length ? hours[d].map((w, i) => `<span class="bk-window">
139
- <input type="time" class="form-input" step="300" value="${esc(w.from)}" data-i="${i}" data-k="from" aria-label="${DAY_NAMES[d]} from">
140
- <span>-</span>
141
- <input type="time" class="form-input" step="300" value="${esc(w.to)}" data-i="${i}" data-k="to" aria-label="${DAY_NAMES[d]} to">
142
- <button type="button" class="btn btn-xs btn-ghost bk-x" data-drop="${i}" title="Remove these hours" aria-label="Remove these hours"><span data-icon="x" data-icon-size="12"></span></button></span>`).join('') : '<span class="bk-muted">Closed</span>'}</div>
143
- <button type="button" class="btn btn-xs btn-ghost" data-add title="Add hours on ${DAY_NAMES[d]}"><span data-icon="plus" data-icon-size="12"></span> Hours</button>
144
- </div>`).join('');
145
- I.scan(box);
146
- };
147
- const drawDaysOff = (el) => {
148
- const box = el.querySelector('[data-part="daysoff"]');
149
- box.innerHTML = daysOff.length ? daysOff.map((o, i) => `<div class="bk-off" data-i="${i}">
150
- <input type="date" class="form-input" value="${esc(o.from)}" data-k="from" aria-label="From">
151
- <span>to</span>
152
- <input type="date" class="form-input" value="${esc(o.to)}" data-k="to" aria-label="To">
153
- <input class="form-input" value="${esc(o.label)}" data-k="label" placeholder="Holiday, training…" maxlength="80" aria-label="Why">
154
- <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('')
155
- : '<p class="bk-note">None. Add holidays and other days they cannot be booked.</p>';
156
- I.scan(box);
157
- };
166
+ // New rows carry icons; they are drawn once the list has rendered.
167
+ const rescan = () => { M.flushSync?.(); if (panel) { I.scan(panel.panel); requestAnimationFrame(() => panel?.panel.isConnected && I.scan(panel.panel)); } };
158
168
 
159
169
  panel = await openPanel({
160
170
  title: b ? `Edit ${b.name}` : 'Add a person or room',
161
171
  size: 'lg',
162
- model: {...f, saving, isPerson},
172
+ model: {
173
+ ...f, saving, isPerson, days, daysOff,
174
+ addWindow(day) {
175
+ const list = day.windows.peek();
176
+ const last = list[list.length - 1];
177
+ day.windows.value = [...list, windowRow(last ? {from: last.to.peek(), to: last.to.peek() < '17:00' ? '17:00' : last.to.peek()} : {from: '09:00', to: '17:00'})];
178
+ rescan();
179
+ },
180
+ dropWindow(day, w) { day.windows.value = day.windows.peek().filter(x => x !== w); },
181
+ dropOff(o) { daysOff.value = daysOff.peek().filter(x => x !== o); },
182
+ // An end before the start is never meant: it follows the start.
183
+ offFrom(o, e) {
184
+ const v = e?.target?.value ?? o.from.peek();
185
+ if (!o.to.peek() || o.to.peek() < v) o.to.value = v;
186
+ }
187
+ },
163
188
  dirty: () => snapshot() !== opened,
164
189
  html: `<div class="bk-form">
165
190
  <div class="bk-grid">
@@ -182,10 +207,11 @@ export async function mount(root, ctx) {
182
207
  </div>
183
208
  </div>
184
209
  <h4 class="bk-sub">Opening hours <span data-help-title="Opening hours" data-help="When they can be booked, in ${esc(ctx.tz)}. Add several windows for a day with a break (09:00-12:30, 13:30-17:00). A day with no hours is closed."></span></h4>
185
- <div class="bk-hours" data-part="hours"></div>
210
+ <div class="bk-hours" data-each="days.value key=id">${DAY_ROW}</div>
186
211
  <div class="bk-actions bk-actions-start"><button type="button" class="btn btn-xs btn-outline" data-on-click="copyMonday"><span data-icon="copy" data-icon-size="12"></span> Copy Monday to Tuesday-Friday</button></div>
187
212
  <h4 class="bk-sub">Days off <span data-help-title="Days off" data-help="Holidays and other days they cannot be booked. For days the whole business is closed, use Closed days in Booking settings."></span></h4>
188
- <div data-part="daysoff"></div>
213
+ <p class="bk-note" data-bind-hidden="daysOff.value.length">None. Add holidays and other days they cannot be booked.</p>
214
+ <div data-each="daysOff.value key=id">${OFF_ROW}</div>
189
215
  <div class="bk-actions bk-actions-start"><button type="button" class="btn btn-xs btn-outline" data-on-click="addOff"><span data-icon="plus" data-icon-size="12"></span> Add days off</button></div>
190
216
  <div class="bk-actions"><span class="bk-spacer"></span>
191
217
  <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
@@ -196,52 +222,24 @@ export async function mount(root, ctx) {
196
222
  box.checked = bools[box.dataset.bool].peek();
197
223
  box.addEventListener('change', () => { bools[box.dataset.bool].value = box.checked; });
198
224
  }
199
- drawHours(el);
200
- drawDaysOff(el);
201
- const hoursBox = el.querySelector('[data-part="hours"]');
202
- hoursBox.addEventListener('input', (e) => {
203
- const day = e.target.closest('[data-day]')?.dataset.day;
204
- if (!day || e.target.dataset.k === undefined) return;
205
- hours[day][Number(e.target.dataset.i)][e.target.dataset.k] = e.target.value;
206
- });
207
- hoursBox.addEventListener('click', (e) => {
208
- const day = e.target.closest('[data-day]')?.dataset.day;
209
- if (!day) return;
210
- const drop = e.target.closest('[data-drop]');
211
- if (drop) { hours[day].splice(Number(drop.dataset.drop), 1); drawHours(el); return; }
212
- if (e.target.closest('[data-add]')) {
213
- const last = hours[day][hours[day].length - 1];
214
- hours[day].push(last ? {from: last.to, to: last.to < '17:00' ? '17:00' : last.to} : {from: '09:00', to: '17:00'});
215
- drawHours(el);
216
- }
217
- });
218
- const offBox = el.querySelector('[data-part="daysoff"]');
219
- offBox.addEventListener('input', (e) => {
220
- const row = e.target.closest('[data-i]');
221
- if (!row) return;
222
- const o = daysOff[Number(row.dataset.i)];
223
- o[e.target.dataset.k] = e.target.value;
224
- if (e.target.dataset.k === 'from' && (!o.to || o.to < o.from)) { o.to = o.from; row.querySelector('[data-k="to"]').value = o.from; }
225
- });
226
- offBox.addEventListener('click', (e) => {
227
- const row = e.target.closest('[data-i]');
228
- if (row && e.target.closest('[data-drop]')) { daysOff.splice(Number(row.dataset.i), 1); drawDaysOff(el); }
229
- });
230
225
  },
231
226
  after: (el) => setTimeout(() => el.querySelector('input')?.focus(), 60),
232
227
  methods: {
233
228
  cancel: () => panel?.close(),
234
229
  copyMonday() {
235
- for (const d of ['tue', 'wed', 'thu', 'fri']) hours[d] = hours.mon.map(w => ({...w}));
236
- drawHours(panel.panel);
230
+ const byId = Object.fromEntries(days.peek().map(d => [d.id, d]));
231
+ for (const d of ['tue', 'wed', 'thu', 'fri']) byId[d].windows.value = byId.mon.windows.peek().map(w => windowRow({from: w.from.peek(), to: w.to.peek()}));
232
+ rescan();
237
233
  },
238
234
  addOff() {
239
- daysOff = [...daysOff, {from: boot.today, to: boot.today, label: ''}];
240
- drawDaysOff(panel.panel);
235
+ daysOff.value = [...daysOff.peek(), offRow({from: boot.today, to: boot.today, label: ''})];
236
+ rescan();
241
237
  },
242
238
  async save() {
243
239
  if (saving.peek()) return;
244
240
  // Say which hours are wrong rather than let the server drop them quietly.
241
+ const hours = hoursOut();
242
+ const daysOff = daysOffOut();
245
243
  for (const d of DAY_KEYS) {
246
244
  for (const w of hours[d]) {
247
245
  if (!(toMinutes(w.to) > toMinutes(w.from))) { E.toast(`${DAY_NAMES[d]}: ${w.from || '?'}-${w.to || '?'} - the end must be after the start.`, {type: 'warning'}); return; }
@@ -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,65 @@ 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).
176
+
177
+ ## 1.1.8 (2026-10-13) - a report deleted on the manager leaves the site too
178
+ Deleting a report in the inbox hid it from the site, but the site's outbox kept the sent item - its text and, through
179
+ `pruneFiles`' keep list, its screenshots - for good. `sync()` now calls `outbox.forgetDropped(remote)` whenever the
180
+ manager answered `/fleet/mine` (its list for the site is complete): a SENT item the manager no longer has is dropped
181
+ (`droppedByManager`, PURE, tested); waiting/refused items and an unreachable manager keep everything. The files go
182
+ at the next prune (older than a day, no longer named). Found on the dcm click-through.
@@ -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