domma-cms 1.6.37 → 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.
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- package/package.json +2 -2
- package/plugins/blog/CLAUDE.md +11 -1
- package/plugins/blog/lib/cache.js +43 -0
- package/plugins/blog/lib/page.js +28 -9
- package/plugins/blog/plugin.js +14 -13
- package/plugins/blog/plugin.json +1 -1
- package/plugins/blog/plugin.public.js +1 -1
- package/plugins/blog/tests/cache.test.js +65 -0
- package/plugins/booking/CLAUDE.md +14 -2
- package/plugins/booking/admin/lib/diary-layout.js +18 -0
- package/plugins/booking/admin/views/diary.js +153 -152
- package/plugins/booking/admin/views/people.js +64 -66
- package/plugins/booking/admin/views/settings.js +31 -32
- package/plugins/booking/plugin.json +1 -1
- package/plugins/booking/tests/admin-diary.test.js +9 -1
- package/plugins/feedback/CLAUDE.md +55 -0
- package/plugins/feedback/admin/css/index.css +16 -1
- package/plugins/feedback/admin/lib/kit.js +106 -0
- package/plugins/feedback/admin/lib/settings.js +107 -0
- package/plugins/feedback/admin/views/feedback.js +4 -1
- package/plugins/feedback/admin/views/inbox.js +11 -2
- package/plugins/feedback/admin/views/send.js +74 -7
- package/plugins/feedback/docs/guide.md +9 -9
- package/plugins/feedback/lib/attachments.js +133 -0
- package/plugins/feedback/lib/cache.js +43 -0
- package/plugins/feedback/lib/receiver.js +36 -6
- package/plugins/feedback/lib/sender.js +70 -6
- package/plugins/feedback/lib/shape.js +111 -6
- package/plugins/feedback/plugin.js +171 -17
- package/plugins/feedback/plugin.json +2 -2
- package/plugins/feedback/tests/api.test.js +125 -3
- package/plugins/feedback/tests/settings.test.js +59 -0
- package/plugins/feedback/tests/shape.test.js +64 -0
- package/plugins/free-tier.lock.json +73 -61
- package/plugins/mail-reader/CLAUDE.md +15 -4
- package/plugins/mail-reader/plugin.json +1 -1
- package/plugins/shopping-cart/CLAUDE.md +28 -2
- package/plugins/shopping-cart/admin/css/index.css +3 -0
- package/plugins/shopping-cart/admin/views/orders.js +42 -10
- package/plugins/shopping-cart/admin/views/shop.js +1 -1
- package/plugins/shopping-cart/docs/guide.md +7 -3
- package/plugins/shopping-cart/lib/payments.js +15 -2
- package/plugins/shopping-cart/lib/refunds.js +51 -0
- package/plugins/shopping-cart/lib/sales.js +15 -8
- package/plugins/shopping-cart/lib/shop.js +38 -3
- package/plugins/shopping-cart/plugin.js +47 -19
- package/plugins/shopping-cart/plugin.json +2 -2
- package/plugins/shopping-cart/tests/api.test.js +52 -0
- package/plugins/shopping-cart/tests/commission.test.js +1 -1
- package/plugins/shopping-cart/tests/refunds.test.js +42 -0
- package/plugins/support/CLAUDE.md +51 -0
- package/plugins/support/admin/css/index.css +16 -1
- package/plugins/support/admin/lib/kit.js +192 -8
- package/plugins/support/admin/lib/settings.js +123 -0
- package/plugins/support/admin/views/desk.js +55 -12
- package/plugins/support/admin/views/inbox.js +17 -4
- package/plugins/support/admin/views/support.js +4 -1
- package/plugins/support/docs/guide.md +7 -9
- package/plugins/support/lib/attachments.js +133 -0
- package/plugins/support/lib/cache.js +43 -0
- package/plugins/support/lib/receiver.js +45 -10
- package/plugins/support/lib/sender.js +81 -10
- package/plugins/support/lib/shape.js +153 -12
- package/plugins/support/plugin.js +159 -29
- package/plugins/support/plugin.json +2 -2
- package/plugins/support/tests/api.test.js +125 -3
- package/plugins/support/tests/settings.test.js +68 -0
- package/plugins/support/tests/shape.test.js +51 -1
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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;
|