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.
Files changed (221) hide show
  1. package/CLAUDE.md +64 -1
  2. package/README.md +14 -10
  3. package/admin/css/admin.css +1 -1
  4. package/admin/index.html +1 -0
  5. package/admin/js/api.js +1 -1
  6. package/admin/js/app.js +4 -4
  7. package/admin/js/lib/analytics-shape.js +1 -0
  8. package/admin/js/lib/contacts-arrange.js +1 -0
  9. package/admin/js/lib/help-popover.js +1 -0
  10. package/admin/js/lib/notes-arrange.js +1 -0
  11. package/admin/js/lib/plugin-accent.js +1 -1
  12. package/admin/js/lib/plugin-chrome.js +1 -1
  13. package/admin/js/lib/sidebar-grouping.js +1 -1
  14. package/admin/js/lib/sidebar-grouping.test.js +1 -1
  15. package/admin/js/lib/sidebar-renderer.js +3 -3
  16. package/admin/js/lib/todo-arrange.js +1 -0
  17. package/admin/js/lib/tool-kit.js +1 -0
  18. package/admin/js/templates/analytics.html +138 -0
  19. package/admin/js/templates/contacts.html +156 -0
  20. package/admin/js/templates/notes.html +137 -0
  21. package/admin/js/templates/plugin-marketplace.html +17 -4
  22. package/admin/js/templates/plugins.html +13 -7
  23. package/admin/js/templates/theme.html +38 -0
  24. package/admin/js/templates/todo.html +113 -0
  25. package/admin/js/views/analytics.js +62 -0
  26. package/admin/js/views/contacts.js +174 -0
  27. package/admin/js/views/index.js +1 -1
  28. package/admin/js/views/notes.js +104 -0
  29. package/admin/js/views/plugin-marketplace.js +1 -1
  30. package/admin/js/views/plugins.js +24 -22
  31. package/admin/js/views/theme.js +1 -1
  32. package/admin/js/views/todo.js +96 -0
  33. package/bin/cli.js +4 -10
  34. package/bin/lib/plugin-version.js +28 -0
  35. package/bin/update.js +14 -0
  36. package/config/menus/admin-sidebar.json +1 -1
  37. package/config/server.json +3 -1
  38. package/package.json +3 -7
  39. package/plugins/_lib/admin/mail/reader-view.js +20 -0
  40. package/plugins/_lib/mail/store.js +21 -1
  41. package/plugins/blog/CLAUDE.md +237 -0
  42. package/plugins/blog/admin/css/index.css +178 -1
  43. package/plugins/blog/admin/templates/blog.html +35 -53
  44. package/plugins/blog/admin/templates/categories.html +19 -6
  45. package/plugins/blog/admin/templates/comments.html +26 -9
  46. package/plugins/blog/admin/templates/post-editor.html +36 -13
  47. package/plugins/blog/admin/templates/settings.html +2 -11
  48. package/plugins/blog/admin/views/blog.js +288 -162
  49. package/plugins/blog/admin/views/categories.js +212 -201
  50. package/plugins/blog/admin/views/comments.js +207 -158
  51. package/plugins/blog/admin/views/kit.js +372 -0
  52. package/plugins/blog/admin/views/post-editor.js +269 -21
  53. package/plugins/blog/admin/views/settings-cog.js +56 -0
  54. package/plugins/blog/admin/views/settings.js +16 -86
  55. package/plugins/blog/blocks/blog-card-compact.css +17 -0
  56. package/plugins/blog/blocks/blog-card-compact.html +8 -0
  57. package/plugins/blog/blocks/blog-card-row.css +22 -0
  58. package/plugins/blog/blocks/blog-card-row.html +11 -0
  59. package/plugins/blog/blocks/blog-card.css +35 -0
  60. package/plugins/blog/blocks/blog-card.html +16 -0
  61. package/plugins/blog/blocks/blog-post-classic.css +15 -0
  62. package/plugins/blog/blocks/blog-post-classic.html +28 -0
  63. package/plugins/blog/blocks/blog-post-feature.css +20 -0
  64. package/plugins/blog/blocks/blog-post-feature.html +30 -0
  65. package/plugins/blog/blocks/blog-post-minimal.css +10 -0
  66. package/plugins/blog/blocks/blog-post-minimal.html +23 -0
  67. package/plugins/blog/blocks/blog-post-sidebar.css +22 -0
  68. package/plugins/blog/blocks/blog-post-sidebar.html +36 -0
  69. package/plugins/blog/collections/posts/schema.json +3 -1
  70. package/plugins/blog/config.js +6 -0
  71. package/plugins/blog/lib/layouts.js +304 -0
  72. package/plugins/blog/lib/page.js +158 -0
  73. package/plugins/blog/lib/render.js +119 -0
  74. package/plugins/blog/lib/samples.js +234 -0
  75. package/plugins/blog/plugin.js +225 -15
  76. package/plugins/blog/plugin.json +21 -81
  77. package/plugins/blog/plugin.public.js +115 -210
  78. package/plugins/blog/public/blog.css +177 -0
  79. package/plugins/blog/public/blog.js +586 -0
  80. package/plugins/blog/public/samples/aurora.svg +7 -0
  81. package/plugins/blog/public/samples/dusk.svg +7 -0
  82. package/plugins/blog/public/samples/ember.svg +7 -0
  83. package/plugins/blog/public/samples/harbour.svg +7 -0
  84. package/plugins/blog/public/samples/meadow.svg +7 -0
  85. package/plugins/blog/public/samples/rosewood.svg +7 -0
  86. package/plugins/blog/templates/index.html +9 -8
  87. package/plugins/blog/templates/post.html +4 -17
  88. package/plugins/blog/tests/layouts.test.js +97 -0
  89. package/plugins/blog/tests/public.test.js +71 -0
  90. package/plugins/free-tier.lock.json +74 -0
  91. package/plugins/mail-reader/CLAUDE.md +78 -0
  92. package/plugins/mail-reader/plugin.json +2 -1
  93. package/public/css/theme-switcher.css +1 -0
  94. package/public/js/analytics.js +1 -0
  95. package/public/js/theme-switcher.js +1 -0
  96. package/scripts/build.js +41 -2
  97. package/scripts/run-tests.mjs +94 -0
  98. package/{plugins/analytics/plugin.js → server/routes/api/analytics.js} +89 -41
  99. package/{plugins/contacts/plugin.js → server/routes/api/contacts.js} +71 -43
  100. package/server/routes/api/dashboard.js +8 -2
  101. package/{plugins/notes/plugin.js → server/routes/api/notes.js} +61 -17
  102. package/server/routes/api/plugin-marketplace.js +78 -23
  103. package/server/routes/api/plugins.js +148 -3
  104. package/server/routes/api/todo.js +178 -0
  105. package/server/server.js +61 -1
  106. package/server/services/collections.js +13 -3
  107. package/server/services/managerClient.js +72 -136
  108. package/server/services/markdown.js +54 -1
  109. package/server/services/permissionRegistry.js +50 -0
  110. package/server/services/pluginEntitlement.js +171 -0
  111. package/server/services/pluginEnvelope.js +242 -0
  112. package/server/services/pluginInstaller.js +246 -26
  113. package/server/services/pluginScaffold.js +5 -0
  114. package/server/services/plugins.js +117 -7
  115. package/server/services/presetCollections.js +344 -0
  116. package/server/services/promoted-plugins-migration.js +254 -0
  117. package/server/services/publicCors.js +46 -0
  118. package/server/services/renderer.js +83 -3
  119. package/server/services/sidebarBadges.js +37 -0
  120. package/server/services/siteGitignore.js +310 -0
  121. package/server/services/themeSettings.js +39 -0
  122. package/config/theme.json +0 -18
  123. package/plugins/analytics/admin/css/index.css +0 -1
  124. package/plugins/analytics/admin/templates/analytics.html +0 -70
  125. package/plugins/analytics/admin/views/analytics.js +0 -176
  126. package/plugins/analytics/config.js +0 -14
  127. package/plugins/analytics/plugin.json +0 -60
  128. package/plugins/analytics/public/inject-body.html +0 -60
  129. package/plugins/analytics/public/inject-head.html +0 -1
  130. package/plugins/blog/templates/author.html +0 -9
  131. package/plugins/blog/templates/category.html +0 -9
  132. package/plugins/blog/templates/tag.html +0 -9
  133. package/plugins/contacts/admin/templates/contacts.html +0 -254
  134. package/plugins/contacts/admin/views/contacts.js +0 -943
  135. package/plugins/contacts/collections/user-contact-groups/schema.json +0 -35
  136. package/plugins/contacts/collections/user-contacts/schema.json +0 -179
  137. package/plugins/contacts/config.js +0 -6
  138. package/plugins/contacts/plugin.json +0 -36
  139. package/plugins/demo-viewer/config.js +0 -4
  140. package/plugins/demo-viewer/plugin.js +0 -36
  141. package/plugins/demo-viewer/plugin.json +0 -9
  142. package/plugins/invoice/admin/css/index.css +0 -1
  143. package/plugins/invoice/admin/templates/editor.html +0 -220
  144. package/plugins/invoice/admin/templates/index.html +0 -173
  145. package/plugins/invoice/admin/templates/issuers.html +0 -2
  146. package/plugins/invoice/admin/templates/receivers.html +0 -2
  147. package/plugins/invoice/admin/views/contacts-source.js +0 -266
  148. package/plugins/invoice/admin/views/editor.js +0 -434
  149. package/plugins/invoice/admin/views/export.js +0 -199
  150. package/plugins/invoice/admin/views/help-content.js +0 -61
  151. package/plugins/invoice/admin/views/index.js +0 -643
  152. package/plugins/invoice/admin/views/issuers.js +0 -30
  153. package/plugins/invoice/admin/views/media.js +0 -172
  154. package/plugins/invoice/admin/views/party-view.js +0 -386
  155. package/plugins/invoice/admin/views/payments.js +0 -127
  156. package/plugins/invoice/admin/views/print.js +0 -130
  157. package/plugins/invoice/admin/views/receivers.js +0 -55
  158. package/plugins/invoice/admin/views/send.js +0 -212
  159. package/plugins/invoice/admin/views/settings.js +0 -594
  160. package/plugins/invoice/admin/views/view-lifecycle.js +0 -33
  161. package/plugins/invoice/collections/invoice-issuers/schema.json +0 -82
  162. package/plugins/invoice/collections/invoice-receivers/schema.json +0 -16
  163. package/plugins/invoice/collections/invoices/schema.json +0 -33
  164. package/plugins/invoice/config.js +0 -37
  165. package/plugins/invoice/pdf.js +0 -164
  166. package/plugins/invoice/plugin.js +0 -1456
  167. package/plugins/invoice/plugin.json +0 -86
  168. package/plugins/invoice/templates/_base.css +0 -1
  169. package/plugins/invoice/templates/classic-nologo.html +0 -100
  170. package/plugins/invoice/templates/classic.html +0 -91
  171. package/plugins/invoice/templates/invoice-print.html +0 -237
  172. package/plugins/invoice/templates/minimal.html +0 -99
  173. package/plugins/invoice/templates/modern-nologo.html +0 -114
  174. package/plugins/invoice/templates/modern.html +0 -113
  175. package/plugins/invoice/templates/templates.json +0 -11
  176. package/plugins/notes/admin/templates/notes.html +0 -83
  177. package/plugins/notes/admin/views/notes.js +0 -281
  178. package/plugins/notes/collections/user-notes/schema.json +0 -54
  179. package/plugins/notes/config.js +0 -6
  180. package/plugins/notes/plugin.json +0 -33
  181. package/plugins/surveys/admin/templates/audience.html +0 -47
  182. package/plugins/surveys/admin/templates/results.html +0 -58
  183. package/plugins/surveys/admin/templates/survey-editor.html +0 -141
  184. package/plugins/surveys/admin/templates/surveys.html +0 -25
  185. package/plugins/surveys/admin/views/audience.js +0 -301
  186. package/plugins/surveys/admin/views/results.js +0 -172
  187. package/plugins/surveys/admin/views/survey-editor.js +0 -211
  188. package/plugins/surveys/admin/views/surveys.js +0 -161
  189. package/plugins/surveys/collections/survey-contacts/schema.json +0 -13
  190. package/plugins/surveys/collections/survey-groups/schema.json +0 -11
  191. package/plugins/surveys/collections/survey-invites/schema.json +0 -16
  192. package/plugins/surveys/collections/surveys/schema.json +0 -23
  193. package/plugins/surveys/config.js +0 -8
  194. package/plugins/surveys/lib/audience.js +0 -37
  195. package/plugins/surveys/lib/campaigns.js +0 -43
  196. package/plugins/surveys/lib/ledger.js +0 -110
  197. package/plugins/surveys/lib/sending.js +0 -106
  198. package/plugins/surveys/lib/stats.js +0 -62
  199. package/plugins/surveys/lib/submit.js +0 -95
  200. package/plugins/surveys/lib/tokens.js +0 -28
  201. package/plugins/surveys/plugin.js +0 -174
  202. package/plugins/surveys/plugin.json +0 -65
  203. package/plugins/surveys/plugin.public.js +0 -149
  204. package/plugins/surveys/public/dist-shared.mjs +0 -1
  205. package/plugins/surveys/public/survey.css +0 -1
  206. package/plugins/surveys/public/survey.mjs +0 -1
  207. package/plugins/surveys/templates/survey-page.html +0 -63
  208. package/plugins/theme-switcher/admin/templates/theme-switcher.html +0 -86
  209. package/plugins/theme-switcher/admin/views/theme-switcher.js +0 -65
  210. package/plugins/theme-switcher/config.js +0 -10
  211. package/plugins/theme-switcher/plugin.js +0 -26
  212. package/plugins/theme-switcher/plugin.json +0 -74
  213. package/plugins/theme-switcher/public/inject-body.html +0 -153
  214. package/plugins/theme-switcher/public/inject-head.html +0 -260
  215. package/plugins/todo/admin/templates/todo.html +0 -158
  216. package/plugins/todo/admin/views/todo.js +0 -343
  217. package/plugins/todo/collections/todos/schema.json +0 -60
  218. package/plugins/todo/config.js +0 -7
  219. package/plugins/todo/plugin.js +0 -102
  220. package/plugins/todo/plugin.json +0 -33
  221. /package/{plugins/contacts/admin/css/index.css → admin/css/contacts.css} +0 -0
