domma-cms 0.91.1 → 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.
- package/package.json +1 -1
- package/plugins/feedback/CLAUDE.md +96 -0
- package/plugins/feedback/admin/css/index.css +80 -0
- package/plugins/feedback/admin/lib/kit.js +105 -0
- package/plugins/feedback/admin/templates/feedback.html +1 -0
- package/plugins/feedback/admin/views/feedback.js +63 -0
- package/plugins/feedback/admin/views/inbox.js +325 -0
- package/plugins/feedback/admin/views/send.js +223 -0
- package/plugins/feedback/collections/feedback-reports.json +24 -0
- package/plugins/feedback/config.js +15 -0
- package/plugins/feedback/lib/receiver.js +121 -0
- package/plugins/feedback/lib/sender.js +161 -0
- package/plugins/feedback/lib/shape.js +211 -0
- package/plugins/feedback/plugin.js +276 -0
- package/plugins/feedback/plugin.json +48 -0
- package/plugins/feedback/tests/api.test.js +219 -0
- package/plugins/feedback/tests/shape.test.js +87 -0
- package/plugins/free-tier.lock.json +22 -1
|
@@ -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
|
+
}
|