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
@@ -114,6 +114,44 @@ test('a sale, from product to refund', async () => {
114
114
  assert.equal(sum.days30.orders, 0, 'a refunded sale is not revenue');
115
115
  });
116
116
 
117
+ test('a part refund keeps the order paid and remembers what went back; the rest closes it', async () => {
118
+ await call('PUT', '/settings', {offlineEnabled: true, countries: ['GB'], taxRate: 0, shippingFlat: 0});
119
+ let r = await call('POST', '/products', {name: 'Teapot', price: 2500, status: 'active', trackStock: true, stock: 3, shippable: false});
120
+ const pot = r.body;
121
+ r = await call('POST', '/checkout', {lines: [{productId: pot.id, qty: 1}], provider: 'offline', customer: {name: 'Bo', email: 'bo@example.com'}});
122
+ assert.equal(r.status, 200, JSON.stringify(r.body));
123
+ const id = r.body.orderId;
124
+ r = await call('PUT', `/orders/${id}/status`, {status: 'paid'});
125
+ assert.equal(r.status, 200, JSON.stringify(r.body));
126
+
127
+ // Too much, nothing, a fraction: refused before anything is written.
128
+ assert.equal((await call('POST', `/orders/${id}/refund`, {amount: 2600})).status, 400);
129
+ assert.equal((await call('POST', `/orders/${id}/refund`, {amount: 0})).status, 400);
130
+ assert.equal((await call('POST', `/orders/${id}/refund`, {amount: 12.5})).status, 400);
131
+
132
+ r = await call('POST', `/orders/${id}/refund`, {amount: 1000, note: 'lid chipped', restock: true});
133
+ assert.equal(r.status, 200, JSON.stringify(r.body));
134
+ assert.equal(r.body.order.data.status, 'paid', 'a part refund is not a move');
135
+ assert.deepEqual(r.body.refund, {amount: 1000, refunded: 1000, remaining: 1500, full: false});
136
+ assert.equal(r.body.order.data.refunded, 1000);
137
+ assert.equal(r.body.order.data.refunds[0].note, 'lid chipped');
138
+ assert.match(r.body.order.data.history.at(-1).note, /^£10\.00 marked refunded - lid chipped$/);
139
+ assert.equal((await call('GET', '/products')).body.find(p => p.id === pot.id).data.stock, 2, 'stock stays out on a part refund');
140
+ assert.equal((await call('POST', `/orders/${id}/refund`, {amount: 1501})).status, 400, 'only what is left');
141
+
142
+ // The rest: now it is refunded, with both refunds on it and the stock back.
143
+ r = await call('POST', `/orders/${id}/refund`, {restock: true});
144
+ assert.equal(r.status, 200, JSON.stringify(r.body));
145
+ assert.equal(r.body.order.data.status, 'refunded');
146
+ assert.deepEqual(r.body.order.data.refunds.map(x => x.amount), [1000, 1500]);
147
+ assert.equal(r.body.order.data.refunded, 2500);
148
+ assert.equal((await call('GET', '/products')).body.find(p => p.id === pot.id).data.stock, 3);
149
+ assert.equal((await call('POST', `/orders/${id}/refund`, {})).status, 409);
150
+
151
+ const sales = (await call('GET', '/sales?range=today')).body;
152
+ assert.equal(sales.totals.refundCount >= 2, true);
153
+ });
154
+
117
155
  test('free orders are paid at once; a provider that is off is refused', async () => {
118
156
  let r = await call('POST', '/products', {name: 'Free guide', price: 0, status: 'active', shippable: false});
119
157
  const guide = r.body;
@@ -325,3 +363,17 @@ test('review: links for emails never come from a request\'s Host header; two ref
325
363
  assert.deepEqual(both.map(r => r.status).sort(), [200, 409], JSON.stringify(both.map(r => r.body)));
326
364
  assert.equal((await reg.services.getOrder(order.id)).data.history.filter(h => h.status === 'refunded').length, 1);
327
365
  });
366
+
367
+ test('1.6.2: the badge is kept between writes, and any write (here a new product) refreshes it', async () => {
368
+ const {writeGeneration} = await import('../lib/shop.js');
369
+ const first = (await call('GET', '/count')).body;
370
+ const gen = writeGeneration();
371
+ assert.deepEqual((await call('GET', '/count')).body, first, 'the same minute answers the same');
372
+ assert.equal(writeGeneration(), gen, 'a read writes nothing');
373
+ const r = await call('POST', '/products', {name: 'Badge cup', price: 500, status: 'active'});
374
+ assert.equal(r.status, 201, JSON.stringify(r.body));
375
+ assert.ok(writeGeneration() > gen, 'the write moved the generation');
376
+ const after = (await call('GET', '/count')).body;
377
+ if (first.inventory) assert.equal(after.count, first.count + 1, 'the new product is counted at once');
378
+ await call('DELETE', `/products/${r.body.id}`);
379
+ });
@@ -83,7 +83,7 @@ test('commission: stored on the sale when paid, at that day\'s rate; returned wi
83
83
  const r = (await call('GET', '/sales?range=year')).body;
84
84
  assert.deepEqual([r.totals.commission, r.totals.commissionBack, r.totals.commissionNet], [110, 110, 0]);
85
85
  const csv = (await call('GET', '/sales.csv?range=year')).text;
86
- assert.ok(csv.split('\r\n')[0].endsWith('Commission,Commission rate'));
86
+ assert.ok(csv.split('\r\n')[0].endsWith('Commission,Commission rate,Refunded amount'));
87
87
  // The customer never sees it.
88
88
  const receipt = (await call('GET', `/order/${id}?t=${o.data.token}`)).body;
89
89
  assert.ok(!('commission' in receipt));
@@ -313,3 +313,19 @@ test('payments: Stripe refunds and sessions carry an Idempotency-Key, so a retry
313
313
  assert.equal(seen[2].init.headers['Idempotency-Key'], 'shop-session-o1');
314
314
  } finally { globalThis.fetch = realFetch; }
315
315
  });
