domma-cms 1.6.36 → 1.6.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/compose-window.js +4 -0
- package/plugins/_lib/admin/mail/mail.css +1 -1
- package/plugins/_lib/admin/mail/reader-view.js +15 -6
- package/plugins/_lib/mail/compose.js +7 -2
- package/plugins/_lib/mail/message-action.js +56 -0
- package/plugins/_lib/mail/scheduler.js +2 -0
- package/plugins/_lib/mail/send.js +12 -3
- package/plugins/_lib/mail/store.js +16 -6
- package/plugins/_lib/outreach/index.js +1 -1
- package/plugins/_lib/outreach/ledger.js +10 -3
- package/plugins/_lib/outreach/sending.js +9 -3
- package/plugins/_lib/outreach/suppression.js +14 -3
- package/plugins/_lib/outreach/unsubscribe.js +13 -0
- 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
- package/server/routes/outreach-public.js +54 -10
|
@@ -12,16 +12,20 @@
|
|
|
12
12
|
* POST /tickets/:ref/messages write again
|
|
13
13
|
* POST /tickets/:ref/close mark resolved
|
|
14
14
|
* PUT /tickets/:ref/seen Domma's latest reply has been read
|
|
15
|
+
* POST /attachments a file for the message being written (multipart, kept here)
|
|
16
|
+
* GET /attachments/:id one of this site's attached files
|
|
15
17
|
* PUT /tickets/:ref/shelf archive it, remove it from this site's list, or put it back
|
|
16
18
|
* ({state: archived|removed|null}); removing one that never
|
|
17
19
|
* reached Domma cancels it. Domma keeps its copy either way.
|
|
18
20
|
*
|
|
19
21
|
* manager (Site Manager is installed beside it)
|
|
20
22
|
* POST /fleet/tickets · POST /fleet/messages · GET /fleet/mine from the sites (Bearer fleet token)
|
|
23
|
+
* POST /fleet/attach/:ref/:att one of a request's announced files, raw bytes (Bearer fleet token)
|
|
21
24
|
* GET /mode · /meta · /count the badge counts requests waiting on Domma, red while one is urgent
|
|
22
25
|
* GET /tickets · GET /tickets/:id
|
|
23
26
|
* POST /tickets/:id/reply answer (and set the status), emailing the customer
|
|
24
27
|
* PUT /tickets/:id status, priority, internal notes, archived
|
|
28
|
+
* GET /tickets/:id/attachments/:att a file the customer attached
|
|
25
29
|
* DELETE /tickets/:id
|
|
26
30
|
*
|
|
27
31
|
* A standalone site (not started by a manager) still gets the contact card -
|
|
@@ -36,9 +40,11 @@ import path from 'node:path';
|
|
|
36
40
|
import {fileURLToPath} from 'node:url';
|
|
37
41
|
import defaults from './config.js';
|
|
38
42
|
import {
|
|
39
|
-
CATEGORIES, PRIORITIES, STATUSES, buildMessagePayload, buildTicketPayload,
|
|
40
|
-
contactCard, createThrottle, lastDommaAt, senderView, toCustomerEmail, toDommaEmail
|
|
43
|
+
ATTACHMENTS, CATEGORIES, PRIORITIES, STATUSES, attachmentsOf, buildMessagePayload, buildTicketPayload, cleanAttachment, cleanMessage,
|
|
44
|
+
cleanRequest, cleanSettings, cleanUpdate, contactCard, createThrottle, settingsErrors, lastDommaAt, senderView, toCustomerEmail, toDommaEmail
|
|
41
45
|
} from './lib/shape.js';
|
|
46
|
+
import {createStore, readUpload, sendFile} from './lib/attachments.js';
|
|
47
|
+
import {getPluginSettings, getPluginStates, savePluginState} from '../../server/services/plugins.js';
|
|
42
48
|
import {applyShelf, createOutbox, flush, managerApi, managerOf, mergeTickets, newRef} from './lib/sender.js';
|
|
43
49
|
|
|
44
50
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -59,9 +65,22 @@ export default async function supportPlugin(fastify, options) {
|
|
|
59
65
|
const {authenticate, requirePermission} = options.auth;
|
|
60
66
|
const hooks = options.hooks || {};
|
|
61
67
|
const can = (action) => ({preHandler: [authenticate, requirePermission(NAME, action)]});
|
|
62
|
-
const settings = {...defaults, ...(options.settings || {})};
|
|
63
68
|
const receiverMod = await import('./lib/receiver.js');
|
|
64
69
|
const mode = options.mode || (receiverMod.isManager() ? 'receiver' : 'sender');
|
|
70
|
+
// Settings are read when used (1.1.5), not once at boot: the cog's save, and
|
|
71
|
+
// Marketplace > Configure, take effect without a restart. `allSettings` is the
|
|
72
|
+
// whole merged object (a site's own contact card is its fallback before the
|
|
73
|
+
// manager's arrives); `liveSettings` is this side's keys, checked
|
|
74
|
+
// (lib/shape.js SIDE_SETTINGS). Tests inject a reader and writer.
|
|
75
|
+
const side = mode === 'receiver' ? 'receiver' : 'sender';
|
|
76
|
+
const readSettings = typeof options.readSettings === 'function' ? options.readSettings
|
|
77
|
+
: async () => getPluginSettings(NAME).catch(() => options.settings || {});
|
|
78
|
+
const writeSettings = typeof options.writeSettings === 'function' ? options.writeSettings
|
|
79
|
+
// Merged over what is stored: the object holds the other side's keys too.
|
|
80
|
+
: (next) => savePluginState(NAME, {settings: {...(getPluginStates()[NAME]?.settings || {}), ...next}});
|
|
81
|
+
const allSettings = async () => ({...defaults, ...(await readSettings().catch(() => ({})))});
|
|
82
|
+
const liveSettings = async () => cleanSettings(await allSettings(), defaults, side);
|
|
83
|
+
const settingsHooks = {saved: () => {}};
|
|
65
84
|
const notify = typeof hooks.notify === 'function' ? (n) => { try { hooks.notify(n); } catch { /* a notice is a courtesy */ } } : () => {};
|
|
66
85
|
const mail = options.mail || await import('./lib/mail.js');
|
|
67
86
|
if (typeof hooks.registerNotificationSource === 'function') {
|
|
@@ -77,23 +96,51 @@ export default async function supportPlugin(fastify, options) {
|
|
|
77
96
|
}
|
|
78
97
|
|
|
79
98
|
// On the manager the inbox needs it, so `manage` is enough there.
|
|
80
|
-
fastify.get('/meta', can(mode === 'receiver' ? 'manage' : 'send'), async () => ({categories: CATEGORIES, priorities: PRIORITIES, statuses: STATUSES
|
|
99
|
+
fastify.get('/meta', can(mode === 'receiver' ? 'manage' : 'send'), async () => ({categories: CATEGORIES, priorities: PRIORITIES, statuses: STATUSES,
|
|
100
|
+
attachments: {max: ATTACHMENTS.max, maxBytes: ATTACHMENTS.maxBytes}}));
|
|
101
|
+
|
|
102
|
+
// ---- The settings cog (1.1.5) ----
|
|
103
|
+
// A site's sync interval is its administrators' (requireAdmin); the manager's card,
|
|
104
|
+
// notify address and per-site brake are the inbox's (`manage`).
|
|
105
|
+
const settingsGuard = mode === 'receiver' ? can('manage')
|
|
106
|
+
: {preHandler: [authenticate, typeof options.auth.requireAdmin === 'function' ? options.auth.requireAdmin : requirePermission(NAME, 'send')]};
|
|
107
|
+
fastify.get('/settings', settingsGuard, async () => ({side, settings: await liveSettings()}));
|
|
108
|
+
fastify.put('/settings', settingsGuard, async (request, reply) => {
|
|
109
|
+
const errors = settingsErrors(request.body, side);
|
|
110
|
+
if (Object.keys(errors).length) return reply.code(400).send({error: Object.values(errors).join(' '), errors});
|
|
111
|
+
const next = cleanSettings(request.body, await liveSettings(), side);
|
|
112
|
+
await writeSettings(next);
|
|
113
|
+
settingsHooks.saved(next);
|
|
114
|
+
return {side, settings: next};
|
|
115
|
+
});
|
|
81
116
|
|
|
82
|
-
if (mode === 'receiver') await receiverRoutes(fastify, {can, notify, mail, receiverMod,
|
|
83
|
-
else await senderRoutes(fastify, {can, notify,
|
|
117
|
+
if (mode === 'receiver') await receiverRoutes(fastify, {can, notify, mail, receiverMod, liveSettings, options});
|
|
118
|
+
else await senderRoutes(fastify, {can, notify, allSettings, liveSettings, settingsHooks, options});
|
|
84
119
|
}
|
|
85
120
|
|
|
86
121
|
// ---------------------------------------------------------------------------------
|
|
87
122
|
// Site
|
|
88
123
|
// ---------------------------------------------------------------------------------
|
|
89
124
|
|
|
90
|
-
async function senderRoutes(fastify, {can, notify,
|
|
125
|
+
async function senderRoutes(fastify, {can, notify, allSettings, liveSettings, settingsHooks, options}) {
|
|
91
126
|
const env = options.env || process.env;
|
|
92
127
|
const manager = managerOf(env);
|
|
93
|
-
const
|
|
128
|
+
const dataDir = options.dataDir || path.join(HERE, 'data');
|
|
129
|
+
const outbox = createOutbox(dataDir);
|
|
94
130
|
const api = manager ? managerApi(manager, options.fetch) : null;
|
|
131
|
+
// Files attached to messages, kept here as well: they go after their message, a message waiting
|
|
132
|
+
// to send must still have them, and the thread shows them from here. Never under the public
|
|
133
|
+
// media folder - they are screenshots of this site's admin.
|
|
134
|
+
const files = createStore(path.join(dataDir, 'attachments'));
|
|
135
|
+
/** Files picked in a panel that was then closed: gone after a day. Those a message names stay. */
|
|
136
|
+
const pruneFiles = () => { try { files.prune({keep: outbox.fileIds()}); } catch { /* tidying only */ } };
|
|
137
|
+
/** What each attached file is comes from what was uploaded here, never from the form's say-so. */
|
|
138
|
+
const ownFiles = (list) => {
|
|
139
|
+
const out = list.map(a => files.meta(a.id));
|
|
140
|
+
return out.some(a => !a) ? null : out;
|
|
141
|
+
};
|
|
95
142
|
// The manager's card wins once heard; until then (and standalone) this site's settings.
|
|
96
|
-
const contact = () => outbox.contact() || contactCard(
|
|
143
|
+
const contact = async () => outbox.contact() || contactCard(await allSettings());
|
|
97
144
|
|
|
98
145
|
let lastSync = 0;
|
|
99
146
|
let syncing = null;
|
|
@@ -107,7 +154,7 @@ async function senderRoutes(fastify, {can, notify, settings, options}) {
|
|
|
107
154
|
lastSync = Date.now();
|
|
108
155
|
const seen = outbox.seen();
|
|
109
156
|
if (!api) return {tickets: mergeTickets(outbox.all(), [], seen), reachable: false, error: ''};
|
|
110
|
-
const pushed = await flush(outbox, api);
|
|
157
|
+
const pushed = await flush(outbox, api, files);
|
|
111
158
|
const mine = await api.mine();
|
|
112
159
|
const remote = mine.ok ? (mine.body?.tickets || []) : null;
|
|
113
160
|
if (remote) {
|
|
@@ -129,15 +176,26 @@ async function senderRoutes(fastify, {can, notify, settings, options}) {
|
|
|
129
176
|
}
|
|
130
177
|
|
|
131
178
|
// Every few minutes: waiting messages go, new replies are noticed. Off in tests.
|
|
179
|
+
// Each wait asks for syncMinutes again, and a save re-arms it (1.1.5).
|
|
132
180
|
if (api && options.timer !== false) {
|
|
133
|
-
|
|
134
|
-
|
|
181
|
+
let t = null;
|
|
182
|
+
let closed = false;
|
|
183
|
+
const arm = async () => {
|
|
184
|
+
clearTimeout(t);
|
|
185
|
+
if (closed) return;
|
|
186
|
+
const minutes = Math.max(1, Number((await liveSettings().catch(() => ({}))).syncMinutes) || 5);
|
|
187
|
+
if (closed) return;
|
|
188
|
+
t = setTimeout(() => { pruneFiles(); sync().catch(() => {}).finally(() => arm()); }, minutes * 60_000);
|
|
189
|
+
t.unref?.();
|
|
190
|
+
};
|
|
191
|
+
arm();
|
|
192
|
+
settingsHooks.saved = () => { arm(); };
|
|
135
193
|
const first = setTimeout(() => { sync().catch(() => {}); }, 30_000);
|
|
136
194
|
first.unref?.();
|
|
137
|
-
fastify.addHook('onClose', async () => {
|
|
195
|
+
fastify.addHook('onClose', async () => { closed = true; clearTimeout(t); clearTimeout(first); });
|
|
138
196
|
}
|
|
139
197
|
|
|
140
|
-
fastify.get('/mode', can('send'), async () => ({mode: 'sender', connected: Boolean(manager), site: manager?.slug || '', contact: contact()}));
|
|
198
|
+
fastify.get('/mode', can('send'), async () => ({mode: 'sender', connected: Boolean(manager), site: manager?.slug || '', contact: await contact()}));
|
|
141
199
|
|
|
142
200
|
fastify.get('/count', can('send'), async () => {
|
|
143
201
|
// The badge reads the last sync's copy and never waits on the manager - but it is
|
|
@@ -174,18 +232,36 @@ async function senderRoutes(fastify, {can, notify, settings, options}) {
|
|
|
174
232
|
|
|
175
233
|
fastify.get('/tickets', can('send'), async () => {
|
|
176
234
|
const r = await withDeadline(sync(), () => offlineView('Domma is slow to answer - showing what we last heard.'));
|
|
177
|
-
return {connected: Boolean(manager), reachable: r.reachable, error: r.error, contact: contact(), tickets: applyShelf(r.tickets, outbox.shelf())};
|
|
235
|
+
return {connected: Boolean(manager), reachable: r.reachable, error: r.error, contact: await contact(), tickets: applyShelf(r.tickets, outbox.shelf())};
|
|
178
236
|
});
|
|
179
237
|
|
|
180
238
|
const notConnected = (reply) => reply.code(409).send({error: 'This site is not connected to Domma, so a request cannot be sent from here. Email us instead.'});
|
|
181
239
|
|
|
240
|
+
fastify.post('/attachments', can('send'), async (request, reply) => {
|
|
241
|
+
if (!manager) return notConnected(reply);
|
|
242
|
+
const up = await readUpload(request);
|
|
243
|
+
if (up.error) return reply.code(up.code || 400).send({error: up.error});
|
|
244
|
+
return reply.code(201).send(files.save({id: randomUUID(), name: up.file.name, type: up.file.type, size: up.file.size}, up.file.buffer));
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
fastify.get('/attachments/:id', can('send'), async (request, reply) => {
|
|
248
|
+
const a = files.meta(String(request.params.id));
|
|
249
|
+
const buffer = a && files.read(a);
|
|
250
|
+
if (!buffer) return reply.code(404).send({error: 'That file is not here.'});
|
|
251
|
+
return sendFile(reply, a, buffer);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const missingFile = (reply) => reply.code(400).send({error: 'An attached file is no longer here - attach it again.'});
|
|
255
|
+
|
|
182
256
|
fastify.post('/tickets', can('send'), async (request, reply) => {
|
|
183
257
|
if (!manager) return notConnected(reply);
|
|
184
258
|
const {request: req, error} = cleanRequest(request.body || {});
|
|
185
259
|
if (error) return reply.code(400).send({error});
|
|
260
|
+
req.attachments = ownFiles(req.attachments);
|
|
261
|
+
if (!req.attachments) return missingFile(reply);
|
|
186
262
|
const payload = buildTicketPayload({request: req, user: request.user, cmsVersion: cmsVersion(), site: manager.slug, ref: newRef()});
|
|
187
263
|
outbox.add('ticket', payload);
|
|
188
|
-
const r = await withDeadline(flush(outbox, api), () => ({error: 'Domma is slow to answer'}));
|
|
264
|
+
const r = await withDeadline(flush(outbox, api, files), () => ({error: 'Domma is slow to answer'}));
|
|
189
265
|
const item = outbox.all().find(i => i.ref === payload.ref);
|
|
190
266
|
return reply.code(201).send({ref: payload.ref, state: item?.state || 'waiting',
|
|
191
267
|
message: item?.state === 'sent' ? 'Sent to Domma Support. We will reply here, and by email.'
|
|
@@ -198,16 +274,19 @@ async function senderRoutes(fastify, {can, notify, settings, options}) {
|
|
|
198
274
|
const known = outbox.tickets().some(t => t.ref === ticketRef) || outbox.all().some(i => i.kind === 'ticket' && i.ref === ticketRef);
|
|
199
275
|
if (!known) return reply.code(404).send({error: 'No such request on this site.'});
|
|
200
276
|
let body = '';
|
|
277
|
+
let attachments = [];
|
|
201
278
|
if (!close) {
|
|
202
279
|
const {message, error} = cleanMessage(request.body || {});
|
|
203
280
|
if (error) return reply.code(400).send({error});
|
|
204
281
|
body = message.body;
|
|
282
|
+
attachments = ownFiles(message.attachments);
|
|
283
|
+
if (!attachments) return missingFile(reply);
|
|
205
284
|
}
|
|
206
|
-
const payload = buildMessagePayload({ticketRef, ref: newRef(), body, close, user: request.user});
|
|
285
|
+
const payload = buildMessagePayload({ticketRef, ref: newRef(), body, close, user: request.user, attachments});
|
|
207
286
|
outbox.add('message', payload, ticketRef);
|
|
208
287
|
// Writing to an archived request brings it back to the list.
|
|
209
288
|
if (body) outbox.shelve(ticketRef, null);
|
|
210
|
-
await withDeadline(flush(outbox, api), () => ({}));
|
|
289
|
+
await withDeadline(flush(outbox, api, files), () => ({}));
|
|
211
290
|
const item = outbox.all().find(i => i.ref === payload.ref);
|
|
212
291
|
return reply.code(201).send({ref: payload.ref, state: item?.state || 'waiting',
|
|
213
292
|
message: item?.state === 'sent' ? (close ? 'Marked as resolved. Thank you.' : 'Sent.') : 'Kept here and sent as soon as Domma answers.'});
|
|
@@ -242,11 +321,31 @@ async function senderRoutes(fastify, {can, notify, settings, options}) {
|
|
|
242
321
|
// Manager
|
|
243
322
|
// ---------------------------------------------------------------------------------
|
|
244
323
|
|
|
245
|
-
async function receiverRoutes(fastify, {can, notify, mail, receiverMod,
|
|
324
|
+
async function receiverRoutes(fastify, {can, notify, mail, receiverMod, liveSettings, options}) {
|
|
246
325
|
const rx = receiverMod;
|
|
247
326
|
const slugOf = options.slugFromToken || rx.slugFromToken;
|
|
248
327
|
const directory = options.siteDirectory || rx.siteDirectory;
|
|
249
|
-
|
|
328
|
+
// The card every site shows, as set now.
|
|
329
|
+
const card = async () => contactCard(await liveSettings());
|
|
330
|
+
// The per-site brakes take their limits at each call (the cog changes them live).
|
|
331
|
+
const messageThrottle = createThrottle();
|
|
332
|
+
const fileThrottle = createThrottle();
|
|
333
|
+
const limits = async () => {
|
|
334
|
+
const s = await liveSettings().catch(() => ({}));
|
|
335
|
+
return {max: Number(s.perSiteMax) || 20, windowMs: (Number(s.perSiteMinutes) || 10) * 60_000};
|
|
336
|
+
};
|
|
337
|
+
const allowed = async (slug) => messageThrottle(slug, Date.now(), await limits());
|
|
338
|
+
// Files have their own brake: up to ATTACHMENTS.max per message allowed in the same window.
|
|
339
|
+
const allowedFile = async (slug) => {
|
|
340
|
+
const l = await limits();
|
|
341
|
+
return fileThrottle(slug, Date.now(), {max: l.max * ATTACHMENTS.max, windowMs: l.windowMs});
|
|
342
|
+
};
|
|
343
|
+
// A request's files live in data/attachments/<request entry id>/.
|
|
344
|
+
const files = createStore(path.join(options.dataDir || path.join(HERE, 'data'), 'attachments'));
|
|
345
|
+
// A site sends a file's bytes raw. Only inside this plugin's scope; the engine has no parser for it.
|
|
346
|
+
if (!fastify.hasContentTypeParser('application/octet-stream')) {
|
|
347
|
+
fastify.addContentTypeParser('application/octet-stream', {parseAs: 'buffer', bodyLimit: ATTACHMENTS.maxBytes + 1024}, (req, body, done) => done(null, body));
|
|
348
|
+
}
|
|
250
349
|
const log = fastify.log;
|
|
251
350
|
if (options.dataDir) rx.setDataDir(options.dataDir);
|
|
252
351
|
try { await rx.ensureCollection(); } catch (err) { log.error(`[support] collection: ${err.message}`); }
|
|
@@ -280,9 +379,10 @@ async function receiverRoutes(fastify, {can, notify, mail, receiverMod, settings
|
|
|
280
379
|
notify({title: `${d.priority === 'urgent' ? 'URGENT - ' : ''}${isNew ? 'Support request' : 'Reply'} from ${name}: ${d.subject}`,
|
|
281
380
|
body: body.slice(0, 300), severity: d.priority === 'urgent' ? 'warning' : 'info', source: 'new-request',
|
|
282
381
|
link: `#/plugins/support/${entry.id}`, dedupeKey: `support:${entry.id}:${d.lastAt}`});
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
382
|
+
// The address as set now; read without holding up the request that caused it.
|
|
383
|
+
liveSettings().then((s) => {
|
|
384
|
+
if (s.notifyEmail) mail.send({to: s.notifyEmail, ...toDommaEmail({ticket: d, siteName: name, link: inboxLink(entry.id), body, isNew})}, {log});
|
|
385
|
+
}).catch(() => {});
|
|
286
386
|
}
|
|
287
387
|
|
|
288
388
|
// ---- From the sites (no session: the fleet token IS the identity) ----
|
|
@@ -296,7 +396,7 @@ async function receiverRoutes(fastify, {can, notify, mail, receiverMod, settings
|
|
|
296
396
|
fastify.post('/fleet/tickets', {bodyLimit: 32 * 1024}, async (request, reply) => {
|
|
297
397
|
const slug = await fleet(request, reply);
|
|
298
398
|
if (!slug) return reply;
|
|
299
|
-
if (!allowed(slug)) return reply.code(429).send({error: 'That is a lot of requests at once - try again in a few minutes, or email us.'});
|
|
399
|
+
if (!await allowed(slug)) return reply.code(429).send({error: 'That is a lot of requests at once - try again in a few minutes, or email us.'});
|
|
300
400
|
const r = await rx.open(slug, request.body || {}, undefined, siteOf(slug));
|
|
301
401
|
if (r.missing) return reply.code(404).send({error: r.error});
|
|
302
402
|
if (r.error) return reply.code(400).send({error: r.error});
|
|
@@ -307,7 +407,7 @@ async function receiverRoutes(fastify, {can, notify, mail, receiverMod, settings
|
|
|
307
407
|
fastify.post('/fleet/messages', {bodyLimit: 32 * 1024}, async (request, reply) => {
|
|
308
408
|
const slug = await fleet(request, reply);
|
|
309
409
|
if (!slug) return reply;
|
|
310
|
-
if (!allowed(slug)) return reply.code(429).send({error: 'That is a lot of messages at once - try again in a few minutes.'});
|
|
410
|
+
if (!await allowed(slug)) return reply.code(429).send({error: 'That is a lot of messages at once - try again in a few minutes.'});
|
|
311
411
|
const r = await rx.customerTurn(slug, request.body || {});
|
|
312
412
|
if (r.missing) return reply.code(404).send({error: r.error});
|
|
313
413
|
if (r.error) return reply.code(400).send({error: r.error});
|
|
@@ -316,10 +416,29 @@ async function receiverRoutes(fastify, {can, notify, mail, receiverMod, settings
|
|
|
316
416
|
return {id: r.entry.id, status: r.entry.data.status};
|
|
317
417
|
});
|
|
318
418
|
|
|
419
|
+
fastify.post('/fleet/attach/:ref/:att', {bodyLimit: ATTACHMENTS.maxBytes + 1024}, async (request, reply) => {
|
|
420
|
+
const slug = await fleet(request, reply);
|
|
421
|
+
if (!slug) return reply;
|
|
422
|
+
if (!await allowedFile(slug)) return reply.code(429).send({error: 'That is a lot of files at once - try again in a few minutes.'});
|
|
423
|
+
// Looked up in THAT site's requests only: another site's ref is "not found".
|
|
424
|
+
const e = await rx.ticketByRef(slug, request.params.ref);
|
|
425
|
+
if (!e) return reply.code(404).send({error: 'No such request.'});
|
|
426
|
+
// Only a file a message announced, at the size and type it announced.
|
|
427
|
+
const announced = attachmentsOf(e.data).find(x => x.id === String(request.params.att));
|
|
428
|
+
const a = cleanAttachment(announced);
|
|
429
|
+
if (!a) return reply.code(404).send({error: 'That request has no such file.'});
|
|
430
|
+
if (announced.stored) return {id: a.id, stored: true};
|
|
431
|
+
const body = request.body;
|
|
432
|
+
if (!Buffer.isBuffer(body) || body.length !== a.size) return reply.code(400).send({error: 'That file is not the size the message said.'});
|
|
433
|
+
files.save(a, body, e.id);
|
|
434
|
+
await rx.markStored(e.id, a.id);
|
|
435
|
+
return reply.code(201).send({id: a.id, stored: true});
|
|
436
|
+
});
|
|
437
|
+
|
|
319
438
|
fastify.get('/fleet/mine', async (request, reply) => {
|
|
320
439
|
const slug = await fleet(request, reply);
|
|
321
440
|
if (!slug) return reply;
|
|
322
|
-
return {tickets: (await rx.ticketsOf(slug)).map(senderView), contact:
|
|
441
|
+
return {tickets: (await rx.ticketsOf(slug)).map(senderView), contact: await card()};
|
|
323
442
|
});
|
|
324
443
|
|
|
325
444
|
// ---- The inbox ----
|
|
@@ -329,8 +448,10 @@ async function receiverRoutes(fastify, {can, notify, mail, receiverMod, settings
|
|
|
329
448
|
/** One request for the panel: plus where a reply will be emailed, and why. */
|
|
330
449
|
const detail = async (e) => ({...row(e), replyTo: await replyTarget(e.data)});
|
|
331
450
|
|
|
332
|
-
fastify.get('/mode', can('manage'), async () =>
|
|
333
|
-
|
|
451
|
+
fastify.get('/mode', can('manage'), async () => {
|
|
452
|
+
const s = await liveSettings();
|
|
453
|
+
return {mode: 'receiver', connected: true, contact: contactCard(s), notifyEmail: s.notifyEmail || ''};
|
|
454
|
+
});
|
|
334
455
|
|
|
335
456
|
fastify.get('/count', can('manage'), async () => {
|
|
336
457
|
// The archive is out of sight: it counts for nothing (a customer writing again takes it out).
|
|
@@ -378,7 +499,7 @@ async function receiverRoutes(fastify, {can, notify, mail, receiverMod, settings
|
|
|
378
499
|
const target = await replyTarget(d);
|
|
379
500
|
if (target.email && (body || (d.status === 'closed' && e.data.status !== 'closed'))) {
|
|
380
501
|
// From the support mailbox and replying to it: a customer who answers the email reaches Domma Support.
|
|
381
|
-
const support =
|
|
502
|
+
const support = (await card()).email;
|
|
382
503
|
mail.send({to: target.email, ...(support && {from: support, replyTo: support}),
|
|
383
504
|
...toCustomerEmail({ticket: d, siteName: siteOf(d.site).name, link: siteLink(d.site, d.ref), body, resolved: d.status === 'closed'})}, {log});
|
|
384
505
|
}
|
|
@@ -393,10 +514,19 @@ async function receiverRoutes(fastify, {can, notify, mail, receiverMod, settings
|
|
|
393
514
|
return row(await rx.patch(e.id, patch, String(request.user?.name || 'Domma Support')));
|
|
394
515
|
});
|
|
395
516
|
|
|
517
|
+
fastify.get('/tickets/:id/attachments/:att', can('manage'), async (request, reply) => {
|
|
518
|
+
const e = await rx.getTicket(request.params.id);
|
|
519
|
+
const a = e && cleanAttachment(attachmentsOf(e.data).find(x => x.id === String(request.params.att)));
|
|
520
|
+
const buffer = a && files.read(a, e.id);
|
|
521
|
+
if (!buffer) return reply.code(404).send({error: 'That file has not arrived, or is gone.'});
|
|
522
|
+
return sendFile(reply, a, buffer);
|
|
523
|
+
});
|
|
524
|
+
|
|
396
525
|
fastify.delete('/tickets/:id', can('manage'), async (request, reply) => {
|
|
397
526
|
const e = await rx.getTicket(request.params.id);
|
|
398
527
|
if (!e) return reply.code(404).send({error: 'No such request.'});
|
|
399
528
|
await rx.removeTicket(e.id);
|
|
529
|
+
files.removeAll(e.id);
|
|
400
530
|
return {ok: true};
|
|
401
531
|
});
|
|
402
532
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "support",
|
|
3
3
|
"displayName": "Support",
|
|
4
|
-
"version": "1.1.
|
|
4
|
+
"version": "1.1.5",
|
|
5
5
|
"tier": "free",
|
|
6
6
|
"description": "Get help from Domma straight from your admin: our email, phone and hours always to hand, and a conversation with the Domma team for anything you need - urgent requests first. Right-click a request to archive, resolve or remove it.",
|
|
7
7
|
"author": "Domma CMS",
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
],
|
|
62
62
|
"views": {
|
|
63
63
|
"plugin-support": {
|
|
64
|
-
"entry": "support/admin/views/support.js?v=1.1.
|
|
64
|
+
"entry": "support/admin/views/support.js?v=1.1.5",
|
|
65
65
|
"exportName": "supportView"
|
|
66
66
|
}
|
|
67
67
|
}
|
|
@@ -11,6 +11,7 @@ import os from 'node:os';
|
|
|
11
11
|
import path from 'node:path';
|
|
12
12
|
import Fastify from 'fastify';
|
|
13
13
|
import support from '../plugin.js';
|
|
14
|
+
import {allTickets, forget} from '../lib/receiver.js';
|
|
14
15
|
import {getEntry, updateEntry} from '../../../server/services/collections.js';
|
|
15
16
|
|
|
16
17
|
const COLL = path.resolve('content/collections/support-tickets');
|
|
@@ -34,12 +35,15 @@ const fetchInto = (app) => async (url, init = {}) => {
|
|
|
34
35
|
|
|
35
36
|
const ANN = {id: 'u1', name: 'Ann', email: 'ann@acme.test'};
|
|
36
37
|
|
|
38
|
+
// The manager's settings as stored; the plugin reads them per request (1.1.5).
|
|
39
|
+
const managerSettings = {perSiteMax: 30, perSiteMinutes: 10, phone: '+44 1234 567890', hours: 'Always', notifyEmail: 'support@dommajs.test'};
|
|
40
|
+
|
|
37
41
|
async function site({token, user = ANN, settings = {}, env, screenWaitMs} = {}) {
|
|
38
42
|
const app = Fastify();
|
|
39
43
|
await app.register(support, {
|
|
40
44
|
...(screenWaitMs && {screenWaitMs}),
|
|
41
45
|
auth: {authenticate: async (req) => { req.user = user; }, requirePermission: () => async () => {}},
|
|
42
|
-
settings, mode: 'sender', timer: false, dataDir: await fs.mkdtemp(path.join(dataDir, 'site-')),
|
|
46
|
+
settings, readSettings: async () => ({...settings}), mode: 'sender', timer: false, dataDir: await fs.mkdtemp(path.join(dataDir, 'site-')),
|
|
43
47
|
env: env ?? {MANAGER_URL: 'http://127.0.0.1:8192/', FLEET_TOKEN: token, SITE_SLUG: TOKENS[token] || 'x'},
|
|
44
48
|
fetch: fetchInto(manager),
|
|
45
49
|
hooks: {notify: (n) => siteNotices.push(n), registerNotificationSource() {}}
|
|
@@ -63,7 +67,7 @@ before(async () => {
|
|
|
63
67
|
prefix: '/api/plugins/support', mode: 'receiver', mail,
|
|
64
68
|
// acme was bought (an owner on the manager); beta was made by hand.
|
|
65
69
|
ownerEmail: async (slug) => (slug === 'acme' ? 'owner@acme.test' : null), dataDir: await fs.mkdtemp(path.join(dataDir, 'manager-')),
|
|
66
|
-
settings: {
|
|
70
|
+
settings: {...managerSettings}, readSettings: async () => ({...managerSettings}),
|
|
67
71
|
slugFromToken: async (h) => TOKENS[String(h || '').replace(/^Bearer\s+/i, '')] || null,
|
|
68
72
|
siteDirectory: () => SITES,
|
|
69
73
|
hooks: {notify: (n) => notices.push(n), registerNotificationSource() {}}
|
|
@@ -197,6 +201,11 @@ test('one site cannot flood the inbox', async () => {
|
|
|
197
201
|
codes.push(r.status);
|
|
198
202
|
}
|
|
199
203
|
assert.deepEqual(codes.filter(c => c === 429).length, 2);
|
|
204
|
+
// A higher limit saved in the cog applies to the next message - no restart.
|
|
205
|
+
managerSettings.perSiteMax = 31;
|
|
206
|
+
const more = await call(manager, 'POST', '/api/plugins/support/fleet/tickets', {ref: 'f-live', subject: 's', body: 'b'}, {authorization: 'Bearer tok-flood'});
|
|
207
|
+
assert.equal(more.status, 201, 'live limit');
|
|
208
|
+
managerSettings.perSiteMax = 30;
|
|
200
209
|
void flood;
|
|
201
210
|
});
|
|
202
211
|
|
|
@@ -218,9 +227,10 @@ test('review: a customer message lost on the manager is sent again', async () =>
|
|
|
218
227
|
const s = (await call(acme, 'POST', '/tickets', {subject: 'Lost one', body: 'first'})).body;
|
|
219
228
|
await call(acme, 'POST', `/tickets/${s.ref}/messages`, {body: 'second'});
|
|
220
229
|
const t = (await call(manager, 'GET', '/api/plugins/support/tickets')).body.tickets.find(x => x.ref === s.ref);
|
|
221
|
-
// Simulate a concurrent write that dropped it.
|
|
230
|
+
// Simulate a concurrent write that dropped it - behind the plugin's back, so its memory is retired by hand.
|
|
222
231
|
const e = await getEntry('support-tickets', t.id);
|
|
223
232
|
await updateEntry('support-tickets', t.id, {...e.data, messages: e.data.messages.slice(0, 1)});
|
|
233
|
+
forget();
|
|
224
234
|
await call(acme, 'GET', '/tickets'); // notices it is missing, marks it waiting
|
|
225
235
|
await call(acme, 'GET', '/tickets'); // sends it again
|
|
226
236
|
const after = (await call(manager, 'GET', `/api/plugins/support/tickets/${t.id}`)).body;
|
|
@@ -379,3 +389,115 @@ test('review: archiving measures against the reply the customer SAW - a newer on
|
|
|
379
389
|
assert.equal(now.archived, false, 'the newer reply keeps it in the list');
|
|
380
390
|
assert.equal(now.unread, true);
|
|
381
391
|
});
|
|
392
|
+
|
|
393
|
+
test('the inbox memory: polls share one read, and every write is seen by the very next read', async () => {
|
|
394
|
+
const P = '/api/plugins/support';
|
|
395
|
+
const acme = await site({token: 'tok-acme'});
|
|
396
|
+
forget();
|
|
397
|
+
const first = await allTickets();
|
|
398
|
+
assert.equal(await allTickets(), first, 'a second read inside the window is the same read');
|
|
399
|
+
await call(manager, 'GET', `${P}/count`);
|
|
400
|
+
// A site opens a request: the badge and the inbox show it at once, not in 30 s.
|
|
401
|
+
const s = (await call(acme, 'POST', '/tickets', {subject: 'Cache me', body: 'A fresh read, please.', priority: 'urgent'})).body;
|
|
402
|
+
assert.notEqual(await allTickets(), first, 'the write retired the memory');
|
|
403
|
+
const t = (await call(manager, 'GET', `${P}/tickets`)).body.tickets.find(x => x.ref === s.ref);
|
|
404
|
+
assert.ok(t, 'in the inbox straight away');
|
|
405
|
+
assert.ok((await call(manager, 'GET', `${P}/count`)).body.details.items.some(i => i.text === 'Cache me'), 'and on the badge');
|
|
406
|
+
// Domma's reply, a status change and a delete: each on the very next read.
|
|
407
|
+
await call(manager, 'POST', `${P}/tickets/${t.id}/reply`, {body: 'Here you are.'});
|
|
408
|
+
assert.equal((await call(manager, 'GET', `${P}/tickets`)).body.tickets.find(x => x.id === t.id).messages.length, 2);
|
|
409
|
+
await call(manager, 'PUT', `${P}/tickets/${t.id}`, {status: 'closed'});
|
|
410
|
+
assert.equal((await call(manager, 'GET', `${P}/tickets`)).body.tickets.find(x => x.id === t.id).status, 'closed');
|
|
411
|
+
assert.equal((await call(acme, 'GET', '/tickets')).body.tickets.find(x => x.ref === s.ref).status, 'closed', 'the site hears it on its next sync');
|
|
412
|
+
assert.equal((await call(manager, 'DELETE', `${P}/tickets/${t.id}`)).status, 200);
|
|
413
|
+
assert.ok(!(await call(manager, 'GET', `${P}/tickets`)).body.tickets.some(x => x.id === t.id), 'gone from the very next read');
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
// ---- Attachments (1.1.4) ----
|
|
417
|
+
|
|
418
|
+
import multipart from '@fastify/multipart';
|
|
419
|
+
|
|
420
|
+
/** One file as a multipart form. */
|
|
421
|
+
function form(name, type, content) {
|
|
422
|
+
const b = '----sp' + Math.random().toString(16).slice(2);
|
|
423
|
+
return {
|
|
424
|
+
headers: {'content-type': `multipart/form-data; boundary=${b}`},
|
|
425
|
+
payload: Buffer.concat([Buffer.from(`--${b}\r\nContent-Disposition: form-data; name="file"; filename="${name}"\r\nContent-Type: ${type}\r\n\r\n`),
|
|
426
|
+
Buffer.from(content), Buffer.from(`\r\n--${b}--\r\n`)])
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** A site app that can take uploads (the engine registers multipart for every plugin). */
|
|
431
|
+
async function siteWithUploads(token) {
|
|
432
|
+
const app = Fastify();
|
|
433
|
+
await app.register(multipart, {limits: {fileSize: 10 * 1024 * 1024}});
|
|
434
|
+
await app.register(support, {
|
|
435
|
+
auth: {authenticate: async (req) => { req.user = ANN; }, requirePermission: () => async () => {}},
|
|
436
|
+
settings: {}, readSettings: async () => ({}), mode: 'sender', timer: false, dataDir: await fs.mkdtemp(path.join(dataDir, 'site-')),
|
|
437
|
+
env: {MANAGER_URL: 'http://127.0.0.1:8192/', FLEET_TOKEN: token, SITE_SLUG: TOKENS[token]},
|
|
438
|
+
fetch: fetchInto(manager), hooks: {}
|
|
439
|
+
});
|
|
440
|
+
return app;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
test('a screenshot goes with a request, and a log with a follow-up; Domma opens both', async () => {
|
|
444
|
+
const P = '/api/plugins/support';
|
|
445
|
+
const acme = await siteWithUploads('tok-acme');
|
|
446
|
+
const png = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47]), Buffer.alloc(5000, 1)]);
|
|
447
|
+
const up = await acme.inject({method: 'POST', url: '/attachments', ...form('checkout.png', 'image/png', png)});
|
|
448
|
+
assert.equal(up.statusCode, 201, up.body);
|
|
449
|
+
const shot = up.json();
|
|
450
|
+
assert.equal((await acme.inject({method: 'POST', url: '/attachments', ...form('x.html', 'text/html', '<script>')})).statusCode, 400);
|
|
451
|
+
const big = await acme.inject({method: 'POST', url: '/attachments', ...form('big.png', 'image/png', Buffer.alloc(2 * 1024 * 1024 + 1))});
|
|
452
|
+
assert.equal(big.statusCode, 413);
|
|
453
|
+
|
|
454
|
+
const sent = await call(acme, 'POST', '/tickets', {subject: 'Checkout looks wrong', body: 'See the screenshot.', attachments: [shot]});
|
|
455
|
+
assert.equal(sent.status, 201, JSON.stringify(sent.body));
|
|
456
|
+
let t = (await call(manager, 'GET', `${P}/tickets`)).body.tickets.find(x => x.ref === sent.body.ref);
|
|
457
|
+
assert.deepEqual(t.messages[0].attachments.map(a => [a.name, a.size, a.stored]), [['checkout.png', png.length, true]]);
|
|
458
|
+
const got = await manager.inject({method: 'GET', url: `${P}/tickets/${t.id}/attachments/${shot.id}`});
|
|
459
|
+
assert.equal(got.statusCode, 200);
|
|
460
|
+
assert.equal(got.headers['content-type'], 'image/png');
|
|
461
|
+
assert.ok(Buffer.compare(got.rawPayload, png) === 0);
|
|
462
|
+
|
|
463
|
+
// A follow-up with a log file.
|
|
464
|
+
const log = (await acme.inject({method: 'POST', url: '/attachments', ...form('console.txt', 'text/plain', 'TypeError: x is undefined')})).json();
|
|
465
|
+
assert.equal((await call(acme, 'POST', `/tickets/${sent.body.ref}/messages`, {body: 'And the console.', attachments: [log]})).status, 201);
|
|
466
|
+
t = (await call(manager, 'GET', `${P}/tickets/${t.id}`)).body;
|
|
467
|
+
assert.equal(t.messages[1].attachments[0].name, 'console.txt');
|
|
468
|
+
assert.equal((await manager.inject({method: 'GET', url: `${P}/tickets/${t.id}/attachments/${log.id}`})).body, 'TypeError: x is undefined');
|
|
469
|
+
|
|
470
|
+
// The site sees its files in the thread and opens its own copy.
|
|
471
|
+
const mine = (await call(acme, 'GET', '/tickets')).body.tickets.find(x => x.ref === sent.body.ref);
|
|
472
|
+
assert.equal(mine.messages[0].attachments[0].stored, true);
|
|
473
|
+
assert.equal((await acme.inject({method: 'GET', url: `/attachments/${shot.id}`})).statusCode, 200);
|
|
474
|
+
|
|
475
|
+
// The fleet route: another site's token and an unannounced file are refused; a retry is fine.
|
|
476
|
+
const raw = (tok, att, body) => manager.inject({method: 'POST', url: `${P}/fleet/attach/${sent.body.ref}/${att}`,
|
|
477
|
+
headers: {authorization: `Bearer ${tok}`, 'content-type': 'application/octet-stream'}, payload: body});
|
|
478
|
+
assert.equal((await raw('tok-beta', shot.id, png)).statusCode, 404);
|
|
479
|
+
assert.equal((await raw('tok-acme', '0f8fad5b-d9cb-469f-a165-000000000000', png)).statusCode, 404);
|
|
480
|
+
assert.equal((await raw('tok-acme', shot.id, png)).statusCode, 200);
|
|
481
|
+
|
|
482
|
+
// Delete the request: its files go too.
|
|
483
|
+
assert.equal((await call(manager, 'DELETE', `${P}/tickets/${t.id}`)).status, 200);
|
|
484
|
+
assert.equal((await manager.inject({method: 'GET', url: `${P}/tickets/${t.id}/attachments/${shot.id}`})).statusCode, 404);
|
|
485
|
+
await acme.close();
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
test('a file attached while the manager restarts waits with its request, then both go', async () => {
|
|
489
|
+
const P = '/api/plugins/support';
|
|
490
|
+
const acme = await siteWithUploads('tok-acme');
|
|
491
|
+
const shot = (await acme.inject({method: 'POST', url: '/attachments', ...form('down.png', 'image/png', Buffer.alloc(900, 3))})).json();
|
|
492
|
+
managerUp = false;
|
|
493
|
+
let ref;
|
|
494
|
+
try {
|
|
495
|
+
const r = await call(acme, 'POST', '/tickets', {subject: 'Offline with a picture', body: 'b', attachments: [shot]});
|
|
496
|
+
assert.equal(r.body.state, 'waiting');
|
|
497
|
+
ref = r.body.ref;
|
|
498
|
+
} finally { managerUp = true; }
|
|
499
|
+
await call(acme, 'GET', '/tickets');
|
|
500
|
+
const t = (await call(manager, 'GET', `${P}/tickets`)).body.tickets.find(x => x.ref === ref);
|
|
501
|
+
assert.equal(t.messages[0].attachments[0].stored, true);
|
|
502
|
+
await acme.close();
|
|
503
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The settings cog (1.1.5): each side keeps its own keys; emails and the phone
|
|
3
|
+
* are checked as the contact card checks them; the manager's routes save live.
|
|
4
|
+
*/
|
|
5
|
+
import {test, after} from 'node:test';
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import fs from 'node:fs/promises';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import Fastify from 'fastify';
|
|
11
|
+
import support from '../plugin.js';
|
|
12
|
+
import {cleanSettings, settingsErrors} from '../lib/shape.js';
|
|
13
|
+
|
|
14
|
+
const dirs = [];
|
|
15
|
+
after(async () => { for (const d of dirs) await fs.rm(d, {recursive: true, force: true}); });
|
|
16
|
+
const tmp = async () => { const d = await fs.mkdtemp(path.join(os.tmpdir(), 'support-set-')); dirs.push(d); return d; };
|
|
17
|
+
|
|
18
|
+
test('cleanSettings: a side\'s own keys, checked, else current, else default', () => {
|
|
19
|
+
assert.deepEqual(cleanSettings({syncMinutes: '15', email: 'x@y.z'}, {}, 'sender'), {syncMinutes: 15});
|
|
20
|
+
const cur = {email: 'help@domma.test', phone: '', hours: 'Weekdays', responseTime: 'A day', notifyEmail: 'in@domma.test', perSiteMax: 20, perSiteMinutes: 10};
|
|
21
|
+
const out = cleanSettings({email: 'not an email', phone: '+44 (0)1234 567890', hours: 'Mon\nto Fri', notifyEmail: '', perSiteMax: 0}, cur, 'receiver');
|
|
22
|
+
assert.equal(out.email, 'help@domma.test', 'a bad address keeps the current one');
|
|
23
|
+
assert.equal(out.phone, '+44 (0)1234 567890');
|
|
24
|
+
assert.equal(out.hours, 'Mon to Fri', 'one line');
|
|
25
|
+
assert.equal(out.notifyEmail, '', 'empty is allowed: email nobody');
|
|
26
|
+
assert.equal(out.perSiteMax, 20);
|
|
27
|
+
assert.deepEqual(Object.keys(settingsErrors({email: 'nope', phone: 'call me', perSiteMinutes: 0, notifyEmail: ''}, 'receiver')).sort(),
|
|
28
|
+
['email', 'perSiteMinutes', 'phone']);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
async function receiver(store) {
|
|
32
|
+
const app = Fastify();
|
|
33
|
+
await app.register(support, {
|
|
34
|
+
auth: {authenticate: async (req) => { req.user = {id: 'v1', name: 'D'}; }, requirePermission: () => async () => {}},
|
|
35
|
+
prefix: '/api/plugins/support', mode: 'receiver', mail: {send() {}}, dataDir: await tmp(),
|
|
36
|
+
readSettings: async () => ({...store}), writeSettings: async (next) => { Object.assign(store, next); },
|
|
37
|
+
slugFromToken: async () => null, siteDirectory: () => ({}), hooks: {}
|
|
38
|
+
});
|
|
39
|
+
return app;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
test('the manager saves its card; /mode shows it at once; a bad phone is 400', async () => {
|
|
43
|
+
const store = {syncMinutes: 5, email: 'a@domma.test', hours: 'Old', notifyEmail: 'in@domma.test', perSiteMax: 20, perSiteMinutes: 10};
|
|
44
|
+
const app = await receiver(store);
|
|
45
|
+
const got = (await app.inject({url: '/api/plugins/support/settings'})).json();
|
|
46
|
+
assert.equal(got.side, 'receiver');
|
|
47
|
+
assert.equal('syncMinutes' in got.settings, false, 'the site\'s key is not the manager\'s');
|
|
48
|
+
const put = await app.inject({method: 'PUT', url: '/api/plugins/support/settings', payload: {...got.settings, hours: 'Nine to five'}});
|
|
49
|
+
assert.equal(put.statusCode, 200);
|
|
50
|
+
assert.equal(store.hours, 'Nine to five');
|
|
51
|
+
assert.equal(store.syncMinutes, 5);
|
|
52
|
+
assert.equal((await app.inject({url: '/api/plugins/support/mode'})).json().contact.hours, 'Nine to five');
|
|
53
|
+
const bad = await app.inject({method: 'PUT', url: '/api/plugins/support/settings', payload: {phone: 'ring us'}});
|
|
54
|
+
assert.equal(bad.statusCode, 400);
|
|
55
|
+
assert.ok(bad.json().errors.phone);
|
|
56
|
+
await app.close();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('a site\'s settings route is for administrators', async () => {
|
|
60
|
+
const app = Fastify();
|
|
61
|
+
await app.register(support, {
|
|
62
|
+
auth: {authenticate: async (req) => { req.user = {id: 'u'}; }, requirePermission: () => async () => {},
|
|
63
|
+
requireAdmin: async (req, reply) => reply.code(403).send({error: 'Forbidden'})},
|
|
64
|
+
mode: 'sender', timer: false, env: {}, dataDir: await tmp(), readSettings: async () => ({}), hooks: {}
|
|
65
|
+
});
|
|
66
|
+
assert.equal((await app.inject({url: '/settings'})).statusCode, 403);
|
|
67
|
+
await app.close();
|
|
68
|
+
});
|