domma-cms 0.91.0 → 0.92.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.
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Feedback - the RECEIVING side, on the manager (dcm).
3
+ *
4
+ * Reports live in the `feedback-reports` collection (NOT `feedback`: that slug
5
+ * is the starter preset every site has for visitor comments). Its API is off
6
+ * on all four verbs - a collection's default is a public read, and these carry
7
+ * admins' names and emails.
8
+ *
9
+ * A site is who its fleet token says it is (site-manager/services/fleetTokens.js,
10
+ * memory only, minted when the manager forked it). The module is imported at
11
+ * call time and only exists where Site Manager does, which is also how this
12
+ * plugin knows it is on the manager.
13
+ *
14
+ * @module feedback/lib/receiver
15
+ */
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import {fileURLToPath} from 'node:url';
19
+ import {createCollection, createEntry, deleteEntry, getCollection, getEntry, listEntries, updateEntry} from '../../../server/services/collections.js';
20
+ import {receivedReport, statusFromIssue, statusLabel} from './shape.js';
21
+
22
+ export const COLLECTION = 'feedback-reports';
23
+
24
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
25
+ const SITE_MANAGER = path.resolve(HERE, '..', '..', 'site-manager');
26
+ const TOKENS = path.join(SITE_MANAGER, 'services', 'fleetTokens.js');
27
+
28
+ /** Is Site Manager here - so this is the manager, and reports come TO it? */
29
+ export const isManager = () => fs.existsSync(TOKENS);
30
+
31
+ /** The site a bearer token speaks for, or null. */
32
+ export async function slugFromToken(authorization) {
33
+ const token = String(authorization || '').replace(/^Bearer\s+/i, '').trim();
34
+ if (!token) return null;
35
+ try {
36
+ const tokens = await import(TOKENS);
37
+ return tokens.resolve(token) || null;
38
+ } catch { return null; }
39
+ }
40
+
41
+ /** slug -> display name, from Site Manager's registry. Empty when there is none. */
42
+ export function siteNames() {
43
+ try {
44
+ const j = JSON.parse(fs.readFileSync(path.join(SITE_MANAGER, 'data', 'sites.json'), 'utf8'));
45
+ const list = Array.isArray(j) ? j : (j.sites || Object.values(j));
46
+ return Object.fromEntries(list.filter(s => s?.slug).map(s => [s.slug, s.displayName || s.name || s.slug]));
47
+ } catch { return {}; }
48
+ }
49
+
50
+ /** Make the collection when it is missing. Every boot: a plugin switched on by copy never runs onEnable. */
51
+ export async function ensureCollection() {
52
+ if (await getCollection(COLLECTION)) return false;
53
+ const schema = JSON.parse(fs.readFileSync(path.join(HERE, '..', 'collections', `${COLLECTION}.json`), 'utf8'));
54
+ await createCollection(schema);
55
+ return true;
56
+ }
57
+
58
+ /** Every report, newest first. */
59
+ export async function allReports() {
60
+ const {entries} = await listEntries(COLLECTION, {limit: 100000, sort: 'createdAt', order: 'desc'});
61
+ return entries || [];
62
+ }
63
+
64
+ export const getReport = (id) => getEntry(COLLECTION, id);
65
+ export const removeReport = (id) => deleteEntry(COLLECTION, id);
66
+
67
+ /**
68
+ * A site's report arrived. The same site sending the same `ref` again (a retry
69
+ * after a reply that never reached it) gets the report it already made.
70
+ *
71
+ * @returns {Promise<{entry?: object, created?: boolean, error?: string}>}
72
+ */
73
+ export async function receive(slug, payload) {
74
+ const {data, error} = receivedReport(payload, slug);
75
+ if (error) return {error};
76
+ if (data.ref) {
77
+ const same = (await allReports()).find(e => e.data?.site === slug && e.data?.ref === data.ref);
78
+ if (same) return {entry: same, created: false};
79
+ }
80
+ data.history = [{at: new Date().toISOString(), by: data.userName, what: 'Sent'}];
81
+ const entry = await createEntry(COLLECTION, data, {source: 'fleet'});
82
+ return {entry, created: true};
83
+ }
84
+
85
+ /** One site's reports - never another's. */
86
+ export async function reportsOf(slug) {
87
+ return (await allReports()).filter(e => e.data?.site === slug);
88
+ }
89
+
90
+ /**
91
+ * Change a report, keeping a line of history for each thing that moved.
92
+ *
93
+ * @param {string} id
94
+ * @param {object} patch - from cleanUpdate()
95
+ * @param {string} by - who, for the history
96
+ * @returns {Promise<object|null>}
97
+ */
98
+ export async function applyPatch(id, patch, by) {
99
+ const e = await getReport(id);
100
+ if (!e) return null;
101
+ const d = {...e.data};
102
+ const now = new Date().toISOString();
103
+ const history = Array.isArray(d.history) ? [...d.history] : [];
104
+ if (patch.status !== undefined && patch.status !== d.status) history.push({at: now, by, what: `Status: ${statusLabel(patch.status)}`});
105
+ if (patch.reply !== undefined) { d.replyAt = patch.reply ? now : ''; history.push({at: now, by, what: patch.reply ? 'Replied' : 'Reply removed'}); }
106
+ if (patch.issueKey !== undefined && patch.issueKey !== d.issueKey) history.push({at: now, by, what: `Waypoint ${patch.issueKey}`});
107
+ Object.assign(d, patch, {history: history.slice(-100)});
108
+ return updateEntry(COLLECTION, id, d);
109
+ }
110
+
111
+ /**
112
+ * Waypoint says an issue linked to a report moved. The report follows, unless
113
+ * it was set to "Not planned" by hand.
114
+ */
115
+ export async function issueMoved(sourceId, issue = {}) {
116
+ const e = await getReport(sourceId).catch(() => null);
117
+ if (!e) return null;
118
+ const status = statusFromIssue(issue.statusCategory, e.data.status);
119
+ return applyPatch(sourceId, {status, issueStatus: String(issue.statusName || '').slice(0, 60),
120
+ ...(issue.key && {issueKey: String(issue.key)})}, 'Waypoint');
121
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Feedback - the SENDING side, on every site the manager started.
3
+ *
4
+ * A report is written to this site's own outbox FIRST and only then sent. The
5
+ * manager's token for this site rotates whenever either end restarts, and for
6
+ * those seconds a send answers 401 or does not connect at all - so a report is
7
+ * never "sent or lost", it is "sent, or waiting". Waiting ones go again the
8
+ * next time anyone opens the screen, and on the hourly sync.
9
+ *
10
+ * The outbox also remembers which vendor replies this site has already seen,
11
+ * which is what the sidebar badge and the "new reply" notification count.
12
+ *
13
+ * @module feedback/lib/sender
14
+ */
15
+ import {randomUUID} from 'node:crypto';
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+
19
+ /**
20
+ * Where this site's manager is, from the environment its manager set.
21
+ *
22
+ * @param {object} [env]
23
+ * @returns {{url: string, token: string, slug: string}|null}
24
+ */
25
+ export function managerOf(env = process.env) {
26
+ if (!env.MANAGER_URL || !env.FLEET_TOKEN) return null;
27
+ return {url: String(env.MANAGER_URL).replace(/\/+$/, ''), token: String(env.FLEET_TOKEN), slug: String(env.SITE_SLUG || '')};
28
+ }
29
+
30
+ /**
31
+ * The outbox: this site's copy of everything it sent, in data/outbox.json.
32
+ *
33
+ * @param {string} dataDir
34
+ * @returns {object}
35
+ */
36
+ export function createOutbox(dataDir) {
37
+ const file = path.join(dataDir, 'outbox.json');
38
+ const read = () => {
39
+ try {
40
+ const j = JSON.parse(fs.readFileSync(file, 'utf8'));
41
+ return {items: Array.isArray(j.items) ? j.items : [], seen: j.seen && typeof j.seen === 'object' ? j.seen : {}};
42
+ } catch { return {items: [], seen: {}}; }
43
+ };
44
+ const write = (state) => {
45
+ fs.mkdirSync(dataDir, {recursive: true});
46
+ fs.writeFileSync(`${file}.tmp`, JSON.stringify(state, null, 2));
47
+ fs.renameSync(`${file}.tmp`, file);
48
+ };
49
+ return {
50
+ all: () => read().items,
51
+ seen: () => read().seen,
52
+ add(payload) {
53
+ const s = read();
54
+ const item = {ref: payload.ref, state: 'waiting', payload, createdAt: new Date().toISOString(), remoteId: '', lastError: ''};
55
+ s.items.push(item);
56
+ write(s);
57
+ return item;
58
+ },
59
+ mark(ref, patch) {
60
+ const s = read();
61
+ const item = s.items.find(i => i.ref === ref);
62
+ if (!item) return null;
63
+ Object.assign(item, patch);
64
+ write(s);
65
+ return item;
66
+ },
67
+ markSeen(id, replyAt) {
68
+ const s = read();
69
+ s.seen[id] = replyAt;
70
+ write(s);
71
+ }
72
+ };
73
+ }
74
+
75
+ export const newRef = () => randomUUID();
76
+
77
+ /**
78
+ * Talk to the manager. `fetchImpl` is swapped in tests; nothing here throws -
79
+ * every call answers `{ok, status?, body?, error?}`.
80
+ *
81
+ * @param {{url: string, token: string}} manager
82
+ * @param {Function} [fetchImpl]
83
+ */
84
+ export function managerApi(manager, fetchImpl = globalThis.fetch) {
85
+ const call = async (method, route, body) => {
86
+ try {
87
+ const res = await fetchImpl(`${manager.url}/api/plugins/feedback/fleet/${route}`, {
88
+ method,
89
+ headers: {authorization: `Bearer ${manager.token}`, ...(body && {'content-type': 'application/json'})},
90
+ ...(body && {body: JSON.stringify(body)}),
91
+ signal: AbortSignal.timeout(10_000)
92
+ });
93
+ let json = null;
94
+ try { json = await res.json(); } catch { /* not JSON */ }
95
+ if (res.status >= 200 && res.status < 300) return {ok: true, status: res.status, body: json};
96
+ return {ok: false, status: res.status, error: json?.error || `The Domma server answered ${res.status}.`};
97
+ } catch (err) {
98
+ return {ok: false, error: `Could not reach the Domma server: ${err.message}`};
99
+ }
100
+ };
101
+ return {
102
+ submit: (payload) => call('POST', 'submit', payload),
103
+ mine: () => call('GET', 'mine')
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Send whatever is waiting. A report the manager already has (a retry after a
109
+ * timeout that did arrive) comes back with the same id, so this is safe to
110
+ * repeat.
111
+ *
112
+ * @returns {Promise<{sent: number, waiting: number, error: string}>}
113
+ */
114
+ export async function flush(outbox, api) {
115
+ let sent = 0;
116
+ let error = '';
117
+ for (const item of outbox.all().filter(i => i.state === 'waiting')) {
118
+ const r = await api.submit(item.payload);
119
+ if (r.ok && r.body?.id) {
120
+ outbox.mark(item.ref, {state: 'sent', remoteId: r.body.id, lastError: '', sentAt: new Date().toISOString()});
121
+ sent++;
122
+ } else {
123
+ error = r.error || 'Not sent.';
124
+ outbox.mark(item.ref, {lastError: error, triedAt: new Date().toISOString()});
125
+ // 400 means the manager will never take it as it stands; stop retrying it.
126
+ if (r.status === 400) outbox.mark(item.ref, {state: 'refused'});
127
+ }
128
+ }
129
+ return {sent, waiting: outbox.all().filter(i => i.state === 'waiting').length, error};
130
+ }
131
+
132
+ /**
133
+ * The list the screen shows: the manager's view of every report this site
134
+ * sent, plus the ones still waiting to go (which the manager has never seen).
135
+ *
136
+ * @param {object[]} outboxItems
137
+ * @param {object[]|null} remote - the manager's /mine, or null when it could not be reached
138
+ * @param {Object<string, string>} seen - report id -> the replyAt this site has read
139
+ * @returns {object[]}
140
+ */
141
+ export function mergeReports(outboxItems, remote, seen = {}) {
142
+ const out = [];
143
+ const known = new Set();
144
+ for (const r of remote || []) {
145
+ known.add(r.ref);
146
+ out.push({...r, local: false, unread: Boolean(r.reply && r.replyAt && seen[r.id] !== r.replyAt)});
147
+ }
148
+ for (const i of outboxItems) {
149
+ if (known.has(i.ref)) continue;
150
+ if (i.state === 'sent' && remote) continue; // the manager dropped it; nothing to show but noise
151
+ const p = i.payload || {};
152
+ out.push({
153
+ id: '', ref: i.ref, type: p.type, title: p.title, description: p.description, where: p.where || '', severity: p.severity || '',
154
+ userName: p.user?.name || '', status: i.state === 'refused' ? 'refused' : 'waiting',
155
+ statusLabel: i.state === 'refused' ? 'Not accepted' : 'Waiting to send',
156
+ reply: '', replyAt: '', issueKey: '', issueStatus: '', createdAt: i.createdAt, updatedAt: i.createdAt,
157
+ local: true, unread: false, lastError: i.lastError || ''
158
+ });
159
+ }
160
+ return out.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)));
161
+ }
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Feedback - the pure half: what a report is, what each side may say about it,
3
+ * and how a Waypoint issue's progress becomes a report's status.
4
+ *
5
+ * Nothing here touches the disk, the network or the clock (the throttle takes
6
+ * `now`), so all of it is tested on its own (tests/shape.test.js).
7
+ *
8
+ * @module feedback/lib/shape
9
+ */
10
+
11
+ /** What a report can be. The icon is a Domma icon name (checked against the registry). */
12
+ export const TYPES = [
13
+ {key: 'bug', label: 'Something is broken', short: 'Bug', icon: 'alert-circle'},
14
+ {key: 'feature', label: 'An idea or request', short: 'Request', icon: 'sparkles'},
15
+ {key: 'question', label: 'A question', short: 'Question', icon: 'help-circle'},
16
+ {key: 'praise', label: 'Something we got right', short: 'Praise', icon: 'heart'},
17
+ {key: 'other', label: 'Something else', short: 'Other', icon: 'message-square'}
18
+ ];
19
+
20
+ /** How bad a bug is. Only asked for bugs. */
21
+ export const SEVERITIES = [
22
+ {key: 'minor', label: 'Minor - a nuisance'},
23
+ {key: 'major', label: 'Major - gets in the way'},
24
+ {key: 'blocker', label: 'Blocker - cannot carry on'}
25
+ ];
26
+
27
+ /** Where a report is. The order is the order the inbox offers them in. */
28
+ export const STATUSES = [
29
+ {key: 'new', label: 'New', tone: 'info'},
30
+ {key: 'acknowledged', label: 'Seen', tone: 'muted'},
31
+ {key: 'planned', label: 'Planned', tone: 'info'},
32
+ {key: 'in-progress', label: 'In progress', tone: 'warning'},
33
+ {key: 'done', label: 'Done', tone: 'success'},
34
+ {key: 'declined', label: 'Not planned', tone: 'muted'}
35
+ ];
36
+
37
+ export const TYPE_KEYS = TYPES.map(t => t.key);
38
+ export const STATUS_KEYS = STATUSES.map(s => s.key);
39
+ export const SEVERITY_KEYS = SEVERITIES.map(s => s.key);
40
+ export const statusLabel = (key) => STATUSES.find(s => s.key === key)?.label ?? key;
41
+ export const typeLabel = (key) => TYPES.find(t => t.key === key)?.short ?? key;
42
+
43
+ /** Longest a field may be. The route's bodyLimit (32 KB) is the outer wall. */
44
+ export const LIMITS = {title: 140, description: 8000, where: 300, reply: 8000, notes: 8000};
45
+
46
+ const text = (v, max) => String(v ?? '').replace(/\u0000/g, '').replace(/\r\n?/g, '\n').trim().slice(0, max);
47
+
48
+ /**
49
+ * What a site admin typed, cleaned. Never carries who sent it - that comes
50
+ * from the signed-in user on the server, not from the form.
51
+ *
52
+ * @param {object} body
53
+ * @returns {{report?: object, error?: string}}
54
+ */
55
+ export function cleanSubmission(body = {}) {
56
+ const type = TYPE_KEYS.includes(body.type) ? body.type : 'other';
57
+ const title = text(body.title, LIMITS.title);
58
+ const description = text(body.description, LIMITS.description);
59
+ if (!title) return {error: 'Give it a short title.'};
60
+ if (!description) return {error: 'Say a little more in the description.'};
61
+ const severity = type === 'bug' && SEVERITY_KEYS.includes(body.severity) ? body.severity : '';
62
+ return {report: {type, title, description, where: text(body.where, LIMITS.where), severity}};
63
+ }
64
+
65
+ /**
66
+ * The payload a site sends the manager: the cleaned report, who sent it (from
67
+ * the session), and which CMS it runs. `site` is informational only - the
68
+ * manager attributes a report to the site its token names, never to this.
69
+ *
70
+ * @param {{report: object, user: object, cmsVersion?: string, site?: string, ref: string}} p
71
+ * @returns {object}
72
+ */
73
+ export function buildPayload({report, user = {}, cmsVersion = '', site = '', ref}) {
74
+ return {
75
+ ref: text(ref, 64),
76
+ ...report,
77
+ cmsVersion: text(cmsVersion, 20),
78
+ site: text(site, 64),
79
+ user: {id: text(user.id, 64), name: text(user.name, 120), email: text(user.email, 200)}
80
+ };
81
+ }
82
+
83
+ /**
84
+ * A report as the MANAGER stores it, from a site's payload. The slug is the
85
+ * one the token resolved to; anything the body claims about the site is
86
+ * ignored.
87
+ *
88
+ * @param {object} payload
89
+ * @param {string} slug
90
+ * @returns {{data?: object, error?: string}}
91
+ */
92
+ export function receivedReport(payload = {}, slug) {
93
+ const {report, error} = cleanSubmission(payload);
94
+ if (error) return {error};
95
+ const u = payload.user && typeof payload.user === 'object' ? payload.user : {};
96
+ return {
97
+ data: {
98
+ ...report,
99
+ site: slug,
100
+ ref: text(payload.ref, 64),
101
+ cmsVersion: text(payload.cmsVersion, 20),
102
+ userId: text(u.id, 64),
103
+ userName: text(u.name, 120) || 'An admin',
104
+ userEmail: text(u.email, 200),
105
+ status: 'new',
106
+ reply: '',
107
+ replyAt: '',
108
+ notes: '',
109
+ issueId: '',
110
+ issueKey: '',
111
+ issueStatus: '',
112
+ history: []
113
+ }
114
+ };
115
+ }
116
+
117
+ /**
118
+ * What a site may read of one of its own reports. A whitelist: internal notes,
119
+ * other sites and anything added to the record later stay on the manager.
120
+ *
121
+ * @param {{id: string, data: object, meta?: object}} entry
122
+ * @returns {object}
123
+ */
124
+ export function senderView(entry) {
125
+ const d = entry?.data || {};
126
+ return {
127
+ id: entry.id,
128
+ ref: d.ref || '',
129
+ type: d.type,
130
+ title: d.title,
131
+ description: d.description,
132
+ where: d.where || '',
133
+ severity: d.severity || '',
134
+ userName: d.userName || '',
135
+ status: d.status,
136
+ statusLabel: statusLabel(d.status),
137
+ reply: d.reply || '',
138
+ replyAt: d.replyAt || '',
139
+ issueKey: d.issueKey || '',
140
+ issueStatus: d.issueStatus || '',
141
+ createdAt: entry.meta?.createdAt || '',
142
+ updatedAt: entry.meta?.updatedAt || ''
143
+ };
144
+ }
145
+
146
+ /**
147
+ * The status a report takes when its Waypoint issue moves. A report the vendor
148
+ * marked "Not planned" by hand keeps that, whatever the issue does.
149
+ *
150
+ * @param {string} category - 'todo' | 'doing' | 'done'
151
+ * @param {string} current
152
+ * @returns {string}
153
+ */
154
+ export function statusFromIssue(category, current) {
155
+ if (current === 'declined') return current;
156
+ return {todo: 'planned', doing: 'in-progress', done: 'done'}[category] || current;
157
+ }
158
+
159
+ /**
160
+ * What the vendor may change on a report from the inbox.
161
+ *
162
+ * @param {object} body
163
+ * @param {object} current - the stored data
164
+ * @returns {{patch?: object, error?: string}}
165
+ */
166
+ export function cleanUpdate(body = {}, current = {}) {
167
+ const patch = {};
168
+ if (body.status !== undefined) {
169
+ if (!STATUS_KEYS.includes(body.status)) return {error: 'That is not a status.'};
170
+ patch.status = body.status;
171
+ }
172
+ if (body.reply !== undefined) patch.reply = text(body.reply, LIMITS.reply);
173
+ if (body.notes !== undefined) patch.notes = text(body.notes, LIMITS.notes);
174
+ if (!Object.keys(patch).length) return {error: 'Nothing to change.'};
175
+ if (patch.reply !== undefined && patch.reply === (current.reply || '')) delete patch.reply;
176
+ return {patch};
177
+ }
178
+
179
+ /**
180
+ * How many a site may send: `max` in any `windowMs`. The manager's global
181
+ * limiter exempts loopback, which is where every site calls from, so this is
182
+ * the only brake.
183
+ *
184
+ * @param {{max?: number, windowMs?: number}} [opts]
185
+ * @returns {(key: string, now?: number) => boolean} true when allowed
186
+ */
187
+ export function createThrottle({max = 10, windowMs = 10 * 60_000} = {}) {
188
+ const seen = new Map();
189
+ return (key, now = Date.now()) => {
190
+ const recent = (seen.get(key) || []).filter(t => now - t < windowMs);
191
+ if (recent.length >= max) { seen.set(key, recent); return false; }
192
+ recent.push(now);
193
+ seen.set(key, recent);
194
+ return true;
195
+ };
196
+ }
197
+
198
+ /**
199
+ * The description a Waypoint issue gets from a report: the words, then where
200
+ * they came from.
201
+ *
202
+ * @param {object} d - report data
203
+ * @returns {string}
204
+ */
205
+ export function issueDescription(d = {}) {
206
+ const lines = [d.description || '', '', '---', `From ${d.userName || 'an admin'} on ${d.site || 'a site'}`];
207
+ if (d.cmsVersion) lines.push(`Domma CMS ${d.cmsVersion}`);
208
+ if (d.where) lines.push(`Where: ${d.where}`);
209
+ if (d.severity) lines.push(`Severity: ${d.severity}`);
210
+ return lines.join('\n');
211
+ }