domma-cms 0.113.0 → 0.114.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.
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Support on the manager: every site's requests. Waiting on Domma comes first,
3
+ * urgent at the top; a request opens in a panel with the conversation, a reply
4
+ * box (the customer is emailed), status, priority and internal notes.
5
+ */
6
+
7
+ import {TONE, api, esc, listOf, openPanel, refreshBadge, stamp, store, threadHtml, when} from '../lib/kit.js';
8
+
9
+ const ROW = `
10
+ <div class="sp-row" data-id="{{key}}" tabindex="0" role="button">
11
+ <span class="sp-cat" data-bind-title="categoryLabel"><span data-icon="{{icon}}" data-icon-size="16"></span></span>
12
+ <span class="sp-main"><strong data-bind-text="subject"></strong><small data-bind-text="meta"></small></span>
13
+ <span class="sp-urgent" data-bind-hidden="!urgent">Urgent</span>
14
+ <span class="sp-chip" data-bind-class="tone" data-bind-text="statusLabel"></span>
15
+ </div>`;
16
+
17
+ const FILTERS = [
18
+ {key: 'open', label: 'Waiting on Domma'},
19
+ {key: 'answered', label: 'Answered'},
20
+ {key: 'closed', label: 'Resolved'},
21
+ {key: 'all', label: 'All'}
22
+ ];
23
+
24
+ export async function mount(root, ctx) {
25
+ const {kit, scope, here, meta, id, mode} = ctx;
26
+ const categories = meta.categories || [];
27
+ const statuses = meta.statuses || [];
28
+ const iconOf = (c) => categories.find(x => x.key === c)?.icon || 'message-square';
29
+ const labelOf = (c) => categories.find(x => x.key === c)?.short || c;
30
+ const inboxLabel = (s) => statuses.find(x => x.key === s)?.inboxLabel || s;
31
+
32
+ const tickets = M.observable([]);
33
+ const loading = M.observable(true);
34
+ const error = M.observable('');
35
+ const filter = M.observable(store.get('support.filter') || 'open');
36
+ const query = M.observable('');
37
+ const rows = M.observable([]);
38
+ const counts = M.observable({});
39
+ scope(M.effect(() => {
40
+ const all = tickets.value;
41
+ counts.value = Object.fromEntries(FILTERS.map(f => [f.key, f.key === 'all' ? all.length : all.filter(t => t.status === f.key).length]));
42
+ const q = query.value.trim().toLowerCase();
43
+ rows.value = all
44
+ .filter(t => filter.value === 'all' || t.status === filter.value)
45
+ .filter(t => !q || [t.subject, t.siteName, t.userName, t.userEmail, ...(t.messages || []).map(m => m.body)]
46
+ .some(v => String(v || '').toLowerCase().includes(q)))
47
+ // Urgent first, then whoever has waited longest for us (newest first elsewhere).
48
+ .sort((a, b) => (b.priority === 'urgent') - (a.priority === 'urgent')
49
+ || (filter.value === 'open' ? String(a.lastAt).localeCompare(String(b.lastAt)) : String(b.lastAt).localeCompare(String(a.lastAt))))
50
+ .map(t => ({
51
+ key: t.id,
52
+ subject: t.subject,
53
+ icon: iconOf(t.category),
54
+ categoryLabel: labelOf(t.category),
55
+ meta: [t.siteName, t.userName, `${t.lastFrom === 'domma' ? 'we wrote' : 'they wrote'} ${when(t.lastAt)}`].filter(Boolean).join(' · '),
56
+ statusLabel: inboxLabel(t.status),
57
+ tone: `is-${TONE[t.status] || 'muted'}`,
58
+ urgent: t.priority === 'urgent' && t.status !== 'closed'
59
+ }));
60
+ }));
61
+
62
+ const card = mode.contact || {};
63
+ root.innerHTML = '';
64
+ const view = document.createElement('div');
65
+ view.className = 'sp-card';
66
+ view.innerHTML = `
67
+ <div class="sp-bar">
68
+ <div class="sp-seg" role="tablist" aria-label="Filter">
69
+ ${FILTERS.map(f => `<button type="button" role="tab" data-filter="${f.key}" data-bind-class="filter.value === '${f.key}' && 'is-on'">${esc(f.label)} <b data-bind-text="counts.value['${f.key}'] || 0"></b></button>`).join('')}
70
+ </div>
71
+ <span class="sp-spacer"></span>
72
+ <input class="form-input sp-search" type="search" placeholder="Search requests" data-model="query.value" aria-label="Search requests">
73
+ <span data-help-title="Support inbox" data-help="Every site's support requests. Replying emails the customer a link back to the conversation in their own admin, and moves it to Answered (or Resolved, if you choose). A customer writing again moves it back to Waiting on Domma. Urgent requests sort first and turn the sidebar badge red. Internal notes never reach the site."></span>
74
+ </div>
75
+ <p class="sp-note sp-small">Customers see: ${esc([card.email, card.phone, card.hours].filter(Boolean).join(' · ') || 'no contact details set')}${mode.notifyEmail ? ` · New requests are emailed to ${esc(mode.notifyEmail)}` : ' · New requests are not emailed (no notifyEmail set)'}.</p>
76
+ <div class="sp-loading dm-loading-slot" data-bind-hidden="!loading.value">Loading...</div>
77
+ <p class="sp-note is-bad" data-bind-hidden="!error.value" data-bind-text="error.value"></p>
78
+ <p class="sp-note" data-bind-hidden="loading.value || rows.value.length">Nothing here.</p>
79
+ <div class="sp-list" data-each="rows.value key=key"></div>`;
80
+ root.appendChild(view);
81
+ scope(kit.helpPopovers(view, {position: 'bottom'}));
82
+ const listEl = view.querySelector('.sp-list');
83
+ listEl.innerHTML = ROW;
84
+ scope(M.effect(() => { rows.value; Promise.resolve().then(() => { M.flush?.(); I.scan(listEl); }); }));
85
+
86
+ async function load() {
87
+ loading.value = true;
88
+ try {
89
+ const res = await api.tickets();
90
+ if (!here()) return;
91
+ tickets.value = listOf(res, 'tickets');
92
+ error.value = '';
93
+ } catch (err) {
94
+ error.value = `Could not load requests: ${err.message || err}`;
95
+ } finally {
96
+ loading.value = false;
97
+ }
98
+ }
99
+
100
+ const byId = (tid) => tickets.peek().find(t => t.id === tid);
101
+ const replace = (t) => { tickets.value = tickets.peek().map(x => (x.id === t.id ? {...x, ...t} : x)); refreshBadge(); };
102
+
103
+ async function open(tid) {
104
+ let t = byId(tid);
105
+ if (!t) {
106
+ try { t = await api.ticket(tid); } catch { E.toast('That request is no longer here.', {type: 'warning'}); return; }
107
+ }
108
+ const draft = M.observable('');
109
+ const after = M.observable('answered');
110
+ const notes = M.observable(t.notes || '');
111
+ const priority = M.observable(t.priority || 'normal');
112
+ const status = M.observable(t.status);
113
+ const busy = M.observable(false);
114
+ let panel = null;
115
+ const render = () => {
116
+ const cur = byId(tid) || t;
117
+ status.value = cur.status;
118
+ priority.value = cur.priority;
119
+ const box = panel?.panel.querySelector('.sp-thread-host');
120
+ if (box) { box.innerHTML = threadHtml(cur.messages, 'domma'); box.scrollTop = box.scrollHeight; }
121
+ const urgent = panel?.panel.querySelector('[data-ref="urgent"]');
122
+ if (urgent) urgent.checked = cur.priority === 'urgent';
123
+ const chip = panel?.panel.querySelector('[data-ref="status"]');
124
+ if (chip) { chip.textContent = inboxLabel(cur.status); chip.className = `sp-chip is-${TONE[cur.status] || 'muted'}`; }
125
+ };
126
+ const siteUrl = t.siteHost ? `https://${t.siteHost}/admin/` : '';
127
+ const run = async (fn) => {
128
+ if (busy.peek()) return;
129
+ busy.value = true;
130
+ try { await fn(); render(); } catch (err) { E.toast(err.message || String(err), {type: 'error'}); } finally { busy.value = false; }
131
+ };
132
+ panel = await openPanel({
133
+ title: t.subject,
134
+ size: 'lg',
135
+ model: {draft, after, notes, priority, status, busy},
136
+ html: `<div class="sp-detail">
137
+ <dl class="sp-facts">
138
+ <div><dt>Status</dt><dd><span data-ref="status" class="sp-chip"></span></dd></div>
139
+ <div><dt>Site</dt><dd>${siteUrl ? `<a href="${esc(siteUrl)}" target="_blank" rel="noopener">${esc(t.siteName)}</a>` : esc(t.siteName)}</dd></div>
140
+ <div><dt>From</dt><dd>${esc(t.userName)}${t.userEmail ? ` · <a href="mailto:${esc(t.userEmail)}">${esc(t.userEmail)}</a>` : ''}</dd></div>
141
+ <div><dt>About</dt><dd>${esc(labelOf(t.category))}</dd></div>
142
+ <div><dt>Opened</dt><dd>${esc(stamp(t.createdAt))}</dd></div>
143
+ ${t.where ? `<div><dt>Where</dt><dd>${esc(t.where)}</dd></div>` : ''}
144
+ ${t.cmsVersion ? `<div><dt>CMS</dt><dd>${esc(t.cmsVersion)}</dd></div>` : ''}
145
+ </dl>
146
+ <div class="sp-thread-host"></div>
147
+ <div class="sp-reply">
148
+ <label class="form-label" for="sp-answer">Reply <span data-help="The customer sees it in their admin and gets it by email${t.userEmail ? '' : ' (this one has no email on record, so only in their admin)'}."></span></label>
149
+ <textarea id="sp-answer" class="form-textarea" rows="5" maxlength="8000" data-model="draft.value" placeholder="Write to ${esc(t.userName || 'the customer')}"></textarea>
150
+ <div class="sp-actions">
151
+ <label class="sp-inline">Then <select class="form-select" data-model="after.value"><option value="answered">wait for them</option><option value="closed">mark resolved</option></select></label>
152
+ <span class="sp-spacer"></span>
153
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="resolve" data-bind-hidden="status.value === 'closed'" data-bind-disabled="busy.value">Resolve without reply</button>
154
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="reopen" data-bind-hidden="status.value !== 'closed'" data-bind-disabled="busy.value">Reopen</button>
155
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="send" data-bind-disabled="busy.value || !draft.value.trim()"><span data-icon="send" data-icon-size="13"></span> Send reply</button>
156
+ </div>
157
+ </div>
158
+ <div class="sp-side">
159
+ <label class="sp-inline"><input type="checkbox" data-ref="urgent"> Urgent</label>
160
+ <label class="form-label" for="sp-notes">Internal notes <span data-help="For the Domma team only - never sent to the site."></span></label>
161
+ <textarea id="sp-notes" class="form-textarea" rows="3" data-model="notes.value"></textarea>
162
+ <div class="sp-actions">
163
+ <button type="button" class="btn btn-sm btn-ghost sp-danger" data-on-click="remove" data-bind-disabled="busy.value"><span data-icon="trash" data-icon-size="13"></span> Delete</button>
164
+ <span class="sp-spacer"></span>
165
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="saveNotes" data-bind-disabled="busy.value">Save notes</button>
166
+ </div>
167
+ </div>
168
+ </div>`,
169
+ methods: {
170
+ send: () => run(async () => {
171
+ const next = await api.reply(tid, {body: draft.peek(), status: after.peek()});
172
+ replace(next);
173
+ draft.value = '';
174
+ E.toast(t.userEmail ? `Sent - ${t.userName || 'the customer'} has been emailed.` : 'Sent - it shows in their admin.', {type: 'success'});
175
+ }),
176
+ resolve: () => run(async () => { replace(await api.reply(tid, {status: 'closed'})); E.toast('Resolved.', {type: 'success'}); }),
177
+ reopen: () => run(async () => { replace(await api.update(tid, {status: 'open'})); }),
178
+ saveNotes: () => run(async () => { replace(await api.update(tid, {notes: notes.peek()})); E.toast('Notes saved.', {type: 'success'}); }),
179
+ remove: async () => {
180
+ if (!await E.confirm(`Delete "${esc(t.subject)}"? It goes from ${esc(t.siteName)}'s admin too.`)) return;
181
+ await run(async () => {
182
+ await api.remove(tid);
183
+ tickets.value = tickets.peek().filter(x => x.id !== tid);
184
+ refreshBadge();
185
+ panel?.close();
186
+ });
187
+ }
188
+ }
189
+ });
190
+ panel.panel.querySelector('[data-ref="urgent"]').addEventListener('change', (e) => run(async () => {
191
+ replace(await api.update(tid, {priority: e.target.checked ? 'urgent' : 'normal'}));
192
+ }));
193
+ render();
194
+ }
195
+
196
+ const bindings = M.applyBindings({tickets, loading, error, filter, query, rows, counts}, view, {methods: {}});
197
+ scope(() => bindings.dispose());
198
+
199
+ const seg = view.querySelector('.sp-seg');
200
+ const onFilter = (e) => { const b = e.target.closest('[data-filter]'); if (b) { filter.value = b.dataset.filter; store.set('support.filter', filter.value); } };
201
+ seg.addEventListener('click', onFilter);
202
+ const onClick = (e) => { const row = e.target.closest('.sp-row'); if (row) open(row.dataset.id); };
203
+ const onKey = (e) => { const row = e.target.closest('.sp-row'); if (row && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); open(row.dataset.id); } };
204
+ listEl.addEventListener('click', onClick);
205
+ listEl.addEventListener('keydown', onKey);
206
+ scope(() => { seg.removeEventListener('click', onFilter); listEl.removeEventListener('click', onClick); listEl.removeEventListener('keydown', onKey); });
207
+
208
+ I.scan(view);
209
+ await load();
210
+ if (id && here()) open(id);
211
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Support - one sidebar entry, two screens, decided by the server:
3
+ *
4
+ * on a site the contact card and this site's conversations with Domma (desk.js)
5
+ * on the manager the inbox of every site's requests (inbox.js)
6
+ *
7
+ * #/plugins/support the screen
8
+ * #/plugins/support/<id> the same, with that request open (a notification's or email's link):
9
+ * on a site the id is the request's ref, on the manager its entry id
10
+ *
11
+ * Path segments, not ?query: the admin router 404s a query on a fresh load.
12
+ */
13
+
14
+ import {api, loadKit} from '../lib/kit.js';
15
+
16
+ const ROUTE = /^#\/plugins\/support(\/|$)/;
17
+ const V = '1.0.0';
18
+ let lifetime = null;
19
+
20
+ export const supportView = {
21
+ templateUrl: '/plugins/support/admin/templates/support.html',
22
+
23
+ async onMount($container) {
24
+ const kit = await loadKit();
25
+ lifetime ??= kit.viewLifetime(ROUTE);
26
+ if (!ROUTE.test(location.hash)) return;
27
+ lifetime.reset();
28
+ const scope = (fn) => { lifetime.add(fn); return fn; };
29
+ const container = $container.get(0);
30
+ const root = container.querySelector('[data-support-admin]') || container;
31
+ const mountedFor = location.hash;
32
+ const here = () => location.hash === mountedFor && root.isConnected;
33
+ const m = location.hash.match(/^#\/plugins\/support\/([^/?#]+)/);
34
+ const id = m ? decodeURIComponent(m[1]) : '';
35
+
36
+ const fail = (text) => {
37
+ root.innerHTML = '';
38
+ const p = document.createElement('p');
39
+ p.className = 'sp-note is-bad';
40
+ p.textContent = text;
41
+ root.appendChild(p);
42
+ };
43
+
44
+ let mode;
45
+ let meta;
46
+ try {
47
+ [mode, meta] = await Promise.all([api.mode(), api.meta()]);
48
+ } catch (err) {
49
+ fail(`Support could not load: ${err.message || err}. You can always email support@dommajs.org.`);
50
+ return;
51
+ }
52
+ if (!here()) return;
53
+ try {
54
+ const screen = mode.mode === 'receiver' ? 'inbox' : 'desk';
55
+ const mod = await import(`/plugins/support/admin/views/${screen}.js?v=${V}`);
56
+ if (!here()) return;
57
+ const cleanup = await mod.mount(root, {kit, scope, here, meta, mode, id});
58
+ if (typeof cleanup === 'function') scope(cleanup);
59
+ } catch (err) {
60
+ fail(`This screen could not load: ${err.message || err}`);
61
+ }
62
+ }
63
+ };
@@ -0,0 +1,30 @@
1
+ {
2
+ "slug": "support-tickets",
3
+ "title": "Support requests",
4
+ "description": "Support requests from every site the manager runs. Read and answer them in Support, never here: the sites see only what the Support inbox lets them.",
5
+ "plugin": "support",
6
+ "fields": [
7
+ {"name": "subject", "label": "Subject", "type": "text", "required": true},
8
+ {"name": "category", "label": "Category", "type": "text"},
9
+ {"name": "priority", "label": "Priority", "type": "text"},
10
+ {"name": "status", "label": "Status", "type": "text"},
11
+ {"name": "site", "label": "Site", "type": "text"},
12
+ {"name": "ref", "label": "Reference", "type": "text"},
13
+ {"name": "where", "label": "Where", "type": "text"},
14
+ {"name": "cmsVersion", "label": "CMS version", "type": "text"},
15
+ {"name": "userId", "label": "User id", "type": "text"},
16
+ {"name": "userName", "label": "From", "type": "text"},
17
+ {"name": "userEmail", "label": "Email", "type": "text"},
18
+ {"name": "messages", "label": "Messages", "type": "array", "items": "object"},
19
+ {"name": "lastFrom", "label": "Last from", "type": "text"},
20
+ {"name": "lastAt", "label": "Last message", "type": "text"},
21
+ {"name": "notes", "label": "Internal notes", "type": "textarea"},
22
+ {"name": "history", "label": "History", "type": "array", "items": "object"}
23
+ ],
24
+ "api": {
25
+ "create": {"enabled": false, "access": "admin"},
26
+ "read": {"enabled": false, "access": "admin"},
27
+ "update": {"enabled": false, "access": "admin"},
28
+ "delete": {"enabled": false, "access": "admin"}
29
+ }
30
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Support - default settings (config/plugins.json `support.settings` overrides).
3
+ *
4
+ * The contact card (every site):
5
+ * email, phone, hours, responseTime
6
+ * On the MANAGER these are the source: each managed site gets them with its
7
+ * next sync, so a change of hours is made once, on dcm. A site's own values
8
+ * are used only until it has heard from the manager, and on a standalone
9
+ * install (which never does).
10
+ *
11
+ * Site side:
12
+ * syncMinutes how often a site sends what is waiting and looks for replies
13
+ *
14
+ * Manager side:
15
+ * notifyEmail where new requests and customer replies are emailed ('' = none)
16
+ * perSiteMax messages one site may send in perSiteMinutes; more answer 429
17
+ * perSiteMinutes the window for perSiteMax
18
+ */
19
+ export default {
20
+ email: 'support@dommajs.org',
21
+ phone: '',
22
+ hours: 'Monday to Friday, 9am to 5pm (UK time)',
23
+ responseTime: 'We answer within one working day, and urgent requests first.',
24
+ syncMinutes: 5,
25
+ notifyEmail: 'support@dommajs.org',
26
+ perSiteMax: 20,
27
+ perSiteMinutes: 10
28
+ };
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Support mail: one way out, and never a pretend one.
3
+ *
4
+ * Without an SMTP host the CMS falls back to Ethereal and "succeeds" while
5
+ * delivering nothing, so with no SMTP these are simply not sent - the in-admin
6
+ * inbox and notices still carry everything. Tests swap the sender with
7
+ * `setMailer()`.
8
+ *
9
+ * @module support/lib/mail
10
+ */
11
+ import {createTransport, isSmtpConfigured, sendEmail} from '../../../server/services/email.js';
12
+ import {getConfig} from '../../../server/config.js';
13
+
14
+ let override = null;
15
+ let transport = null;
16
+ let transportFor = '';
17
+
18
+ /** Tests only: `fn(msg)` receives every message instead. Pass null to restore. */
19
+ export function setMailer(fn) { override = fn; }
20
+
21
+ const site = () => { try { return getConfig('site') || {}; } catch { return {}; } };
22
+
23
+ /** This server's own address, as links in emails need it. */
24
+ export function siteUrl() {
25
+ const s = site();
26
+ return String(s.url || s.baseUrl || '').replace(/\/+$/, '');
27
+ }
28
+
29
+ /**
30
+ * Send one message. Returns false (and logs) rather than throwing: a lost email
31
+ * must never fail the request that caused it, and callers do not await it - an
32
+ * unhandled rejection stops the whole server.
33
+ *
34
+ * @param {{to: string, subject: string, html: string, text: string, replyTo?: string}} msg
35
+ * @returns {Promise<boolean>}
36
+ */
37
+ export async function send(msg, {log} = {}) {
38
+ try {
39
+ if (!msg?.to) return false;
40
+ if (override) { await override(msg); return true; }
41
+ const s = site();
42
+ const smtp = s.smtp || {};
43
+ if (!isSmtpConfigured(smtp)) return false;
44
+ const key = JSON.stringify([smtp.host, smtp.port, smtp.user, smtp.secure]);
45
+ if (!transport || transportFor !== key) { transport = await createTransport(smtp); transportFor = key; }
46
+ await sendEmail(transport, {
47
+ from: smtp.fromAddress || 'noreply@example.com',
48
+ fromName: 'Domma Support',
49
+ to: msg.to, subject: msg.subject, html: msg.html, text: msg.text,
50
+ ...(msg.replyTo && {replyTo: msg.replyTo})
51
+ });
52
+ return true;
53
+ } catch (err) {
54
+ const line = `[support] Email to ${msg?.to} not sent: ${err?.message || err}`;
55
+ try { if (typeof log?.error === 'function') log.error(line); else console.error(line); } catch { /* never */ }
56
+ return false;
57
+ }
58
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Support - the RECEIVING side, on the manager (dcm).
3
+ *
4
+ * Requests live in the `support-tickets` collection. Its API is off on all four
5
+ * verbs - a collection's default is a public read, and these carry customers'
6
+ * names, emails and words.
7
+ *
8
+ * A site is who its fleet token says it is (site-manager/services/fleetTokens.js,
9
+ * memory only, minted when the manager forked it). That module only exists where
10
+ * Site Manager does, which is also how this plugin knows it is on the manager.
11
+ *
12
+ * @module support/lib/receiver
13
+ */
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import {fileURLToPath} from 'node:url';
17
+ import {createCollection, createEntry, deleteEntry, getCollection, getEntry, listEntries, updateEntry} from '../../../server/services/collections.js';
18
+ import {addTurn, receivedTicket} from './shape.js';
19
+
20
+ export const COLLECTION = 'support-tickets';
21
+
22
+ /**
23
+ * One write at a time per key. Each turn reads a request, changes it and writes the whole
24
+ * record back, so two at once (a customer message while Domma's reply saves) would lose one.
25
+ */
26
+ const queues = new Map();
27
+ function serial(key, fn) {
28
+ const prev = queues.get(key) || Promise.resolve();
29
+ const next = prev.catch(() => {}).then(fn);
30
+ const tail = next.catch(() => {});
31
+ queues.set(key, tail);
32
+ tail.then(() => { if (queues.get(key) === tail) queues.delete(key); });
33
+ return next;
34
+ }
35
+
36
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
37
+ const SITE_MANAGER = path.resolve(HERE, '..', '..', 'site-manager');
38
+ const TOKENS = path.join(SITE_MANAGER, 'services', 'fleetTokens.js');
39
+
40
+ /** Is Site Manager here - so this is the manager, and requests come TO it? */
41
+ export const isManager = () => fs.existsSync(TOKENS);
42
+
43
+ /** The site a bearer token speaks for, or null. */
44
+ export async function slugFromToken(authorization) {
45
+ const token = String(authorization || '').replace(/^Bearer\s+/i, '').trim();
46
+ if (!token) return null;
47
+ try {
48
+ const tokens = await import(TOKENS);
49
+ return tokens.resolve(token) || null;
50
+ } catch { return null; }
51
+ }
52
+
53
+ /** slug -> {name, host}, from Site Manager's registry. Empty when there is none. */
54
+ export function siteDirectory() {
55
+ try {
56
+ const j = JSON.parse(fs.readFileSync(path.join(SITE_MANAGER, 'data', 'sites.json'), 'utf8'));
57
+ const list = Array.isArray(j) ? j : (j.sites || Object.values(j));
58
+ return Object.fromEntries(list.filter(s => s?.slug).map(s => [s.slug, {
59
+ name: s.displayName || s.name || s.slug,
60
+ host: s.primaryDomain || s.fallbackDomain || (Array.isArray(s.domains) ? s.domains[0] : '') || '',
61
+ cmsVersion: s.cmsVersion || ''
62
+ }]));
63
+ } catch { return {}; }
64
+ }
65
+
66
+ /** Make the collection when it is missing. Every boot: a plugin switched on by copy never runs onEnable. */
67
+ export async function ensureCollection() {
68
+ if (await getCollection(COLLECTION)) return false;
69
+ const schema = JSON.parse(fs.readFileSync(path.join(HERE, '..', 'collections', `${COLLECTION}.json`), 'utf8'));
70
+ await createCollection(schema);
71
+ return true;
72
+ }
73
+
74
+ /** Every request, newest first. */
75
+ export async function allTickets() {
76
+ const {entries} = await listEntries(COLLECTION, {limit: 100000, sort: 'createdAt', order: 'desc'});
77
+ return entries || [];
78
+ }
79
+
80
+ export const getTicket = (id) => getEntry(COLLECTION, id);
81
+ /**
82
+ * Deleted requests, as `slug:ref`, so a site retrying one (it never heard the first answer)
83
+ * is told 404 - which it takes as final - instead of the request coming back.
84
+ */
85
+ let DELETED = path.join(HERE, '..', 'data', 'deleted.json');
86
+ /** Where this plugin keeps its own state (tests pass a temp dir). */
87
+ export function setDataDir(dir) { DELETED = path.join(dir, 'deleted.json'); }
88
+ const readDeleted = () => { try { return new Set(JSON.parse(fs.readFileSync(DELETED, 'utf8'))); } catch { return new Set(); } };
89
+ export const wasDeleted = (slug, ref) => readDeleted().has(`${slug}:${ref}`);
90
+
91
+ export async function removeTicket(id) {
92
+ const e = await getTicket(id);
93
+ if (e?.data?.ref) {
94
+ const gone = [...readDeleted(), `${e.data.site}:${e.data.ref}`].slice(-5000);
95
+ fs.mkdirSync(path.dirname(DELETED), {recursive: true});
96
+ fs.writeFileSync(DELETED, JSON.stringify(gone));
97
+ }
98
+ return deleteEntry(COLLECTION, id);
99
+ }
100
+
101
+ /** One site's requests - never another's. */
102
+ export async function ticketsOf(slug) {
103
+ return (await allTickets()).filter(e => e.data?.site === slug);
104
+ }
105
+
106
+ async function byRef(slug, ref) {
107
+ if (!ref) return null;
108
+ return (await ticketsOf(slug)).find(e => e.data?.ref === ref) || null;
109
+ }
110
+
111
+ /**
112
+ * A site opened a request. The same site sending the same `ref` again (a retry
113
+ * after an answer that never reached it) gets the request it already made.
114
+ *
115
+ * @returns {Promise<{entry?: object, created?: boolean, error?: string}>}
116
+ */
117
+ export function open(slug, payload, now = new Date().toISOString(), site = {}) {
118
+ return serial(`open:${slug}:${payload?.ref}`, () => openNow(slug, payload, now, site));
119
+ }
120
+
121
+ async function openNow(slug, payload, now, site) {
122
+ const {data, error} = receivedTicket(payload, slug, now);
123
+ if (error) return {error};
124
+ if (wasDeleted(slug, data.ref)) return {missing: true, error: 'That request was deleted by Domma.'};
125
+ // A site cannot always tell its own engine version (its package.json is the site's); the registry can.
126
+ if (site.cmsVersion) data.cmsVersion = String(site.cmsVersion).slice(0, 20);
127
+ if (!data.ref) return {error: 'A request needs a reference.'};
128
+ const same = await byRef(slug, data.ref);
129
+ if (same) return {entry: same, created: false};
130
+ return {entry: await createEntry(COLLECTION, data, {source: 'fleet'}), created: true};
131
+ }
132
+
133
+ /**
134
+ * The customer wrote again, or closed the request. Found by the site's ref, in
135
+ * that site's requests only.
136
+ *
137
+ * @returns {Promise<{entry?: object, added?: boolean, error?: string, missing?: boolean}>}
138
+ */
139
+ export async function customerTurn(slug, payload = {}, now = new Date().toISOString()) {
140
+ const found = await byRef(slug, String(payload.ticketRef || ''));
141
+ if (!found) return {missing: true, error: 'No such request.'};
142
+ return serial(found.id, () => customerTurnNow(found.id, payload, now));
143
+ }
144
+
145
+ async function customerTurnNow(id, payload, now) {
146
+ const e = await getTicket(id);
147
+ if (!e) return {missing: true, error: 'No such request.'};
148
+ const body = String(payload.body || '').trim();
149
+ if (!body && !payload.close) return {error: 'Write a message first.'};
150
+ const name = String(payload.user?.name || '').trim() || e.data.userName;
151
+ const {data, added} = addTurn(e.data, {from: 'customer', name, body, id: String(payload.ref || ''), at: now, close: Boolean(payload.close)});
152
+ if (!added) return {entry: e, added: false};
153
+ return {entry: await updateEntry(COLLECTION, e.id, data), added: true};
154
+ }
155
+
156
+ /** Domma replied (and/or moved the status). */
157
+ export function dommaTurn(id, turn, now = new Date().toISOString()) {
158
+ return serial(id, () => dommaTurnNow(id, turn, now));
159
+ }
160
+
161
+ async function dommaTurnNow(id, {body = '', status, by, msgId}, now) {
162
+ const e = await getTicket(id);
163
+ if (!e) return null;
164
+ const {data} = addTurn(e.data, {from: 'domma', name: by, body, id: msgId, at: now, status});
165
+ return updateEntry(COLLECTION, id, data);
166
+ }
167
+
168
+ /** Status, priority or notes, from the inbox - with a line of history for what moved. */
169
+ export function patch(id, change, by, now = new Date().toISOString()) {
170
+ return serial(id, () => patchNow(id, change, by, now));
171
+ }
172
+
173
+ async function patchNow(id, change, by, now) {
174
+ const e = await getTicket(id);
175
+ if (!e) return null;
176
+ const d = {...e.data};
177
+ const history = Array.isArray(d.history) ? [...d.history] : [];
178
+ if (change.status !== undefined && change.status !== d.status) history.push({at: now, by, what: `Status: ${change.status}`});
179
+ if (change.priority !== undefined && change.priority !== d.priority) history.push({at: now, by, what: `Priority: ${change.priority}`});
180
+ Object.assign(d, change, {history: history.slice(-200)});
181
+ return updateEntry(COLLECTION, id, d);
182
+ }