domma-cms 1.6.56 → 1.6.57
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/css/calculator.css +1 -1
- package/admin/js/lib/analytics-shape.js +1 -1
- package/admin/js/lib/calculator-engine.js +1 -1
- package/admin/js/lib/calculator.js +22 -10
- package/admin/js/lib/form-editor-arrange.js +1 -1
- package/admin/js/lib/forms-arrange.js +1 -1
- package/admin/js/lib/notes-arrange.js +1 -1
- package/admin/js/lib/search-settings-arrange.js +1 -1
- package/admin/js/lib/seo-arrange.js +1 -0
- package/admin/js/lib/todo-arrange.js +1 -1
- package/admin/js/lib/tool-kit.js +1 -1
- package/admin/js/lib/tool-recovery.js +4 -4
- package/admin/js/templates/analytics.html +26 -14
- package/admin/js/templates/contacts.html +26 -5
- package/admin/js/templates/form-editor.html +2 -0
- package/admin/js/templates/notes.html +11 -8
- package/admin/js/templates/search.html +9 -3
- package/admin/js/templates/seo.html +23 -17
- package/admin/js/templates/todo.html +18 -14
- package/admin/js/views/analytics.js +10 -10
- package/admin/js/views/contacts.js +47 -47
- package/admin/js/views/form-editor.js +7 -7
- package/admin/js/views/form-submissions.js +2 -2
- package/admin/js/views/forms.js +5 -5
- package/admin/js/views/notes.js +6 -6
- package/admin/js/views/search.js +2 -2
- package/admin/js/views/seo.js +43 -43
- package/admin/js/views/todo.js +8 -8
- package/package.json +1 -1
- package/plugins/_lib/admin/mail/compose-window.js +4 -1
- package/plugins/_lib/admin/mail/mail.css +1 -1
- package/plugins/_lib/admin/mail/reader-kit.js +364 -0
- package/plugins/_lib/admin/mail/reader-view.js +1009 -161
- package/plugins/_lib/admin/mail/when-closed.js +33 -3
- package/plugins/_lib/mail/readRoutes.js +48 -10
- package/plugins/_lib/mail/unreadBadge.js +4 -1
- package/plugins/blog/CLAUDE.md +14 -0
- package/plugins/blog/admin/css/index.css +5 -0
- package/plugins/blog/admin/lib/drafts.js +95 -0
- package/plugins/blog/admin/templates/blog.html +1 -1
- package/plugins/blog/admin/templates/categories.html +1 -1
- package/plugins/blog/admin/templates/comments.html +1 -1
- package/plugins/blog/admin/templates/post-editor.html +5 -1
- package/plugins/blog/admin/views/blog.js +44 -19
- package/plugins/blog/admin/views/categories.js +138 -20
- package/plugins/blog/admin/views/comments.js +32 -23
- package/plugins/blog/admin/views/kit.js +232 -22
- package/plugins/blog/admin/views/post-editor.js +348 -178
- package/plugins/blog/admin/views/settings.js +17 -3
- package/plugins/blog/plugin.js +1 -1
- package/plugins/blog/plugin.json +7 -7
- package/plugins/blog/plugin.public.js +1 -1
- package/plugins/blog/tests/drafts.test.js +50 -0
- package/plugins/booking/CLAUDE.md +13 -0
- package/plugins/booking/admin/lib/drafts.js +139 -0
- package/plugins/booking/admin/lib/kit.js +246 -25
- package/plugins/booking/admin/views/booking.js +11 -2
- package/plugins/booking/admin/views/bookings.js +7 -2
- package/plugins/booking/admin/views/diary.js +13 -6
- package/plugins/booking/admin/views/panels.js +85 -12
- package/plugins/booking/admin/views/people.js +41 -2
- package/plugins/booking/admin/views/services.js +45 -3
- package/plugins/booking/admin/views/settings.js +22 -2
- package/plugins/booking/plugin.json +2 -2
- package/plugins/booking/tests/admin-drafts.test.js +67 -0
- package/plugins/feedback/CLAUDE.md +12 -0
- package/plugins/feedback/admin/css/index.css +5 -0
- package/plugins/feedback/admin/lib/drafts.js +145 -0
- package/plugins/feedback/admin/lib/kit.js +161 -5
- package/plugins/feedback/admin/lib/settings.js +46 -28
- package/plugins/feedback/admin/views/feedback.js +1 -1
- package/plugins/feedback/admin/views/inbox.js +110 -46
- package/plugins/feedback/admin/views/send.js +174 -83
- package/plugins/feedback/plugin.json +3 -3
- package/plugins/feedback/tests/drafts.test.js +93 -0
- package/plugins/free-tier.lock.json +75 -63
- package/plugins/security/CLAUDE.md +9 -0
- package/plugins/security/admin/lib/screen-state.js +120 -0
- package/plugins/security/admin/templates/security.html +15 -10
- package/plugins/security/admin/views/security.js +270 -132
- package/plugins/security/plugin.json +2 -2
- package/plugins/security/tests/screen-state.test.js +69 -0
- package/plugins/shopping-cart/CLAUDE.md +13 -0
- package/plugins/shopping-cart/admin/css/index.css +21 -5
- package/plugins/shopping-cart/admin/lib/drafts.js +174 -0
- package/plugins/shopping-cart/admin/lib/kit.js +136 -28
- package/plugins/shopping-cart/admin/views/orders.js +117 -36
- package/plugins/shopping-cart/admin/views/overview.js +38 -8
- package/plugins/shopping-cart/admin/views/products.js +317 -68
- package/plugins/shopping-cart/admin/views/sales.js +21 -3
- package/plugins/shopping-cart/admin/views/settings.js +62 -3
- package/plugins/shopping-cart/admin/views/shop.js +18 -3
- package/plugins/shopping-cart/plugin.json +3 -3
- package/plugins/shopping-cart/tests/accounts.test.js +11 -0
- package/plugins/shopping-cart/tests/drafts.test.js +118 -0
- package/plugins/support/CLAUDE.md +14 -0
- package/plugins/support/admin/css/index.css +10 -0
- package/plugins/support/admin/lib/drafts.js +162 -0
- package/plugins/support/admin/lib/kit.js +156 -16
- package/plugins/support/admin/lib/settings.js +52 -29
- package/plugins/support/admin/views/desk.js +114 -33
- package/plugins/support/admin/views/inbox.js +85 -26
- package/plugins/support/admin/views/support.js +1 -1
- package/plugins/support/plugin.json +3 -3
- package/plugins/support/tests/drafts.test.js +92 -0
- package/server/routes/api/analytics.js +5 -1
- package/server/routes/api/contacts.js +42 -0
- package/server/routes/api/forms.js +8 -0
- package/server/routes/api/seo.js +4 -1
- package/server/routes/api/todo.js +50 -10
- package/server/server.js +1 -1
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kept drafts (admin/lib/drafts.js): what a panel keeps of unsaved typing, the
|
|
3
|
+
* question it is offered back with - OK always throws away, so Cancel, Escape
|
|
4
|
+
* and a backdrop click (all `false` from E.confirm) keep - and what a reply box
|
|
5
|
+
* holds once its message has gone.
|
|
6
|
+
*/
|
|
7
|
+
import {test} from 'node:test';
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import {
|
|
10
|
+
DRAFT_KEYS, RESTORE_CONFIRM, changedFields, afterSend, differs, draftFate, draftQuestion, draftText, draftValues, isDraft, makeDraft, newDraftQuestion,
|
|
11
|
+
settingsPrint, shortWhen, stableJson, ticketPrint
|
|
12
|
+
} from '../admin/lib/drafts.js';
|
|
13
|
+
|
|
14
|
+
test('stableJson ignores key order; differs compares by it', () => {
|
|
15
|
+
assert.equal(stableJson({b: 1, a: [1, {d: 2, c: 3}]}), stableJson({a: [1, {c: 3, d: 2}], b: 1}));
|
|
16
|
+
assert.equal(differs({text: 'a'}, {text: 'a'}), false);
|
|
17
|
+
assert.equal(differs({text: 'a'}, {text: ''}), true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('keys are one per panel and request - no shared map', () => {
|
|
21
|
+
assert.equal(DRAFT_KEYS.reply('ref-1'), 'support.draft.reply.ref-1');
|
|
22
|
+
assert.equal(DRAFT_KEYS.answer('e1'), 'support.draft.answer.e1');
|
|
23
|
+
assert.notEqual(DRAFT_KEYS.reply('x'), DRAFT_KEYS.answer('x'));
|
|
24
|
+
assert.notEqual(DRAFT_KEYS.compose, DRAFT_KEYS.settings);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('makeDraft / isDraft / draftValues', () => {
|
|
28
|
+
const d = makeDraft({subject: 'x', other: 'y', n: 5}, 'fp', '2026-10-15T10:00:00.000Z');
|
|
29
|
+
assert.deepEqual(d, {data: {subject: 'x', other: 'y', n: 5}, was: 'fp', at: '2026-10-15T10:00:00.000Z'});
|
|
30
|
+
assert.ok(isDraft(d));
|
|
31
|
+
for (const bad of [null, 'x', {}, {data: []}, {data: 'x'}]) assert.equal(isDraft(bad), false);
|
|
32
|
+
assert.deepEqual(draftValues(d, {subject: '', where: 'here', n: ''}), {subject: 'x', where: 'here', n: '5'});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('ticketPrint: someone writing moves it; reading or archiving does not', () => {
|
|
36
|
+
const t = {messages: [{id: 'm1', from: 'customer'}], status: 'open', notes: 'n', archived: false, unread: true};
|
|
37
|
+
assert.equal(ticketPrint(t), ticketPrint({...t, archived: true, unread: false, status: 'answered'}));
|
|
38
|
+
assert.notEqual(ticketPrint(t), ticketPrint({...t, messages: [...t.messages, {id: 'm2', from: 'domma'}]}));
|
|
39
|
+
// The inbox also counts its notes; the site never sees them.
|
|
40
|
+
assert.equal(ticketPrint(t), ticketPrint({...t, notes: 'changed'}));
|
|
41
|
+
assert.notEqual(ticketPrint(t, {notes: true}), ticketPrint({...t, notes: 'changed'}, {notes: true}));
|
|
42
|
+
assert.equal(ticketPrint(null), '');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('afterSend: what stays in the reply box once a message has gone', () => {
|
|
46
|
+
assert.equal(afterSend('Hello', 'Hello'), '', 'unchanged while sending: emptied');
|
|
47
|
+
assert.equal(afterSend('Hello\n\nand one more thing', 'Hello'), 'and one more thing', 'carried on at the end: only the new part stays');
|
|
48
|
+
assert.equal(afterSend('Hullo', 'Hello'), 'Hullo', 'the text itself changed: all of it stays, unsent');
|
|
49
|
+
assert.equal(afterSend('', 'Hello'), '');
|
|
50
|
+
assert.equal(afterSend(undefined, undefined), '');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('draftFate and draftQuestion: OK throws away; the text says what happened since', () => {
|
|
54
|
+
const d = makeDraft({text: 'Hi'}, 'A', '2026-10-15T10:00:00.000Z');
|
|
55
|
+
assert.equal(draftFate(d, null), 'deleted');
|
|
56
|
+
assert.equal(draftFate(d, 'B'), 'changed');
|
|
57
|
+
assert.equal(draftFate(d, 'A'), '');
|
|
58
|
+
assert.equal(draftFate({was: ''}, 'B'), '');
|
|
59
|
+
|
|
60
|
+
const plain = draftQuestion(d, 'A', {what: 'your reply to “Down”'});
|
|
61
|
+
assert.match(plain.text, /^Throw away the changes you left unsaved on your reply to “Down” \(15 Oct, \d\d:00\)\? Keeping them puts them back in the form\.$/);
|
|
62
|
+
assert.deepEqual(plain.options, RESTORE_CONFIRM);
|
|
63
|
+
assert.equal(plain.options.confirmText, 'Throw away');
|
|
64
|
+
assert.equal(plain.options.cancelText, 'Keep them');
|
|
65
|
+
|
|
66
|
+
const changed = draftQuestion(d, 'B', {what: 'x', changed: 'Domma has written since', changedAt: '2026-10-15T11:30:00.000Z'});
|
|
67
|
+
assert.match(changed.text, /Domma has written since \(15 Oct, \d\d:30\) - keeping them puts your changes over that\.$/);
|
|
68
|
+
assert.match(draftQuestion(d, 'B', {what: 'x'}).text, /It has been changed since - keeping/);
|
|
69
|
+
|
|
70
|
+
const gone = draftQuestion(d, null, {what: 'a request'});
|
|
71
|
+
assert.equal(gone.fate, 'deleted');
|
|
72
|
+
assert.equal(gone.options.confirmText, 'Throw away');
|
|
73
|
+
assert.equal(gone.options.cancelText, 'Copy them');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('newDraftQuestion, settingsPrint and draftText', () => {
|
|
77
|
+
const q = newDraftQuestion(makeDraft({subject: 't'}, '', '2026-10-15T09:05:00.000Z'));
|
|
78
|
+
assert.match(q.text, /^Throw away the request you left unsent \(15 Oct, \d\d:05\)\? Keeping it puts it back in the form\.$/);
|
|
79
|
+
assert.equal(q.options.confirmText, 'Throw away');
|
|
80
|
+
assert.equal(settingsPrint({b: '2', a: '1'}), settingsPrint({a: '1', b: '2'}));
|
|
81
|
+
assert.equal(draftText(makeDraft({reply: 'Thanks', notes: ''})), 'reply:\nThanks');
|
|
82
|
+
assert.equal(shortWhen('nope'), '');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('changedFields keeps only what was edited, so a picked-up draft leaves the rest as the record has it now', () => {
|
|
86
|
+
const saved = {status: 'acknowledged', reply: '', notes: ''};
|
|
87
|
+
const kept = changedFields({status: 'acknowledged', reply: 'Thanks', notes: ''}, saved);
|
|
88
|
+
assert.deepEqual(kept, {reply: 'Thanks'});
|
|
89
|
+
// Later, someone else set the status and wrote notes: the draft puts back only the reply.
|
|
90
|
+
assert.deepEqual(draftValues(makeDraft(kept), {status: 'planned', reply: '', notes: 'theirs'}), {status: 'planned', reply: 'Thanks', notes: 'theirs'});
|
|
91
|
+
assert.deepEqual(changedFields({a: '1'}, {a: '1'}), {});
|
|
92
|
+
});
|
|
@@ -66,11 +66,15 @@ const REALTIME_WINDOW_MS = 5 * 60 * 1000;
|
|
|
66
66
|
* for ever). Prototype names are refused explicitly as well - the '/' rule
|
|
67
67
|
* already excludes them, but `lifetime["constructor"] + 1` turning every total
|
|
68
68
|
* into a string is exactly the bug a relaxed rule must not bring back.
|
|
69
|
+
* A path the browser would resolve off the site is refused too: '//host/x' is
|
|
70
|
+
* protocol-relative, and browsers read '\' as '/', so '/\host' is the same.
|
|
71
|
+
* The admin opens these as links.
|
|
69
72
|
*/
|
|
70
73
|
const URL_MAX = 2048;
|
|
71
74
|
const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
72
75
|
function isBeaconPath(url) {
|
|
73
|
-
return typeof url === 'string' && url.length <= URL_MAX && url.startsWith('/') &&
|
|
76
|
+
return typeof url === 'string' && url.length <= URL_MAX && url.startsWith('/') && !url.startsWith('//')
|
|
77
|
+
&& !url.includes('\\') && !/\s/.test(url);
|
|
74
78
|
}
|
|
75
79
|
function isSafeKey(key) {
|
|
76
80
|
return !UNSAFE_KEYS.has(key) && !UNSAFE_KEYS.has(key.replace(/^\/+/, ''));
|
|
@@ -35,6 +35,7 @@ import {createEntry, deleteEntry, getEntry, listEntries, updateEntry} from '../.
|
|
|
35
35
|
import {hooks} from '../../services/hooks.js';
|
|
36
36
|
import {getPermissionsFor} from '../../services/roles.js';
|
|
37
37
|
import {getEffectiveRoles} from '../../services/userRoles.js';
|
|
38
|
+
import {ago, clip, plural} from '../../services/sidebarBadges.js';
|
|
38
39
|
|
|
39
40
|
/**
|
|
40
41
|
* The permission that lets someone rename or delete a group they did not
|
|
@@ -238,6 +239,41 @@ function toGroupName(entry) {
|
|
|
238
239
|
return entry.data.name ?? '';
|
|
239
240
|
}
|
|
240
241
|
|
|
242
|
+
/**
|
|
243
|
+
* The sidebar badge: how many contacts this user has - an inventory, so it is
|
|
244
|
+
* left out of a closed folder's total. The popover gives the starred and the
|
|
245
|
+
* groups, and the four most recently changed. Plain text throughout.
|
|
246
|
+
*
|
|
247
|
+
* @param {object[]} contacts - the caller's own, as toContact() shapes them
|
|
248
|
+
* @param {number} groupCount - every group (groups are shared)
|
|
249
|
+
* @param {number} [now]
|
|
250
|
+
* @returns {object}
|
|
251
|
+
*/
|
|
252
|
+
export function contactsBadge(contacts, groupCount = 0, now = Date.now()) {
|
|
253
|
+
const list = Array.isArray(contacts) ? contacts : [];
|
|
254
|
+
const stamp = c => String(c.updatedAt || c.createdAt || '');
|
|
255
|
+
const recent = [...list].sort((a, b) => stamp(b).localeCompare(stamp(a))).slice(0, 4);
|
|
256
|
+
return {
|
|
257
|
+
count: list.length,
|
|
258
|
+
label: plural(list.length, 'contact'),
|
|
259
|
+
tone: 'info',
|
|
260
|
+
inventory: true,
|
|
261
|
+
details: {
|
|
262
|
+
title: 'Contacts',
|
|
263
|
+
rows: [
|
|
264
|
+
{label: 'Contacts', value: String(list.length)},
|
|
265
|
+
{label: 'Starred', value: String(list.filter(c => c.favourite === true).length)},
|
|
266
|
+
{label: 'Groups', value: String(groupCount)}
|
|
267
|
+
],
|
|
268
|
+
items: recent.map(c => ({
|
|
269
|
+
text: clip(c.name || 'Unnamed'),
|
|
270
|
+
meta: stamp(c) ? `changed ${ago(stamp(c), now)}` : ''
|
|
271
|
+
})),
|
|
272
|
+
empty: 'No contacts yet.'
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
241
277
|
/**
|
|
242
278
|
* Auth is injectable, the shape dashboard.js and plugins.js use.
|
|
243
279
|
*
|
|
@@ -308,6 +344,12 @@ export async function contactsRoutes(fastify, opts = {}) {
|
|
|
308
344
|
// Contacts CRUD
|
|
309
345
|
// -------------------------------------------------------------------------
|
|
310
346
|
|
|
347
|
+
/** GET /api/contacts/count - the sidebar badge (contactsBadge). */
|
|
348
|
+
fastify.get('/count', {preHandler: [authenticate, requirePermission('contacts', 'read')]}, async (request) => {
|
|
349
|
+
const [contacts, groups] = await Promise.all([loadContacts(userId(request)), loadGroups()]);
|
|
350
|
+
return contactsBadge(contacts, groups.length);
|
|
351
|
+
});
|
|
352
|
+
|
|
311
353
|
/** GET /api/contacts/contacts - supports ?q=, ?group=, ?favourites=true */
|
|
312
354
|
fastify.get('/contacts', {preHandler: [authenticate, requirePermission('contacts', 'read')]}, async (request, reply) => {
|
|
313
355
|
const uid = userId(request);
|
|
@@ -329,6 +329,8 @@ async function cachedStats(form, now) {
|
|
|
329
329
|
statsCache.set(form.slug, {at: now, value});
|
|
330
330
|
return value;
|
|
331
331
|
}
|
|
332
|
+
/** A form's submissions changed (sent, deleted, cleared) or the form went: the next badge reads afresh. */
|
|
333
|
+
const forgetStats = (slug) => { statsCache.delete(slug); };
|
|
332
334
|
|
|
333
335
|
export function formsBadge(forms = [], now = Date.now()) {
|
|
334
336
|
const week = forms.reduce((n, f) => n + (f.stats?.week || 0), 0);
|
|
@@ -376,6 +378,8 @@ export async function formsRoutes(fastify) {
|
|
|
376
378
|
const result = await Promise.all(forms.map(async form => {
|
|
377
379
|
// Counted where the submissions screen reads them (submissionsCollectionFor).
|
|
378
380
|
const stats = await submissionStats(form, now);
|
|
381
|
+
// The list has just counted them: the badge, asked straight after, agrees with it.
|
|
382
|
+
statsCache.set(form.slug, {at: now, value: stats});
|
|
379
383
|
// listForms returns metadata only; readForm returns full record with meta
|
|
380
384
|
let full = null;
|
|
381
385
|
try { full = await readForm(form.slug); } catch { /* skip */ }
|
|
@@ -556,6 +560,7 @@ export async function formsRoutes(fastify) {
|
|
|
556
560
|
} catch {
|
|
557
561
|
return reply.status(404).send({ error: 'Form not found.' });
|
|
558
562
|
}
|
|
563
|
+
forgetStats(slug);
|
|
559
564
|
return { ok: true };
|
|
560
565
|
});
|
|
561
566
|
|
|
@@ -666,6 +671,7 @@ export async function formsRoutes(fastify) {
|
|
|
666
671
|
} catch {
|
|
667
672
|
return reply.status(404).send({ error: 'Collection not found for this form.' });
|
|
668
673
|
}
|
|
674
|
+
forgetStats(slug);
|
|
669
675
|
return { ok: true };
|
|
670
676
|
});
|
|
671
677
|
|
|
@@ -690,6 +696,7 @@ export async function formsRoutes(fastify) {
|
|
|
690
696
|
} catch {
|
|
691
697
|
return reply.status(404).send({ error: 'Submission not found.' });
|
|
692
698
|
}
|
|
699
|
+
forgetStats(slug);
|
|
693
700
|
return { ok: true };
|
|
694
701
|
});
|
|
695
702
|
|
|
@@ -905,6 +912,7 @@ export async function formsRoutes(fastify) {
|
|
|
905
912
|
excused,
|
|
906
913
|
outcome: ended ? {type: 'ended', trigger: ended.key, field: ended.field} : null
|
|
907
914
|
});
|
|
915
|
+
forgetStats(slug);
|
|
908
916
|
// What a visitor sent is theirs, not the site's content: keep it out of the site repo.
|
|
909
917
|
try { keepSubmissionsOut(ROOT, targetSlug); } catch { /* never fail a submission over this */ }
|
|
910
918
|
} else {
|
package/server/routes/api/seo.js
CHANGED
|
@@ -41,7 +41,10 @@ export async function seoRoutes(fastify, opts = {}) {
|
|
|
41
41
|
|
|
42
42
|
fastify.get('/count', may('read'), async () => {
|
|
43
43
|
const s = seo().summary();
|
|
44
|
-
if (!s.at)
|
|
44
|
+
if (!s.at) {
|
|
45
|
+
return {count: 0, label: 'Not checked yet',
|
|
46
|
+
details: {title: 'SEO', rows: [], items: [], empty: 'Not checked yet - open SEO to check every page.'}};
|
|
47
|
+
}
|
|
45
48
|
const bad = (seo().store.audit().results || []).filter(r => r.error || r.failing > 0);
|
|
46
49
|
return {
|
|
47
50
|
count: bad.length,
|
|
@@ -23,6 +23,41 @@ import {getPermissionsFor} from '../../services/roles.js';
|
|
|
23
23
|
|
|
24
24
|
const SLUG = 'todo-items';
|
|
25
25
|
|
|
26
|
+
/** What a to-do may hold - the admin view's own lists (admin/js/lib/todo-arrange.js). */
|
|
27
|
+
export const TODO_STATUSES = ['not-started', 'in-progress', 'completed'];
|
|
28
|
+
export const TODO_PRIORITIES = ['highest', 'high', 'medium', 'low', 'lowest'];
|
|
29
|
+
|
|
30
|
+
/** Most urgent first; an unknown priority sits with medium. */
|
|
31
|
+
const priorityRank = (p) => { const i = TODO_PRIORITIES.indexOf(p); return i === -1 ? 2 : i; };
|
|
32
|
+
const isHigh = (p) => p === 'highest' || p === 'high';
|
|
33
|
+
|
|
34
|
+
/** A status or priority of null or '' was not given. */
|
|
35
|
+
const blank = (v) => v === null || v === undefined || v === '';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Check the fields a create or update carries. Only what is present is
|
|
39
|
+
* checked, and a null or '' status or priority counts as absent. A CHANGE to
|
|
40
|
+
* something outside the lists is refused rather than stored, since a stray
|
|
41
|
+
* status or priority falls out of every filter and sort - but a value equal to
|
|
42
|
+
* what the task already holds is let through: todo-items keeps these as free
|
|
43
|
+
* text, so a legacy or API-made task may hold 'urgent' or 'High', and the
|
|
44
|
+
* editor sends it back untouched with any edit.
|
|
45
|
+
*
|
|
46
|
+
* @param {object} body
|
|
47
|
+
* @param {object} [stored] - the task's current data, on an update
|
|
48
|
+
* @returns {string|null} the problem, or null
|
|
49
|
+
*/
|
|
50
|
+
export function todoFieldError(body, stored = {}) {
|
|
51
|
+
if (!body || typeof body !== 'object') return null;
|
|
52
|
+
const same = (key) => key in body && stored && body[key] === stored[key];
|
|
53
|
+
if (!blank(body.status) && !same('status') && !TODO_STATUSES.includes(body.status)) return `status must be one of ${TODO_STATUSES.join(', ')}`;
|
|
54
|
+
if (!blank(body.priority) && !same('priority') && !TODO_PRIORITIES.includes(body.priority)) return `priority must be one of ${TODO_PRIORITIES.join(', ')}`;
|
|
55
|
+
if ('dueAt' in body && !blank(body.dueAt) && !same('dueAt')
|
|
56
|
+
&& !(typeof body.dueAt === 'string' && /^\d{4}-\d{2}-\d{2}/.test(body.dueAt))) return 'dueAt must be a YYYY-MM-DD day or null';
|
|
57
|
+
if ('text' in body && !same('text') && (typeof body.text !== 'string' || !body.text.trim())) return 'text cannot be empty';
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
26
61
|
/** 'YYYY-MM-DD' of a date, in the server's local time. */
|
|
27
62
|
function localDay(d) {
|
|
28
63
|
const pad = (n) => String(n).padStart(2, '0');
|
|
@@ -65,7 +100,7 @@ export async function todoCalendarItems({from, to, user}) {
|
|
|
65
100
|
endAt: `${addDays(day, 1)}T00:00:00.000Z`,
|
|
66
101
|
allDay: true,
|
|
67
102
|
url: '#/todo',
|
|
68
|
-
meta: t.priority
|
|
103
|
+
meta: isHigh(t.priority) ? `${t.priority} priority` : ''
|
|
69
104
|
}));
|
|
70
105
|
}
|
|
71
106
|
|
|
@@ -124,17 +159,16 @@ export async function todoRoutes(fastify, opts = {}) {
|
|
|
124
159
|
const daysBetween = (a, b) => Math.round((Date.parse(a) - Date.parse(b)) / 86_400_000);
|
|
125
160
|
const overdue = open.filter(t => dayOf(t) && dayOf(t) < today);
|
|
126
161
|
const dueToday = open.filter(t => dayOf(t) === today);
|
|
127
|
-
const PRIORITY = {high: 0, medium: 1, low: 2};
|
|
128
162
|
|
|
129
163
|
// Most urgent first: overdue (oldest first), due today, then by due day, then priority.
|
|
130
164
|
const urgent = [...open].sort((a, b) =>
|
|
131
165
|
(dayOf(a) ?? '9999') .localeCompare(dayOf(b) ?? '9999')
|
|
132
|
-
|| (
|
|
166
|
+
|| priorityRank(a.priority) - priorityRank(b.priority));
|
|
133
167
|
const items = urgent.slice(0, 5).map(t => {
|
|
134
168
|
const day = dayOf(t);
|
|
135
169
|
const d = day ? daysBetween(day, today) : null;
|
|
136
170
|
const when = d === null ? '' : d < 0 ? `${plural(-d, 'day')} overdue` : d === 0 ? 'due today' : d === 1 ? 'due tomorrow' : `due in ${d} days`;
|
|
137
|
-
const meta = [when, t.priority
|
|
171
|
+
const meta = [when, isHigh(t.priority) ? `${t.priority} priority` : ''].filter(Boolean).join(' · ');
|
|
138
172
|
return {text: clip(t.text), meta, tone: d !== null && d < 0 ? 'danger' : undefined};
|
|
139
173
|
});
|
|
140
174
|
|
|
@@ -178,12 +212,15 @@ export async function todoRoutes(fastify, opts = {}) {
|
|
|
178
212
|
return reply.code(400).send({ error: 'text is required' });
|
|
179
213
|
}
|
|
180
214
|
|
|
215
|
+
const problem = todoFieldError(request.body);
|
|
216
|
+
if (problem) return reply.code(400).send({error: problem});
|
|
217
|
+
|
|
181
218
|
const {dueAt} = request.body ?? {};
|
|
182
219
|
const entry = await createEntry(SLUG, {
|
|
183
220
|
text: text.trim(),
|
|
184
|
-
status: status
|
|
185
|
-
priority: priority
|
|
186
|
-
dueAt: dueAt
|
|
221
|
+
status: blank(status) ? 'not-started' : status,
|
|
222
|
+
priority: blank(priority) ? 'medium' : priority,
|
|
223
|
+
dueAt: dueAt || null,
|
|
187
224
|
userId: uid ?? null
|
|
188
225
|
}, {createdBy: uid, source: 'admin'});
|
|
189
226
|
|
|
@@ -194,15 +231,18 @@ export async function todoRoutes(fastify, opts = {}) {
|
|
|
194
231
|
fastify.put('/items/:id', {preHandler: [authenticate, requirePermission('todo', 'update')]}, async (request, reply) => {
|
|
195
232
|
const uid = userId(request);
|
|
196
233
|
const { id } = request.params;
|
|
197
|
-
const patch = request.body
|
|
198
|
-
|
|
234
|
+
const patch = request.body && typeof request.body === 'object' ? request.body : {};
|
|
199
235
|
const entry = await getEntry(SLUG, id);
|
|
200
236
|
if (!entry) return reply.code(404).send({error: 'Todo not found'});
|
|
201
237
|
if (uid && entry.data.userId !== uid) return reply.code(404).send({error: 'Todo not found'});
|
|
202
238
|
|
|
239
|
+
const problem = todoFieldError(patch, entry.data);
|
|
240
|
+
if (problem) return reply.code(400).send({error: problem});
|
|
241
|
+
|
|
203
242
|
const merged = {...entry.data};
|
|
204
243
|
for (const key of ['text', 'status', 'priority', 'dueAt']) {
|
|
205
|
-
if (key in patch)
|
|
244
|
+
if (!(key in patch) || (blank(patch[key]) && key !== 'dueAt')) continue;
|
|
245
|
+
merged[key] = key === 'text' ? String(patch[key]).trim() : key === 'dueAt' ? (patch.dueAt || null) : patch[key];
|
|
206
246
|
}
|
|
207
247
|
|
|
208
248
|
const updated = await updateEntry(SLUG, id, merged);
|
package/server/server.js
CHANGED
|
@@ -637,7 +637,7 @@ const coreToolItems = [
|
|
|
637
637
|
{text: 'Todo', url: '#/todo', icon: 'check-square', permission: 'todo', countUrl: '/api/todo/count'},
|
|
638
638
|
{text: 'Calculator', url: '#/calculator', icon: 'calculator', permission: 'calculator'},
|
|
639
639
|
{text: 'Analytics', url: '#/analytics', icon: 'chart-bar', permission: 'analytics', countUrl: '/api/analytics/active-count'},
|
|
640
|
-
{text: 'Contacts', url: '#/contacts', icon: 'users', permission: 'contacts'},
|
|
640
|
+
{text: 'Contacts', url: '#/contacts', icon: 'users', permission: 'contacts', countUrl: '/api/contacts/count', inventory: true},
|
|
641
641
|
{text: 'SEO', url: '#/seo', icon: 'search', permission: 'seo', countUrl: '/api/seo/count'}
|
|
642
642
|
];
|
|
643
643
|
for (const item of coreToolItems) {
|