domma-cms 0.112.1 → 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/admin/js/app.js +5 -5
- package/package.json +1 -1
- package/plugins/free-tier.lock.json +32 -10
- package/plugins/shopping-cart/CLAUDE.md +15 -0
- package/plugins/shopping-cart/lib/pricing.js +2 -1
- package/plugins/shopping-cart/lib/shop.js +5 -1
- package/plugins/shopping-cart/plugin.js +117 -47
- package/plugins/shopping-cart/plugin.json +1 -1
- package/plugins/shopping-cart/plugin.public.js +2 -2
- package/plugins/shopping-cart/public/lib/products.js +6 -1
- package/plugins/shopping-cart/tests/api.test.js +42 -0
- 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,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Support - the SITE side, on every site the manager started.
|
|
3
|
+
*
|
|
4
|
+
* Everything the customer writes goes into this site's outbox FIRST and is then
|
|
5
|
+
* sent. The manager's token for this site rotates whenever either end restarts,
|
|
6
|
+
* and for those seconds a send answers 401 or does not connect at all - so a
|
|
7
|
+
* request is never "sent or lost", it is "sent, or waiting". Waiting ones go
|
|
8
|
+
* again on the next sync (every few minutes) and whenever the screen is opened.
|
|
9
|
+
*
|
|
10
|
+
* Requests are known here by `ref` (made on this site) because a waiting one
|
|
11
|
+
* has no manager id yet. A follow-up message carries its request's ref, and
|
|
12
|
+
* waits behind it if the request itself has not gone.
|
|
13
|
+
*
|
|
14
|
+
* The outbox also keeps the last view of each request from the manager (for the
|
|
15
|
+
* sidebar badge, which must never call the manager), which Domma replies this
|
|
16
|
+
* site has seen, and the contact card the manager last sent.
|
|
17
|
+
*
|
|
18
|
+
* @module support/lib/sender
|
|
19
|
+
*/
|
|
20
|
+
import {randomUUID} from 'node:crypto';
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import {lastDommaAt} from './shape.js';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Where this site's manager is, from the environment its manager set.
|
|
27
|
+
* @returns {{url: string, token: string, slug: string}|null}
|
|
28
|
+
*/
|
|
29
|
+
export function managerOf(env = process.env) {
|
|
30
|
+
if (!env.MANAGER_URL || !env.FLEET_TOKEN) return null;
|
|
31
|
+
return {url: String(env.MANAGER_URL).replace(/\/+$/, ''), token: String(env.FLEET_TOKEN), slug: String(env.SITE_SLUG || '')};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const newRef = () => randomUUID();
|
|
35
|
+
|
|
36
|
+
/** The outbox, in data/outbox.json. Written whole, through a temp file. */
|
|
37
|
+
export function createOutbox(dataDir) {
|
|
38
|
+
const file = path.join(dataDir, 'outbox.json');
|
|
39
|
+
const read = () => {
|
|
40
|
+
try {
|
|
41
|
+
const j = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
42
|
+
return {
|
|
43
|
+
items: Array.isArray(j.items) ? j.items : [],
|
|
44
|
+
seen: j.seen && typeof j.seen === 'object' ? j.seen : {},
|
|
45
|
+
tickets: Array.isArray(j.tickets) ? j.tickets : [],
|
|
46
|
+
contact: j.contact && typeof j.contact === 'object' ? j.contact : null
|
|
47
|
+
};
|
|
48
|
+
} catch { return {items: [], seen: {}, tickets: [], contact: null}; }
|
|
49
|
+
};
|
|
50
|
+
const write = (state) => {
|
|
51
|
+
fs.mkdirSync(dataDir, {recursive: true});
|
|
52
|
+
fs.writeFileSync(`${file}.tmp`, JSON.stringify(state, null, 2));
|
|
53
|
+
fs.renameSync(`${file}.tmp`, file);
|
|
54
|
+
};
|
|
55
|
+
const update = (fn) => { const s = read(); const r = fn(s); write(s); return r; };
|
|
56
|
+
return {
|
|
57
|
+
all: () => read().items,
|
|
58
|
+
seen: () => read().seen,
|
|
59
|
+
tickets: () => read().tickets,
|
|
60
|
+
contact: () => read().contact,
|
|
61
|
+
add(kind, payload, ticketRef) {
|
|
62
|
+
return update(s => {
|
|
63
|
+
const item = {ref: payload.ref, kind, ticketRef: ticketRef || payload.ref, state: 'waiting', payload,
|
|
64
|
+
createdAt: new Date().toISOString(), lastError: ''};
|
|
65
|
+
s.items.push(item);
|
|
66
|
+
return item;
|
|
67
|
+
});
|
|
68
|
+
},
|
|
69
|
+
mark(ref, patch) {
|
|
70
|
+
return update(s => {
|
|
71
|
+
const item = s.items.find(i => i.ref === ref);
|
|
72
|
+
if (item) Object.assign(item, patch);
|
|
73
|
+
return item || null;
|
|
74
|
+
});
|
|
75
|
+
},
|
|
76
|
+
markSeen(ticketRef, at) { update(s => { s.seen[ticketRef] = at; }); },
|
|
77
|
+
/** The manager's latest view: what the badge reads. Sent items it already has are dropped from the outbox. */
|
|
78
|
+
remember(remote, contact) {
|
|
79
|
+
update(s => {
|
|
80
|
+
s.tickets = remote.map(t => ({ref: t.ref, subject: t.subject, status: t.status, dommaAt: lastDommaAt(t)}));
|
|
81
|
+
if (contact) s.contact = contact;
|
|
82
|
+
const byRef = new Map(remote.map(t => [t.ref, t]));
|
|
83
|
+
const have = new Set(remote.flatMap(t => [t.ref, ...(t.messages || []).map(m => m.id)]));
|
|
84
|
+
s.items = s.items.filter((i) => {
|
|
85
|
+
if (i.state !== 'sent') return true;
|
|
86
|
+
if (have.has(i.ref)) return false; // arrived: done with it
|
|
87
|
+
const t = byRef.get(i.kind === 'ticket' ? i.ref : i.ticketRef);
|
|
88
|
+
if (!t) return true; // not in the view yet
|
|
89
|
+
if (i.kind === 'message' && !i.payload?.body) return false; // a close: no message to look for
|
|
90
|
+
// The manager answered 2xx yet its request lacks this message (lost to a concurrent
|
|
91
|
+
// write, say): send it again. Not once the conversation is long enough to have trimmed it.
|
|
92
|
+
if (i.kind === 'message' && (t.messages || []).length < 500) { i.state = 'waiting'; i.lastError = 'Sent again: it did not arrive.'; }
|
|
93
|
+
return true;
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Talk to the manager. `fetchImpl` is swapped in tests; nothing here throws -
|
|
102
|
+
* every call answers `{ok, status?, body?, error?}`.
|
|
103
|
+
*/
|
|
104
|
+
export function managerApi(manager, fetchImpl = globalThis.fetch) {
|
|
105
|
+
const call = async (method, route, body) => {
|
|
106
|
+
try {
|
|
107
|
+
const res = await fetchImpl(`${manager.url}/api/plugins/support/fleet/${route}`, {
|
|
108
|
+
method,
|
|
109
|
+
headers: {authorization: `Bearer ${manager.token}`, ...(body && {'content-type': 'application/json'})},
|
|
110
|
+
...(body && {body: JSON.stringify(body)}),
|
|
111
|
+
signal: AbortSignal.timeout(10_000)
|
|
112
|
+
});
|
|
113
|
+
let json = null;
|
|
114
|
+
try { json = await res.json(); } catch { /* not JSON */ }
|
|
115
|
+
if (res.status >= 200 && res.status < 300) return {ok: true, status: res.status, body: json};
|
|
116
|
+
return {ok: false, status: res.status, error: json?.error || `The Domma server answered ${res.status}.`};
|
|
117
|
+
} catch (err) {
|
|
118
|
+
return {ok: false, error: `Could not reach the Domma server: ${err.message}`};
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
return {
|
|
122
|
+
open: (payload) => call('POST', 'tickets', payload),
|
|
123
|
+
message: (payload) => call('POST', 'messages', payload),
|
|
124
|
+
mine: () => call('GET', 'mine')
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Send whatever is waiting, oldest first. Safe to repeat: the manager answers a
|
|
130
|
+
* request or message it already has with what it already made.
|
|
131
|
+
*
|
|
132
|
+
* @returns {Promise<{sent: number, waiting: number, error: string}>}
|
|
133
|
+
*/
|
|
134
|
+
export function flush(outbox, api) {
|
|
135
|
+
// One flush at a time per outbox: the timer, the badge's nudge, a screen load and a send can
|
|
136
|
+
// all ask at once, and two flushes would POST the same waiting item twice.
|
|
137
|
+
if (!outbox.flushing) outbox.flushing = flushOnce(outbox, api).finally(() => { outbox.flushing = null; });
|
|
138
|
+
return outbox.flushing;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Statuses that mean "try again later"; any other 4xx means the manager will never take it. */
|
|
142
|
+
const RETRY = new Set([401, 408, 429]);
|
|
143
|
+
|
|
144
|
+
async function flushOnce(outbox, api) {
|
|
145
|
+
let sent = 0;
|
|
146
|
+
let error = '';
|
|
147
|
+
for (const item of outbox.all().filter(i => i.state === 'waiting')) {
|
|
148
|
+
if (item.kind === 'message') {
|
|
149
|
+
const parent = outbox.all().find(i => i.kind === 'ticket' && i.ref === item.ticketRef);
|
|
150
|
+
if (parent?.state === 'waiting') continue; // goes after its request
|
|
151
|
+
if (parent?.state === 'refused') { outbox.mark(item.ref, {state: 'refused', lastError: 'Its request was not accepted.'}); continue; }
|
|
152
|
+
}
|
|
153
|
+
const r = item.kind === 'ticket' ? await api.open(item.payload) : await api.message(item.payload);
|
|
154
|
+
if (r.ok) {
|
|
155
|
+
outbox.mark(item.ref, {state: 'sent', lastError: '', sentAt: new Date().toISOString()});
|
|
156
|
+
sent++;
|
|
157
|
+
} else {
|
|
158
|
+
error = r.error || 'Not sent.';
|
|
159
|
+
// A 4xx other than 401/408/429 is permanent (400 malformed, 404 its request is gone, 413 too big):
|
|
160
|
+
// stop retrying it. 401 is a token the manager does not know yet; 5xx and no answer are transient.
|
|
161
|
+
const permanent = r.status >= 400 && r.status < 500 && !RETRY.has(r.status);
|
|
162
|
+
outbox.mark(item.ref, {lastError: error, triedAt: new Date().toISOString(), ...(permanent && {state: 'refused'})});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return {sent, waiting: outbox.all().filter(i => i.state === 'waiting').length, error};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The requests the screen shows: the manager's view of each, plus anything
|
|
170
|
+
* still waiting here (a request the manager has never seen, or a message it
|
|
171
|
+
* has not got yet, shown at the end of its conversation as "sending").
|
|
172
|
+
*
|
|
173
|
+
* @param {object[]} items - the outbox
|
|
174
|
+
* @param {object[]|null} remote - the manager's /mine, or null when it could not be reached
|
|
175
|
+
* @param {Object<string, string>} seen - request ref -> the Domma message time this site has read
|
|
176
|
+
* @param {object[]} [cached] - the last remote view, used while the manager cannot be reached
|
|
177
|
+
* @returns {object[]}
|
|
178
|
+
*/
|
|
179
|
+
export function mergeTickets(items, remote, seen = {}, cached = []) {
|
|
180
|
+
const out = new Map();
|
|
181
|
+
for (const t of remote || []) {
|
|
182
|
+
const dommaAt = lastDommaAt(t);
|
|
183
|
+
out.set(t.ref, {...t, local: false, unread: Boolean(dommaAt && seen[t.ref] !== dommaAt)});
|
|
184
|
+
}
|
|
185
|
+
if (!remote) {
|
|
186
|
+
// Offline: what we last heard, so the list does not empty while the manager restarts.
|
|
187
|
+
for (const c of cached) {
|
|
188
|
+
out.set(c.ref, {ref: c.ref, id: '', subject: c.subject, status: c.status, statusLabel: '', messages: [], local: false, stale: true,
|
|
189
|
+
unread: Boolean(c.dommaAt && seen[c.ref] !== c.dommaAt)});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
for (const i of items) {
|
|
193
|
+
const p = i.payload || {};
|
|
194
|
+
if (i.kind === 'ticket' && !out.has(i.ref) && i.state !== 'sent') {
|
|
195
|
+
out.set(i.ref, {
|
|
196
|
+
id: '', ref: i.ref, category: p.category, priority: p.priority, subject: p.subject, where: p.where || '',
|
|
197
|
+
userName: p.user?.name || '', status: i.state === 'refused' ? 'refused' : 'waiting',
|
|
198
|
+
statusLabel: i.state === 'refused' ? 'Not accepted' : 'Waiting to send',
|
|
199
|
+
messages: [{id: i.ref, from: 'customer', name: p.user?.name || '', body: p.body, at: i.createdAt, pending: true}],
|
|
200
|
+
lastFrom: 'customer', lastAt: i.createdAt, createdAt: i.createdAt, updatedAt: i.createdAt,
|
|
201
|
+
local: true, unread: false, lastError: i.lastError || ''
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
if (i.kind === 'message' && i.state !== 'sent' && p.body) {
|
|
205
|
+
const t = out.get(i.ticketRef);
|
|
206
|
+
if (t && !(t.messages || []).some(m => m.id === i.ref)) {
|
|
207
|
+
t.messages = [...(t.messages || []), {id: i.ref, from: 'customer', name: p.user?.name || '', body: p.body, at: i.createdAt,
|
|
208
|
+
pending: i.state === 'waiting', refused: i.state === 'refused'}];
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return [...out.values()].sort((a, b) => String(b.lastAt || b.createdAt).localeCompare(String(a.lastAt || a.createdAt)));
|
|
213
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Support - the pure half: what a request is, what a conversation holds, what
|
|
3
|
+
* each side may say, and the emails both sides get.
|
|
4
|
+
*
|
|
5
|
+
* Nothing here touches the disk, the network or the clock (callers pass `now`),
|
|
6
|
+
* so all of it is tested on its own (tests/shape.test.js).
|
|
7
|
+
*
|
|
8
|
+
* A request is a CONVERSATION: the customer's opening message, then turns from
|
|
9
|
+
* either side. Its status follows whose turn it is:
|
|
10
|
+
*
|
|
11
|
+
* open waiting on Domma - a new request, or the customer wrote again
|
|
12
|
+
* answered waiting on you - Domma replied
|
|
13
|
+
* closed resolved - either side said so; writing again reopens it
|
|
14
|
+
*
|
|
15
|
+
* @module support/lib/shape
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** What a request is about. The icon is a Domma icon name. */
|
|
19
|
+
export const CATEGORIES = [
|
|
20
|
+
{key: 'help', label: 'I need help with something', short: 'Help', icon: 'help-circle'},
|
|
21
|
+
{key: 'problem', label: 'Something is not working', short: 'Problem', icon: 'alert-triangle'},
|
|
22
|
+
{key: 'billing', label: 'Billing or my account', short: 'Billing', icon: 'credit-card'},
|
|
23
|
+
{key: 'other', label: 'Something else', short: 'Other', icon: 'message-square'}
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
export const PRIORITIES = [
|
|
27
|
+
{key: 'normal', label: 'Normal', hint: 'We answer in order, usually within a working day.'},
|
|
28
|
+
{key: 'urgent', label: 'Urgent', hint: 'My site is down, or I cannot take orders, bookings or payments.'}
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
export const STATUSES = [
|
|
32
|
+
{key: 'open', label: 'Waiting on Domma', inboxLabel: 'Open', tone: 'warning'},
|
|
33
|
+
{key: 'answered', label: 'Domma replied', inboxLabel: 'Answered', tone: 'info'},
|
|
34
|
+
{key: 'closed', label: 'Resolved', inboxLabel: 'Resolved', tone: 'success'}
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
export const CATEGORY_KEYS = CATEGORIES.map(c => c.key);
|
|
38
|
+
export const PRIORITY_KEYS = PRIORITIES.map(p => p.key);
|
|
39
|
+
export const STATUS_KEYS = STATUSES.map(s => s.key);
|
|
40
|
+
export const statusLabel = (key) => STATUSES.find(s => s.key === key)?.label ?? key;
|
|
41
|
+
export const categoryLabel = (key) => CATEGORIES.find(c => c.key === key)?.short ?? key;
|
|
42
|
+
|
|
43
|
+
/** Longest a field may be. The routes' bodyLimit (32 KB) is the outer wall. */
|
|
44
|
+
export const LIMITS = {subject: 140, body: 8000, where: 300, notes: 8000, messages: 500};
|
|
45
|
+
|
|
46
|
+
// Control characters go (all but tab and newline): each is 6 bytes once JSON-escaped, so 8000 of them
|
|
47
|
+
// would pass here and then be refused by the manager's 32 KB body limit, forever.
|
|
48
|
+
const text = (v, max) => String(v ?? '').replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000B-\u001F\u007F]/g, '').trim().slice(0, max);
|
|
49
|
+
const line = (v, max) => text(v, max).replace(/\s*\n\s*/g, ' ');
|
|
50
|
+
const EMAIL_RE = /^[^\s@<>"]+@[^\s@<>"]+\.[^\s@<>"]+$/;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A new request as the customer typed it, cleaned. Never carries who sent it -
|
|
54
|
+
* that comes from the signed-in user on the server, not from the form.
|
|
55
|
+
*
|
|
56
|
+
* @param {object} body
|
|
57
|
+
* @returns {{request?: object, error?: string}}
|
|
58
|
+
*/
|
|
59
|
+
export function cleanRequest(body = {}) {
|
|
60
|
+
const category = CATEGORY_KEYS.includes(body.category) ? body.category : 'help';
|
|
61
|
+
const priority = PRIORITY_KEYS.includes(body.priority) ? body.priority : 'normal';
|
|
62
|
+
const subject = line(body.subject, LIMITS.subject);
|
|
63
|
+
const message = text(body.body, LIMITS.body);
|
|
64
|
+
if (!subject) return {error: 'Give it a short subject.'};
|
|
65
|
+
if (!message) return {error: 'Tell us what you need.'};
|
|
66
|
+
return {request: {category, priority, subject, body: message, where: line(body.where, LIMITS.where)}};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** One more message in a conversation. */
|
|
70
|
+
export function cleanMessage(body = {}) {
|
|
71
|
+
const message = text(body.body, LIMITS.body);
|
|
72
|
+
if (!message) return {error: 'Write a message first.'};
|
|
73
|
+
return {message: {body: message}};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const who = (user = {}) => ({id: text(user.id, 64), name: line(user.name, 120), email: text(user.email, 200)});
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* What a site sends the manager for a new request. `site` is informational
|
|
80
|
+
* only - the manager files it under the site its token names.
|
|
81
|
+
*/
|
|
82
|
+
export function buildTicketPayload({request, user = {}, cmsVersion = '', site = '', ref}) {
|
|
83
|
+
return {ref: text(ref, 64), ...request, cmsVersion: text(cmsVersion, 20), site: text(site, 64), user: who(user)};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** What a site sends for another message, or to close a request (`body` empty, `close: true`). */
|
|
87
|
+
export function buildMessagePayload({ticketRef, ref, body = '', close = false, user = {}}) {
|
|
88
|
+
return {ticketRef: text(ticketRef, 64), ref: text(ref, 64), body: text(body, LIMITS.body), close: Boolean(close), user: who(user)};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A request as the MANAGER stores it. The slug is the one the token resolved
|
|
93
|
+
* to; anything the body claims about the site is ignored.
|
|
94
|
+
*
|
|
95
|
+
* @param {object} payload
|
|
96
|
+
* @param {string} slug
|
|
97
|
+
* @param {string} now - ISO time
|
|
98
|
+
* @returns {{data?: object, error?: string}}
|
|
99
|
+
*/
|
|
100
|
+
export function receivedTicket(payload = {}, slug, now) {
|
|
101
|
+
const {request, error} = cleanRequest(payload);
|
|
102
|
+
if (error) return {error};
|
|
103
|
+
const u = who(payload.user && typeof payload.user === 'object' ? payload.user : {});
|
|
104
|
+
const ref = text(payload.ref, 64);
|
|
105
|
+
return {
|
|
106
|
+
data: {
|
|
107
|
+
category: request.category,
|
|
108
|
+
priority: request.priority,
|
|
109
|
+
subject: request.subject,
|
|
110
|
+
where: request.where,
|
|
111
|
+
site: slug,
|
|
112
|
+
ref,
|
|
113
|
+
cmsVersion: text(payload.cmsVersion, 20),
|
|
114
|
+
userId: u.id,
|
|
115
|
+
userName: u.name || 'A site admin',
|
|
116
|
+
userEmail: EMAIL_RE.test(u.email) ? u.email : '',
|
|
117
|
+
status: 'open',
|
|
118
|
+
messages: [{id: ref || 'm0', from: 'customer', name: u.name || 'A site admin', body: request.body, at: now}],
|
|
119
|
+
lastFrom: 'customer',
|
|
120
|
+
lastAt: now,
|
|
121
|
+
notes: '',
|
|
122
|
+
history: [{at: now, by: u.name || 'A site admin', what: 'Opened'}]
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Add a turn to a conversation, PURE: returns the new data. A message whose id
|
|
129
|
+
* is already there (a retry that did arrive) changes nothing.
|
|
130
|
+
*
|
|
131
|
+
* @param {object} data - the stored request
|
|
132
|
+
* @param {{from: 'customer'|'domma', name: string, body?: string, id: string, at: string, status?: string, close?: boolean}} turn
|
|
133
|
+
* @returns {{data: object, added: boolean}}
|
|
134
|
+
*/
|
|
135
|
+
export function addTurn(data, {from, name, body = '', id, at, status, close = false}) {
|
|
136
|
+
const messages = Array.isArray(data.messages) ? [...data.messages] : [];
|
|
137
|
+
if (id && messages.some(m => m.id === id)) return {data, added: false};
|
|
138
|
+
const history = Array.isArray(data.history) ? [...data.history] : [];
|
|
139
|
+
const next = {...data};
|
|
140
|
+
let added = false;
|
|
141
|
+
if (body) {
|
|
142
|
+
messages.push({id, from, name, body, at});
|
|
143
|
+
next.lastFrom = from;
|
|
144
|
+
next.lastAt = at;
|
|
145
|
+
added = true;
|
|
146
|
+
history.push({at, by: name, what: from === 'domma' ? 'Replied' : 'Wrote'});
|
|
147
|
+
}
|
|
148
|
+
// Whose turn it is now. Domma may choose (answered, or closed); the customer reopens by writing.
|
|
149
|
+
let to = data.status;
|
|
150
|
+
if (close) to = 'closed';
|
|
151
|
+
else if (from === 'customer' && body) to = 'open';
|
|
152
|
+
else if (from === 'domma') to = STATUS_KEYS.includes(status) ? status : (body ? 'answered' : data.status);
|
|
153
|
+
if (to !== data.status) {
|
|
154
|
+
history.push({at, by: name, what: to === 'closed' ? 'Resolved' : to === 'open' && data.status === 'closed' ? 'Reopened' : `Status: ${statusLabel(to)}`});
|
|
155
|
+
added = true;
|
|
156
|
+
}
|
|
157
|
+
next.status = to;
|
|
158
|
+
next.messages = messages.slice(-LIMITS.messages);
|
|
159
|
+
next.history = history.slice(-200);
|
|
160
|
+
return {data: next, added};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* What a site may read of one of its own requests. A whitelist: internal notes,
|
|
165
|
+
* history and other sites stay on the manager.
|
|
166
|
+
*/
|
|
167
|
+
export function senderView(entry) {
|
|
168
|
+
const d = entry?.data || {};
|
|
169
|
+
return {
|
|
170
|
+
id: entry.id,
|
|
171
|
+
ref: d.ref || '',
|
|
172
|
+
category: d.category,
|
|
173
|
+
priority: d.priority,
|
|
174
|
+
subject: d.subject,
|
|
175
|
+
where: d.where || '',
|
|
176
|
+
userName: d.userName || '',
|
|
177
|
+
status: d.status,
|
|
178
|
+
statusLabel: statusLabel(d.status),
|
|
179
|
+
messages: (d.messages || []).map(m => ({id: m.id, from: m.from, name: m.name, body: m.body, at: m.at})),
|
|
180
|
+
lastFrom: d.lastFrom || 'customer',
|
|
181
|
+
lastAt: d.lastAt || '',
|
|
182
|
+
createdAt: entry.meta?.createdAt || '',
|
|
183
|
+
updatedAt: entry.meta?.updatedAt || ''
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The newest message from Domma, for "unread" - its time, or ''. */
|
|
188
|
+
export function lastDommaAt(ticket) {
|
|
189
|
+
const ms = (ticket?.messages || []).filter(m => m.from === 'domma');
|
|
190
|
+
return ms.length ? String(ms[ms.length - 1].at || '') : '';
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** What the inbox may change without writing: status and internal notes. */
|
|
194
|
+
export function cleanUpdate(body = {}) {
|
|
195
|
+
const patch = {};
|
|
196
|
+
if (body.status !== undefined) {
|
|
197
|
+
if (!STATUS_KEYS.includes(body.status)) return {error: 'That is not a status.'};
|
|
198
|
+
patch.status = body.status;
|
|
199
|
+
}
|
|
200
|
+
if (body.notes !== undefined) patch.notes = text(body.notes, LIMITS.notes);
|
|
201
|
+
if (body.priority !== undefined) {
|
|
202
|
+
if (!PRIORITY_KEYS.includes(body.priority)) return {error: 'That is not a priority.'};
|
|
203
|
+
patch.priority = body.priority;
|
|
204
|
+
}
|
|
205
|
+
if (!Object.keys(patch).length) return {error: 'Nothing to change.'};
|
|
206
|
+
return {patch};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The contact card every site shows, from settings. Only what is safe and
|
|
211
|
+
* well-formed gets through: an email that looks like one, a phone of phone
|
|
212
|
+
* characters, short plain text otherwise.
|
|
213
|
+
*/
|
|
214
|
+
export function contactCard(s = {}) {
|
|
215
|
+
const email = text(s.email, 200);
|
|
216
|
+
const phone = line(s.phone, 40);
|
|
217
|
+
return {
|
|
218
|
+
email: EMAIL_RE.test(email) ? email : '',
|
|
219
|
+
phone: /^[+\d][\d\s().-]{4,}$/.test(phone) ? phone : '',
|
|
220
|
+
hours: line(s.hours, 200),
|
|
221
|
+
responseTime: line(s.responseTime, 300)
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** The tel: form of a phone number. */
|
|
226
|
+
export const telHref = (phone) => `tel:${String(phone || '').replace(/[^\d+]/g, '')}`;
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* How many a site may send: `max` in any `windowMs`. The manager's global
|
|
230
|
+
* limiter exempts loopback, which is where every site calls from, so this is
|
|
231
|
+
* the only brake.
|
|
232
|
+
*/
|
|
233
|
+
export function createThrottle({max = 20, windowMs = 10 * 60_000} = {}) {
|
|
234
|
+
const seen = new Map();
|
|
235
|
+
return (key, now = Date.now()) => {
|
|
236
|
+
const recent = (seen.get(key) || []).filter(t => now - t < windowMs);
|
|
237
|
+
if (recent.length >= max) { seen.set(key, recent); return false; }
|
|
238
|
+
recent.push(now);
|
|
239
|
+
seen.set(key, recent);
|
|
240
|
+
return true;
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ---------------------------------------------------------------------------------
|
|
245
|
+
// Emails
|
|
246
|
+
// ---------------------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
export const escapeHtml = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
249
|
+
|
|
250
|
+
/** A plain message: paragraphs, a quoted block and a button. Everything escaped. */
|
|
251
|
+
function compose({intro = [], quote = '', facts = [], link = '', linkText = 'Open'}) {
|
|
252
|
+
const text = [
|
|
253
|
+
...intro, '',
|
|
254
|
+
...(quote ? [quote.split('\n').map(l => `> ${l}`).join('\n'), ''] : []),
|
|
255
|
+
...facts.map(([k, v]) => `${k}: ${v}`),
|
|
256
|
+
...(link ? ['', `${linkText}: ${link}`] : [])
|
|
257
|
+
].join('\n');
|
|
258
|
+
const html = `<div style="font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;font-size:15px;line-height:1.5;color:#1f2937;max-width:600px">
|
|
259
|
+
${intro.map(p => `<p>${escapeHtml(p)}</p>`).join('\n')}
|
|
260
|
+
${quote ? `<blockquote style="margin:16px 0;padding:12px 16px;border-left:3px solid #6366f1;background:#f5f5ff;white-space:pre-wrap">${escapeHtml(quote)}</blockquote>` : ''}
|
|
261
|
+
${facts.length ? `<table style="font-size:13px;color:#4b5563">${facts.map(([k, v]) => `<tr><td style="padding:2px 12px 2px 0">${escapeHtml(k)}</td><td>${escapeHtml(v)}</td></tr>`).join('')}</table>` : ''}
|
|
262
|
+
${link ? `<p style="margin-top:20px"><a href="${escapeHtml(link)}" style="background:#4f46e5;color:#fff;padding:10px 18px;border-radius:6px;text-decoration:none">${escapeHtml(linkText)}</a></p>` : ''}
|
|
263
|
+
</div>`;
|
|
264
|
+
return {text, html};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* To Domma: a site opened a request, or wrote again. Reply-To is the customer,
|
|
269
|
+
* so answering from the mailbox reaches them - but the inbox link is the way to
|
|
270
|
+
* keep the conversation in one place.
|
|
271
|
+
*/
|
|
272
|
+
export function toDommaEmail({ticket, siteName, link, body, isNew}) {
|
|
273
|
+
const urgent = ticket.priority === 'urgent';
|
|
274
|
+
const subject = `${urgent ? '[URGENT] ' : ''}${isNew ? 'Support request' : 'Reply'} from ${siteName}: ${ticket.subject}`;
|
|
275
|
+
return {
|
|
276
|
+
subject,
|
|
277
|
+
replyTo: ticket.userEmail || '',
|
|
278
|
+
...compose({
|
|
279
|
+
intro: [isNew ? `${ticket.userName} at ${siteName} needs help.` : `${ticket.userName} at ${siteName} wrote again.`],
|
|
280
|
+
quote: body,
|
|
281
|
+
facts: [['Site', siteName], ['Category', categoryLabel(ticket.category)], ['Priority', urgent ? 'URGENT' : 'Normal'],
|
|
282
|
+
...(ticket.where ? [['Where', ticket.where]] : []), ...(ticket.cmsVersion ? [['CMS', ticket.cmsVersion]] : []),
|
|
283
|
+
...(ticket.userEmail ? [['Email', ticket.userEmail]] : [])],
|
|
284
|
+
link, linkText: 'Open in the Support inbox'
|
|
285
|
+
})
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** To the customer: Domma replied (or resolved the request). */
|
|
290
|
+
export function toCustomerEmail({ticket, siteName, link, body, resolved}) {
|
|
291
|
+
return {
|
|
292
|
+
subject: `Re: ${ticket.subject}`,
|
|
293
|
+
...compose({
|
|
294
|
+
intro: [`Hello ${ticket.userName || ''}`.trim() + ',',
|
|
295
|
+
body ? 'Domma Support has replied to your request.' : 'Domma Support has marked your request as resolved.',
|
|
296
|
+
...(resolved && body ? ['We have marked it as resolved. If it is not, just reply in your admin and it reopens.'] : [])],
|
|
297
|
+
quote: body,
|
|
298
|
+
facts: [['Request', ticket.subject], ['Site', siteName]],
|
|
299
|
+
link, linkText: 'Open it in your admin'
|
|
300
|
+
})
|
|
301
|
+
};
|
|
302
|
+
}
|