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
@@ -47,9 +47,10 @@ export function createOutbox(dataDir) {
47
47
  seen: j.seen && typeof j.seen === 'object' ? j.seen : {},
48
48
  tickets: Array.isArray(j.tickets) ? j.tickets : [],
49
49
  contact: j.contact && typeof j.contact === 'object' ? j.contact : null,
50
- shelf: j.shelf && typeof j.shelf === 'object' ? j.shelf : {}
50
+ shelf: j.shelf && typeof j.shelf === 'object' ? j.shelf : {},
51
+ files: Array.isArray(j.files) ? j.files : []
51
52
  };
52
- } catch { return {items: [], seen: {}, tickets: [], contact: null, shelf: {}}; }
53
+ } catch { return {items: [], seen: {}, tickets: [], contact: null, shelf: {}, files: []}; }
53
54
  };
54
55
  const write = (state) => {
55
56
  fs.mkdirSync(dataDir, {recursive: true});
@@ -63,6 +64,26 @@ export function createOutbox(dataDir) {
63
64
  tickets: () => read().tickets,
64
65
  contact: () => read().contact,
65
66
  shelf: () => read().shelf,
67
+ /**
68
+ * Every file id this site still needs: named by an item not done with, or already sent (the
69
+ * thread shows them from this site's copy long after the item itself has gone).
70
+ */
71
+ fileIds: () => { const s = read(); return new Set([...s.files, ...s.items.flatMap(i => (i.payload?.attachments || []).map(a => a.id))]); },
72
+ /** A file reached the manager: keep it (the newest 2000), and note it on its item. */
73
+ fileSent(ref, id) {
74
+ update(s => {
75
+ const item = s.items.find(i => i.ref === ref);
76
+ if (item) item.filesSent = [...new Set([...(item.filesSent || []), id])];
77
+ s.files = [...s.files.filter(x => x !== id), id].slice(-2000);
78
+ });
79
+ },
80
+ /** A file the manager will never take (or this site lost): stop trying it. */
81
+ fileRefused(ref, id) {
82
+ update(s => {
83
+ const item = s.items.find(i => i.ref === ref);
84
+ if (item) item.filesRefused = [...new Set([...(item.filesRefused || []), id])];
85
+ });
86
+ },
66
87
  /**
67
88
  * Archive or remove a request from this site's list (`state` null puts it back). Remembers
68
89
  * Domma's latest reply at the time, and marks it read: what is put away is not "new".
@@ -122,7 +143,7 @@ export function createOutbox(dataDir) {
122
143
  const have = new Set(remote.flatMap(t => [t.ref, ...(t.messages || []).map(m => m.id)]));
123
144
  s.items = s.items.filter((i) => {
124
145
  if (i.state !== 'sent') return true;
125
- if (have.has(i.ref)) return false; // arrived: done with it
146
+ if (have.has(i.ref)) return !filesDone(i); // arrived: done with it, once its files have gone
126
147
  const t = byRef.get(i.kind === 'ticket' ? i.ref : i.ticketRef);
127
148
  if (!t) return true; // not in the view yet
128
149
  if (i.kind === 'message' && !i.payload?.body) return false; // a close: no message to look for
@@ -136,6 +157,12 @@ export function createOutbox(dataDir) {
136
157
  };
137
158
  }
138
159
 
160
+ /** Has every file an item announced been sent, or given up on? */
161
+ const filesDone = (i) => {
162
+ const done = new Set([...(i.filesSent || []), ...(i.filesRefused || [])]);
163
+ return (i.payload?.attachments || []).every(a => done.has(a.id));
164
+ };
165
+
139
166
  /**
140
167
  * Talk to the manager. `fetchImpl` is swapped in tests; nothing here throws -
141
168
  * every call answers `{ok, status?, body?, error?}`.
@@ -157,10 +184,28 @@ export function managerApi(manager, fetchImpl = globalThis.fetch) {
157
184
  return {ok: false, error: `Could not reach the Domma server: ${err.message}`};
158
185
  }
159
186
  };
187
+ /** One attached file's bytes, after its message: raw, so the JSON routes' 32 KB limit never applies to them. */
188
+ const attach = async (ticketRef, attId, buffer) => {
189
+ try {
190
+ const res = await fetchImpl(`${manager.url}/api/plugins/support/fleet/attach/${encodeURIComponent(ticketRef)}/${encodeURIComponent(attId)}`, {
191
+ method: 'POST',
192
+ headers: {authorization: `Bearer ${manager.token}`, 'content-type': 'application/octet-stream'},
193
+ body: buffer,
194
+ signal: AbortSignal.timeout(30_000)
195
+ });
196
+ let json = null;
197
+ try { json = await res.json(); } catch { /* not JSON */ }
198
+ if (res.status >= 200 && res.status < 300) return {ok: true, status: res.status, body: json};
199
+ return {ok: false, status: res.status, error: json?.error || `The Domma server answered ${res.status}.`};
200
+ } catch (err) {
201
+ return {ok: false, error: `Could not reach the Domma server: ${err.message}`};
202
+ }
203
+ };
160
204
  return {
161
205
  open: (payload) => call('POST', 'tickets', payload),
162
206
  message: (payload) => call('POST', 'messages', payload),
163
- mine: () => call('GET', 'mine')
207
+ mine: () => call('GET', 'mine'),
208
+ attach
164
209
  };
165
210
  }
166
211
 
@@ -170,17 +215,40 @@ export function managerApi(manager, fetchImpl = globalThis.fetch) {
170
215
  *
171
216
  * @returns {Promise<{sent: number, waiting: number, error: string}>}
172
217
  */
173
- export function flush(outbox, api) {
218
+ export function flush(outbox, api, files = null) {
174
219
  // One flush at a time per outbox: the timer, the badge's nudge, a screen load and a send can
175
220
  // all ask at once, and two flushes would POST the same waiting item twice.
176
- if (!outbox.flushing) outbox.flushing = flushOnce(outbox, api).finally(() => { outbox.flushing = null; });
221
+ if (!outbox.flushing) outbox.flushing = flushOnce(outbox, api, files).finally(() => { outbox.flushing = null; });
177
222
  return outbox.flushing;
178
223
  }
179
224
 
180
225
  /** Statuses that mean "try again later"; any other 4xx means the manager will never take it. */
181
226
  const RETRY = new Set([401, 408, 429]);
182
227
 
183
- async function flushOnce(outbox, api) {
228
+ /**
229
+ * The files of messages that have arrived but whose files have not all followed.
230
+ * A file the manager refuses for good (400/404/413), or one this site no longer
231
+ * has, is not tried again - the words are there either way.
232
+ */
233
+ async function sendFiles(outbox, api, files) {
234
+ let error = '';
235
+ for (const item of outbox.all().filter(i => i.state === 'sent' && !filesDone(i))) {
236
+ const done = new Set([...(item.filesSent || []), ...(item.filesRefused || [])]);
237
+ for (const a of item.payload.attachments.filter(x => !done.has(x.id))) {
238
+ const buffer = files?.read(a);
239
+ if (!buffer) { outbox.fileRefused(item.ref, a.id); continue; }
240
+ const r = await api.attach(item.ticketRef, a.id, buffer);
241
+ if (r.ok) outbox.fileSent(item.ref, a.id);
242
+ else {
243
+ error = r.error || 'A file was not sent.';
244
+ if (r.status >= 400 && r.status < 500 && !RETRY.has(r.status)) outbox.fileRefused(item.ref, a.id);
245
+ }
246
+ }
247
+ }
248
+ return error;
249
+ }
250
+
251
+ async function flushOnce(outbox, api, files) {
184
252
  let sent = 0;
185
253
  let error = '';
186
254
  for (const queued of outbox.all().filter(i => i.state === 'waiting')) {
@@ -204,7 +272,9 @@ async function flushOnce(outbox, api) {
204
272
  outbox.mark(item.ref, {lastError: error, triedAt: new Date().toISOString(), ...(permanent && {state: 'refused'})});
205
273
  }
206
274
  }
207
- return {sent, waiting: outbox.all().filter(i => i.state === 'waiting').length, error};
275
+ // Then the files, each on its own request once its message is on the manager.
276
+ const fileError = api.attach ? await sendFiles(outbox, api, files) : '';
277
+ return {sent, waiting: outbox.all().filter(i => i.state === 'waiting').length, error: error || fileError};
208
278
  }
209
279
 
210
280
  /**
@@ -260,7 +330,8 @@ export function mergeTickets(items, remote, seen = {}, cached = []) {
260
330
  id: '', ref: i.ref, category: p.category, priority: p.priority, subject: p.subject, where: p.where || '',
261
331
  userName: p.user?.name || '', status: i.state === 'refused' ? 'refused' : 'waiting',
262
332
  statusLabel: i.state === 'refused' ? 'Not accepted' : 'Waiting to send',
263
- messages: [{id: i.ref, from: 'customer', name: p.user?.name || '', body: p.body, at: i.createdAt, pending: true}],
333
+ messages: [{id: i.ref, from: 'customer', name: p.user?.name || '', body: p.body, at: i.createdAt, pending: true,
334
+ ...(p.attachments?.length && {attachments: p.attachments})}],
264
335
  lastFrom: 'customer', lastAt: i.createdAt, createdAt: i.createdAt, updatedAt: i.createdAt,
265
336
  local: true, unread: false, lastError: i.lastError || ''
266
337
  });
@@ -269,7 +340,7 @@ export function mergeTickets(items, remote, seen = {}, cached = []) {
269
340
  const t = out.get(i.ticketRef);
270
341
  if (t && !(t.messages || []).some(m => m.id === i.ref)) {
271
342
  t.messages = [...(t.messages || []), {id: i.ref, from: 'customer', name: p.user?.name || '', body: p.body, at: i.createdAt,
272
- pending: i.state === 'waiting', refused: i.state === 'refused'}];
343
+ pending: i.state === 'waiting', refused: i.state === 'refused', ...(p.attachments?.length && {attachments: p.attachments})}];
273
344
  }
274
345
  }
275
346
  }
@@ -47,6 +47,68 @@ export const LIMITS = {subject: 140, body: 8000, where: 300, notes: 8000, messag
47
47
  // would pass here and then be refused by the manager's 32 KB body limit, forever.
48
48
  const text = (v, max) => String(v ?? '').replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000B-\u001F\u007F]/g, '').trim().slice(0, max);
49
49
  const line = (v, max) => text(v, max).replace(/\s*\n\s*/g, ' ');
50
+
51
+ // ---------------------------------------------------------------------------------
52
+ // Attachments: a screenshot is what "something is not working" needs most.
53
+ // The same rules as Feedback's (plugins/feedback/lib/shape.js) - each plugin
54
+ // carries its own copy, since either can be installed without the other.
55
+ // ---------------------------------------------------------------------------------
56
+
57
+ /** How many files one message may carry and how big each may be. The files travel one per request, after their message. */
58
+ export const ATTACHMENTS = {max: 3, maxBytes: 2 * 1024 * 1024};
59
+
60
+ /** What may be attached: mime -> the extension the file is kept under. No SVG or HTML - they can carry scripts. */
61
+ export const ATTACHMENT_TYPES = {
62
+ 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp', 'application/pdf': 'pdf', 'text/plain': 'txt'
63
+ };
64
+
65
+ /** Ids are made by the server (randomUUID) and become file names, so nothing else gets through. */
66
+ const ATTACHMENT_ID = /^[A-Za-z0-9-]{8,64}$/;
67
+
68
+ export const extOf = (type) => ATTACHMENT_TYPES[String(type || '').toLowerCase()] || 'bin';
69
+
70
+ /** A file name fit to show and to put in a Content-Disposition header: the last path segment, odd characters replaced, never empty. */
71
+ export function safeFileName(name, ext = 'bin') {
72
+ const base = String(name ?? '').split(/[\\/]/).pop().replace(/[^A-Za-z0-9._ -]/g, '_').replace(/^[. ]+/, '').trim().slice(0, 120);
73
+ return base || `file.${ext}`;
74
+ }
75
+
76
+ /**
77
+ * One attachment's description, cleaned - or null when it is not one we take.
78
+ * The bytes are checked against `size` when they arrive.
79
+ *
80
+ * @returns {{id: string, name: string, type: string, size: number}|null}
81
+ */
82
+ export function cleanAttachment(a = {}) {
83
+ if (!a || typeof a !== 'object') return null;
84
+ const type = String(a.type || '').toLowerCase().split(';')[0].trim();
85
+ const id = String(a.id || '');
86
+ const size = Number(a.size);
87
+ if (!ATTACHMENT_TYPES[type] || !ATTACHMENT_ID.test(id) || !Number.isInteger(size) || size < 1 || size > ATTACHMENTS.maxBytes) return null;
88
+ return {id, name: safeFileName(a.name, ATTACHMENT_TYPES[type]), type, size};
89
+ }
90
+
91
+ /** A list of them: the bad ones dropped, duplicates once, at most ATTACHMENTS.max. */
92
+ export function cleanAttachments(list) {
93
+ if (!Array.isArray(list)) return [];
94
+ const out = [];
95
+ const seen = new Set();
96
+ for (const a of list) {
97
+ const c = cleanAttachment(a);
98
+ if (!c || seen.has(c.id)) continue;
99
+ seen.add(c.id);
100
+ out.push(c);
101
+ if (out.length >= ATTACHMENTS.max) break;
102
+ }
103
+ return out;
104
+ }
105
+
106
+ /** The sender-safe view of a stored attachment: what it is and whether the manager has the bytes yet. */
107
+ export const attachmentView = (a) => ({id: a.id, name: a.name, type: a.type, size: a.size, stored: Boolean(a.stored)});
108
+
109
+ /** Every attachment in a conversation, with the message it came in. */
110
+ export const attachmentsOf = (data = {}) => (data.messages || []).flatMap(m => (m.attachments || []).map(a => ({...a, messageId: m.id})));
111
+
50
112
  const EMAIL_RE = /^[^\s@<>"]+@[^\s@<>"]+\.[^\s@<>"]+$/;
51
113
 
52
114
  /**
@@ -63,14 +125,15 @@ export function cleanRequest(body = {}) {
63
125
  const message = text(body.body, LIMITS.body);
64
126
  if (!subject) return {error: 'Give it a short subject.'};
65
127
  if (!message) return {error: 'Tell us what you need.'};
66
- return {request: {category, priority, subject, body: message, where: line(body.where, LIMITS.where)}};
128
+ // Descriptions of files, not the files: those follow one per request once the request is in.
129
+ return {request: {category, priority, subject, body: message, where: line(body.where, LIMITS.where), attachments: cleanAttachments(body.attachments)}};
67
130
  }
68
131
 
69
- /** One more message in a conversation. */
132
+ /** One more message in a conversation. Files go with words: a message is never files alone. */
70
133
  export function cleanMessage(body = {}) {
71
134
  const message = text(body.body, LIMITS.body);
72
135
  if (!message) return {error: 'Write a message first.'};
73
- return {message: {body: message}};
136
+ return {message: {body: message, attachments: cleanAttachments(body.attachments)}};
74
137
  }
75
138
 
76
139
  const who = (user = {}) => ({id: text(user.id, 64), name: line(user.name, 120), email: text(user.email, 200)});
@@ -84,8 +147,9 @@ export function buildTicketPayload({request, user = {}, cmsVersion = '', site =
84
147
  }
85
148
 
86
149
  /** What a site sends for another message, or to close a request (`body` empty, `close: true`). */
87
- export function buildMessagePayload({ticketRef, ref, body = '', close = false, user = {}}) {
88
- return {ticketRef: text(ticketRef, 64), ref: text(ref, 64), body: text(body, LIMITS.body), close: Boolean(close), user: who(user)};
150
+ export function buildMessagePayload({ticketRef, ref, body = '', close = false, user = {}, attachments = []}) {
151
+ return {ticketRef: text(ticketRef, 64), ref: text(ref, 64), body: text(body, LIMITS.body), close: Boolean(close), user: who(user),
152
+ ...(body && {attachments: cleanAttachments(attachments)})};
89
153
  }
90
154
 
91
155
  /**
@@ -115,7 +179,9 @@ export function receivedTicket(payload = {}, slug, now) {
115
179
  userName: u.name || 'A site admin',
116
180
  userEmail: EMAIL_RE.test(u.email) ? u.email : '',
117
181
  status: 'open',
118
- messages: [{id: ref || 'm0', from: 'customer', name: u.name || 'A site admin', body: request.body, at: now}],
182
+ // Announced with the message; `stored` turns true as each file arrives (/fleet/attach).
183
+ messages: [{id: ref || 'm0', from: 'customer', name: u.name || 'A site admin', body: request.body, at: now,
184
+ ...(request.attachments.length && {attachments: request.attachments.map(a => ({...a, stored: false}))})}],
119
185
  lastFrom: 'customer',
120
186
  lastAt: now,
121
187
  notes: '',
@@ -132,14 +198,15 @@ export function receivedTicket(payload = {}, slug, now) {
132
198
  * @param {{from: 'customer'|'domma', name: string, body?: string, id: string, at: string, status?: string, close?: boolean}} turn
133
199
  * @returns {{data: object, added: boolean}}
134
200
  */
135
- export function addTurn(data, {from, name, body = '', id, at, status, close = false}) {
201
+ export function addTurn(data, {from, name, body = '', id, at, status, close = false, attachments = []}) {
136
202
  const messages = Array.isArray(data.messages) ? [...data.messages] : [];
137
203
  if (id && messages.some(m => m.id === id)) return {data, added: false};
138
204
  const history = Array.isArray(data.history) ? [...data.history] : [];
139
205
  const next = {...data};
140
206
  let added = false;
141
207
  if (body) {
142
- messages.push({id, from, name, body, at});
208
+ const files = cleanAttachments(attachments).map(a => ({...a, stored: false}));
209
+ messages.push({id, from, name, body, at, ...(files.length && {attachments: files})});
143
210
  next.lastFrom = from;
144
211
  next.lastAt = at;
145
212
  added = true;
@@ -181,7 +248,8 @@ export function senderView(entry) {
181
248
  userName: d.userName || '',
182
249
  status: d.status,
183
250
  statusLabel: statusLabel(d.status),
184
- messages: (d.messages || []).map(m => ({id: m.id, from: m.from, name: m.name, body: m.body, at: m.at})),
251
+ messages: (d.messages || []).map(m => ({id: m.id, from: m.from, name: m.name, body: m.body, at: m.at,
252
+ ...(m.attachments?.length && {attachments: m.attachments.map(attachmentView)})})),
185
253
  lastFrom: d.lastFrom || 'customer',
186
254
  lastAt: d.lastAt || '',
187
255
  createdAt: entry.meta?.createdAt || '',
@@ -231,6 +299,76 @@ export function contactCard(s = {}) {
231
299
  };
232
300
  }
233
301
 
302
+ /**
303
+ * The settings each side owns (1.1.5). The site keeps its sync interval; the
304
+ * manager keeps the contact card every site shows, where new requests are
305
+ * emailed, and the per-site brake.
306
+ */
307
+ export const SIDE_SETTINGS = {
308
+ sender: ['syncMinutes'],
309
+ receiver: ['email', 'phone', 'hours', 'responseTime', 'notifyEmail', 'perSiteMax', 'perSiteMinutes']
310
+ };
311
+ export const SETTING_RULES = {
312
+ syncMinutes: {min: 1, max: 1440, fallback: 5},
313
+ perSiteMax: {min: 1, max: 1000, fallback: 20},
314
+ perSiteMinutes: {min: 1, max: 1440, fallback: 10}
315
+ };
316
+ const PHONE_RE = /^[+\d][\d\s().-]{4,}$/;
317
+
318
+ /** What is wrong with a sent settings object, per field - empty when nothing. */
319
+ export function settingsErrors(raw, side = 'sender') {
320
+ const r = raw && typeof raw === 'object' ? raw : {};
321
+ const errors = {};
322
+ const keys = SIDE_SETTINGS[side] || [];
323
+ for (const k of keys) {
324
+ if (!(k in r)) continue;
325
+ const v = r[k];
326
+ if (SETTING_RULES[k]) {
327
+ const n = Number(v);
328
+ const {min, max} = SETTING_RULES[k];
329
+ if (v === '' || v == null || !Number.isInteger(n) || n < min || n > max) errors[k] = `A whole number from ${min} to ${max}.`;
330
+ } else if ((k === 'email' || k === 'notifyEmail') && String(v ?? '').trim() && !EMAIL_RE.test(String(v).trim())) {
331
+ errors[k] = 'One email address, or leave it empty.';
332
+ } else if (k === 'phone' && String(v ?? '').trim() && !PHONE_RE.test(String(v).trim())) {
333
+ errors[k] = 'Digits, spaces and + ( ) . - only.';
334
+ }
335
+ }
336
+ return errors;
337
+ }
338
+
339
+ /**
340
+ * One side's settings from whatever was saved or sent: only that side's keys,
341
+ * each checked; a value that does not pass keeps what is current, then the
342
+ * default. An empty email or phone is allowed - it hides that line of the card
343
+ * (notifyEmail: emails nobody).
344
+ *
345
+ * @param {object} raw
346
+ * @param {object} [current]
347
+ * @param {'sender'|'receiver'} [side]
348
+ */
349
+ export function cleanSettings(raw, current = {}, side = 'sender') {
350
+ const r = raw && typeof raw === 'object' ? raw : {};
351
+ const cur = current && typeof current === 'object' ? current : {};
352
+ const out = {};
353
+ const okFor = (k, v) => {
354
+ if (v === undefined) return undefined;
355
+ if (SETTING_RULES[k]) {
356
+ const n = Number(v);
357
+ const {min, max} = SETTING_RULES[k];
358
+ return v !== '' && v != null && Number.isInteger(n) && n >= min && n <= max ? n : undefined;
359
+ }
360
+ if (k === 'email' || k === 'notifyEmail') { const e = text(v, 200); return !e || EMAIL_RE.test(e) ? e : undefined; }
361
+ if (k === 'phone') { const p = line(v, 40); return !p || PHONE_RE.test(p) ? p : undefined; }
362
+ if (k === 'hours') return line(v, 200);
363
+ if (k === 'responseTime') return line(v, 300);
364
+ return undefined;
365
+ };
366
+ for (const k of SIDE_SETTINGS[side] || []) {
367
+ out[k] = okFor(k, r[k]) ?? okFor(k, cur[k]) ?? SETTING_RULES[k]?.fallback ?? '';
368
+ }
369
+ return out;
370
+ }
371
+
234
372
  /** The tel: form of a phone number. */
235
373
  export const telHref = (phone) => `tel:${String(phone || '').replace(/[^\d+]/g, '')}`;
236
374
 
@@ -241,9 +379,12 @@ export const telHref = (phone) => `tel:${String(phone || '').replace(/[^\d+]/g,
241
379
  */
242
380
  export function createThrottle({max = 20, windowMs = 10 * 60_000} = {}) {
243
381
  const seen = new Map();
244
- return (key, now = Date.now()) => {
245
- const recent = (seen.get(key) || []).filter(t => now - t < windowMs);
246
- if (recent.length >= max) { seen.set(key, recent); return false; }
382
+ // `limits` per call (1.1.5): the settings cog changes them without a restart.
383
+ return (key, now = Date.now(), limits = {}) => {
384
+ const cap = Number(limits.max) > 0 ? Number(limits.max) : max;
385
+ const window = Number(limits.windowMs) > 0 ? Number(limits.windowMs) : windowMs;
386
+ const recent = (seen.get(key) || []).filter(t => now - t < window);
387
+ if (recent.length >= cap) { seen.set(key, recent); return false; }
247
388
  recent.push(now);
248
389
  seen.set(key, recent);
249
390
  return true;