domma-cms 0.55.1 → 0.69.3

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 (305) hide show
  1. package/CLAUDE.md +194 -3
  2. package/README.md +18 -14
  3. package/admin/css/admin.css +1 -1
  4. package/admin/css/contacts.css +1 -0
  5. package/admin/dist/domma/domma-tools.css +3 -3
  6. package/admin/dist/domma/domma-tools.min.js +3 -3
  7. package/admin/index.html +1 -0
  8. package/admin/js/api.js +1 -1
  9. package/admin/js/app.js +4 -4
  10. package/admin/js/lib/analytics-shape.js +1 -0
  11. package/admin/js/lib/contacts-arrange.js +1 -0
  12. package/admin/js/lib/help-popover.js +1 -0
  13. package/admin/js/lib/notes-arrange.js +1 -0
  14. package/admin/js/lib/page-picker.js +1 -0
  15. package/admin/js/lib/plugin-accent.js +1 -0
  16. package/admin/js/lib/plugin-chrome.js +1 -0
  17. package/admin/js/lib/shortcode-context-menu.js +2 -2
  18. package/admin/js/lib/sidebar-grouping.js +1 -1
  19. package/admin/js/lib/sidebar-grouping.test.js +1 -1
  20. package/admin/js/lib/sidebar-renderer.js +4 -4
  21. package/admin/js/lib/slideover-resizable.js +1 -0
  22. package/admin/js/lib/todo-arrange.js +1 -0
  23. package/admin/js/lib/tool-kit.js +1 -0
  24. package/admin/js/templates/analytics.html +138 -0
  25. package/admin/js/templates/contacts.html +156 -0
  26. package/admin/js/templates/context-menu-editor.html +212 -0
  27. package/admin/js/templates/context-menus.html +16 -0
  28. package/admin/js/templates/notes.html +137 -0
  29. package/admin/js/templates/plugin-code.html +1 -1
  30. package/admin/js/templates/plugin-marketplace.html +17 -4
  31. package/admin/js/templates/plugins.html +13 -7
  32. package/{plugins/site-search/admin/templates/site-search.html → admin/js/templates/search.html} +50 -4
  33. package/admin/js/templates/settings.html +16 -97
  34. package/admin/js/templates/theme.html +211 -0
  35. package/admin/js/templates/todo.html +113 -0
  36. package/admin/js/views/analytics.js +62 -0
  37. package/admin/js/views/contacts.js +174 -0
  38. package/admin/js/views/context-menu-editor.js +55 -0
  39. package/admin/js/views/context-menus.js +5 -0
  40. package/admin/js/views/form-editor.js +7 -7
  41. package/admin/js/views/index.js +1 -1
  42. package/admin/js/views/notes.js +104 -0
  43. package/admin/js/views/plugin-marketplace.js +1 -1
  44. package/admin/js/views/plugins.js +28 -24
  45. package/admin/js/views/search.js +1 -0
  46. package/admin/js/views/settings.js +3 -3
  47. package/admin/js/views/theme.js +1 -0
  48. package/admin/js/views/todo.js +96 -0
  49. package/bin/cli.js +6 -10
  50. package/bin/lib/plugin-version.js +28 -0
  51. package/bin/update.js +27 -2
  52. package/config/menus/admin-sidebar.json +129 -23
  53. package/config/plugins.json +5 -5
  54. package/config/search.json +13 -0
  55. package/config/server.json +3 -1
  56. package/package.json +14 -10
  57. package/plugins/_lib/admin/mail/compose-window.js +914 -0
  58. package/plugins/_lib/admin/mail/contacts.js +301 -0
  59. package/plugins/_lib/admin/mail/diagnostics-section.js +133 -0
  60. package/plugins/_lib/admin/mail/folder-tree.js +254 -0
  61. package/plugins/_lib/admin/mail/identity.js +480 -0
  62. package/plugins/_lib/admin/mail/image-senders-section.js +131 -0
  63. package/plugins/_lib/admin/mail/keyboard.js +136 -0
  64. package/plugins/_lib/admin/mail/mail.css +1 -0
  65. package/plugins/_lib/admin/mail/mail.html +71 -0
  66. package/plugins/_lib/admin/mail/panes.js +253 -0
  67. package/plugins/_lib/admin/mail/reader-view.js +4473 -0
  68. package/plugins/_lib/admin/mail/resizable.js +26 -0
  69. package/plugins/_lib/admin/mail/rules.js +343 -0
  70. package/plugins/_lib/admin/mail/scheduling.js +203 -0
  71. package/plugins/_lib/admin/mail/section-kit.js +277 -0
  72. package/plugins/_lib/admin/mail/templates.js +238 -0
  73. package/plugins/_lib/admin/mail/threads.js +200 -0
  74. package/plugins/_lib/admin/mail/vacation.js +269 -0
  75. package/plugins/_lib/admin/ui/help.css +1 -0
  76. package/plugins/_lib/admin/ui/help.js +174 -0
  77. package/plugins/_lib/admin/ui/resizable.js +151 -0
  78. package/plugins/_lib/dataStore.js +117 -0
  79. package/plugins/_lib/mail/accounts.js +919 -0
  80. package/plugins/_lib/mail/bodyTokens.js +101 -0
  81. package/plugins/_lib/mail/compose.js +256 -0
  82. package/plugins/_lib/mail/defaults.js +57 -0
  83. package/plugins/_lib/mail/diagnostics.js +154 -0
  84. package/plugins/_lib/mail/envelope.js +161 -0
  85. package/plugins/_lib/mail/folders.js +192 -0
  86. package/plugins/_lib/mail/handoff.js +274 -0
  87. package/plugins/_lib/mail/imapPool.js +293 -0
  88. package/plugins/_lib/mail/mbox.js +74 -0
  89. package/plugins/_lib/mail/pollSchedule.js +79 -0
  90. package/plugins/_lib/mail/poller.js +135 -0
  91. package/plugins/_lib/mail/priority.js +138 -0
  92. package/plugins/_lib/mail/readRoutes.js +680 -0
  93. package/plugins/_lib/mail/render.js +291 -0
  94. package/plugins/_lib/mail/ruleRunner.js +152 -0
  95. package/plugins/_lib/mail/scheduler.js +254 -0
  96. package/plugins/_lib/mail/secretbox.js +229 -0
  97. package/plugins/_lib/mail/send.js +396 -0
  98. package/plugins/_lib/mail/store.js +1022 -0
  99. package/plugins/_lib/mail/sync.js +292 -0
  100. package/plugins/_lib/mail/syncPlan.js +126 -0
  101. package/plugins/_lib/mail/syncSelection.js +82 -0
  102. package/plugins/_lib/mail/unsubscribe.js +183 -0
  103. package/plugins/_lib/mail/vacationRunner.js +114 -0
  104. package/plugins/_lib/mail/write.js +473 -0
  105. package/plugins/_lib/schemaSync.js +83 -0
  106. package/plugins/_template/admin/css/index.css +0 -0
  107. package/plugins/_template/admin/templates/index.html +4 -4
  108. package/plugins/_template/admin/views/index.js +7 -0
  109. package/plugins/blog/CLAUDE.md +237 -0
  110. package/plugins/blog/admin/css/index.css +178 -0
  111. package/plugins/blog/admin/templates/blog.html +35 -41
  112. package/plugins/blog/admin/templates/categories.html +19 -6
  113. package/plugins/blog/admin/templates/comments.html +26 -9
  114. package/plugins/blog/admin/templates/post-editor.html +63 -40
  115. package/plugins/blog/admin/templates/settings.html +3 -9
  116. package/plugins/blog/admin/views/blog.js +288 -159
  117. package/plugins/blog/admin/views/categories.js +212 -206
  118. package/plugins/blog/admin/views/comments.js +207 -158
  119. package/plugins/blog/admin/views/kit.js +372 -0
  120. package/plugins/blog/admin/views/post-editor.js +306 -39
  121. package/plugins/blog/admin/views/settings-cog.js +56 -0
  122. package/plugins/blog/admin/views/settings.js +16 -84
  123. package/plugins/blog/blocks/blog-card-compact.css +17 -0
  124. package/plugins/blog/blocks/blog-card-compact.html +8 -0
  125. package/plugins/blog/blocks/blog-card-row.css +22 -0
  126. package/plugins/blog/blocks/blog-card-row.html +11 -0
  127. package/plugins/blog/blocks/blog-card.css +35 -0
  128. package/plugins/blog/blocks/blog-card.html +16 -0
  129. package/plugins/blog/blocks/blog-post-classic.css +15 -0
  130. package/plugins/blog/blocks/blog-post-classic.html +28 -0
  131. package/plugins/blog/blocks/blog-post-feature.css +20 -0
  132. package/plugins/blog/blocks/blog-post-feature.html +30 -0
  133. package/plugins/blog/blocks/blog-post-minimal.css +10 -0
  134. package/plugins/blog/blocks/blog-post-minimal.html +23 -0
  135. package/plugins/blog/blocks/blog-post-sidebar.css +22 -0
  136. package/plugins/blog/blocks/blog-post-sidebar.html +36 -0
  137. package/plugins/blog/collections/categories/schema.json +7 -6
  138. package/plugins/blog/collections/comments/schema.json +11 -10
  139. package/plugins/blog/collections/posts/schema.json +16 -13
  140. package/plugins/blog/config.js +6 -0
  141. package/plugins/blog/lib/layouts.js +304 -0
  142. package/plugins/blog/lib/page.js +158 -0
  143. package/plugins/blog/lib/render.js +119 -0
  144. package/plugins/blog/lib/samples.js +234 -0
  145. package/plugins/blog/plugin.js +259 -26
  146. package/plugins/blog/plugin.json +21 -73
  147. package/plugins/blog/plugin.public.js +217 -0
  148. package/plugins/blog/public/blog.css +177 -0
  149. package/plugins/blog/public/blog.js +586 -0
  150. package/plugins/blog/public/samples/aurora.svg +7 -0
  151. package/plugins/blog/public/samples/dusk.svg +7 -0
  152. package/plugins/blog/public/samples/ember.svg +7 -0
  153. package/plugins/blog/public/samples/harbour.svg +7 -0
  154. package/plugins/blog/public/samples/meadow.svg +7 -0
  155. package/plugins/blog/public/samples/rosewood.svg +7 -0
  156. package/plugins/blog/templates/index.html +9 -8
  157. package/plugins/blog/templates/post.html +4 -17
  158. package/plugins/blog/tests/layouts.test.js +97 -0
  159. package/plugins/blog/tests/public.test.js +71 -0
  160. package/plugins/free-tier.lock.json +74 -0
  161. package/plugins/mail-reader/CLAUDE.md +78 -0
  162. package/plugins/mail-reader/admin/views/mail.js +19 -0
  163. package/plugins/mail-reader/config.js +7 -0
  164. package/plugins/mail-reader/plugin.js +48 -0
  165. package/plugins/mail-reader/plugin.json +34 -0
  166. package/public/css/forms.css +1 -1
  167. package/public/css/search.css +1 -0
  168. package/public/css/site.css +1 -1
  169. package/public/css/theme-switcher.css +1 -0
  170. package/public/js/analytics.js +1 -0
  171. package/public/js/collection-context.js +2 -2
  172. package/public/js/context-menus.js +1 -0
  173. package/public/js/form-logic-engine.js +1 -1
  174. package/public/js/forms.js +2 -2
  175. package/public/js/search.js +1 -0
  176. package/public/js/site.js +1 -1
  177. package/public/js/theme-switcher.js +1 -0
  178. package/scripts/build.js +77 -4
  179. package/scripts/copy-domma.js +48 -0
  180. package/scripts/run-tests.mjs +92 -0
  181. package/scripts/seed.js +1996 -0
  182. package/{plugins/analytics/plugin.js → server/routes/api/analytics.js} +89 -41
  183. package/server/routes/api/collections.js +34 -0
  184. package/server/routes/api/contacts.js +506 -0
  185. package/server/routes/api/context-menus.js +104 -0
  186. package/server/routes/api/dashboard.js +8 -2
  187. package/server/routes/api/forms.js +42 -3
  188. package/{plugins/notes/plugin.js → server/routes/api/notes.js} +61 -17
  189. package/server/routes/api/notifications.js +69 -19
  190. package/server/routes/api/plugin-marketplace.js +78 -23
  191. package/server/routes/api/plugins.js +198 -9
  192. package/server/routes/api/search.js +43 -0
  193. package/server/routes/api/theme.js +69 -0
  194. package/server/routes/api/todo.js +178 -0
  195. package/server/routes/public.js +42 -7
  196. package/server/server.js +135 -1
  197. package/server/services/adapters/FileAdapter.js +6 -1
  198. package/server/services/collections.js +13 -3
  199. package/server/services/content.js +26 -0
  200. package/server/services/contextMenus.js +477 -0
  201. package/server/services/managerClient.js +72 -136
  202. package/server/services/markdown.js +124 -10
  203. package/server/services/permissionRegistry.js +74 -0
  204. package/server/services/pluginEntitlement.js +171 -0
  205. package/server/services/pluginEnvelope.js +242 -0
  206. package/server/services/pluginFiles.js +52 -11
  207. package/server/services/pluginInstaller.js +246 -26
  208. package/server/services/pluginScaffold.js +5 -0
  209. package/server/services/plugins.js +340 -7
  210. package/server/services/presetCollections.js +344 -0
  211. package/server/services/promoted-plugins-migration.js +254 -0
  212. package/server/services/publicCors.js +46 -0
  213. package/server/services/renderer.js +224 -22
  214. package/server/services/roles.js +1 -1
  215. package/server/services/search-migration.js +82 -0
  216. package/server/services/search.js +413 -0
  217. package/server/services/sidebar-migration.js +1 -0
  218. package/server/services/sidebarBadges.js +37 -0
  219. package/server/services/siteGitignore.js +307 -0
  220. package/server/services/themeSettings.js +580 -0
  221. package/server/services/users.js +8 -0
  222. package/server/templates/page.html +4 -2
  223. package/plugins/analytics/admin/templates/analytics.html +0 -61
  224. package/plugins/analytics/admin/views/analytics.js +0 -176
  225. package/plugins/analytics/config.js +0 -14
  226. package/plugins/analytics/plugin.json +0 -57
  227. package/plugins/analytics/public/inject-body.html +0 -60
  228. package/plugins/analytics/public/inject-head.html +0 -1
  229. package/plugins/blog/templates/author.html +0 -9
  230. package/plugins/blog/templates/category.html +0 -9
  231. package/plugins/blog/templates/tag.html +0 -9
  232. package/plugins/contacts/admin/templates/contacts.html +0 -126
  233. package/plugins/contacts/admin/views/contacts.js +0 -710
  234. package/plugins/contacts/collections/user-contact-groups/schema.json +0 -35
  235. package/plugins/contacts/collections/user-contacts/schema.json +0 -71
  236. package/plugins/contacts/config.js +0 -6
  237. package/plugins/contacts/data/contacts.json +0 -20
  238. package/plugins/contacts/plugin.js +0 -291
  239. package/plugins/contacts/plugin.json +0 -33
  240. package/plugins/demo-viewer/config.js +0 -4
  241. package/plugins/demo-viewer/plugin.js +0 -36
  242. package/plugins/demo-viewer/plugin.json +0 -9
  243. package/plugins/invoice/admin/templates/editor.html +0 -129
  244. package/plugins/invoice/admin/templates/index.html +0 -43
  245. package/plugins/invoice/admin/templates/issuers.html +0 -5
  246. package/plugins/invoice/admin/templates/receivers.html +0 -5
  247. package/plugins/invoice/admin/views/editor.js +0 -267
  248. package/plugins/invoice/admin/views/index.js +0 -155
  249. package/plugins/invoice/admin/views/issuers.js +0 -23
  250. package/plugins/invoice/admin/views/party-view.js +0 -148
  251. package/plugins/invoice/admin/views/receivers.js +0 -22
  252. package/plugins/invoice/collections/invoice-issuers/schema.json +0 -16
  253. package/plugins/invoice/collections/invoice-receivers/schema.json +0 -15
  254. package/plugins/invoice/collections/invoices/schema.json +0 -27
  255. package/plugins/invoice/config.js +0 -16
  256. package/plugins/invoice/plugin.js +0 -283
  257. package/plugins/invoice/plugin.json +0 -85
  258. package/plugins/invoice/templates/invoice-print.html +0 -213
  259. package/plugins/notes/admin/templates/notes.html +0 -83
  260. package/plugins/notes/admin/views/notes.js +0 -281
  261. package/plugins/notes/collections/user-notes/schema.json +0 -54
  262. package/plugins/notes/config.js +0 -6
  263. package/plugins/notes/data/notes.json +0 -1
  264. package/plugins/notes/plugin.json +0 -33
  265. package/plugins/site-search/admin/views/site-search.js +0 -116
  266. package/plugins/site-search/config.js +0 -15
  267. package/plugins/site-search/plugin.js +0 -188
  268. package/plugins/site-search/plugin.json +0 -40
  269. package/plugins/site-search/public/inject-body.html +0 -17
  270. package/plugins/site-search/public/inject-head.html +0 -1
  271. package/plugins/site-search/public/search.css +0 -1
  272. package/plugins/site-search/public/search.js +0 -1
  273. package/plugins/surveys/admin/templates/audience.html +0 -47
  274. package/plugins/surveys/admin/templates/results.html +0 -58
  275. package/plugins/surveys/admin/templates/survey-editor.html +0 -141
  276. package/plugins/surveys/admin/templates/surveys.html +0 -25
  277. package/plugins/surveys/admin/views/audience.js +0 -301
  278. package/plugins/surveys/admin/views/results.js +0 -172
  279. package/plugins/surveys/admin/views/survey-editor.js +0 -211
  280. package/plugins/surveys/admin/views/surveys.js +0 -161
  281. package/plugins/surveys/collections/survey-contacts/schema.json +0 -13
  282. package/plugins/surveys/collections/survey-groups/schema.json +0 -11
  283. package/plugins/surveys/collections/survey-invites/schema.json +0 -16
  284. package/plugins/surveys/collections/surveys/schema.json +0 -23
  285. package/plugins/surveys/config.js +0 -8
  286. package/plugins/surveys/plugin.js +0 -174
  287. package/plugins/surveys/plugin.json +0 -65
  288. package/plugins/surveys/public/dist-shared.mjs +0 -1
  289. package/plugins/surveys/public/survey.css +0 -1
  290. package/plugins/surveys/public/survey.mjs +0 -1
  291. package/plugins/surveys/templates/survey-page.html +0 -63
  292. package/plugins/theme-switcher/admin/templates/theme-switcher.html +0 -86
  293. package/plugins/theme-switcher/admin/views/theme-switcher.js +0 -65
  294. package/plugins/theme-switcher/config.js +0 -10
  295. package/plugins/theme-switcher/plugin.js +0 -26
  296. package/plugins/theme-switcher/plugin.json +0 -74
  297. package/plugins/theme-switcher/public/inject-body.html +0 -153
  298. package/plugins/theme-switcher/public/inject-head.html +0 -260
  299. package/plugins/todo/admin/templates/todo.html +0 -158
  300. package/plugins/todo/admin/views/todo.js +0 -343
  301. package/plugins/todo/collections/todos/schema.json +0 -60
  302. package/plugins/todo/config.js +0 -7
  303. package/plugins/todo/data/todos.json +0 -1
  304. package/plugins/todo/plugin.js +0 -102
  305. package/plugins/todo/plugin.json +0 -33
