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
|
@@ -12,13 +12,43 @@ import {
|
|
|
12
12
|
import {
|
|
13
13
|
discoverPlugins,
|
|
14
14
|
getAdminPluginConfig,
|
|
15
|
+
getEntitlementStates,
|
|
16
|
+
getLoadedPlugins,
|
|
15
17
|
getPluginStates,
|
|
18
|
+
isCorePlugin,
|
|
19
|
+
pluginLicence,
|
|
16
20
|
runLifecycleHook,
|
|
17
21
|
savePluginState
|
|
18
22
|
} from '../../services/plugins.js';
|
|
23
|
+
import {getEffectiveLevel} from '../../services/userRoles.js';
|
|
19
24
|
import {scaffoldPlugin} from '../../services/pluginScaffold.js';
|
|
20
25
|
import {deletePluginFile, listPluginFiles, readPluginFile, writePluginFile} from '../../services/pluginFiles.js';
|
|
21
26
|
import {getConfig} from '../../config.js';
|
|
27
|
+
import path from 'path';
|
|
28
|
+
import {existsSync, readFileSync} from 'node:fs';
|
|
29
|
+
import {installPlugin} from '../../services/pluginInstaller.js';
|
|
30
|
+
import {packEnvelope, readEnvelope, readManifestFromTarball, tarDirectory} from '../../services/pluginEnvelope.js';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The version of a plugin currently on disk, or null if the slug is free.
|
|
34
|
+
*
|
|
35
|
+
* Deliberately a directory check rather than `discoverPlugins()`: a half-broken
|
|
36
|
+
* installation still occupies the slug - that is exactly what the installer
|
|
37
|
+
* refuses to overwrite - and it would be invisible to discovery. An unreadable
|
|
38
|
+
* manifest reports an occupied slug with an unknown version, which is honest.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} slug
|
|
41
|
+
* @returns {string|null|undefined} version, `null` when unreadable, `undefined` when absent
|
|
42
|
+
*/
|
|
43
|
+
function installedVersion(slug) {
|
|
44
|
+
const dir = path.join(path.resolve('plugins'), slug);
|
|
45
|
+
if (!existsSync(dir)) return undefined;
|
|
46
|
+
try {
|
|
47
|
+
return JSON.parse(readFileSync(path.join(dir, 'plugin.json'), 'utf8')).version ?? null;
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
22
52
|
|
|
23
53
|
/**
|
|
24
54
|
* True when something will bring the process back after a clean exit.
|
|
@@ -78,9 +108,11 @@ export async function pluginsRoutes(fastify, opts = {}) {
|
|
|
78
108
|
const canDevelop = { preHandler: [authenticate, requirePermission('plugins', 'develop')] };
|
|
79
109
|
|
|
80
110
|
// List all plugins with their current state
|
|
81
|
-
fastify.get('/plugins', canRead, async () => {
|
|
111
|
+
fastify.get('/plugins', canRead, async (request) => {
|
|
82
112
|
const manifests = await discoverPlugins();
|
|
83
113
|
const states = getPluginStates();
|
|
114
|
+
const loadedPlugins = getLoadedPlugins();
|
|
115
|
+
const entitlementStates = getEntitlementStates();
|
|
84
116
|
|
|
85
117
|
return manifests.map(manifest => ({
|
|
86
118
|
name: manifest.name,
|
|
@@ -90,8 +122,27 @@ export async function pluginsRoutes(fastify, opts = {}) {
|
|
|
90
122
|
author: manifest.author || '',
|
|
91
123
|
date: manifest.date || '',
|
|
92
124
|
icon: manifest.icon || 'package',
|
|
93
|
-
|
|
94
|
-
|
|
125
|
+
// A core plugin runs whatever plugins.json says, so reporting its
|
|
126
|
+
// stored flag would show "Disabled" next to a feature that is
|
|
127
|
+
// plainly running. It is on because it is built in.
|
|
128
|
+
core: isCorePlugin(manifest.name),
|
|
129
|
+
enabled: isCorePlugin(manifest.name) || !!(states[manifest.name]?.enabled),
|
|
130
|
+
// A licensed plugin is distributed to run, not to be read: the
|
|
131
|
+
// source endpoints refuse it (pluginFiles.isClosedSource). Sent so
|
|
132
|
+
// the screen can leave the Code button off rather than offer one
|
|
133
|
+
// that answers 403 - the refusal is the guard, not the UI.
|
|
134
|
+
closedSource: manifest.closedSource === true,
|
|
135
|
+
licence: pluginLicence(manifest),
|
|
136
|
+
// Why a licensed plugin is not running, when it is not. Null for
|
|
137
|
+
// anything open-source, which is never asked for an entitlement.
|
|
138
|
+
entitlement: entitlementStates[manifest.name] ?? null,
|
|
139
|
+
// Whether the CODE button belongs on this viewer's card. Decided
|
|
140
|
+
// here rather than in the browser: it is the same answer the file
|
|
141
|
+
// endpoints will give, from the same two facts.
|
|
142
|
+
sourceEditable: manifest.closedSource !== true || mayEditLicensed(request),
|
|
143
|
+
// Enabled but displaced by another plugin that replaces it. Shown
|
|
144
|
+
// so the screen can explain the inactivity instead of looking broken.
|
|
145
|
+
supersededBy: loadedPlugins[manifest.name]?.supersededBy ?? null,
|
|
95
146
|
settings: states[manifest.name]?.settings || {}
|
|
96
147
|
}));
|
|
97
148
|
});
|
|
@@ -113,17 +164,26 @@ export async function pluginsRoutes(fastify, opts = {}) {
|
|
|
113
164
|
// Enable/disable or update settings for a plugin
|
|
114
165
|
fastify.put('/plugins/:name', canUpdate, async (request, reply) => {
|
|
115
166
|
const { name } = request.params;
|
|
116
|
-
const {enabled, settings
|
|
167
|
+
const {enabled, settings} = request.body || {};
|
|
117
168
|
|
|
118
169
|
const manifests = await discoverPlugins();
|
|
119
170
|
const manifest = manifests.find(m => m.name === name);
|
|
120
171
|
if (!manifest) return reply.status(404).send({ error: 'Plugin not found' });
|
|
121
172
|
|
|
173
|
+
// A core plugin is a built-in feature, not an add-on. Refusing here
|
|
174
|
+
// rather than writing the flag and ignoring it keeps the stored state
|
|
175
|
+
// honest - otherwise plugins.json says disabled while the feature runs.
|
|
176
|
+
// Settings are still editable; only the on/off switch is not.
|
|
177
|
+
if (isCorePlugin(name) && enabled === false) {
|
|
178
|
+
return reply.status(409).send({
|
|
179
|
+
error: `${manifest.displayName || name} is a built-in feature and cannot be disabled.`
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
122
183
|
const prevState = getPluginStates()[name] || {};
|
|
123
184
|
const update = {};
|
|
124
185
|
if (enabled !== undefined) update.enabled = !!enabled;
|
|
125
186
|
if (settings !== undefined) update.settings = settings;
|
|
126
|
-
if (bundled !== undefined) update.bundled = !!bundled;
|
|
127
187
|
savePluginState(name, update);
|
|
128
188
|
|
|
129
189
|
if (!prevState.enabled && !!enabled) {
|
|
@@ -158,17 +218,146 @@ export async function pluginsRoutes(fastify, opts = {}) {
|
|
|
158
218
|
|
|
159
219
|
const mapErr = (reply, err) => reply.status(err.statusCode || 500).send({ error: err.message });
|
|
160
220
|
|
|
221
|
+
/*
|
|
222
|
+
* May this request read a LICENSED plugin's source?
|
|
223
|
+
*
|
|
224
|
+
* Only a super-admin (level 0). On the fleet that role exists on the
|
|
225
|
+
* manager and nowhere else - every customer-site user is an admin, level 1
|
|
226
|
+
* - so this opens the source to the people who publish the paid plugins
|
|
227
|
+
* without opening it on the sites they are distributed to. Level rather
|
|
228
|
+
* than role name, the same way requireAdmin and canManageUser decide.
|
|
229
|
+
*/
|
|
230
|
+
const mayEditLicensed = (request) => !!request.user && getEffectiveLevel(request.user) === 0;
|
|
231
|
+
const licensed = (request) => ({allowLicensed: mayEditLicensed(request)});
|
|
232
|
+
|
|
161
233
|
// List a plugin's editable files
|
|
234
|
+
// ---- Export: a plugin leaves as one file ----------------------------
|
|
235
|
+
//
|
|
236
|
+
// The same .dcmsplugin envelope a purchase arrives in, so an export can be
|
|
237
|
+
// installed by the ordinary route rather than a second "just unzip it" path
|
|
238
|
+
// that would skip the traversal guard and the manifest check.
|
|
239
|
+
//
|
|
240
|
+
// Unsigned by construction: the marketplace key is offline and never on this
|
|
241
|
+
// host. The importing site decides what to do about that.
|
|
242
|
+
// compress: false - the envelope is ALREADY gzip. @fastify/compress sees the
|
|
243
|
+
// gzip magic and labels it Content-Encoding: gzip, so the browser unzipped
|
|
244
|
+
// it on the way in and saved a bare tar; under brotli the download failed
|
|
245
|
+
// outright ("Decoding failed").
|
|
246
|
+
fastify.get('/plugins/:name/export', {...canDevelop, compress: false}, async (request, reply) => {
|
|
247
|
+
const {name} = request.params;
|
|
248
|
+
if (!/^[a-z0-9][a-z0-9-_]{0,63}$/.test(name)) {
|
|
249
|
+
return reply.code(400).send({error: 'Invalid plugin name'});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const manifests = await discoverPlugins();
|
|
253
|
+
const manifest = manifests.find(m => m.name === name);
|
|
254
|
+
if (!manifest) return reply.code(404).send({error: 'Plugin not found'});
|
|
255
|
+
|
|
256
|
+
// A licensed plugin is distributed to RUN here, not to be re-emitted as
|
|
257
|
+
// a redistributable file. Same flag that refuses the source editor, and
|
|
258
|
+
// the same reason: one click would otherwise turn every customer into a
|
|
259
|
+
// distribution channel.
|
|
260
|
+
if (manifest.closedSource === true) {
|
|
261
|
+
return reply.code(403).send({error: `"${name}" is a licensed plugin and cannot be exported.`});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const dir = path.join(path.resolve('plugins'), name);
|
|
265
|
+
const includeData = request.query?.includeData === '1';
|
|
266
|
+
const tarball = await tarDirectory(dir, name, {includeData});
|
|
267
|
+
const envelope = packEnvelope({tarball});
|
|
268
|
+
|
|
269
|
+
return reply
|
|
270
|
+
.header('Content-Type', 'application/octet-stream')
|
|
271
|
+
.header('Content-Disposition',
|
|
272
|
+
`attachment; filename="${name}-${manifest.version}.dcmsplugin"`)
|
|
273
|
+
.send(envelope);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
// ---- Import: install from an uploaded envelope -----------------------
|
|
277
|
+
fastify.post('/plugins/install-upload', canCreate, async (request, reply) => {
|
|
278
|
+
const upload = await request.file().catch(() => null);
|
|
279
|
+
if (!upload) return reply.code(400).send({error: 'No file uploaded'});
|
|
280
|
+
|
|
281
|
+
// Drain the file first. Multipart is a STREAM: `upload.fields` only
|
|
282
|
+
// holds the parts seen so far, so reading a flag before the file body
|
|
283
|
+
// is consumed silently misses any field the browser put after it.
|
|
284
|
+
let body;
|
|
285
|
+
try {
|
|
286
|
+
body = await upload.toBuffer();
|
|
287
|
+
} catch (err) {
|
|
288
|
+
return reply.code(400).send({error: err.message});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Checkboxes on the form, not defaults. Installing an unsigned plugin
|
|
292
|
+
// runs its code on this server; replacing one that is already here
|
|
293
|
+
// swaps out code that is running. Both should be decisions rather than
|
|
294
|
+
// side effects of dragging a file onto a page.
|
|
295
|
+
const flag = (name) => upload.fields?.[name]?.value === 'true';
|
|
296
|
+
const confirmUnsigned = flag('confirmUnsigned');
|
|
297
|
+
const confirmUpgrade = flag('confirmUpgrade');
|
|
298
|
+
const confirmDowngrade = flag('confirmDowngrade');
|
|
299
|
+
|
|
300
|
+
let parts;
|
|
301
|
+
try {
|
|
302
|
+
parts = readEnvelope(body);
|
|
303
|
+
} catch (err) {
|
|
304
|
+
return reply.code(400).send({error: err.message});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// The slug comes from the manifest inside, never from the filename: a
|
|
308
|
+
// file can be renamed by anyone, a manifest is what the installer then
|
|
309
|
+
// validates against what was actually extracted.
|
|
310
|
+
const manifest = readManifestFromTarball(parts.tarball);
|
|
311
|
+
const slug = manifest?.name;
|
|
312
|
+
if (!slug) return reply.code(400).send({error: 'No readable plugin manifest inside that file'});
|
|
313
|
+
|
|
314
|
+
const installed = installedVersion(slug);
|
|
315
|
+
if (installed !== undefined && !confirmUpgrade) {
|
|
316
|
+
return reply.code(409).send({
|
|
317
|
+
error: `${slug} ${installed ?? ''} is already installed.`.replace(' ', ' '),
|
|
318
|
+
code: 'already-installed',
|
|
319
|
+
name: slug,
|
|
320
|
+
installedVersion: installed,
|
|
321
|
+
incomingVersion: manifest.version ?? null
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
let result;
|
|
326
|
+
try {
|
|
327
|
+
result = await installPlugin(slug, {
|
|
328
|
+
tarball: parts.tarball,
|
|
329
|
+
signature: parts.signature,
|
|
330
|
+
publicKeyId: parts.signature ? (request.body?.keyId || 'marketplace-1') : null,
|
|
331
|
+
allowUnsigned: confirmUnsigned,
|
|
332
|
+
entitlement: parts.entitlement,
|
|
333
|
+
allowUpgrade: confirmUpgrade,
|
|
334
|
+
allowDowngrade: confirmDowngrade
|
|
335
|
+
});
|
|
336
|
+
} catch (err) {
|
|
337
|
+
fastify.log.warn({err}, '[plugins] upload install failed');
|
|
338
|
+
return reply.code(400).send({error: err.message});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return reply.send({
|
|
342
|
+
success: true,
|
|
343
|
+
name: slug,
|
|
344
|
+
action: result?.action ?? 'install',
|
|
345
|
+
from: result?.from ?? null,
|
|
346
|
+
version: result?.version ?? manifest.version ?? null,
|
|
347
|
+
restartRequired: true
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
|
|
162
351
|
fastify.get('/plugins/:name/files', canDevelop, async (request, reply) => {
|
|
163
352
|
try {
|
|
164
|
-
return { files: await listPluginFiles(request.params.name) };
|
|
353
|
+
return { files: await listPluginFiles(request.params.name, licensed(request)) };
|
|
165
354
|
} catch (err) { return mapErr(reply, err); }
|
|
166
355
|
});
|
|
167
356
|
|
|
168
357
|
// Read one file (?path=admin/views/index.js)
|
|
169
358
|
fastify.get('/plugins/:name/file', canDevelop, async (request, reply) => {
|
|
170
359
|
try {
|
|
171
|
-
return await readPluginFile(request.params.name, request.query.path);
|
|
360
|
+
return await readPluginFile(request.params.name, request.query.path, licensed(request));
|
|
172
361
|
} catch (err) { return mapErr(reply, err); }
|
|
173
362
|
});
|
|
174
363
|
|
|
@@ -176,7 +365,7 @@ export async function pluginsRoutes(fastify, opts = {}) {
|
|
|
176
365
|
fastify.put('/plugins/:name/file', canDevelop, async (request, reply) => {
|
|
177
366
|
const { path: relPath, content } = request.body || {};
|
|
178
367
|
try {
|
|
179
|
-
const result = await writePluginFile(request.params.name, relPath, content);
|
|
368
|
+
const result = await writePluginFile(request.params.name, relPath, content, licensed(request));
|
|
180
369
|
return { success: true, ...result };
|
|
181
370
|
} catch (err) { return mapErr(reply, err); }
|
|
182
371
|
});
|
|
@@ -184,7 +373,7 @@ export async function pluginsRoutes(fastify, opts = {}) {
|
|
|
184
373
|
// Delete one file (?path=admin/views/extra.js)
|
|
185
374
|
fastify.delete('/plugins/:name/file', canDevelop, async (request, reply) => {
|
|
186
375
|
try {
|
|
187
|
-
const result = await deletePluginFile(request.params.name, request.query.path);
|
|
376
|
+
const result = await deletePluginFile(request.params.name, request.query.path, licensed(request));
|
|
188
377
|
return { success: true, ...result };
|
|
189
378
|
} catch (err) { return mapErr(reply, err); }
|
|
190
379
|
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search API Routes
|
|
3
|
+
*
|
|
4
|
+
* GET /api/search?q=… - public: full-text search across published pages
|
|
5
|
+
* GET /api/search/settings - public: the search UI's own configuration
|
|
6
|
+
* PUT /api/search/settings - admin-only: save it
|
|
7
|
+
*
|
|
8
|
+
* The public settings endpoint is kept for anything already calling it (and
|
|
9
|
+
* for a page served from a cache older than the feature), but it is no longer
|
|
10
|
+
* on the critical path: the renderer embeds the same object in the page as
|
|
11
|
+
* `window.__CMS_SEARCH__`, so the overlay can draw itself without a round trip.
|
|
12
|
+
*/
|
|
13
|
+
import {authenticate, requireAdmin} from '../../middleware/auth.js';
|
|
14
|
+
import {listPages} from '../../services/content.js';
|
|
15
|
+
import {getSearchSettings, saveSearchSettings, searchPages} from '../../services/search.js';
|
|
16
|
+
|
|
17
|
+
export async function searchRoutes(fastify) {
|
|
18
|
+
fastify.get('/search', async (request, reply) => {
|
|
19
|
+
const settings = getSearchSettings();
|
|
20
|
+
const q = (request.query.q || '').trim();
|
|
21
|
+
|
|
22
|
+
if (q.length < settings.minQueryLength) {
|
|
23
|
+
return reply.status(400).send({
|
|
24
|
+
error: `Query must be at least ${settings.minQueryLength} characters`
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const pages = await listPages();
|
|
29
|
+
const results = searchPages(pages, q, {
|
|
30
|
+
maxResults: settings.maxResults,
|
|
31
|
+
onError: (err, page) =>
|
|
32
|
+
request.log.warn({err, page: page.urlPath}, '[search] skipped an unreadable page')
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
return {results};
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
fastify.get('/search/settings', async () => getSearchSettings());
|
|
39
|
+
|
|
40
|
+
fastify.put('/search/settings', {preHandler: [authenticate, requireAdmin]}, async (request) => {
|
|
41
|
+
return {ok: true, settings: saveSearchSettings(request.body || {})};
|
|
42
|
+
});
|
|
43
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme API
|
|
3
|
+
*
|
|
4
|
+
* GET /api/theme - the theme config (themes, fonts, overrides)
|
|
5
|
+
* PUT /api/theme - replace it
|
|
6
|
+
* GET /api/theme/catalog - theme ids and --dm-* token names the shipped
|
|
7
|
+
* stylesheet actually defines
|
|
8
|
+
* POST /api/theme/compile - compile a candidate config to CSS without saving,
|
|
9
|
+
* so the admin previews exactly what the page gets
|
|
10
|
+
*
|
|
11
|
+
* Auth middlewares are injectable so tests can pass no-ops, the same DI pattern
|
|
12
|
+
* as menus.js and context-menus.js.
|
|
13
|
+
*/
|
|
14
|
+
import {
|
|
15
|
+
authenticate as defaultAuthenticate,
|
|
16
|
+
requirePermission as defaultRequirePermission
|
|
17
|
+
} from '../../middleware/auth.js';
|
|
18
|
+
import {
|
|
19
|
+
compileOverrideCss,
|
|
20
|
+
getThemeTokenMap,
|
|
21
|
+
listThemeIds,
|
|
22
|
+
listThemeTokens,
|
|
23
|
+
loadThemeConfig,
|
|
24
|
+
saveThemeConfig,
|
|
25
|
+
validateThemeConfig
|
|
26
|
+
} from '../../services/themeSettings.js';
|
|
27
|
+
|
|
28
|
+
export async function themeRoutes(fastify, opts = {}) {
|
|
29
|
+
const authenticate = opts.authenticate || defaultAuthenticate;
|
|
30
|
+
const requirePermission = opts.requirePermission || defaultRequirePermission;
|
|
31
|
+
|
|
32
|
+
const canRead = {preHandler: [authenticate, requirePermission('theme', 'read')]};
|
|
33
|
+
const canUpdate = {preHandler: [authenticate, requirePermission('theme', 'update')]};
|
|
34
|
+
|
|
35
|
+
fastify.get('/theme', canRead, async () => loadThemeConfig());
|
|
36
|
+
|
|
37
|
+
// The editor's theme picker and token picker build themselves from this
|
|
38
|
+
// rather than from a hardcoded list that would drift from the CSS.
|
|
39
|
+
fastify.get('/theme/catalog', canRead, async () => ({
|
|
40
|
+
themes: listThemeIds(),
|
|
41
|
+
tokens: listThemeTokens()
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
/*
|
|
45
|
+
* One theme's token values. Fetched on demand rather than folded into
|
|
46
|
+
* /catalog: 32 themes x ~245 tokens is a payload nobody needs on a page
|
|
47
|
+
* load, and the admin only wants the one being considered.
|
|
48
|
+
*/
|
|
49
|
+
fastify.get('/theme/themes/:id', canRead, async (request, reply) => {
|
|
50
|
+
const tokens = getThemeTokenMap(request.params.id);
|
|
51
|
+
if (!tokens) return reply.code(404).send({error: 'Unknown theme'});
|
|
52
|
+
return {id: request.params.id, tokens};
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
fastify.put('/theme', canUpdate, async (request, reply) => {
|
|
56
|
+
try {
|
|
57
|
+
return await saveThemeConfig(request.body || {});
|
|
58
|
+
} catch (err) {
|
|
59
|
+
return reply.code(400).send({error: err.message, validation: err.validation || null});
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
fastify.post('/theme/compile', canRead, async (request, reply) => {
|
|
64
|
+
const candidate = request.body || {};
|
|
65
|
+
const errors = validateThemeConfig(candidate);
|
|
66
|
+
if (errors.length) return reply.code(400).send({error: errors.join('; '), validation: errors});
|
|
67
|
+
return {css: compileOverrideCss(candidate)};
|
|
68
|
+
});
|
|
69
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Todo API - /api/todo
|
|
3
|
+
*
|
|
4
|
+
* Was the `todo` 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 `todo-items`, 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 {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';
|
|
21
|
+
|
|
22
|
+
const SLUG = 'todo-items';
|
|
23
|
+
|
|
24
|
+
/** 'YYYY-MM-DD' of a date, in the server's local time. */
|
|
25
|
+
function localDay(d) {
|
|
26
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
27
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Flatten a collection entry into the shape the admin view expects. */
|
|
31
|
+
function toTodo(entry) {
|
|
32
|
+
return {
|
|
33
|
+
id: entry.id,
|
|
34
|
+
...entry.data,
|
|
35
|
+
createdAt: entry.meta.createdAt,
|
|
36
|
+
updatedAt: entry.meta.updatedAt
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Auth is injectable so a test can drive these routes without registering
|
|
42
|
+
* @fastify/jwt or a role store - the same shape dashboard.js and plugins.js use.
|
|
43
|
+
*
|
|
44
|
+
* @param {import('fastify').FastifyInstance} fastify
|
|
45
|
+
* @param {object} [opts]
|
|
46
|
+
*/
|
|
47
|
+
export async function todoRoutes(fastify, opts = {}) {
|
|
48
|
+
const authenticate = opts.authenticate || defaultAuthenticate;
|
|
49
|
+
const requirePermission = opts.requirePermission || defaultRequirePermission;
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
function userId(request) {
|
|
53
|
+
return request.user?.id ?? request.user?.sub ?? null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function loadAll(uid) {
|
|
57
|
+
const {entries} = await listEntries(SLUG, {limit: 10000, sort: 'createdAt', order: 'asc'});
|
|
58
|
+
let items = entries.map(toTodo);
|
|
59
|
+
if (uid) items = items.filter(t => t.userId === uid);
|
|
60
|
+
return items;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* GET /api/todo/count - the sidebar badge: open tasks, and how many are
|
|
65
|
+
* overdue. `?today=YYYY-MM-DD` is the BROWSER's calendar day - due dates
|
|
66
|
+
* are local days, and the server's today is not the reader's near midnight.
|
|
67
|
+
* Answers {count, label, tone}; tone is 'danger' while anything is overdue.
|
|
68
|
+
*/
|
|
69
|
+
fastify.get('/count', {preHandler: [authenticate, requirePermission('todo', 'read')]}, async (request) => {
|
|
70
|
+
const given = String(request.query?.today ?? '');
|
|
71
|
+
const today = /^\d{4}-\d{2}-\d{2}$/.test(given) ? given : localDay(new Date());
|
|
72
|
+
const open = (await loadAll(userId(request))).filter(t => t.status !== 'completed');
|
|
73
|
+
const dayOf = (t) => /^(\d{4}-\d{2}-\d{2})/.exec(String(t.dueAt ?? ''))?.[1] ?? null;
|
|
74
|
+
const daysBetween = (a, b) => Math.round((Date.parse(a) - Date.parse(b)) / 86_400_000);
|
|
75
|
+
const overdue = open.filter(t => dayOf(t) && dayOf(t) < today);
|
|
76
|
+
const dueToday = open.filter(t => dayOf(t) === today);
|
|
77
|
+
const PRIORITY = {high: 0, medium: 1, low: 2};
|
|
78
|
+
|
|
79
|
+
// Most urgent first: overdue (oldest first), due today, then by due day, then priority.
|
|
80
|
+
const urgent = [...open].sort((a, b) =>
|
|
81
|
+
(dayOf(a) ?? '9999') .localeCompare(dayOf(b) ?? '9999')
|
|
82
|
+
|| (PRIORITY[a.priority] ?? 1) - (PRIORITY[b.priority] ?? 1));
|
|
83
|
+
const items = urgent.slice(0, 5).map(t => {
|
|
84
|
+
const day = dayOf(t);
|
|
85
|
+
const d = day ? daysBetween(day, today) : null;
|
|
86
|
+
const when = d === null ? '' : d < 0 ? `${plural(-d, 'day')} overdue` : d === 0 ? 'due today' : d === 1 ? 'due tomorrow' : `due in ${d} days`;
|
|
87
|
+
const meta = [when, t.priority === 'high' ? 'high priority' : ''].filter(Boolean).join(' · ');
|
|
88
|
+
return {text: clip(t.text), meta, tone: d !== null && d < 0 ? 'danger' : undefined};
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const label = `${plural(open.length, 'open task')}${overdue.length ? `, ${overdue.length} overdue` : ''}`;
|
|
92
|
+
return {
|
|
93
|
+
count: open.length, label, tone: overdue.length ? 'danger' : 'info',
|
|
94
|
+
details: {
|
|
95
|
+
title: 'Todo',
|
|
96
|
+
rows: [
|
|
97
|
+
{label: 'Open', value: String(open.length)},
|
|
98
|
+
{label: 'Overdue', value: String(overdue.length), tone: overdue.length ? 'danger' : undefined},
|
|
99
|
+
{label: 'Due today', value: String(dueToday.length)},
|
|
100
|
+
{label: 'In progress', value: String(open.filter(t => t.status === 'in-progress').length)}
|
|
101
|
+
],
|
|
102
|
+
items,
|
|
103
|
+
empty: 'Nothing open - all done.'
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
/** GET /api/todo/items */
|
|
109
|
+
fastify.get('/items', {preHandler: [authenticate, requirePermission('todo', 'read')]}, async (request, reply) => {
|
|
110
|
+
const uid = userId(request);
|
|
111
|
+
const { status } = request.query;
|
|
112
|
+
let items = await loadAll(uid);
|
|
113
|
+
if (status) items = items.filter(t => t.status === status);
|
|
114
|
+
return reply.send(items);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
/** POST /api/todo/items */
|
|
118
|
+
fastify.post('/items', {preHandler: [authenticate, requirePermission('todo', 'create')]}, async (request, reply) => {
|
|
119
|
+
const uid = userId(request);
|
|
120
|
+
const { text, priority, status } = request.body ?? {};
|
|
121
|
+
|
|
122
|
+
if (!text || typeof text !== 'string' || !text.trim()) {
|
|
123
|
+
return reply.code(400).send({ error: 'text is required' });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const {dueAt} = request.body ?? {};
|
|
127
|
+
const entry = await createEntry(SLUG, {
|
|
128
|
+
text: text.trim(),
|
|
129
|
+
status: status ?? 'not-started',
|
|
130
|
+
priority: priority ?? 'medium',
|
|
131
|
+
dueAt: dueAt ?? null,
|
|
132
|
+
userId: uid ?? null
|
|
133
|
+
}, {createdBy: uid, source: 'admin'});
|
|
134
|
+
|
|
135
|
+
return reply.code(201).send(toTodo(entry));
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
/** PUT /api/todo/items/:id */
|
|
139
|
+
fastify.put('/items/:id', {preHandler: [authenticate, requirePermission('todo', 'update')]}, async (request, reply) => {
|
|
140
|
+
const uid = userId(request);
|
|
141
|
+
const { id } = request.params;
|
|
142
|
+
const patch = request.body ?? {};
|
|
143
|
+
|
|
144
|
+
const entry = await getEntry(SLUG, id);
|
|
145
|
+
if (!entry) return reply.code(404).send({error: 'Todo not found'});
|
|
146
|
+
if (uid && entry.data.userId !== uid) return reply.code(404).send({error: 'Todo not found'});
|
|
147
|
+
|
|
148
|
+
const merged = {...entry.data};
|
|
149
|
+
for (const key of ['text', 'status', 'priority', 'dueAt']) {
|
|
150
|
+
if (key in patch) merged[key] = key === 'text' ? String(patch[key]).trim() : patch[key];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const updated = await updateEntry(SLUG, id, merged);
|
|
154
|
+
return reply.send(toTodo(updated));
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
/** DELETE /api/todo/completed - must be registered before /:id */
|
|
158
|
+
fastify.delete('/completed', {preHandler: [authenticate, requirePermission('todo', 'delete')]}, async (request, reply) => {
|
|
159
|
+
const uid = userId(request);
|
|
160
|
+
const all = await loadAll(uid);
|
|
161
|
+
const completed = all.filter(t => t.status === 'completed');
|
|
162
|
+
await Promise.all(completed.map(t => deleteEntry(SLUG, t.id)));
|
|
163
|
+
return reply.send({success: true, removed: completed.length});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
/** DELETE /api/todo/items/:id */
|
|
167
|
+
fastify.delete('/items/:id', {preHandler: [authenticate, requirePermission('todo', 'delete')]}, async (request, reply) => {
|
|
168
|
+
const uid = userId(request);
|
|
169
|
+
const { id } = request.params;
|
|
170
|
+
|
|
171
|
+
const entry = await getEntry(SLUG, id);
|
|
172
|
+
if (!entry) return reply.code(404).send({error: 'Todo not found'});
|
|
173
|
+
if (uid && entry.data.userId !== uid) return reply.code(404).send({error: 'Todo not found'});
|
|
174
|
+
|
|
175
|
+
await deleteEntry(SLUG, id);
|
|
176
|
+
return reply.send({ success: true });
|
|
177
|
+
});
|
|
178
|
+
}
|