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
|
@@ -1,746 +1,765 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Core Forms API Routes
|
|
3
|
-
* REST endpoints for form CRUD, public rendering, and submission handling.
|
|
4
|
-
* Submissions are stored exclusively in Collections (no dual-storage).
|
|
5
|
-
*
|
|
6
|
-
* Endpoints (prefix: /api):
|
|
7
|
-
* GET /forms — admin: list all forms
|
|
8
|
-
* POST /forms — admin: create new form
|
|
9
|
-
* GET /forms/:slug — admin: get form definition
|
|
10
|
-
* GET /forms/:slug/public — public: get form (no actions block)
|
|
11
|
-
* PUT /forms/:slug — admin: update form definition
|
|
12
|
-
* DELETE /forms/:slug — admin: delete form
|
|
13
|
-
* GET /forms/:slug/submissions — admin: list submissions from collection
|
|
14
|
-
* GET /forms/:slug/submissions/export — admin: CSV export from collection
|
|
15
|
-
* DELETE /forms/:slug/submissions — admin: clear all submissions
|
|
16
|
-
* DELETE /forms/:slug/submissions/:id — admin: delete one submission
|
|
17
|
-
* POST /forms/submit/:slug — public: accept submission
|
|
18
|
-
* POST /forms/test-email — admin: send test email
|
|
19
|
-
*/
|
|
20
|
-
import {createRequire} from 'module';
|
|
21
|
-
import {fileURLToPath} from 'url';
|
|
22
|
-
import path from 'path';
|
|
23
|
-
import {deleteForm, ensureFormsDir, listForms, readForm, slugify, writeForm} from '../../services/forms.js';
|
|
24
|
-
import {executeAction} from '../../services/actions.js';
|
|
25
|
-
import {createTransport, sendFormEmail} from '../../services/email.js';
|
|
26
|
-
import {
|
|
27
|
-
clearEntries,
|
|
28
|
-
createCollection,
|
|
29
|
-
createEntry,
|
|
30
|
-
deleteEntry,
|
|
31
|
-
getCollection,
|
|
32
|
-
listEntries
|
|
33
|
-
} from '../../services/collections.js';
|
|
34
|
-
import {getConfig} from '../../config.js';
|
|
35
|
-
import {authenticate, requireAdmin, requirePermission} from '../../middleware/auth.js';
|
|
36
|
-
import {hooks} from '../../services/hooks.js';
|
|
37
|
-
import {saveMedia} from '../../services/content.js';
|
|
38
|
-
import {v4 as uuidv4} from 'uuid';
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Detect form↔collection mismatches at save time so admins know BEFORE
|
|
42
|
-
* users hit silent rejection. Returns an array of human-readable warning
|
|
43
|
-
* strings — empty when the form lines up cleanly with its target collection.
|
|
44
|
-
*
|
|
45
|
-
* Checks performed:
|
|
46
|
-
* - Target collection exists (otherwise every submission goes nowhere)
|
|
47
|
-
* - Every required collection field has a matching form field
|
|
48
|
-
* - Every form field references a known collection field (typos)
|
|
49
|
-
*
|
|
50
|
-
* @param {object} form - The just-saved form definition
|
|
51
|
-
* @returns {Promise<string[]>}
|
|
52
|
-
*/
|
|
53
|
-
async function detectFormCollectionMismatch(form) {
|
|
54
|
-
const warnings = [];
|
|
55
|
-
const colAction = form?.actions?.collection;
|
|
56
|
-
const targetSlug = (colAction?.enabled && colAction.slug) ? colAction.slug : form?.slug;
|
|
57
|
-
if (!targetSlug) return warnings;
|
|
58
|
-
|
|
59
|
-
let collection;
|
|
60
|
-
try {
|
|
61
|
-
collection = await getCollection(targetSlug);
|
|
62
|
-
} catch {
|
|
63
|
-
return warnings;
|
|
64
|
-
}
|
|
65
|
-
if (!collection) {
|
|
66
|
-
warnings.push(`Target collection "${targetSlug}" does not exist — submissions will fail. Either create the collection or change the form's target.`);
|
|
67
|
-
return warnings;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const formFieldNames = new Set((form.fields || []).map(f => f.name).filter(Boolean));
|
|
71
|
-
const colFieldNames = new Set((collection.fields || []).map(f => f.name));
|
|
72
|
-
|
|
73
|
-
// Missing-required: collection requires X but form doesn't collect X
|
|
74
|
-
const missingRequired = (collection.fields || [])
|
|
75
|
-
.filter(f => f.required)
|
|
76
|
-
.filter(f => !formFieldNames.has(f.name))
|
|
77
|
-
.map(f => f.label || f.name);
|
|
78
|
-
if (missingRequired.length) {
|
|
79
|
-
warnings.push(`The "${targetSlug}" collection requires fields the form does not collect: ${missingRequired.join(', ')}. Submissions will be rejected with "X is required". Either mark these fields as not required on the collection, add them to the form, or set them via an action's createInCollection step.`);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// Unknown fields: form sends X but collection has no such field (typo / drift)
|
|
83
|
-
const unknown = [...formFieldNames].filter(n => !colFieldNames.has(n));
|
|
84
|
-
if (unknown.length) {
|
|
85
|
-
warnings.push(`The form has fields the "${targetSlug}" collection doesn't define: ${unknown.join(', ')}. These values will be stored but won't be validated, indexed, or displayed in [collection] blocks unless you add matching fields to the collection.`);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
return warnings;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Sanitise a user-supplied filename:
|
|
93
|
-
* - strip path separators and traversal markers
|
|
94
|
-
* - keep only `[a-z0-9._-]`, lower-case
|
|
95
|
-
* - prepend an 8-char uuid prefix so concurrent uploads of the same name
|
|
96
|
-
* don't overwrite each other
|
|
97
|
-
*
|
|
98
|
-
* @param {string} raw
|
|
99
|
-
* @returns {string}
|
|
100
|
-
*/
|
|
101
|
-
function safeFilename(raw) {
|
|
102
|
-
const cleaned = String(raw || 'upload')
|
|
103
|
-
.toLowerCase()
|
|
104
|
-
.replace(/[^\w.-]+/g, '-')
|
|
105
|
-
.replace(/-+/g, '-')
|
|
106
|
-
.replace(/^[-.]+|[-.]+$/g, '')
|
|
107
|
-
.slice(0, 200) || 'upload';
|
|
108
|
-
const prefix = uuidv4().slice(0, 8);
|
|
109
|
-
return `${prefix}-${cleaned}`;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Check a multipart file part against a form's `type: file` field config.
|
|
114
|
-
*
|
|
115
|
-
* @param {object} fieldCfg - The form-field definition
|
|
116
|
-
* @param {object} part - Multipart part (mimetype, filename)
|
|
117
|
-
* @param {number} size - Buffer length in bytes
|
|
118
|
-
* @returns {string|null} - Error message, or null if OK
|
|
119
|
-
*/
|
|
120
|
-
function validateUpload(fieldCfg, part, size) {
|
|
121
|
-
const cfg = fieldCfg?.file || {};
|
|
122
|
-
const max = Number(cfg.maxSize) || 5 * 1024 * 1024; // 5 MB default
|
|
123
|
-
if (size > max) {
|
|
124
|
-
return `"${fieldCfg.label || fieldCfg.name}" file is too large (max ${Math.round(max / 1024)} KB).`;
|
|
125
|
-
}
|
|
126
|
-
const accept = String(cfg.accept || '').trim();
|
|
127
|
-
if (accept) {
|
|
128
|
-
// Comma-separated list of mime types, allowing wildcards like image/*
|
|
129
|
-
const patterns = accept.split(',').map(s => s.trim()).filter(Boolean);
|
|
130
|
-
const mime = part.mimetype || '';
|
|
131
|
-
const ok = patterns.some(p => {
|
|
132
|
-
if (p.endsWith('/*')) return mime.startsWith(p.slice(0, -1));
|
|
133
|
-
return p === mime;
|
|
134
|
-
});
|
|
135
|
-
if (!ok) {
|
|
136
|
-
return `"${fieldCfg.label || fieldCfg.name}" file type "${mime}" not allowed (expected: ${accept}).`;
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
return null;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* Drain a multipart request into a JSON-shaped body. Text parts become string
|
|
144
|
-
* values; file parts are validated against the matching form field, saved to
|
|
145
|
-
* `content/media/`, and replaced by a `{ url, name, size, mime }` reference.
|
|
146
|
-
*
|
|
147
|
-
* Throws on validation failure (caller handles HTTP 400). Files are streamed
|
|
148
|
-
* into memory up to fastify-multipart's configured limit — fine for the
|
|
149
|
-
* resume-pdf / portrait-photo scale; large uploads should still use the admin
|
|
150
|
-
* media flow.
|
|
151
|
-
*
|
|
152
|
-
* @param {import('fastify').FastifyRequest} request
|
|
153
|
-
* @param {object} form
|
|
154
|
-
* @returns {Promise<object>}
|
|
155
|
-
*/
|
|
156
|
-
async function parseMultipartForm(request, form) {
|
|
157
|
-
const out = {};
|
|
158
|
-
const fileFields = new Map(
|
|
159
|
-
(form.fields || []).filter(f => f.type === 'file').map(f => [f.name, f])
|
|
160
|
-
);
|
|
161
|
-
|
|
162
|
-
for await (const part of request.parts()) {
|
|
163
|
-
if (part.type === 'file') {
|
|
164
|
-
const fieldCfg = fileFields.get(part.fieldname);
|
|
165
|
-
if (!fieldCfg) {
|
|
166
|
-
// Unknown file field — drain and skip rather than 400, so a
|
|
167
|
-
// form that gains a file input later doesn't reject older
|
|
168
|
-
// submissions in-flight from a stale page.
|
|
169
|
-
for await (const _ of part.file) { /* drain */ }
|
|
170
|
-
continue;
|
|
171
|
-
}
|
|
172
|
-
const chunks = [];
|
|
173
|
-
for await (const chunk of part.file) chunks.push(chunk);
|
|
174
|
-
const buffer = Buffer.concat(chunks);
|
|
175
|
-
const err = validateUpload(fieldCfg, part, buffer.length);
|
|
176
|
-
if (err) throw new Error(err);
|
|
177
|
-
if (buffer.length === 0) continue; // empty file input — skip
|
|
178
|
-
const saved = await saveMedia(safeFilename(part.filename), buffer);
|
|
179
|
-
out[part.fieldname] = {
|
|
180
|
-
url: saved.url,
|
|
181
|
-
name: part.filename,
|
|
182
|
-
size: buffer.length,
|
|
183
|
-
mime: part.mimetype
|
|
184
|
-
};
|
|
185
|
-
} else {
|
|
186
|
-
// Text field — last value wins on duplicates (browser-standard).
|
|
187
|
-
out[part.fieldname] = part.value;
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
return out;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
194
|
-
const ROOT = path.resolve(__dirname, '..', '..', '..');
|
|
195
|
-
|
|
196
|
-
// Load shared logic engine (UMD/CJS) from core public assets
|
|
197
|
-
const _require = createRequire(import.meta.url);
|
|
198
|
-
const FormLogicEngine = _require('../../../public/js/form-logic-engine.js');
|
|
199
|
-
|
|
200
|
-
// Per-slug rate limit store: slug → Map<ip, timestamp[]>
|
|
201
|
-
const rateLimitMap = new Map();
|
|
202
|
-
|
|
203
|
-
function isRateLimited(slug, ip, limitPerMinute) {
|
|
204
|
-
const now = Date.now();
|
|
205
|
-
const windowMs = 60 * 1000;
|
|
206
|
-
if (!rateLimitMap.has(slug)) rateLimitMap.set(slug, new Map());
|
|
207
|
-
const slugMap = rateLimitMap.get(slug);
|
|
208
|
-
const timestamps = (slugMap.get(ip) || []).filter(t => now - t < windowMs);
|
|
209
|
-
if (timestamps.length >= limitPerMinute) return true;
|
|
210
|
-
timestamps.push(now);
|
|
211
|
-
slugMap.set(ip, timestamps);
|
|
212
|
-
return false;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
function submissionsToCSV(form, entries) {
|
|
216
|
-
const fields = (form.fields || []).filter(f => f.type !== 'page-break' && f.type !== 'spacer');
|
|
217
|
-
const headers = [...fields.map(f => `"${f.label || f.name}"`), '"Date"'];
|
|
218
|
-
const rows = entries.map(e => {
|
|
219
|
-
const cols = fields.map(f => {
|
|
220
|
-
const raw = e.data?.[f.name] ?? '';
|
|
221
|
-
const str = Array.isArray(raw) ? raw.join('; ') : String(raw);
|
|
222
|
-
const val = str.replace(/"/g, '""');
|
|
223
|
-
return `"${val}"`;
|
|
224
|
-
});
|
|
225
|
-
cols.push(`"${e.meta?.createdAt || ''}"`);
|
|
226
|
-
return cols.join(',');
|
|
227
|
-
});
|
|
228
|
-
return [headers.join(','), ...rows].join('\n');
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
export async function formsRoutes(fastify) {
|
|
232
|
-
await ensureFormsDir();
|
|
233
|
-
|
|
234
|
-
const canRead = { preHandler: [authenticate, requirePermission('collections', 'read')] };
|
|
235
|
-
const canCreate = { preHandler: [authenticate, requirePermission('collections', 'create')] };
|
|
236
|
-
const canUpdate = { preHandler: [authenticate, requirePermission('collections', 'update')] };
|
|
237
|
-
const canDelete = { preHandler: [authenticate, requirePermission('collections', 'delete')] };
|
|
238
|
-
|
|
239
|
-
// -----------------------------------------------------------------------
|
|
240
|
-
// GET /forms — list all form definitions with submission counts
|
|
241
|
-
// -----------------------------------------------------------------------
|
|
242
|
-
fastify.get('/forms', canRead, async (request) => {
|
|
243
|
-
const {canSeeArtefact} = await import('../../services/projects.js');
|
|
244
|
-
const forms = await listForms();
|
|
245
|
-
const result = await Promise.all(forms.map(async form => {
|
|
246
|
-
let submissionCount = 0;
|
|
247
|
-
try {
|
|
248
|
-
// listEntries returns { entries, total, page, limit } — total is the
|
|
249
|
-
// unpaginated count which is what we want for the badge here.
|
|
250
|
-
const r = await listEntries(form.slug, { limit: 0 });
|
|
251
|
-
submissionCount = r.total ?? (r.entries || []).length;
|
|
252
|
-
} catch {
|
|
253
|
-
// collection may not exist yet
|
|
254
|
-
}
|
|
255
|
-
// listForms returns metadata only; readForm returns full record with meta
|
|
256
|
-
let full = null;
|
|
257
|
-
try { full = await readForm(form.slug); } catch { /* skip */ }
|
|
258
|
-
return { form: { ...form, submissionCount }, full };
|
|
259
|
-
}));
|
|
260
|
-
return result
|
|
261
|
-
.filter(({ full }) => canSeeArtefact(request.user, full))
|
|
262
|
-
.map(({ form }) => form);
|
|
263
|
-
});
|
|
264
|
-
|
|
265
|
-
// -----------------------------------------------------------------------
|
|
266
|
-
// POST /forms — create new form
|
|
267
|
-
// -----------------------------------------------------------------------
|
|
268
|
-
fastify.post('/forms', canCreate, async (request, reply) => {
|
|
269
|
-
const { title, slug: rawSlug } = request.body || {};
|
|
270
|
-
if (!title?.trim()) {
|
|
271
|
-
return reply.status(400).send({ error: 'Title is required.' });
|
|
272
|
-
}
|
|
273
|
-
const slug = rawSlug ? slugify(rawSlug) : slugify(title);
|
|
274
|
-
if (!slug) {
|
|
275
|
-
return reply.status(400).send({ error: 'Could not generate a valid slug.' });
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
// Check for existing form with same slug
|
|
279
|
-
try {
|
|
280
|
-
await readForm(slug);
|
|
281
|
-
return reply.status(409).send({ error: `A form with slug "${slug}" already exists.` });
|
|
282
|
-
} catch {
|
|
283
|
-
// Does not exist — good
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
const now = new Date().toISOString();
|
|
287
|
-
const body = request.body || {};
|
|
288
|
-
const form = {
|
|
289
|
-
slug,
|
|
290
|
-
title: title.trim(),
|
|
291
|
-
description: body.description || '',
|
|
292
|
-
fields: Array.isArray(body.fields) ? body.fields : [],
|
|
293
|
-
settings: {
|
|
294
|
-
submitText: 'Submit',
|
|
295
|
-
successMessage: 'Thank you for your submission.',
|
|
296
|
-
layout: 'stacked',
|
|
297
|
-
honeypot: true,
|
|
298
|
-
rateLimitPerMinute: 3,
|
|
299
|
-
...(body.settings || {})
|
|
300
|
-
},
|
|
301
|
-
actions: {
|
|
302
|
-
email: { enabled: false, recipients: '', subjectPrefix: `[${title.trim()}]` },
|
|
303
|
-
webhook: { enabled: false, url: '', method: 'POST' },
|
|
304
|
-
collection: {enabled: true, slug},
|
|
305
|
-
...(body.actions || {})
|
|
306
|
-
},
|
|
307
|
-
createdAt: now,
|
|
308
|
-
updatedAt: now
|
|
309
|
-
};
|
|
310
|
-
|
|
311
|
-
await writeForm(slug, form);
|
|
312
|
-
|
|
313
|
-
// Auto-create a matching collection for this form (skip if one already exists)
|
|
314
|
-
try {
|
|
315
|
-
await createCollection({
|
|
316
|
-
slug,
|
|
317
|
-
title: title.trim(),
|
|
318
|
-
description: `Submissions from the ${title.trim()} form.`,
|
|
319
|
-
fields: [],
|
|
320
|
-
api: {
|
|
321
|
-
create: { enabled: false, access: 'admin' },
|
|
322
|
-
read: { enabled: true, access: 'admin' },
|
|
323
|
-
update: { enabled: false, access: 'admin' },
|
|
324
|
-
delete: { enabled: false, access: 'admin' }
|
|
325
|
-
}
|
|
326
|
-
});
|
|
327
|
-
} catch (err) {
|
|
328
|
-
fastify.log.warn(`[forms] Could not auto-create collection "${slug}": ${err.message}`);
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
const warnings = await detectFormCollectionMismatch(form);
|
|
332
|
-
return reply.status(201).send(warnings.length ? { ...form, warnings } : form);
|
|
333
|
-
});
|
|
334
|
-
|
|
335
|
-
// -----------------------------------------------------------------------
|
|
336
|
-
// GET /forms/:slug — get single form (admin, includes actions)
|
|
337
|
-
// -----------------------------------------------------------------------
|
|
338
|
-
fastify.get('/forms/:slug', canRead, async (request, reply) => {
|
|
339
|
-
let form;
|
|
340
|
-
try {
|
|
341
|
-
form = await readForm(request.params.slug);
|
|
342
|
-
} catch {
|
|
343
|
-
return reply.status(404).send({ error: 'Form not found.' });
|
|
344
|
-
}
|
|
345
|
-
const {canSeeArtefact} = await import('../../services/projects.js');
|
|
346
|
-
if (!canSeeArtefact(request.user, form)) {
|
|
347
|
-
return reply.status(403).send({ error: 'Access denied for this project' });
|
|
348
|
-
}
|
|
349
|
-
return form;
|
|
350
|
-
});
|
|
351
|
-
|
|
352
|
-
// -----------------------------------------------------------------------
|
|
353
|
-
// GET /forms/:slug/public — get form for public rendering (no actions)
|
|
354
|
-
// -----------------------------------------------------------------------
|
|
355
|
-
fastify.get('/forms/:slug/public', async (request, reply) => {
|
|
356
|
-
try {
|
|
357
|
-
const form = await readForm(request.params.slug);
|
|
358
|
-
const { actions: _actions, ...safe } = form;
|
|
359
|
-
return safe;
|
|
360
|
-
} catch {
|
|
361
|
-
return reply.status(404).send({ error: 'Form not found.' });
|
|
362
|
-
}
|
|
363
|
-
});
|
|
364
|
-
|
|
365
|
-
// -----------------------------------------------------------------------
|
|
366
|
-
// PUT /forms/:slug — update form definition
|
|
367
|
-
// -----------------------------------------------------------------------
|
|
368
|
-
fastify.put('/forms/:slug', canUpdate, async (request, reply) => {
|
|
369
|
-
const { slug } = request.params;
|
|
370
|
-
let existing;
|
|
371
|
-
try {
|
|
372
|
-
existing = await readForm(slug);
|
|
373
|
-
} catch {
|
|
374
|
-
return reply.status(404).send({ error: 'Form not found.' });
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
const body = request.body || {};
|
|
378
|
-
const updated = {
|
|
379
|
-
...existing,
|
|
380
|
-
...body,
|
|
381
|
-
slug,
|
|
382
|
-
createdAt: existing.createdAt,
|
|
383
|
-
updatedAt: new Date().toISOString()
|
|
384
|
-
};
|
|
385
|
-
await writeForm(slug, updated);
|
|
386
|
-
|
|
387
|
-
// Non-blocking warnings: check the form's fields against its target
|
|
388
|
-
// collection's required fields. Missing-required-on-form would cause
|
|
389
|
-
// every submission to fail server-side validation — admin gets a
|
|
390
|
-
// heads-up here so they can fix it BEFORE users hit silent rejection.
|
|
391
|
-
const warnings = await detectFormCollectionMismatch(updated);
|
|
392
|
-
return warnings.length ? { ...updated, warnings } : updated;
|
|
393
|
-
});
|
|
394
|
-
|
|
395
|
-
// -----------------------------------------------------------------------
|
|
396
|
-
// DELETE /forms/:slug — delete form
|
|
397
|
-
// -----------------------------------------------------------------------
|
|
398
|
-
fastify.delete('/forms/:slug', canDelete, async (request, reply) => {
|
|
399
|
-
const { slug } = request.params;
|
|
400
|
-
try {
|
|
401
|
-
await deleteForm(slug);
|
|
402
|
-
} catch {
|
|
403
|
-
return reply.status(404).send({ error: 'Form not found.' });
|
|
404
|
-
}
|
|
405
|
-
return { ok: true };
|
|
406
|
-
});
|
|
407
|
-
|
|
408
|
-
// -----------------------------------------------------------------------
|
|
409
|
-
// GET /forms/:slug/submissions — list submissions from collection
|
|
410
|
-
// -----------------------------------------------------------------------
|
|
411
|
-
fastify.get('/forms/:slug/submissions', canRead, async (request, reply) => {
|
|
412
|
-
const { slug } = request.params;
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
const
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
*
|
|
509
|
-
*
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
const
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
const
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
if (
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
await
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Core Forms API Routes
|
|
3
|
+
* REST endpoints for form CRUD, public rendering, and submission handling.
|
|
4
|
+
* Submissions are stored exclusively in Collections (no dual-storage).
|
|
5
|
+
*
|
|
6
|
+
* Endpoints (prefix: /api):
|
|
7
|
+
* GET /forms — admin: list all forms
|
|
8
|
+
* POST /forms — admin: create new form
|
|
9
|
+
* GET /forms/:slug — admin: get form definition
|
|
10
|
+
* GET /forms/:slug/public — public: get form (no actions block)
|
|
11
|
+
* PUT /forms/:slug — admin: update form definition
|
|
12
|
+
* DELETE /forms/:slug — admin: delete form
|
|
13
|
+
* GET /forms/:slug/submissions — admin: list submissions from collection
|
|
14
|
+
* GET /forms/:slug/submissions/export — admin: CSV export from collection
|
|
15
|
+
* DELETE /forms/:slug/submissions — admin: clear all submissions
|
|
16
|
+
* DELETE /forms/:slug/submissions/:id — admin: delete one submission
|
|
17
|
+
* POST /forms/submit/:slug — public: accept submission
|
|
18
|
+
* POST /forms/test-email — admin: send test email
|
|
19
|
+
*/
|
|
20
|
+
import {createRequire} from 'module';
|
|
21
|
+
import {fileURLToPath} from 'url';
|
|
22
|
+
import path from 'path';
|
|
23
|
+
import {deleteForm, ensureCollectionForForm, ensureFormsDir, listForms, readForm, slugify, writeForm} from '../../services/forms.js';
|
|
24
|
+
import {executeAction} from '../../services/actions.js';
|
|
25
|
+
import {createTransport, sendFormEmail} from '../../services/email.js';
|
|
26
|
+
import {
|
|
27
|
+
clearEntries,
|
|
28
|
+
createCollection,
|
|
29
|
+
createEntry,
|
|
30
|
+
deleteEntry,
|
|
31
|
+
getCollection,
|
|
32
|
+
listEntries
|
|
33
|
+
} from '../../services/collections.js';
|
|
34
|
+
import {getConfig} from '../../config.js';
|
|
35
|
+
import {authenticate, requireAdmin, requirePermission} from '../../middleware/auth.js';
|
|
36
|
+
import {hooks} from '../../services/hooks.js';
|
|
37
|
+
import {saveMedia} from '../../services/content.js';
|
|
38
|
+
import {v4 as uuidv4} from 'uuid';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Detect form↔collection mismatches at save time so admins know BEFORE
|
|
42
|
+
* users hit silent rejection. Returns an array of human-readable warning
|
|
43
|
+
* strings — empty when the form lines up cleanly with its target collection.
|
|
44
|
+
*
|
|
45
|
+
* Checks performed:
|
|
46
|
+
* - Target collection exists (otherwise every submission goes nowhere)
|
|
47
|
+
* - Every required collection field has a matching form field
|
|
48
|
+
* - Every form field references a known collection field (typos)
|
|
49
|
+
*
|
|
50
|
+
* @param {object} form - The just-saved form definition
|
|
51
|
+
* @returns {Promise<string[]>}
|
|
52
|
+
*/
|
|
53
|
+
async function detectFormCollectionMismatch(form) {
|
|
54
|
+
const warnings = [];
|
|
55
|
+
const colAction = form?.actions?.collection;
|
|
56
|
+
const targetSlug = (colAction?.enabled && colAction.slug) ? colAction.slug : form?.slug;
|
|
57
|
+
if (!targetSlug) return warnings;
|
|
58
|
+
|
|
59
|
+
let collection;
|
|
60
|
+
try {
|
|
61
|
+
collection = await getCollection(targetSlug);
|
|
62
|
+
} catch {
|
|
63
|
+
return warnings;
|
|
64
|
+
}
|
|
65
|
+
if (!collection) {
|
|
66
|
+
warnings.push(`Target collection "${targetSlug}" does not exist — submissions will fail. Either create the collection or change the form's target.`);
|
|
67
|
+
return warnings;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const formFieldNames = new Set((form.fields || []).map(f => f.name).filter(Boolean));
|
|
71
|
+
const colFieldNames = new Set((collection.fields || []).map(f => f.name));
|
|
72
|
+
|
|
73
|
+
// Missing-required: collection requires X but form doesn't collect X
|
|
74
|
+
const missingRequired = (collection.fields || [])
|
|
75
|
+
.filter(f => f.required)
|
|
76
|
+
.filter(f => !formFieldNames.has(f.name))
|
|
77
|
+
.map(f => f.label || f.name);
|
|
78
|
+
if (missingRequired.length) {
|
|
79
|
+
warnings.push(`The "${targetSlug}" collection requires fields the form does not collect: ${missingRequired.join(', ')}. Submissions will be rejected with "X is required". Either mark these fields as not required on the collection, add them to the form, or set them via an action's createInCollection step.`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Unknown fields: form sends X but collection has no such field (typo / drift)
|
|
83
|
+
const unknown = [...formFieldNames].filter(n => !colFieldNames.has(n));
|
|
84
|
+
if (unknown.length) {
|
|
85
|
+
warnings.push(`The form has fields the "${targetSlug}" collection doesn't define: ${unknown.join(', ')}. These values will be stored but won't be validated, indexed, or displayed in [collection] blocks unless you add matching fields to the collection.`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return warnings;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Sanitise a user-supplied filename:
|
|
93
|
+
* - strip path separators and traversal markers
|
|
94
|
+
* - keep only `[a-z0-9._-]`, lower-case
|
|
95
|
+
* - prepend an 8-char uuid prefix so concurrent uploads of the same name
|
|
96
|
+
* don't overwrite each other
|
|
97
|
+
*
|
|
98
|
+
* @param {string} raw
|
|
99
|
+
* @returns {string}
|
|
100
|
+
*/
|
|
101
|
+
function safeFilename(raw) {
|
|
102
|
+
const cleaned = String(raw || 'upload')
|
|
103
|
+
.toLowerCase()
|
|
104
|
+
.replace(/[^\w.-]+/g, '-')
|
|
105
|
+
.replace(/-+/g, '-')
|
|
106
|
+
.replace(/^[-.]+|[-.]+$/g, '')
|
|
107
|
+
.slice(0, 200) || 'upload';
|
|
108
|
+
const prefix = uuidv4().slice(0, 8);
|
|
109
|
+
return `${prefix}-${cleaned}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Check a multipart file part against a form's `type: file` field config.
|
|
114
|
+
*
|
|
115
|
+
* @param {object} fieldCfg - The form-field definition
|
|
116
|
+
* @param {object} part - Multipart part (mimetype, filename)
|
|
117
|
+
* @param {number} size - Buffer length in bytes
|
|
118
|
+
* @returns {string|null} - Error message, or null if OK
|
|
119
|
+
*/
|
|
120
|
+
function validateUpload(fieldCfg, part, size) {
|
|
121
|
+
const cfg = fieldCfg?.file || {};
|
|
122
|
+
const max = Number(cfg.maxSize) || 5 * 1024 * 1024; // 5 MB default
|
|
123
|
+
if (size > max) {
|
|
124
|
+
return `"${fieldCfg.label || fieldCfg.name}" file is too large (max ${Math.round(max / 1024)} KB).`;
|
|
125
|
+
}
|
|
126
|
+
const accept = String(cfg.accept || '').trim();
|
|
127
|
+
if (accept) {
|
|
128
|
+
// Comma-separated list of mime types, allowing wildcards like image/*
|
|
129
|
+
const patterns = accept.split(',').map(s => s.trim()).filter(Boolean);
|
|
130
|
+
const mime = part.mimetype || '';
|
|
131
|
+
const ok = patterns.some(p => {
|
|
132
|
+
if (p.endsWith('/*')) return mime.startsWith(p.slice(0, -1));
|
|
133
|
+
return p === mime;
|
|
134
|
+
});
|
|
135
|
+
if (!ok) {
|
|
136
|
+
return `"${fieldCfg.label || fieldCfg.name}" file type "${mime}" not allowed (expected: ${accept}).`;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Drain a multipart request into a JSON-shaped body. Text parts become string
|
|
144
|
+
* values; file parts are validated against the matching form field, saved to
|
|
145
|
+
* `content/media/`, and replaced by a `{ url, name, size, mime }` reference.
|
|
146
|
+
*
|
|
147
|
+
* Throws on validation failure (caller handles HTTP 400). Files are streamed
|
|
148
|
+
* into memory up to fastify-multipart's configured limit — fine for the
|
|
149
|
+
* resume-pdf / portrait-photo scale; large uploads should still use the admin
|
|
150
|
+
* media flow.
|
|
151
|
+
*
|
|
152
|
+
* @param {import('fastify').FastifyRequest} request
|
|
153
|
+
* @param {object} form
|
|
154
|
+
* @returns {Promise<object>}
|
|
155
|
+
*/
|
|
156
|
+
async function parseMultipartForm(request, form) {
|
|
157
|
+
const out = {};
|
|
158
|
+
const fileFields = new Map(
|
|
159
|
+
(form.fields || []).filter(f => f.type === 'file').map(f => [f.name, f])
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
for await (const part of request.parts()) {
|
|
163
|
+
if (part.type === 'file') {
|
|
164
|
+
const fieldCfg = fileFields.get(part.fieldname);
|
|
165
|
+
if (!fieldCfg) {
|
|
166
|
+
// Unknown file field — drain and skip rather than 400, so a
|
|
167
|
+
// form that gains a file input later doesn't reject older
|
|
168
|
+
// submissions in-flight from a stale page.
|
|
169
|
+
for await (const _ of part.file) { /* drain */ }
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const chunks = [];
|
|
173
|
+
for await (const chunk of part.file) chunks.push(chunk);
|
|
174
|
+
const buffer = Buffer.concat(chunks);
|
|
175
|
+
const err = validateUpload(fieldCfg, part, buffer.length);
|
|
176
|
+
if (err) throw new Error(err);
|
|
177
|
+
if (buffer.length === 0) continue; // empty file input — skip
|
|
178
|
+
const saved = await saveMedia(safeFilename(part.filename), buffer);
|
|
179
|
+
out[part.fieldname] = {
|
|
180
|
+
url: saved.url,
|
|
181
|
+
name: part.filename,
|
|
182
|
+
size: buffer.length,
|
|
183
|
+
mime: part.mimetype
|
|
184
|
+
};
|
|
185
|
+
} else {
|
|
186
|
+
// Text field — last value wins on duplicates (browser-standard).
|
|
187
|
+
out[part.fieldname] = part.value;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
194
|
+
const ROOT = path.resolve(__dirname, '..', '..', '..');
|
|
195
|
+
|
|
196
|
+
// Load shared logic engine (UMD/CJS) from core public assets
|
|
197
|
+
const _require = createRequire(import.meta.url);
|
|
198
|
+
const FormLogicEngine = _require('../../../public/js/form-logic-engine.js');
|
|
199
|
+
|
|
200
|
+
// Per-slug rate limit store: slug → Map<ip, timestamp[]>
|
|
201
|
+
const rateLimitMap = new Map();
|
|
202
|
+
|
|
203
|
+
function isRateLimited(slug, ip, limitPerMinute) {
|
|
204
|
+
const now = Date.now();
|
|
205
|
+
const windowMs = 60 * 1000;
|
|
206
|
+
if (!rateLimitMap.has(slug)) rateLimitMap.set(slug, new Map());
|
|
207
|
+
const slugMap = rateLimitMap.get(slug);
|
|
208
|
+
const timestamps = (slugMap.get(ip) || []).filter(t => now - t < windowMs);
|
|
209
|
+
if (timestamps.length >= limitPerMinute) return true;
|
|
210
|
+
timestamps.push(now);
|
|
211
|
+
slugMap.set(ip, timestamps);
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function submissionsToCSV(form, entries) {
|
|
216
|
+
const fields = (form.fields || []).filter(f => f.type !== 'page-break' && f.type !== 'spacer');
|
|
217
|
+
const headers = [...fields.map(f => `"${f.label || f.name}"`), '"Date"'];
|
|
218
|
+
const rows = entries.map(e => {
|
|
219
|
+
const cols = fields.map(f => {
|
|
220
|
+
const raw = e.data?.[f.name] ?? '';
|
|
221
|
+
const str = Array.isArray(raw) ? raw.join('; ') : String(raw);
|
|
222
|
+
const val = str.replace(/"/g, '""');
|
|
223
|
+
return `"${val}"`;
|
|
224
|
+
});
|
|
225
|
+
cols.push(`"${e.meta?.createdAt || ''}"`);
|
|
226
|
+
return cols.join(',');
|
|
227
|
+
});
|
|
228
|
+
return [headers.join(','), ...rows].join('\n');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export async function formsRoutes(fastify) {
|
|
232
|
+
await ensureFormsDir();
|
|
233
|
+
|
|
234
|
+
const canRead = { preHandler: [authenticate, requirePermission('collections', 'read')] };
|
|
235
|
+
const canCreate = { preHandler: [authenticate, requirePermission('collections', 'create')] };
|
|
236
|
+
const canUpdate = { preHandler: [authenticate, requirePermission('collections', 'update')] };
|
|
237
|
+
const canDelete = { preHandler: [authenticate, requirePermission('collections', 'delete')] };
|
|
238
|
+
|
|
239
|
+
// -----------------------------------------------------------------------
|
|
240
|
+
// GET /forms — list all form definitions with submission counts
|
|
241
|
+
// -----------------------------------------------------------------------
|
|
242
|
+
fastify.get('/forms', canRead, async (request) => {
|
|
243
|
+
const {canSeeArtefact} = await import('../../services/projects.js');
|
|
244
|
+
const forms = await listForms();
|
|
245
|
+
const result = await Promise.all(forms.map(async form => {
|
|
246
|
+
let submissionCount = 0;
|
|
247
|
+
try {
|
|
248
|
+
// listEntries returns { entries, total, page, limit } — total is the
|
|
249
|
+
// unpaginated count which is what we want for the badge here.
|
|
250
|
+
const r = await listEntries(form.slug, { limit: 0 });
|
|
251
|
+
submissionCount = r.total ?? (r.entries || []).length;
|
|
252
|
+
} catch {
|
|
253
|
+
// collection may not exist yet
|
|
254
|
+
}
|
|
255
|
+
// listForms returns metadata only; readForm returns full record with meta
|
|
256
|
+
let full = null;
|
|
257
|
+
try { full = await readForm(form.slug); } catch { /* skip */ }
|
|
258
|
+
return { form: { ...form, submissionCount }, full };
|
|
259
|
+
}));
|
|
260
|
+
return result
|
|
261
|
+
.filter(({ full }) => canSeeArtefact(request.user, full))
|
|
262
|
+
.map(({ form }) => form);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// -----------------------------------------------------------------------
|
|
266
|
+
// POST /forms — create new form
|
|
267
|
+
// -----------------------------------------------------------------------
|
|
268
|
+
fastify.post('/forms', canCreate, async (request, reply) => {
|
|
269
|
+
const { title, slug: rawSlug } = request.body || {};
|
|
270
|
+
if (!title?.trim()) {
|
|
271
|
+
return reply.status(400).send({ error: 'Title is required.' });
|
|
272
|
+
}
|
|
273
|
+
const slug = rawSlug ? slugify(rawSlug) : slugify(title);
|
|
274
|
+
if (!slug) {
|
|
275
|
+
return reply.status(400).send({ error: 'Could not generate a valid slug.' });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Check for existing form with same slug
|
|
279
|
+
try {
|
|
280
|
+
await readForm(slug);
|
|
281
|
+
return reply.status(409).send({ error: `A form with slug "${slug}" already exists.` });
|
|
282
|
+
} catch {
|
|
283
|
+
// Does not exist — good
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const now = new Date().toISOString();
|
|
287
|
+
const body = request.body || {};
|
|
288
|
+
const form = {
|
|
289
|
+
slug,
|
|
290
|
+
title: title.trim(),
|
|
291
|
+
description: body.description || '',
|
|
292
|
+
fields: Array.isArray(body.fields) ? body.fields : [],
|
|
293
|
+
settings: {
|
|
294
|
+
submitText: 'Submit',
|
|
295
|
+
successMessage: 'Thank you for your submission.',
|
|
296
|
+
layout: 'stacked',
|
|
297
|
+
honeypot: true,
|
|
298
|
+
rateLimitPerMinute: 3,
|
|
299
|
+
...(body.settings || {})
|
|
300
|
+
},
|
|
301
|
+
actions: {
|
|
302
|
+
email: { enabled: false, recipients: '', subjectPrefix: `[${title.trim()}]` },
|
|
303
|
+
webhook: { enabled: false, url: '', method: 'POST' },
|
|
304
|
+
collection: {enabled: true, slug},
|
|
305
|
+
...(body.actions || {})
|
|
306
|
+
},
|
|
307
|
+
createdAt: now,
|
|
308
|
+
updatedAt: now
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
await writeForm(slug, form);
|
|
312
|
+
|
|
313
|
+
// Auto-create a matching collection for this form (skip if one already exists)
|
|
314
|
+
try {
|
|
315
|
+
await createCollection({
|
|
316
|
+
slug,
|
|
317
|
+
title: title.trim(),
|
|
318
|
+
description: `Submissions from the ${title.trim()} form.`,
|
|
319
|
+
fields: [],
|
|
320
|
+
api: {
|
|
321
|
+
create: { enabled: false, access: 'admin' },
|
|
322
|
+
read: { enabled: true, access: 'admin' },
|
|
323
|
+
update: { enabled: false, access: 'admin' },
|
|
324
|
+
delete: { enabled: false, access: 'admin' }
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
} catch (err) {
|
|
328
|
+
fastify.log.warn(`[forms] Could not auto-create collection "${slug}": ${err.message}`);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const warnings = await detectFormCollectionMismatch(form);
|
|
332
|
+
return reply.status(201).send(warnings.length ? { ...form, warnings } : form);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// -----------------------------------------------------------------------
|
|
336
|
+
// GET /forms/:slug — get single form (admin, includes actions)
|
|
337
|
+
// -----------------------------------------------------------------------
|
|
338
|
+
fastify.get('/forms/:slug', canRead, async (request, reply) => {
|
|
339
|
+
let form;
|
|
340
|
+
try {
|
|
341
|
+
form = await readForm(request.params.slug);
|
|
342
|
+
} catch {
|
|
343
|
+
return reply.status(404).send({ error: 'Form not found.' });
|
|
344
|
+
}
|
|
345
|
+
const {canSeeArtefact} = await import('../../services/projects.js');
|
|
346
|
+
if (!canSeeArtefact(request.user, form)) {
|
|
347
|
+
return reply.status(403).send({ error: 'Access denied for this project' });
|
|
348
|
+
}
|
|
349
|
+
return form;
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
// -----------------------------------------------------------------------
|
|
353
|
+
// GET /forms/:slug/public — get form for public rendering (no actions)
|
|
354
|
+
// -----------------------------------------------------------------------
|
|
355
|
+
fastify.get('/forms/:slug/public', async (request, reply) => {
|
|
356
|
+
try {
|
|
357
|
+
const form = await readForm(request.params.slug);
|
|
358
|
+
const { actions: _actions, ...safe } = form;
|
|
359
|
+
return safe;
|
|
360
|
+
} catch {
|
|
361
|
+
return reply.status(404).send({ error: 'Form not found.' });
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
// -----------------------------------------------------------------------
|
|
366
|
+
// PUT /forms/:slug — update form definition
|
|
367
|
+
// -----------------------------------------------------------------------
|
|
368
|
+
fastify.put('/forms/:slug', canUpdate, async (request, reply) => {
|
|
369
|
+
const { slug } = request.params;
|
|
370
|
+
let existing;
|
|
371
|
+
try {
|
|
372
|
+
existing = await readForm(slug);
|
|
373
|
+
} catch {
|
|
374
|
+
return reply.status(404).send({ error: 'Form not found.' });
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const body = request.body || {};
|
|
378
|
+
const updated = {
|
|
379
|
+
...existing,
|
|
380
|
+
...body,
|
|
381
|
+
slug,
|
|
382
|
+
createdAt: existing.createdAt,
|
|
383
|
+
updatedAt: new Date().toISOString()
|
|
384
|
+
};
|
|
385
|
+
await writeForm(slug, updated);
|
|
386
|
+
|
|
387
|
+
// Non-blocking warnings: check the form's fields against its target
|
|
388
|
+
// collection's required fields. Missing-required-on-form would cause
|
|
389
|
+
// every submission to fail server-side validation — admin gets a
|
|
390
|
+
// heads-up here so they can fix it BEFORE users hit silent rejection.
|
|
391
|
+
const warnings = await detectFormCollectionMismatch(updated);
|
|
392
|
+
return warnings.length ? { ...updated, warnings } : updated;
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
// -----------------------------------------------------------------------
|
|
396
|
+
// DELETE /forms/:slug — delete form
|
|
397
|
+
// -----------------------------------------------------------------------
|
|
398
|
+
fastify.delete('/forms/:slug', canDelete, async (request, reply) => {
|
|
399
|
+
const { slug } = request.params;
|
|
400
|
+
try {
|
|
401
|
+
await deleteForm(slug);
|
|
402
|
+
} catch {
|
|
403
|
+
return reply.status(404).send({ error: 'Form not found.' });
|
|
404
|
+
}
|
|
405
|
+
return { ok: true };
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
// -----------------------------------------------------------------------
|
|
409
|
+
// GET /forms/:slug/submissions — list submissions from collection
|
|
410
|
+
// -----------------------------------------------------------------------
|
|
411
|
+
fastify.get('/forms/:slug/submissions', canRead, async (request, reply) => {
|
|
412
|
+
const { slug } = request.params;
|
|
413
|
+
let form;
|
|
414
|
+
try {
|
|
415
|
+
form = await readForm(slug);
|
|
416
|
+
} catch {
|
|
417
|
+
return reply.status(404).send({ error: 'Form not found.' });
|
|
418
|
+
}
|
|
419
|
+
const collectionAction = form.actions?.collection;
|
|
420
|
+
const targetSlug = (collectionAction?.enabled && collectionAction.slug) ? collectionAction.slug : slug;
|
|
421
|
+
|
|
422
|
+
// A form whose backing collection has not been provisioned yet simply
|
|
423
|
+
// has no submissions — return an empty list rather than a 404 so the
|
|
424
|
+
// admin view loads cleanly instead of erroring.
|
|
425
|
+
const collection = await getCollection(targetSlug);
|
|
426
|
+
if (!collection) return [];
|
|
427
|
+
|
|
428
|
+
// listEntries returns { entries, total, page, limit } — unwrap to the
|
|
429
|
+
// array. Newest-first is the conventional display order.
|
|
430
|
+
const { entries } = await listEntries(targetSlug, { limit: 0 });
|
|
431
|
+
return [...entries].reverse();
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
// -----------------------------------------------------------------------
|
|
435
|
+
// GET /forms/:slug/submissions/export — CSV download from collection
|
|
436
|
+
// -----------------------------------------------------------------------
|
|
437
|
+
fastify.get('/forms/:slug/submissions/export', canRead, async (request, reply) => {
|
|
438
|
+
const { slug } = request.params;
|
|
439
|
+
let form;
|
|
440
|
+
try {
|
|
441
|
+
form = await readForm(slug);
|
|
442
|
+
} catch {
|
|
443
|
+
return reply.status(404).send({ error: 'Form not found.' });
|
|
444
|
+
}
|
|
445
|
+
let entries = [];
|
|
446
|
+
try {
|
|
447
|
+
const r = await listEntries(slug, { limit: 0 });
|
|
448
|
+
entries = r.entries || [];
|
|
449
|
+
} catch {
|
|
450
|
+
// empty collection
|
|
451
|
+
}
|
|
452
|
+
const csv = submissionsToCSV(form, entries);
|
|
453
|
+
reply.header('Content-Type', 'text/csv');
|
|
454
|
+
reply.header('Content-Disposition', `attachment; filename="${slug}-submissions.csv"`);
|
|
455
|
+
return reply.send(csv);
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
// -----------------------------------------------------------------------
|
|
459
|
+
// GET /forms/:slug/submissions/export/json — JSON export from collection
|
|
460
|
+
// -----------------------------------------------------------------------
|
|
461
|
+
fastify.get('/forms/:slug/submissions/export/json', canRead, async (request, reply) => {
|
|
462
|
+
const { slug } = request.params;
|
|
463
|
+
let form;
|
|
464
|
+
try {
|
|
465
|
+
form = await readForm(slug);
|
|
466
|
+
} catch {
|
|
467
|
+
return reply.status(404).send({ error: 'Form not found.' });
|
|
468
|
+
}
|
|
469
|
+
let entries = [];
|
|
470
|
+
try {
|
|
471
|
+
const r = await listEntries(slug, { limit: 0 });
|
|
472
|
+
entries = r.entries || [];
|
|
473
|
+
} catch {
|
|
474
|
+
// empty
|
|
475
|
+
}
|
|
476
|
+
reply.header('Content-Type', 'application/json');
|
|
477
|
+
reply.header('Content-Disposition', `attachment; filename="${slug}-submissions.json"`);
|
|
478
|
+
return reply.send(JSON.stringify(entries, null, 2));
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
// -----------------------------------------------------------------------
|
|
482
|
+
// DELETE /forms/:slug/submissions — clear all submissions
|
|
483
|
+
// -----------------------------------------------------------------------
|
|
484
|
+
fastify.delete('/forms/:slug/submissions', canDelete, async (request, reply) => {
|
|
485
|
+
const { slug } = request.params;
|
|
486
|
+
try {
|
|
487
|
+
await clearEntries(slug);
|
|
488
|
+
} catch {
|
|
489
|
+
return reply.status(404).send({ error: 'Collection not found for this form.' });
|
|
490
|
+
}
|
|
491
|
+
return { ok: true };
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
// -----------------------------------------------------------------------
|
|
495
|
+
// DELETE /forms/:slug/submissions/:id — delete one submission
|
|
496
|
+
// -----------------------------------------------------------------------
|
|
497
|
+
fastify.delete('/forms/:slug/submissions/:id', canDelete, async (request, reply) => {
|
|
498
|
+
const { slug, id } = request.params;
|
|
499
|
+
try {
|
|
500
|
+
await deleteEntry(slug, id);
|
|
501
|
+
} catch {
|
|
502
|
+
return reply.status(404).send({ error: 'Submission not found.' });
|
|
503
|
+
}
|
|
504
|
+
return { ok: true };
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
/*
|
|
508
|
+
* POST /forms/submit/:slug — public form submission.
|
|
509
|
+
*
|
|
510
|
+
* Authentication is OPTIONAL: when a valid JWT is present the submitter
|
|
511
|
+
* is captured as the entry's `createdBy` and passed into any triggered
|
|
512
|
+
* action's template context (as `{{user.id}}`, `{{user.email}}`, etc.).
|
|
513
|
+
* Anonymous submissions still work — `createdBy` is null and actions
|
|
514
|
+
* receive an empty user. This is what makes the apply/bookmark patterns
|
|
515
|
+
* work without forcing every form to be auth-only:
|
|
516
|
+
*
|
|
517
|
+
* - Anonymous contact form → submitted by guest, user=null
|
|
518
|
+
* - "Apply for job" form → submitted by candidate, action's
|
|
519
|
+
* createInCollection step stamps the application with their id
|
|
520
|
+
*/
|
|
521
|
+
fastify.post('/forms/submit/:slug', async (request, reply) => {
|
|
522
|
+
const { slug } = request.params;
|
|
523
|
+
let form;
|
|
524
|
+
try {
|
|
525
|
+
form = await readForm(slug);
|
|
526
|
+
} catch {
|
|
527
|
+
return reply.status(404).send({ error: 'Form not found.' });
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Multipart parsing — when the request is multipart (file uploads),
|
|
531
|
+
// we walk parts ourselves: text fields go into `body`, file parts are
|
|
532
|
+
// validated against the form's field config and saved to /content/media,
|
|
533
|
+
// then their { url, name, size, mime } reference object lands in `body`
|
|
534
|
+
// under the field name. Downstream code sees a uniform JSON-shaped body.
|
|
535
|
+
let body = request.body || {};
|
|
536
|
+
if (request.isMultipart && request.isMultipart()) {
|
|
537
|
+
try {
|
|
538
|
+
body = await parseMultipartForm(request, form);
|
|
539
|
+
} catch (err) {
|
|
540
|
+
return reply.status(400).send({ error: err.message });
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
const settings = form.settings || {};
|
|
544
|
+
|
|
545
|
+
// Best-effort auth — never reject, just enrich.
|
|
546
|
+
let submittingUser = null;
|
|
547
|
+
try {
|
|
548
|
+
const decoded = await request.jwtVerify();
|
|
549
|
+
if (decoded.type === 'access') {
|
|
550
|
+
submittingUser = {
|
|
551
|
+
id: decoded.id,
|
|
552
|
+
name: decoded.name,
|
|
553
|
+
email: decoded.email,
|
|
554
|
+
role: decoded.role
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
} catch { /* anonymous submission */ }
|
|
558
|
+
|
|
559
|
+
// Honeypot check — silently accept if filled (bot detected)
|
|
560
|
+
if (settings.honeypot && body._hp) {
|
|
561
|
+
return { ok: true, message: settings.successMessage, redirect: settings.successRedirect || null };
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Timing check — silently accept if submitted too fast (< 2 s, likely a bot)
|
|
565
|
+
if (settings.honeypot && body._t) {
|
|
566
|
+
const elapsed = Date.now() - Number(body._t);
|
|
567
|
+
if (!Number.isNaN(elapsed) && elapsed < 2000) {
|
|
568
|
+
return {ok: true, message: settings.successMessage, redirect: settings.successRedirect || null};
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Build form values for engine evaluation
|
|
573
|
+
const formValues = {};
|
|
574
|
+
for (const field of form.fields || []) {
|
|
575
|
+
if (field.type === 'page-break' || field.type === 'spacer') continue;
|
|
576
|
+
const val = body[field.name];
|
|
577
|
+
formValues[field.name] = val !== undefined ? (typeof val === 'string' ? val.trim() : val) : '';
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Evaluate conditional logic
|
|
581
|
+
const missingFields = [];
|
|
582
|
+
const validationErrors = [];
|
|
583
|
+
const visibleFieldNames = new Set();
|
|
584
|
+
|
|
585
|
+
for (const field of form.fields || []) {
|
|
586
|
+
if (field.type === 'page-break' || field.type === 'spacer') continue;
|
|
587
|
+
const vis = FormLogicEngine.evaluateFieldVisibility(field, formValues);
|
|
588
|
+
if (vis === 'hidden') continue;
|
|
589
|
+
visibleFieldNames.add(field.name);
|
|
590
|
+
|
|
591
|
+
const value = formValues[field.name];
|
|
592
|
+
const isEmpty = !value?.toString().trim();
|
|
593
|
+
const required = FormLogicEngine.evaluateFieldRequirement(field, formValues);
|
|
594
|
+
if (required && isEmpty) {
|
|
595
|
+
missingFields.push(field.label || field.name);
|
|
596
|
+
}
|
|
597
|
+
if (!isEmpty) {
|
|
598
|
+
const errors = FormLogicEngine.validateField(field, value, formValues);
|
|
599
|
+
if (errors.length) validationErrors.push(errors[0].message);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (missingFields.length || validationErrors.length) {
|
|
604
|
+
const parts = [];
|
|
605
|
+
if (missingFields.length) parts.push(`Required fields missing: ${missingFields.join(', ')}`);
|
|
606
|
+
if (validationErrors.length) parts.push(validationErrors.join('; '));
|
|
607
|
+
return reply.status(400).send({ error: `${parts.join('. ')}.` });
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Rate limit by IP
|
|
611
|
+
const ip = request.ip || request.socket?.remoteAddress || 'unknown';
|
|
612
|
+
const limit = settings.rateLimitPerMinute || 3;
|
|
613
|
+
if (isRateLimited(slug, ip, limit)) {
|
|
614
|
+
return reply.status(429).send({ error: 'Too many submissions. Please try again later.' });
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// Build submission data — only include visible fields
|
|
618
|
+
const data = {};
|
|
619
|
+
for (const field of form.fields || []) {
|
|
620
|
+
if (field.type === 'page-break' || field.type === 'spacer') continue;
|
|
621
|
+
if (!visibleFieldNames.has(field.name)) continue;
|
|
622
|
+
const val = body[field.name];
|
|
623
|
+
if (val !== undefined) {
|
|
624
|
+
data[field.name] = typeof val === 'string' ? val.trim() : val;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// Store in collection (sole submission store).
|
|
629
|
+
//
|
|
630
|
+
// CRITICAL: collection write failure is the difference between "your
|
|
631
|
+
// submission was saved" and "your submission disappeared into thin air".
|
|
632
|
+
// We MUST surface the error to the user rather than logging a warning
|
|
633
|
+
// and returning success — anything else is a silent-data-loss bug.
|
|
634
|
+
// (Email / webhook / action failures downstream are still treated as
|
|
635
|
+
// non-fatal — they fire AFTER the entry is persisted, so even a partial
|
|
636
|
+
// delivery has the original submission safely stored.)
|
|
637
|
+
const collectionAction = form.actions?.collection;
|
|
638
|
+
const targetSlug = (collectionAction?.enabled && collectionAction.slug) ? collectionAction.slug : slug;
|
|
639
|
+
let entry = null;
|
|
640
|
+
try {
|
|
641
|
+
let col = await getCollection(targetSlug);
|
|
642
|
+
if (!col) {
|
|
643
|
+
// The backing collection was never created (a form pointing at a
|
|
644
|
+
// non-existent collection). Failing here would lose the visitor's
|
|
645
|
+
// submission outright, so self-heal by provisioning the collection
|
|
646
|
+
// from the form's own fields, then persist into it.
|
|
647
|
+
fastify.log.warn(`[forms] Auto-provisioning missing collection "${targetSlug}" for form "${slug}"`);
|
|
648
|
+
col = await ensureCollectionForForm(form);
|
|
649
|
+
}
|
|
650
|
+
if (col) {
|
|
651
|
+
entry = await createEntry(targetSlug, data, {
|
|
652
|
+
source: `form:${slug}`,
|
|
653
|
+
createdBy: submittingUser?.id || null
|
|
654
|
+
});
|
|
655
|
+
} else {
|
|
656
|
+
// Could not resolve or provision a target — e.g. collection
|
|
657
|
+
// storage is explicitly disabled with no slug. Fail loudly
|
|
658
|
+
// rather than silently lose submissions.
|
|
659
|
+
fastify.log.warn(`[forms] Submission for "${slug}" had no target collection "${targetSlug}"`);
|
|
660
|
+
return reply.status(500).send({
|
|
661
|
+
error: `Submission not saved — target collection "${targetSlug}" does not exist. Please contact the site administrator.`
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
} catch (err) {
|
|
665
|
+
fastify.log.warn(`[forms] Collection write failed for "${slug}" → "${targetSlug}": ${err.message}`);
|
|
666
|
+
// Distinguish validation errors (user-actionable) from other
|
|
667
|
+
// failures (admin needs to look) so the message helps the right
|
|
668
|
+
// person. Validation errors typically start with "Validation failed:".
|
|
669
|
+
const msg = err.message.startsWith('Validation failed')
|
|
670
|
+
? err.message.replace('Validation failed: ', '')
|
|
671
|
+
: `Submission could not be saved: ${err.message}`;
|
|
672
|
+
return reply.status(400).send({ error: msg });
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Email action
|
|
676
|
+
const emailAction = form.actions?.email;
|
|
677
|
+
if (emailAction?.enabled && emailAction.recipients) {
|
|
678
|
+
try {
|
|
679
|
+
const smtp = getConfig('site').smtp || {};
|
|
680
|
+
const transport = await createTransport(smtp);
|
|
681
|
+
await sendFormEmail(transport, {
|
|
682
|
+
from: smtp.fromAddress,
|
|
683
|
+
fromName: smtp.fromName,
|
|
684
|
+
to: emailAction.recipients,
|
|
685
|
+
subject: `${emailAction.subjectPrefix || `[${form.title}]`} New submission`,
|
|
686
|
+
formTitle: form.title,
|
|
687
|
+
fields: form.fields,
|
|
688
|
+
data
|
|
689
|
+
});
|
|
690
|
+
} catch (err) {
|
|
691
|
+
fastify.log.warn(`[forms] Email send failed for "${slug}": ${err.message}`);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// Webhook action
|
|
696
|
+
const webhookAction = form.actions?.webhook;
|
|
697
|
+
if (webhookAction?.enabled && webhookAction.url) {
|
|
698
|
+
try {
|
|
699
|
+
await fetch(webhookAction.url, {
|
|
700
|
+
method: webhookAction.method || 'POST',
|
|
701
|
+
headers: { 'Content-Type': 'application/json' },
|
|
702
|
+
body: JSON.stringify({ form: slug, data })
|
|
703
|
+
});
|
|
704
|
+
} catch (err) {
|
|
705
|
+
fastify.log.warn(`[forms] Webhook failed for "${slug}": ${err.message}`);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// CMS Action trigger — forwards the authenticated submitter so
|
|
710
|
+
// action steps can interpolate {{user.id}}, {{user.email}}, etc.
|
|
711
|
+
// (Anonymous submissions still trigger the action with user={}.)
|
|
712
|
+
const actionSlug = form.settings?.actionSlug;
|
|
713
|
+
if (actionSlug && entry) {
|
|
714
|
+
try {
|
|
715
|
+
await executeAction(actionSlug, entry.id, { user: submittingUser });
|
|
716
|
+
} catch (err) {
|
|
717
|
+
fastify.log.warn(`[forms] Action "${actionSlug}" failed for form "${slug}": ${err.message}`);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
hooks.emit('form:submitted', {slug, entryId: entry?.id || null, data, formTitle: form.title});
|
|
722
|
+
|
|
723
|
+
// Template interpolation for successRedirect
|
|
724
|
+
let redirect = settings.successRedirect || null;
|
|
725
|
+
if (redirect && entry?.id) {
|
|
726
|
+
redirect = redirect.replace(/\{\{entryId\}\}/g, entry.id);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
return {
|
|
730
|
+
ok: true,
|
|
731
|
+
entryId: entry?.id || null,
|
|
732
|
+
message: settings.successMessage || 'Thank you for your submission.',
|
|
733
|
+
redirect: redirect
|
|
734
|
+
};
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
// -----------------------------------------------------------------------
|
|
738
|
+
// POST /forms/test-email — send a test email
|
|
739
|
+
// -----------------------------------------------------------------------
|
|
740
|
+
fastify.post('/forms/test-email', { preHandler: [authenticate, requireAdmin] }, async (request, reply) => {
|
|
741
|
+
const smtp = getConfig('site').smtp || {};
|
|
742
|
+
const to = (request.body?.to) || smtp.fromAddress || 'test@ethereal.email';
|
|
743
|
+
try {
|
|
744
|
+
const transport = await createTransport(smtp);
|
|
745
|
+
await sendFormEmail(transport, {
|
|
746
|
+
from: smtp.fromAddress,
|
|
747
|
+
fromName: smtp.fromName,
|
|
748
|
+
to,
|
|
749
|
+
subject: '[Forms] Test Email',
|
|
750
|
+
formTitle: 'Test Form',
|
|
751
|
+
fields: [
|
|
752
|
+
{ name: 'name', label: 'Name' },
|
|
753
|
+
{ name: 'message', label: 'Message' }
|
|
754
|
+
],
|
|
755
|
+
data: {
|
|
756
|
+
name: 'Test Sender',
|
|
757
|
+
message: 'This is a test email from your Domma CMS. If you received this, your SMTP settings are working correctly.'
|
|
758
|
+
}
|
|
759
|
+
});
|
|
760
|
+
return { ok: true, message: `Test email sent to ${to}` };
|
|
761
|
+
} catch (err) {
|
|
762
|
+
return reply.status(500).send({ error: `Failed to send test email: ${err.message}` });
|
|
763
|
+
}
|
|
764
|
+
});
|
|
765
|
+
}
|