316
+
317
+ test('admin: no form fields inside E.confirm (its message is sanitised; inputs vanish)', async () => {
318
+ const {readFileSync, readdirSync} = await import('node:fs');
319
+ const dir = new URL('../admin/views/', import.meta.url);
320
+ for (const f of readdirSync(dir).filter(n => n.endsWith('.js'))) {
321
+ const src = readFileSync(new URL(f, dir), 'utf8');
322
+ for (const m of src.matchAll(/E\.confirm\(\s*([A-Za-z_$][\w$]*)\s*[,)]/g)) {
323
+ // A variable passed to E.confirm: find its last assignment before the call and refuse form fields in it.
324
+ const before = src.slice(0, m.index);
325
+ const at = before.lastIndexOf(`${m[1]} =`);
326
+ const assigned = at < 0 ? '' : before.slice(at, at + 4000);
327
+ assert.ok(!/<(input|select|textarea)\b/.test(assigned), `${f}: E.confirm(${m[1]}) carries form fields`);
328
+ }
329
+ for (const m of src.matchAll(/E\.confirm\(`([^`]*)`/g)) assert.ok(!/<(input|select|textarea)\b/.test(m[1]), `${f}: E.confirm with form fields`);
330
+ }
331
+ });
@@ -0,0 +1,42 @@
1
+ import {test} from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {refundPlan, refundable, refundedSoFar, refundsOf} from '../lib/refunds.js';
4
+ import {salesReport} from '../lib/sales.js';
5
+
6
+ const paid = {status: 'paid', total: 2500, currency: 'GBP', paidAt: '2026-10-01T10:00:00Z'};
7
+
8
+ test('refundPlan: the rest by default, a part when asked, never more than is left', () => {
9
+ assert.deepEqual(refundPlan(paid), {amount: 2500, before: 0, after: 2500, remaining: 0, full: true});
10
+ assert.deepEqual(refundPlan(paid, 1000), {amount: 1000, before: 0, after: 1000, remaining: 1500, full: false});
11
+ const part = {...paid, refunds: [{at: '2026-10-02T10:00:00Z', amount: 1000}]};
12
+ assert.deepEqual(refundPlan(part, '1500'), {amount: 1500, before: 1000, after: 2500, remaining: 0, full: true});
13
+ assert.deepEqual(refundPlan(part), {amount: 1500, before: 1000, after: 2500, remaining: 0, full: true});
14
+ assert.throws(() => refundPlan(part, 1501), /Only 1500 is left/);
15
+ assert.throws(() => refundPlan(paid, 0), {status: 400});
16
+ assert.throws(() => refundPlan(paid, -5), {status: 400});
17
+ assert.throws(() => refundPlan(paid, 12.5), {status: 400});
18
+ assert.throws(() => refundPlan(paid, 'ten'), {status: 400});
19
+ assert.throws(() => refundPlan({...paid, refunds: [{amount: 2500}]}), {status: 409});
20
+ });
21
+
22
+ test('refunds: legacy whole-order refunds read as one refund of the total', () => {
23
+ const legacy = {status: 'refunded', total: 900, refundedAt: '2026-10-05T09:00:00Z'};
24
+ assert.deepEqual(refundsOf(legacy).map(r => [r.amount, r.at]), [[900, '2026-10-05T09:00:00Z']]);
25
+ assert.equal(refundedSoFar(legacy), 900);
26
+ assert.equal(refundable(legacy), 0);
27
+ assert.equal(refundable(paid), 2500);
28
+ assert.deepEqual(refundsOf({status: 'paid', total: 100}), []);
29
+ assert.throws(() => refundPlan(legacy), {status: 409});
30
+ });
31
+
32
+ test('sales: each part refund counts on its own day; the order stays a sale', () => {
33
+ const o = {id: 'o1', meta: {createdAt: '2026-10-01T10:00:00Z'}, data: {...paid, number: 1001, provider: 'stripe', lines: [{productId: 'mug', qty: 1, lineTotal: 2500}],
34
+ adjustments: [], subtotal: 2500, refunds: [{at: '2026-10-03T10:00:00Z', amount: 1000}, {at: '2026-10-06T10:00:00Z', amount: 500}], refunded: 1500}};
35
+ const r = salesReport([o], {from: '2026-10-01', to: '2026-10-07', by: 'day', timeZone: 'UTC'});
36
+ assert.deepEqual([r.totals.orders, r.totals.takings, r.totals.refunds, r.totals.refundCount, r.totals.net], [1, 2500, 1500, 2, 1000]);
37
+ const day = (d) => r.periods.find(p => p.period === d);
38
+ assert.equal(day('2026-10-03').refunds, 1000);
39
+ assert.equal(day('2026-10-06').refunds, 500);
40
+ const later = salesReport([o], {from: '2026-10-05', to: '2026-10-07', by: 'day', timeZone: 'UTC'});
41
+ assert.deepEqual([later.totals.orders, later.totals.refunds, later.totals.net], [0, 500, -500]);
42
+ });
@@ -82,3 +82,61 @@ Site: new replies (`info`), else open requests as an inventory. Manager: request
82
82
  - `docs/guide.md` added (the Documentation > Plugins page Feedback already had). The manifest entry carries
83
83
  `?v=1.1.2` and `V = '1.1.2'` loads the sub-views - bump both with the version. lib/receiver.js header corrected
84
84
  (fleet tokens are on disk since 2026-10-04).
85
+
86
+ ## 1.1.3 (2026-10-13 Tool sweep - caching)
87
+
88
+ - **`allTickets()` is cached 30 s** (`lib/cache.js`): the manager badge, `/tickets`, every site's `/fleet/mine`
89
+ (nudged each minute by its badge) and `byRef()` read every request. Every write in lib/receiver.js (`openNow`,
90
+ `customerTurnNow`, `dommaTurnNow`, `patchNow`, `removeTicket`) calls `forget()` after the write. A test that
91
+ writes the collection behind the plugin's back calls `forget()` (see "a customer message lost"). Test:
92
+ `tests/api.test.js` "the inbox memory".
93
+
94
+ ## 1.1.4 (2026-10-13) - attachments
95
+
96
+ Files on a customer's messages - the opening request and any follow-up - up to 3 per message, 2 MB each, PNG, JPEG,
97
+ GIF, WebP, PDF or plain text. The same design as Feedback 1.1.6 (read its CLAUDE.md note); Support carries its own
98
+ copy of the pure rules (lib/shape.js) and the store (lib/attachments.js) since either plugin can be installed alone.
99
+
100
+ - Not the CMS media API: that folder is public and needs `media.create`. Own upload route (`POST /attachments`,
101
+ multipart, mime whitelist, size cap) into `data/attachments/` (0600/0700), with a sidecar the send routes read
102
+ the file's description from - never the form.
103
+ - A message announces its files (`messages[].attachments[{id, name, type, size, stored}]`, set by `receivedTicket` /
104
+ `addTurn`); `flush()` sends the message, then each file raw to `POST /fleet/attach/:ticketRef/:attId` (token's
105
+ site only, announced file at the announced size; plugin-scoped `application/octet-stream` parser, own throttle).
106
+ The manager saves under `data/attachments/<ticket id>/` and `rx.markStored` (inside `serial()`) sets `stored`.
107
+ - **The outbox keeps a sent item until its files are done** (`filesDone`): `remember()` used to drop an item the
108
+ moment the manager had it, which would have lost files still to send. `outbox.files` lists every file sent (newest
109
+ 2000) so `fileIds()` keeps the site's copy - the desk thread shows files from this site's copy
110
+ (`GET /attachments/:id`); the inbox from `GET /tickets/:id/attachments/:att` (`manage`), listing a file still on
111
+ its way as such.
112
+ - Files go with words: a follow-up still needs text, and a close carries none. Domma's own replies cannot carry
113
+ files (not built).
114
+ - Thread: THREAD's row has a nested `{{#each files key=id}}`; `threadRows(messages, mine, {thumbs, available})`
115
+ builds it, `threadFiles()` fetches blob thumbnails into the `thumbs` observable, `fileKeys()` opens one on
116
+ click/Enter. The picker (`filePicker()`, compose and reply box) uploads on choose or paste and is bound on its own
117
+ host after the panel; its `room`/`uploading` are mirrored into the panel's model by an effect.
118
+ - Deleting a request deletes its folder. Tests: shape.test.js (rules, a message's files, outbox keeps an item until
119
+ its files go), api.test.js (request + follow-up files travel and open, refusals, offline wait).
120
+
121
+ ## 1.1.5 (2026-10-13) - settings cog
122
+
123
+ - Settings are read when used, per side (`cleanSettings(raw, current, side)`, `settingsErrors`, `SIDE_SETTINGS` in
124
+ lib/shape.js - emails by `EMAIL_RE`, the phone by the contact card's rule, an empty one allowed): the site's
125
+ `syncMinutes`; the manager's card (`email`, `phone`, `hours`, `responseTime`), `notifyEmail`, `perSiteMax`,
126
+ `perSiteMinutes`. A site still falls back to its own card (`allSettings()`) until the manager's arrives.
127
+ `GET/PUT /settings` answers this side only - a site's for `requireAdmin` (`send` without it), the manager's for
128
+ `manage`; 400 with `errors`; the save merges over the stored object. Tests inject `options.readSettings` /
129
+ `options.writeSettings` (tests/api.test.js's `managerSettings` is mutable - the flood test raises the limit live).
130
+ - The sync timer re-arms itself, asking for `syncMinutes` before each wait; a save re-arms it. The throttles
131
+ (`createThrottle()` takes `{max, windowMs}` per call) and the notify email read the live values. kit.js now loads
132
+ plugin-chrome for `addBannerAction`.
133
+ - `admin/lib/settings.js`: the cog, added by `support.js` once the mode is known, only when the silent
134
+ `GET /settings` answers; slideover through kit's `openPanel`, M bindings, data-help per field, discard guard,
135
+ Ctrl+Enter. Test: tests/settings.test.js.
136
+
137
+ ## 1.1.6 (2026-10-13) - a request deleted on the manager takes the site's files with it
138
+ The site keeps a copy of each file it sent (`outbox.files`, newest 2000) to draw its own thread, and the prune spared
139
+ every id on that list - so a request Domma deleted left its screenshots on the site for good. `remember(remote)` now
140
+ narrows `files` with `keepFiles(files, remote, items)` (PURE, tested): only ids in a message of a request the manager
141
+ still holds (its `/fleet/mine` is complete for the site) or named by an outbox item. The next prune (older than a day)
142
+ removes the rest. Found on the dcm click-through.
@@ -1,7 +1,7 @@
1
1
  /* Support - 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
- .sp [hidden], .sp-detail [hidden], .sp-form [hidden] { display: none !important; }
4
+ .sp [hidden], .sp-detail [hidden], .sp-form [hidden], .sp-files-host [hidden] { display: none !important; }
5
5
 
6
6
  .sp { max-width: 60rem; display: flex; flex-direction: column; gap: 1rem; }
7
7
  .sp-desk { display: flex; flex-direction: column; gap: 1rem; }
@@ -92,6 +92,21 @@
92
92
  .sp-msg-body { white-space: pre-wrap; overflow-wrap: anywhere; margin-top: .25rem; }
93
93
  .sp-reply, .sp-side { border-top: 1px solid var(--dm-border); padding-top: .8rem; }
94
94
 
95
+ /* Attached files: a thumbnail or the file's kind, its name and size */
96
+ .sp-attach { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; margin-top: .4rem; }
97
+ .sp-files { list-style: none; margin: .4rem 0 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr)); gap: .4rem; }
98
+ .sp-file { display: flex; align-items: center; gap: .55rem; padding: .35rem .5rem; border: 1px solid var(--dm-border); border-radius: var(--dm-radius-md);
99
+ background: var(--dm-surface); cursor: pointer; min-width: 0; }
100
+ .sp-file:hover, .sp-file:focus-visible { border-color: var(--dm-primary); outline: none; }
101
+ .sp-thumb { width: 2.6rem; height: 2.6rem; object-fit: cover; border-radius: var(--dm-radius-md); flex: none; }
102
+ .sp-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);
103
+ font-weight: 700; color: var(--dm-text-secondary); background: var(--dm-hover-bg); border-radius: var(--dm-radius-md); }
104
+ .sp-file-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
105
+ .sp-file-main strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--dm-font-size-sm); }
106
+ .sp-file-main small { color: var(--dm-text-secondary); font-size: var(--dm-font-size-xs); }
107
+ .sp-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); }
108
+ .sp-file-x:hover { background: var(--dm-hover-bg); color: var(--dm-danger); }
109
+
95
110
  @media (max-width: 640px) {
96
111
  .sp-choices.is-two { grid-template-columns: 1fr; }
97
112
  .sp-row { flex-wrap: wrap; }
@@ -13,20 +13,23 @@ export const BASE = '/api/plugins/support';
13
13
 
14
14
  let kit = null;
15
15
 
16
- /** tool-kit, plugin-chrome and help-popover, once, with stand-ins for an older admin. */
16
+ /** tool-kit, help-popover and plugin-chrome, once, with stand-ins for an older admin. */
17
17
  export function loadKit() {
18
18
  if (!kit) {
19
19
  kit = Promise.all([
20
20
  import('/admin/js/lib/tool-kit.js').catch(() => ({})),
21
- import('/admin/js/lib/help-popover.js').catch(() => ({}))
22
- ]).then(([tk, help]) => ({
21
+ import('/admin/js/lib/help-popover.js').catch(() => ({})),
22
+ import('/admin/js/lib/plugin-chrome.js').catch(() => ({}))
23
+ ]).then(([tk, help, chrome]) => ({
23
24
  onceClosed: tk.onceClosed || ((so, fn) => { const c = so.close.bind(so); so.close = (...a) => { const r = c(...a); fn?.(); return r; }; }),
24
25
  openMenuAt: tk.openMenuAt || ((el) => {
25
26
  const r = el.getBoundingClientRect();
26
27
  el.dispatchEvent(new MouseEvent('contextmenu', {bubbles: true, cancelable: true, clientX: r.left, clientY: r.bottom, view: window}));
27
28
  }),
28
29
  viewLifetime: tk.viewLifetime || (() => ({add() {}, reset() {}})),
29
- helpPopovers: help.helpPopovers || (() => () => {})
30
+ helpPopovers: help.helpPopovers || (() => () => {}),
31
+ // The banner's door (the settings cog, 1.1.5); an older admin has none and gets no cog.
32
+ addBannerAction: chrome.addBannerAction || (() => null)
30
33
  }));
31
34
  }
32
35
  return kit;
@@ -39,7 +42,7 @@ export const api = {
39
42
  // site
40
43
  tickets: () => H.get(`${BASE}/tickets`),
41
44
  open: (d) => H.post(`${BASE}/tickets`, d),
42
- write: (ref, body) => H.post(`${BASE}/tickets/${seg(ref)}/messages`, {body}),
45
+ write: (ref, body, attachments = []) => H.post(`${BASE}/tickets/${seg(ref)}/messages`, {body, ...(attachments.length && {attachments})}),
43
46
  close: (ref) => H.post(`${BASE}/tickets/${seg(ref)}/close`, {}),
44
47
  seen: (ref, at) => H.put(`${BASE}/tickets/${seg(ref)}/seen`, {at}),
45
48
  /** `at`: Domma's latest reply as the screen showed it (omitted for an offline copy, which has none). */
@@ -48,9 +51,102 @@ export const api = {
48
51
  ticket: (id) => H.get(`${BASE}/tickets/${seg(id)}`),
49
52
  reply: (id, d) => H.post(`${BASE}/tickets/${seg(id)}/reply`, d),
50
53
  update: (id, d) => H.put(`${BASE}/tickets/${seg(id)}`, d),
51
- remove: (id) => H.delete(`${BASE}/tickets/${seg(id)}`)
54
+ remove: (id) => H.delete(`${BASE}/tickets/${seg(id)}`),
55
+ /** Where a file is read from: this site's own copy, or a request's on the manager. */
56
+ ownFileUrl: (id) => `${BASE}/attachments/${seg(id)}`,
57
+ ticketFileUrl: (id, att) => `${BASE}/tickets/${seg(id)}/attachments/${seg(att)}`
52
58
  };
53
59
 
60
+ // ---------------------------------------------------------------------------------
61
+ // Attachments
62
+ // ---------------------------------------------------------------------------------
63
+
64
+ const authHeaders = () => { const t = (() => { try { return S.get('auth_token'); } catch { return ''; } })(); return t ? {Authorization: `Bearer ${t}`} : {}; };
65
+
66
+ /** Upload one file for the message being written. H has no multipart helper, so this is the admin's own upload pattern. */
67
+ export async function uploadFile(file) {
68
+ const fd = new FormData();
69
+ fd.append('file', file, file.name);
70
+ const res = await fetch(`${BASE}/attachments`, {method: 'POST', headers: authHeaders(), body: fd});
71
+ const body = await res.json().catch(() => ({}));
72
+ if (!res.ok) throw new Error(body.error || `Upload failed (${res.status}).`);
73
+ return body;
74
+ }
75
+
76
+ /** A file behind the session as a blob: URL (an <img src> or a new tab cannot send the token). Revoke it when done. */
77
+ export async function blobUrl(url) {
78
+ const res = await fetch(url, {headers: authHeaders()});
79
+ if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || `Could not open the file (${res.status}).`);
80
+ return URL.createObjectURL(await res.blob());
81
+ }
82
+
83
+ export const FILE_TYPES = 'image/png,image/jpeg,image/gif,image/webp,application/pdf,text/plain';
84
+ export const isImage = (type) => /^image\//.test(String(type || ''));
85
+ const EXT = {'image/png': 'PNG', 'image/jpeg': 'JPG', 'image/gif': 'GIF', 'image/webp': 'WEBP', 'application/pdf': 'PDF', 'text/plain': 'TXT'};
86
+
87
+ /** 812 B, 14 KB, 1.2 MB. */
88
+ export function fileSize(n) {
89
+ const b = Number(n) || 0;
90
+ if (b < 1024) return `${b} B`;
91
+ if (b < 1024 * 1024) return `${Math.round(b / 1024)} KB`;
92
+ return `${(b / 1024 / 1024).toFixed(1)} MB`;
93
+ }
94
+
95
+ /** One row of a file list (the picker's, or a message's). */
96
+ export function fileRow(a, {thumb = '', note = '', removable = false, key} = {}) {
97
+ return {key: key || a.id, id: a.id || '', name: a.name, type: a.type, ext: EXT[a.type] || 'FILE', thumb: thumb || '',
98
+ meta: [fileSize(a.size), note].filter(Boolean).join(' · '), removable};
99
+ }
100
+
101
+ /** The compose panel's picked files, bound to a `files` observable of fileRow()s. Text bindings only, no icons (rows arrive after the icon scan). */
102
+ export const FILES = `
103
+ <ul class="sp-files" data-bind-hidden="!files.value.length" data-each="files.value key=key"><li class="sp-file" data-key="{{key}}" data-bind-title="name">
104
+ <img class="sp-thumb" alt="" data-bind-src="thumb || false" data-bind-hidden="!thumb">
105
+ <span class="sp-file-ext" data-bind-hidden="thumb" data-bind-text="ext"></span>
106
+ <span class="sp-file-main"><strong data-bind-text="name"></strong><small data-bind-text="meta"></small></span>
107
+ <button type="button" class="sp-file-x" data-bind-hidden="!removable" aria-label="Remove this file" title="Remove">&times;</button>
108
+ </li></ul>`;
109
+
110
+ /**
111
+ * Thumbnails and opening for the files in a thread. The thread's computed reads
112
+ * `thumbs.value`, so a thumbnail shows as soon as its blob arrives.
113
+ *
114
+ * @param {(a) => string} urlOf
115
+ * @param {(a) => boolean} [available] - false for a file that has not reached this server yet
116
+ */
117
+ export function threadFiles(urlOf, available = () => true) {
118
+ const thumbs = M.observable({});
119
+ const urls = [];
120
+ const asked = new Set();
121
+ let live = true;
122
+ return {
123
+ thumbs,
124
+ /** Fetch the thumbnails not yet asked for. */
125
+ load(messages = []) {
126
+ for (const a of messages.flatMap(m => m.attachments || [])) {
127
+ if (!isImage(a.type) || !available(a) || asked.has(a.id)) continue;
128
+ asked.add(a.id);
129
+ blobUrl(urlOf(a)).then((u) => {
130
+ if (!live) { URL.revokeObjectURL(u); return; }
131
+ urls.push(u);
132
+ thumbs.value = {...thumbs.peek(), [a.id]: u};
133
+ }).catch(() => { /* no thumbnail; the row still opens */ });
134
+ }
135
+ },
136
+ /** Open one in a new tab. */
137
+ async open(a) {
138
+ if (!a) return;
139
+ if (!available(a)) { E.toast(`${esc(a.name)} has not arrived yet - it follows its message.`, {type: 'info'}); return; }
140
+ try {
141
+ const u = await blobUrl(urlOf(a));
142
+ urls.push(u);
143
+ window.open(u, '_blank', 'noopener');
144
+ } catch (err) { E.toast(esc(err.message || String(err)), {type: 'error'}); }
145
+ },
146
+ dispose() { live = false; for (const u of urls.splice(0)) URL.revokeObjectURL(u); }
147
+ };
148
+ }
149
+
54
150
  /** Some routes send a bare array, some an object. */
55
151
  export const listOf = (res, key) => res?.[key] ?? (Array.isArray(res) ? res : []);
56
152
 
@@ -97,24 +193,46 @@ export const TONE = {open: 'warning', answered: 'info', closed: 'success', waiti
97
193
  * The conversation, oldest first, as rows for THREAD. `mine` says which side is
98
194
  * "us" (customer on a site, domma on the manager).
99
195
  */
100
- export function threadRows(messages = [], mine) {
196
+ export function threadRows(messages = [], mine, {thumbs = {}, available = () => true, missing = 'on its way'} = {}) {
101
197
  return messages.map((m, i) => ({
102
198
  key: m.id || `m${i}`,
103
199
  cls: `${m.from === mine ? 'is-mine' : 'is-theirs'}${m.from === 'domma' ? ' is-domma' : ''}`,
104
200
  who: m.from === 'domma' ? `${m.name || 'Domma'} · Domma Support` : (m.name || 'Customer'),
105
201
  when: `${stamp(m.at)}${m.pending ? ' · waiting to send' : ''}${m.refused ? ' · not accepted' : ''}`,
106
- body: m.body || ''
202
+ body: m.body || '',
203
+ files: (m.attachments || []).map(a => fileRow(a, {thumb: thumbs[a.id], note: available(a) ? '' : missing})),
204
+ hasFiles: Boolean((m.attachments || []).length)
107
205
  }));
108
206
  }
109
207
 
208
+ /** A message's file, by id - for the thread's click handler. */
209
+ export const findFile = (messages = [], id) => messages.flatMap(m => m.attachments || []).find(a => a.id === id) || null;
210
+
110
211
  /** The thread, bound to a `thread` observable of threadRows(). Text bindings only: nothing is parsed as HTML. */
111
212
  export const THREAD = `
112
213
  <p class="sp-note" data-bind-hidden="thread.value.length">Nothing here yet.</p>
113
214
  <ol class="sp-thread" data-each="thread.value key=key"><li class="sp-msg" data-bind-class="cls" data-key="{{key}}" tabindex="-1">
114
215
  <div class="sp-msg-head"><strong data-bind-text="who"></strong><small data-bind-text="when"></small></div>
115
216
  <div class="sp-msg-body" data-bind-text="body"></div>
217
+ <ul class="sp-files" data-bind-hidden="!hasFiles">{{#each files key=id}}<li class="sp-file" data-att="{{id}}" tabindex="0" role="button" data-bind-title="name">
218
+ <img class="sp-thumb" alt="" data-bind-src="thumb || false" data-bind-hidden="!thumb">
219
+ <span class="sp-file-ext" data-bind-hidden="thumb">{{ext}}</span>
220
+ <span class="sp-file-main"><strong>{{name}}</strong><small>{{meta}}</small></span>
221
+ </li>{{/each}}</ul>
116
222
  </li></ol>`;
117
223
 
224
+ /**
225
+ * Click or Enter on a file in the thread opens it.
226
+ * @returns {Function} removes the listeners
227
+ */
228
+ export function fileKeys(box, open) {
229
+ const onClick = (e) => { const li = e.target.closest('.sp-file[data-att]'); if (li) { e.stopPropagation(); open(li.dataset.att); } };
230
+ const onKey = (e) => { const li = e.target.closest('.sp-file[data-att]'); if (li && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); e.stopPropagation(); open(li.dataset.att); } };
231
+ box.addEventListener('click', onClick);
232
+ box.addEventListener('keydown', onKey);
233
+ return () => { box.removeEventListener('click', onClick); box.removeEventListener('keydown', onKey); };
234
+ }
235
+
118
236
  /** Copy text, with a toast either way. */
119
237
  export async function copyText(text, what = 'Copied') {
120
238
  try {
@@ -166,3 +284,69 @@ export async function openPanel({title, size = 'md', html, model = {}, methods =
166
284
  so.open();
167
285
  return {so, panel, close: () => so.close()};
168
286
  }
287
+
288
+ /**
289
+ * The picker for files going with a message: chosen with the button or pasted
290
+ * into the text box, uploaded at once (so Send only names them), shown with a
291
+ * thumbnail and a remove button. Bound on its own host, after the panel's own
292
+ * bindings - the panel's model has no `files`.
293
+ *
294
+ * @param {{host: HTMLElement, input: HTMLInputElement, paste?: HTMLElement, caps: {max: number, maxBytes: number}}} o
295
+ */
296
+ export function filePicker({host, input, paste, caps}) {
297
+ const picked = M.observable([]);
298
+ const room = M.computed(() => picked.value.length < caps.max);
299
+ const uploading = M.computed(() => picked.value.some(x => x.busy));
300
+ const files = M.computed(() => picked.value.map(x => fileRow(x, {key: x.key, thumb: x.thumb, removable: !x.busy, note: x.busy ? 'uploading...' : ''})));
301
+ const localUrls = [];
302
+ host.innerHTML = FILES;
303
+ const bound = M.applyBindings({files}, host);
304
+ const onRemove = (e) => {
305
+ const x = e.target.closest('.sp-file-x');
306
+ if (x) picked.value = picked.peek().filter(r => r.key !== x.closest('.sp-file').dataset.key);
307
+ };
308
+ host.addEventListener('click', onRemove);
309
+ async function add(list) {
310
+ for (const file of list) {
311
+ if (picked.peek().length >= caps.max) { E.toast(`Up to ${caps.max} files a message.`, {type: 'warning'}); break; }
312
+ if (file.size > caps.maxBytes) { E.toast(`${esc(file.name)} is too big - up to ${fileSize(caps.maxBytes)} each.`, {type: 'warning'}); continue; }
313
+ if (!FILE_TYPES.split(',').includes(file.type)) { E.toast(`${esc(file.name)} cannot be attached - images, PDF or text only.`, {type: 'warning'}); continue; }
314
+ const key = `k${Date.now()}${Math.random().toString(16).slice(2)}`;
315
+ let thumb = '';
316
+ if (isImage(file.type)) { thumb = URL.createObjectURL(file); localUrls.push(thumb); }
317
+ picked.value = [...picked.peek(), {key, name: file.name, type: file.type, size: file.size, thumb, busy: true}];
318
+ try {
319
+ const a = await uploadFile(file);
320
+ picked.value = picked.peek().map(r => (r.key === key ? {...r, ...a, key, busy: false} : r));
321
+ } catch (err) {
322
+ picked.value = picked.peek().filter(r => r.key !== key);
323
+ E.toast(esc(err.message || String(err)), {type: 'error'});
324
+ }
325
+ }
326
+ }
327
+ const onChange = () => { const list = [...input.files]; input.value = ''; add(list); };
328
+ // A screenshot pasted into the message is the quickest way there is.
329
+ const onPaste = (e) => {
330
+ const list = [...(e.clipboardData?.files || [])];
331
+ if (!list.length) return;
332
+ e.preventDefault();
333
+ const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
334
+ add(list.map(f => (f.name && f.name !== 'image.png' ? f : new File([f], `screenshot-${stamp}.png`, {type: f.type}))));
335
+ };
336
+ input.addEventListener('change', onChange);
337
+ paste?.addEventListener('paste', onPaste);
338
+ return {
339
+ room, uploading,
340
+ /** What Send names: the uploaded ones. */
341
+ attachments: () => picked.peek().filter(x => x.id && !x.busy).map(x => ({id: x.id, name: x.name, type: x.type, size: x.size})),
342
+ clear() { picked.value = []; },
343
+ dispose() {
344
+ host.removeEventListener('click', onRemove);
345
+ input.removeEventListener('change', onChange);
346
+ paste?.removeEventListener('paste', onPaste);
347
+ bound.dispose();
348
+ [room, uploading, files].forEach(c => c.dispose?.());
349
+ for (const u of localUrls.splice(0)) URL.revokeObjectURL(u);
350
+ }
351
+ };
352
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Support - the settings cog (1.1.5). 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 the contact card every site shows, where new requests are
6
+ * emailed, and how many messages one site may send (`manage`)
7
+ *
8
+ * They used to be reachable only through Marketplace > Configure and were read
9
+ * at boot; the server now reads them when used, so a save here is live - the
10
+ * card reaches each site with its next sync.
11
+ *
12
+ * @module support/admin/lib/settings
13
+ */
14
+ import {BASE, esc, openPanel} from './kit.js';
15
+
16
+ /** As lib/shape.js checks them; the server checks again. */
17
+ const EMAIL_RE = /^[^\s@<>"]+@[^\s@<>"]+\.[^\s@<>"]+$/;
18
+ const PHONE_RE = /^[+\d][\d\s().-]{4,}$/;
19
+ const NUM = {syncMinutes: {min: 1, max: 1440}, perSiteMax: {min: 1, max: 1000}, perSiteMinutes: {min: 1, max: 1440}};
20
+
21
+ const FIELDS = {
22
+ sender: [
23
+ {key: 'syncMinutes', kind: 'number', label: 'Check for replies every (minutes)',
24
+ help: 'How often this site sends any message still waiting and looks for Domma Support\'s replies. Opening Support always checks at once; this is the background check. 1 to 1440 (a day).'}
25
+ ],
26
+ receiver: [
27
+ {key: 'email', kind: 'email', label: 'Support email', max: 200,
28
+ help: 'Shown on every site\'s contact card, and the address replies to customers come from. Empty hides the line.'},
29
+ {key: 'phone', kind: 'phone', label: 'Support phone', max: 40,
30
+ help: 'Shown on every site\'s contact card as a tap-to-call link. Digits, spaces and + ( ) . - only. Empty hides the line.'},
31
+ {key: 'hours', kind: 'text', label: 'Opening hours', max: 200,
32
+ help: 'One line on the contact card, for example "Monday to Friday, 9am to 5pm (UK time)".'},
33
+ {key: 'responseTime', kind: 'text', label: 'How fast you answer', max: 300,
34
+ help: 'One line on the contact card, for example "We answer within one working day".'},
35
+ {key: 'notifyEmail', kind: 'email', label: 'Email new requests to', max: 200,
36
+ help: 'Every new request and every customer reply is emailed here, as well as appearing in the Inbox. Empty emails nobody.'},
37
+ {key: 'perSiteMax', kind: 'number', label: 'Messages per site',
38
+ help: 'The most messages one site may send within the window below; more are turned away for now and the site sends them later.'},
39
+ {key: 'perSiteMinutes', kind: 'number', label: 'Window (minutes)',
40
+ help: 'The window the limit above counts in, in minutes.'}
41
+ ]
42
+ };
43
+
44
+ const problem = (kind, v, key) => {
45
+ const s = String(v ?? '').trim();
46
+ if (kind === 'number') { const n = Number(s); const r = NUM[key]; return s !== '' && Number.isInteger(n) && n >= r.min && n <= r.max ? '' : `A whole number from ${r.min} to ${r.max}.`; }
47
+ if (kind === 'email') return !s || EMAIL_RE.test(s) ? '' : 'One email address, or leave it empty.';
48
+ if (kind === 'phone') return !s || PHONE_RE.test(s) ? '' : 'Digits, spaces and + ( ) . - only.';
49
+ return '';
50
+ };
51
+
52
+ /** Add the cog when this user may change this side's settings (asked once, silently). */
53
+ export async function addSettingsCog($container, kit) {
54
+ let got = null;
55
+ try { got = await H.request('GET', `${BASE}/settings`, null, {silent: true}); } catch { return; }
56
+ if (!got?.side) return;
57
+ kit.addBannerAction($container, {id: 'support-settings', icon: 'settings', title: 'Support settings', onClick: () => openSettings()});
58
+ }
59
+
60
+ export async function openSettings() {
61
+ let got;
62
+ try { got = await H.get(`${BASE}/settings`); } catch (err) { E.toast(`Settings not loaded: ${esc(err?.message || err)}`, {type: 'error'}); return; }
63
+ const fields = FIELDS[got.side] || [];
64
+ const f = Object.fromEntries(fields.map(x => [x.key, M.observable(String(got.settings?.[x.key] ?? ''))]));
65
+ const values = () => Object.fromEntries(fields.map(x => [x.key, x.kind === 'number' ? Number(f[x.key].peek()) : f[x.key].peek().trim()]));
66
+ const opened = JSON.stringify(values());
67
+ const saving = M.observable(false);
68
+ const errs = Object.fromEntries(fields.map(x => [`${x.key}Err`, M.computed(() => problem(x.kind, f[x.key].value, x.key))]));
69
+ const model = {...f, ...errs, saving, cannot: M.computed(() => saving.value || Object.values(errs).some(e => e.value))};
70
+ const input = (x) => {
71
+ const type = x.kind === 'number' ? `type="number" min="${NUM[x.key].min}" max="${NUM[x.key].max}" step="1"`
72
+ : x.kind === 'email' ? 'type="email"' : x.kind === 'phone' ? 'type="tel"' : 'type="text"';
73
+ return `<input id="sp-set-${x.key}" ${type} class="form-input"${x.max ? ` maxlength="${x.max}"` : ''} data-model="${x.key}.value">`;
74
+ };
75
+ const html = `
76
+ <div class="sp-form">
77
+ ${got.side === 'receiver' ? '<p class="sp-note">The contact card reaches every site with its next sync.</p>' : ''}
78
+ ${fields.map(x => `
79
+ <label class="form-label" for="sp-set-${x.key}">${x.label} <span data-help="${esc(x.help)}"></span></label>
80
+ ${input(x)}
81
+ <small class="sp-note is-bad" data-bind-hidden="!${x.key}Err.value" data-bind-text="${x.key}Err.value"></small>`).join('')}
82
+ <p class="sp-note">Saved settings take effect at once - no restart.</p>
83
+ <div class="sp-actions">
84
+ <span class="sp-spacer"></span>
85
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
86
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="cannot.value">Save</button>
87
+ </div>
88
+ </div>`;
89
+ let panel = null;
90
+ let closed = false;
91
+ let asking = false;
92
+ const methods = {
93
+ cancel: () => panel?.so.close(),
94
+ async save() {
95
+ if (model.cannot.peek()) return;
96
+ saving.value = true;
97
+ try {
98
+ await H.put(`${BASE}/settings`, values());
99
+ E.toast('Support settings saved.', {type: 'success'});
100
+ panel.so.close(true);
101
+ } catch (err) {
102
+ E.toast(`Not saved: ${esc(err?.message || err)}`, {type: 'error'});
103
+ } finally {
104
+ saving.value = false;
105
+ }
106
+ }
107
+ };
108
+ panel = await openPanel({title: 'Support settings', size: 'md', html, model, methods});
109
+ // Closing with a change asks first - once (onceClosed calls close() again, argument-less).
110
+ const close = panel.so.close.bind(panel.so);
111
+ panel.so.close = async (force) => {
112
+ if (!closed && force !== true && JSON.stringify(values()) !== opened) {
113
+ if (asking) return undefined;
114
+ asking = true;
115
+ const ok = await E.confirm('Discard what you have entered?');
116
+ asking = false;
117
+ if (!ok) return undefined;
118
+ }
119
+ closed = true;
120
+ return close();
121
+ };
122
+ panel.panel.addEventListener('keydown', (e) => { if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); methods.save(); } });
123
+ }