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.
- 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/_lib/admin/mail/mail.css +1 -1
- package/plugins/_lib/admin/mail/oauth-panel.js +303 -0
- package/plugins/_lib/admin/mail/reader-view.js +126 -13
- package/plugins/_lib/mail/accounts.js +548 -225
- package/plugins/_lib/mail/imapPool.js +63 -13
- package/plugins/_lib/mail/oauth.js +453 -0
- package/plugins/_lib/mail/oauthRoutes.js +294 -0
- package/plugins/_lib/mail/readRoutes.js +23 -2
- package/plugins/_lib/mail/scheduler.js +30 -6
- 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 +62 -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 +89 -6
- package/plugins/feedback/lib/shape.js +111 -6
- package/plugins/feedback/plugin.js +173 -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 +87 -1
- package/plugins/free-tier.lock.json +74 -62
- package/plugins/mail-reader/CLAUDE.md +15 -4
- package/plugins/mail-reader/plugin.json +1 -1
- package/plugins/shopping-cart/CLAUDE.md +35 -2
- package/plugins/shopping-cart/admin/css/index.css +3 -0
- package/plugins/shopping-cart/admin/views/orders.js +75 -23
- 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/pure.test.js +16 -0
- package/plugins/shopping-cart/tests/refunds.test.js +42 -0
- package/plugins/support/CLAUDE.md +58 -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 +96 -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 +76 -2
- package/server/routes/outreach-public.js +2 -2
- package/server/services/request-log.js +3 -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".
|
|
@@ -118,11 +139,14 @@ export function createOutbox(dataDir) {
|
|
|
118
139
|
const known = latest.has(ref);
|
|
119
140
|
if ((!known && !local.has(ref)) || (known && (latest.get(ref) || '') !== (entry.dommaAt || ''))) delete s.shelf[ref];
|
|
120
141
|
}
|
|
142
|
+
// Files this site keeps to show its own thread: only those the manager still has (a request
|
|
143
|
+
// deleted there takes its files with it) or an item here still names. The next prune removes the rest.
|
|
144
|
+
s.files = keepFiles(s.files, remote, s.items);
|
|
121
145
|
const byRef = new Map(remote.map(t => [t.ref, t]));
|
|
122
146
|
const have = new Set(remote.flatMap(t => [t.ref, ...(t.messages || []).map(m => m.id)]));
|
|
123
147
|
s.items = s.items.filter((i) => {
|
|
124
148
|
if (i.state !== 'sent') return true;
|
|
125
|
-
if (have.has(i.ref)) return
|
|
149
|
+
if (have.has(i.ref)) return !filesDone(i); // arrived: done with it, once its files have gone
|
|
126
150
|
const t = byRef.get(i.kind === 'ticket' ? i.ref : i.ticketRef);
|
|
127
151
|
if (!t) return true; // not in the view yet
|
|
128
152
|
if (i.kind === 'message' && !i.payload?.body) return false; // a close: no message to look for
|
|
@@ -136,6 +160,24 @@ export function createOutbox(dataDir) {
|
|
|
136
160
|
};
|
|
137
161
|
}
|
|
138
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Of the file ids this site kept after sending them, those still wanted: in a message of a request the
|
|
165
|
+
* manager still holds (its list for this site is complete), or named by an outbox item. PURE.
|
|
166
|
+
*/
|
|
167
|
+
export function keepFiles(files, remote, items = []) {
|
|
168
|
+
const live = new Set([
|
|
169
|
+
...(remote || []).flatMap(t => (t.messages || []).flatMap(m => (m.attachments || []).map(a => a.id))),
|
|
170
|
+
...(items || []).flatMap(i => (i.payload?.attachments || []).map(a => a.id))
|
|
171
|
+
]);
|
|
172
|
+
return (files || []).filter(id => live.has(id));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Has every file an item announced been sent, or given up on? */
|
|
176
|
+
const filesDone = (i) => {
|
|
177
|
+
const done = new Set([...(i.filesSent || []), ...(i.filesRefused || [])]);
|
|
178
|
+
return (i.payload?.attachments || []).every(a => done.has(a.id));
|
|
179
|
+
};
|
|
180
|
+
|
|
139
181
|
/**
|
|
140
182
|
* Talk to the manager. `fetchImpl` is swapped in tests; nothing here throws -
|
|
141
183
|
* every call answers `{ok, status?, body?, error?}`.
|
|
@@ -157,10 +199,28 @@ export function managerApi(manager, fetchImpl = globalThis.fetch) {
|
|
|
157
199
|
return {ok: false, error: `Could not reach the Domma server: ${err.message}`};
|
|
158
200
|
}
|
|
159
201
|
};
|
|
202
|
+
/** One attached file's bytes, after its message: raw, so the JSON routes' 32 KB limit never applies to them. */
|
|
203
|
+
const attach = async (ticketRef, attId, buffer) => {
|
|
204
|
+
try {
|
|
205
|
+
const res = await fetchImpl(`${manager.url}/api/plugins/support/fleet/attach/${encodeURIComponent(ticketRef)}/${encodeURIComponent(attId)}`, {
|
|
206
|
+
method: 'POST',
|
|
207
|
+
headers: {authorization: `Bearer ${manager.token}`, 'content-type': 'application/octet-stream'},
|
|
208
|
+
body: buffer,
|
|
209
|
+
signal: AbortSignal.timeout(30_000)
|
|
210
|
+
});
|
|
211
|
+
let json = null;
|
|
212
|
+
try { json = await res.json(); } catch { /* not JSON */ }
|
|
213
|
+
if (res.status >= 200 && res.status < 300) return {ok: true, status: res.status, body: json};
|
|
214
|
+
return {ok: false, status: res.status, error: json?.error || `The Domma server answered ${res.status}.`};
|
|
215
|
+
} catch (err) {
|
|
216
|
+
return {ok: false, error: `Could not reach the Domma server: ${err.message}`};
|
|
217
|
+
}
|
|
218
|
+
};
|
|
160
219
|
return {
|
|
161
220
|
open: (payload) => call('POST', 'tickets', payload),
|
|
162
221
|
message: (payload) => call('POST', 'messages', payload),
|
|
163
|
-
mine: () => call('GET', 'mine')
|
|
222
|
+
mine: () => call('GET', 'mine'),
|
|
223
|
+
attach
|
|
164
224
|
};
|
|
165
225
|
}
|
|
166
226
|
|
|
@@ -170,17 +230,40 @@ export function managerApi(manager, fetchImpl = globalThis.fetch) {
|
|
|
170
230
|
*
|
|
171
231
|
* @returns {Promise<{sent: number, waiting: number, error: string}>}
|
|
172
232
|
*/
|
|
173
|
-
export function flush(outbox, api) {
|
|
233
|
+
export function flush(outbox, api, files = null) {
|
|
174
234
|
// One flush at a time per outbox: the timer, the badge's nudge, a screen load and a send can
|
|
175
235
|
// 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; });
|
|
236
|
+
if (!outbox.flushing) outbox.flushing = flushOnce(outbox, api, files).finally(() => { outbox.flushing = null; });
|
|
177
237
|
return outbox.flushing;
|
|
178
238
|
}
|
|
179
239
|
|
|
180
240
|
/** Statuses that mean "try again later"; any other 4xx means the manager will never take it. */
|
|
181
241
|
const RETRY = new Set([401, 408, 429]);
|
|
182
242
|
|
|
183
|
-
|
|
243
|
+
/**
|
|
244
|
+
* The files of messages that have arrived but whose files have not all followed.
|
|
245
|
+
* A file the manager refuses for good (400/404/413), or one this site no longer
|
|
246
|
+
* has, is not tried again - the words are there either way.
|
|
247
|
+
*/
|
|
248
|
+
async function sendFiles(outbox, api, files) {
|
|
249
|
+
let error = '';
|
|
250
|
+
for (const item of outbox.all().filter(i => i.state === 'sent' && !filesDone(i))) {
|
|
251
|
+
const done = new Set([...(item.filesSent || []), ...(item.filesRefused || [])]);
|
|
252
|
+
for (const a of item.payload.attachments.filter(x => !done.has(x.id))) {
|
|
253
|
+
const buffer = files?.read(a);
|
|
254
|
+
if (!buffer) { outbox.fileRefused(item.ref, a.id); continue; }
|
|
255
|
+
const r = await api.attach(item.ticketRef, a.id, buffer);
|
|
256
|
+
if (r.ok) outbox.fileSent(item.ref, a.id);
|
|
257
|
+
else {
|
|
258
|
+
error = r.error || 'A file was not sent.';
|
|
259
|
+
if (r.status >= 400 && r.status < 500 && !RETRY.has(r.status)) outbox.fileRefused(item.ref, a.id);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return error;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function flushOnce(outbox, api, files) {
|
|
184
267
|
let sent = 0;
|
|
185
268
|
let error = '';
|
|
186
269
|
for (const queued of outbox.all().filter(i => i.state === 'waiting')) {
|
|
@@ -204,7 +287,9 @@ async function flushOnce(outbox, api) {
|
|
|
204
287
|
outbox.mark(item.ref, {lastError: error, triedAt: new Date().toISOString(), ...(permanent && {state: 'refused'})});
|
|
205
288
|
}
|
|
206
289
|
}
|
|
207
|
-
|
|
290
|
+
// Then the files, each on its own request once its message is on the manager.
|
|
291
|
+
const fileError = api.attach ? await sendFiles(outbox, api, files) : '';
|
|
292
|
+
return {sent, waiting: outbox.all().filter(i => i.state === 'waiting').length, error: error || fileError};
|
|
208
293
|
}
|
|
209
294
|
|
|
210
295
|
/**
|
|
@@ -260,7 +345,8 @@ export function mergeTickets(items, remote, seen = {}, cached = []) {
|
|
|
260
345
|
id: '', ref: i.ref, category: p.category, priority: p.priority, subject: p.subject, where: p.where || '',
|
|
261
346
|
userName: p.user?.name || '', status: i.state === 'refused' ? 'refused' : 'waiting',
|
|
262
347
|
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
|
|
348
|
+
messages: [{id: i.ref, from: 'customer', name: p.user?.name || '', body: p.body, at: i.createdAt, pending: true,
|
|
349
|
+
...(p.attachments?.length && {attachments: p.attachments})}],
|
|
264
350
|
lastFrom: 'customer', lastAt: i.createdAt, createdAt: i.createdAt, updatedAt: i.createdAt,
|
|
265
351
|
local: true, unread: false, lastError: i.lastError || ''
|
|
266
352
|
});
|
|
@@ -269,7 +355,7 @@ export function mergeTickets(items, remote, seen = {}, cached = []) {
|
|
|
269
355
|
const t = out.get(i.ticketRef);
|
|
270
356
|
if (t && !(t.messages || []).some(m => m.id === i.ref)) {
|
|
271
357
|
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'}];
|
|
358
|
+
pending: i.state === 'waiting', refused: i.state === 'refused', ...(p.attachments?.length && {attachments: p.attachments})}];
|
|
273
359
|
}
|
|
274
360
|
}
|
|
275
361
|
}
|
|
@@ -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;
|