@@ -0,0 +1,506 @@
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';
29
+
30
+ const CONTACTS_SLUG = 'contacts-contacts';
31
+ const GROUPS_SLUG = 'contacts-groups';
32
+
33
+ /**
34
+ * The fields a contact carries beyond a name and a phone number.
35
+ *
36
+ * Kept as one list so the create, update and import paths cannot drift apart -
37
+ * they each used to spell the field set out by hand, which is how a field gets
38
+ * added to two of the three and quietly dropped by the other.
39
+ */
40
+ const TEXT_FIELDS = [
41
+ 'name', 'email', 'phone', 'notes',
42
+ 'company', 'jobTitle', 'website',
43
+ 'vatNumber', 'companyNumber', 'reference', 'billingEmail', 'currency',
44
+ 'accountName', 'bankName', 'sortCode', 'accountNumber', 'iban', 'bic'
45
+ ];
46
+
47
+ /** Repeatable lists, each a array of small objects. */
48
+ const LIST_FIELDS = ['addresses', 'emails', 'phones'];
49
+
50
+ /** Parse a value that may have been stored as JSON text by an import or the API. */
51
+ function toArray(value) {
52
+ if (Array.isArray(value)) return value;
53
+ if (typeof value === 'string' && value.trim()) {
54
+ try {
55
+ const parsed = JSON.parse(value);
56
+ return Array.isArray(parsed) ? parsed : [];
57
+ } catch {
58
+ return [];
59
+ }
60
+ }
61
+ return [];
62
+ }
63
+
64
+ const str = (v) => (typeof v === 'string' ? v.trim() : (v === undefined || v === null ? '' : String(v)));
65
+
66
+ /**
67
+ * One address, with every part named.
68
+ *
69
+ * Stored as parts rather than a blob because an invoice, a letter and a
70
+ * delivery note each want them arranged differently - and because a postcode
71
+ * you cannot read out separately is not much use for anything.
72
+ */
73
+ function cleanAddress(raw) {
74
+ return {
75
+ label: str(raw?.label) || 'Address',
76
+ line1: str(raw?.line1),
77
+ line2: str(raw?.line2),
78
+ city: str(raw?.city),
79
+ region: str(raw?.region),
80
+ postcode: str(raw?.postcode),
81
+ country: str(raw?.country),
82
+ isBilling: raw?.isBilling === true || raw?.isBilling === 'true'
83
+ };
84
+ }
85
+
86
+ function cleanEmail(raw) {
87
+ return { label: str(raw?.label) || 'Email', address: str(raw?.address) };
88
+ }
89
+
90
+ function cleanPhone(raw) {
91
+ return { label: str(raw?.label) || 'Phone', number: str(raw?.number) };
92
+ }
93
+
94
+ const CLEANERS = { addresses: cleanAddress, emails: cleanEmail, phones: cleanPhone };
95
+
96
+ /** Coerce a repeatable list, dropping rows with nothing in them. */
97
+ function cleanList(field, value) {
98
+ const clean = CLEANERS[field];
99
+ return toArray(value)
100
+ .map(clean)
101
+ .filter((row) => Object.entries(row).some(([k, v]) => k !== 'label' && v !== '' && v !== false));
102
+ }
103
+
104
+ /**
105
+ * An address as an invoice prints it: one line per part, blanks omitted.
106
+ *
107
+ * @param {object} address
108
+ * @returns {string}
109
+ */
110
+ export function formatAddress(address) {
111
+ if (!address) return '';
112
+ return [address.line1, address.line2, address.city, address.region, address.postcode, address.country]
113
+ .map(str)
114
+ .filter(Boolean)
115
+ .join('\n');
116
+ }
117
+
118
+ /**
119
+ * The address to bill to: the one marked for billing, else the first.
120
+ *
121
+ * @param {object[]} addresses
122
+ * @returns {object|null}
123
+ */
124
+ export function billingAddress(addresses) {
125
+ const list = toArray(addresses);
126
+ return list.find((a) => a?.isBilling) ?? list[0] ?? null;
127
+ }
128
+
129
+ /**
130
+ * A contact projected into the shape the Invoice plugin's receivers use.
131
+ *
132
+ * This is the join between the two plugins: the field names are Invoice's, not
133
+ * Contacts', so the caller can post it straight at `/api/plugins/invoice/receivers`.
134
+ *
135
+ * @param {object} contact as returned by toContact()
136
+ * @returns {object}
137
+ */
138
+ export function toBillingProfile(contact) {
139
+ const address = billingAddress(contact.addresses);
140
+ return {
141
+ name: contact.name,
142
+ company: contact.company,
143
+ // The billing address wins over the personal one, which is the whole
144
+ // point of having both - invoices go to accounts, not to a mobile.
145
+ email: contact.billingEmail || contact.email,
146
+ phone: contact.phone,
147
+ address: formatAddress(address),
148
+ vatNumber: contact.vatNumber,
149
+ reference: contact.reference,
150
+ // An issuer prints these so the invoice can be paid; a receiver simply
151
+ // has none, and an empty string is what the Invoice plugin stores anyway.
152
+ bankDetails: formatBankDetails(contact)
153
+ };
154
+ }
155
+
156
+ /**
157
+ * Bank details as an invoice prints them: label and value, one per line,
158
+ * blanks omitted. Sort code and account number ride together on one line
159
+ * because that is how anyone reads them off a page.
160
+ *
161
+ * @param {object} contact
162
+ * @returns {string}
163
+ */
164
+ export function formatBankDetails(contact) {
165
+ const lines = [];
166
+ if (contact.accountName) lines.push(contact.accountName);
167
+ if (contact.bankName) lines.push(contact.bankName);
168
+
169
+ const sort = str(contact.sortCode);
170
+ const acct = str(contact.accountNumber);
171
+ if (sort && acct) lines.push(`Sort ${sort} Acc ${acct}`);
172
+ else if (sort) lines.push(`Sort ${sort}`);
173
+ else if (acct) lines.push(`Acc ${acct}`);
174
+
175
+ if (contact.iban) lines.push(`IBAN ${str(contact.iban)}`);
176
+ if (contact.bic) lines.push(`BIC ${str(contact.bic)}`);
177
+ return lines.join('\n');
178
+ }
179
+
180
+ /** Map a collection entry to the shape the admin view expects. */
181
+ function toContact(entry) {
182
+ let groups = entry.data.groups;
183
+ if (typeof groups === 'string') {
184
+ try {
185
+ groups = JSON.parse(groups);
186
+ } catch {
187
+ groups = [];
188
+ }
189
+ }
190
+ const contact = {
191
+ id: entry.id,
192
+ groups: Array.isArray(groups) ? groups : [],
193
+ favourite: entry.data.favourite === true || entry.data.favourite === 'true',
194
+ paymentTerms: entry.data.paymentTerms === '' || entry.data.paymentTerms === undefined || entry.data.paymentTerms === null
195
+ ? null
196
+ : Number(entry.data.paymentTerms),
197
+ userId: entry.data.userId ?? null,
198
+ createdAt: entry.meta.createdAt,
199
+ updatedAt: entry.meta.updatedAt
200
+ };
201
+ for (const field of TEXT_FIELDS) contact[field] = entry.data[field] ?? '';
202
+ for (const field of LIST_FIELDS) contact[field] = toArray(entry.data[field]);
203
+ return contact;
204
+ }
205
+
206
+ function toGroupName(entry) {
207
+ return entry.data.name ?? '';
208
+ }
209
+
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;
219
+
220
+
221
+ function userId(request) {
222
+ return request.user?.id ?? request.user?.sub ?? null;
223
+ }
224
+
225
+ async function loadContacts(uid) {
226
+ const {entries} = await listEntries(CONTACTS_SLUG, {limit: 100000, sort: 'createdAt', order: 'asc'});
227
+ let items = entries.map(toContact);
228
+ if (uid) items = items.filter(c => c.userId === uid);
229
+ return items;
230
+ }
231
+
232
+ async function loadGroups() {
233
+ const {entries} = await listEntries(GROUPS_SLUG, {limit: 10000, sort: 'createdAt', order: 'asc'});
234
+ return entries.map(e => ({id: e.id, name: toGroupName(e)}));
235
+ }
236
+
237
+ /**
238
+ * Build the stored shape from whatever a caller sent.
239
+ *
240
+ * Used by create AND import so the two cannot drift - a field added to one
241
+ * and forgotten in the other is the bug this exists to prevent.
242
+ *
243
+ * @param {object} raw
244
+ * @param {string|null} uid
245
+ * @returns {object}
246
+ */
247
+ function buildContact(raw, uid) {
248
+ const data = {
249
+ groups: Array.isArray(raw.groups) ? raw.groups : [],
250
+ favourite: raw.favourite === true || raw.favourite === 'true',
251
+ paymentTerms: raw.paymentTerms === '' || raw.paymentTerms === undefined || raw.paymentTerms === null
252
+ ? ''
253
+ : (Number(raw.paymentTerms) || 0),
254
+ userId: uid ?? null
255
+ };
256
+ for (const field of TEXT_FIELDS) data[field] = str(raw[field]);
257
+ for (const field of LIST_FIELDS) data[field] = cleanList(field, raw[field]);
258
+ return data;
259
+ }
260
+
261
+ // -------------------------------------------------------------------------
262
+ // Contacts CRUD
263
+ // -------------------------------------------------------------------------
264
+
265
+ /** GET /api/contacts/contacts - supports ?q=, ?group=, ?favourites=true */
266
+ fastify.get('/contacts', {preHandler: [authenticate, requirePermission('contacts', 'read')]}, async (request, reply) => {
267
+ const uid = userId(request);
268
+ const { q, group, favourites } = request.query;
269
+
270
+ let result = await loadContacts(uid);
271
+
272
+ if (q) {
273
+ const query = q.toLowerCase();
274
+ result = result.filter(c =>
275
+ c.name.toLowerCase().includes(query) ||
276
+ c.email.toLowerCase().includes(query) ||
277
+ c.phone.includes(query)
278
+ );
279
+ }
280
+ if (group) {
281
+ result = result.filter(c => Array.isArray(c.groups) && c.groups.includes(group));
282
+ }
283
+ if (favourites === 'true') {
284
+ result = result.filter(c => c.favourite === true);
285
+ }
286
+
287
+ result.sort((a, b) => a.name.localeCompare(b.name));
288
+ return reply.send(result);
289
+ });
290
+
291
+ /** POST /api/contacts/contacts */
292
+ fastify.post('/contacts', {preHandler: [authenticate, requirePermission('contacts', 'create')]}, async (request, reply) => {
293
+ const uid = userId(request);
294
+ const body = request.body ?? {};
295
+
296
+ if (!body.name || typeof body.name !== 'string' || !body.name.trim()) {
297
+ return reply.code(400).send({ error: 'name is required' });
298
+ }
299
+
300
+ const entry = await createEntry(CONTACTS_SLUG, buildContact(body, uid),
301
+ {createdBy: uid, source: 'admin'});
302
+
303
+ return reply.code(201).send(toContact(entry));
304
+ });
305
+
306
+ /** PUT /api/contacts/contacts/:id */
307
+ fastify.put('/contacts/:id', {preHandler: [authenticate, requirePermission('contacts', 'update')]}, async (request, reply) => {
308
+ const uid = userId(request);
309
+ const { id } = request.params;
310
+ const updates = request.body ?? {};
311
+
312
+ const entry = await getEntry(CONTACTS_SLUG, id);
313
+ if (!entry) return reply.code(404).send({error: 'Contact not found'});
314
+ if (uid && entry.data.userId !== uid) return reply.code(404).send({error: 'Contact not found'});
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);
320
+ const merged = {...entry.data};
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];
326
+ }
327
+ for (const key of LIST_FIELDS) {
328
+ if (key in updates) merged[key] = cleanList(key, updates[key]);
329
+ }
330
+
331
+ const updated = await updateEntry(CONTACTS_SLUG, id, merged);
332
+ return reply.send(toContact(updated));
333
+ });
334
+
335
+ /** DELETE /api/contacts/contacts/bulk - must be before /:id */
336
+ fastify.delete('/contacts/bulk', {preHandler: [authenticate, requirePermission('contacts', 'delete')]}, async (request, reply) => {
337
+ const uid = userId(request);
338
+ const { ids } = request.body ?? {};
339
+
340
+ if (!Array.isArray(ids) || ids.length === 0) {
341
+ return reply.code(400).send({ error: 'ids array is required' });
342
+ }
343
+
344
+ let deleted = 0;
345
+ for (const id of ids) {
346
+ const entry = await getEntry(CONTACTS_SLUG, id).catch(() => null);
347
+ if (!entry) continue;
348
+ if (uid && entry.data.userId !== uid) continue;
349
+ await deleteEntry(CONTACTS_SLUG, id).catch(() => {
350
+ });
351
+ deleted++;
352
+ }
353
+
354
+ return reply.send({ deleted });
355
+ });
356
+
357
+ /** DELETE /api/contacts/contacts/:id */
358
+ fastify.delete('/contacts/:id', {preHandler: [authenticate, requirePermission('contacts', 'delete')]}, async (request, reply) => {
359
+ const uid = userId(request);
360
+ const { id } = request.params;
361
+
362
+ const entry = await getEntry(CONTACTS_SLUG, id);
363
+ if (!entry) return reply.code(404).send({error: 'Contact not found'});
364
+ if (uid && entry.data.userId !== uid) return reply.code(404).send({error: 'Contact not found'});
365
+
366
+ await deleteEntry(CONTACTS_SLUG, id);
367
+ return reply.send({ ok: true });
368
+ });
369
+
370
+ /**
371
+ * GET /api/contacts/contacts/:id/billing
372
+ *
373
+ * The contact as the Invoice plugin wants a receiver: same field names, so
374
+ * the body can go straight to POST /api/plugins/invoice/receivers. Nothing
375
+ * in the CMS calls this yet - it is the seam, put where the data is.
376
+ */
377
+ fastify.get('/contacts/:id/billing', {preHandler: [authenticate, requirePermission('contacts', 'read')]}, async (request, reply) => {
378
+ const uid = userId(request);
379
+ const entry = await getEntry(CONTACTS_SLUG, request.params.id);
380
+ if (!entry) return reply.code(404).send({error: 'Contact not found'});
381
+ if (uid && entry.data.userId !== uid) return reply.code(404).send({error: 'Contact not found'});
382
+ return reply.send(toBillingProfile(toContact(entry)));
383
+ });
384
+
385
+ // -------------------------------------------------------------------------
386
+ // Groups CRUD
387
+ // -------------------------------------------------------------------------
388
+
389
+ /** GET /api/contacts/groups */
390
+ fastify.get('/groups', {preHandler: [authenticate, requirePermission('contacts', 'read')]}, async (request, reply) => {
391
+ const groups = await loadGroups();
392
+ return reply.send(groups.map(g => g.name));
393
+ });
394
+
395
+ /** POST /api/contacts/groups */
396
+ fastify.post('/groups', {preHandler: [authenticate, requirePermission('contacts', 'create')]}, async (request, reply) => {
397
+ const { name } = request.body ?? {};
398
+
399
+ if (!name || typeof name !== 'string' || !name.trim()) {
400
+ return reply.code(400).send({ error: 'name is required' });
401
+ }
402
+
403
+ const trimmed = name.trim();
404
+ const existing = await loadGroups();
405
+ if (existing.some(g => g.name === trimmed)) {
406
+ return reply.code(400).send({ error: 'Group already exists' });
407
+ }
408
+
409
+ await createEntry(GROUPS_SLUG, {name: trimmed}, {source: 'admin'});
410
+ return reply.code(201).send({ name: trimmed });
411
+ });
412
+
413
+ /** PUT /api/contacts/groups/:name - rename + cascade */
414
+ fastify.put('/groups/:name', {preHandler: [authenticate, requirePermission('contacts', 'update')]}, async (request, reply) => {
415
+ const oldName = decodeURIComponent(request.params.name);
416
+ const { newName } = request.body ?? {};
417
+
418
+ if (!newName || typeof newName !== 'string' || !newName.trim()) {
419
+ return reply.code(400).send({ error: 'newName is required' });
420
+ }
421
+
422
+ const groups = await loadGroups();
423
+ const grpEntry = groups.find(g => g.name === oldName);
424
+ if (!grpEntry) return reply.code(404).send({error: 'Group not found'});
425
+
426
+ const trimmed = newName.trim();
427
+ if (groups.some(g => g.name === trimmed)) {
428
+ return reply.code(400).send({ error: 'A group with that name already exists' });
429
+ }
430
+
431
+ // Rename the group entry
432
+ await updateEntry(GROUPS_SLUG, grpEntry.id, {name: trimmed});
433
+
434
+ // Cascade rename across all contacts
435
+ const {entries} = await listEntries(CONTACTS_SLUG, {limit: 100000});
436
+ for (const entry of entries) {
437
+ const grps = Array.isArray(entry.data.groups) ? entry.data.groups : [];
438
+ if (grps.includes(oldName)) {
439
+ await updateEntry(CONTACTS_SLUG, entry.id, {
440
+ ...entry.data,
441
+ groups: grps.map(g => (g === oldName ? trimmed : g))
442
+ });
443
+ }
444
+ }
445
+
446
+ return reply.send({ name: trimmed });
447
+ });
448
+
449
+ /** DELETE /api/contacts/groups/:name - delete + cascade */
450
+ fastify.delete('/groups/:name', {preHandler: [authenticate, requirePermission('contacts', 'delete')]}, async (request, reply) => {
451
+ const name = decodeURIComponent(request.params.name);
452
+
453
+ const groups = await loadGroups();
454
+ const grpEntry = groups.find(g => g.name === name);
455
+ if (!grpEntry) return reply.code(404).send({error: 'Group not found'});
456
+
457
+ // Remove the group entry
458
+ await deleteEntry(GROUPS_SLUG, grpEntry.id);
459
+
460
+ // Cascade: strip group from all contacts
461
+ const {entries} = await listEntries(CONTACTS_SLUG, {limit: 100000});
462
+ for (const entry of entries) {
463
+ const grps = Array.isArray(entry.data.groups) ? entry.data.groups : [];
464
+ if (grps.includes(name)) {
465
+ await updateEntry(CONTACTS_SLUG, entry.id, {
466
+ ...entry.data,
467
+ groups: grps.filter(g => g !== name)
468
+ });
469
+ }
470
+ }
471
+
472
+ return reply.send({ ok: true });
473
+ });
474
+
475
+ // -------------------------------------------------------------------------
476
+ // Import / Export
477
+ // -------------------------------------------------------------------------
478
+
479
+ /** GET /api/contacts/export */
480
+ fastify.get('/export', {preHandler: [authenticate, requirePermission('contacts', 'read')]}, async (request, reply) => {
481
+ const uid = userId(request);
482
+ const contacts = await loadContacts(uid);
483
+ return reply
484
+ .header('Content-Disposition', 'attachment; filename="contacts.json"')
485
+ .send(contacts);
486
+ });
487
+
488
+ /** POST /api/contacts/import */
489
+ fastify.post('/import', {preHandler: [authenticate, requirePermission('contacts', 'create')]}, async (request, reply) => {
490
+ const uid = userId(request);
491
+ const { contacts: incoming } = request.body ?? {};
492
+
493
+ if (!Array.isArray(incoming)) {
494
+ return reply.code(400).send({ error: 'contacts array is required' });
495
+ }
496
+
497
+ let imported = 0;
498
+ for (const raw of incoming) {
499
+ if (!raw || typeof raw.name !== 'string') continue;
500
+ await createEntry(CONTACTS_SLUG, buildContact(raw, uid), {source: 'import'});
501
+ imported++;
502
+ }
503
+
504
+ return reply.send({ imported });
505
+ });
506
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Context menus API
3
+ *
4
+ * GET /api/context-menus - list (metadata only)
5
+ * GET /api/context-menus/:slug - full menu
6
+ * POST /api/context-menus - create
7
+ * PUT /api/context-menus/:slug - replace
8
+ * DELETE /api/context-menus/:slug - delete
9
+ * POST /api/context-menus/:slug/duplicate - copy as <slug>-copy
10
+ *
11
+ * Auth middlewares are accepted as options so tests can supply no-ops without
12
+ * needing @fastify/jwt and a seeded roles cache - the same DI pattern as
13
+ * menus.js and projects.js.
14
+ */
15
+ import {
16
+ authenticate as defaultAuthenticate,
17
+ requirePermission as defaultRequirePermission
18
+ } from '../../middleware/auth.js';
19
+ import {
20
+ ACTION_TYPES,
21
+ createContextMenu,
22
+ deleteContextMenu,
23
+ duplicateContextMenu,
24
+ getContextMenu,
25
+ listContextMenus,
26
+ updateContextMenu
27
+ } from '../../services/contextMenus.js';
28
+
29
+ export async function contextMenusRoutes(fastify, opts = {}) {
30
+ const authenticate = opts.authenticate || defaultAuthenticate;
31
+ const requirePermission = opts.requirePermission || defaultRequirePermission;
32
+
33
+ const canRead = {preHandler: [authenticate, requirePermission('context-menus', 'read')]};
34
+ const canCreate = {preHandler: [authenticate, requirePermission('context-menus', 'create')]};
35
+ const canUpdate = {preHandler: [authenticate, requirePermission('context-menus', 'update')]};
36
+ const canDelete = {preHandler: [authenticate, requirePermission('context-menus', 'delete')]};
37
+
38
+ // The editor builds its action picker from this rather than hardcoding a
39
+ // list that would drift from the server's validation.
40
+ fastify.get('/context-menus/action-types', canRead, async () => [...ACTION_TYPES]);
41
+
42
+ fastify.get('/context-menus', canRead, async (request) => {
43
+ const {canSeeArtefact} = await import('../../services/projects.js');
44
+ const all = await listContextMenus();
45
+ return all.filter((m) => canSeeArtefact(request.user, m));
46
+ });
47
+
48
+ fastify.get('/context-menus/:slug', canRead, async (request, reply) => {
49
+ const menu = await getContextMenu(request.params.slug);
50
+ if (!menu) return reply.code(404).send({error: 'Context menu not found'});
51
+
52
+ const {canSeeArtefact} = await import('../../services/projects.js');
53
+ if (!canSeeArtefact(request.user, menu)) {
54
+ return reply.code(403).send({error: 'Outside your project scope'});
55
+ }
56
+ return menu;
57
+ });
58
+
59
+ fastify.post('/context-menus', canCreate, async (request, reply) => {
60
+ try {
61
+ return await createContextMenu(request.body || {});
62
+ } catch (err) {
63
+ // Validation and collision are both the caller's fault, and the
64
+ // message already names the field - passing it through is what
65
+ // makes the editor able to show it.
66
+ return reply.code(400).send({error: err.message});
67
+ }
68
+ });
69
+
70
+ fastify.put('/context-menus/:slug', canUpdate, async (request, reply) => {
71
+ const existing = await getContextMenu(request.params.slug);
72
+ if (!existing) return reply.code(404).send({error: 'Context menu not found'});
73
+
74
+ const {canSeeArtefact} = await import('../../services/projects.js');
75
+ if (!canSeeArtefact(request.user, existing)) {
76
+ return reply.code(403).send({error: 'Outside your project scope'});
77
+ }
78
+ try {
79
+ return await updateContextMenu(request.params.slug, request.body || {});
80
+ } catch (err) {
81
+ return reply.code(400).send({error: err.message});
82
+ }
83
+ });
84
+
85
+ fastify.delete('/context-menus/:slug', canDelete, async (request, reply) => {
86
+ const existing = await getContextMenu(request.params.slug);
87
+ if (!existing) return reply.code(404).send({error: 'Context menu not found'});
88
+
89
+ const {canSeeArtefact} = await import('../../services/projects.js');
90
+ if (!canSeeArtefact(request.user, existing)) {
91
+ return reply.code(403).send({error: 'Outside your project scope'});
92
+ }
93
+ await deleteContextMenu(request.params.slug);
94
+ return {deleted: true};
95
+ });
96
+
97
+ fastify.post('/context-menus/:slug/duplicate', canCreate, async (request, reply) => {
98
+ try {
99
+ return await duplicateContextMenu(request.params.slug);
100
+ } catch (err) {
101
+ return reply.code(400).send({error: err.message});
102
+ }
103
+ });
104
+ }
@@ -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
  }