@@ -1,35 +1,60 @@
1
1
  /**
2
- * Analytics Plugin - Server
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
- * Storage:
7
- * lifetime.json { "/url": totalHits }
8
- * daily.json { "YYYY-MM-DD": { "/url": hits } }
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
- * Endpoints (mounted at /api/plugins/analytics):
11
- * POST /hit public record a hit { url, dnt }
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 stats
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 {fileURLToPath} from 'url';
32
+ import {authenticate as defaultAuthenticate, requireAdmin as defaultRequireAdmin} from '../../middleware/auth.js';
20
33
 
21
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
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
- // Runtime counters live under data/ - the carve-out that BOTH update paths
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
- export default async function analyticsPlugin(fastify, options) {
278
- const { authenticate, requireAdmin } = options.auth;
279
- const settings = options.settings || {};
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 (settings.excludeAdmin && normalised.startsWith('/admin')) {
350
+ if (EXCLUDE_ADMIN && normalised.startsWith('/admin')) {
337
351
  return { ok: true, ignored: 'admin' };
338
352
  }
339
- if (Array.isArray(settings.excludePaths)) {
340
- for (const prefix of settings.excludePaths) {
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
- import path from 'path';
2
- import {fileURLToPath} from 'url';
3
-
4
- import defaultConfig from './config.js';
5
- import {createEntry, deleteEntry, getCollection, getEntry, listEntries, updateCollection, updateEntry} from '../../server/services/collections.js';
6
- import {syncPluginSchemas} from '../_lib/schemaSync.js';
7
-
8
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
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
- export default async function contactsPlugin(fastify, options) {
191
- const {authenticate} = options.auth;
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 scope === 'user' ? (request.user?.id ?? request.user?.sub ?? null) : null;
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/plugins/contacts/contacts - supports ?q=, ?group=, ?favourites=true */
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/plugins/contacts/contacts */
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/plugins/contacts/contacts/:id */
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 [...TEXT_FIELDS, 'groups', 'favourite', 'paymentTerms']) {
297
- if (key in updates) merged[key] = updates[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/plugins/contacts/contacts/bulk - must be before /:id */
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/plugins/contacts/contacts/:id */
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/plugins/contacts/contacts/:id/billing
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/plugins/contacts/groups */
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/plugins/contacts/groups */
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/plugins/contacts/groups/:name - rename + cascade */
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/plugins/contacts/groups/:name - delete + cascade */
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/plugins/contacts/export */
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/plugins/contacts/import */
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
- for (const rel of ['plugins/analytics/data/daily.json', 'plugins/analytics/daily.json']) {
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
- for (const rel of ['plugins/analytics/data/journeys.json', 'plugins/analytics/journeys.json']) {
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
- import defaultConfig from './config.js';
2
- import {createEntry, deleteEntry, getEntry, listEntries, updateEntry} from '../../server/services/collections.js';
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
- export default async function notesPlugin(fastify, options) {
18
- const { authenticate } = options.auth;
19
- const config = {...defaultConfig, ...(options.settings || {})};
20
- const scope = config.scope ?? 'user';
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 scope === 'user' ? (request.user?.id ?? request.user?.sub ?? null) : null;
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/plugins/notes/items - supports ?q= and ?category= */
34
- fastify.get('/items', { preHandler: [authenticate] }, async (request, reply) => {
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/plugins/notes/items */
59
- fastify.post('/items', { preHandler: [authenticate] }, async (request, reply) => {
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/plugins/notes/items/:id */
76
- fastify.put('/items/:id', { preHandler: [authenticate] }, async (request, reply) => {
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/plugins/notes/items/:id */
99
- fastify.delete('/items/:id', { preHandler: [authenticate] }, async (request, reply) => {
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/plugins/notes/categories */
112
- fastify.get('/categories', { preHandler: [authenticate] }, async (request, reply) => {
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();