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
|
@@ -13,7 +13,10 @@
|
|
|
13
13
|
* Domma brings it straight back. Removing one that never reached Domma cancels it.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
FILE_TYPES, THREAD, TONE, api, copyText, esc, fileKeys, filePicker, fileSize, findFile, listOf, openPanel, refreshBadge, rowKeys, stamp, telHref,
|
|
18
|
+
threadFiles, threadRows, when
|
|
19
|
+
} from '../lib/kit.js';
|
|
17
20
|
|
|
18
21
|
const ROW = `
|
|
19
22
|
<div class="sp-row" data-id="{{key}}" tabindex="0" role="button" aria-haspopup="menu">
|
|
@@ -37,6 +40,21 @@ export async function mount(root, ctx) {
|
|
|
37
40
|
const {kit, scope, here, meta, id} = ctx;
|
|
38
41
|
const categories = meta.categories || [];
|
|
39
42
|
const priorities = meta.priorities || [];
|
|
43
|
+
const caps = meta.attachments || {max: 3, maxBytes: 2 * 1024 * 1024};
|
|
44
|
+
/** The attach button and its hidden input; the picker's list goes in [data-files]. */
|
|
45
|
+
const attachUi = (pasteHint) => `<div class="sp-files-host" data-files></div>
|
|
46
|
+
<div class="sp-attach" data-bind-hidden="!room.value">
|
|
47
|
+
<button type="button" class="btn btn-sm btn-outline" data-on-click="pickFiles"><span data-icon="paperclip" data-icon-size="13"></span> Attach a file</button>
|
|
48
|
+
<span class="sp-hint">${esc(pasteHint)}</span>
|
|
49
|
+
<input type="file" class="sp-file-input" accept="${FILE_TYPES}" multiple hidden>
|
|
50
|
+
</div>`;
|
|
51
|
+
/** Wire a panel's picker, mirroring its state into the panel's own observables (bound before it existed). */
|
|
52
|
+
const wirePicker = (p, pasteSel, room, uploading) => {
|
|
53
|
+
const picker = filePicker({host: p.panel.querySelector('[data-files]'), input: p.panel.querySelector('.sp-file-input'),
|
|
54
|
+
paste: p.panel.querySelector(pasteSel), caps});
|
|
55
|
+
const stop = M.effect(() => { room.value = picker.room.value; uploading.value = picker.uploading.value; });
|
|
56
|
+
return {picker, dispose: () => { stop(); picker.dispose(); }};
|
|
57
|
+
};
|
|
40
58
|
const iconOf = (c) => categories.find(x => x.key === c)?.icon || 'message-square';
|
|
41
59
|
const labelOf = (c) => categories.find(x => x.key === c)?.short || c;
|
|
42
60
|
const plural = (n, w) => (n ? `${n} ${w}${n === 1 ? '' : 's'}` : '');
|
|
@@ -58,7 +76,8 @@ export async function mount(root, ctx) {
|
|
|
58
76
|
subject: t.subject,
|
|
59
77
|
icon: iconOf(t.category),
|
|
60
78
|
categoryLabel: labelOf(t.category),
|
|
61
|
-
meta: [when(t.lastAt || t.createdAt), t.userName, plural((t.messages || []).length, 'message')
|
|
79
|
+
meta: [when(t.lastAt || t.createdAt), t.userName, plural((t.messages || []).length, 'message'),
|
|
80
|
+
plural((t.messages || []).reduce((n, m) => n + (m.attachments || []).length, 0), 'file')].filter(Boolean).join(' · '),
|
|
62
81
|
statusLabel: t.statusLabel || t.status,
|
|
63
82
|
tone: `is-${TONE[t.status] || 'muted'}`,
|
|
64
83
|
urgent: t.priority === 'urgent' && t.status !== 'closed',
|
|
@@ -212,11 +231,13 @@ export async function mount(root, ctx) {
|
|
|
212
231
|
|
|
213
232
|
function compose() {
|
|
214
233
|
const f = {category: M.observable('help'), priority: M.observable('normal'), subject: M.observable(''), body: M.observable(''),
|
|
215
|
-
where: M.observable(''), busy: M.observable(false)};
|
|
234
|
+
where: M.observable(''), busy: M.observable(false), room: M.observable(true), uploading: M.observable(false)};
|
|
216
235
|
let panel = null;
|
|
236
|
+
let wired = null;
|
|
217
237
|
openPanel({
|
|
218
238
|
title: 'Ask Domma for help',
|
|
219
239
|
model: f,
|
|
240
|
+
cleanup: () => wired?.dispose(),
|
|
220
241
|
html: `<div class="sp-form">
|
|
221
242
|
<label class="form-label">What is it about?</label>
|
|
222
243
|
<div class="sp-choices" role="radiogroup" aria-label="Category">
|
|
@@ -232,21 +253,24 @@ export async function mount(root, ctx) {
|
|
|
232
253
|
</div>
|
|
233
254
|
<label class="form-label" for="sp-where">Where <span data-help="The page or admin screen it is about, if there is one - e.g. /contact, or Pages. Optional."></span></label>
|
|
234
255
|
<input id="sp-where" class="form-input" maxlength="300" data-model="where.value" placeholder="Optional">
|
|
256
|
+
<label class="form-label">Screenshots and files <span data-help="${esc(`Up to ${caps.max} files of ${fileSize(caps.maxBytes)} each: PNG, JPEG, GIF or WebP images, a PDF or a text file. A screenshot of what went wrong helps us most. Only the Domma team sees them.`)}"></span></label>
|
|
257
|
+
${attachUi('Or paste a screenshot into the message.')}
|
|
235
258
|
<p class="sp-hint">Sent with your name, email and this site, so we can reply here and by email.</p>
|
|
236
259
|
<div class="sp-actions"><span class="sp-spacer"></span>
|
|
237
260
|
<button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
|
|
238
|
-
<button type="button" class="btn btn-sm btn-primary" data-on-click="send" data-bind-disabled="busy.value"><span data-icon="send" data-icon-size="13"></span> Send</button>
|
|
261
|
+
<button type="button" class="btn btn-sm btn-primary" data-on-click="send" data-bind-disabled="busy.value || uploading.value"><span data-icon="send" data-icon-size="13"></span> Send</button>
|
|
239
262
|
</div>
|
|
240
263
|
</div>`,
|
|
241
264
|
methods: {
|
|
242
265
|
pick: (group, value) => { if (f[group]) f[group].value = value; },
|
|
243
266
|
cancel: () => panel?.close(),
|
|
267
|
+
pickFiles: () => panel?.panel.querySelector('.sp-file-input')?.click(),
|
|
244
268
|
async send() {
|
|
245
|
-
if (f.busy.peek()) return;
|
|
269
|
+
if (f.busy.peek() || f.uploading.peek()) return;
|
|
246
270
|
f.busy.value = true;
|
|
247
271
|
try {
|
|
248
272
|
const r = await api.open({category: f.category.peek(), priority: f.priority.peek(), subject: f.subject.peek(),
|
|
249
|
-
body: f.body.peek(), where: f.where.peek()});
|
|
273
|
+
body: f.body.peek(), where: f.where.peek(), attachments: wired?.picker.attachments() || []});
|
|
250
274
|
E.toast(r.message || 'Sent.', {type: r.state === 'sent' ? 'success' : 'warning'});
|
|
251
275
|
panel?.close();
|
|
252
276
|
refreshBadge();
|
|
@@ -261,6 +285,7 @@ export async function mount(root, ctx) {
|
|
|
261
285
|
}
|
|
262
286
|
}).then(p => {
|
|
263
287
|
panel = p;
|
|
288
|
+
wired = wirePicker(p, '#sp-body', f.room, f.uploading);
|
|
264
289
|
p.panel.querySelector('#sp-subject')?.focus();
|
|
265
290
|
});
|
|
266
291
|
}
|
|
@@ -270,7 +295,14 @@ export async function mount(root, ctx) {
|
|
|
270
295
|
if (!t0) return;
|
|
271
296
|
// The panel follows the list's copy, so a reload or a move from the row menu shows here at once.
|
|
272
297
|
const cur = M.computed(() => tickets.value.find(x => x.ref === ref) || t0);
|
|
273
|
-
|
|
298
|
+
// Every file in this site's requests was attached here, so this site's own copy shows them -
|
|
299
|
+
// even while Domma's copy is still on its way.
|
|
300
|
+
const shown = threadFiles((a) => api.ownFileUrl(a.id));
|
|
301
|
+
const thread = M.computed(() => threadRows(cur.value.messages || [], 'customer', {thumbs: shown.thumbs.value}));
|
|
302
|
+
const room = M.observable(true);
|
|
303
|
+
const uploading = M.observable(false);
|
|
304
|
+
let wired = null;
|
|
305
|
+
let stopFiles = () => {};
|
|
274
306
|
const status = M.computed(() => cur.value.status);
|
|
275
307
|
const statusLabel = M.computed(() => cur.value.statusLabel || cur.value.status);
|
|
276
308
|
const tone = M.computed(() => `is-${TONE[cur.value.status] || 'muted'}`);
|
|
@@ -291,8 +323,11 @@ export async function mount(root, ctx) {
|
|
|
291
323
|
panel = await openPanel({
|
|
292
324
|
title: t0.subject,
|
|
293
325
|
size: 'lg',
|
|
294
|
-
model: {thread, status, statusLabel, tone, archived, draft, hasDraft, busy},
|
|
295
|
-
cleanup: () => {
|
|
326
|
+
model: {thread, status, statusLabel, tone, archived, draft, hasDraft, busy, room, uploading},
|
|
327
|
+
cleanup: () => {
|
|
328
|
+
stopScroll(); stopFiles(); menu?.destroy?.(); wired?.dispose(); shown.dispose();
|
|
329
|
+
[cur, thread, status, statusLabel, tone, archived, hasDraft].forEach(c => c.dispose?.());
|
|
330
|
+
},
|
|
296
331
|
html: `<div class="sp-detail">
|
|
297
332
|
<dl class="sp-facts">
|
|
298
333
|
<div><dt>Status</dt><dd><span class="sp-chip" data-bind-class="tone.value" data-bind-text="statusLabel.value"></span>
|
|
@@ -306,21 +341,24 @@ export async function mount(root, ctx) {
|
|
|
306
341
|
${canWrite ? `<div class="sp-reply">
|
|
307
342
|
<label class="form-label" for="sp-write" data-bind-text="status.value === 'closed' ? 'Write again (this reopens it)' : 'Reply'"></label>
|
|
308
343
|
<textarea id="sp-write" class="form-textarea" rows="4" maxlength="8000" data-model="draft.value" placeholder="Write to Domma Support"></textarea>
|
|
344
|
+
${attachUi('Files go with a message - paste a screenshot into it, or attach one.')}
|
|
309
345
|
<div class="sp-actions">
|
|
310
346
|
<button type="button" class="btn btn-sm btn-ghost" data-on-click="resolve" data-bind-hidden="status.value === 'closed'" data-bind-disabled="busy.value"><span data-icon="check-circle" data-icon-size="13"></span> Mark resolved</button>
|
|
311
347
|
<button type="button" class="btn btn-sm btn-ghost" data-on-click="archive" data-bind-disabled="busy.value">
|
|
312
348
|
<span data-icon="archive" data-icon-size="13"></span> <span data-bind-text="archived.value ? 'Back to your requests' : 'Archive'"></span></button>
|
|
313
349
|
<span class="sp-spacer"></span>
|
|
314
|
-
<button type="button" class="btn btn-sm btn-primary" data-on-click="send" data-bind-disabled="busy.value || !hasDraft.value"><span data-icon="send" data-icon-size="13"></span> Send</button>
|
|
350
|
+
<button type="button" class="btn btn-sm btn-primary" data-on-click="send" data-bind-disabled="busy.value || uploading.value || !hasDraft.value"><span data-icon="send" data-icon-size="13"></span> Send</button>
|
|
315
351
|
</div>
|
|
316
352
|
</div>` : '<p class="sp-note">You can write again once this request reaches Domma.</p>'}
|
|
317
353
|
</div>`,
|
|
318
354
|
methods: {
|
|
355
|
+
pickFiles: () => panel?.panel.querySelector('.sp-file-input')?.click(),
|
|
319
356
|
send: () => run(async () => {
|
|
320
|
-
if (!draft.peek().trim()) return;
|
|
321
|
-
const r = await api.write(ref, draft.peek());
|
|
357
|
+
if (!draft.peek().trim() || uploading.peek()) return;
|
|
358
|
+
const r = await api.write(ref, draft.peek(), wired?.picker.attachments() || []);
|
|
322
359
|
if (r.state !== 'sent') E.toast(r.message, {type: 'warning'});
|
|
323
360
|
draft.value = '';
|
|
361
|
+
wired?.picker.clear();
|
|
324
362
|
await load();
|
|
325
363
|
}),
|
|
326
364
|
resolve: () => run(() => actions.resolve(cur.peek())),
|
|
@@ -333,6 +371,11 @@ export async function mount(root, ctx) {
|
|
|
333
371
|
});
|
|
334
372
|
const box = panel.panel.querySelector('.sp-thread-host');
|
|
335
373
|
stopScroll = M.effect(() => { thread.value; Promise.resolve().then(() => { M.flush?.(); box.scrollTop = box.scrollHeight; }); });
|
|
374
|
+
if (canWrite) wired = wirePicker(panel, '#sp-write', room, uploading);
|
|
375
|
+
// Thumbnails for the images in the thread, as messages come; click a file to open it.
|
|
376
|
+
const fileStop = M.effect(() => { shown.load(cur.value.messages || []); });
|
|
377
|
+
const keysOff = fileKeys(box, (id) => shown.open(findFile(cur.peek().messages, id)));
|
|
378
|
+
stopFiles = () => { fileStop(); keysOff(); };
|
|
336
379
|
menu = E.contextMenu(box, {
|
|
337
380
|
cursor: 'glyph', match: '.sp-msg', inherit: false, density: 'compact',
|
|
338
381
|
items: (li) => {
|
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
* still sees the request - and a customer writing again takes it back out.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
THREAD, TONE, api, copyText, esc, fileKeys, findFile, listOf, openPanel, refreshBadge, rowKeys, stamp, store, threadFiles, threadRows, when
|
|
13
|
+
} from '../lib/kit.js';
|
|
12
14
|
|
|
13
15
|
const ROW = `
|
|
14
16
|
<div class="sp-row" data-id="{{key}}" tabindex="0" role="button" aria-haspopup="menu">
|
|
@@ -64,7 +66,9 @@ export async function mount(root, ctx) {
|
|
|
64
66
|
subject: t.subject,
|
|
65
67
|
icon: iconOf(t.category),
|
|
66
68
|
categoryLabel: labelOf(t.category),
|
|
67
|
-
meta: [t.siteName, t.userName, `${t.lastFrom === 'domma' ? 'we wrote' : 'they wrote'} ${when(t.lastAt)}
|
|
69
|
+
meta: [t.siteName, t.userName, `${t.lastFrom === 'domma' ? 'we wrote' : 'they wrote'} ${when(t.lastAt)}`,
|
|
70
|
+
(() => { const n = (t.messages || []).reduce((k, m) => k + (m.attachments || []).length, 0); return n ? `${n} file${n === 1 ? '' : 's'}` : ''; })()]
|
|
71
|
+
.filter(Boolean).join(' · '),
|
|
68
72
|
statusLabel: inboxLabel(t.status),
|
|
69
73
|
tone: `is-${TONE[t.status] || 'muted'}`,
|
|
70
74
|
urgent: t.priority === 'urgent' && t.status !== 'closed'
|
|
@@ -173,7 +177,12 @@ export async function mount(root, ctx) {
|
|
|
173
177
|
|
|
174
178
|
// The panel follows the list's copy, so a move from the row menu shows here at once.
|
|
175
179
|
const cur = M.computed(() => tickets.value.find(x => x.id === tid) || t);
|
|
176
|
-
|
|
180
|
+
// A file follows its message from the site; until it lands it is listed but cannot open.
|
|
181
|
+
const arrived = (a) => a.stored !== false;
|
|
182
|
+
const shown = threadFiles((a) => api.ticketFileUrl(tid, a.id), arrived);
|
|
183
|
+
const thread = M.computed(() => threadRows(cur.value.messages || [], 'domma',
|
|
184
|
+
{thumbs: shown.thumbs.value, available: arrived, missing: 'on its way from the site'}));
|
|
185
|
+
let stopFiles = () => {};
|
|
177
186
|
const status = M.computed(() => cur.value.status);
|
|
178
187
|
const statusLabel = M.computed(() => inboxLabel(cur.value.status));
|
|
179
188
|
const tone = M.computed(() => `is-${TONE[cur.value.status] || 'muted'}`);
|
|
@@ -200,7 +209,7 @@ export async function mount(root, ctx) {
|
|
|
200
209
|
size: 'lg',
|
|
201
210
|
model: {thread, status, statusLabel, tone, urgent, archived, draft, hasDraft, after, notes, busy},
|
|
202
211
|
cleanup: () => {
|
|
203
|
-
stopScroll(); menu?.destroy?.();
|
|
212
|
+
stopScroll(); stopFiles(); shown.dispose(); menu?.destroy?.();
|
|
204
213
|
[cur, thread, status, statusLabel, tone, urgent, archived, hasDraft].forEach(c => c.dispose?.());
|
|
205
214
|
if (openPanel_?.tid === tid) openPanel_ = null;
|
|
206
215
|
},
|
|
@@ -266,6 +275,10 @@ export async function mount(root, ctx) {
|
|
|
266
275
|
// Keep the newest message in view as the thread grows.
|
|
267
276
|
const box = panel.panel.querySelector('.sp-thread-host');
|
|
268
277
|
stopScroll = M.effect(() => { thread.value; Promise.resolve().then(() => { M.flush?.(); box.scrollTop = box.scrollHeight; }); });
|
|
278
|
+
// Thumbnails for the images that have arrived; click a file to open it in a new tab.
|
|
279
|
+
const fileStop = M.effect(() => { shown.load(cur.value.messages || []); });
|
|
280
|
+
const keysOff = fileKeys(box, (fid) => shown.open(findFile(cur.peek().messages, fid)));
|
|
281
|
+
stopFiles = () => { fileStop(); keysOff(); };
|
|
269
282
|
// A message: copy it, or quote it into the reply.
|
|
270
283
|
menu = E.contextMenu(box, {
|
|
271
284
|
cursor: 'glyph', match: '.sp-msg', inherit: false, density: 'compact',
|
|
@@ -12,9 +12,10 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import {api, loadKit} from '../lib/kit.js';
|
|
15
|
+
import {addSettingsCog} from '../lib/settings.js';
|
|
15
16
|
|
|
16
17
|
const ROUTE = /^#\/plugins\/support(\/|$)/;
|
|
17
|
-
const V = '1.1.
|
|
18
|
+
const V = '1.1.6';
|
|
18
19
|
let lifetime = null;
|
|
19
20
|
|
|
20
21
|
export const supportView = {
|
|
@@ -50,6 +51,8 @@ export const supportView = {
|
|
|
50
51
|
return;
|
|
51
52
|
}
|
|
52
53
|
if (!here()) return;
|
|
54
|
+
// The cog (1.1.5) - this side's settings, for those who may change them. Never blocks the screen.
|
|
55
|
+
addSettingsCog($container, kit).catch(() => {});
|
|
53
56
|
try {
|
|
54
57
|
const screen = mode.mode === 'receiver' ? 'inbox' : 'desk';
|
|
55
58
|
const mod = await import(`/plugins/support/admin/views/${screen}.js?v=${V}`);
|
|
@@ -20,7 +20,8 @@ Support gets you help from Domma without leaving your admin: our email, phone an
|
|
|
20
20
|
4. Type a **Subject** (one line) and your **Message**.
|
|
21
21
|
5. Choose **Normal** (we answer in order, usually within a working day) or **Urgent** (your site is down, or you cannot take orders, bookings or payments).
|
|
22
22
|
6. Optional: fill in **Where** - the screen or page it is about, for example "Pages" or `/contact`.
|
|
23
|
-
7.
|
|
23
|
+
7. Optional: click **Attach a file**, or paste a screenshot straight into the message. Up to 3 files of 2 MB each: PNG, JPEG, GIF or WebP images, a PDF or a text file.
|
|
24
|
+
8. Click **Send**.
|
|
24
25
|
|
|
25
26
|
For something that is not working, say what you did, what you expected and what happened instead.
|
|
26
27
|
|
|
@@ -37,7 +38,7 @@ Each row shows the subject, when it was last written to, and a status:
|
|
|
37
38
|
| Resolved | Finished. Writing again reopens it |
|
|
38
39
|
| Waiting to send | Domma could not be reached yet; it goes automatically |
|
|
39
40
|
|
|
40
|
-
A dot on a row means a new reply. Click a row to read the conversation and **Reply
|
|
41
|
+
A dot on a row means a new reply. Click a row to read the conversation and **Reply** - you can attach files to a reply too. Files show under the message they came with; click one to open it. Only the Domma team sees them - they are never put in your Media library or on your website. Right-click a row (or use the row's menu) to **Archive**, **Resolve** or **Remove** it:
|
|
41
42
|
|
|
42
43
|
- **Archive** tidies a request away under **Archived**. It comes back by itself if Domma writes to it.
|
|
43
44
|
- **Resolve** closes it. You can still write to it, which reopens it.
|
|
@@ -49,11 +50,7 @@ If the screen offers the contact card only and says to email us, your site was n
|
|
|
49
50
|
|
|
50
51
|
## Settings
|
|
51
52
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
| Option | Default | Meaning |
|
|
55
|
-
|---|---|---|
|
|
56
|
-
| `syncMinutes` | 5 | How often the site sends waiting requests and checks for replies |
|
|
53
|
+
Administrators see a **Support settings** cog in the banner. It has one setting, **Check for replies every (minutes)** - how often the site sends anything still waiting and looks for replies in the background (default 5). A change takes effect at once, no restart.
|
|
57
54
|
|
|
58
55
|
Opening the Support screen also sends anything waiting and checks for replies straight away. The contact card is set by Domma and arrives with the first sync.
|
|
59
56
|
|
|
@@ -77,14 +74,15 @@ Only the **admin** role has them by default. Everyone with **Send** sees every r
|
|
|
77
74
|
On the Domma server itself (the Manager), the same screen is the **Inbox** of every site's requests, for holders of **Support > Manage**:
|
|
78
75
|
|
|
79
76
|
- Filter by **Open**, **Answered**, **Resolved**, **Archived** or **All**, urgent first; search subject, text, person and site.
|
|
80
|
-
- Open a request to read the conversation, **Reply** (the site sees it and is notified, and the person is emailed), **Resolve**, change its priority, and keep **Internal notes** (never sent to the site).
|
|
77
|
+
- Open a request to read the conversation (with any files the customer attached - one still on its way says so), **Reply** (the site sees it and is notified, and the person is emailed), **Resolve**, change its priority, and keep **Internal notes** (never sent to the site).
|
|
81
78
|
- Right-click a row to archive, resolve or **Delete** it. Archiving is Domma's own filing: the site does not see it, and a request comes out of the archive when the customer writes again.
|
|
82
79
|
- A new request or a customer's reply is emailed to the Domma support address, with the customer as Reply-To and `[URGENT]` in the subject when it is urgent.
|
|
80
|
+
- The **Support settings** cog sets the contact card every site shows (email, phone, opening hours, how fast you answer), the address new requests are emailed to, and how many messages one site may send in a window. Changes apply at once; each site gets the new card with its next sync.
|
|
83
81
|
|
|
84
82
|
## Limitations
|
|
85
83
|
|
|
86
84
|
- Only sites started by a Domma server can send. A standalone install gets the contact card only.
|
|
87
|
-
-
|
|
85
|
+
- Up to 3 files per message, 2 MB each, and only images (PNG, JPEG, GIF, WebP), PDFs and text files. A message with files still needs a few words.
|
|
88
86
|
- Subjects are limited to 140 characters and messages to 8,000.
|
|
89
87
|
- The Domma server accepts up to 20 requests and replies from one site in 10 minutes. Any more show as **Waiting to send** and go later.
|
|
90
88
|
- You cannot edit or withdraw a message once sent. Write a follow-up instead.
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Support - attachments on disk, and reading one out of an upload.
|
|
3
|
+
*
|
|
4
|
+
* A site keeps the files it attached under data/attachments/<id>.<ext> (with a
|
|
5
|
+
* <id>.json beside each saying what it is); the manager keeps a request's files
|
|
6
|
+
* under data/attachments/<request entry id>/. Files are 0600 in 0700 folders:
|
|
7
|
+
* they are screenshots of admin screens, nothing a web server should ever list.
|
|
8
|
+
* Nothing here is served from a public path - a route reads and sends them.
|
|
9
|
+
*
|
|
10
|
+
* @module support/lib/attachments
|
|
11
|
+
*/
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import {ATTACHMENTS, ATTACHMENT_TYPES, cleanAttachment, extOf} from './shape.js';
|
|
15
|
+
|
|
16
|
+
/** A folder name that is safe to join: a collection entry id, or our own staging name. */
|
|
17
|
+
const SUB_RE = /^[A-Za-z0-9_-]{1,80}$/;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Read ONE file out of a multipart request, within the caps. The whole file is
|
|
21
|
+
* held in memory - it is at most ATTACHMENTS.maxBytes.
|
|
22
|
+
*
|
|
23
|
+
* @returns {Promise<{file?: {name: string, type: string, size: number, buffer: Buffer}, error?: string, code?: number}>}
|
|
24
|
+
*/
|
|
25
|
+
export async function readUpload(request) {
|
|
26
|
+
if (typeof request.isMultipart !== 'function' || !request.isMultipart()) return {error: 'Send the file as a form upload.', code: 400};
|
|
27
|
+
let part;
|
|
28
|
+
try { part = await request.file(); } catch (err) { return {error: `That upload could not be read: ${err.message}`, code: 400}; }
|
|
29
|
+
if (!part) return {error: 'No file was sent.', code: 400};
|
|
30
|
+
const type = String(part.mimetype || '').toLowerCase().split(';')[0].trim();
|
|
31
|
+
const drain = async () => { for await (const _ of part.file) { /* a refused part must still be consumed */ } };
|
|
32
|
+
if (!ATTACHMENT_TYPES[type]) {
|
|
33
|
+
await drain();
|
|
34
|
+
return {error: `That kind of file (${type || 'unknown'}) cannot be attached. Attach a PNG, JPEG, GIF or WebP image, a PDF or a text file.`, code: 400};
|
|
35
|
+
}
|
|
36
|
+
const chunks = [];
|
|
37
|
+
let size = 0;
|
|
38
|
+
let over = false;
|
|
39
|
+
for await (const chunk of part.file) {
|
|
40
|
+
size += chunk.length;
|
|
41
|
+
// Keep reading so the request completes, but keep nothing more.
|
|
42
|
+
if (size > ATTACHMENTS.maxBytes) { over = true; continue; }
|
|
43
|
+
chunks.push(chunk);
|
|
44
|
+
}
|
|
45
|
+
if (over || part.file.truncated) return {error: `That file is too big - up to ${Math.round(ATTACHMENTS.maxBytes / 1024 / 1024)} MB each.`, code: 413};
|
|
46
|
+
if (!size) return {error: 'That file is empty.', code: 400};
|
|
47
|
+
return {file: {name: part.filename, type, size, buffer: Buffer.concat(chunks)}};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Send a stored file: its own type, shown inline, never sniffed into anything else.
|
|
52
|
+
* Private cache only - it needs the session that fetched it.
|
|
53
|
+
*/
|
|
54
|
+
export function sendFile(reply, a, buffer) {
|
|
55
|
+
return reply
|
|
56
|
+
.type(a.type)
|
|
57
|
+
.header('content-disposition', `inline; filename="${a.name.replace(/["\\]/g, '_')}"`)
|
|
58
|
+
.header('x-content-type-options', 'nosniff')
|
|
59
|
+
.header('cache-control', 'private, max-age=300')
|
|
60
|
+
.send(buffer);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The files under one root. Every path is built from a cleaned attachment and a
|
|
65
|
+
* checked sub-folder name - nothing from a request reaches `path.join` raw.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} root - e.g. <plugin data dir>/attachments
|
|
68
|
+
*/
|
|
69
|
+
export function createStore(root) {
|
|
70
|
+
const dirOf = (sub) => {
|
|
71
|
+
if (sub && !SUB_RE.test(sub)) throw new Error('bad attachment folder');
|
|
72
|
+
return sub ? path.join(root, sub) : root;
|
|
73
|
+
};
|
|
74
|
+
const ensure = (sub) => fs.mkdirSync(dirOf(sub), {recursive: true, mode: 0o700});
|
|
75
|
+
const clean = (a) => { const c = cleanAttachment(a); if (!c) throw new Error('bad attachment'); return c; };
|
|
76
|
+
const fileOf = (sub, a) => path.join(dirOf(sub), `${a.id}.${extOf(a.type)}`);
|
|
77
|
+
const metaOf = (sub, id) => path.join(dirOf(sub), `${id}.json`);
|
|
78
|
+
return {
|
|
79
|
+
root,
|
|
80
|
+
/** Keep a file and what it is. */
|
|
81
|
+
save(att, buffer, sub = '') {
|
|
82
|
+
const a = clean(att);
|
|
83
|
+
ensure(sub);
|
|
84
|
+
fs.writeFileSync(fileOf(sub, a), buffer, {mode: 0o600});
|
|
85
|
+
fs.writeFileSync(metaOf(sub, a.id), JSON.stringify({id: a.id, name: a.name, type: a.type, size: a.size, at: new Date().toISOString()}), {mode: 0o600});
|
|
86
|
+
return a;
|
|
87
|
+
},
|
|
88
|
+
/** What a kept file is, from its sidecar - null when unknown. */
|
|
89
|
+
meta(id, sub = '') {
|
|
90
|
+
if (!/^[A-Za-z0-9-]{8,64}$/.test(String(id))) return null;
|
|
91
|
+
try { return cleanAttachment(JSON.parse(fs.readFileSync(metaOf(sub, id), 'utf8'))); } catch { return null; }
|
|
92
|
+
},
|
|
93
|
+
/** The bytes, or null. */
|
|
94
|
+
read(att, sub = '') {
|
|
95
|
+
try { return fs.readFileSync(fileOf(sub, clean(att))); } catch { return null; }
|
|
96
|
+
},
|
|
97
|
+
/** Move a file between folders. */
|
|
98
|
+
move(att, from, to) {
|
|
99
|
+
const a = clean(att);
|
|
100
|
+
ensure(to);
|
|
101
|
+
fs.renameSync(fileOf(from, a), fileOf(to, a));
|
|
102
|
+
try { fs.unlinkSync(metaOf(from, a.id)); } catch { /* no sidecar */ }
|
|
103
|
+
},
|
|
104
|
+
/** A request went: so do its files. */
|
|
105
|
+
removeAll(sub) {
|
|
106
|
+
if (!sub) return;
|
|
107
|
+
try { fs.rmSync(dirOf(sub), {recursive: true, force: true}); } catch { /* already gone */ }
|
|
108
|
+
},
|
|
109
|
+
/**
|
|
110
|
+
* Files picked but never sent (the panel was closed) are dropped once they are older
|
|
111
|
+
* than `maxAgeMs` and nothing still names them.
|
|
112
|
+
*/
|
|
113
|
+
prune({maxAgeMs = 24 * 3600_000, keep = new Set(), sub = ''} = {}) {
|
|
114
|
+
let dir;
|
|
115
|
+
try { dir = fs.readdirSync(dirOf(sub)); } catch { return 0; }
|
|
116
|
+
let dropped = 0;
|
|
117
|
+
const cutoff = Date.now() - maxAgeMs;
|
|
118
|
+
for (const f of dir) {
|
|
119
|
+
const m = f.match(/^([A-Za-z0-9-]{8,64})\.json$/);
|
|
120
|
+
if (!m || keep.has(m[1])) continue;
|
|
121
|
+
const a = this.meta(m[1], sub);
|
|
122
|
+
const at = new Date(a?.at || 0).getTime();
|
|
123
|
+
let stat;
|
|
124
|
+
try { stat = fs.statSync(metaOf(sub, m[1])); } catch { continue; }
|
|
125
|
+
if (Math.max(at || 0, stat.mtimeMs) > cutoff) continue;
|
|
126
|
+
try { if (a) fs.unlinkSync(fileOf(sub, a)); } catch { /* gone */ }
|
|
127
|
+
try { fs.unlinkSync(metaOf(sub, m[1])); } catch { /* gone */ }
|
|
128
|
+
dropped++;
|
|
129
|
+
}
|
|
130
|
+
return dropped;
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A short memory for whole-collection reads.
|
|
3
|
+
*
|
|
4
|
+
* On the manager, the badge (polled once a minute from every open admin tab
|
|
5
|
+
* with `manage`), every site's `/fleet/mine` (nudged once a minute by its own
|
|
6
|
+
* badge) and `byRef()` each read EVERY request. The data changes only when
|
|
7
|
+
* someone writes, so a read is kept for a little while and every write in
|
|
8
|
+
* lib/receiver.js throws the memory away (`forget()`, right after the write,
|
|
9
|
+
* so the writer's next read is fresh). The TTL is the safety net for writes
|
|
10
|
+
* this plugin never sees (the Collections tool).
|
|
11
|
+
*
|
|
12
|
+
* PURE - no I/O of its own; the loader is handed in. Tested in tests/cache.test.js.
|
|
13
|
+
*
|
|
14
|
+
* @module support/lib/cache
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export const CACHE_MS = 30_000;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {{ttl?: number, clock?: () => number}} [opts]
|
|
21
|
+
* @returns {{get: (key: string, load: () => Promise<any>) => Promise<any>, forget: () => void, size: number}}
|
|
22
|
+
*/
|
|
23
|
+
export function createCache({ttl = CACHE_MS, clock = Date.now} = {}) {
|
|
24
|
+
// key -> {at, value: Promise}. The PROMISE is kept, not the result, so
|
|
25
|
+
// concurrent misses share one read, and a write landing while a read is in
|
|
26
|
+
// flight clears the map before that read can be stored - nothing stale
|
|
27
|
+
// survives a forget().
|
|
28
|
+
const kept = new Map();
|
|
29
|
+
return {
|
|
30
|
+
get(key, load) {
|
|
31
|
+
const hit = kept.get(key);
|
|
32
|
+
if (hit && clock() - hit.at < ttl) return hit.value;
|
|
33
|
+
const value = Promise.resolve().then(load);
|
|
34
|
+
const entry = {at: clock(), value};
|
|
35
|
+
kept.set(key, entry);
|
|
36
|
+
// A failed read is not remembered: the next caller tries again.
|
|
37
|
+
value.catch(() => { if (kept.get(key) === entry) kept.delete(key); });
|
|
38
|
+
return value;
|
|
39
|
+
},
|
|
40
|
+
forget() { kept.clear(); },
|
|
41
|
+
get size() { return kept.size; }
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -15,6 +15,7 @@ import fs from 'node:fs';
|
|
|
15
15
|
import path from 'node:path';
|
|
16
16
|
import {fileURLToPath} from 'node:url';
|
|
17
17
|
import {createCollection, createEntry, deleteEntry, getCollection, getEntry, listEntries, updateEntry} from '../../../server/services/collections.js';
|
|
18
|
+
import {createCache} from './cache.js';
|
|
18
19
|
import {addTurn, receivedTicket} from './shape.js';
|
|
19
20
|
|
|
20
21
|
export const COLLECTION = 'support-tickets';
|
|
@@ -71,10 +72,17 @@ export async function ensureCollection() {
|
|
|
71
72
|
return true;
|
|
72
73
|
}
|
|
73
74
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
75
|
+
// The whole-collection read, kept a short while (lib/cache.js). Every write
|
|
76
|
+
// below calls forget() so the writer's own next read - and the badge - is fresh.
|
|
77
|
+
const remembered = createCache();
|
|
78
|
+
export const forget = () => remembered.forget();
|
|
79
|
+
|
|
80
|
+
/** Every request, newest first - the SAME array to every caller inside the cache window: filter, never change it. */
|
|
81
|
+
export function allTickets() {
|
|
82
|
+
return remembered.get('tickets', async () => {
|
|
83
|
+
const {entries} = await listEntries(COLLECTION, {limit: 100000, sort: 'createdAt', order: 'desc'});
|
|
84
|
+
return entries || [];
|
|
85
|
+
});
|
|
78
86
|
}
|
|
79
87
|
|
|
80
88
|
export const getTicket = (id) => getEntry(COLLECTION, id);
|
|
@@ -95,7 +103,9 @@ export async function removeTicket(id) {
|
|
|
95
103
|
fs.mkdirSync(path.dirname(DELETED), {recursive: true});
|
|
96
104
|
fs.writeFileSync(DELETED, JSON.stringify(gone));
|
|
97
105
|
}
|
|
98
|
-
|
|
106
|
+
const r = await deleteEntry(COLLECTION, id);
|
|
107
|
+
forget();
|
|
108
|
+
return r;
|
|
99
109
|
}
|
|
100
110
|
|
|
101
111
|
/** One site's requests - never another's. */
|
|
@@ -127,7 +137,9 @@ async function openNow(slug, payload, now, site) {
|
|
|
127
137
|
if (!data.ref) return {error: 'A request needs a reference.'};
|
|
128
138
|
const same = await byRef(slug, data.ref);
|
|
129
139
|
if (same) return {entry: same, created: false};
|
|
130
|
-
|
|
140
|
+
const entry = await createEntry(COLLECTION, data, {source: 'fleet'});
|
|
141
|
+
forget();
|
|
142
|
+
return {entry, created: true};
|
|
131
143
|
}
|
|
132
144
|
|
|
133
145
|
/**
|
|
@@ -148,11 +160,30 @@ async function customerTurnNow(id, payload, now) {
|
|
|
148
160
|
const body = String(payload.body || '').trim();
|
|
149
161
|
if (!body && !payload.close) return {error: 'Write a message first.'};
|
|
150
162
|
const name = String(payload.user?.name || '').trim() || e.data.userName;
|
|
151
|
-
const {data, added} = addTurn(e.data, {from: 'customer', name, body, id: String(payload.ref || ''), at: now, close: Boolean(payload.close)
|
|
163
|
+
const {data, added} = addTurn(e.data, {from: 'customer', name, body, id: String(payload.ref || ''), at: now, close: Boolean(payload.close),
|
|
164
|
+
attachments: payload.attachments});
|
|
152
165
|
if (!added) return {entry: e, added: false};
|
|
153
|
-
|
|
166
|
+
const entry = await updateEntry(COLLECTION, e.id, data);
|
|
167
|
+
forget();
|
|
168
|
+
return {entry, added: true};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** A request's file has arrived: mark it stored on its message. No history line - nothing moved. */
|
|
172
|
+
export function markStored(id, attId) {
|
|
173
|
+
return serial(id, async () => {
|
|
174
|
+
const e = await getTicket(id);
|
|
175
|
+
if (!e) return null;
|
|
176
|
+
const messages = (e.data.messages || []).map(m => (m.attachments?.some(a => a.id === attId)
|
|
177
|
+
? {...m, attachments: m.attachments.map(a => (a.id === attId ? {...a, stored: true} : a))} : m));
|
|
178
|
+
const next = await updateEntry(COLLECTION, id, {...e.data, messages});
|
|
179
|
+
forget();
|
|
180
|
+
return next;
|
|
181
|
+
});
|
|
154
182
|
}
|
|
155
183
|
|
|
184
|
+
/** That site's request with this ref (a site knows its requests by ref, never by the manager's id). */
|
|
185
|
+
export const ticketByRef = (slug, ref) => byRef(slug, String(ref || ''));
|
|
186
|
+
|
|
156
187
|
/** Domma replied (and/or moved the status). */
|
|
157
188
|
export function dommaTurn(id, turn, now = new Date().toISOString()) {
|
|
158
189
|
return serial(id, () => dommaTurnNow(id, turn, now));
|
|
@@ -162,7 +193,9 @@ async function dommaTurnNow(id, {body = '', status, by, msgId}, now) {
|
|
|
162
193
|
const e = await getTicket(id);
|
|
163
194
|
if (!e) return null;
|
|
164
195
|
const {data} = addTurn(e.data, {from: 'domma', name: by, body, id: msgId, at: now, status});
|
|
165
|
-
|
|
196
|
+
const next = await updateEntry(COLLECTION, id, data);
|
|
197
|
+
forget();
|
|
198
|
+
return next;
|
|
166
199
|
}
|
|
167
200
|
|
|
168
201
|
/** Status, priority, notes or the archive, from the inbox - with a line of history for what moved. */
|
|
@@ -179,5 +212,7 @@ async function patchNow(id, change, by, now) {
|
|
|
179
212
|
if (change.priority !== undefined && change.priority !== d.priority) history.push({at: now, by, what: `Priority: ${change.priority}`});
|
|
180
213
|
if (change.archived !== undefined && change.archived !== Boolean(d.archived)) history.push({at: now, by, what: change.archived ? 'Archived' : 'Back from the archive'});
|
|
181
214
|
Object.assign(d, change, {history: history.slice(-200)});
|
|
182
|
-
|
|
215
|
+
const next = await updateEntry(COLLECTION, id, d);
|
|
216
|
+
forget();
|
|
217
|
+
return next;
|
|
183
218
|
}
|