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.
@@ -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, threadHtml, when} from '../lib/kit.js';
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">&#8942;</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 filter = M.observable(store.get('support.filter') || 'open');
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, f.key === 'all' ? all.length : all.filter(t => t.status === f.key).length]));
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 => filter.value === 'all' || t.status === filter.value)
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. Replying emails the customer a link back to the conversation in their own admin, and moves it to Answered (or Resolved, if you choose). A customer writing again moves it back to Waiting on Domma. Urgent requests sort first and turn the sidebar badge red. Internal notes never reach the site."></span>
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">Nothing here.</p>
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
- const render = () => {
120
- const cur = byId(tid) || t;
121
- status.value = cur.status;
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(); render(); } catch (err) { E.toast(err.message || String(err), {type: 'error'}); } finally { busy.value = false; }
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: {draft, after, notes, priority, status, busy},
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 data-ref="status" class="sp-chip"></span></dd></div>
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"></div>
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 || !draft.value.trim()"><span data-icon="send" data-icon-size="13"></span> Send reply</button>
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-ref="urgent"> Urgent</label>
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
- const next = await api.reply(tid, {body: draft.peek(), status: after.peek()});
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(async () => { replace(await api.reply(tid, {status: 'closed'})); E.toast('Resolved.', {type: 'success'}); }),
182
- reopen: () => run(async () => { replace(await api.update(tid, {status: 'open'})); }),
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
- if (!await E.confirm(`Delete "${esc(t.subject)}"? It goes from ${esc(t.siteName)}'s admin too.`)) return;
186
- await run(async () => {
187
- await api.remove(tid);
188
- tickets.value = tickets.peek().filter(x => x.id !== tid);
189
- refreshBadge();
190
- panel?.close();
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
- const onClick = (e) => { const row = e.target.closest('.sp-row'); if (row) open(row.dataset.id); };
208
- const onKey = (e) => { const row = e.target.closest('.sp-row'); if (row && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); open(row.dataset.id); } };
209
- listEl.addEventListener('click', onClick);
210
- listEl.addEventListener('keydown', onKey);
211
- scope(() => { seg.removeEventListener('click', onFilter); listEl.removeEventListener('click', onClick); listEl.removeEventListener('keydown', onKey); });
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();
@@ -14,7 +14,7 @@
14
14
  import {api, loadKit} from '../lib/kit.js';
15
15
 
16
16
  const ROUTE = /^#\/plugins\/support(\/|$)/;
17
- const V = '1.0.0';
17
+ const V = '1.1.0';
18
18
  let lifetime = null;
19
19
 
20
20
  export const supportView = {
@@ -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 notes, from the inbox - with a line of history for what moved. */
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, and the contact card the manager last sent.
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 item of outbox.all().filter(i => i.state === 'waiting')) {
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 and internal notes. */
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 sync() {
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
- const all = await rx.allTickets();
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();