domma-cms 0.22.5 → 0.23.0
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 +7 -5
- package/admin/js/lib/sidebar-grouping.js +1 -0
- package/admin/js/lib/sidebar-grouping.test.js +1 -0
- package/admin/js/lib/sidebar-renderer.js +4 -4
- package/admin/js/templates/project-settings.html +1 -1
- package/admin/js/views/project-settings.js +1 -1
- package/admin/js/views/projects.js +3 -3
- package/config/menus/admin-sidebar.json +0 -6
- package/package.json +3 -2
- package/plugins/analytics/admin/templates/analytics.html +1 -1
- package/server/routes/api/forms.js +765 -746
- package/server/routes/api/projects.js +9 -2
- package/server/server.js +2 -0
- package/server/services/forms.js +345 -255
- package/server/services/plugins.js +5 -2
- package/server/services/presetCollections.js +2 -1
- package/server/services/projects.js +115 -24
- package/server/services/roles.js +1 -1
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
* GET /api/projects/:slug — full project
|
|
6
6
|
* POST /api/projects — create
|
|
7
7
|
* PUT /api/projects/:slug — edit (slug immutable)
|
|
8
|
-
* DELETE /api/projects/:slug — delete (409 if any artefacts tagged)
|
|
8
|
+
* DELETE /api/projects/:slug — delete (403 for core; 409 if any artefacts tagged)
|
|
9
9
|
* GET /api/projects/:slug/artefacts — grouped artefacts
|
|
10
|
-
* POST /api/projects/:slug/untag-all — escape hatch before delete
|
|
10
|
+
* POST /api/projects/:slug/untag-all — escape hatch before delete (403 for core)
|
|
11
11
|
*
|
|
12
12
|
* Auth middlewares are accepted as DI options so tests can supply no-ops.
|
|
13
13
|
*/
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
requirePermission as defaultRequirePermission
|
|
17
17
|
} from '../../middleware/auth.js';
|
|
18
18
|
import {
|
|
19
|
+
CORE_PROJECT_SLUG,
|
|
19
20
|
createProject,
|
|
20
21
|
deleteProject,
|
|
21
22
|
getArtefactsForProject,
|
|
@@ -76,6 +77,9 @@ export async function projectsRoutes(fastify, opts = {}) {
|
|
|
76
77
|
});
|
|
77
78
|
|
|
78
79
|
fastify.delete('/projects/:slug', canDelete, async (request, reply) => {
|
|
80
|
+
if (request.params.slug === CORE_PROJECT_SLUG) {
|
|
81
|
+
return reply.status(403).send({error: 'Cannot delete the core project'});
|
|
82
|
+
}
|
|
79
83
|
const grouped = await getArtefactsForProject(request.params.slug);
|
|
80
84
|
const total = Object.values(grouped).reduce((sum, arr) => sum + arr.length, 0);
|
|
81
85
|
if (total > 0) {
|
|
@@ -98,6 +102,9 @@ export async function projectsRoutes(fastify, opts = {}) {
|
|
|
98
102
|
});
|
|
99
103
|
|
|
100
104
|
fastify.post('/projects/:slug/untag-all', canDelete, async (request, reply) => {
|
|
105
|
+
if (request.params.slug === CORE_PROJECT_SLUG) {
|
|
106
|
+
return reply.status(403).send({error: 'Cannot untag the core project — artefacts would immediately resolve back to it'});
|
|
107
|
+
}
|
|
101
108
|
if (!(await getProject(request.params.slug))) {
|
|
102
109
|
return reply.status(404).send({error: 'Project not found'});
|
|
103
110
|
}
|
package/server/server.js
CHANGED
|
@@ -22,6 +22,7 @@ import {getLoadedPlugins, getPluginSettings, registerPlugins} from './services/p
|
|
|
22
22
|
import {load as loadRoles, seed as seedRoles} from './services/roles.js';
|
|
23
23
|
import {ensureAllProfiles, seed as seedUserProfiles} from './services/userProfiles.js';
|
|
24
24
|
import {seedAll as seedPresetCollections} from './services/presetCollections.js';
|
|
25
|
+
import {seedCoreProject} from './services/projects.js';
|
|
25
26
|
import {seedDefaultBlocks} from './services/blocks.js';
|
|
26
27
|
import {seedDefaultComponents} from './services/components.js';
|
|
27
28
|
import {refreshComponentTagAllowlist} from './services/markdown.js';
|
|
@@ -184,6 +185,7 @@ await loadRoles();
|
|
|
184
185
|
await seedUserProfiles();
|
|
185
186
|
await ensureAllProfiles();
|
|
186
187
|
await seedPresetCollections();
|
|
188
|
+
await seedCoreProject();
|
|
187
189
|
await seedDefaultBlocks();
|
|
188
190
|
await seedDefaultComponents();
|
|
189
191
|
await refreshComponentTagAllowlist();
|
package/server/services/forms.js
CHANGED
|
@@ -1,255 +1,345 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Core Forms Service
|
|
3
|
-
* CRUD operations for form definitions stored in content/forms/.
|
|
4
|
-
* Submissions are stored exclusively in Collections (slug matching form slug).
|
|
5
|
-
*/
|
|
6
|
-
import fs from 'fs/promises';
|
|
7
|
-
import path from 'path';
|
|
8
|
-
import {fileURLToPath} from 'url';
|
|
9
|
-
import * as cache from './cache/index.js';
|
|
10
|
-
|
|
11
|
-
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
-
const ROOT = path.resolve(__dirname, '..', '..');
|
|
13
|
-
export const FORMS_DIR = path.join(ROOT, 'content', 'forms');
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Ensure the forms directory exists.
|
|
17
|
-
*
|
|
18
|
-
* @returns {Promise<void>}
|
|
19
|
-
*/
|
|
20
|
-
export async function ensureFormsDir() {
|
|
21
|
-
await fs.mkdir(FORMS_DIR, { recursive: true });
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Read a single form definition by slug.
|
|
26
|
-
*
|
|
27
|
-
* @param {string} slug
|
|
28
|
-
* @returns {Promise<object>}
|
|
29
|
-
* @throws {Error} If the form file does not exist or cannot be parsed.
|
|
30
|
-
*/
|
|
31
|
-
export async function readForm(slug) {
|
|
32
|
-
const file = path.join(FORMS_DIR, `${slug}.json`);
|
|
33
|
-
return JSON.parse(await fs.readFile(file, 'utf8'));
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Write a form definition to disk.
|
|
38
|
-
*
|
|
39
|
-
* @param {string} slug
|
|
40
|
-
* @param {object} data
|
|
41
|
-
* @returns {Promise<void>}
|
|
42
|
-
*/
|
|
43
|
-
export async function writeForm(slug, data) {
|
|
44
|
-
await ensureFormsDir();
|
|
45
|
-
const file = path.join(FORMS_DIR, `${slug}.json`);
|
|
46
|
-
await fs.writeFile(file, JSON.stringify(data, null, 4) + '\n', 'utf8');
|
|
47
|
-
await cache.invalidateTags([`form:${slug}`]);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* List all form definitions.
|
|
52
|
-
*
|
|
53
|
-
* @returns {Promise<object[]>}
|
|
54
|
-
*/
|
|
55
|
-
export async function listForms() {
|
|
56
|
-
let entries;
|
|
57
|
-
try {
|
|
58
|
-
entries = await fs.readdir(FORMS_DIR);
|
|
59
|
-
} catch {
|
|
60
|
-
return [];
|
|
61
|
-
}
|
|
62
|
-
const forms = [];
|
|
63
|
-
for (const entry of entries.filter(e => e.endsWith('.json'))) {
|
|
64
|
-
try {
|
|
65
|
-
const data = JSON.parse(await fs.readFile(path.join(FORMS_DIR, entry), 'utf8'));
|
|
66
|
-
forms.push(data);
|
|
67
|
-
} catch {
|
|
68
|
-
// skip malformed
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
return forms;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Delete a form definition by slug.
|
|
76
|
-
*
|
|
77
|
-
* @param {string} slug
|
|
78
|
-
* @returns {Promise<void>}
|
|
79
|
-
* @throws {Error} If the form file does not exist.
|
|
80
|
-
*/
|
|
81
|
-
export async function deleteForm(slug) {
|
|
82
|
-
await fs.unlink(path.join(FORMS_DIR, `${slug}.json`));
|
|
83
|
-
await cache.invalidateTags([`form:${slug}`]);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Convert a string to a URL-friendly slug.
|
|
88
|
-
*
|
|
89
|
-
* @param {string} str
|
|
90
|
-
* @returns {string}
|
|
91
|
-
*/
|
|
92
|
-
export function slugify(str) {
|
|
93
|
-
return str.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Get a single form definition by slug (alias for readForm).
|
|
98
|
-
*
|
|
99
|
-
* @param {string} slug
|
|
100
|
-
* @returns {Promise<object>}
|
|
101
|
-
* @throws {Error} If the form file does not exist or cannot be parsed.
|
|
102
|
-
*/
|
|
103
|
-
export async function getForm(slug) {
|
|
104
|
-
return readForm(slug);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Create a new form definition and write it to disk.
|
|
109
|
-
*
|
|
110
|
-
* @param {object} data - Form definition data
|
|
111
|
-
* @param {string} data.title - Form title (required if no slug)
|
|
112
|
-
* @param {string} [data.slug] - Explicit slug (derived from title if omitted)
|
|
113
|
-
* @param {string} [data.description]
|
|
114
|
-
* @param {Array} [data.fields]
|
|
115
|
-
* @param {object} [data.settings]
|
|
116
|
-
* @param {object} [data.actions]
|
|
117
|
-
* @returns {Promise<object>} The created form object
|
|
118
|
-
* @throws {Error} If title is missing, slug cannot be derived, or a form with the slug already exists.
|
|
119
|
-
*/
|
|
120
|
-
export async function createForm(data) {
|
|
121
|
-
const { title, slug: rawSlug, description = '', fields = [], settings = {}, actions = {}, plugin } = data || {};
|
|
122
|
-
|
|
123
|
-
if (!title?.trim() && !rawSlug?.trim()) {
|
|
124
|
-
throw new Error('A title or slug is required to create a form.');
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
const slug = rawSlug ? slugify(rawSlug) : slugify(title);
|
|
128
|
-
if (!slug) {
|
|
129
|
-
throw new Error('Could not derive a valid slug from the provided title or slug.');
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// Throw if a form with this slug already exists
|
|
133
|
-
try {
|
|
134
|
-
await readForm(slug);
|
|
135
|
-
const err = new Error(`Form with slug "${slug}" already exists`);
|
|
136
|
-
err.code = 'FORM_ALREADY_EXISTS';
|
|
137
|
-
throw err;
|
|
138
|
-
} catch (err) {
|
|
139
|
-
if (err.code === 'FORM_ALREADY_EXISTS') throw err;
|
|
140
|
-
// File not found — safe to proceed
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
const now = new Date().toISOString();
|
|
144
|
-
const trimmedTitle = title ? title.trim() : slug;
|
|
145
|
-
|
|
146
|
-
const form = {
|
|
147
|
-
slug,
|
|
148
|
-
title: trimmedTitle,
|
|
149
|
-
description,
|
|
150
|
-
...(plugin ? {plugin} : {}),
|
|
151
|
-
fields: Array.isArray(fields) ? fields : [],
|
|
152
|
-
settings: {
|
|
153
|
-
submitText: 'Submit',
|
|
154
|
-
successMessage: 'Thank you for your submission.',
|
|
155
|
-
layout: 'stacked',
|
|
156
|
-
honeypot: true,
|
|
157
|
-
rateLimitPerMinute: 3,
|
|
158
|
-
...settings
|
|
159
|
-
},
|
|
160
|
-
actions: {
|
|
161
|
-
email: { enabled: false, recipients: '', subjectPrefix: `[${trimmedTitle}]` },
|
|
162
|
-
webhook: { enabled: false, url: '', method: 'POST' },
|
|
163
|
-
collection: { enabled: true, slug },
|
|
164
|
-
...actions
|
|
165
|
-
},
|
|
166
|
-
createdAt: now,
|
|
167
|
-
updatedAt: now
|
|
168
|
-
};
|
|
169
|
-
|
|
170
|
-
await writeForm(slug, form);
|
|
171
|
-
return form;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/** System collections that should never have an auto-generated public form. */
|
|
175
|
-
const NO_FORM_SLUGS = new Set(['roles', 'user-profiles']);
|
|
176
|
-
|
|
177
|
-
/**
|
|
178
|
-
* Collection field type → form field type mapping.
|
|
179
|
-
*
|
|
180
|
-
* @param {string} type
|
|
181
|
-
* @returns {string}
|
|
182
|
-
*/
|
|
183
|
-
function toFormFieldType(type) {
|
|
184
|
-
const map = {
|
|
185
|
-
string: 'string', text: 'string', email: 'email', tel: 'tel',
|
|
186
|
-
number: 'number', textarea: 'textarea', select: 'select', radio: 'radio',
|
|
187
|
-
checkbox: 'checkbox', 'checkbox-group': 'checkbox-group',
|
|
188
|
-
date: 'date', time: 'time', url: 'url', hidden: 'hidden'
|
|
189
|
-
};
|
|
190
|
-
return map[type] || 'string';
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/**
|
|
194
|
-
* Build a form definition object from a collection schema.
|
|
195
|
-
*
|
|
196
|
-
* @param {object} schema - Collection schema
|
|
197
|
-
* @returns {object} Form definition
|
|
198
|
-
*/
|
|
199
|
-
function buildFormFromCollection(schema) {
|
|
200
|
-
const now = new Date().toISOString();
|
|
201
|
-
const fields = (schema.fields || []).map(f => {
|
|
202
|
-
const field = {
|
|
203
|
-
name: f.name,
|
|
204
|
-
type: toFormFieldType(f.type),
|
|
205
|
-
label: f.label || f.name,
|
|
206
|
-
required: !!f.required,
|
|
207
|
-
placeholder: f.placeholder || '',
|
|
208
|
-
helper: f.helper || '',
|
|
209
|
-
tooltip: f.tooltip || ''
|
|
210
|
-
};
|
|
211
|
-
if (f.options) field.options = f.options;
|
|
212
|
-
if (f.validation) field.validation = f.validation;
|
|
213
|
-
return field;
|
|
214
|
-
});
|
|
215
|
-
|
|
216
|
-
return {
|
|
217
|
-
slug: schema.slug,
|
|
218
|
-
title: schema.title,
|
|
219
|
-
description: schema.description || '',
|
|
220
|
-
fields,
|
|
221
|
-
settings: {
|
|
222
|
-
submitText: 'Submit',
|
|
223
|
-
successMessage: 'Thank you for your submission.',
|
|
224
|
-
layout: 'stacked',
|
|
225
|
-
honeypot: true,
|
|
226
|
-
rateLimitPerMinute: 5
|
|
227
|
-
},
|
|
228
|
-
actions: {
|
|
229
|
-
email: {enabled: false, recipients: '', subjectPrefix: `[${schema.slug}]`},
|
|
230
|
-
webhook: {enabled: false, url: '', method: 'POST'},
|
|
231
|
-
collection: {enabled: true, slug: schema.slug}
|
|
232
|
-
},
|
|
233
|
-
createdAt: now,
|
|
234
|
-
updatedAt: now
|
|
235
|
-
};
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/**
|
|
239
|
-
* Ensure a form exists for the given collection schema.
|
|
240
|
-
* Creates the form only if absent — never overwrites an existing one.
|
|
241
|
-
* Skips system collections (roles, user-profiles).
|
|
242
|
-
*
|
|
243
|
-
* @param {object} schema - Collection schema
|
|
244
|
-
* @returns {Promise<void>}
|
|
245
|
-
*/
|
|
246
|
-
export async function ensureFormForCollection(schema) {
|
|
247
|
-
if (NO_FORM_SLUGS.has(schema.slug)) return;
|
|
248
|
-
await ensureFormsDir();
|
|
249
|
-
const filePath = path.join(FORMS_DIR, `${schema.slug}.json`);
|
|
250
|
-
try {
|
|
251
|
-
await fs.access(filePath);
|
|
252
|
-
} catch {
|
|
253
|
-
await writeForm(schema.slug, buildFormFromCollection(schema));
|
|
254
|
-
}
|
|
255
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Core Forms Service
|
|
3
|
+
* CRUD operations for form definitions stored in content/forms/.
|
|
4
|
+
* Submissions are stored exclusively in Collections (slug matching form slug).
|
|
5
|
+
*/
|
|
6
|
+
import fs from 'fs/promises';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import {fileURLToPath} from 'url';
|
|
9
|
+
import * as cache from './cache/index.js';
|
|
10
|
+
|
|
11
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const ROOT = path.resolve(__dirname, '..', '..');
|
|
13
|
+
export const FORMS_DIR = path.join(ROOT, 'content', 'forms');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Ensure the forms directory exists.
|
|
17
|
+
*
|
|
18
|
+
* @returns {Promise<void>}
|
|
19
|
+
*/
|
|
20
|
+
export async function ensureFormsDir() {
|
|
21
|
+
await fs.mkdir(FORMS_DIR, { recursive: true });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Read a single form definition by slug.
|
|
26
|
+
*
|
|
27
|
+
* @param {string} slug
|
|
28
|
+
* @returns {Promise<object>}
|
|
29
|
+
* @throws {Error} If the form file does not exist or cannot be parsed.
|
|
30
|
+
*/
|
|
31
|
+
export async function readForm(slug) {
|
|
32
|
+
const file = path.join(FORMS_DIR, `${slug}.json`);
|
|
33
|
+
return JSON.parse(await fs.readFile(file, 'utf8'));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Write a form definition to disk.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} slug
|
|
40
|
+
* @param {object} data
|
|
41
|
+
* @returns {Promise<void>}
|
|
42
|
+
*/
|
|
43
|
+
export async function writeForm(slug, data) {
|
|
44
|
+
await ensureFormsDir();
|
|
45
|
+
const file = path.join(FORMS_DIR, `${slug}.json`);
|
|
46
|
+
await fs.writeFile(file, JSON.stringify(data, null, 4) + '\n', 'utf8');
|
|
47
|
+
await cache.invalidateTags([`form:${slug}`]);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* List all form definitions.
|
|
52
|
+
*
|
|
53
|
+
* @returns {Promise<object[]>}
|
|
54
|
+
*/
|
|
55
|
+
export async function listForms() {
|
|
56
|
+
let entries;
|
|
57
|
+
try {
|
|
58
|
+
entries = await fs.readdir(FORMS_DIR);
|
|
59
|
+
} catch {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
const forms = [];
|
|
63
|
+
for (const entry of entries.filter(e => e.endsWith('.json'))) {
|
|
64
|
+
try {
|
|
65
|
+
const data = JSON.parse(await fs.readFile(path.join(FORMS_DIR, entry), 'utf8'));
|
|
66
|
+
forms.push(data);
|
|
67
|
+
} catch {
|
|
68
|
+
// skip malformed
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return forms;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Delete a form definition by slug.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} slug
|
|
78
|
+
* @returns {Promise<void>}
|
|
79
|
+
* @throws {Error} If the form file does not exist.
|
|
80
|
+
*/
|
|
81
|
+
export async function deleteForm(slug) {
|
|
82
|
+
await fs.unlink(path.join(FORMS_DIR, `${slug}.json`));
|
|
83
|
+
await cache.invalidateTags([`form:${slug}`]);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Convert a string to a URL-friendly slug.
|
|
88
|
+
*
|
|
89
|
+
* @param {string} str
|
|
90
|
+
* @returns {string}
|
|
91
|
+
*/
|
|
92
|
+
export function slugify(str) {
|
|
93
|
+
return str.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Get a single form definition by slug (alias for readForm).
|
|
98
|
+
*
|
|
99
|
+
* @param {string} slug
|
|
100
|
+
* @returns {Promise<object>}
|
|
101
|
+
* @throws {Error} If the form file does not exist or cannot be parsed.
|
|
102
|
+
*/
|
|
103
|
+
export async function getForm(slug) {
|
|
104
|
+
return readForm(slug);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Create a new form definition and write it to disk.
|
|
109
|
+
*
|
|
110
|
+
* @param {object} data - Form definition data
|
|
111
|
+
* @param {string} data.title - Form title (required if no slug)
|
|
112
|
+
* @param {string} [data.slug] - Explicit slug (derived from title if omitted)
|
|
113
|
+
* @param {string} [data.description]
|
|
114
|
+
* @param {Array} [data.fields]
|
|
115
|
+
* @param {object} [data.settings]
|
|
116
|
+
* @param {object} [data.actions]
|
|
117
|
+
* @returns {Promise<object>} The created form object
|
|
118
|
+
* @throws {Error} If title is missing, slug cannot be derived, or a form with the slug already exists.
|
|
119
|
+
*/
|
|
120
|
+
export async function createForm(data) {
|
|
121
|
+
const { title, slug: rawSlug, description = '', fields = [], settings = {}, actions = {}, plugin } = data || {};
|
|
122
|
+
|
|
123
|
+
if (!title?.trim() && !rawSlug?.trim()) {
|
|
124
|
+
throw new Error('A title or slug is required to create a form.');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const slug = rawSlug ? slugify(rawSlug) : slugify(title);
|
|
128
|
+
if (!slug) {
|
|
129
|
+
throw new Error('Could not derive a valid slug from the provided title or slug.');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Throw if a form with this slug already exists
|
|
133
|
+
try {
|
|
134
|
+
await readForm(slug);
|
|
135
|
+
const err = new Error(`Form with slug "${slug}" already exists`);
|
|
136
|
+
err.code = 'FORM_ALREADY_EXISTS';
|
|
137
|
+
throw err;
|
|
138
|
+
} catch (err) {
|
|
139
|
+
if (err.code === 'FORM_ALREADY_EXISTS') throw err;
|
|
140
|
+
// File not found — safe to proceed
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const now = new Date().toISOString();
|
|
144
|
+
const trimmedTitle = title ? title.trim() : slug;
|
|
145
|
+
|
|
146
|
+
const form = {
|
|
147
|
+
slug,
|
|
148
|
+
title: trimmedTitle,
|
|
149
|
+
description,
|
|
150
|
+
...(plugin ? {plugin} : {}),
|
|
151
|
+
fields: Array.isArray(fields) ? fields : [],
|
|
152
|
+
settings: {
|
|
153
|
+
submitText: 'Submit',
|
|
154
|
+
successMessage: 'Thank you for your submission.',
|
|
155
|
+
layout: 'stacked',
|
|
156
|
+
honeypot: true,
|
|
157
|
+
rateLimitPerMinute: 3,
|
|
158
|
+
...settings
|
|
159
|
+
},
|
|
160
|
+
actions: {
|
|
161
|
+
email: { enabled: false, recipients: '', subjectPrefix: `[${trimmedTitle}]` },
|
|
162
|
+
webhook: { enabled: false, url: '', method: 'POST' },
|
|
163
|
+
collection: { enabled: true, slug },
|
|
164
|
+
...actions
|
|
165
|
+
},
|
|
166
|
+
createdAt: now,
|
|
167
|
+
updatedAt: now
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
await writeForm(slug, form);
|
|
171
|
+
return form;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** System collections that should never have an auto-generated public form. */
|
|
175
|
+
const NO_FORM_SLUGS = new Set(['roles', 'user-profiles']);
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Collection field type → form field type mapping.
|
|
179
|
+
*
|
|
180
|
+
* @param {string} type
|
|
181
|
+
* @returns {string}
|
|
182
|
+
*/
|
|
183
|
+
function toFormFieldType(type) {
|
|
184
|
+
const map = {
|
|
185
|
+
string: 'string', text: 'string', email: 'email', tel: 'tel',
|
|
186
|
+
number: 'number', textarea: 'textarea', select: 'select', radio: 'radio',
|
|
187
|
+
checkbox: 'checkbox', 'checkbox-group': 'checkbox-group',
|
|
188
|
+
date: 'date', time: 'time', url: 'url', hidden: 'hidden'
|
|
189
|
+
};
|
|
190
|
+
return map[type] || 'string';
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Build a form definition object from a collection schema.
|
|
195
|
+
*
|
|
196
|
+
* @param {object} schema - Collection schema
|
|
197
|
+
* @returns {object} Form definition
|
|
198
|
+
*/
|
|
199
|
+
function buildFormFromCollection(schema) {
|
|
200
|
+
const now = new Date().toISOString();
|
|
201
|
+
const fields = (schema.fields || []).map(f => {
|
|
202
|
+
const field = {
|
|
203
|
+
name: f.name,
|
|
204
|
+
type: toFormFieldType(f.type),
|
|
205
|
+
label: f.label || f.name,
|
|
206
|
+
required: !!f.required,
|
|
207
|
+
placeholder: f.placeholder || '',
|
|
208
|
+
helper: f.helper || '',
|
|
209
|
+
tooltip: f.tooltip || ''
|
|
210
|
+
};
|
|
211
|
+
if (f.options) field.options = f.options;
|
|
212
|
+
if (f.validation) field.validation = f.validation;
|
|
213
|
+
return field;
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
slug: schema.slug,
|
|
218
|
+
title: schema.title,
|
|
219
|
+
description: schema.description || '',
|
|
220
|
+
fields,
|
|
221
|
+
settings: {
|
|
222
|
+
submitText: 'Submit',
|
|
223
|
+
successMessage: 'Thank you for your submission.',
|
|
224
|
+
layout: 'stacked',
|
|
225
|
+
honeypot: true,
|
|
226
|
+
rateLimitPerMinute: 5
|
|
227
|
+
},
|
|
228
|
+
actions: {
|
|
229
|
+
email: {enabled: false, recipients: '', subjectPrefix: `[${schema.slug}]`},
|
|
230
|
+
webhook: {enabled: false, url: '', method: 'POST'},
|
|
231
|
+
collection: {enabled: true, slug: schema.slug}
|
|
232
|
+
},
|
|
233
|
+
createdAt: now,
|
|
234
|
+
updatedAt: now
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Ensure a form exists for the given collection schema.
|
|
240
|
+
* Creates the form only if absent — never overwrites an existing one.
|
|
241
|
+
* Skips system collections (roles, user-profiles).
|
|
242
|
+
*
|
|
243
|
+
* @param {object} schema - Collection schema
|
|
244
|
+
* @returns {Promise<void>}
|
|
245
|
+
*/
|
|
246
|
+
export async function ensureFormForCollection(schema) {
|
|
247
|
+
if (NO_FORM_SLUGS.has(schema.slug)) return;
|
|
248
|
+
await ensureFormsDir();
|
|
249
|
+
const filePath = path.join(FORMS_DIR, `${schema.slug}.json`);
|
|
250
|
+
try {
|
|
251
|
+
await fs.access(filePath);
|
|
252
|
+
} catch {
|
|
253
|
+
await writeForm(schema.slug, buildFormFromCollection(schema));
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Form field types that are presentational only and store no value. */
|
|
258
|
+
const NON_DATA_FIELD_TYPES = new Set(['page-break', 'spacer', 'heading', 'paragraph', 'html']);
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Form field type → collection field type. Mostly an identity mapping; a form's
|
|
262
|
+
* field types are a superset of (and align with) collection field types.
|
|
263
|
+
*
|
|
264
|
+
* @param {string} type
|
|
265
|
+
* @returns {string}
|
|
266
|
+
*/
|
|
267
|
+
function toCollectionFieldType(type) {
|
|
268
|
+
const map = {
|
|
269
|
+
string: 'string', email: 'email', tel: 'tel', number: 'number',
|
|
270
|
+
textarea: 'textarea', select: 'select', radio: 'radio',
|
|
271
|
+
checkbox: 'checkbox', 'checkbox-group': 'checkbox-group',
|
|
272
|
+
date: 'date', time: 'time', url: 'url', hidden: 'hidden'
|
|
273
|
+
};
|
|
274
|
+
return map[type] || 'string';
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Ensure the collection that backs a form's submissions exists.
|
|
279
|
+
*
|
|
280
|
+
* This is the inverse of {@link ensureFormForCollection}. A form whose
|
|
281
|
+
* `collection` action points at a slug that was never created is permanently
|
|
282
|
+
* broken: listing submissions 404s and — worse — every public submission is
|
|
283
|
+
* rejected and lost. Provisioning the collection from the form's own fields
|
|
284
|
+
* makes such forms self-heal instead of silently dropping data.
|
|
285
|
+
*
|
|
286
|
+
* Creates the collection only if absent — never overwrites an existing one.
|
|
287
|
+
* No-op when the form disables collection storage.
|
|
288
|
+
*
|
|
289
|
+
* @param {object} form - Form definition
|
|
290
|
+
* @returns {Promise<object|null>} The existing or newly created collection schema, or null when not applicable.
|
|
291
|
+
*/
|
|
292
|
+
export async function ensureCollectionForForm(form) {
|
|
293
|
+
if (!form || typeof form !== 'object') return null;
|
|
294
|
+
|
|
295
|
+
const action = form.actions?.collection;
|
|
296
|
+
// Respect an explicitly disabled collection store — nothing to provision.
|
|
297
|
+
if (action && action.enabled === false) return null;
|
|
298
|
+
|
|
299
|
+
const targetSlug = (action && action.slug) ? action.slug : form.slug;
|
|
300
|
+
if (!targetSlug) return null;
|
|
301
|
+
|
|
302
|
+
// Dynamic import avoids a forms ↔ collections module cycle at load time.
|
|
303
|
+
const { getCollection, createCollection } = await import('./collections.js');
|
|
304
|
+
|
|
305
|
+
const existing = await getCollection(targetSlug);
|
|
306
|
+
if (existing) return existing;
|
|
307
|
+
|
|
308
|
+
const fields = (form.fields || [])
|
|
309
|
+
.filter(f => f && f.name && !NON_DATA_FIELD_TYPES.has(f.type))
|
|
310
|
+
.map(f => {
|
|
311
|
+
const field = {
|
|
312
|
+
name: f.name,
|
|
313
|
+
type: toCollectionFieldType(f.type),
|
|
314
|
+
label: f.label || f.name,
|
|
315
|
+
required: !!f.required
|
|
316
|
+
};
|
|
317
|
+
if (f.options) field.options = f.options;
|
|
318
|
+
if (f.validation) field.validation = f.validation;
|
|
319
|
+
return field;
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
try {
|
|
323
|
+
return await createCollection({
|
|
324
|
+
title: form.title || targetSlug,
|
|
325
|
+
slug: targetSlug,
|
|
326
|
+
description: form.description || `Submissions for the "${form.title || targetSlug}" form.`,
|
|
327
|
+
fields,
|
|
328
|
+
// Submissions can contain PII — lock the backing collection to admin
|
|
329
|
+
// access so it is never exposed through the public collections API.
|
|
330
|
+
// (Mirrors the access config the POST /forms create path applies.)
|
|
331
|
+
api: {
|
|
332
|
+
create: { enabled: false, access: 'admin' },
|
|
333
|
+
read: { enabled: true, access: 'admin' },
|
|
334
|
+
update: { enabled: false, access: 'admin' },
|
|
335
|
+
delete: { enabled: false, access: 'admin' }
|
|
336
|
+
},
|
|
337
|
+
meta: { generatedFromForm: form.slug }
|
|
338
|
+
});
|
|
339
|
+
} catch (err) {
|
|
340
|
+
// Lost a race with a concurrent provision (EEXIST) — re-read and use it.
|
|
341
|
+
const now = await getCollection(targetSlug);
|
|
342
|
+
if (now) return now;
|
|
343
|
+
throw err;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
@@ -64,7 +64,7 @@ const _loadedPlugins = {};
|
|
|
64
64
|
* Core plugins — always loaded regardless of plugins.json enabled state.
|
|
65
65
|
* These are considered first-class CMS features, not optional add-ons.
|
|
66
66
|
*/
|
|
67
|
-
const CORE_PLUGINS = new Set();
|
|
67
|
+
const CORE_PLUGINS = new Set(['analytics']);
|
|
68
68
|
|
|
69
69
|
/**
|
|
70
70
|
* Scan the plugins/ directory and return all valid manifests.
|
|
@@ -222,9 +222,12 @@ export async function registerPlugins(fastify) {
|
|
|
222
222
|
// reads plugin nav from /api/sidebar/registered-items; plugins
|
|
223
223
|
// declare their nav in the manifest rather than calling
|
|
224
224
|
// registerSidebarItem, so register it here on their behalf.
|
|
225
|
+
// Tag each item with the plugin's core status so the admin sidebar
|
|
226
|
+
// can auto-group it: core plugins → Tools folder, optional → Plugins.
|
|
227
|
+
const isCore = CORE_PLUGINS.has(manifest.name);
|
|
225
228
|
for (const item of manifest.admin?.sidebar || []) {
|
|
226
229
|
try {
|
|
227
|
-
registerSidebarItem({item});
|
|
230
|
+
registerSidebarItem({item: {...item, core: isCore}});
|
|
228
231
|
} catch (err) {
|
|
229
232
|
fastify.log.warn(`[plugins] sidebar item for "${manifest.name}" skipped: ${err.message}`);
|
|
230
233
|
}
|