domma-cms 1.6.37 → 1.6.39
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/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- package/package.json +2 -2
- package/plugins/_lib/admin/mail/mail.css +1 -1
- package/plugins/_lib/admin/mail/oauth-panel.js +303 -0
- package/plugins/_lib/admin/mail/reader-view.js +126 -13
- package/plugins/_lib/mail/accounts.js +548 -225
- package/plugins/_lib/mail/imapPool.js +63 -13
- package/plugins/_lib/mail/oauth.js +453 -0
- package/plugins/_lib/mail/oauthRoutes.js +294 -0
- package/plugins/_lib/mail/readRoutes.js +23 -2
- package/plugins/_lib/mail/scheduler.js +30 -6
- package/plugins/blog/CLAUDE.md +11 -1
- package/plugins/blog/lib/cache.js +43 -0
- package/plugins/blog/lib/page.js +28 -9
- package/plugins/blog/plugin.js +14 -13
- package/plugins/blog/plugin.json +1 -1
- package/plugins/blog/plugin.public.js +1 -1
- package/plugins/blog/tests/cache.test.js +65 -0
- package/plugins/booking/CLAUDE.md +14 -2
- package/plugins/booking/admin/lib/diary-layout.js +18 -0
- package/plugins/booking/admin/views/diary.js +153 -152
- package/plugins/booking/admin/views/people.js +64 -66
- package/plugins/booking/admin/views/settings.js +31 -32
- package/plugins/booking/plugin.json +1 -1
- package/plugins/booking/tests/admin-diary.test.js +9 -1
- package/plugins/feedback/CLAUDE.md +62 -0
- package/plugins/feedback/admin/css/index.css +16 -1
- package/plugins/feedback/admin/lib/kit.js +106 -0
- package/plugins/feedback/admin/lib/settings.js +107 -0
- package/plugins/feedback/admin/views/feedback.js +4 -1
- package/plugins/feedback/admin/views/inbox.js +11 -2
- package/plugins/feedback/admin/views/send.js +74 -7
- package/plugins/feedback/docs/guide.md +9 -9
- package/plugins/feedback/lib/attachments.js +133 -0
- package/plugins/feedback/lib/cache.js +43 -0
- package/plugins/feedback/lib/receiver.js +36 -6
- package/plugins/feedback/lib/sender.js +89 -6
- package/plugins/feedback/lib/shape.js +111 -6
- package/plugins/feedback/plugin.js +173 -17
- package/plugins/feedback/plugin.json +2 -2
- package/plugins/feedback/tests/api.test.js +125 -3
- package/plugins/feedback/tests/settings.test.js +59 -0
- package/plugins/feedback/tests/shape.test.js +87 -1
- package/plugins/free-tier.lock.json +74 -62
- package/plugins/mail-reader/CLAUDE.md +15 -4
- package/plugins/mail-reader/plugin.json +1 -1
- package/plugins/shopping-cart/CLAUDE.md +35 -2
- package/plugins/shopping-cart/admin/css/index.css +3 -0
- package/plugins/shopping-cart/admin/views/orders.js +75 -23
- package/plugins/shopping-cart/admin/views/shop.js +1 -1
- package/plugins/shopping-cart/docs/guide.md +7 -3
- package/plugins/shopping-cart/lib/payments.js +15 -2
- package/plugins/shopping-cart/lib/refunds.js +51 -0
- package/plugins/shopping-cart/lib/sales.js +15 -8
- package/plugins/shopping-cart/lib/shop.js +38 -3
- package/plugins/shopping-cart/plugin.js +47 -19
- package/plugins/shopping-cart/plugin.json +2 -2
- package/plugins/shopping-cart/tests/api.test.js +52 -0
- package/plugins/shopping-cart/tests/commission.test.js +1 -1
- package/plugins/shopping-cart/tests/pure.test.js +16 -0
- package/plugins/shopping-cart/tests/refunds.test.js +42 -0
- package/plugins/support/CLAUDE.md +58 -0
- package/plugins/support/admin/css/index.css +16 -1
- package/plugins/support/admin/lib/kit.js +192 -8
- package/plugins/support/admin/lib/settings.js +123 -0
- package/plugins/support/admin/views/desk.js +55 -12
- package/plugins/support/admin/views/inbox.js +17 -4
- package/plugins/support/admin/views/support.js +4 -1
- package/plugins/support/docs/guide.md +7 -9
- package/plugins/support/lib/attachments.js +133 -0
- package/plugins/support/lib/cache.js +43 -0
- package/plugins/support/lib/receiver.js +45 -10
- package/plugins/support/lib/sender.js +96 -10
- package/plugins/support/lib/shape.js +153 -12
- package/plugins/support/plugin.js +159 -29
- package/plugins/support/plugin.json +2 -2
- package/plugins/support/tests/api.test.js +125 -3
- package/plugins/support/tests/settings.test.js +68 -0
- package/plugins/support/tests/shape.test.js +76 -2
- package/server/routes/outreach-public.js +2 -2
- package/server/services/request-log.js +3 -1
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feedback - the settings cog (1.1.7). Each side shows its own settings only:
|
|
3
|
+
*
|
|
4
|
+
* a site how often it sends what waits and looks for replies (administrators)
|
|
5
|
+
* the manager how many reports one site may send in how many minutes (`manage`)
|
|
6
|
+
*
|
|
7
|
+
* They used to be reachable only through Marketplace > Configure and were read
|
|
8
|
+
* at boot; the server now reads them when used, so a save here is live.
|
|
9
|
+
*
|
|
10
|
+
* @module feedback/admin/lib/settings
|
|
11
|
+
*/
|
|
12
|
+
import {BASE, esc, openPanel} from './kit.js';
|
|
13
|
+
|
|
14
|
+
/** Ranges - lib/shape.js SETTING_RULES, which the server applies again. */
|
|
15
|
+
const RULES = {
|
|
16
|
+
syncMinutes: {min: 5, max: 1440},
|
|
17
|
+
perSiteMax: {min: 1, max: 1000},
|
|
18
|
+
perSiteMinutes: {min: 1, max: 1440}
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const FIELDS = {
|
|
22
|
+
sender: [
|
|
23
|
+
{key: 'syncMinutes', label: 'Check for replies every (minutes)',
|
|
24
|
+
help: 'How often this site sends any feedback still waiting and looks for Domma\'s replies. Opening Feedback always checks at once; this is the background check. 5 to 1440 (a day).'}
|
|
25
|
+
],
|
|
26
|
+
receiver: [
|
|
27
|
+
{key: 'perSiteMax', label: 'Reports per site',
|
|
28
|
+
help: 'The most reports one site may send within the window below; more are refused with "try again in a few minutes" and the site retries later. Files have their own allowance: this many reports\' worth.'},
|
|
29
|
+
{key: 'perSiteMinutes', label: 'Window (minutes)',
|
|
30
|
+
help: 'The window the limit above counts in, in minutes.'}
|
|
31
|
+
]
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const wholeIn = (v, {min, max}) => { const n = Number(v); return String(v).trim() !== '' && Number.isInteger(n) && n >= min && n <= max; };
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Add the cog when this user may change this side's settings (asked once).
|
|
38
|
+
*
|
|
39
|
+
* @param {object} $container
|
|
40
|
+
* @param {object} kit - loadKit()
|
|
41
|
+
*/
|
|
42
|
+
export async function addSettingsCog($container, kit) {
|
|
43
|
+
let got = null;
|
|
44
|
+
// Silent: a site user who is not an administrator gets 403 here, and needs no cog and no error.
|
|
45
|
+
try { got = await H.request('GET', `${BASE}/settings`, null, {silent: true}); } catch { return; }
|
|
46
|
+
if (!got?.side) return;
|
|
47
|
+
kit.addBannerAction($container, {id: 'feedback-settings', icon: 'settings', title: 'Feedback settings', onClick: () => openSettings()});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function openSettings() {
|
|
51
|
+
let got;
|
|
52
|
+
try { got = await H.get(`${BASE}/settings`); } catch (err) { E.toast(`Settings not loaded: ${esc(err?.message || err)}`, {type: 'error'}); return; }
|
|
53
|
+
const fields = FIELDS[got.side] || [];
|
|
54
|
+
const f = Object.fromEntries(fields.map(x => [x.key, M.observable(String(got.settings?.[x.key] ?? ''))]));
|
|
55
|
+
const values = () => Object.fromEntries(fields.map(x => [x.key, Number(f[x.key].peek())]));
|
|
56
|
+
const opened = JSON.stringify(values());
|
|
57
|
+
const saving = M.observable(false);
|
|
58
|
+
const bad = Object.fromEntries(fields.map(x => [`${x.key}Bad`, M.computed(() => !wholeIn(f[x.key].value, RULES[x.key]))]));
|
|
59
|
+
const model = {...f, ...bad, saving, cannot: M.computed(() => saving.value || Object.values(bad).some(b => b.value))};
|
|
60
|
+
const html = `
|
|
61
|
+
<div class="fb-form">
|
|
62
|
+
${fields.map(x => `
|
|
63
|
+
<label class="form-label" for="fb-set-${x.key}">${x.label} <span data-help="${esc(x.help)}"></span></label>
|
|
64
|
+
<input id="fb-set-${x.key}" type="number" class="form-input" min="${RULES[x.key].min}" max="${RULES[x.key].max}" step="1" data-model="${x.key}.value">
|
|
65
|
+
<small class="fb-note is-bad" data-bind-hidden="!${x.key}Bad.value">A whole number from ${RULES[x.key].min} to ${RULES[x.key].max}.</small>`).join('')}
|
|
66
|
+
<p class="fb-note">Saved settings take effect at once - no restart.</p>
|
|
67
|
+
<div class="fb-actions">
|
|
68
|
+
<span class="fb-spacer"></span>
|
|
69
|
+
<button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
|
|
70
|
+
<button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="cannot.value">Save</button>
|
|
71
|
+
</div>
|
|
72
|
+
</div>`;
|
|
73
|
+
let panel = null;
|
|
74
|
+
let closed = false;
|
|
75
|
+
let asking = false;
|
|
76
|
+
const methods = {
|
|
77
|
+
cancel: () => panel?.so.close(),
|
|
78
|
+
async save() {
|
|
79
|
+
if (model.cannot.peek()) return;
|
|
80
|
+
saving.value = true;
|
|
81
|
+
try {
|
|
82
|
+
await H.put(`${BASE}/settings`, values());
|
|
83
|
+
E.toast('Feedback settings saved.', {type: 'success'});
|
|
84
|
+
panel.so.close(true);
|
|
85
|
+
} catch (err) {
|
|
86
|
+
E.toast(`Not saved: ${esc(err?.message || err)}`, {type: 'error'});
|
|
87
|
+
} finally {
|
|
88
|
+
saving.value = false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
panel = await openPanel({title: 'Feedback settings', size: 'sm', html, model, methods});
|
|
93
|
+
// Closing with a change asks first - once (onceClosed calls close() again, argument-less).
|
|
94
|
+
const close = panel.so.close.bind(panel.so);
|
|
95
|
+
panel.so.close = async (force) => {
|
|
96
|
+
if (!closed && force !== true && JSON.stringify(values()) !== opened) {
|
|
97
|
+
if (asking) return undefined;
|
|
98
|
+
asking = true;
|
|
99
|
+
const ok = await E.confirm('Discard what you have entered?');
|
|
100
|
+
asking = false;
|
|
101
|
+
if (!ok) return undefined;
|
|
102
|
+
}
|
|
103
|
+
closed = true;
|
|
104
|
+
return close();
|
|
105
|
+
};
|
|
106
|
+
panel.panel.addEventListener('keydown', (e) => { if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); methods.save(); } });
|
|
107
|
+
}
|
|
@@ -14,9 +14,10 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import {api, loadKit, store} from '../lib/kit.js';
|
|
17
|
+
import {addSettingsCog} from '../lib/settings.js';
|
|
17
18
|
|
|
18
19
|
const ROUTE = /^#\/plugins\/feedback(\/|$)/;
|
|
19
|
-
const V = '1.1.
|
|
20
|
+
const V = '1.1.8';
|
|
20
21
|
const TABS = [
|
|
21
22
|
{key: 'inbox', label: 'Inbox', icon: 'inbox'},
|
|
22
23
|
{key: 'mine', label: 'My feedback', icon: 'send'}
|
|
@@ -56,6 +57,8 @@ export const feedbackView = {
|
|
|
56
57
|
return;
|
|
57
58
|
}
|
|
58
59
|
if (!here()) return;
|
|
60
|
+
// The cog (1.1.7) - this side's settings, for those who may change them. Never blocks the screen.
|
|
61
|
+
addSettingsCog($container, kit).catch(() => {});
|
|
59
62
|
|
|
60
63
|
const load = async (screen, into, ctx) => {
|
|
61
64
|
const mod = await import(`/plugins/feedback/admin/views/${screen}.js?v=${V}`);
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* Right-click (or the row's kebab, or Shift+F10) for the quick moves.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import {api, esc, openPanel, refreshBadge, stamp, store, when} from '../lib/kit.js';
|
|
11
|
+
import {FILES, api, esc, openPanel, refreshBadge, showFiles, stamp, store, when} from '../lib/kit.js';
|
|
12
12
|
|
|
13
13
|
const ROW = `
|
|
14
14
|
<div class="fb-row" data-id="{{id}}" tabindex="0" role="button">
|
|
@@ -67,7 +67,8 @@ export async function mount(root, ctx) {
|
|
|
67
67
|
title: r.title,
|
|
68
68
|
icon: iconOf(r.type),
|
|
69
69
|
typeLabel: labelOf(r.type),
|
|
70
|
-
meta: [r.siteName, r.userName, when(r.createdAt), r.severity === 'blocker' ? 'Blocker' : ''
|
|
70
|
+
meta: [r.siteName, r.userName, when(r.createdAt), r.severity === 'blocker' ? 'Blocker' : '',
|
|
71
|
+
(r.attachments || []).length ? `${r.attachments.length} file${r.attachments.length === 1 ? '' : 's'}` : ''].filter(Boolean).join(' · '),
|
|
71
72
|
statusLabel: statusOf(r.status),
|
|
72
73
|
tone: `is-${TONE[r.status] || 'muted'}`,
|
|
73
74
|
issueKey: r.issueKey || ''
|
|
@@ -189,14 +190,17 @@ export async function mount(root, ctx) {
|
|
|
189
190
|
...(r.cmsVersion ? [['CMS', r.cmsVersion]] : [])
|
|
190
191
|
];
|
|
191
192
|
let panel = null;
|
|
193
|
+
let filesView = null;
|
|
192
194
|
panel = await openPanel({
|
|
193
195
|
title: r.title,
|
|
194
196
|
size: 'lg',
|
|
195
197
|
model: f,
|
|
198
|
+
cleanup: () => filesView?.dispose(),
|
|
196
199
|
html: `<div class="fb-detail">
|
|
197
200
|
<dl class="fb-facts">${facts.map(([a, b]) => `<div><dt>${esc(a)}</dt><dd>${esc(b)}</dd></div>`).join('')}</dl>
|
|
198
201
|
<h4>What they sent</h4>
|
|
199
202
|
<div class="fb-text">${esc(r.description)}</div>
|
|
203
|
+
${(r.attachments || []).length ? `<h4>Files <span data-help="What the sender attached. Click one to open it in a new tab. A file still on its way follows its report within the site's next sync."></span></h4><div class="fb-files-host" data-files></div>` : ''}
|
|
200
204
|
|
|
201
205
|
<div class="fb-form">
|
|
202
206
|
<label class="form-label" for="fb-status">Status <span data-help="What the site sees. Once a Waypoint issue is linked, the status follows it - except Not planned, which stays until you change it."></span></label>
|
|
@@ -272,6 +276,11 @@ export async function mount(root, ctx) {
|
|
|
272
276
|
}
|
|
273
277
|
}
|
|
274
278
|
});
|
|
279
|
+
const host = panel.panel.querySelector('[data-files]');
|
|
280
|
+
if (host) {
|
|
281
|
+
host.innerHTML = FILES;
|
|
282
|
+
filesView = showFiles(host, r.attachments, (a) => api.reportFileUrl(r.id, a.id), 'on its way from the site');
|
|
283
|
+
}
|
|
275
284
|
// Opening a new report is seeing it.
|
|
276
285
|
if (r.status === 'new') {
|
|
277
286
|
try { const next = await api.update(r.id, {status: 'acknowledged'}); replace(next); f.status.value = next.status; refreshBadge(); }
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* the same screen files straight into the inbox: nothing waits, nothing travels.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import {api, esc, openPanel, refreshBadge, stamp, when} from '../lib/kit.js';
|
|
13
|
+
import {FILES, FILE_TYPES, api, esc, fileRow, fileSize, isImage, openPanel, refreshBadge, showFiles, stamp, uploadFile, when} from '../lib/kit.js';
|
|
14
14
|
|
|
15
15
|
const ROW = `
|
|
16
16
|
<div class="fb-row" data-id="{{key}}" tabindex="0" role="button">
|
|
@@ -44,6 +44,7 @@ export async function mount(root, ctx) {
|
|
|
44
44
|
noReply: 'No reply yet. The status above moves as Domma works on it.'
|
|
45
45
|
};
|
|
46
46
|
const types = meta.types || [];
|
|
47
|
+
const caps = meta.attachments || {max: 3, maxBytes: 2 * 1024 * 1024};
|
|
47
48
|
const iconOf = (t) => types.find(x => x.key === t)?.icon || 'message-square';
|
|
48
49
|
const labelOf = (t) => types.find(x => x.key === t)?.short || t;
|
|
49
50
|
|
|
@@ -59,7 +60,8 @@ export async function mount(root, ctx) {
|
|
|
59
60
|
title: r.title,
|
|
60
61
|
icon: iconOf(r.type),
|
|
61
62
|
typeLabel: labelOf(r.type),
|
|
62
|
-
meta: [when(r.createdAt), r.userName, r.issueKey ? `Tracked as ${r.issueKey}` : ''
|
|
63
|
+
meta: [when(r.createdAt), r.userName, r.issueKey ? `Tracked as ${r.issueKey}` : '',
|
|
64
|
+
(r.attachments || []).length ? `${r.attachments.length} file${r.attachments.length === 1 ? '' : 's'}` : ''].filter(Boolean).join(' · '),
|
|
63
65
|
statusLabel: r.statusLabel,
|
|
64
66
|
tone: `is-${TONE[r.status] || 'muted'}`,
|
|
65
67
|
unread: Boolean(r.unread)
|
|
@@ -135,11 +137,18 @@ export async function mount(root, ctx) {
|
|
|
135
137
|
where: M.observable(''), severity: M.observable('minor'), busy: M.observable(false)
|
|
136
138
|
};
|
|
137
139
|
const isBug = M.computed(() => f.type.value === 'bug');
|
|
140
|
+
// The files picked so far: uploaded as soon as they are chosen, sent by id with the report.
|
|
141
|
+
const picked = M.observable([]);
|
|
142
|
+
const room = M.computed(() => picked.value.length < caps.max);
|
|
143
|
+
const uploading = M.computed(() => picked.value.some(x => x.busy));
|
|
144
|
+
const localUrls = [];
|
|
145
|
+
let filesHandle = null;
|
|
138
146
|
let panel = null;
|
|
139
147
|
openPanel({
|
|
140
148
|
title: COPY.title,
|
|
141
149
|
size: 'md',
|
|
142
|
-
model: {...f, isBug},
|
|
150
|
+
model: {...f, isBug, room, uploading},
|
|
151
|
+
cleanup: () => { filesHandle?.dispose(); for (const u of localUrls.splice(0)) URL.revokeObjectURL(u); },
|
|
143
152
|
html: `<div class="fb-form">
|
|
144
153
|
<label class="form-label">What is it?</label>
|
|
145
154
|
<div class="fb-types" role="radiogroup" aria-label="Type">
|
|
@@ -157,20 +166,29 @@ export async function mount(root, ctx) {
|
|
|
157
166
|
</div>
|
|
158
167
|
<label class="form-label" for="fb-where">Where <span data-help="The screen or page it is about, if there is one - e.g. Pages, the Blog editor, /contact. Optional."></span></label>
|
|
159
168
|
<input id="fb-where" class="form-input" maxlength="300" data-model="where.value" placeholder="Optional">
|
|
169
|
+
<label class="form-label">Screenshots and files <span data-help="${esc(`Up to ${caps.max} files of ${fileSize(caps.maxBytes)} each: PNG, JPEG, GIF or WebP images, a PDF or a text file. A screenshot of what went wrong helps most. They are seen only by the Domma team.`)}"></span></label>
|
|
170
|
+
<div class="fb-files-host" data-files></div>
|
|
171
|
+
<div class="fb-attach" data-bind-hidden="!room.value">
|
|
172
|
+
<button type="button" class="btn btn-sm btn-outline" data-on-click="pickFiles"><span data-icon="paperclip" data-icon-size="13"></span> Attach a file</button>
|
|
173
|
+
<span class="fb-hint">Or paste a screenshot into the description.</span>
|
|
174
|
+
<input type="file" class="fb-file-input" accept="${FILE_TYPES}" multiple hidden>
|
|
175
|
+
</div>
|
|
160
176
|
<p class="fb-hint">${esc(COPY.hint)}</p>
|
|
161
177
|
<div class="fb-actions"><span class="fb-spacer"></span>
|
|
162
178
|
<button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
|
|
163
|
-
<button type="button" class="btn btn-sm btn-primary" data-on-click="send" data-bind-disabled="busy.value"><span data-icon="send" data-icon-size="13"></span> Send</button>
|
|
179
|
+
<button type="button" class="btn btn-sm btn-primary" data-on-click="send" data-bind-disabled="busy.value || uploading.value"><span data-icon="send" data-icon-size="13"></span> Send</button>
|
|
164
180
|
</div>
|
|
165
181
|
</div>`,
|
|
166
182
|
methods: {
|
|
167
183
|
cancel: () => panel?.close(),
|
|
184
|
+
pickFiles: () => panel?.panel.querySelector('.fb-file-input')?.click(),
|
|
168
185
|
async send() {
|
|
169
|
-
if (f.busy.peek()) return;
|
|
186
|
+
if (f.busy.peek() || uploading.peek()) return;
|
|
170
187
|
f.busy.value = true;
|
|
171
188
|
try {
|
|
172
189
|
const r = await api.send({type: f.type.peek(), title: f.title.peek(), description: f.description.peek(),
|
|
173
|
-
where: f.where.peek(), severity: f.type.peek() === 'bug' ? f.severity.peek() : ''
|
|
190
|
+
where: f.where.peek(), severity: f.type.peek() === 'bug' ? f.severity.peek() : '',
|
|
191
|
+
attachments: picked.peek().filter(x => x.id && !x.busy).map(x => ({id: x.id, name: x.name, type: x.type, size: x.size}))});
|
|
174
192
|
E.toast(r.message || 'Sent.', {type: r.state === 'sent' ? 'success' : 'warning'});
|
|
175
193
|
panel?.close();
|
|
176
194
|
refreshBadge();
|
|
@@ -188,6 +206,45 @@ export async function mount(root, ctx) {
|
|
|
188
206
|
const b = e.target.closest('[data-type]');
|
|
189
207
|
if (b) f.type.value = b.dataset.type;
|
|
190
208
|
});
|
|
209
|
+
// The file list is bound on its own, after the panel: the panel's model has no `files`.
|
|
210
|
+
const host = p.panel.querySelector('[data-files]');
|
|
211
|
+
host.innerHTML = FILES;
|
|
212
|
+
const files = M.computed(() => picked.value.map(x => fileRow(x, {key: x.key, thumb: x.thumb, removable: !x.busy,
|
|
213
|
+
meta: x.busy ? 'Uploading...' : fileSize(x.size), title: x.name})));
|
|
214
|
+
const bound = M.applyBindings({files}, host);
|
|
215
|
+
filesHandle = {dispose: () => { bound.dispose(); files.dispose?.(); }};
|
|
216
|
+
host.addEventListener('click', (e) => {
|
|
217
|
+
const x = e.target.closest('.fb-file-x');
|
|
218
|
+
if (x) picked.value = picked.peek().filter(r => r.key !== x.closest('.fb-file').dataset.key);
|
|
219
|
+
});
|
|
220
|
+
const add = async (list) => {
|
|
221
|
+
for (const file of list) {
|
|
222
|
+
if (picked.peek().length >= caps.max) { E.toast(`Up to ${caps.max} files.`, {type: 'warning'}); break; }
|
|
223
|
+
if (file.size > caps.maxBytes) { E.toast(`${esc(file.name)} is too big - up to ${fileSize(caps.maxBytes)} each.`, {type: 'warning'}); continue; }
|
|
224
|
+
if (!FILE_TYPES.split(',').includes(file.type)) { E.toast(`${esc(file.name)} cannot be attached - images, PDF or text only.`, {type: 'warning'}); continue; }
|
|
225
|
+
const key = `k${Date.now()}${Math.random().toString(16).slice(2)}`;
|
|
226
|
+
let thumb = '';
|
|
227
|
+
if (isImage(file.type)) { thumb = URL.createObjectURL(file); localUrls.push(thumb); }
|
|
228
|
+
picked.value = [...picked.peek(), {key, name: file.name || 'screenshot.png', type: file.type, size: file.size, thumb, busy: true}];
|
|
229
|
+
try {
|
|
230
|
+
const a = await uploadFile(file);
|
|
231
|
+
picked.value = picked.peek().map(r => (r.key === key ? {...r, ...a, key, busy: false} : r));
|
|
232
|
+
} catch (err) {
|
|
233
|
+
picked.value = picked.peek().filter(r => r.key !== key);
|
|
234
|
+
E.toast(esc(err.message || String(err)), {type: 'error'});
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
const input = p.panel.querySelector('.fb-file-input');
|
|
239
|
+
input.addEventListener('change', () => { const list = [...input.files]; input.value = ''; add(list); });
|
|
240
|
+
// A screenshot pasted into the description is the quickest way there is.
|
|
241
|
+
p.panel.querySelector('#fb-desc')?.addEventListener('paste', (e) => {
|
|
242
|
+
const list = [...(e.clipboardData?.files || [])];
|
|
243
|
+
if (!list.length) return;
|
|
244
|
+
e.preventDefault();
|
|
245
|
+
add(list.map(file => (file.name && file.name !== 'image.png' ? file
|
|
246
|
+
: new File([file], `screenshot-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.png`, {type: file.type}))));
|
|
247
|
+
});
|
|
191
248
|
p.panel.querySelector('#fb-title')?.focus();
|
|
192
249
|
});
|
|
193
250
|
}
|
|
@@ -204,16 +261,26 @@ export async function mount(root, ctx) {
|
|
|
204
261
|
...(r.issueKey ? [['Tracked as', `${r.issueKey}${r.issueStatus ? ` (${r.issueStatus})` : ''}`]] : []),
|
|
205
262
|
...(r.lastError ? [['Last try', r.lastError]] : [])
|
|
206
263
|
];
|
|
207
|
-
|
|
264
|
+
let filesView = null;
|
|
265
|
+
const p = await openPanel({
|
|
208
266
|
title: r.title,
|
|
209
267
|
size: 'md',
|
|
268
|
+
cleanup: () => filesView?.dispose(),
|
|
210
269
|
html: `<div class="fb-detail">
|
|
211
270
|
<dl class="fb-facts">${lines.map(([a, b]) => `<div><dt>${esc(a)}</dt><dd>${esc(b)}</dd></div>`).join('')}</dl>
|
|
212
271
|
<h4>What was sent</h4>
|
|
213
272
|
<div class="fb-text">${esc(r.description)}</div>
|
|
273
|
+
${(r.attachments || []).length ? '<h4>Files</h4><div class="fb-files-host" data-files></div>' : ''}
|
|
214
274
|
${r.reply ? `<h4><span data-icon="corner-up-left" data-icon-size="14"></span> ${esc(COPY.replied)} <small>${esc(stamp(r.replyAt))}</small></h4><div class="fb-text fb-reply">${esc(r.reply)}</div>` : `<p class="fb-note">${esc(COPY.noReply)}</p>`}
|
|
215
275
|
</div>`
|
|
216
276
|
});
|
|
277
|
+
const host = p.panel.querySelector('[data-files]');
|
|
278
|
+
if (host) {
|
|
279
|
+
host.innerHTML = FILES;
|
|
280
|
+
// This site kept its own copy of every file it sent, so they open from here even while
|
|
281
|
+
// Domma's copy is still on its way. The manager's own reports read theirs in place.
|
|
282
|
+
filesView = showFiles(host, (r.attachments || []).map(a => ({...a, stored: true})), (a) => api.ownFileUrl(a.id));
|
|
283
|
+
}
|
|
217
284
|
if (r.unread && r.id) {
|
|
218
285
|
try {
|
|
219
286
|
await api.seen(r.id, r.replyAt);
|
|
@@ -20,7 +20,8 @@ Feedback lets you tell Domma what is broken, missing or good, straight from your
|
|
|
20
20
|
4. Type a **Title** (one line) and a **Description**.
|
|
21
21
|
5. For something broken, choose **How bad is it?**: Minor - a nuisance, Major - gets in the way, or Blocker - cannot carry on.
|
|
22
22
|
6. Optional: fill in **Where** - the screen or page it is about, for example "Pages" or `/contact`.
|
|
23
|
-
7.
|
|
23
|
+
7. Optional: click **Attach a file**, or paste a screenshot straight into the description. Up to 3 files of 2 MB each: PNG, JPEG, GIF or WebP images, a PDF or a text file.
|
|
24
|
+
8. Click **Send**.
|
|
24
25
|
|
|
25
26
|
A good bug report says what you did, what you expected and what happened instead. For a request, say what you are trying to get done.
|
|
26
27
|
|
|
@@ -41,7 +42,9 @@ Each row shows the type, title, when it was sent and by whom, and a status:
|
|
|
41
42
|
| Waiting to send | Domma could not be reached yet; it goes automatically |
|
|
42
43
|
| Not accepted | Domma's server turned the report down |
|
|
43
44
|
|
|
44
|
-
A dot on a row means a new reply. Click a row to open it: you see the details, **What was sent**, and **Domma replied** with the reply, or "No reply yet".
|
|
45
|
+
A dot on a row means a new reply. Click a row to open it: you see the details, **What was sent**, any **Files** you attached (click one to open it), and **Domma replied** with the reply, or "No reply yet".
|
|
46
|
+
|
|
47
|
+
Attached files go to Domma straight after the report. Only the Domma team sees them - they are never put in your Media library or on your website.
|
|
45
48
|
|
|
46
49
|
If "Tracked as" appears, Domma has filed the report in its own issue tracker, and the status follows that issue.
|
|
47
50
|
|
|
@@ -53,11 +56,7 @@ If the screen says "This site is not connected to Domma", your site was not star
|
|
|
53
56
|
|
|
54
57
|
## Settings
|
|
55
58
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
| Option | Default | Meaning |
|
|
59
|
-
|---|---|---|
|
|
60
|
-
| `syncMinutes` | 60 | How often the site sends waiting reports and checks for replies |
|
|
59
|
+
Administrators see a **Feedback settings** cog in the banner. It has one setting, **Check for replies every (minutes)** - how often the site sends anything still waiting and looks for Domma's replies in the background (default 60, from 5 to 1440). A change takes effect at once, no restart.
|
|
61
60
|
|
|
62
61
|
Opening the Feedback screen also sends anything waiting and checks for replies straight away.
|
|
63
62
|
|
|
@@ -81,15 +80,16 @@ Only the **admin** role has them by default. Everyone with **Send** sees every r
|
|
|
81
80
|
On the Domma server itself (the Manager), the same screen is the **Inbox** of every site's reports, for holders of **Feedback > Manage**:
|
|
82
81
|
|
|
83
82
|
- Filter by **Open**, each status, or **All**; by site and type; and search title, text, person and site.
|
|
84
|
-
- Open a report to set its **Status**, write a **Reply to the site** (the site sees it and is notified) and keep **Internal notes** (never sent to the site). Click **Save**.
|
|
83
|
+
- Open a report to see any **Files** the site attached (a file still on its way says so), set its **Status**, write a **Reply to the site** (the site sees it and is notified) and keep **Internal notes** (never sent to the site). Click **Save**.
|
|
85
84
|
- With Waypoint Pro installed, **Make a Waypoint issue** files the report in a project with a type and priority. The report then follows the issue: planned, in progress, done. "Not planned" set by hand stays until you change it.
|
|
86
85
|
- Right-click a row to open it, change its status quickly, or **Delete** it. Deleting removes it for the site too.
|
|
87
86
|
- Holders of the most senior role also get **My feedback**, to file their own reports. These appear in the Inbox as "Manager (this server)".
|
|
87
|
+
- The **Feedback settings** cog sets how many reports one site may send in a window (default 10 in 10 minutes); more are turned away for now and the site sends them later. Changes apply to the next report.
|
|
88
88
|
|
|
89
89
|
## Limitations
|
|
90
90
|
|
|
91
91
|
- Only sites started by a Domma server can send. A standalone install cannot.
|
|
92
|
-
-
|
|
92
|
+
- Up to 3 files per report, 2 MB each, and only images (PNG, JPEG, GIF, WebP), PDFs and text files.
|
|
93
93
|
- Titles are limited to 140 characters and descriptions to 8,000.
|
|
94
94
|
- The Domma server accepts up to 10 reports from one site in 10 minutes. Any more show as **Waiting to send** and go later.
|
|
95
95
|
- You cannot edit or withdraw a report once sent. Send a follow-up instead.
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feedback - attachments on disk, and reading one out of an upload.
|
|
3
|
+
*
|
|
4
|
+
* A site keeps the files it attached under data/attachments/<id>.<ext> (with a
|
|
5
|
+
* <id>.json beside each saying what it is); the manager keeps a report's files
|
|
6
|
+
* under data/attachments/<report entry id>/. Files are 0600 in 0700 folders:
|
|
7
|
+
* they are screenshots of admin screens, nothing a web server should ever list.
|
|
8
|
+
* Nothing here is served from a public path - a route reads and sends them.
|
|
9
|
+
*
|
|
10
|
+
* @module feedback/lib/attachments
|
|
11
|
+
*/
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import {ATTACHMENTS, ATTACHMENT_TYPES, cleanAttachment, extOf} from './shape.js';
|
|
15
|
+
|
|
16
|
+
/** A folder name that is safe to join: a collection entry id, or our own staging name. */
|
|
17
|
+
const SUB_RE = /^[A-Za-z0-9_-]{1,80}$/;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Read ONE file out of a multipart request, within the caps. The whole file is
|
|
21
|
+
* held in memory - it is at most ATTACHMENTS.maxBytes.
|
|
22
|
+
*
|
|
23
|
+
* @returns {Promise<{file?: {name: string, type: string, size: number, buffer: Buffer}, error?: string, code?: number}>}
|
|
24
|
+
*/
|
|
25
|
+
export async function readUpload(request) {
|
|
26
|
+
if (typeof request.isMultipart !== 'function' || !request.isMultipart()) return {error: 'Send the file as a form upload.', code: 400};
|
|
27
|
+
let part;
|
|
28
|
+
try { part = await request.file(); } catch (err) { return {error: `That upload could not be read: ${err.message}`, code: 400}; }
|
|
29
|
+
if (!part) return {error: 'No file was sent.', code: 400};
|
|
30
|
+
const type = String(part.mimetype || '').toLowerCase().split(';')[0].trim();
|
|
31
|
+
const drain = async () => { for await (const _ of part.file) { /* a refused part must still be consumed */ } };
|
|
32
|
+
if (!ATTACHMENT_TYPES[type]) {
|
|
33
|
+
await drain();
|
|
34
|
+
return {error: `That kind of file (${type || 'unknown'}) cannot be attached. Attach a PNG, JPEG, GIF or WebP image, a PDF or a text file.`, code: 400};
|
|
35
|
+
}
|
|
36
|
+
const chunks = [];
|
|
37
|
+
let size = 0;
|
|
38
|
+
let over = false;
|
|
39
|
+
for await (const chunk of part.file) {
|
|
40
|
+
size += chunk.length;
|
|
41
|
+
// Keep reading so the request completes, but keep nothing more.
|
|
42
|
+
if (size > ATTACHMENTS.maxBytes) { over = true; continue; }
|
|
43
|
+
chunks.push(chunk);
|
|
44
|
+
}
|
|
45
|
+
if (over || part.file.truncated) return {error: `That file is too big - up to ${Math.round(ATTACHMENTS.maxBytes / 1024 / 1024)} MB each.`, code: 413};
|
|
46
|
+
if (!size) return {error: 'That file is empty.', code: 400};
|
|
47
|
+
return {file: {name: part.filename, type, size, buffer: Buffer.concat(chunks)}};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Send a stored file: its own type, shown inline, never sniffed into anything else.
|
|
52
|
+
* Private cache only - it needs the session that fetched it.
|
|
53
|
+
*/
|
|
54
|
+
export function sendFile(reply, a, buffer) {
|
|
55
|
+
return reply
|
|
56
|
+
.type(a.type)
|
|
57
|
+
.header('content-disposition', `inline; filename="${a.name.replace(/["\\]/g, '_')}"`)
|
|
58
|
+
.header('x-content-type-options', 'nosniff')
|
|
59
|
+
.header('cache-control', 'private, max-age=300')
|
|
60
|
+
.send(buffer);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The files under one root. Every path is built from a cleaned attachment and a
|
|
65
|
+
* checked sub-folder name - nothing from a request reaches `path.join` raw.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} root - e.g. <plugin data dir>/attachments
|
|
68
|
+
*/
|
|
69
|
+
export function createStore(root) {
|
|
70
|
+
const dirOf = (sub) => {
|
|
71
|
+
if (sub && !SUB_RE.test(sub)) throw new Error('bad attachment folder');
|
|
72
|
+
return sub ? path.join(root, sub) : root;
|
|
73
|
+
};
|
|
74
|
+
const ensure = (sub) => fs.mkdirSync(dirOf(sub), {recursive: true, mode: 0o700});
|
|
75
|
+
const clean = (a) => { const c = cleanAttachment(a); if (!c) throw new Error('bad attachment'); return c; };
|
|
76
|
+
const fileOf = (sub, a) => path.join(dirOf(sub), `${a.id}.${extOf(a.type)}`);
|
|
77
|
+
const metaOf = (sub, id) => path.join(dirOf(sub), `${id}.json`);
|
|
78
|
+
return {
|
|
79
|
+
root,
|
|
80
|
+
/** Keep a file and what it is. */
|
|
81
|
+
save(att, buffer, sub = '') {
|
|
82
|
+
const a = clean(att);
|
|
83
|
+
ensure(sub);
|
|
84
|
+
fs.writeFileSync(fileOf(sub, a), buffer, {mode: 0o600});
|
|
85
|
+
fs.writeFileSync(metaOf(sub, a.id), JSON.stringify({id: a.id, name: a.name, type: a.type, size: a.size, at: new Date().toISOString()}), {mode: 0o600});
|
|
86
|
+
return a;
|
|
87
|
+
},
|
|
88
|
+
/** What a kept file is, from its sidecar - null when unknown. */
|
|
89
|
+
meta(id, sub = '') {
|
|
90
|
+
if (!/^[A-Za-z0-9-]{8,64}$/.test(String(id))) return null;
|
|
91
|
+
try { return cleanAttachment(JSON.parse(fs.readFileSync(metaOf(sub, id), 'utf8'))); } catch { return null; }
|
|
92
|
+
},
|
|
93
|
+
/** The bytes, or null. */
|
|
94
|
+
read(att, sub = '') {
|
|
95
|
+
try { return fs.readFileSync(fileOf(sub, clean(att))); } catch { return null; }
|
|
96
|
+
},
|
|
97
|
+
/** A staged file becomes a report's (the manager's own feedback). */
|
|
98
|
+
move(att, from, to) {
|
|
99
|
+
const a = clean(att);
|
|
100
|
+
ensure(to);
|
|
101
|
+
fs.renameSync(fileOf(from, a), fileOf(to, a));
|
|
102
|
+
try { fs.unlinkSync(metaOf(from, a.id)); } catch { /* no sidecar */ }
|
|
103
|
+
},
|
|
104
|
+
/** A report went: so do its files. */
|
|
105
|
+
removeAll(sub) {
|
|
106
|
+
if (!sub) return;
|
|
107
|
+
try { fs.rmSync(dirOf(sub), {recursive: true, force: true}); } catch { /* already gone */ }
|
|
108
|
+
},
|
|
109
|
+
/**
|
|
110
|
+
* Files picked but never sent (the panel was closed) are dropped once they are older
|
|
111
|
+
* than `maxAgeMs` and no outbox item names them.
|
|
112
|
+
*/
|
|
113
|
+
prune({maxAgeMs = 24 * 3600_000, keep = new Set(), sub = ''} = {}) {
|
|
114
|
+
let dir;
|
|
115
|
+
try { dir = fs.readdirSync(dirOf(sub)); } catch { return 0; }
|
|
116
|
+
let dropped = 0;
|
|
117
|
+
const cutoff = Date.now() - maxAgeMs;
|
|
118
|
+
for (const f of dir) {
|
|
119
|
+
const m = f.match(/^([A-Za-z0-9-]{8,64})\.json$/);
|
|
120
|
+
if (!m || keep.has(m[1])) continue;
|
|
121
|
+
const a = this.meta(m[1], sub);
|
|
122
|
+
const at = new Date(a?.at || 0).getTime();
|
|
123
|
+
let stat;
|
|
124
|
+
try { stat = fs.statSync(metaOf(sub, m[1])); } catch { continue; }
|
|
125
|
+
if (Math.max(at || 0, stat.mtimeMs) > cutoff) continue;
|
|
126
|
+
try { if (a) fs.unlinkSync(fileOf(sub, a)); } catch { /* gone */ }
|
|
127
|
+
try { fs.unlinkSync(metaOf(sub, m[1])); } catch { /* gone */ }
|
|
128
|
+
dropped++;
|
|
129
|
+
}
|
|
130
|
+
return dropped;
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A short memory for whole-collection reads.
|
|
3
|
+
*
|
|
4
|
+
* On the manager, the badge (polled once a minute from every open admin tab
|
|
5
|
+
* with `manage`), every site's hourly `/fleet/mine` and the dedupe in
|
|
6
|
+
* `receive()` each read EVERY report. The data changes only when someone
|
|
7
|
+
* writes, so a read is kept for a little while and every write in
|
|
8
|
+
* lib/receiver.js throws the memory away (`forget()`, right after the write,
|
|
9
|
+
* so the writer's next read is fresh). The TTL is the safety net for writes
|
|
10
|
+
* this plugin never sees (the Collections tool).
|
|
11
|
+
*
|
|
12
|
+
* PURE - no I/O of its own; the loader is handed in. Tested in tests/cache.test.js.
|
|
13
|
+
*
|
|
14
|
+
* @module feedback/lib/cache
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export const CACHE_MS = 30_000;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {{ttl?: number, clock?: () => number}} [opts]
|
|
21
|
+
* @returns {{get: (key: string, load: () => Promise<any>) => Promise<any>, forget: () => void, size: number}}
|
|
22
|
+
*/
|
|
23
|
+
export function createCache({ttl = CACHE_MS, clock = Date.now} = {}) {
|
|
24
|
+
// key -> {at, value: Promise}. The PROMISE is kept, not the result, so
|
|
25
|
+
// concurrent misses share one read, and a write landing while a read is in
|
|
26
|
+
// flight clears the map before that read can be stored - nothing stale
|
|
27
|
+
// survives a forget().
|
|
28
|
+
const kept = new Map();
|
|
29
|
+
return {
|
|
30
|
+
get(key, load) {
|
|
31
|
+
const hit = kept.get(key);
|
|
32
|
+
if (hit && clock() - hit.at < ttl) return hit.value;
|
|
33
|
+
const value = Promise.resolve().then(load);
|
|
34
|
+
const entry = {at: clock(), value};
|
|
35
|
+
kept.set(key, entry);
|
|
36
|
+
// A failed read is not remembered: the next caller tries again.
|
|
37
|
+
value.catch(() => { if (kept.get(key) === entry) kept.delete(key); });
|
|
38
|
+
return value;
|
|
39
|
+
},
|
|
40
|
+
forget() { kept.clear(); },
|
|
41
|
+
get size() { return kept.size; }
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -17,6 +17,7 @@ import fs from 'node:fs';
|
|
|
17
17
|
import path from 'node:path';
|
|
18
18
|
import {fileURLToPath} from 'node:url';
|
|
19
19
|
import {createCollection, createEntry, deleteEntry, getCollection, getEntry, listEntries, updateEntry} from '../../../server/services/collections.js';
|
|
20
|
+
import {createCache} from './cache.js';
|
|
20
21
|
import {receivedReport, statusFromIssue, statusLabel} from './shape.js';
|
|
21
22
|
|
|
22
23
|
export const COLLECTION = 'feedback-reports';
|
|
@@ -59,14 +60,25 @@ export async function ensureCollection() {
|
|
|
59
60
|
return true;
|
|
60
61
|
}
|
|
61
62
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
// The whole-collection read, kept a short while (lib/cache.js). Every write
|
|
64
|
+
// below calls forget() so the writer's own next read - and the badge - is fresh.
|
|
65
|
+
const remembered = createCache();
|
|
66
|
+
export const forget = () => remembered.forget();
|
|
67
|
+
|
|
68
|
+
/** Every report, newest first - the SAME array to every caller inside the cache window: filter, never change it. */
|
|
69
|
+
export function allReports() {
|
|
70
|
+
return remembered.get('reports', async () => {
|
|
71
|
+
const {entries} = await listEntries(COLLECTION, {limit: 100000, sort: 'createdAt', order: 'desc'});
|
|
72
|
+
return entries || [];
|
|
73
|
+
});
|
|
66
74
|
}
|
|
67
75
|
|
|
68
76
|
export const getReport = (id) => getEntry(COLLECTION, id);
|
|
69
|
-
export
|
|
77
|
+
export async function removeReport(id) {
|
|
78
|
+
const r = await deleteEntry(COLLECTION, id);
|
|
79
|
+
forget();
|
|
80
|
+
return r;
|
|
81
|
+
}
|
|
70
82
|
|
|
71
83
|
/**
|
|
72
84
|
* A site's report arrived. The same site sending the same `ref` again (a retry
|
|
@@ -86,6 +98,7 @@ export async function receive(slug, payload, source = 'fleet') {
|
|
|
86
98
|
}
|
|
87
99
|
data.history = [{at: new Date().toISOString(), by: data.userName, what: 'Sent'}];
|
|
88
100
|
const entry = await createEntry(COLLECTION, data, {source});
|
|
101
|
+
forget();
|
|
89
102
|
return {entry, created: true};
|
|
90
103
|
}
|
|
91
104
|
|
|
@@ -112,7 +125,24 @@ export async function applyPatch(id, patch, by) {
|
|
|
112
125
|
if (patch.reply !== undefined) { d.replyAt = patch.reply ? now : ''; history.push({at: now, by, what: patch.reply ? 'Replied' : 'Reply removed'}); }
|
|
113
126
|
if (patch.issueKey !== undefined && patch.issueKey !== d.issueKey) history.push({at: now, by, what: `Waypoint ${patch.issueKey}`});
|
|
114
127
|
Object.assign(d, patch, {history: history.slice(-100)});
|
|
115
|
-
|
|
128
|
+
const next = await updateEntry(COLLECTION, id, d);
|
|
129
|
+
forget();
|
|
130
|
+
return next;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* A file announced by a report has arrived: mark it stored. Touches nothing else
|
|
135
|
+
* on the record, so it adds no history line.
|
|
136
|
+
*
|
|
137
|
+
* @returns {Promise<object|null>} the updated entry
|
|
138
|
+
*/
|
|
139
|
+
export async function markStored(id, attId) {
|
|
140
|
+
const e = await getReport(id);
|
|
141
|
+
if (!e) return null;
|
|
142
|
+
const attachments = (e.data.attachments || []).map(a => (a.id === attId ? {...a, stored: true} : a));
|
|
143
|
+
const next = await updateEntry(COLLECTION, id, {...e.data, attachments});
|
|
144
|
+
forget();
|
|
145
|
+
return next;
|
|
116
146
|
}
|
|
117
147
|
|
|
118
148
|
/**
|