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.
- package/CLAUDE.md +194 -3
- package/README.md +18 -14
- package/admin/css/admin.css +1 -1
- package/admin/css/contacts.css +1 -0
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- 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/page-picker.js +1 -0
- package/admin/js/lib/plugin-accent.js +1 -0
- package/admin/js/lib/plugin-chrome.js +1 -0
- package/admin/js/lib/shortcode-context-menu.js +2 -2
- 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 +4 -4
- package/admin/js/lib/slideover-resizable.js +1 -0
- 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/context-menu-editor.html +212 -0
- package/admin/js/templates/context-menus.html +16 -0
- package/admin/js/templates/notes.html +137 -0
- package/admin/js/templates/plugin-code.html +1 -1
- package/admin/js/templates/plugin-marketplace.html +17 -4
- package/admin/js/templates/plugins.html +13 -7
- package/{plugins/site-search/admin/templates/site-search.html → admin/js/templates/search.html} +50 -4
- package/admin/js/templates/settings.html +16 -97
- package/admin/js/templates/theme.html +211 -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/context-menu-editor.js +55 -0
- package/admin/js/views/context-menus.js +5 -0
- package/admin/js/views/form-editor.js +7 -7
- 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 +28 -24
- package/admin/js/views/search.js +1 -0
- package/admin/js/views/settings.js +3 -3
- package/admin/js/views/theme.js +1 -0
- package/admin/js/views/todo.js +96 -0
- package/bin/cli.js +6 -10
- package/bin/lib/plugin-version.js +28 -0
- package/bin/update.js +27 -2
- package/config/menus/admin-sidebar.json +129 -23
- package/config/plugins.json +5 -5
- package/config/search.json +13 -0
- package/config/server.json +3 -1
- package/package.json +14 -10
- package/plugins/_lib/admin/mail/compose-window.js +914 -0
- package/plugins/_lib/admin/mail/contacts.js +301 -0
- package/plugins/_lib/admin/mail/diagnostics-section.js +133 -0
- package/plugins/_lib/admin/mail/folder-tree.js +254 -0
- package/plugins/_lib/admin/mail/identity.js +480 -0
- package/plugins/_lib/admin/mail/image-senders-section.js +131 -0
- package/plugins/_lib/admin/mail/keyboard.js +136 -0
- package/plugins/_lib/admin/mail/mail.css +1 -0
- package/plugins/_lib/admin/mail/mail.html +71 -0
- package/plugins/_lib/admin/mail/panes.js +253 -0
- package/plugins/_lib/admin/mail/reader-view.js +4473 -0
- package/plugins/_lib/admin/mail/resizable.js +26 -0
- package/plugins/_lib/admin/mail/rules.js +343 -0
- package/plugins/_lib/admin/mail/scheduling.js +203 -0
- package/plugins/_lib/admin/mail/section-kit.js +277 -0
- package/plugins/_lib/admin/mail/templates.js +238 -0
- package/plugins/_lib/admin/mail/threads.js +200 -0
- package/plugins/_lib/admin/mail/vacation.js +269 -0
- package/plugins/_lib/admin/ui/help.css +1 -0
- package/plugins/_lib/admin/ui/help.js +174 -0
- package/plugins/_lib/admin/ui/resizable.js +151 -0
- package/plugins/_lib/dataStore.js +117 -0
- package/plugins/_lib/mail/accounts.js +919 -0
- package/plugins/_lib/mail/bodyTokens.js +101 -0
- package/plugins/_lib/mail/compose.js +256 -0
- package/plugins/_lib/mail/defaults.js +57 -0
- package/plugins/_lib/mail/diagnostics.js +154 -0
- package/plugins/_lib/mail/envelope.js +161 -0
- package/plugins/_lib/mail/folders.js +192 -0
- package/plugins/_lib/mail/handoff.js +274 -0
- package/plugins/_lib/mail/imapPool.js +293 -0
- package/plugins/_lib/mail/mbox.js +74 -0
- package/plugins/_lib/mail/pollSchedule.js +79 -0
- package/plugins/_lib/mail/poller.js +135 -0
- package/plugins/_lib/mail/priority.js +138 -0
- package/plugins/_lib/mail/readRoutes.js +680 -0
- package/plugins/_lib/mail/render.js +291 -0
- package/plugins/_lib/mail/ruleRunner.js +152 -0
- package/plugins/_lib/mail/scheduler.js +254 -0
- package/plugins/_lib/mail/secretbox.js +229 -0
- package/plugins/_lib/mail/send.js +396 -0
- package/plugins/_lib/mail/store.js +1022 -0
- package/plugins/_lib/mail/sync.js +292 -0
- package/plugins/_lib/mail/syncPlan.js +126 -0
- package/plugins/_lib/mail/syncSelection.js +82 -0
- package/plugins/_lib/mail/unsubscribe.js +183 -0
- package/plugins/_lib/mail/vacationRunner.js +114 -0
- package/plugins/_lib/mail/write.js +473 -0
- package/plugins/_lib/schemaSync.js +83 -0
- package/plugins/_template/admin/css/index.css +0 -0
- package/plugins/_template/admin/templates/index.html +4 -4
- package/plugins/_template/admin/views/index.js +7 -0
- package/plugins/blog/CLAUDE.md +237 -0
- package/plugins/blog/admin/css/index.css +178 -0
- package/plugins/blog/admin/templates/blog.html +35 -41
- 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 +63 -40
- package/plugins/blog/admin/templates/settings.html +3 -9
- package/plugins/blog/admin/views/blog.js +288 -159
- package/plugins/blog/admin/views/categories.js +212 -206
- 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 +306 -39
- package/plugins/blog/admin/views/settings-cog.js +56 -0
- package/plugins/blog/admin/views/settings.js +16 -84
- 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/categories/schema.json +7 -6
- package/plugins/blog/collections/comments/schema.json +11 -10
- package/plugins/blog/collections/posts/schema.json +16 -13
- 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 +259 -26
- package/plugins/blog/plugin.json +21 -73
- package/plugins/blog/plugin.public.js +217 -0
- 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/admin/views/mail.js +19 -0
- package/plugins/mail-reader/config.js +7 -0
- package/plugins/mail-reader/plugin.js +48 -0
- package/plugins/mail-reader/plugin.json +34 -0
- package/public/css/forms.css +1 -1
- package/public/css/search.css +1 -0
- package/public/css/site.css +1 -1
- package/public/css/theme-switcher.css +1 -0
- package/public/js/analytics.js +1 -0
- package/public/js/collection-context.js +2 -2
- package/public/js/context-menus.js +1 -0
- package/public/js/form-logic-engine.js +1 -1
- package/public/js/forms.js +2 -2
- package/public/js/search.js +1 -0
- package/public/js/site.js +1 -1
- package/public/js/theme-switcher.js +1 -0
- package/scripts/build.js +77 -4
- package/scripts/copy-domma.js +48 -0
- package/scripts/run-tests.mjs +92 -0
- package/scripts/seed.js +1996 -0
- package/{plugins/analytics/plugin.js → server/routes/api/analytics.js} +89 -41
- package/server/routes/api/collections.js +34 -0
- package/server/routes/api/contacts.js +506 -0
- package/server/routes/api/context-menus.js +104 -0
- package/server/routes/api/dashboard.js +8 -2
- package/server/routes/api/forms.js +42 -3
- package/{plugins/notes/plugin.js → server/routes/api/notes.js} +61 -17
- package/server/routes/api/notifications.js +69 -19
- package/server/routes/api/plugin-marketplace.js +78 -23
- package/server/routes/api/plugins.js +198 -9
- package/server/routes/api/search.js +43 -0
- package/server/routes/api/theme.js +69 -0
- package/server/routes/api/todo.js +178 -0
- package/server/routes/public.js +42 -7
- package/server/server.js +135 -1
- package/server/services/adapters/FileAdapter.js +6 -1
- package/server/services/collections.js +13 -3
- package/server/services/content.js +26 -0
- package/server/services/contextMenus.js +477 -0
- package/server/services/managerClient.js +72 -136
- package/server/services/markdown.js +124 -10
- package/server/services/permissionRegistry.js +74 -0
- package/server/services/pluginEntitlement.js +171 -0
- package/server/services/pluginEnvelope.js +242 -0
- package/server/services/pluginFiles.js +52 -11
- package/server/services/pluginInstaller.js +246 -26
- package/server/services/pluginScaffold.js +5 -0
- package/server/services/plugins.js +340 -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 +224 -22
- package/server/services/roles.js +1 -1
- package/server/services/search-migration.js +82 -0
- package/server/services/search.js +413 -0
- package/server/services/sidebar-migration.js +1 -0
- package/server/services/sidebarBadges.js +37 -0
- package/server/services/siteGitignore.js +307 -0
- package/server/services/themeSettings.js +580 -0
- package/server/services/users.js +8 -0
- package/server/templates/page.html +4 -2
- package/plugins/analytics/admin/templates/analytics.html +0 -61
- package/plugins/analytics/admin/views/analytics.js +0 -176
- package/plugins/analytics/config.js +0 -14
- package/plugins/analytics/plugin.json +0 -57
- 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 -126
- package/plugins/contacts/admin/views/contacts.js +0 -710
- package/plugins/contacts/collections/user-contact-groups/schema.json +0 -35
- package/plugins/contacts/collections/user-contacts/schema.json +0 -71
- package/plugins/contacts/config.js +0 -6
- package/plugins/contacts/data/contacts.json +0 -20
- package/plugins/contacts/plugin.js +0 -291
- package/plugins/contacts/plugin.json +0 -33
- 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/templates/editor.html +0 -129
- package/plugins/invoice/admin/templates/index.html +0 -43
- package/plugins/invoice/admin/templates/issuers.html +0 -5
- package/plugins/invoice/admin/templates/receivers.html +0 -5
- package/plugins/invoice/admin/views/editor.js +0 -267
- package/plugins/invoice/admin/views/index.js +0 -155
- package/plugins/invoice/admin/views/issuers.js +0 -23
- package/plugins/invoice/admin/views/party-view.js +0 -148
- package/plugins/invoice/admin/views/receivers.js +0 -22
- package/plugins/invoice/collections/invoice-issuers/schema.json +0 -16
- package/plugins/invoice/collections/invoice-receivers/schema.json +0 -15
- package/plugins/invoice/collections/invoices/schema.json +0 -27
- package/plugins/invoice/config.js +0 -16
- package/plugins/invoice/plugin.js +0 -283
- package/plugins/invoice/plugin.json +0 -85
- package/plugins/invoice/templates/invoice-print.html +0 -213
- 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/data/notes.json +0 -1
- package/plugins/notes/plugin.json +0 -33
- package/plugins/site-search/admin/views/site-search.js +0 -116
- package/plugins/site-search/config.js +0 -15
- package/plugins/site-search/plugin.js +0 -188
- package/plugins/site-search/plugin.json +0 -40
- package/plugins/site-search/public/inject-body.html +0 -17
- package/plugins/site-search/public/inject-head.html +0 -1
- package/plugins/site-search/public/search.css +0 -1
- package/plugins/site-search/public/search.js +0 -1
- 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/plugin.js +0 -174
- package/plugins/surveys/plugin.json +0 -65
- 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/data/todos.json +0 -1
- package/plugins/todo/plugin.js +0 -102
- 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
|
-
|
|
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
|
}
|