domma-cms 0.66.0 → 0.69.4
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/CLAUDE.md +64 -1
- package/README.md +14 -10
- package/admin/css/admin.css +1 -1
- package/admin/index.html +1 -0
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +4 -4
- package/admin/js/lib/analytics-shape.js +1 -0
- package/admin/js/lib/contacts-arrange.js +1 -0
- package/admin/js/lib/help-popover.js +1 -0
- package/admin/js/lib/notes-arrange.js +1 -0
- package/admin/js/lib/plugin-accent.js +1 -1
- package/admin/js/lib/plugin-chrome.js +1 -1
- package/admin/js/lib/sidebar-grouping.js +1 -1
- package/admin/js/lib/sidebar-grouping.test.js +1 -1
- package/admin/js/lib/sidebar-renderer.js +3 -3
- package/admin/js/lib/todo-arrange.js +1 -0
- package/admin/js/lib/tool-kit.js +1 -0
- package/admin/js/templates/analytics.html +138 -0
- package/admin/js/templates/contacts.html +156 -0
- package/admin/js/templates/notes.html +137 -0
- package/admin/js/templates/plugin-marketplace.html +17 -4
- package/admin/js/templates/plugins.html +13 -7
- package/admin/js/templates/theme.html +38 -0
- package/admin/js/templates/todo.html +113 -0
- package/admin/js/views/analytics.js +62 -0
- package/admin/js/views/contacts.js +174 -0
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/notes.js +104 -0
- package/admin/js/views/plugin-marketplace.js +1 -1
- package/admin/js/views/plugins.js +24 -22
- package/admin/js/views/theme.js +1 -1
- package/admin/js/views/todo.js +96 -0
- package/bin/cli.js +4 -10
- package/bin/lib/plugin-version.js +28 -0
- package/bin/update.js +14 -0
- package/config/menus/admin-sidebar.json +1 -1
- package/config/server.json +3 -1
- package/package.json +3 -7
- package/plugins/_lib/admin/mail/reader-view.js +20 -0
- package/plugins/_lib/mail/store.js +21 -1
- package/plugins/blog/CLAUDE.md +237 -0
- package/plugins/blog/admin/css/index.css +178 -1
- package/plugins/blog/admin/templates/blog.html +35 -53
- package/plugins/blog/admin/templates/categories.html +19 -6
- package/plugins/blog/admin/templates/comments.html +26 -9
- package/plugins/blog/admin/templates/post-editor.html +36 -13
- package/plugins/blog/admin/templates/settings.html +2 -11
- package/plugins/blog/admin/views/blog.js +288 -162
- package/plugins/blog/admin/views/categories.js +212 -201
- package/plugins/blog/admin/views/comments.js +207 -158
- package/plugins/blog/admin/views/kit.js +372 -0
- package/plugins/blog/admin/views/post-editor.js +269 -21
- package/plugins/blog/admin/views/settings-cog.js +56 -0
- package/plugins/blog/admin/views/settings.js +16 -86
- package/plugins/blog/blocks/blog-card-compact.css +17 -0
- package/plugins/blog/blocks/blog-card-compact.html +8 -0
- package/plugins/blog/blocks/blog-card-row.css +22 -0
- package/plugins/blog/blocks/blog-card-row.html +11 -0
- package/plugins/blog/blocks/blog-card.css +35 -0
- package/plugins/blog/blocks/blog-card.html +16 -0
- package/plugins/blog/blocks/blog-post-classic.css +15 -0
- package/plugins/blog/blocks/blog-post-classic.html +28 -0
- package/plugins/blog/blocks/blog-post-feature.css +20 -0
- package/plugins/blog/blocks/blog-post-feature.html +30 -0
- package/plugins/blog/blocks/blog-post-minimal.css +10 -0
- package/plugins/blog/blocks/blog-post-minimal.html +23 -0
- package/plugins/blog/blocks/blog-post-sidebar.css +22 -0
- package/plugins/blog/blocks/blog-post-sidebar.html +36 -0
- package/plugins/blog/collections/posts/schema.json +3 -1
- package/plugins/blog/config.js +6 -0
- package/plugins/blog/lib/layouts.js +304 -0
- package/plugins/blog/lib/page.js +158 -0
- package/plugins/blog/lib/render.js +119 -0
- package/plugins/blog/lib/samples.js +234 -0
- package/plugins/blog/plugin.js +225 -15
- package/plugins/blog/plugin.json +21 -81
- package/plugins/blog/plugin.public.js +115 -210
- package/plugins/blog/public/blog.css +177 -0
- package/plugins/blog/public/blog.js +586 -0
- package/plugins/blog/public/samples/aurora.svg +7 -0
- package/plugins/blog/public/samples/dusk.svg +7 -0
- package/plugins/blog/public/samples/ember.svg +7 -0
- package/plugins/blog/public/samples/harbour.svg +7 -0
- package/plugins/blog/public/samples/meadow.svg +7 -0
- package/plugins/blog/public/samples/rosewood.svg +7 -0
- package/plugins/blog/templates/index.html +9 -8
- package/plugins/blog/templates/post.html +4 -17
- package/plugins/blog/tests/layouts.test.js +97 -0
- package/plugins/blog/tests/public.test.js +71 -0
- package/plugins/free-tier.lock.json +74 -0
- package/plugins/mail-reader/CLAUDE.md +78 -0
- package/plugins/mail-reader/plugin.json +2 -1
- package/public/css/theme-switcher.css +1 -0
- package/public/js/analytics.js +1 -0
- package/public/js/theme-switcher.js +1 -0
- package/scripts/build.js +41 -2
- package/scripts/run-tests.mjs +94 -0
- package/{plugins/analytics/plugin.js → server/routes/api/analytics.js} +89 -41
- package/{plugins/contacts/plugin.js → server/routes/api/contacts.js} +71 -43
- package/server/routes/api/dashboard.js +8 -2
- package/{plugins/notes/plugin.js → server/routes/api/notes.js} +61 -17
- package/server/routes/api/plugin-marketplace.js +78 -23
- package/server/routes/api/plugins.js +148 -3
- package/server/routes/api/todo.js +178 -0
- package/server/server.js +61 -1
- package/server/services/collections.js +13 -3
- package/server/services/managerClient.js +72 -136
- package/server/services/markdown.js +54 -1
- package/server/services/permissionRegistry.js +50 -0
- package/server/services/pluginEntitlement.js +171 -0
- package/server/services/pluginEnvelope.js +242 -0
- package/server/services/pluginInstaller.js +246 -26
- package/server/services/pluginScaffold.js +5 -0
- package/server/services/plugins.js +117 -7
- package/server/services/presetCollections.js +344 -0
- package/server/services/promoted-plugins-migration.js +254 -0
- package/server/services/publicCors.js +46 -0
- package/server/services/renderer.js +83 -3
- package/server/services/sidebarBadges.js +37 -0
- package/server/services/siteGitignore.js +310 -0
- package/server/services/themeSettings.js +39 -0
- package/config/theme.json +0 -18
- package/plugins/analytics/admin/css/index.css +0 -1
- package/plugins/analytics/admin/templates/analytics.html +0 -70
- package/plugins/analytics/admin/views/analytics.js +0 -176
- package/plugins/analytics/config.js +0 -14
- package/plugins/analytics/plugin.json +0 -60
- package/plugins/analytics/public/inject-body.html +0 -60
- package/plugins/analytics/public/inject-head.html +0 -1
- package/plugins/blog/templates/author.html +0 -9
- package/plugins/blog/templates/category.html +0 -9
- package/plugins/blog/templates/tag.html +0 -9
- package/plugins/contacts/admin/templates/contacts.html +0 -254
- package/plugins/contacts/admin/views/contacts.js +0 -943
- package/plugins/contacts/collections/user-contact-groups/schema.json +0 -35
- package/plugins/contacts/collections/user-contacts/schema.json +0 -179
- package/plugins/contacts/config.js +0 -6
- package/plugins/contacts/plugin.json +0 -36
- package/plugins/demo-viewer/config.js +0 -4
- package/plugins/demo-viewer/plugin.js +0 -36
- package/plugins/demo-viewer/plugin.json +0 -9
- package/plugins/invoice/admin/css/index.css +0 -1
- package/plugins/invoice/admin/templates/editor.html +0 -220
- package/plugins/invoice/admin/templates/index.html +0 -173
- package/plugins/invoice/admin/templates/issuers.html +0 -2
- package/plugins/invoice/admin/templates/receivers.html +0 -2
- package/plugins/invoice/admin/views/contacts-source.js +0 -266
- package/plugins/invoice/admin/views/editor.js +0 -434
- package/plugins/invoice/admin/views/export.js +0 -199
- package/plugins/invoice/admin/views/help-content.js +0 -61
- package/plugins/invoice/admin/views/index.js +0 -643
- package/plugins/invoice/admin/views/issuers.js +0 -30
- package/plugins/invoice/admin/views/media.js +0 -172
- package/plugins/invoice/admin/views/party-view.js +0 -386
- package/plugins/invoice/admin/views/payments.js +0 -127
- package/plugins/invoice/admin/views/print.js +0 -130
- package/plugins/invoice/admin/views/receivers.js +0 -55
- package/plugins/invoice/admin/views/send.js +0 -212
- package/plugins/invoice/admin/views/settings.js +0 -594
- package/plugins/invoice/admin/views/view-lifecycle.js +0 -33
- package/plugins/invoice/collections/invoice-issuers/schema.json +0 -82
- package/plugins/invoice/collections/invoice-receivers/schema.json +0 -16
- package/plugins/invoice/collections/invoices/schema.json +0 -33
- package/plugins/invoice/config.js +0 -37
- package/plugins/invoice/pdf.js +0 -164
- package/plugins/invoice/plugin.js +0 -1456
- package/plugins/invoice/plugin.json +0 -86
- package/plugins/invoice/templates/_base.css +0 -1
- package/plugins/invoice/templates/classic-nologo.html +0 -100
- package/plugins/invoice/templates/classic.html +0 -91
- package/plugins/invoice/templates/invoice-print.html +0 -237
- package/plugins/invoice/templates/minimal.html +0 -99
- package/plugins/invoice/templates/modern-nologo.html +0 -114
- package/plugins/invoice/templates/modern.html +0 -113
- package/plugins/invoice/templates/templates.json +0 -11
- package/plugins/notes/admin/templates/notes.html +0 -83
- package/plugins/notes/admin/views/notes.js +0 -281
- package/plugins/notes/collections/user-notes/schema.json +0 -54
- package/plugins/notes/config.js +0 -6
- package/plugins/notes/plugin.json +0 -33
- package/plugins/surveys/admin/templates/audience.html +0 -47
- package/plugins/surveys/admin/templates/results.html +0 -58
- package/plugins/surveys/admin/templates/survey-editor.html +0 -141
- package/plugins/surveys/admin/templates/surveys.html +0 -25
- package/plugins/surveys/admin/views/audience.js +0 -301
- package/plugins/surveys/admin/views/results.js +0 -172
- package/plugins/surveys/admin/views/survey-editor.js +0 -211
- package/plugins/surveys/admin/views/surveys.js +0 -161
- package/plugins/surveys/collections/survey-contacts/schema.json +0 -13
- package/plugins/surveys/collections/survey-groups/schema.json +0 -11
- package/plugins/surveys/collections/survey-invites/schema.json +0 -16
- package/plugins/surveys/collections/surveys/schema.json +0 -23
- package/plugins/surveys/config.js +0 -8
- package/plugins/surveys/lib/audience.js +0 -37
- package/plugins/surveys/lib/campaigns.js +0 -43
- package/plugins/surveys/lib/ledger.js +0 -110
- package/plugins/surveys/lib/sending.js +0 -106
- package/plugins/surveys/lib/stats.js +0 -62
- package/plugins/surveys/lib/submit.js +0 -95
- package/plugins/surveys/lib/tokens.js +0 -28
- package/plugins/surveys/plugin.js +0 -174
- package/plugins/surveys/plugin.json +0 -65
- package/plugins/surveys/plugin.public.js +0 -149
- package/plugins/surveys/public/dist-shared.mjs +0 -1
- package/plugins/surveys/public/survey.css +0 -1
- package/plugins/surveys/public/survey.mjs +0 -1
- package/plugins/surveys/templates/survey-page.html +0 -63
- package/plugins/theme-switcher/admin/templates/theme-switcher.html +0 -86
- package/plugins/theme-switcher/admin/views/theme-switcher.js +0 -65
- package/plugins/theme-switcher/config.js +0 -10
- package/plugins/theme-switcher/plugin.js +0 -26
- package/plugins/theme-switcher/plugin.json +0 -74
- package/plugins/theme-switcher/public/inject-body.html +0 -153
- package/plugins/theme-switcher/public/inject-head.html +0 -260
- package/plugins/todo/admin/templates/todo.html +0 -158
- package/plugins/todo/admin/views/todo.js +0 -343
- package/plugins/todo/collections/todos/schema.json +0 -60
- package/plugins/todo/config.js +0 -7
- package/plugins/todo/plugin.js +0 -102
- package/plugins/todo/plugin.json +0 -33
- /package/{plugins/contacts/admin/css/index.css → admin/css/contacts.css} +0 -0
|
@@ -1,35 +1,60 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Analytics
|
|
3
|
-
* Tracks page hits with per-day buckets so we can chart trends and
|
|
4
|
-
* filter by time range.
|
|
2
|
+
* Analytics - page-hit counters, trends and journeys.
|
|
5
3
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* Was the `analytics` plugin until 0.67.0, where it was a CORE_PLUGINS member:
|
|
5
|
+
* always loaded, never switchable off. A feature nobody may turn off has no
|
|
6
|
+
* business being listed as an add-on, so it is simply part of the CMS now.
|
|
9
7
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* **Counters live in `content/analytics/`, not under plugins/.** They are real
|
|
9
|
+
* traffic history that cannot be regenerated, and the retirement migration
|
|
10
|
+
* deletes the plugin directory - so the data had to move out from under it. The
|
|
11
|
+
* migration copies and verifies before anything is removed.
|
|
12
|
+
*
|
|
13
|
+
* This is the CLIENT-SIDE beacon's store: a browser posts to /api/analytics/hit,
|
|
14
|
+
* which means bots, blocked scripts and Do-Not-Track visitors are absent by
|
|
15
|
+
* design. `analytics-pro` counts the same site server-side from an onRequest
|
|
16
|
+
* hook and will always report more. They are different instruments, not two
|
|
17
|
+
* versions of one, which is why neither supersedes the other.
|
|
18
|
+
*
|
|
19
|
+
* Endpoints (mounted at /api/analytics):
|
|
20
|
+
* POST /hit public record a hit { url, sid, ref }
|
|
12
21
|
* GET /stats admin summary + top pages (?range=today|7d|30d|90d|all|custom&from=&to=)
|
|
13
22
|
* GET /series admin per-day totals series (?days=N or ?from=&to=)
|
|
23
|
+
* GET /journeys admin entry/exit/path aggregates
|
|
24
|
+
* GET /realtime admin sessions active in the last 5 minutes
|
|
25
|
+
* GET /spikes admin days that stand out from the trend
|
|
14
26
|
* GET /export.csv admin CSV export of top pages for the active range
|
|
15
|
-
* DELETE /stats admin reset all
|
|
27
|
+
* DELETE /stats admin reset all counters
|
|
16
28
|
*/
|
|
29
|
+
import {clip, plural} from '../../services/sidebarBadges.js';
|
|
17
30
|
import fs from 'fs/promises';
|
|
18
31
|
import path from 'path';
|
|
19
|
-
import {
|
|
32
|
+
import {authenticate as defaultAuthenticate, requireAdmin as defaultRequireAdmin} from '../../middleware/auth.js';
|
|
20
33
|
|
|
21
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Where the counters live. Resolved per call rather than frozen at import so a
|
|
36
|
+
* test can run against a temporary install, the way the migrations do.
|
|
37
|
+
*/
|
|
38
|
+
export function analyticsDir() {
|
|
39
|
+
return path.resolve('content', 'analytics');
|
|
40
|
+
}
|
|
22
41
|
|
|
23
|
-
|
|
24
|
-
// preserve (siteCmsUpdater's copyPlugin backs up data/; `domma-cms update`
|
|
25
|
-
// keeps 'data' in its preserve set). Pre-2.1 they sat in the plugin root and
|
|
26
|
-
// were overwritten by the manager's copy on every plugin push. On first boot
|
|
27
|
-
// migrateToDataDir() moves any pre-2.1 root files across.
|
|
28
|
-
const DATA_DIR = path.join(__dirname, 'data');
|
|
42
|
+
const DATA_DIR = analyticsDir();
|
|
29
43
|
const LIFETIME_FILE = path.join(DATA_DIR, 'lifetime.json');
|
|
30
44
|
const DAILY_FILE = path.join(DATA_DIR, 'daily.json');
|
|
31
45
|
const LEGACY_FILE = path.join(DATA_DIR, 'stats.json');
|
|
32
46
|
const JOURNEYS_FILE = path.join(DATA_DIR, 'journeys.json');
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* What never gets counted, server side.
|
|
50
|
+
*
|
|
51
|
+
* These were the plugin's `config.js` defaults and no install ever changed one,
|
|
52
|
+
* so they are constants rather than a config block nobody would open. The other
|
|
53
|
+
* two defaults it carried - honourDnt and sessionDedup - were always decided in
|
|
54
|
+
* the browser, where the facts are, and still are: see public/js/analytics.js.
|
|
55
|
+
*/
|
|
56
|
+
const EXCLUDE_ADMIN = true;
|
|
57
|
+
const EXCLUDE_PATHS = ['/api'];
|
|
33
58
|
const JOURNEY_DAILY_CAP = 5000;
|
|
34
59
|
const REALTIME_WINDOW_MS = 5 * 60 * 1000;
|
|
35
60
|
|
|
@@ -48,23 +73,6 @@ async function writeJson(file, data) {
|
|
|
48
73
|
await fs.rename(tmp, file);
|
|
49
74
|
}
|
|
50
75
|
|
|
51
|
-
/**
|
|
52
|
-
* One-shot relocation of pre-2.1 counters from the plugin root into data/.
|
|
53
|
-
* Runs before migrateLegacy() so the rest of the plugin only ever sees data/.
|
|
54
|
-
*/
|
|
55
|
-
async function migrateToDataDir() {
|
|
56
|
-
await fs.mkdir(DATA_DIR, { recursive: true });
|
|
57
|
-
for (const name of ['lifetime.json', 'daily.json', 'journeys.json', 'stats.json']) {
|
|
58
|
-
const dest = path.join(DATA_DIR, name);
|
|
59
|
-
try { await fs.access(dest); continue; } catch { /* dest absent - maybe move */ }
|
|
60
|
-
const oldPath = path.join(__dirname, name);
|
|
61
|
-
try {
|
|
62
|
-
await fs.access(oldPath);
|
|
63
|
-
await fs.rename(oldPath, dest);
|
|
64
|
-
} catch { /* no pre-2.1 file to move */ }
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
76
|
/**
|
|
69
77
|
* One-shot migration from the legacy flat stats.json.
|
|
70
78
|
* Preserves existing totals as lifetime numbers; daily history starts empty.
|
|
@@ -274,11 +282,17 @@ function shouldRecordJourney(dayEntries, cap = JOURNEY_DAILY_CAP) {
|
|
|
274
282
|
return Array.isArray(dayEntries) && dayEntries.length < cap;
|
|
275
283
|
}
|
|
276
284
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
285
|
+
/**
|
|
286
|
+
* Auth is injectable, the shape dashboard.js and plugins.js use, so a test can
|
|
287
|
+
* drive these routes without registering @fastify/jwt.
|
|
288
|
+
*
|
|
289
|
+
* @param {import('fastify').FastifyInstance} fastify
|
|
290
|
+
* @param {object} [opts]
|
|
291
|
+
*/
|
|
292
|
+
export async function analyticsRoutes(fastify, opts = {}) {
|
|
293
|
+
const authenticate = opts.authenticate || defaultAuthenticate;
|
|
294
|
+
const requireAdmin = opts.requireAdmin || defaultRequireAdmin;
|
|
280
295
|
|
|
281
|
-
await migrateToDataDir();
|
|
282
296
|
await migrateLegacy();
|
|
283
297
|
|
|
284
298
|
fastify.decorate('analytics', {
|
|
@@ -333,11 +347,11 @@ export default async function analyticsPlugin(fastify, options) {
|
|
|
333
347
|
}
|
|
334
348
|
|
|
335
349
|
const normalised = url.split('?')[0].split('#')[0].replace(/\/+$/, '') || '/';
|
|
336
|
-
if (
|
|
350
|
+
if (EXCLUDE_ADMIN && normalised.startsWith('/admin')) {
|
|
337
351
|
return { ok: true, ignored: 'admin' };
|
|
338
352
|
}
|
|
339
|
-
if (Array.isArray(
|
|
340
|
-
for (const prefix of
|
|
353
|
+
if (Array.isArray(EXCLUDE_PATHS)) {
|
|
354
|
+
for (const prefix of EXCLUDE_PATHS) {
|
|
341
355
|
if (typeof prefix === 'string' && prefix && normalised.startsWith(prefix)) {
|
|
342
356
|
return { ok: true, ignored: 'excluded' };
|
|
343
357
|
}
|
|
@@ -441,6 +455,40 @@ export default async function analyticsPlugin(fastify, options) {
|
|
|
441
455
|
return { activeSessions: sids.size, windowMinutes: REALTIME_WINDOW_MS / 60000 };
|
|
442
456
|
});
|
|
443
457
|
|
|
458
|
+
/** GET /api/analytics/active-count - the sidebar badge: visitors on the site right now. */
|
|
459
|
+
fastify.get('/active-count', { preHandler: [authenticate, requireAdmin] }, async () => {
|
|
460
|
+
const [journeys, daily] = await Promise.all([readJson(JOURNEYS_FILE, {}), readJson(DAILY_FILE, {})]);
|
|
461
|
+
const today = todayKey();
|
|
462
|
+
const cutoff = Date.now() - REALTIME_WINDOW_MS;
|
|
463
|
+
const recent = (journeys[today] || []).filter(ev => ev.t >= cutoff);
|
|
464
|
+
const n = new Set(recent.map(ev => ev.sid)).size;
|
|
465
|
+
const mins = REALTIME_WINDOW_MS / 60000;
|
|
466
|
+
|
|
467
|
+
// What each active visitor is on now: their latest page.
|
|
468
|
+
const latest = new Map();
|
|
469
|
+
for (const ev of recent) if (!latest.has(ev.sid) || latest.get(ev.sid).t < ev.t) latest.set(ev.sid, ev);
|
|
470
|
+
const onPage = {};
|
|
471
|
+
for (const ev of latest.values()) onPage[ev.url] = (onPage[ev.url] || 0) + 1;
|
|
472
|
+
const viewsToday = Object.values(daily[today] || {}).reduce((a, b) => a + b, 0);
|
|
473
|
+
const sessionsToday = new Set((journeys[today] || []).map(ev => ev.sid)).size;
|
|
474
|
+
|
|
475
|
+
return {
|
|
476
|
+
count: n, label: `${plural(n, 'visitor')} on the site in the last ${mins} minutes`, tone: 'live',
|
|
477
|
+
details: {
|
|
478
|
+
title: 'Analytics',
|
|
479
|
+
rows: [
|
|
480
|
+
{label: 'Active now', value: String(n)},
|
|
481
|
+
{label: 'Views today', value: viewsToday.toLocaleString('en-GB')},
|
|
482
|
+
{label: 'Sessions today', value: sessionsToday.toLocaleString('en-GB')}
|
|
483
|
+
],
|
|
484
|
+
// URLs come from the PUBLIC hit counter - plain text only, set with textContent.
|
|
485
|
+
items: Object.entries(onPage).sort((a, b) => b[1] - a[1]).slice(0, 5)
|
|
486
|
+
.map(([url, c]) => ({text: clip(url, 48), meta: `${plural(c, 'visitor')} here now`})),
|
|
487
|
+
empty: `Nobody on the site in the last ${mins} minutes.`
|
|
488
|
+
}
|
|
489
|
+
};
|
|
490
|
+
});
|
|
491
|
+
|
|
444
492
|
fastify.get('/spikes', { preHandler: [authenticate, requireAdmin] }, async () => {
|
|
445
493
|
const daily = await readJson(DAILY_FILE, {});
|
|
446
494
|
return { items: detectSpikes(daily) };
|
|
@@ -1,11 +1,31 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Contacts API - /api/contacts
|
|
3
|
+
*
|
|
4
|
+
* Was the `contacts` plugin until 0.67.0. Enabled on every site on the fleet,
|
|
5
|
+
* which is the case for it being part of the CMS rather than something you go
|
|
6
|
+
* shopping for.
|
|
7
|
+
*
|
|
8
|
+
* **Per-user scope, for contacts only.** A contact carries a `userId` and reads
|
|
9
|
+
* are filtered to the caller's own. **Groups are not**: `loadGroups()` takes no
|
|
10
|
+
* user and never has, so a group one person creates is visible to everyone.
|
|
11
|
+
* That asymmetry is preserved rather than tidied - changing who can see a group
|
|
12
|
+
* is a behaviour change, and this was a relocation. A test pins it so the next
|
|
13
|
+
* reader finds it stated rather than inferred.
|
|
14
|
+
*
|
|
15
|
+
* Hand-filtered, as the plugin did it, rather than via rowAccess.js - the right
|
|
16
|
+
* end state, and again not something to smuggle into a move.
|
|
17
|
+
*
|
|
18
|
+
* **The slugs are `contacts-contacts` and `contacts-groups`**, not the
|
|
19
|
+
* directories the schemas used to sit in (`user-contacts`,
|
|
20
|
+
* `user-contact-groups`). setupPlugin() read `schema.slug`, so those are the
|
|
21
|
+
* live collections and existing rows are found only if they stay exactly so.
|
|
22
|
+
*
|
|
23
|
+
* The plugin called syncPluginSchemas() to keep an existing collection's fields
|
|
24
|
+
* in step with its schema. Core has no need: seedAll() rewrites every preset's
|
|
25
|
+
* schema.json on each boot, which is the same job done earlier.
|
|
26
|
+
*/
|
|
27
|
+
import {authenticate as defaultAuthenticate, requirePermission as defaultRequirePermission} from '../../middleware/auth.js';
|
|
28
|
+
import {createEntry, deleteEntry, getEntry, listEntries, updateEntry} from '../../services/collections.js';
|
|
9
29
|
|
|
10
30
|
const CONTACTS_SLUG = 'contacts-contacts';
|
|
11
31
|
const GROUPS_SLUG = 'contacts-groups';
|
|
@@ -187,18 +207,19 @@ function toGroupName(entry) {
|
|
|
187
207
|
return entry.data.name ?? '';
|
|
188
208
|
}
|
|
189
209
|
|
|
190
|
-
|
|
191
|
-
|
|
210
|
+
/**
|
|
211
|
+
* Auth is injectable, the shape dashboard.js and plugins.js use.
|
|
212
|
+
*
|
|
213
|
+
* @param {import('fastify').FastifyInstance} fastify
|
|
214
|
+
* @param {object} [opts]
|
|
215
|
+
*/
|
|
216
|
+
export async function contactsRoutes(fastify, opts = {}) {
|
|
217
|
+
const authenticate = opts.authenticate || defaultAuthenticate;
|
|
218
|
+
const requirePermission = opts.requirePermission || defaultRequirePermission;
|
|
192
219
|
|
|
193
|
-
// A collection is created once and never revisited, so the fields added
|
|
194
|
-
// here reach a fresh install and nowhere else without this.
|
|
195
|
-
await syncPluginSchemas(path.join(__dirname, 'collections'),
|
|
196
|
-
{getCollection, updateCollection}, fastify.log);
|
|
197
|
-
const config = {...defaultConfig, ...(options.settings || {})};
|
|
198
|
-
const scope = config.scope ?? 'user';
|
|
199
220
|
|
|
200
221
|
function userId(request) {
|
|
201
|
-
return
|
|
222
|
+
return request.user?.id ?? request.user?.sub ?? null;
|
|
202
223
|
}
|
|
203
224
|
|
|
204
225
|
async function loadContacts(uid) {
|
|
@@ -241,8 +262,8 @@ export default async function contactsPlugin(fastify, options) {
|
|
|
241
262
|
// Contacts CRUD
|
|
242
263
|
// -------------------------------------------------------------------------
|
|
243
264
|
|
|
244
|
-
/** GET /api/
|
|
245
|
-
fastify.get('/contacts', {preHandler: [authenticate]}, async (request, reply) => {
|
|
265
|
+
/** GET /api/contacts/contacts - supports ?q=, ?group=, ?favourites=true */
|
|
266
|
+
fastify.get('/contacts', {preHandler: [authenticate, requirePermission('contacts', 'read')]}, async (request, reply) => {
|
|
246
267
|
const uid = userId(request);
|
|
247
268
|
const { q, group, favourites } = request.query;
|
|
248
269
|
|
|
@@ -267,8 +288,8 @@ export default async function contactsPlugin(fastify, options) {
|
|
|
267
288
|
return reply.send(result);
|
|
268
289
|
});
|
|
269
290
|
|
|
270
|
-
/** POST /api/
|
|
271
|
-
fastify.post('/contacts', {preHandler: [authenticate]}, async (request, reply) => {
|
|
291
|
+
/** POST /api/contacts/contacts */
|
|
292
|
+
fastify.post('/contacts', {preHandler: [authenticate, requirePermission('contacts', 'create')]}, async (request, reply) => {
|
|
272
293
|
const uid = userId(request);
|
|
273
294
|
const body = request.body ?? {};
|
|
274
295
|
|
|
@@ -282,8 +303,8 @@ export default async function contactsPlugin(fastify, options) {
|
|
|
282
303
|
return reply.code(201).send(toContact(entry));
|
|
283
304
|
});
|
|
284
305
|
|
|
285
|
-
/** PUT /api/
|
|
286
|
-
fastify.put('/contacts/:id', {preHandler: [authenticate]}, async (request, reply) => {
|
|
306
|
+
/** PUT /api/contacts/contacts/:id */
|
|
307
|
+
fastify.put('/contacts/:id', {preHandler: [authenticate, requirePermission('contacts', 'update')]}, async (request, reply) => {
|
|
287
308
|
const uid = userId(request);
|
|
288
309
|
const { id } = request.params;
|
|
289
310
|
const updates = request.body ?? {};
|
|
@@ -292,9 +313,16 @@ export default async function contactsPlugin(fastify, options) {
|
|
|
292
313
|
if (!entry) return reply.code(404).send({error: 'Contact not found'});
|
|
293
314
|
if (uid && entry.data.userId !== uid) return reply.code(404).send({error: 'Contact not found'});
|
|
294
315
|
|
|
316
|
+
// Coerced exactly as create (buildContact) does: the editor sends
|
|
317
|
+
// payment terms as the input's string and a PUT used to store "30"
|
|
318
|
+
// where a POST stores 30 - and a bare `favourite: "false"` was truthy.
|
|
319
|
+
const shaped = buildContact({...entry.data, ...updates}, entry.data.userId ?? null);
|
|
295
320
|
const merged = {...entry.data};
|
|
296
|
-
for (const key of
|
|
297
|
-
if (key in updates) merged[key] =
|
|
321
|
+
for (const key of TEXT_FIELDS) {
|
|
322
|
+
if (key in updates) merged[key] = shaped[key];
|
|
323
|
+
}
|
|
324
|
+
for (const key of ['groups', 'favourite', 'paymentTerms']) {
|
|
325
|
+
if (key in updates) merged[key] = shaped[key];
|
|
298
326
|
}
|
|
299
327
|
for (const key of LIST_FIELDS) {
|
|
300
328
|
if (key in updates) merged[key] = cleanList(key, updates[key]);
|
|
@@ -304,8 +332,8 @@ export default async function contactsPlugin(fastify, options) {
|
|
|
304
332
|
return reply.send(toContact(updated));
|
|
305
333
|
});
|
|
306
334
|
|
|
307
|
-
/** DELETE /api/
|
|
308
|
-
fastify.delete('/contacts/bulk', {preHandler: [authenticate]}, async (request, reply) => {
|
|
335
|
+
/** DELETE /api/contacts/contacts/bulk - must be before /:id */
|
|
336
|
+
fastify.delete('/contacts/bulk', {preHandler: [authenticate, requirePermission('contacts', 'delete')]}, async (request, reply) => {
|
|
309
337
|
const uid = userId(request);
|
|
310
338
|
const { ids } = request.body ?? {};
|
|
311
339
|
|
|
@@ -326,8 +354,8 @@ export default async function contactsPlugin(fastify, options) {
|
|
|
326
354
|
return reply.send({ deleted });
|
|
327
355
|
});
|
|
328
356
|
|
|
329
|
-
/** DELETE /api/
|
|
330
|
-
fastify.delete('/contacts/:id', {preHandler: [authenticate]}, async (request, reply) => {
|
|
357
|
+
/** DELETE /api/contacts/contacts/:id */
|
|
358
|
+
fastify.delete('/contacts/:id', {preHandler: [authenticate, requirePermission('contacts', 'delete')]}, async (request, reply) => {
|
|
331
359
|
const uid = userId(request);
|
|
332
360
|
const { id } = request.params;
|
|
333
361
|
|
|
@@ -340,13 +368,13 @@ export default async function contactsPlugin(fastify, options) {
|
|
|
340
368
|
});
|
|
341
369
|
|
|
342
370
|
/**
|
|
343
|
-
* GET /api/
|
|
371
|
+
* GET /api/contacts/contacts/:id/billing
|
|
344
372
|
*
|
|
345
373
|
* The contact as the Invoice plugin wants a receiver: same field names, so
|
|
346
374
|
* the body can go straight to POST /api/plugins/invoice/receivers. Nothing
|
|
347
375
|
* in the CMS calls this yet - it is the seam, put where the data is.
|
|
348
376
|
*/
|
|
349
|
-
fastify.get('/contacts/:id/billing', {preHandler: [authenticate]}, async (request, reply) => {
|
|
377
|
+
fastify.get('/contacts/:id/billing', {preHandler: [authenticate, requirePermission('contacts', 'read')]}, async (request, reply) => {
|
|
350
378
|
const uid = userId(request);
|
|
351
379
|
const entry = await getEntry(CONTACTS_SLUG, request.params.id);
|
|
352
380
|
if (!entry) return reply.code(404).send({error: 'Contact not found'});
|
|
@@ -358,14 +386,14 @@ export default async function contactsPlugin(fastify, options) {
|
|
|
358
386
|
// Groups CRUD
|
|
359
387
|
// -------------------------------------------------------------------------
|
|
360
388
|
|
|
361
|
-
/** GET /api/
|
|
362
|
-
fastify.get('/groups', {preHandler: [authenticate]}, async (request, reply) => {
|
|
389
|
+
/** GET /api/contacts/groups */
|
|
390
|
+
fastify.get('/groups', {preHandler: [authenticate, requirePermission('contacts', 'read')]}, async (request, reply) => {
|
|
363
391
|
const groups = await loadGroups();
|
|
364
392
|
return reply.send(groups.map(g => g.name));
|
|
365
393
|
});
|
|
366
394
|
|
|
367
|
-
/** POST /api/
|
|
368
|
-
fastify.post('/groups', {preHandler: [authenticate]}, async (request, reply) => {
|
|
395
|
+
/** POST /api/contacts/groups */
|
|
396
|
+
fastify.post('/groups', {preHandler: [authenticate, requirePermission('contacts', 'create')]}, async (request, reply) => {
|
|
369
397
|
const { name } = request.body ?? {};
|
|
370
398
|
|
|
371
399
|
if (!name || typeof name !== 'string' || !name.trim()) {
|
|
@@ -382,8 +410,8 @@ export default async function contactsPlugin(fastify, options) {
|
|
|
382
410
|
return reply.code(201).send({ name: trimmed });
|
|
383
411
|
});
|
|
384
412
|
|
|
385
|
-
/** PUT /api/
|
|
386
|
-
fastify.put('/groups/:name', {preHandler: [authenticate]}, async (request, reply) => {
|
|
413
|
+
/** PUT /api/contacts/groups/:name - rename + cascade */
|
|
414
|
+
fastify.put('/groups/:name', {preHandler: [authenticate, requirePermission('contacts', 'update')]}, async (request, reply) => {
|
|
387
415
|
const oldName = decodeURIComponent(request.params.name);
|
|
388
416
|
const { newName } = request.body ?? {};
|
|
389
417
|
|
|
@@ -418,8 +446,8 @@ export default async function contactsPlugin(fastify, options) {
|
|
|
418
446
|
return reply.send({ name: trimmed });
|
|
419
447
|
});
|
|
420
448
|
|
|
421
|
-
/** DELETE /api/
|
|
422
|
-
fastify.delete('/groups/:name', {preHandler: [authenticate]}, async (request, reply) => {
|
|
449
|
+
/** DELETE /api/contacts/groups/:name - delete + cascade */
|
|
450
|
+
fastify.delete('/groups/:name', {preHandler: [authenticate, requirePermission('contacts', 'delete')]}, async (request, reply) => {
|
|
423
451
|
const name = decodeURIComponent(request.params.name);
|
|
424
452
|
|
|
425
453
|
const groups = await loadGroups();
|
|
@@ -448,8 +476,8 @@ export default async function contactsPlugin(fastify, options) {
|
|
|
448
476
|
// Import / Export
|
|
449
477
|
// -------------------------------------------------------------------------
|
|
450
478
|
|
|
451
|
-
/** GET /api/
|
|
452
|
-
fastify.get('/export', {preHandler: [authenticate]}, async (request, reply) => {
|
|
479
|
+
/** GET /api/contacts/export */
|
|
480
|
+
fastify.get('/export', {preHandler: [authenticate, requirePermission('contacts', 'read')]}, async (request, reply) => {
|
|
453
481
|
const uid = userId(request);
|
|
454
482
|
const contacts = await loadContacts(uid);
|
|
455
483
|
return reply
|
|
@@ -457,8 +485,8 @@ export default async function contactsPlugin(fastify, options) {
|
|
|
457
485
|
.send(contacts);
|
|
458
486
|
});
|
|
459
487
|
|
|
460
|
-
/** POST /api/
|
|
461
|
-
fastify.post('/import', {preHandler: [authenticate]}, async (request, reply) => {
|
|
488
|
+
/** POST /api/contacts/import */
|
|
489
|
+
fastify.post('/import', {preHandler: [authenticate, requirePermission('contacts', 'create')]}, async (request, reply) => {
|
|
462
490
|
const uid = userId(request);
|
|
463
491
|
const { contacts: incoming } = request.body ?? {};
|
|
464
492
|
|
|
@@ -281,7 +281,10 @@ export async function dashboardRoutes(fastify, opts = {}) {
|
|
|
281
281
|
// are recorded. Read the plugin's daily counters straight off disk (same
|
|
282
282
|
// pattern as buildActivity reading versions/collections below).
|
|
283
283
|
const daily = (await safe('analytics.daily', async () => {
|
|
284
|
-
|
|
284
|
+
// content/analytics/ is where the counters live since analytics became
|
|
285
|
+
// core. The plugin paths stay as a fallback for one release: a site
|
|
286
|
+
// that has not restarted yet still has its history under plugins/.
|
|
287
|
+
for (const rel of ['content/analytics/daily.json', 'plugins/analytics/data/daily.json', 'plugins/analytics/daily.json']) {
|
|
285
288
|
try { return JSON.parse(await fs.readFile(path.join(ROOT, rel), 'utf8')); }
|
|
286
289
|
catch { /* try next location */ }
|
|
287
290
|
}
|
|
@@ -305,7 +308,10 @@ export async function dashboardRoutes(fastify, opts = {}) {
|
|
|
305
308
|
|
|
306
309
|
// Session events read straight off disk too (same reason as daily above).
|
|
307
310
|
const journeysData = (await safe('analytics.journeysData', async () => {
|
|
308
|
-
|
|
311
|
+
// content/analytics/ is where the counters live since analytics became
|
|
312
|
+
// core. The plugin paths stay as a fallback for one release: a site
|
|
313
|
+
// that has not restarted yet still has its history under plugins/.
|
|
314
|
+
for (const rel of ['content/analytics/journeys.json', 'plugins/analytics/data/journeys.json', 'plugins/analytics/journeys.json']) {
|
|
309
315
|
try { return JSON.parse(await fs.readFile(path.join(ROOT, rel), 'utf8')); }
|
|
310
316
|
catch { /* try next location */ }
|
|
311
317
|
}
|
|
@@ -1,5 +1,23 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Notes API - /api/notes
|
|
3
|
+
*
|
|
4
|
+
* Was the `notes` plugin until 0.67.0. The logic is unchanged; what moved is
|
|
5
|
+
* where it lives and how it is gated.
|
|
6
|
+
*
|
|
7
|
+
* **Per-user scope.** Entries carry a `userId` and every read is filtered to the
|
|
8
|
+
* caller's own. The plugin made this configurable (`scope: 'user' | 'site'`) and
|
|
9
|
+
* no install ever set it to anything else, so it is a constant here. Note this
|
|
10
|
+
* is hand-filtering rather than `rowAccess.js`, which is the mechanism core has
|
|
11
|
+
* for exactly this - moving onto it is a behaviour change and belongs in its own
|
|
12
|
+
* task, not smuggled into a relocation.
|
|
13
|
+
*
|
|
14
|
+
* **The collection slug is `notes-notes`, not the directory it used to sit in.**
|
|
15
|
+
* `setupPlugin()` read `schema.slug`, so the live collection has always been
|
|
16
|
+
* called this, and existing entries are found only if it stays exactly so.
|
|
17
|
+
*/
|
|
18
|
+
import {ago, clip, plural} from '../../services/sidebarBadges.js';
|
|
19
|
+
import {authenticate as defaultAuthenticate, requirePermission as defaultRequirePermission} from '../../middleware/auth.js';
|
|
20
|
+
import {createEntry, deleteEntry, getEntry, listEntries, updateEntry} from '../../services/collections.js';
|
|
3
21
|
|
|
4
22
|
const SLUG = 'notes-notes';
|
|
5
23
|
|
|
@@ -14,13 +32,20 @@ function toNote(entry) {
|
|
|
14
32
|
};
|
|
15
33
|
}
|
|
16
34
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Auth is injectable so a test can drive these routes without registering
|
|
37
|
+
* @fastify/jwt or a role store - the same shape dashboard.js and plugins.js use.
|
|
38
|
+
*
|
|
39
|
+
* @param {import('fastify').FastifyInstance} fastify
|
|
40
|
+
* @param {object} [opts]
|
|
41
|
+
*/
|
|
42
|
+
export async function notesRoutes(fastify, opts = {}) {
|
|
43
|
+
const authenticate = opts.authenticate || defaultAuthenticate;
|
|
44
|
+
const requirePermission = opts.requirePermission || defaultRequirePermission;
|
|
45
|
+
|
|
21
46
|
|
|
22
47
|
function userId(request) {
|
|
23
|
-
return
|
|
48
|
+
return request.user?.id ?? request.user?.sub ?? null;
|
|
24
49
|
}
|
|
25
50
|
|
|
26
51
|
async function loadAll(uid) {
|
|
@@ -30,8 +55,27 @@ export default async function notesPlugin(fastify, options) {
|
|
|
30
55
|
return items;
|
|
31
56
|
}
|
|
32
57
|
|
|
33
|
-
/** GET /api/
|
|
34
|
-
fastify.get('/
|
|
58
|
+
/** GET /api/notes/count - the sidebar badge: how many notes this user has. */
|
|
59
|
+
fastify.get('/count', {preHandler: [authenticate, requirePermission('notes', 'read')]}, async (request) => {
|
|
60
|
+
const notes = await loadAll(userId(request)); // newest edit first
|
|
61
|
+
const categories = new Set(notes.flatMap(n => (Array.isArray(n.categories) ? n.categories : []).map(c => String(c).toLowerCase())));
|
|
62
|
+
return {
|
|
63
|
+
count: notes.length, label: plural(notes.length, 'note'), tone: 'info',
|
|
64
|
+
details: {
|
|
65
|
+
title: 'Notes',
|
|
66
|
+
rows: [
|
|
67
|
+
{label: 'Notes', value: String(notes.length)},
|
|
68
|
+
{label: 'Categories', value: String(categories.size)},
|
|
69
|
+
{label: 'Edited this week', value: String(notes.filter(n => Date.now() - new Date(n.updatedAt).getTime() < 7 * 86_400_000).length)}
|
|
70
|
+
],
|
|
71
|
+
items: notes.slice(0, 4).map(n => ({text: clip(n.title || n.content || 'Untitled'), meta: `edited ${ago(n.updatedAt)}`})),
|
|
72
|
+
empty: 'No notes yet.'
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/** GET /api/notes/items - supports ?q= and ?category= */
|
|
78
|
+
fastify.get('/items', {preHandler: [authenticate, requirePermission('notes', 'read')]}, async (request, reply) => {
|
|
35
79
|
const uid = userId(request);
|
|
36
80
|
const { q, category } = request.query;
|
|
37
81
|
|
|
@@ -55,8 +99,8 @@ export default async function notesPlugin(fastify, options) {
|
|
|
55
99
|
return reply.send(items);
|
|
56
100
|
});
|
|
57
101
|
|
|
58
|
-
/** POST /api/
|
|
59
|
-
fastify.post('/items', {
|
|
102
|
+
/** POST /api/notes/items */
|
|
103
|
+
fastify.post('/items', {preHandler: [authenticate, requirePermission('notes', 'create')]}, async (request, reply) => {
|
|
60
104
|
const uid = userId(request);
|
|
61
105
|
const { title, content, categories } = request.body ?? {};
|
|
62
106
|
|
|
@@ -72,8 +116,8 @@ export default async function notesPlugin(fastify, options) {
|
|
|
72
116
|
return reply.code(201).send(toNote(entry));
|
|
73
117
|
});
|
|
74
118
|
|
|
75
|
-
/** PUT /api/
|
|
76
|
-
fastify.put('/items/:id', {
|
|
119
|
+
/** PUT /api/notes/items/:id */
|
|
120
|
+
fastify.put('/items/:id', {preHandler: [authenticate, requirePermission('notes', 'update')]}, async (request, reply) => {
|
|
77
121
|
const uid = userId(request);
|
|
78
122
|
const { id } = request.params;
|
|
79
123
|
const { title, content, categories } = request.body ?? {};
|
|
@@ -95,8 +139,8 @@ export default async function notesPlugin(fastify, options) {
|
|
|
95
139
|
return reply.send(toNote(updated));
|
|
96
140
|
});
|
|
97
141
|
|
|
98
|
-
/** DELETE /api/
|
|
99
|
-
fastify.delete('/items/:id', {
|
|
142
|
+
/** DELETE /api/notes/items/:id */
|
|
143
|
+
fastify.delete('/items/:id', {preHandler: [authenticate, requirePermission('notes', 'delete')]}, async (request, reply) => {
|
|
100
144
|
const uid = userId(request);
|
|
101
145
|
const { id } = request.params;
|
|
102
146
|
|
|
@@ -108,8 +152,8 @@ export default async function notesPlugin(fastify, options) {
|
|
|
108
152
|
return reply.send({ success: true });
|
|
109
153
|
});
|
|
110
154
|
|
|
111
|
-
/** GET /api/
|
|
112
|
-
fastify.get('/categories', {
|
|
155
|
+
/** GET /api/notes/categories */
|
|
156
|
+
fastify.get('/categories', {preHandler: [authenticate, requirePermission('notes', 'read')]}, async (request, reply) => {
|
|
113
157
|
const uid = userId(request);
|
|
114
158
|
const items = await loadAll(uid);
|
|
115
159
|
const cats = new Set();
|