domma-cms 0.115.5 → 0.116.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.
- package/admin/js/lib/effect-defs.js +1 -1
- package/package.json +1 -1
- package/plugins/free-tier.lock.json +16 -16
- package/plugins/support/CLAUDE.md +19 -0
- package/plugins/support/admin/css/index.css +4 -0
- package/plugins/support/admin/lib/kit.js +60 -11
- package/plugins/support/admin/views/desk.js +158 -74
- package/plugins/support/admin/views/inbox.js +146 -56
- package/plugins/support/admin/views/support.js +1 -1
- package/plugins/support/collections/support-tickets.json +1 -0
- package/plugins/support/lib/receiver.js +2 -1
- package/plugins/support/lib/sender.js +68 -4
- package/plugins/support/lib/shape.js +10 -1
- package/plugins/support/plugin.js +38 -6
- package/plugins/support/plugin.json +2 -2
- package/plugins/support/tests/api.test.js +94 -0
- package/plugins/support/tests/shape.test.js +68 -1
- package/public/js/celebrations/character-catalog.mjs +1 -0
- package/public/js/celebrations/characters.js +1 -0
- package/public/js/celebrations/index.js +1 -1
- package/public/js/celebrations/themes/christmas.js +1 -1
- package/public/js/celebrations/themes/mooning-antics.js +1 -0
- package/public/js/celebrations/themes/mooning-drawing.js +1 -0
- package/public/js/celebrations/themes/st-patricks.js +1 -1
- package/public/js/celebrations/trait-data.mjs +1 -1
- package/public/js/effects.js +1 -1
- package/server/services/markdown.js +18 -2
- package/server/templates/page.html +1 -1
|
@@ -2,24 +2,32 @@
|
|
|
2
2
|
* Support on the manager: every site's requests. Waiting on Domma comes first,
|
|
3
3
|
* urgent at the top; a request opens in a panel with the conversation, a reply
|
|
4
4
|
* box (the customer is emailed), status, priority and internal notes.
|
|
5
|
+
*
|
|
6
|
+
* Right-click a row (or its ⋮, or Shift+F10) for the quick moves: resolve or
|
|
7
|
+
* reopen, urgent, archive, delete. The archive is Domma's own filing - the site
|
|
8
|
+
* still sees the request - and a customer writing again takes it back out.
|
|
5
9
|
*/
|
|
6
10
|
|
|
7
|
-
import {TONE, api, esc, listOf, openPanel, refreshBadge, stamp, store,
|
|
11
|
+
import {THREAD, TONE, api, copyText, esc, listOf, openPanel, refreshBadge, rowKeys, stamp, store, threadRows, when} from '../lib/kit.js';
|
|
8
12
|
|
|
9
13
|
const ROW = `
|
|
10
|
-
<div class="sp-row" data-id="{{key}}" tabindex="0" role="button">
|
|
14
|
+
<div class="sp-row" data-id="{{key}}" tabindex="0" role="button" aria-haspopup="menu">
|
|
11
15
|
<span class="sp-cat" data-bind-title="categoryLabel"><span data-icon="{{icon}}" data-icon-size="16"></span></span>
|
|
12
16
|
<span class="sp-main"><strong data-bind-text="subject"></strong><small data-bind-text="meta"></small></span>
|
|
13
17
|
<span class="sp-urgent" data-bind-hidden="!urgent">Urgent</span>
|
|
14
18
|
<span class="sp-chip" data-bind-class="tone" data-bind-text="statusLabel"></span>
|
|
19
|
+
<button type="button" class="sp-kebab" aria-label="Actions" title="Actions">⋮</button>
|
|
15
20
|
</div>`;
|
|
16
21
|
|
|
22
|
+
/** `all` is everything still in the inbox; the archive is its own tab. */
|
|
17
23
|
const FILTERS = [
|
|
18
24
|
{key: 'open', label: 'Waiting on Domma'},
|
|
19
25
|
{key: 'answered', label: 'Answered'},
|
|
20
26
|
{key: 'closed', label: 'Resolved'},
|
|
21
|
-
{key: 'all', label: 'All'}
|
|
27
|
+
{key: 'all', label: 'All'},
|
|
28
|
+
{key: 'archived', label: 'Archived'}
|
|
22
29
|
];
|
|
30
|
+
const inFilter = (t, f) => (f === 'archived' ? Boolean(t.archived) : !t.archived && (f === 'all' || t.status === f));
|
|
23
31
|
|
|
24
32
|
export async function mount(root, ctx) {
|
|
25
33
|
const {kit, scope, here, meta, id, mode} = ctx;
|
|
@@ -32,20 +40,24 @@ export async function mount(root, ctx) {
|
|
|
32
40
|
const tickets = M.observable([]);
|
|
33
41
|
const loading = M.observable(true);
|
|
34
42
|
const error = M.observable('');
|
|
35
|
-
const
|
|
43
|
+
const stored = store.get('support.filter');
|
|
44
|
+
const filter = M.observable(FILTERS.some(f => f.key === stored) ? stored : 'open');
|
|
36
45
|
const query = M.observable('');
|
|
37
46
|
const rows = M.observable([]);
|
|
38
47
|
const counts = M.observable({});
|
|
48
|
+
const emptyText = M.computed(() => (query.value.trim() ? 'Nothing matches that search.'
|
|
49
|
+
: filter.value === 'archived' ? 'Nothing archived. Archive a request from its menu to put it away without deleting it.'
|
|
50
|
+
: filter.value === 'open' ? 'Nothing is waiting on Domma.' : 'Nothing here.'));
|
|
39
51
|
scope(M.effect(() => {
|
|
40
52
|
const all = tickets.value;
|
|
41
|
-
counts.value = Object.fromEntries(FILTERS.map(f => [f.key,
|
|
53
|
+
counts.value = Object.fromEntries(FILTERS.map(f => [f.key, all.filter(t => inFilter(t, f.key)).length]));
|
|
42
54
|
const q = query.value.trim().toLowerCase();
|
|
43
55
|
rows.value = all
|
|
44
|
-
.filter(t =>
|
|
56
|
+
.filter(t => inFilter(t, filter.value))
|
|
45
57
|
.filter(t => !q || [t.subject, t.siteName, t.userName, t.userEmail, ...(t.messages || []).map(m => m.body)]
|
|
46
58
|
.some(v => String(v || '').toLowerCase().includes(q)))
|
|
47
59
|
// Urgent first, then whoever has waited longest for us (newest first elsewhere).
|
|
48
|
-
.sort((a, b) => (b.priority === 'urgent') - (a.priority === 'urgent')
|
|
60
|
+
.sort((a, b) => (b.priority === 'urgent' && b.status !== 'closed') - (a.priority === 'urgent' && a.status !== 'closed')
|
|
49
61
|
|| (filter.value === 'open' ? String(a.lastAt).localeCompare(String(b.lastAt)) : String(b.lastAt).localeCompare(String(a.lastAt))))
|
|
50
62
|
.map(t => ({
|
|
51
63
|
key: t.id,
|
|
@@ -66,16 +78,16 @@ export async function mount(root, ctx) {
|
|
|
66
78
|
view.innerHTML = `
|
|
67
79
|
<div class="sp-bar">
|
|
68
80
|
<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('')}
|
|
81
|
+
${FILTERS.map(f => `<button type="button" role="tab" data-filter="${f.key}" data-bind-class="filter.value === '${f.key}' && 'is-on'" data-bind-aria-selected="filter.value === '${f.key}' ? 'true' : 'false'">${esc(f.label)} <b data-bind-text="counts.value['${f.key}'] || 0"></b></button>`).join('')}
|
|
70
82
|
</div>
|
|
71
83
|
<span class="sp-spacer"></span>
|
|
72
84
|
<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.
|
|
85
|
+
<span data-help-title="Support inbox" data-help="Every site's support requests. Click one to read and reply - replying emails the customer a link back to the conversation in their own admin. Right-click a request (or use its ⋮ button, or Shift+F10) to resolve, reopen, mark urgent, archive or delete it. Archiving puts a request away without deleting it; the customer still sees it, and if they write again it comes back here. Urgent requests sort first and turn the sidebar badge red. Internal notes never reach the site."></span>
|
|
74
86
|
</div>
|
|
75
87
|
<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
88
|
<div class="sp-loading dm-loading-slot" data-bind-hidden="!loading.value">Loading...</div>
|
|
77
89
|
<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"
|
|
90
|
+
<p class="sp-note" data-bind-hidden="loading.value || rows.value.length" data-bind-text="emptyText.value"></p>
|
|
79
91
|
<div class="sp-list" data-each="rows.value key=key"></div>`;
|
|
80
92
|
root.appendChild(view);
|
|
81
93
|
scope(kit.helpPopovers(view, {position: 'bottom'}));
|
|
@@ -98,48 +110,104 @@ export async function mount(root, ctx) {
|
|
|
98
110
|
}
|
|
99
111
|
|
|
100
112
|
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(); };
|
|
113
|
+
const replace = (t) => { tickets.value = tickets.peek().map(x => (x.id === t.id ? {...x, ...t} : x)); refreshBadge(); return t; };
|
|
114
|
+
const fail = (err) => E.toast(err.message || String(err), {type: 'error'});
|
|
115
|
+
|
|
116
|
+
// ---- The moves: one definition each, for the row menu and the panel alike ----
|
|
117
|
+
|
|
118
|
+
const actions = {
|
|
119
|
+
/** Resolve goes through reply, so the customer is told; reopen is a quiet status change. */
|
|
120
|
+
async resolve(t) { replace(await api.reply(t.id, {status: 'closed'})); E.toast('Resolved.', {type: 'success'}); },
|
|
121
|
+
async reopen(t) { replace(await api.update(t.id, {status: 'open'})); E.toast('Reopened - it is waiting on Domma again.', {type: 'info'}); },
|
|
122
|
+
async urgent(t, on) { replace(await api.update(t.id, {priority: on ? 'urgent' : 'normal'})); },
|
|
123
|
+
async archive(t, on) {
|
|
124
|
+
replace(await api.update(t.id, {archived: on}));
|
|
125
|
+
E.toast(on ? `Archived "${t.subject}".` : `"${t.subject}" is back in the inbox.`, {type: 'success'});
|
|
126
|
+
},
|
|
127
|
+
async remove(t) {
|
|
128
|
+
if (!await E.confirm(`Delete "${esc(t.subject)}" for good? It goes from ${esc(t.siteName)}'s admin too. Archive it instead to keep it.`)) return false;
|
|
129
|
+
await api.remove(t.id);
|
|
130
|
+
if (openPanel_?.tid === t.id) openPanel_.close();
|
|
131
|
+
tickets.value = tickets.peek().filter(x => x.id !== t.id);
|
|
132
|
+
refreshBadge();
|
|
133
|
+
E.toast('Deleted.', {type: 'success'});
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
/** The request open in a panel, so a delete from its row menu closes it. */
|
|
138
|
+
let openPanel_ = null;
|
|
139
|
+
const act = (name, ...args) => async (t) => { try { return await actions[name](t, ...args); } catch (err) { fail(err); return false; } };
|
|
140
|
+
|
|
141
|
+
function menuItems(t) {
|
|
142
|
+
const closed = t.status === 'closed';
|
|
143
|
+
const urgent = t.priority === 'urgent';
|
|
144
|
+
const siteUrl = t.siteHost ? `https://${t.siteHost}/admin/` : '';
|
|
145
|
+
return [
|
|
146
|
+
{label: 'Open and reply', icon: 'message-square', action: () => open(t.id)},
|
|
147
|
+
{type: 'divider'},
|
|
148
|
+
closed ? {label: 'Reopen', icon: 'rotate-ccw', action: () => act('reopen')(t)}
|
|
149
|
+
: {label: 'Resolve (emails the customer)', icon: 'check-circle', action: () => act('resolve')(t)},
|
|
150
|
+
{label: urgent ? 'Not urgent' : 'Mark urgent', icon: 'alert-triangle', action: () => act('urgent', !urgent)(t)},
|
|
151
|
+
t.archived ? {label: 'Back to the inbox', icon: 'inbox', action: () => act('archive', false)(t)}
|
|
152
|
+
: {label: 'Archive', icon: 'archive', action: () => act('archive', true)(t)},
|
|
153
|
+
{type: 'divider'},
|
|
154
|
+
...(t.userEmail ? [{label: 'Copy customer email', icon: 'copy', action: () => copyText(t.userEmail, 'Email copied')}] : []),
|
|
155
|
+
...(siteUrl ? [{label: `Open ${t.siteName}'s admin`, icon: 'external-link', action: () => window.open(siteUrl, '_blank', 'noopener')}] : []),
|
|
156
|
+
{type: 'divider'},
|
|
157
|
+
{label: 'Delete', icon: 'trash', danger: true, action: () => act('remove')(t)}
|
|
158
|
+
];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---- A request, in a panel ----
|
|
102
162
|
|
|
103
163
|
async function open(tid) {
|
|
104
164
|
// Read fresh: the panel shows where a reply will be emailed, which only the detail route works out.
|
|
105
165
|
let t;
|
|
106
166
|
try { t = await api.ticket(tid); } catch { t = byId(tid); }
|
|
107
|
-
if (!t) { E.toast('That request is no longer here.', {type: 'warning'}); return; }
|
|
167
|
+
if (!t || !here()) { if (!t) E.toast('That request is no longer here.', {type: 'warning'}); return; }
|
|
168
|
+
if (!byId(tid)) tickets.value = [t, ...tickets.peek()];
|
|
108
169
|
const rt = t.replyTo || {email: t.userEmail || '', source: t.userEmail ? 'profile' : 'none'};
|
|
109
170
|
const replyNote = rt.source === 'registration' ? `Replies are emailed to ${rt.email} - the address the site was bought with.`
|
|
110
171
|
: rt.source === 'profile' ? `Replies are emailed to ${rt.email} - from the requester's profile on their site (this site was not bought through Customers).`
|
|
111
172
|
: 'This customer has no email on record - replies show only in their admin.';
|
|
173
|
+
|
|
174
|
+
// The panel follows the list's copy, so a move from the row menu shows here at once.
|
|
175
|
+
const cur = M.computed(() => tickets.value.find(x => x.id === tid) || t);
|
|
176
|
+
const thread = M.computed(() => threadRows(cur.value.messages || [], 'domma'));
|
|
177
|
+
const status = M.computed(() => cur.value.status);
|
|
178
|
+
const statusLabel = M.computed(() => inboxLabel(cur.value.status));
|
|
179
|
+
const tone = M.computed(() => `is-${TONE[cur.value.status] || 'muted'}`);
|
|
180
|
+
const urgent = M.computed(() => cur.value.priority === 'urgent');
|
|
181
|
+
const archived = M.computed(() => Boolean(cur.value.archived));
|
|
112
182
|
const draft = M.observable('');
|
|
183
|
+
// Bindings may not call methods (.trim()), so the test is a computed.
|
|
184
|
+
const hasDraft = M.computed(() => Boolean(draft.value.trim()));
|
|
113
185
|
const after = M.observable('answered');
|
|
114
186
|
const notes = M.observable(t.notes || '');
|
|
115
|
-
const priority = M.observable(t.priority || 'normal');
|
|
116
|
-
const status = M.observable(t.status);
|
|
117
187
|
const busy = M.observable(false);
|
|
118
188
|
let panel = null;
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
priority.value = cur.priority;
|
|
123
|
-
const box = panel?.panel.querySelector('.sp-thread-host');
|
|
124
|
-
if (box) { box.innerHTML = threadHtml(cur.messages, 'domma'); box.scrollTop = box.scrollHeight; }
|
|
125
|
-
const urgent = panel?.panel.querySelector('[data-ref="urgent"]');
|
|
126
|
-
if (urgent) urgent.checked = cur.priority === 'urgent';
|
|
127
|
-
const chip = panel?.panel.querySelector('[data-ref="status"]');
|
|
128
|
-
if (chip) { chip.textContent = inboxLabel(cur.status); chip.className = `sp-chip is-${TONE[cur.status] || 'muted'}`; }
|
|
129
|
-
};
|
|
130
|
-
const siteUrl = t.siteHost ? `https://${t.siteHost}/admin/` : '';
|
|
189
|
+
let stopScroll = () => {};
|
|
190
|
+
let menu = null;
|
|
191
|
+
|
|
131
192
|
const run = async (fn) => {
|
|
132
193
|
if (busy.peek()) return;
|
|
133
194
|
busy.value = true;
|
|
134
|
-
try { await fn();
|
|
195
|
+
try { await fn(); } catch (err) { fail(err); } finally { busy.value = false; }
|
|
135
196
|
};
|
|
197
|
+
const siteUrl = t.siteHost ? `https://${t.siteHost}/admin/` : '';
|
|
136
198
|
panel = await openPanel({
|
|
137
199
|
title: t.subject,
|
|
138
200
|
size: 'lg',
|
|
139
|
-
model: {
|
|
201
|
+
model: {thread, status, statusLabel, tone, urgent, archived, draft, hasDraft, after, notes, busy},
|
|
202
|
+
cleanup: () => {
|
|
203
|
+
stopScroll(); menu?.destroy?.();
|
|
204
|
+
[cur, thread, status, statusLabel, tone, urgent, archived, hasDraft].forEach(c => c.dispose?.());
|
|
205
|
+
if (openPanel_?.tid === tid) openPanel_ = null;
|
|
206
|
+
},
|
|
140
207
|
html: `<div class="sp-detail">
|
|
141
208
|
<dl class="sp-facts">
|
|
142
|
-
<div><dt>Status</dt><dd><span
|
|
209
|
+
<div><dt>Status</dt><dd><span class="sp-chip" data-bind-class="tone.value" data-bind-text="statusLabel.value"></span>
|
|
210
|
+
<span class="sp-chip is-muted" data-bind-hidden="!archived.value">Archived</span></dd></div>
|
|
143
211
|
<div><dt>Site</dt><dd>${siteUrl ? `<a href="${esc(siteUrl)}" target="_blank" rel="noopener">${esc(t.siteName)}</a>` : esc(t.siteName)}</dd></div>
|
|
144
212
|
<div><dt>From</dt><dd>${esc(t.userName)}${t.userEmail ? ` · <a href="mailto:${esc(t.userEmail)}">${esc(t.userEmail)}</a>` : ''}</dd></div>
|
|
145
213
|
<div><dt>About</dt><dd>${esc(labelOf(t.category))}</dd></div>
|
|
@@ -147,9 +215,9 @@ export async function mount(root, ctx) {
|
|
|
147
215
|
${t.where ? `<div><dt>Where</dt><dd>${esc(t.where)}</dd></div>` : ''}
|
|
148
216
|
${t.cmsVersion ? `<div><dt>CMS</dt><dd>${esc(t.cmsVersion)}</dd></div>` : ''}
|
|
149
217
|
</dl>
|
|
150
|
-
<div class="sp-thread-host"
|
|
218
|
+
<div class="sp-thread-host">${THREAD}</div>
|
|
151
219
|
<div class="sp-reply">
|
|
152
|
-
<label class="form-label" for="sp-answer">Reply <span data-help="The customer sees it in their admin and gets it by email."></span></label>
|
|
220
|
+
<label class="form-label" for="sp-answer">Reply <span data-help="The customer sees it in their admin and gets it by email. Right-click a message to copy or quote it."></span></label>
|
|
153
221
|
<p class="sp-note sp-small">${esc(replyNote)}</p>
|
|
154
222
|
<textarea id="sp-answer" class="form-textarea" rows="5" maxlength="8000" data-model="draft.value" placeholder="Write to ${esc(t.userName || 'the customer')}"></textarea>
|
|
155
223
|
<div class="sp-actions">
|
|
@@ -157,15 +225,17 @@ export async function mount(root, ctx) {
|
|
|
157
225
|
<span class="sp-spacer"></span>
|
|
158
226
|
<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>
|
|
159
227
|
<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>
|
|
160
|
-
<button type="button" class="btn btn-sm btn-primary" data-on-click="send" data-bind-disabled="busy.value || !
|
|
228
|
+
<button type="button" class="btn btn-sm btn-primary" data-on-click="send" data-bind-disabled="busy.value || !hasDraft.value"><span data-icon="send" data-icon-size="13"></span> Send reply</button>
|
|
161
229
|
</div>
|
|
162
230
|
</div>
|
|
163
231
|
<div class="sp-side">
|
|
164
|
-
<label class="sp-inline"><input type="checkbox" data-
|
|
232
|
+
<label class="sp-inline"><input type="checkbox" data-bind-checked="urgent.value" data-bind-disabled="busy.value" data-on-change="toggleUrgent"> Urgent</label>
|
|
165
233
|
<label class="form-label" for="sp-notes">Internal notes <span data-help="For the Domma team only - never sent to the site."></span></label>
|
|
166
234
|
<textarea id="sp-notes" class="form-textarea" rows="3" data-model="notes.value"></textarea>
|
|
167
235
|
<div class="sp-actions">
|
|
168
236
|
<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>
|
|
237
|
+
<button type="button" class="btn btn-sm btn-ghost" data-on-click="archive" data-bind-disabled="busy.value">
|
|
238
|
+
<span data-icon="archive" data-icon-size="13"></span> <span data-bind-text="archived.value ? 'Back to the inbox' : 'Archive'"></span></button>
|
|
169
239
|
<span class="sp-spacer"></span>
|
|
170
240
|
<button type="button" class="btn btn-sm btn-ghost" data-on-click="saveNotes" data-bind-disabled="busy.value">Save notes</button>
|
|
171
241
|
</div>
|
|
@@ -173,42 +243,62 @@ export async function mount(root, ctx) {
|
|
|
173
243
|
</div>`,
|
|
174
244
|
methods: {
|
|
175
245
|
send: () => run(async () => {
|
|
176
|
-
|
|
177
|
-
replace(next);
|
|
246
|
+
replace(await api.reply(tid, {body: draft.peek(), status: after.peek()}));
|
|
178
247
|
draft.value = '';
|
|
179
248
|
E.toast(rt.email ? `Sent - emailed to ${rt.email}.` : 'Sent - it shows in their admin.', {type: 'success'});
|
|
180
249
|
}),
|
|
181
|
-
resolve: () => run(
|
|
182
|
-
reopen: () => run(
|
|
250
|
+
resolve: () => run(() => actions.resolve(cur.peek())),
|
|
251
|
+
reopen: () => run(() => actions.reopen(cur.peek())),
|
|
252
|
+
toggleUrgent: (e) => run(async () => {
|
|
253
|
+
// A failed save puts the box back: the binding only re-runs when the value changes.
|
|
254
|
+
try { await actions.urgent(cur.peek(), Boolean(e.target.checked)); } finally { e.target.checked = urgent.peek(); }
|
|
255
|
+
}),
|
|
256
|
+
archive: () => run(async () => {
|
|
257
|
+
const on = !archived.peek();
|
|
258
|
+
await actions.archive(cur.peek(), on);
|
|
259
|
+
if (on) panel?.close();
|
|
260
|
+
}),
|
|
183
261
|
saveNotes: () => run(async () => { replace(await api.update(tid, {notes: notes.peek()})); E.toast('Notes saved.', {type: 'success'}); }),
|
|
184
|
-
remove: async () => {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
262
|
+
remove: () => run(async () => { if (await actions.remove(cur.peek())) panel?.close(); })
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
openPanel_ = {tid, close: () => panel?.close()};
|
|
266
|
+
// Keep the newest message in view as the thread grows.
|
|
267
|
+
const box = panel.panel.querySelector('.sp-thread-host');
|
|
268
|
+
stopScroll = M.effect(() => { thread.value; Promise.resolve().then(() => { M.flush?.(); box.scrollTop = box.scrollHeight; }); });
|
|
269
|
+
// A message: copy it, or quote it into the reply.
|
|
270
|
+
menu = E.contextMenu(box, {
|
|
271
|
+
cursor: 'glyph', match: '.sp-msg', inherit: false, density: 'compact',
|
|
272
|
+
items: (li) => {
|
|
273
|
+
const m = thread.peek().find(x => x.key === li.dataset.key);
|
|
274
|
+
if (!m) return [];
|
|
275
|
+
return [
|
|
276
|
+
{label: 'Copy message', icon: 'copy', action: () => copyText(m.body, 'Message copied')},
|
|
277
|
+
{label: 'Quote in reply', icon: 'corner-down-right', action: () => {
|
|
278
|
+
const quoted = m.body.split('\n').map(l => `> ${l}`).join('\n');
|
|
279
|
+
draft.value = `${draft.peek() ? `${draft.peek().replace(/\s+$/, '')}\n\n` : ''}${quoted}\n\n`;
|
|
280
|
+
panel.panel.querySelector('#sp-answer')?.focus();
|
|
281
|
+
}}
|
|
282
|
+
];
|
|
193
283
|
}
|
|
194
284
|
});
|
|
195
|
-
panel.panel.querySelector('[data-ref="urgent"]').addEventListener('change', (e) => run(async () => {
|
|
196
|
-
replace(await api.update(tid, {priority: e.target.checked ? 'urgent' : 'normal'}));
|
|
197
|
-
}));
|
|
198
|
-
render();
|
|
199
285
|
}
|
|
200
286
|
|
|
201
|
-
const bindings = M.applyBindings({tickets, loading, error, filter, query, rows, counts}, view, {methods: {}});
|
|
287
|
+
const bindings = M.applyBindings({tickets, loading, error, filter, query, rows, counts, emptyText}, view, {methods: {}});
|
|
202
288
|
scope(() => bindings.dispose());
|
|
203
289
|
|
|
204
290
|
const seg = view.querySelector('.sp-seg');
|
|
205
291
|
const onFilter = (e) => { const b = e.target.closest('[data-filter]'); if (b) { filter.value = b.dataset.filter; store.set('support.filter', filter.value); } };
|
|
206
292
|
seg.addEventListener('click', onFilter);
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
293
|
+
scope(() => seg.removeEventListener('click', onFilter));
|
|
294
|
+
|
|
295
|
+
// Rows: click or Enter opens; right-click, the ⋮ or Shift+F10 = the menu.
|
|
296
|
+
const rowMenu = E.contextMenu(listEl, {
|
|
297
|
+
cursor: 'glyph', match: '.sp-row', inherit: false, density: 'compact',
|
|
298
|
+
items: (row) => { const t = byId(row.dataset.id); return t ? menuItems(t) : []; }
|
|
299
|
+
});
|
|
300
|
+
scope(() => rowMenu?.destroy?.());
|
|
301
|
+
scope(rowKeys(listEl, {rowSel: '.sp-row', kebabSel: '.sp-kebab', openMenuAt: kit.openMenuAt, primary: open}));
|
|
212
302
|
|
|
213
303
|
I.scan(view);
|
|
214
304
|
await load();
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
{"name": "lastFrom", "label": "Last from", "type": "text"},
|
|
20
20
|
{"name": "lastAt", "label": "Last message", "type": "text"},
|
|
21
21
|
{"name": "notes", "label": "Internal notes", "type": "textarea"},
|
|
22
|
+
{"name": "archived", "label": "Archived", "type": "boolean"},
|
|
22
23
|
{"name": "history", "label": "History", "type": "array", "items": "object"}
|
|
23
24
|
],
|
|
24
25
|
"api": {
|
|
@@ -165,7 +165,7 @@ async function dommaTurnNow(id, {body = '', status, by, msgId}, now) {
|
|
|
165
165
|
return updateEntry(COLLECTION, id, data);
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
-
/** Status, priority or
|
|
168
|
+
/** Status, priority, notes or the archive, from the inbox - with a line of history for what moved. */
|
|
169
169
|
export function patch(id, change, by, now = new Date().toISOString()) {
|
|
170
170
|
return serial(id, () => patchNow(id, change, by, now));
|
|
171
171
|
}
|
|
@@ -177,6 +177,7 @@ async function patchNow(id, change, by, now) {
|
|
|
177
177
|
const history = Array.isArray(d.history) ? [...d.history] : [];
|
|
178
178
|
if (change.status !== undefined && change.status !== d.status) history.push({at: now, by, what: `Status: ${change.status}`});
|
|
179
179
|
if (change.priority !== undefined && change.priority !== d.priority) history.push({at: now, by, what: `Priority: ${change.priority}`});
|
|
180
|
+
if (change.archived !== undefined && change.archived !== Boolean(d.archived)) history.push({at: now, by, what: change.archived ? 'Archived' : 'Back from the archive'});
|
|
180
181
|
Object.assign(d, change, {history: history.slice(-200)});
|
|
181
182
|
return updateEntry(COLLECTION, id, d);
|
|
182
183
|
}
|
|
@@ -13,7 +13,10 @@
|
|
|
13
13
|
*
|
|
14
14
|
* The outbox also keeps the last view of each request from the manager (for the
|
|
15
15
|
* sidebar badge, which must never call the manager), which Domma replies this
|
|
16
|
-
* site has seen,
|
|
16
|
+
* site has seen, the contact card the manager last sent, and the SHELF: the
|
|
17
|
+
* requests this site has archived or removed from its own list. The shelf is
|
|
18
|
+
* this site's alone - Domma keeps every request whatever a site does with it -
|
|
19
|
+
* and a new reply from Domma takes a request off it again (see applyShelf).
|
|
17
20
|
*
|
|
18
21
|
* @module support/lib/sender
|
|
19
22
|
*/
|
|
@@ -43,9 +46,10 @@ export function createOutbox(dataDir) {
|
|
|
43
46
|
items: Array.isArray(j.items) ? j.items : [],
|
|
44
47
|
seen: j.seen && typeof j.seen === 'object' ? j.seen : {},
|
|
45
48
|
tickets: Array.isArray(j.tickets) ? j.tickets : [],
|
|
46
|
-
contact: j.contact && typeof j.contact === 'object' ? j.contact : null
|
|
49
|
+
contact: j.contact && typeof j.contact === 'object' ? j.contact : null,
|
|
50
|
+
shelf: j.shelf && typeof j.shelf === 'object' ? j.shelf : {}
|
|
47
51
|
};
|
|
48
|
-
} catch { return {items: [], seen: {}, tickets: [], contact: null}; }
|
|
52
|
+
} catch { return {items: [], seen: {}, tickets: [], contact: null, shelf: {}}; }
|
|
49
53
|
};
|
|
50
54
|
const write = (state) => {
|
|
51
55
|
fs.mkdirSync(dataDir, {recursive: true});
|
|
@@ -58,6 +62,34 @@ export function createOutbox(dataDir) {
|
|
|
58
62
|
seen: () => read().seen,
|
|
59
63
|
tickets: () => read().tickets,
|
|
60
64
|
contact: () => read().contact,
|
|
65
|
+
shelf: () => read().shelf,
|
|
66
|
+
/**
|
|
67
|
+
* Archive or remove a request from this site's list (`state` null puts it back). Remembers
|
|
68
|
+
* Domma's latest reply at the time, and marks it read: what is put away is not "new".
|
|
69
|
+
*/
|
|
70
|
+
shelve(ref, state, dommaAt = '') {
|
|
71
|
+
update(s => {
|
|
72
|
+
if (!state) { delete s.shelf[ref]; return; }
|
|
73
|
+
s.shelf[ref] = {state, dommaAt, at: new Date().toISOString()};
|
|
74
|
+
if (dommaAt) s.seen[ref] = dommaAt;
|
|
75
|
+
});
|
|
76
|
+
},
|
|
77
|
+
/**
|
|
78
|
+
* Cancel a request that never reached Domma: it and its follow-ups leave the outbox.
|
|
79
|
+
* @returns {boolean} false when any of it has been sent - that is Domma's now
|
|
80
|
+
*/
|
|
81
|
+
discard(ref) {
|
|
82
|
+
// A send in flight may be carrying it right now: too late to promise it will not go.
|
|
83
|
+
if (this.flushing) return false;
|
|
84
|
+
return update(s => {
|
|
85
|
+
const mine = s.items.filter(i => i.ref === ref || i.ticketRef === ref);
|
|
86
|
+
if (!mine.some(i => i.kind === 'ticket') || mine.some(i => i.state === 'sent')) return false;
|
|
87
|
+
s.items = s.items.filter(i => !(i.ref === ref || i.ticketRef === ref));
|
|
88
|
+
delete s.shelf[ref];
|
|
89
|
+
delete s.seen[ref];
|
|
90
|
+
return true;
|
|
91
|
+
});
|
|
92
|
+
},
|
|
61
93
|
add(kind, payload, ticketRef) {
|
|
62
94
|
return update(s => {
|
|
63
95
|
const item = {ref: payload.ref, kind, ticketRef: ticketRef || payload.ref, state: 'waiting', payload,
|
|
@@ -79,6 +111,13 @@ export function createOutbox(dataDir) {
|
|
|
79
111
|
update(s => {
|
|
80
112
|
s.tickets = remote.map(t => ({ref: t.ref, subject: t.subject, status: t.status, dommaAt: lastDommaAt(t)}));
|
|
81
113
|
if (contact) s.contact = contact;
|
|
114
|
+
// The shelf forgets what Domma deleted, and what Domma has written to since.
|
|
115
|
+
const latest = new Map(s.tickets.map(t => [t.ref, t.dommaAt]));
|
|
116
|
+
const local = new Set(s.items.map(i => i.ticketRef));
|
|
117
|
+
for (const [ref, entry] of Object.entries(s.shelf)) {
|
|
118
|
+
const known = latest.has(ref);
|
|
119
|
+
if ((!known && !local.has(ref)) || (known && (latest.get(ref) || '') !== (entry.dommaAt || ''))) delete s.shelf[ref];
|
|
120
|
+
}
|
|
82
121
|
const byRef = new Map(remote.map(t => [t.ref, t]));
|
|
83
122
|
const have = new Set(remote.flatMap(t => [t.ref, ...(t.messages || []).map(m => m.id)]));
|
|
84
123
|
s.items = s.items.filter((i) => {
|
|
@@ -144,7 +183,10 @@ const RETRY = new Set([401, 408, 429]);
|
|
|
144
183
|
async function flushOnce(outbox, api) {
|
|
145
184
|
let sent = 0;
|
|
146
185
|
let error = '';
|
|
147
|
-
for (const
|
|
186
|
+
for (const queued of outbox.all().filter(i => i.state === 'waiting')) {
|
|
187
|
+
// Read it again: it may have been cancelled while an earlier item was sending.
|
|
188
|
+
const item = outbox.all().find(i => i.ref === queued.ref && i.state === 'waiting');
|
|
189
|
+
if (!item) continue;
|
|
148
190
|
if (item.kind === 'message') {
|
|
149
191
|
const parent = outbox.all().find(i => i.kind === 'ticket' && i.ref === item.ticketRef);
|
|
150
192
|
if (parent?.state === 'waiting') continue; // goes after its request
|
|
@@ -165,6 +207,28 @@ async function flushOnce(outbox, api) {
|
|
|
165
207
|
return {sent, waiting: outbox.all().filter(i => i.state === 'waiting').length, error};
|
|
166
208
|
}
|
|
167
209
|
|
|
210
|
+
/**
|
|
211
|
+
* The shelf over a list of requests, PURE: removed ones go, archived ones are
|
|
212
|
+
* marked `archived`. Either comes back the moment Domma has written since it was
|
|
213
|
+
* put away (`dommaAt` moved on) - a reply is never hidden from the customer.
|
|
214
|
+
*
|
|
215
|
+
* @param {object[]} tickets - from mergeTickets
|
|
216
|
+
* @param {Object<string, {state: 'archived'|'removed', dommaAt?: string}>} shelf
|
|
217
|
+
* @returns {object[]}
|
|
218
|
+
*/
|
|
219
|
+
export function applyShelf(tickets, shelf = {}) {
|
|
220
|
+
const out = [];
|
|
221
|
+
for (const t of tickets) {
|
|
222
|
+
const entry = shelf[t.ref];
|
|
223
|
+
// An offline (stale) copy has no messages to read the latest reply from: trust the shelf.
|
|
224
|
+
const latest = t.stale ? (entry?.dommaAt || '') : lastDommaAt(t);
|
|
225
|
+
const live = entry && (latest || '') === (entry.dommaAt || '');
|
|
226
|
+
if (live && entry.state === 'removed') continue;
|
|
227
|
+
out.push({...t, archived: Boolean(live && entry.state === 'archived')});
|
|
228
|
+
}
|
|
229
|
+
return out;
|
|
230
|
+
}
|
|
231
|
+
|
|
168
232
|
/**
|
|
169
233
|
* The requests the screen shows: the manager's view of each, plus anything
|
|
170
234
|
* still waiting here (a request the manager has never seen, or a message it
|
|
@@ -144,6 +144,11 @@ export function addTurn(data, {from, name, body = '', id, at, status, close = fa
|
|
|
144
144
|
next.lastAt = at;
|
|
145
145
|
added = true;
|
|
146
146
|
history.push({at, by: name, what: from === 'domma' ? 'Replied' : 'Wrote'});
|
|
147
|
+
// Archived in the inbox, and the customer wrote again: it comes back to be answered.
|
|
148
|
+
if (from === 'customer' && next.archived) {
|
|
149
|
+
next.archived = false;
|
|
150
|
+
history.push({at, by: name, what: 'Back from the archive'});
|
|
151
|
+
}
|
|
147
152
|
}
|
|
148
153
|
// Whose turn it is now. Domma may choose (answered, or closed); the customer reopens by writing.
|
|
149
154
|
let to = data.status;
|
|
@@ -190,7 +195,7 @@ export function lastDommaAt(ticket) {
|
|
|
190
195
|
return ms.length ? String(ms[ms.length - 1].at || '') : '';
|
|
191
196
|
}
|
|
192
197
|
|
|
193
|
-
/** What the inbox may change without writing: status
|
|
198
|
+
/** What the inbox may change without writing: status, priority, internal notes and the archive. */
|
|
194
199
|
export function cleanUpdate(body = {}) {
|
|
195
200
|
const patch = {};
|
|
196
201
|
if (body.status !== undefined) {
|
|
@@ -202,6 +207,10 @@ export function cleanUpdate(body = {}) {
|
|
|
202
207
|
if (!PRIORITY_KEYS.includes(body.priority)) return {error: 'That is not a priority.'};
|
|
203
208
|
patch.priority = body.priority;
|
|
204
209
|
}
|
|
210
|
+
if (body.archived !== undefined) {
|
|
211
|
+
if (typeof body.archived !== 'boolean') return {error: 'Archived is yes or no.'};
|
|
212
|
+
patch.archived = body.archived;
|
|
213
|
+
}
|
|
205
214
|
if (!Object.keys(patch).length) return {error: 'Nothing to change.'};
|
|
206
215
|
return {patch};
|
|
207
216
|
}
|
|
@@ -12,13 +12,16 @@
|
|
|
12
12
|
* POST /tickets/:ref/messages write again
|
|
13
13
|
* POST /tickets/:ref/close mark resolved
|
|
14
14
|
* PUT /tickets/:ref/seen Domma's latest reply has been read
|
|
15
|
+
* PUT /tickets/:ref/shelf archive it, remove it from this site's list, or put it back
|
|
16
|
+
* ({state: archived|removed|null}); removing one that never
|
|
17
|
+
* reached Domma cancels it. Domma keeps its copy either way.
|
|
15
18
|
*
|
|
16
19
|
* manager (Site Manager is installed beside it)
|
|
17
20
|
* POST /fleet/tickets · POST /fleet/messages · GET /fleet/mine from the sites (Bearer fleet token)
|
|
18
21
|
* GET /mode · /meta · /count the badge counts requests waiting on Domma, red while one is urgent
|
|
19
22
|
* GET /tickets · GET /tickets/:id
|
|
20
23
|
* POST /tickets/:id/reply answer (and set the status), emailing the customer
|
|
21
|
-
* PUT /tickets/:id status, priority, internal notes
|
|
24
|
+
* PUT /tickets/:id status, priority, internal notes, archived
|
|
22
25
|
* DELETE /tickets/:id
|
|
23
26
|
*
|
|
24
27
|
* A standalone site (not started by a manager) still gets the contact card -
|
|
@@ -36,7 +39,7 @@ import {
|
|
|
36
39
|
CATEGORIES, PRIORITIES, STATUSES, buildMessagePayload, buildTicketPayload, cleanMessage, cleanRequest, cleanUpdate,
|
|
37
40
|
contactCard, createThrottle, lastDommaAt, senderView, toCustomerEmail, toDommaEmail
|
|
38
41
|
} from './lib/shape.js';
|
|
39
|
-
import {createOutbox, flush, managerApi, managerOf, mergeTickets, newRef} from './lib/sender.js';
|
|
42
|
+
import {applyShelf, createOutbox, flush, managerApi, managerOf, mergeTickets, newRef} from './lib/sender.js';
|
|
40
43
|
|
|
41
44
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
42
45
|
const NAME = 'support';
|
|
@@ -93,8 +96,14 @@ async function senderRoutes(fastify, {can, notify, settings, options}) {
|
|
|
93
96
|
const contact = () => outbox.contact() || contactCard(settings);
|
|
94
97
|
|
|
95
98
|
let lastSync = 0;
|
|
99
|
+
let syncing = null;
|
|
100
|
+
/** One sync at a time: an older view landing after a newer one would undo the newer one's shelf pruning. */
|
|
101
|
+
function sync() {
|
|
102
|
+
if (!syncing) syncing = syncOnce().finally(() => { syncing = null; });
|
|
103
|
+
return syncing;
|
|
104
|
+
}
|
|
96
105
|
/** Send what waits, then read the manager's view; tell admins about replies they have not heard of. */
|
|
97
|
-
async function
|
|
106
|
+
async function syncOnce() {
|
|
98
107
|
lastSync = Date.now();
|
|
99
108
|
const seen = outbox.seen();
|
|
100
109
|
if (!api) return {tickets: mergeTickets(outbox.all(), [], seen), reachable: false, error: ''};
|
|
@@ -136,9 +145,12 @@ async function senderRoutes(fastify, {can, notify, settings, options}) {
|
|
|
136
145
|
// over a minute old. A reply then shows within about a minute, not syncMinutes.
|
|
137
146
|
if (api && Date.now() - lastSync > 60_000) sync().catch(() => {});
|
|
138
147
|
const seen = outbox.seen();
|
|
148
|
+
const shelf = outbox.shelf();
|
|
149
|
+
// Put away, and Domma has not written since.
|
|
150
|
+
const shelved = (t) => Boolean(shelf[t.ref]) && (shelf[t.ref].dommaAt || '') === (t.dommaAt || '');
|
|
139
151
|
// From the last sync's copy: the badge must not call the manager every minute.
|
|
140
152
|
const unread = outbox.tickets().filter(t => t.dommaAt && seen[t.ref] !== t.dommaAt);
|
|
141
|
-
const openCount = outbox.tickets().filter(t => t.status !== 'closed').length;
|
|
153
|
+
const openCount = outbox.tickets().filter(t => t.status !== 'closed' && !shelved(t)).length;
|
|
142
154
|
const waiting = outbox.all().filter(i => i.state === 'waiting').length;
|
|
143
155
|
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
144
156
|
if (unread.length) {
|
|
@@ -162,7 +174,7 @@ async function senderRoutes(fastify, {can, notify, settings, options}) {
|
|
|
162
174
|
|
|
163
175
|
fastify.get('/tickets', can('send'), async () => {
|
|
164
176
|
const r = await withDeadline(sync(), () => offlineView('Domma is slow to answer - showing what we last heard.'));
|
|
165
|
-
return {connected: Boolean(manager), reachable: r.reachable, error: r.error, contact: contact(), tickets: r.tickets};
|
|
177
|
+
return {connected: Boolean(manager), reachable: r.reachable, error: r.error, contact: contact(), tickets: applyShelf(r.tickets, outbox.shelf())};
|
|
166
178
|
});
|
|
167
179
|
|
|
168
180
|
const notConnected = (reply) => reply.code(409).send({error: 'This site is not connected to Domma, so a request cannot be sent from here. Email us instead.'});
|
|
@@ -193,6 +205,8 @@ async function senderRoutes(fastify, {can, notify, settings, options}) {
|
|
|
193
205
|
}
|
|
194
206
|
const payload = buildMessagePayload({ticketRef, ref: newRef(), body, close, user: request.user});
|
|
195
207
|
outbox.add('message', payload, ticketRef);
|
|
208
|
+
// Writing to an archived request brings it back to the list.
|
|
209
|
+
if (body) outbox.shelve(ticketRef, null);
|
|
196
210
|
await withDeadline(flush(outbox, api), () => ({}));
|
|
197
211
|
const item = outbox.all().find(i => i.ref === payload.ref);
|
|
198
212
|
return reply.code(201).send({ref: payload.ref, state: item?.state || 'waiting',
|
|
@@ -205,6 +219,23 @@ async function senderRoutes(fastify, {can, notify, settings, options}) {
|
|
|
205
219
|
outbox.markSeen(String(request.params.ref), String(request.body?.at || ''));
|
|
206
220
|
return {ok: true};
|
|
207
221
|
});
|
|
222
|
+
|
|
223
|
+
// Works standalone too: a site's own list is its own business.
|
|
224
|
+
fastify.put('/tickets/:ref/shelf', can('send'), async (request, reply) => {
|
|
225
|
+
const ref = String(request.params.ref);
|
|
226
|
+
const state = request.body?.state ?? null;
|
|
227
|
+
if (![null, 'archived', 'removed'].includes(state)) return reply.code(400).send({error: 'Archive, remove, or put back.'});
|
|
228
|
+
const known = outbox.tickets().find(t => t.ref === ref);
|
|
229
|
+
const local = outbox.all().some(i => i.kind === 'ticket' && i.ref === ref);
|
|
230
|
+
if (!known && !local) return reply.code(404).send({error: 'No such request on this site.'});
|
|
231
|
+
// Never reached Domma: removing it cancels it, there is nothing for Domma to keep.
|
|
232
|
+
if (state === 'removed' && !known && outbox.discard(ref)) return {ok: true, state: 'discarded'};
|
|
233
|
+
// Measured against the reply the customer SAW (`at`), not this server's newer copy: a reply that
|
|
234
|
+
// arrived after their screen was drawn must not be put away unread with it.
|
|
235
|
+
const at = typeof request.body?.at === 'string' ? request.body.at.slice(0, 64) : null;
|
|
236
|
+
outbox.shelve(ref, state, at ?? (known?.dommaAt || ''));
|
|
237
|
+
return {ok: true, state};
|
|
238
|
+
});
|
|
208
239
|
}
|
|
209
240
|
|
|
210
241
|
// ---------------------------------------------------------------------------------
|
|
@@ -302,7 +333,8 @@ async function receiverRoutes(fastify, {can, notify, mail, receiverMod, settings
|
|
|
302
333
|
notifyEmail: settings.notifyEmail || ''}));
|
|
303
334
|
|
|
304
335
|
fastify.get('/count', can('manage'), async () => {
|
|
305
|
-
|
|
336
|
+
// The archive is out of sight: it counts for nothing (a customer writing again takes it out).
|
|
337
|
+
const all = (await rx.allTickets()).filter(e => !e.data?.archived);
|
|
306
338
|
const waiting = all.filter(e => e.data?.status === 'open');
|
|
307
339
|
const urgent = waiting.filter(e => e.data?.priority === 'urgent');
|
|
308
340
|
const dir = directory();
|