domma-cms 0.113.0 → 0.114.0
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/package.json +1 -1
- package/plugins/free-tier.lock.json +23 -1
- package/plugins/support/CLAUDE.md +59 -0
- package/plugins/support/admin/css/index.css +95 -0
- package/plugins/support/admin/lib/kit.js +119 -0
- package/plugins/support/admin/templates/support.html +1 -0
- package/plugins/support/admin/views/desk.js +289 -0
- package/plugins/support/admin/views/inbox.js +211 -0
- package/plugins/support/admin/views/support.js +63 -0
- package/plugins/support/collections/support-tickets.json +30 -0
- package/plugins/support/config.js +28 -0
- package/plugins/support/lib/mail.js +58 -0
- package/plugins/support/lib/receiver.js +182 -0
- package/plugins/support/lib/sender.js +213 -0
- package/plugins/support/lib/shape.js +302 -0
- package/plugins/support/plugin.js +350 -0
- package/plugins/support/plugin.json +48 -0
- package/plugins/support/tests/api.test.js +265 -0
- package/plugins/support/tests/shape.test.js +122 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Support - a customer asks Domma for help from their own admin, and the two
|
|
3
|
+
* talk it through. Mounted at /api/plugins/support.
|
|
4
|
+
*
|
|
5
|
+
* ONE plugin, TWO sides, chosen by where it runs:
|
|
6
|
+
*
|
|
7
|
+
* site (every site; it can SEND only when the manager started it - MANAGER_URL + FLEET_TOKEN)
|
|
8
|
+
* GET /mode which side, whether connected, and the contact card
|
|
9
|
+
* GET /meta · GET /count categories etc.; sidebar badge: replies not yet read
|
|
10
|
+
* GET /tickets this site's requests (sends any waiting first)
|
|
11
|
+
* POST /tickets a new request: kept here, then sent
|
|
12
|
+
* POST /tickets/:ref/messages write again
|
|
13
|
+
* POST /tickets/:ref/close mark resolved
|
|
14
|
+
* PUT /tickets/:ref/seen Domma's latest reply has been read
|
|
15
|
+
*
|
|
16
|
+
* manager (Site Manager is installed beside it)
|
|
17
|
+
* POST /fleet/tickets · POST /fleet/messages · GET /fleet/mine from the sites (Bearer fleet token)
|
|
18
|
+
* GET /mode · /meta · /count the badge counts requests waiting on Domma, red while one is urgent
|
|
19
|
+
* GET /tickets · GET /tickets/:id
|
|
20
|
+
* POST /tickets/:id/reply answer (and set the status), emailing the customer
|
|
21
|
+
* PUT /tickets/:id status, priority, internal notes
|
|
22
|
+
* DELETE /tickets/:id
|
|
23
|
+
*
|
|
24
|
+
* A standalone site (not started by a manager) still gets the contact card -
|
|
25
|
+
* Support is for everyone - but has nowhere to send a conversation.
|
|
26
|
+
*
|
|
27
|
+
* Nothing the browser sends says who wrote or which site it came from: the user
|
|
28
|
+
* is the session's, the site is the one the fleet token names.
|
|
29
|
+
*/
|
|
30
|
+
import {randomUUID} from 'node:crypto';
|
|
31
|
+
import fs from 'node:fs';
|
|
32
|
+
import path from 'node:path';
|
|
33
|
+
import {fileURLToPath} from 'node:url';
|
|
34
|
+
import defaults from './config.js';
|
|
35
|
+
import {
|
|
36
|
+
CATEGORIES, PRIORITIES, STATUSES, buildMessagePayload, buildTicketPayload, cleanMessage, cleanRequest, cleanUpdate,
|
|
37
|
+
contactCard, createThrottle, lastDommaAt, senderView, toCustomerEmail, toDommaEmail
|
|
38
|
+
} from './lib/shape.js';
|
|
39
|
+
import {createOutbox, flush, managerApi, managerOf, mergeTickets, newRef} from './lib/sender.js';
|
|
40
|
+
|
|
41
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
42
|
+
const NAME = 'support';
|
|
43
|
+
|
|
44
|
+
/** The CMS this site runs, from the updater's marker (the site's package.json is the SITE's version). */
|
|
45
|
+
function cmsVersion() {
|
|
46
|
+
for (const f of [path.join(process.cwd(), '.domma'), path.join(process.cwd(), '.cmsVersion')]) {
|
|
47
|
+
try {
|
|
48
|
+
const raw = fs.readFileSync(f, 'utf8');
|
|
49
|
+
try { return String(JSON.parse(raw).cmsVersion || ''); } catch { return raw.trim(); }
|
|
50
|
+
} catch { /* try the next */ }
|
|
51
|
+
}
|
|
52
|
+
try { return JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8')).version || ''; } catch { return ''; }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export default async function supportPlugin(fastify, options) {
|
|
56
|
+
const {authenticate, requirePermission} = options.auth;
|
|
57
|
+
const hooks = options.hooks || {};
|
|
58
|
+
const can = (action) => ({preHandler: [authenticate, requirePermission(NAME, action)]});
|
|
59
|
+
const settings = {...defaults, ...(options.settings || {})};
|
|
60
|
+
const receiverMod = await import('./lib/receiver.js');
|
|
61
|
+
const mode = options.mode || (receiverMod.isManager() ? 'receiver' : 'sender');
|
|
62
|
+
const notify = typeof hooks.notify === 'function' ? (n) => { try { hooks.notify(n); } catch { /* a notice is a courtesy */ } } : () => {};
|
|
63
|
+
const mail = options.mail || await import('./lib/mail.js');
|
|
64
|
+
if (typeof hooks.registerNotificationSource === 'function') {
|
|
65
|
+
try {
|
|
66
|
+
if (mode === 'receiver') {
|
|
67
|
+
hooks.registerNotificationSource({id: 'new-request', label: 'A site asked for support',
|
|
68
|
+
description: 'A customer opened a support request or wrote again.', severity: 'info', audience: 'admins'});
|
|
69
|
+
} else {
|
|
70
|
+
hooks.registerNotificationSource({id: 'reply', label: 'Domma Support replied',
|
|
71
|
+
description: 'Domma answered one of this site\'s support requests.', severity: 'info', audience: 'admins'});
|
|
72
|
+
}
|
|
73
|
+
} catch (err) { fastify.log.warn(`[support] notification source: ${err.message}`); }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// On the manager the inbox needs it, so `manage` is enough there.
|
|
77
|
+
fastify.get('/meta', can(mode === 'receiver' ? 'manage' : 'send'), async () => ({categories: CATEGORIES, priorities: PRIORITIES, statuses: STATUSES}));
|
|
78
|
+
|
|
79
|
+
if (mode === 'receiver') await receiverRoutes(fastify, {can, notify, mail, receiverMod, settings, options});
|
|
80
|
+
else await senderRoutes(fastify, {can, notify, settings, options});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------------
|
|
84
|
+
// Site
|
|
85
|
+
// ---------------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
async function senderRoutes(fastify, {can, notify, settings, options}) {
|
|
88
|
+
const env = options.env || process.env;
|
|
89
|
+
const manager = managerOf(env);
|
|
90
|
+
const outbox = createOutbox(options.dataDir || path.join(HERE, 'data'));
|
|
91
|
+
const api = manager ? managerApi(manager, options.fetch) : null;
|
|
92
|
+
// The manager's card wins once heard; until then (and standalone) this site's settings.
|
|
93
|
+
const contact = () => outbox.contact() || contactCard(settings);
|
|
94
|
+
|
|
95
|
+
let lastSync = 0;
|
|
96
|
+
/** Send what waits, then read the manager's view; tell admins about replies they have not heard of. */
|
|
97
|
+
async function sync() {
|
|
98
|
+
lastSync = Date.now();
|
|
99
|
+
const seen = outbox.seen();
|
|
100
|
+
if (!api) return {tickets: mergeTickets(outbox.all(), [], seen), reachable: false, error: ''};
|
|
101
|
+
const pushed = await flush(outbox, api);
|
|
102
|
+
const mine = await api.mine();
|
|
103
|
+
const remote = mine.ok ? (mine.body?.tickets || []) : null;
|
|
104
|
+
if (remote) {
|
|
105
|
+
const before = new Map(outbox.tickets().map(t => [t.ref, t.dommaAt]));
|
|
106
|
+
for (const t of remote) {
|
|
107
|
+
const at = lastDommaAt(t);
|
|
108
|
+
// A reply this site had not heard of before this sync: one notice each.
|
|
109
|
+
if (at && before.get(t.ref) !== at && seen[t.ref] !== at) {
|
|
110
|
+
const last = [...t.messages].reverse().find(m => m.from === 'domma');
|
|
111
|
+
notify({title: `Domma Support replied: ${t.subject}`, body: String(last?.body || '').slice(0, 300), severity: 'info',
|
|
112
|
+
source: 'reply', link: `#/plugins/support/${t.ref}`, dedupeKey: `support-reply:${t.ref}:${at}`});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const card = mine.body?.contact ? contactCard(mine.body.contact) : null;
|
|
116
|
+
outbox.remember(remote, card);
|
|
117
|
+
}
|
|
118
|
+
const tickets = mergeTickets(outbox.all(), remote, seen, outbox.tickets());
|
|
119
|
+
return {tickets, reachable: Boolean(remote), error: mine.ok ? pushed.error : mine.error};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Every few minutes: waiting messages go, new replies are noticed. Off in tests.
|
|
123
|
+
if (api && options.timer !== false) {
|
|
124
|
+
const t = setInterval(() => { sync().catch(() => {}); }, Math.max(1, Number(settings.syncMinutes) || 5) * 60_000);
|
|
125
|
+
t.unref?.();
|
|
126
|
+
const first = setTimeout(() => { sync().catch(() => {}); }, 30_000);
|
|
127
|
+
first.unref?.();
|
|
128
|
+
fastify.addHook('onClose', async () => { clearInterval(t); clearTimeout(first); });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
fastify.get('/mode', can('send'), async () => ({mode: 'sender', connected: Boolean(manager), site: manager?.slug || '', contact: contact()}));
|
|
132
|
+
|
|
133
|
+
fastify.get('/count', can('send'), async () => {
|
|
134
|
+
// The badge reads the last sync's copy and never waits on the manager - but it is
|
|
135
|
+
// asked every minute, so it also nudges a sync in the background when the copy is
|
|
136
|
+
// over a minute old. A reply then shows within about a minute, not syncMinutes.
|
|
137
|
+
if (api && Date.now() - lastSync > 60_000) sync().catch(() => {});
|
|
138
|
+
const seen = outbox.seen();
|
|
139
|
+
// From the last sync's copy: the badge must not call the manager every minute.
|
|
140
|
+
const unread = outbox.tickets().filter(t => t.dommaAt && seen[t.ref] !== t.dommaAt);
|
|
141
|
+
const openCount = outbox.tickets().filter(t => t.status !== 'closed').length;
|
|
142
|
+
const waiting = outbox.all().filter(i => i.state === 'waiting').length;
|
|
143
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
144
|
+
if (unread.length) {
|
|
145
|
+
return {count: unread.length, label: `${unread.length} new ${unread.length === 1 ? 'reply' : 'replies'} from Domma Support`, tone: 'info',
|
|
146
|
+
details: {title: 'Support', items: unread.slice(0, 5).map(t => ({text: t.subject, meta: 'New reply'})), empty: ''}};
|
|
147
|
+
}
|
|
148
|
+
return {count: openCount, inventory: true, tone: 'info',
|
|
149
|
+
label: openCount ? `${plural(openCount, 'open request')}${waiting ? `, ${waiting} waiting to send` : ''}` : 'Nothing open - Domma is a message away',
|
|
150
|
+
details: {title: 'Support', rows: [{label: 'Open requests', value: String(openCount)}, {label: 'Waiting to send', value: String(waiting)}],
|
|
151
|
+
empty: 'Nothing open.'}};
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// A screen load waits for the manager at most this long; past it, the last view is shown and
|
|
155
|
+
// the sync carries on in the background (each call has its own 10 s timeout, and items go one by one).
|
|
156
|
+
const SCREEN_WAIT_MS = options.screenWaitMs ?? 6000;
|
|
157
|
+
const withDeadline = (p, fallback) => new Promise((resolve) => {
|
|
158
|
+
const t = setTimeout(() => resolve(fallback()), SCREEN_WAIT_MS);
|
|
159
|
+
p.then((v) => { clearTimeout(t); resolve(v); }, () => { clearTimeout(t); resolve(fallback()); });
|
|
160
|
+
});
|
|
161
|
+
const offlineView = (error) => ({tickets: mergeTickets(outbox.all(), null, outbox.seen(), outbox.tickets()), reachable: false, error});
|
|
162
|
+
|
|
163
|
+
fastify.get('/tickets', can('send'), async () => {
|
|
164
|
+
const r = await withDeadline(sync(), () => offlineView('Domma is slow to answer - showing what we last heard.'));
|
|
165
|
+
return {connected: Boolean(manager), reachable: r.reachable, error: r.error, contact: contact(), tickets: r.tickets};
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
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.'});
|
|
169
|
+
|
|
170
|
+
fastify.post('/tickets', can('send'), async (request, reply) => {
|
|
171
|
+
if (!manager) return notConnected(reply);
|
|
172
|
+
const {request: req, error} = cleanRequest(request.body || {});
|
|
173
|
+
if (error) return reply.code(400).send({error});
|
|
174
|
+
const payload = buildTicketPayload({request: req, user: request.user, cmsVersion: cmsVersion(), site: manager.slug, ref: newRef()});
|
|
175
|
+
outbox.add('ticket', payload);
|
|
176
|
+
const r = await withDeadline(flush(outbox, api), () => ({error: 'Domma is slow to answer'}));
|
|
177
|
+
const item = outbox.all().find(i => i.ref === payload.ref);
|
|
178
|
+
return reply.code(201).send({ref: payload.ref, state: item?.state || 'waiting',
|
|
179
|
+
message: item?.state === 'sent' ? 'Sent to Domma Support. We will reply here, and by email.'
|
|
180
|
+
: `Kept here and sent as soon as Domma answers. (${r.error || 'not sent yet'})`});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const turn = (close) => async (request, reply) => {
|
|
184
|
+
if (!manager) return notConnected(reply);
|
|
185
|
+
const ticketRef = String(request.params.ref);
|
|
186
|
+
const known = outbox.tickets().some(t => t.ref === ticketRef) || outbox.all().some(i => i.kind === 'ticket' && i.ref === ticketRef);
|
|
187
|
+
if (!known) return reply.code(404).send({error: 'No such request on this site.'});
|
|
188
|
+
let body = '';
|
|
189
|
+
if (!close) {
|
|
190
|
+
const {message, error} = cleanMessage(request.body || {});
|
|
191
|
+
if (error) return reply.code(400).send({error});
|
|
192
|
+
body = message.body;
|
|
193
|
+
}
|
|
194
|
+
const payload = buildMessagePayload({ticketRef, ref: newRef(), body, close, user: request.user});
|
|
195
|
+
outbox.add('message', payload, ticketRef);
|
|
196
|
+
await withDeadline(flush(outbox, api), () => ({}));
|
|
197
|
+
const item = outbox.all().find(i => i.ref === payload.ref);
|
|
198
|
+
return reply.code(201).send({ref: payload.ref, state: item?.state || 'waiting',
|
|
199
|
+
message: item?.state === 'sent' ? (close ? 'Marked as resolved. Thank you.' : 'Sent.') : 'Kept here and sent as soon as Domma answers.'});
|
|
200
|
+
};
|
|
201
|
+
fastify.post('/tickets/:ref/messages', {...can('send'), bodyLimit: 32 * 1024}, turn(false));
|
|
202
|
+
fastify.post('/tickets/:ref/close', can('send'), turn(true));
|
|
203
|
+
|
|
204
|
+
fastify.put('/tickets/:ref/seen', can('send'), async (request) => {
|
|
205
|
+
outbox.markSeen(String(request.params.ref), String(request.body?.at || ''));
|
|
206
|
+
return {ok: true};
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ---------------------------------------------------------------------------------
|
|
211
|
+
// Manager
|
|
212
|
+
// ---------------------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
async function receiverRoutes(fastify, {can, notify, mail, receiverMod, settings, options}) {
|
|
215
|
+
const rx = receiverMod;
|
|
216
|
+
const slugOf = options.slugFromToken || rx.slugFromToken;
|
|
217
|
+
const directory = options.siteDirectory || rx.siteDirectory;
|
|
218
|
+
const allowed = createThrottle({max: Number(settings.perSiteMax) || 20, windowMs: (Number(settings.perSiteMinutes) || 10) * 60_000});
|
|
219
|
+
const log = fastify.log;
|
|
220
|
+
if (options.dataDir) rx.setDataDir(options.dataDir);
|
|
221
|
+
try { await rx.ensureCollection(); } catch (err) { log.error(`[support] collection: ${err.message}`); }
|
|
222
|
+
|
|
223
|
+
const siteOf = (slug) => directory()[slug] || {name: slug, host: ''};
|
|
224
|
+
const inboxLink = (id) => `${mail.siteUrl()}/admin/#/plugins/support/${encodeURIComponent(id)}`;
|
|
225
|
+
const siteLink = (slug, ref) => {
|
|
226
|
+
const host = siteOf(slug).host;
|
|
227
|
+
return host ? `https://${host}/admin/#/plugins/support/${encodeURIComponent(ref)}` : '';
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
/** Tell Domma: a notice in the admin, and an email to the support mailbox. Never awaited. */
|
|
231
|
+
function toDomma(entry, body, isNew) {
|
|
232
|
+
const d = entry.data;
|
|
233
|
+
const name = siteOf(d.site).name;
|
|
234
|
+
notify({title: `${d.priority === 'urgent' ? 'URGENT - ' : ''}${isNew ? 'Support request' : 'Reply'} from ${name}: ${d.subject}`,
|
|
235
|
+
body: body.slice(0, 300), severity: d.priority === 'urgent' ? 'warning' : 'info', source: 'new-request',
|
|
236
|
+
link: `#/plugins/support/${entry.id}`, dedupeKey: `support:${entry.id}:${d.lastAt}`});
|
|
237
|
+
if (settings.notifyEmail) {
|
|
238
|
+
mail.send({to: settings.notifyEmail, ...toDommaEmail({ticket: d, siteName: name, link: inboxLink(entry.id), body, isNew})}, {log});
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ---- From the sites (no session: the fleet token IS the identity) ----
|
|
243
|
+
|
|
244
|
+
const fleet = async (request, reply) => {
|
|
245
|
+
const slug = await slugOf(request.headers.authorization);
|
|
246
|
+
if (!slug) { reply.code(401).send({error: 'Unknown site.'}); return null; }
|
|
247
|
+
return slug;
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
fastify.post('/fleet/tickets', {bodyLimit: 32 * 1024}, async (request, reply) => {
|
|
251
|
+
const slug = await fleet(request, reply);
|
|
252
|
+
if (!slug) return reply;
|
|
253
|
+
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.'});
|
|
254
|
+
const r = await rx.open(slug, request.body || {}, undefined, siteOf(slug));
|
|
255
|
+
if (r.missing) return reply.code(404).send({error: r.error});
|
|
256
|
+
if (r.error) return reply.code(400).send({error: r.error});
|
|
257
|
+
if (r.created) toDomma(r.entry, r.entry.data.messages[0]?.body || '', true);
|
|
258
|
+
return reply.code(r.created ? 201 : 200).send({id: r.entry.id, ref: r.entry.data.ref, status: r.entry.data.status});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
fastify.post('/fleet/messages', {bodyLimit: 32 * 1024}, async (request, reply) => {
|
|
262
|
+
const slug = await fleet(request, reply);
|
|
263
|
+
if (!slug) return reply;
|
|
264
|
+
if (!allowed(slug)) return reply.code(429).send({error: 'That is a lot of messages at once - try again in a few minutes.'});
|
|
265
|
+
const r = await rx.customerTurn(slug, request.body || {});
|
|
266
|
+
if (r.missing) return reply.code(404).send({error: r.error});
|
|
267
|
+
if (r.error) return reply.code(400).send({error: r.error});
|
|
268
|
+
const body = String(request.body?.body || '').trim();
|
|
269
|
+
if (r.added && body) toDomma(r.entry, body, false);
|
|
270
|
+
return {id: r.entry.id, status: r.entry.data.status};
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
fastify.get('/fleet/mine', async (request, reply) => {
|
|
274
|
+
const slug = await fleet(request, reply);
|
|
275
|
+
if (!slug) return reply;
|
|
276
|
+
return {tickets: (await rx.ticketsOf(slug)).map(senderView), contact: contactCard(settings)};
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// ---- The inbox ----
|
|
280
|
+
|
|
281
|
+
const row = (e, dir = directory()) => ({id: e.id, ...e.data, siteName: dir[e.data.site]?.name || e.data.site,
|
|
282
|
+
siteHost: dir[e.data.site]?.host || '', createdAt: e.meta?.createdAt, updatedAt: e.meta?.updatedAt});
|
|
283
|
+
|
|
284
|
+
fastify.get('/mode', can('manage'), async () => ({mode: 'receiver', connected: true, contact: contactCard(settings),
|
|
285
|
+
notifyEmail: settings.notifyEmail || ''}));
|
|
286
|
+
|
|
287
|
+
fastify.get('/count', can('manage'), async () => {
|
|
288
|
+
const all = await rx.allTickets();
|
|
289
|
+
const waiting = all.filter(e => e.data?.status === 'open');
|
|
290
|
+
const urgent = waiting.filter(e => e.data?.priority === 'urgent');
|
|
291
|
+
const dir = directory();
|
|
292
|
+
if (!waiting.length) {
|
|
293
|
+
return {count: all.length, inventory: true, tone: 'info', label: `${all.length} request${all.length === 1 ? '' : 's'} - none waiting`,
|
|
294
|
+
details: {title: 'Support', empty: 'Nothing waiting on Domma.'}};
|
|
295
|
+
}
|
|
296
|
+
return {
|
|
297
|
+
count: waiting.length,
|
|
298
|
+
tone: urgent.length ? 'danger' : 'attention',
|
|
299
|
+
label: `${waiting.length} waiting on Domma${urgent.length ? `, ${urgent.length} urgent` : ''}`,
|
|
300
|
+
details: {title: 'Waiting on Domma', rows: [{label: 'Waiting', value: String(waiting.length)}, {label: 'Urgent', value: String(urgent.length), ...(urgent.length && {tone: 'danger'})}],
|
|
301
|
+
items: [...urgent, ...waiting.filter(e => e.data.priority !== 'urgent')].slice(0, 6)
|
|
302
|
+
.map(e => ({text: e.data.subject, meta: dir[e.data.site]?.name || e.data.site, ...(e.data.priority === 'urgent' && {tone: 'danger'})})),
|
|
303
|
+
empty: ''}
|
|
304
|
+
};
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
fastify.get('/tickets', can('manage'), async () => {
|
|
308
|
+
const dir = directory();
|
|
309
|
+
return {tickets: (await rx.allTickets()).map(e => row(e, dir))};
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
fastify.get('/tickets/:id', can('manage'), async (request, reply) => {
|
|
313
|
+
const e = await rx.getTicket(request.params.id);
|
|
314
|
+
if (!e) return reply.code(404).send({error: 'No such request.'});
|
|
315
|
+
return row(e);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
fastify.post('/tickets/:id/reply', {...can('manage'), bodyLimit: 32 * 1024}, async (request, reply) => {
|
|
319
|
+
const e = await rx.getTicket(request.params.id);
|
|
320
|
+
if (!e) return reply.code(404).send({error: 'No such request.'});
|
|
321
|
+
const body = String(request.body?.body ?? '').replace(/\r\n?/g, '\n').trim().slice(0, 8000);
|
|
322
|
+
const status = request.body?.status;
|
|
323
|
+
if (status !== undefined && !STATUSES.some(s => s.key === status)) return reply.code(400).send({error: 'That is not a status.'});
|
|
324
|
+
if (!body && !status) return reply.code(400).send({error: 'Write a reply first.'});
|
|
325
|
+
const by = String(request.user?.name || 'Domma Support');
|
|
326
|
+
const next = await rx.dommaTurn(e.id, {body, status, by, msgId: randomUUID()});
|
|
327
|
+
const d = next.data;
|
|
328
|
+
// The customer hears of a reply, and of their request being resolved - not of a quiet status change back to open.
|
|
329
|
+
if (d.userEmail && (body || (d.status === 'closed' && e.data.status !== 'closed'))) {
|
|
330
|
+
mail.send({to: d.userEmail, ...toCustomerEmail({ticket: d, siteName: siteOf(d.site).name, link: siteLink(d.site, d.ref), body,
|
|
331
|
+
resolved: d.status === 'closed'})}, {log});
|
|
332
|
+
}
|
|
333
|
+
return row(next);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
fastify.put('/tickets/:id', can('manage'), async (request, reply) => {
|
|
337
|
+
const e = await rx.getTicket(request.params.id);
|
|
338
|
+
if (!e) return reply.code(404).send({error: 'No such request.'});
|
|
339
|
+
const {patch, error} = cleanUpdate(request.body || {});
|
|
340
|
+
if (error) return reply.code(400).send({error});
|
|
341
|
+
return row(await rx.patch(e.id, patch, String(request.user?.name || 'Domma Support')));
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
fastify.delete('/tickets/:id', can('manage'), async (request, reply) => {
|
|
345
|
+
const e = await rx.getTicket(request.params.id);
|
|
346
|
+
if (!e) return reply.code(404).send({error: 'No such request.'});
|
|
347
|
+
await rx.removeTicket(e.id);
|
|
348
|
+
return {ok: true};
|
|
349
|
+
});
|
|
350
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "support",
|
|
3
|
+
"displayName": "Support",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"tier": "free",
|
|
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.",
|
|
7
|
+
"author": "Domma CMS",
|
|
8
|
+
"date": "2026-10-04",
|
|
9
|
+
"icon": "headphones",
|
|
10
|
+
"permissions": [
|
|
11
|
+
{
|
|
12
|
+
"key": "support",
|
|
13
|
+
"label": "Support",
|
|
14
|
+
"group": "Plugins",
|
|
15
|
+
"icon": "headphones",
|
|
16
|
+
"description": "Ask Domma for help and follow the conversation (send). On the Domma server itself: the inbox of every site's requests (manage).",
|
|
17
|
+
"actions": [
|
|
18
|
+
{"key": "send", "label": "Send", "description": "Ask Domma for help and read this site's requests and replies"},
|
|
19
|
+
{"key": "manage", "label": "Manage", "description": "On the Domma server: read and answer every site's requests"}
|
|
20
|
+
],
|
|
21
|
+
"grant": ["admin"]
|
|
22
|
+
}
|
|
23
|
+
],
|
|
24
|
+
"admin": {
|
|
25
|
+
"css": ["admin/css/index.css"],
|
|
26
|
+
"sidebar": [
|
|
27
|
+
{
|
|
28
|
+
"id": "support",
|
|
29
|
+
"text": "Support",
|
|
30
|
+
"icon": "headphones",
|
|
31
|
+
"url": "#/plugins/support",
|
|
32
|
+
"section": "#/plugins/support",
|
|
33
|
+
"permission": "support",
|
|
34
|
+
"countUrl": "/api/plugins/support/count"
|
|
35
|
+
}
|
|
36
|
+
],
|
|
37
|
+
"routes": [
|
|
38
|
+
{"path": "/plugins/support", "view": "plugin-support", "title": "Support - Domma CMS"},
|
|
39
|
+
{"path": "/plugins/support/:id", "view": "plugin-support", "title": "Support - Domma CMS"}
|
|
40
|
+
],
|
|
41
|
+
"views": {
|
|
42
|
+
"plugin-support": {
|
|
43
|
+
"entry": "support/admin/views/support.js",
|
|
44
|
+
"exportName": "supportView"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|