domma-cms 0.116.0 → 0.116.1
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 +16 -16
- package/plugins/support/CLAUDE.md +19 -0
- package/plugins/support/admin/css/index.css +4 -0
- package/plugins/support/admin/lib/kit.js +60 -11
- package/plugins/support/admin/views/desk.js +158 -74
- package/plugins/support/admin/views/inbox.js +146 -56
- package/plugins/support/admin/views/support.js +1 -1
- package/plugins/support/collections/support-tickets.json +1 -0
- package/plugins/support/lib/receiver.js +2 -1
- package/plugins/support/lib/sender.js +68 -4
- package/plugins/support/lib/shape.js +10 -1
- package/plugins/support/plugin.js +38 -6
- package/plugins/support/plugin.json +2 -2
- package/plugins/support/tests/api.test.js +94 -0
- package/plugins/support/tests/shape.test.js +68 -1
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "support",
|
|
3
3
|
"displayName": "Support",
|
|
4
|
-
"version": "1.0
|
|
4
|
+
"version": "1.1.0",
|
|
5
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.",
|
|
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",
|
|
8
8
|
"date": "2026-10-04",
|
|
9
9
|
"icon": "headphones",
|
|
@@ -285,3 +285,97 @@ test('replies go to the address a site was bought with; a hand-made site falls b
|
|
|
285
285
|
await call(manager, 'POST', `/api/plugins/support/tickets/${tb.id}/reply`, {body: 'hi Bob'});
|
|
286
286
|
assert.ok(mails.some(m => m.to === 'bob@beta.test'));
|
|
287
287
|
});
|
|
288
|
+
|
|
289
|
+
test('the inbox archive: out of the badge, back when the customer writes; the site never knows', async () => {
|
|
290
|
+
const acme = await site({token: 'tok-acme'});
|
|
291
|
+
const {body: s} = await call(acme, 'POST', '/tickets', {subject: 'Archive me', body: 'x', priority: 'urgent'});
|
|
292
|
+
const t = (await call(manager, 'GET', '/api/plugins/support/tickets')).body.tickets.find(x => x.ref === s.ref);
|
|
293
|
+
const before = (await call(manager, 'GET', '/api/plugins/support/count')).body;
|
|
294
|
+
const put = await call(manager, 'PUT', `/api/plugins/support/tickets/${t.id}`, {archived: true});
|
|
295
|
+
assert.equal(put.status, 200);
|
|
296
|
+
assert.equal(put.body.archived, true);
|
|
297
|
+
assert.equal(put.body.history.at(-1).what, 'Archived');
|
|
298
|
+
const after = (await call(manager, 'GET', '/api/plugins/support/count')).body;
|
|
299
|
+
assert.equal((after.count ?? 0) < (before.count ?? 0) || after.inventory === true, true, 'an archived request is not waiting');
|
|
300
|
+
assert.ok(!(after.details.items || []).some(i => i.text === 'Archive me'));
|
|
301
|
+
assert.equal((await call(manager, 'PUT', `/api/plugins/support/tickets/${t.id}`, {archived: 'no'})).status, 400);
|
|
302
|
+
// The site sees nothing of Domma's filing.
|
|
303
|
+
const mine = (await call(acme, 'GET', '/tickets')).body.tickets.find(x => x.ref === s.ref);
|
|
304
|
+
assert.equal(mine.archived, false);
|
|
305
|
+
// The customer writes again: it is back in the inbox.
|
|
306
|
+
await call(acme, 'POST', `/tickets/${s.ref}/messages`, {body: 'still there?'});
|
|
307
|
+
assert.equal((await call(manager, 'GET', `/api/plugins/support/tickets/${t.id}`)).body.archived, false);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test('a site archives and removes its own requests; Domma keeps them, and a reply brings them back', async () => {
|
|
311
|
+
const acme = await site({token: 'tok-acme'});
|
|
312
|
+
const a = (await call(acme, 'POST', '/tickets', {subject: 'Keep me', body: 'x'})).body;
|
|
313
|
+
const b = (await call(acme, 'POST', '/tickets', {subject: 'Lose me', body: 'y'})).body;
|
|
314
|
+
// A sync: the site knows both. Earlier tests' replies are read, so the badge counts open requests.
|
|
315
|
+
for (const t of (await call(acme, 'GET', '/tickets')).body.tickets.filter(x => x.unread)) {
|
|
316
|
+
await call(acme, 'PUT', `/tickets/${t.ref}/seen`, {at: t.messages.filter(m => m.from === 'domma').at(-1).at});
|
|
317
|
+
}
|
|
318
|
+
assert.equal((await call(acme, 'GET', '/count')).body.inventory, true);
|
|
319
|
+
const openBefore = (await call(acme, 'GET', '/count')).body.count;
|
|
320
|
+
assert.equal((await call(acme, 'PUT', `/tickets/${a.ref}/shelf`, {state: 'archived'})).body.state, 'archived');
|
|
321
|
+
assert.equal((await call(acme, 'PUT', `/tickets/${b.ref}/shelf`, {state: 'removed'})).body.state, 'removed');
|
|
322
|
+
assert.equal((await call(acme, 'PUT', `/tickets/${b.ref}/shelf`, {state: 'binned'})).status, 400);
|
|
323
|
+
assert.equal((await call(acme, 'PUT', '/tickets/nope/shelf', {state: 'archived'})).status, 404);
|
|
324
|
+
let list = (await call(acme, 'GET', '/tickets')).body.tickets;
|
|
325
|
+
assert.equal(list.find(x => x.ref === a.ref).archived, true);
|
|
326
|
+
assert.ok(!list.some(x => x.ref === b.ref), 'removed from the list');
|
|
327
|
+
// Neither counts on the badge.
|
|
328
|
+
assert.equal((await call(acme, 'GET', '/count')).body.count, openBefore - 2);
|
|
329
|
+
// Domma still has both.
|
|
330
|
+
const inbox = (await call(manager, 'GET', '/api/plugins/support/tickets')).body.tickets;
|
|
331
|
+
const tb = inbox.find(x => x.ref === b.ref);
|
|
332
|
+
assert.ok(tb && inbox.some(x => x.ref === a.ref));
|
|
333
|
+
// Domma replies to the removed one: it comes back, unread.
|
|
334
|
+
await call(manager, 'POST', `/api/plugins/support/tickets/${tb.id}/reply`, {body: 'We fixed it.'});
|
|
335
|
+
list = (await call(acme, 'GET', '/tickets')).body.tickets;
|
|
336
|
+
const back = list.find(x => x.ref === b.ref);
|
|
337
|
+
assert.ok(back, 'a new reply brings it back');
|
|
338
|
+
assert.equal(back.archived, false);
|
|
339
|
+
assert.equal(back.unread, true);
|
|
340
|
+
// Put back by hand.
|
|
341
|
+
await call(acme, 'PUT', `/tickets/${a.ref}/shelf`, {state: null});
|
|
342
|
+
assert.equal((await call(acme, 'GET', '/tickets')).body.tickets.find(x => x.ref === a.ref).archived, false);
|
|
343
|
+
// Writing to an archived one brings it back too.
|
|
344
|
+
await call(acme, 'PUT', `/tickets/${a.ref}/shelf`, {state: 'archived'});
|
|
345
|
+
await call(acme, 'POST', `/tickets/${a.ref}/messages`, {body: 'one more thing'});
|
|
346
|
+
assert.equal((await call(acme, 'GET', '/tickets')).body.tickets.find(x => x.ref === a.ref).archived, false);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test('removing a request that never reached Domma cancels it - it is never sent', async () => {
|
|
350
|
+
const acme = await site({token: 'tok-acme'});
|
|
351
|
+
managerUp = false;
|
|
352
|
+
let s;
|
|
353
|
+
try {
|
|
354
|
+
s = (await call(acme, 'POST', '/tickets', {subject: 'Never mind', body: 'x'})).body;
|
|
355
|
+
await call(acme, 'POST', `/tickets/${s.ref}/messages`, {body: 'follow-up'});
|
|
356
|
+
assert.equal(s.state, 'waiting');
|
|
357
|
+
const r = await call(acme, 'PUT', `/tickets/${s.ref}/shelf`, {state: 'removed'});
|
|
358
|
+
assert.equal(r.body.state, 'discarded');
|
|
359
|
+
} finally { managerUp = true; }
|
|
360
|
+
const list = (await call(acme, 'GET', '/tickets')).body.tickets;
|
|
361
|
+
assert.ok(!list.some(x => x.ref === s.ref));
|
|
362
|
+
const inbox = (await call(manager, 'GET', '/api/plugins/support/tickets')).body.tickets;
|
|
363
|
+
assert.ok(!inbox.some(x => x.ref === s.ref), 'Domma never got it');
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test('review: archiving measures against the reply the customer SAW - a newer one is not put away unread', async () => {
|
|
367
|
+
const acme = await site({token: 'tok-acme'});
|
|
368
|
+
const s = (await call(acme, 'POST', '/tickets', {subject: 'Seen vs newer', body: 'x'})).body;
|
|
369
|
+
const t = (await call(manager, 'GET', '/api/plugins/support/tickets')).body.tickets.find(x => x.ref === s.ref);
|
|
370
|
+
await call(manager, 'POST', `/api/plugins/support/tickets/${t.id}/reply`, {body: 'first'});
|
|
371
|
+
const shown = (await call(acme, 'GET', '/tickets')).body.tickets.find(x => x.ref === s.ref);
|
|
372
|
+
const seenAt = shown.messages.at(-1).at;
|
|
373
|
+
await new Promise(r => setTimeout(r, 5));
|
|
374
|
+
await call(manager, 'POST', `/api/plugins/support/tickets/${t.id}/reply`, {body: 'second - not on their screen'});
|
|
375
|
+
await call(acme, 'GET', '/count'); // a background sync hears of it
|
|
376
|
+
await call(acme, 'GET', '/tickets');
|
|
377
|
+
await call(acme, 'PUT', `/tickets/${s.ref}/shelf`, {state: 'archived', at: seenAt});
|
|
378
|
+
const now = (await call(acme, 'GET', '/tickets')).body.tickets.find(x => x.ref === s.ref);
|
|
379
|
+
assert.equal(now.archived, false, 'the newer reply keeps it in the list');
|
|
380
|
+
assert.equal(now.unread, true);
|
|
381
|
+
});
|
|
@@ -5,7 +5,10 @@ import {
|
|
|
5
5
|
addTurn, buildMessagePayload, buildTicketPayload, cleanRequest, cleanUpdate, contactCard, createThrottle, lastDommaAt,
|
|
6
6
|
receivedTicket, senderView, telHref, toCustomerEmail, toDommaEmail
|
|
7
7
|
} from '../lib/shape.js';
|
|
8
|
-
import {mergeTickets} from '../lib/sender.js';
|
|
8
|
+
import {applyShelf, createOutbox, flush, mergeTickets} from '../lib/sender.js';
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
9
12
|
|
|
10
13
|
const T0 = '2026-10-04T10:00:00.000Z';
|
|
11
14
|
const T1 = '2026-10-04T11:00:00.000Z';
|
|
@@ -120,3 +123,67 @@ test('the site\'s list: the manager\'s view, plus what is still waiting here', (
|
|
|
120
123
|
assert.equal(offline[0].stale, true);
|
|
121
124
|
assert.equal(offline[0].unread, true);
|
|
122
125
|
});
|
|
126
|
+
|
|
127
|
+
test('the inbox archive: a yes/no, and a customer writing again takes a request out of it', () => {
|
|
128
|
+
assert.deepEqual(cleanUpdate({archived: true}), {patch: {archived: true}});
|
|
129
|
+
assert.deepEqual(cleanUpdate({archived: false}), {patch: {archived: false}});
|
|
130
|
+
assert.equal(cleanUpdate({archived: 'yes'}).error, 'Archived is yes or no.');
|
|
131
|
+
const {data} = receivedTicket({ref: 'r1', subject: 'S', body: 'B'}, 'acme', T0);
|
|
132
|
+
const archived = {...data, archived: true};
|
|
133
|
+
// Domma writing to it leaves it where Domma put it.
|
|
134
|
+
assert.equal(addTurn(archived, {from: 'domma', name: 'D', body: 'hi', id: 'd1', at: T1}).data.archived, true);
|
|
135
|
+
// The customer closing it does too.
|
|
136
|
+
assert.equal(addTurn(archived, {from: 'customer', name: 'Ann', id: 'c0', at: T1, close: true}).data.archived, true);
|
|
137
|
+
const back = addTurn(archived, {from: 'customer', name: 'Ann', body: 'still broken', id: 'c1', at: T2}).data;
|
|
138
|
+
assert.equal(back.archived, false);
|
|
139
|
+
assert.equal(back.status, 'open');
|
|
140
|
+
assert.ok(back.history.some(h => h.what === 'Back from the archive'));
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('the site shelf: removed ones go, archived ones are marked, a new Domma reply brings either back', () => {
|
|
144
|
+
const t = (ref, dommaAt) => ({ref, subject: ref, messages: dommaAt ? [{from: 'customer', at: T0}, {from: 'domma', at: dommaAt}] : [{from: 'customer', at: T0}]});
|
|
145
|
+
const list = [t('a', T1), t('b', T1), t('c', ''), t('d', T2)];
|
|
146
|
+
const shelf = {
|
|
147
|
+
a: {state: 'archived', dommaAt: T1},
|
|
148
|
+
b: {state: 'removed', dommaAt: T1},
|
|
149
|
+
c: {state: 'removed', dommaAt: ''},
|
|
150
|
+
d: {state: 'archived', dommaAt: T1} // Domma has written since (T2)
|
|
151
|
+
};
|
|
152
|
+
const out = applyShelf(list, shelf);
|
|
153
|
+
assert.deepEqual(out.map(x => [x.ref, x.archived]), [['a', true], ['d', false]]);
|
|
154
|
+
// Nothing shelved: all there, none archived.
|
|
155
|
+
assert.deepEqual(applyShelf(list, {}).map(x => x.archived), [false, false, false, false]);
|
|
156
|
+
// An offline copy has no messages to compare - the shelf is trusted.
|
|
157
|
+
assert.deepEqual(applyShelf([{ref: 'a', stale: true, messages: []}], shelf).map(x => x.archived), [true]);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('review: a request removed while a send is in flight is not half-deleted, and a cancelled item is never sent', async () => {
|
|
161
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'support-outbox-'));
|
|
162
|
+
try {
|
|
163
|
+
const outbox = createOutbox(dir);
|
|
164
|
+
outbox.add('ticket', {ref: 'T1', subject: 's', body: 'b'});
|
|
165
|
+
outbox.add('message', {ticketRef: 'T1', ref: 'M1', body: 'more'}, 'T1');
|
|
166
|
+
outbox.add('ticket', {ref: 'T2', subject: 's2', body: 'b2'});
|
|
167
|
+
const posted = [];
|
|
168
|
+
let release;
|
|
169
|
+
const gate = new Promise(r => { release = r; });
|
|
170
|
+
const api = {
|
|
171
|
+
open: async (p) => { posted.push(p.ref); if (p.ref === 'T1') await gate; return {ok: true}; },
|
|
172
|
+
message: async (p) => { posted.push(p.ref); return {ok: true}; }
|
|
173
|
+
};
|
|
174
|
+
const run = flush(outbox, api);
|
|
175
|
+
await new Promise(r => setImmediate(r));
|
|
176
|
+
// T1 is on the wire: discarding it now would be a promise we cannot keep.
|
|
177
|
+
assert.equal(outbox.discard('T1'), false);
|
|
178
|
+
assert.ok(outbox.all().some(i => i.ref === 'T1'));
|
|
179
|
+
release();
|
|
180
|
+
await run;
|
|
181
|
+
assert.deepEqual(posted, ['T1', 'M1', 'T2']);
|
|
182
|
+
// Nothing in flight: a waiting request is cancelled, follow-ups and all.
|
|
183
|
+
outbox.add('ticket', {ref: 'T3', subject: 's3', body: 'b3'});
|
|
184
|
+
outbox.add('message', {ticketRef: 'T3', ref: 'M3', body: 'x'}, 'T3');
|
|
185
|
+
assert.equal(outbox.discard('T3'), true);
|
|
186
|
+
await flush(outbox, api);
|
|
187
|
+
assert.ok(!posted.includes('T3') && !posted.includes('M3'));
|
|
188
|
+
} finally { fs.rmSync(dir, {recursive: true, force: true}); }
|
|
189
|
+
});
|