@actuate-media/cms-core 0.57.0 → 0.57.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/dist/__tests__/api/stats-seo-cache.test.js +1 -1
  2. package/dist/__tests__/api/stats-seo-cache.test.js.map +1 -1
  3. package/dist/api/guarded-db.d.ts +13 -0
  4. package/dist/api/guarded-db.d.ts.map +1 -0
  5. package/dist/api/guarded-db.js +58 -0
  6. package/dist/api/guarded-db.js.map +1 -0
  7. package/dist/api/handler-factory.d.ts.map +1 -1
  8. package/dist/api/handler-factory.js +2 -1
  9. package/dist/api/handler-factory.js.map +1 -1
  10. package/dist/api/handlers.d.ts +5 -3
  11. package/dist/api/handlers.d.ts.map +1 -1
  12. package/dist/api/handlers.js +54 -13068
  13. package/dist/api/handlers.js.map +1 -1
  14. package/dist/api/route-helpers.d.ts +320 -0
  15. package/dist/api/route-helpers.d.ts.map +1 -0
  16. package/dist/api/route-helpers.js +1307 -0
  17. package/dist/api/route-helpers.js.map +1 -0
  18. package/dist/api/routes/ai-seo.d.ts +3 -0
  19. package/dist/api/routes/ai-seo.d.ts.map +1 -0
  20. package/dist/api/routes/ai-seo.js +377 -0
  21. package/dist/api/routes/ai-seo.js.map +1 -0
  22. package/dist/api/routes/ai-settings.d.ts +3 -0
  23. package/dist/api/routes/ai-settings.d.ts.map +1 -0
  24. package/dist/api/routes/ai-settings.js +764 -0
  25. package/dist/api/routes/ai-settings.js.map +1 -0
  26. package/dist/api/routes/auth.d.ts +3 -0
  27. package/dist/api/routes/auth.d.ts.map +1 -0
  28. package/dist/api/routes/auth.js +787 -0
  29. package/dist/api/routes/auth.js.map +1 -0
  30. package/dist/api/routes/collab.d.ts +3 -0
  31. package/dist/api/routes/collab.d.ts.map +1 -0
  32. package/dist/api/routes/collab.js +426 -0
  33. package/dist/api/routes/collab.js.map +1 -0
  34. package/dist/api/routes/dashboard.d.ts +7 -0
  35. package/dist/api/routes/dashboard.d.ts.map +1 -0
  36. package/dist/api/routes/dashboard.js +540 -0
  37. package/dist/api/routes/dashboard.js.map +1 -0
  38. package/dist/api/routes/documents.d.ts +3 -0
  39. package/dist/api/routes/documents.d.ts.map +1 -0
  40. package/dist/api/routes/documents.js +180 -0
  41. package/dist/api/routes/documents.js.map +1 -0
  42. package/dist/api/routes/folders.d.ts +3 -0
  43. package/dist/api/routes/folders.d.ts.map +1 -0
  44. package/dist/api/routes/folders.js +195 -0
  45. package/dist/api/routes/folders.js.map +1 -0
  46. package/dist/api/routes/forms.d.ts +3 -0
  47. package/dist/api/routes/forms.d.ts.map +1 -0
  48. package/dist/api/routes/forms.js +661 -0
  49. package/dist/api/routes/forms.js.map +1 -0
  50. package/dist/api/routes/globals.d.ts +3 -0
  51. package/dist/api/routes/globals.d.ts.map +1 -0
  52. package/dist/api/routes/globals.js +131 -0
  53. package/dist/api/routes/globals.js.map +1 -0
  54. package/dist/api/routes/media.d.ts +3 -0
  55. package/dist/api/routes/media.d.ts.map +1 -0
  56. package/dist/api/routes/media.js +521 -0
  57. package/dist/api/routes/media.js.map +1 -0
  58. package/dist/api/routes/meta.d.ts +3 -0
  59. package/dist/api/routes/meta.d.ts.map +1 -0
  60. package/dist/api/routes/meta.js +96 -0
  61. package/dist/api/routes/meta.js.map +1 -0
  62. package/dist/api/routes/page-templates.d.ts +3 -0
  63. package/dist/api/routes/page-templates.d.ts.map +1 -0
  64. package/dist/api/routes/page-templates.js +265 -0
  65. package/dist/api/routes/page-templates.js.map +1 -0
  66. package/dist/api/routes/presence.d.ts +3 -0
  67. package/dist/api/routes/presence.d.ts.map +1 -0
  68. package/dist/api/routes/presence.js +35 -0
  69. package/dist/api/routes/presence.js.map +1 -0
  70. package/dist/api/routes/preview.d.ts +3 -0
  71. package/dist/api/routes/preview.d.ts.map +1 -0
  72. package/dist/api/routes/preview.js +111 -0
  73. package/dist/api/routes/preview.js.map +1 -0
  74. package/dist/api/routes/redirects.d.ts +3 -0
  75. package/dist/api/routes/redirects.d.ts.map +1 -0
  76. package/dist/api/routes/redirects.js +790 -0
  77. package/dist/api/routes/redirects.js.map +1 -0
  78. package/dist/api/routes/saved-sections.d.ts +3 -0
  79. package/dist/api/routes/saved-sections.d.ts.map +1 -0
  80. package/dist/api/routes/saved-sections.js +1036 -0
  81. package/dist/api/routes/saved-sections.js.map +1 -0
  82. package/dist/api/routes/scheduling.d.ts +3 -0
  83. package/dist/api/routes/scheduling.d.ts.map +1 -0
  84. package/dist/api/routes/scheduling.js +373 -0
  85. package/dist/api/routes/scheduling.js.map +1 -0
  86. package/dist/api/routes/sections.d.ts +3 -0
  87. package/dist/api/routes/sections.d.ts.map +1 -0
  88. package/dist/api/routes/sections.js +500 -0
  89. package/dist/api/routes/sections.js.map +1 -0
  90. package/dist/api/routes/security-center.d.ts +3 -0
  91. package/dist/api/routes/security-center.d.ts.map +1 -0
  92. package/dist/api/routes/security-center.js +71 -0
  93. package/dist/api/routes/security-center.js.map +1 -0
  94. package/dist/api/routes/seo-public.d.ts +14 -0
  95. package/dist/api/routes/seo-public.d.ts.map +1 -0
  96. package/dist/api/routes/seo-public.js +930 -0
  97. package/dist/api/routes/seo-public.js.map +1 -0
  98. package/dist/api/routes/seo.d.ts +8 -0
  99. package/dist/api/routes/seo.d.ts.map +1 -0
  100. package/dist/api/routes/seo.js +848 -0
  101. package/dist/api/routes/seo.js.map +1 -0
  102. package/dist/api/routes/system.d.ts +3 -0
  103. package/dist/api/routes/system.d.ts.map +1 -0
  104. package/dist/api/routes/system.js +340 -0
  105. package/dist/api/routes/system.js.map +1 -0
  106. package/dist/api/routes/url-resolution.d.ts +3 -0
  107. package/dist/api/routes/url-resolution.d.ts.map +1 -0
  108. package/dist/api/routes/url-resolution.js +457 -0
  109. package/dist/api/routes/url-resolution.js.map +1 -0
  110. package/dist/api/routes/users.d.ts +3 -0
  111. package/dist/api/routes/users.d.ts.map +1 -0
  112. package/dist/api/routes/users.js +1162 -0
  113. package/dist/api/routes/users.js.map +1 -0
  114. package/dist/api/routes/webhooks.d.ts +3 -0
  115. package/dist/api/routes/webhooks.d.ts.map +1 -0
  116. package/dist/api/routes/webhooks.js +338 -0
  117. package/dist/api/routes/webhooks.js.map +1 -0
  118. package/package.json +1 -1
@@ -0,0 +1,661 @@
1
+ import { logEvent } from '../../security/audit.js';
2
+ import { getCaptchaConfig } from '../../security/captcha.js';
3
+ import { listForms as listFormsService, getFormById as getFormByIdService, getFormBySlug as getFormBySlugService, createForm as createFormService, updateForm as updateFormService, duplicateForm as duplicateFormService, deleteForm as deleteFormService, saveFormSchema as saveFormSchemaService, getFormSchema as getFormSchemaService, getNotificationSettings as getFormNotificationSettingsService, updateNotificationSettings as updateFormNotificationSettingsService, setNotifyEnabled as setFormNotifyEnabledService, getEmbedSettings as getFormEmbedSettingsService, updateEmbedSettings as updateFormEmbedSettingsService, getFormStats as getFormStatsService, getFormsSidebarCounts as getFormsSidebarCountsService, } from '../../forms/service.js';
4
+ import { listEntries as listEntriesService, getEntryWithSchema as getEntryWithSchemaService, getEntryCounts as getEntryCountsService, updateEntry as updateEntryService, bulkUpdateEntries as bulkUpdateEntriesService, deleteEntry as deleteEntryService, exportFormEntries as exportFormEntriesService, } from '../../forms/entries.js';
5
+ import { buildPublicForm } from '../../forms/submission.js';
6
+ import { resolveSubmissionFields } from '../../forms/submission.js';
7
+ import { createFormWebhook, listFormWebhooks, updateFormWebhook, deleteFormWebhook, testFormWebhook, } from '../../forms/webhooks.js';
8
+ import { sendFormNotifications } from '../../forms/notify.js';
9
+ import { getClientIp } from '../../security/client-ip.js';
10
+ import { guardedDb } from '../guarded-db.js';
11
+ import { json, errorResponse, internalError, clampPageSize, normalizeSubmission, mapFormError, parseFormsQuery, parseEntriesQuery, formCorsHeaders, jsonWithHeaders, submitToForm, requireAuth, buildActionContext, WRITE_ROLES, requireRole, } from '../route-helpers.js';
12
+ export function registerFormRoutes(router) {
13
+ const db = guardedDb;
14
+ // ---------------------------------------------------------------------------
15
+ // Forms routes
16
+ // ---------------------------------------------------------------------------
17
+ router.get('/forms', async (request) => {
18
+ try {
19
+ const auth = await requireAuth(request);
20
+ if (auth.error)
21
+ return auth.error;
22
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
23
+ if (roleErr)
24
+ return roleErr;
25
+ const url = new URL(request.url);
26
+ const { forms, total, page, pageSize } = await listFormsService(parseFormsQuery(url));
27
+ // Response is a superset of the typed FormDefinition with legacy aliases
28
+ // (`submissions`, `fieldCount`) so older admin views keep working while
29
+ // PR3 migrates them to the typed shape.
30
+ const data = forms.map((f) => ({
31
+ ...f,
32
+ fieldCount: f.fields?.length ?? 0,
33
+ submissions: f.totalEntries ?? 0,
34
+ }));
35
+ return json({ data, total, page, pageSize });
36
+ }
37
+ catch (err) {
38
+ return internalError(err);
39
+ }
40
+ });
41
+ router.get('/forms/:id/submissions', async (request, params) => {
42
+ try {
43
+ const auth = await requireAuth(request);
44
+ if (auth.error)
45
+ return auth.error;
46
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
47
+ if (roleErr)
48
+ return roleErr;
49
+ const url = new URL(request.url);
50
+ const page = Number(url.searchParams.get('page')) || 1;
51
+ const pageSize = clampPageSize(url.searchParams.get('pageSize'));
52
+ const skip = (page - 1) * pageSize;
53
+ const [submissions, total] = await Promise.all([
54
+ db().formSubmission.findMany({
55
+ where: { formId: params.id },
56
+ skip,
57
+ take: pageSize,
58
+ orderBy: { createdAt: 'desc' },
59
+ }),
60
+ db().formSubmission.count({ where: { formId: params.id } }),
61
+ ]);
62
+ return json({
63
+ data: {
64
+ submissions: submissions.map(normalizeSubmission),
65
+ total,
66
+ page,
67
+ pageSize,
68
+ totalPages: Math.ceil(total / pageSize),
69
+ },
70
+ });
71
+ }
72
+ catch (err) {
73
+ return internalError(err);
74
+ }
75
+ });
76
+ // Public, unauthenticated form schema — what an embedded form needs to render.
77
+ // CSRF-exempt and CORS-enabled (see handler-factory + formCorsHeaders).
78
+ router.options('/public/forms/:slug', async (request, params) => {
79
+ const form = await getFormBySlugService(params.slug).catch(() => null);
80
+ return new Response(null, {
81
+ status: 204,
82
+ headers: formCorsHeaders(request, form?.embedSettings?.allowedDomains),
83
+ });
84
+ });
85
+ router.get('/public/forms/:slug', async (request, params) => {
86
+ try {
87
+ const form = await getFormBySlugService(params.slug);
88
+ if (!form || form.status !== 'active') {
89
+ return jsonWithHeaders({ error: 'Form not found' }, 404, formCorsHeaders(request));
90
+ }
91
+ const cors = formCorsHeaders(request, form.embedSettings?.allowedDomains);
92
+ const { fields, versionId } = await resolveSubmissionFields(form);
93
+ const captcha = getCaptchaConfig();
94
+ const publicForm = buildPublicForm(form, fields, versionId, {
95
+ captcha: { provider: captcha.provider, siteKey: captcha.siteKey },
96
+ });
97
+ return jsonWithHeaders({ data: publicForm }, 200, cors);
98
+ }
99
+ catch (err) {
100
+ return internalError(err);
101
+ }
102
+ });
103
+ router.options('/public/forms/:slug/submit', async (request, params) => {
104
+ const form = await getFormBySlugService(params.slug).catch(() => null);
105
+ return new Response(null, {
106
+ status: 204,
107
+ headers: formCorsHeaders(request, form?.embedSettings?.allowedDomains),
108
+ });
109
+ });
110
+ router.post('/public/forms/:slug/submit', async (request, params) => {
111
+ try {
112
+ const form = await getFormBySlugService(params.slug);
113
+ if (!form || form.status !== 'active') {
114
+ return jsonWithHeaders({ error: 'Form not found' }, 404, formCorsHeaders(request));
115
+ }
116
+ return submitToForm(request, form);
117
+ }
118
+ catch (err) {
119
+ return internalError(err);
120
+ }
121
+ });
122
+ // Legacy by-id submission endpoint — same pipeline, kept for existing embeds.
123
+ router.post('/forms/:id/submit', async (request, params) => {
124
+ try {
125
+ const form = await getFormByIdService(params.id);
126
+ if (!form || form.status !== 'active') {
127
+ return jsonWithHeaders({ error: 'Form not found' }, 404, formCorsHeaders(request));
128
+ }
129
+ return submitToForm(request, form);
130
+ }
131
+ catch (err) {
132
+ return internalError(err);
133
+ }
134
+ });
135
+ // ---------------------------------------------------------------------------
136
+ // Forms management routes (admin) — services in ../forms/*.
137
+ //
138
+ // Route order matters: the router matches the first registered pattern, and
139
+ // `:id` is a greedy `[^/]+`. All static `/forms/<word>` GETs are registered
140
+ // before `/forms/:id` so e.g. `/forms/entries` is not captured as an id.
141
+ // ---------------------------------------------------------------------------
142
+ router.get('/forms/stats', async (request) => {
143
+ try {
144
+ const auth = await requireAuth(request);
145
+ if (auth.error)
146
+ return auth.error;
147
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
148
+ if (roleErr)
149
+ return roleErr;
150
+ return json({ data: await getFormStatsService() });
151
+ }
152
+ catch (err) {
153
+ return internalError(err);
154
+ }
155
+ });
156
+ router.get('/forms/sidebar-counts', async (request) => {
157
+ try {
158
+ const auth = await requireAuth(request);
159
+ if (auth.error)
160
+ return auth.error;
161
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
162
+ if (roleErr)
163
+ return roleErr;
164
+ return json({ data: await getFormsSidebarCountsService() });
165
+ }
166
+ catch (err) {
167
+ return internalError(err);
168
+ }
169
+ });
170
+ router.get('/forms/entries/counts', async (request) => {
171
+ try {
172
+ const auth = await requireAuth(request);
173
+ if (auth.error)
174
+ return auth.error;
175
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
176
+ if (roleErr)
177
+ return roleErr;
178
+ const counts = await getEntryCountsService(parseEntriesQuery(new URL(request.url)));
179
+ return json({ data: counts });
180
+ }
181
+ catch (err) {
182
+ return internalError(err);
183
+ }
184
+ });
185
+ // Defined before `/forms/entries/:entryId` so "export" is not parsed as an id.
186
+ router.get('/forms/entries/export', async (request) => {
187
+ try {
188
+ const auth = await requireAuth(request);
189
+ if (auth.error)
190
+ return auth.error;
191
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
192
+ if (roleErr)
193
+ return roleErr;
194
+ const formId = new URL(request.url).searchParams.get('formId');
195
+ if (!formId)
196
+ return errorResponse('"formId" query parameter is required', 400);
197
+ const result = await exportFormEntriesService(formId);
198
+ if (!result)
199
+ return errorResponse('Form not found', 404);
200
+ // PII access audit: a bulk export of decrypted submission data.
201
+ void logEvent({
202
+ event: 'form.entries.exported',
203
+ userId: auth.session.userId,
204
+ ipAddress: getClientIp(request),
205
+ details: { formId, count: result.count },
206
+ });
207
+ const filename = `${result.formSlug || 'form'}-entries.csv`;
208
+ return new Response(result.csv, {
209
+ status: 200,
210
+ headers: {
211
+ 'Content-Type': 'text/csv; charset=utf-8',
212
+ 'Content-Disposition': `attachment; filename="${filename}"`,
213
+ },
214
+ });
215
+ }
216
+ catch (err) {
217
+ return internalError(err);
218
+ }
219
+ });
220
+ router.get('/forms/entries/:entryId', async (request, params) => {
221
+ try {
222
+ const auth = await requireAuth(request);
223
+ if (auth.error)
224
+ return auth.error;
225
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
226
+ if (roleErr)
227
+ return roleErr;
228
+ const result = await getEntryWithSchemaService(params.entryId);
229
+ if (!result)
230
+ return errorResponse('Entry not found', 404);
231
+ // PII access audit: viewing a single entry exposes its decrypted fields.
232
+ void logEvent({
233
+ event: 'form.entry.viewed',
234
+ userId: auth.session.userId,
235
+ ipAddress: getClientIp(request),
236
+ details: { entryId: params.entryId, formId: result.entry.formId },
237
+ });
238
+ return json({ data: result });
239
+ }
240
+ catch (err) {
241
+ return internalError(err);
242
+ }
243
+ });
244
+ router.patch('/forms/entries/:entryId', async (request, params) => {
245
+ try {
246
+ const auth = await requireAuth(request);
247
+ if (auth.error)
248
+ return auth.error;
249
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
250
+ if (roleErr)
251
+ return roleErr;
252
+ const body = (await request.json().catch(() => ({})));
253
+ const ctx = buildActionContext(auth.session, db());
254
+ const updated = await updateEntryService(ctx, params.entryId, body);
255
+ if (!updated)
256
+ return errorResponse('Entry not found', 404);
257
+ return json({ data: updated });
258
+ }
259
+ catch (err) {
260
+ return internalError(err);
261
+ }
262
+ });
263
+ router.delete('/forms/entries/:entryId', async (request, params) => {
264
+ try {
265
+ const auth = await requireAuth(request);
266
+ if (auth.error)
267
+ return auth.error;
268
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
269
+ if (roleErr)
270
+ return roleErr;
271
+ const ctx = buildActionContext(auth.session, db());
272
+ await deleteEntryService(ctx, params.entryId);
273
+ return json({ data: { success: true } });
274
+ }
275
+ catch (err) {
276
+ return internalError(err);
277
+ }
278
+ });
279
+ router.post('/forms/entries/bulk', async (request) => {
280
+ try {
281
+ const auth = await requireAuth(request);
282
+ if (auth.error)
283
+ return auth.error;
284
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
285
+ if (roleErr)
286
+ return roleErr;
287
+ const body = (await request.json().catch(() => ({})));
288
+ if (!Array.isArray(body.ids) || body.ids.length === 0) {
289
+ return errorResponse('"ids" must be a non-empty array', 400);
290
+ }
291
+ const ctx = buildActionContext(auth.session, db());
292
+ const count = await bulkUpdateEntriesService(ctx, body.ids, body.patch ?? {});
293
+ return json({ data: { updated: count } });
294
+ }
295
+ catch (err) {
296
+ return internalError(err);
297
+ }
298
+ });
299
+ router.get('/forms/entries', async (request) => {
300
+ try {
301
+ const auth = await requireAuth(request);
302
+ if (auth.error)
303
+ return auth.error;
304
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
305
+ if (roleErr)
306
+ return roleErr;
307
+ const result = await listEntriesService(parseEntriesQuery(new URL(request.url)));
308
+ return json({ data: result });
309
+ }
310
+ catch (err) {
311
+ return internalError(err);
312
+ }
313
+ });
314
+ router.post('/forms', async (request) => {
315
+ try {
316
+ const auth = await requireAuth(request);
317
+ if (auth.error)
318
+ return auth.error;
319
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
320
+ if (roleErr)
321
+ return roleErr;
322
+ const body = (await request.json().catch(() => null));
323
+ if (!body || typeof body.name !== 'string' || !body.name.trim()) {
324
+ return errorResponse('"name" is required', 400);
325
+ }
326
+ const ctx = buildActionContext(auth.session, db());
327
+ const form = await createFormService(ctx, body);
328
+ return json({ data: form }, 201);
329
+ }
330
+ catch (err) {
331
+ return mapFormError(err) ?? internalError(err);
332
+ }
333
+ });
334
+ router.get('/forms/:id', async (request, params) => {
335
+ try {
336
+ const auth = await requireAuth(request);
337
+ if (auth.error)
338
+ return auth.error;
339
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
340
+ if (roleErr)
341
+ return roleErr;
342
+ const form = await getFormByIdService(params.id);
343
+ if (!form)
344
+ return errorResponse('Form not found', 404);
345
+ return json({ data: form });
346
+ }
347
+ catch (err) {
348
+ return internalError(err);
349
+ }
350
+ });
351
+ router.patch('/forms/:id', async (request, params) => {
352
+ try {
353
+ const auth = await requireAuth(request);
354
+ if (auth.error)
355
+ return auth.error;
356
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
357
+ if (roleErr)
358
+ return roleErr;
359
+ const body = (await request.json().catch(() => ({})));
360
+ const ctx = buildActionContext(auth.session, db());
361
+ const form = await updateFormService(ctx, params.id, body);
362
+ return json({ data: form });
363
+ }
364
+ catch (err) {
365
+ return mapFormError(err) ?? internalError(err);
366
+ }
367
+ });
368
+ router.delete('/forms/:id', async (request, params) => {
369
+ try {
370
+ const auth = await requireAuth(request);
371
+ if (auth.error)
372
+ return auth.error;
373
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
374
+ if (roleErr)
375
+ return roleErr;
376
+ const url = new URL(request.url);
377
+ const force = url.searchParams.get('force') === 'true';
378
+ const ctx = buildActionContext(auth.session, db());
379
+ await deleteFormService(ctx, params.id, { force });
380
+ return json({ data: { success: true } });
381
+ }
382
+ catch (err) {
383
+ return mapFormError(err) ?? internalError(err);
384
+ }
385
+ });
386
+ router.post('/forms/:id/duplicate', async (request, params) => {
387
+ try {
388
+ const auth = await requireAuth(request);
389
+ if (auth.error)
390
+ return auth.error;
391
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
392
+ if (roleErr)
393
+ return roleErr;
394
+ const ctx = buildActionContext(auth.session, db());
395
+ const form = await duplicateFormService(ctx, params.id);
396
+ return json({ data: form }, 201);
397
+ }
398
+ catch (err) {
399
+ return mapFormError(err) ?? internalError(err);
400
+ }
401
+ });
402
+ router.get('/forms/:id/schema', async (request, params) => {
403
+ try {
404
+ const auth = await requireAuth(request);
405
+ if (auth.error)
406
+ return auth.error;
407
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
408
+ if (roleErr)
409
+ return roleErr;
410
+ const schema = await getFormSchemaService(params.id);
411
+ return json({ data: schema });
412
+ }
413
+ catch (err) {
414
+ return mapFormError(err) ?? internalError(err);
415
+ }
416
+ });
417
+ router.put('/forms/:id/schema', async (request, params) => {
418
+ try {
419
+ const auth = await requireAuth(request);
420
+ if (auth.error)
421
+ return auth.error;
422
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
423
+ if (roleErr)
424
+ return roleErr;
425
+ const body = (await request.json().catch(() => ({})));
426
+ if (!Array.isArray(body.fields)) {
427
+ return errorResponse('"fields" must be an array', 400);
428
+ }
429
+ const ctx = buildActionContext(auth.session, db());
430
+ const result = await saveFormSchemaService(ctx, params.id, body.fields, body.notes);
431
+ return json({ data: result });
432
+ }
433
+ catch (err) {
434
+ return mapFormError(err) ?? internalError(err);
435
+ }
436
+ });
437
+ router.get('/forms/:id/notifications', async (request, params) => {
438
+ try {
439
+ const auth = await requireAuth(request);
440
+ if (auth.error)
441
+ return auth.error;
442
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
443
+ if (roleErr)
444
+ return roleErr;
445
+ const settings = await getFormNotificationSettingsService(params.id);
446
+ return json({ data: settings });
447
+ }
448
+ catch (err) {
449
+ return mapFormError(err) ?? internalError(err);
450
+ }
451
+ });
452
+ router.put('/forms/:id/notifications', async (request, params) => {
453
+ try {
454
+ const auth = await requireAuth(request);
455
+ if (auth.error)
456
+ return auth.error;
457
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
458
+ if (roleErr)
459
+ return roleErr;
460
+ const body = (await request.json().catch(() => ({})));
461
+ const ctx = buildActionContext(auth.session, db());
462
+ const settings = await updateFormNotificationSettingsService(ctx, params.id, body);
463
+ return json({ data: settings });
464
+ }
465
+ catch (err) {
466
+ return mapFormError(err) ?? internalError(err);
467
+ }
468
+ });
469
+ // Lightweight notify toggle used by the forms table switch.
470
+ router.put('/forms/:id/notify', async (request, params) => {
471
+ try {
472
+ const auth = await requireAuth(request);
473
+ if (auth.error)
474
+ return auth.error;
475
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
476
+ if (roleErr)
477
+ return roleErr;
478
+ const body = (await request.json().catch(() => ({})));
479
+ const ctx = buildActionContext(auth.session, db());
480
+ const settings = await setFormNotifyEnabledService(ctx, params.id, Boolean(body.enabled));
481
+ return json({ data: { notifyEnabled: settings.enabled } });
482
+ }
483
+ catch (err) {
484
+ return mapFormError(err) ?? internalError(err);
485
+ }
486
+ });
487
+ router.get('/forms/:id/embed', async (request, params) => {
488
+ try {
489
+ const auth = await requireAuth(request);
490
+ if (auth.error)
491
+ return auth.error;
492
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
493
+ if (roleErr)
494
+ return roleErr;
495
+ const settings = await getFormEmbedSettingsService(params.id);
496
+ return json({ data: settings });
497
+ }
498
+ catch (err) {
499
+ return mapFormError(err) ?? internalError(err);
500
+ }
501
+ });
502
+ router.put('/forms/:id/embed', async (request, params) => {
503
+ try {
504
+ const auth = await requireAuth(request);
505
+ if (auth.error)
506
+ return auth.error;
507
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
508
+ if (roleErr)
509
+ return roleErr;
510
+ const body = (await request.json().catch(() => ({})));
511
+ const ctx = buildActionContext(auth.session, db());
512
+ const settings = await updateFormEmbedSettingsService(ctx, params.id, body);
513
+ return json({ data: settings });
514
+ }
515
+ catch (err) {
516
+ return mapFormError(err) ?? internalError(err);
517
+ }
518
+ });
519
+ // ── Per-form webhooks (CRUD + test) ──────────────────────────────────────
520
+ router.get('/forms/:id/webhooks', async (request, params) => {
521
+ try {
522
+ const auth = await requireAuth(request);
523
+ if (auth.error)
524
+ return auth.error;
525
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
526
+ if (roleErr)
527
+ return roleErr;
528
+ const form = await getFormByIdService(params.id);
529
+ if (!form)
530
+ return errorResponse('Form not found', 404);
531
+ const webhooks = await listFormWebhooks(params.id);
532
+ return json({ data: webhooks });
533
+ }
534
+ catch (err) {
535
+ return mapFormError(err) ?? internalError(err);
536
+ }
537
+ });
538
+ router.post('/forms/:id/webhooks', async (request, params) => {
539
+ try {
540
+ const auth = await requireAuth(request);
541
+ if (auth.error)
542
+ return auth.error;
543
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
544
+ if (roleErr)
545
+ return roleErr;
546
+ const form = await getFormByIdService(params.id);
547
+ if (!form)
548
+ return errorResponse('Form not found', 404);
549
+ const body = (await request.json().catch(() => ({})));
550
+ if (!body.url || typeof body.url !== 'string') {
551
+ return errorResponse('A webhook url is required', 400);
552
+ }
553
+ const ctx = buildActionContext(auth.session, db());
554
+ const webhook = await createFormWebhook(ctx, params.id, body);
555
+ // The plaintext secret is returned exactly once here, on creation.
556
+ return json({ data: webhook }, 201);
557
+ }
558
+ catch (err) {
559
+ return mapFormError(err) ?? internalError(err);
560
+ }
561
+ });
562
+ router.patch('/forms/:id/webhooks/:webhookId', async (request, params) => {
563
+ try {
564
+ const auth = await requireAuth(request);
565
+ if (auth.error)
566
+ return auth.error;
567
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
568
+ if (roleErr)
569
+ return roleErr;
570
+ const body = (await request.json().catch(() => ({})));
571
+ const ctx = buildActionContext(auth.session, db());
572
+ const webhook = await updateFormWebhook(ctx, params.webhookId, body);
573
+ return json({ data: webhook });
574
+ }
575
+ catch (err) {
576
+ return mapFormError(err) ?? internalError(err);
577
+ }
578
+ });
579
+ router.delete('/forms/:id/webhooks/:webhookId', async (request, params) => {
580
+ try {
581
+ const auth = await requireAuth(request);
582
+ if (auth.error)
583
+ return auth.error;
584
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
585
+ if (roleErr)
586
+ return roleErr;
587
+ const ctx = buildActionContext(auth.session, db());
588
+ await deleteFormWebhook(ctx, params.webhookId);
589
+ return json({ data: { success: true } });
590
+ }
591
+ catch (err) {
592
+ return mapFormError(err) ?? internalError(err);
593
+ }
594
+ });
595
+ router.post('/forms/:id/webhooks/:webhookId/test', async (request, params) => {
596
+ try {
597
+ const auth = await requireAuth(request);
598
+ if (auth.error)
599
+ return auth.error;
600
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
601
+ if (roleErr)
602
+ return roleErr;
603
+ const ctx = buildActionContext(auth.session, db());
604
+ const result = await testFormWebhook(ctx, params.webhookId);
605
+ return json({ data: result });
606
+ }
607
+ catch (err) {
608
+ return mapFormError(err) ?? internalError(err);
609
+ }
610
+ });
611
+ // ── Test notification email ──────────────────────────────────────────────
612
+ router.post('/forms/:id/notifications/test', async (request, params) => {
613
+ try {
614
+ const auth = await requireAuth(request);
615
+ if (auth.error)
616
+ return auth.error;
617
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
618
+ if (roleErr)
619
+ return roleErr;
620
+ const form = await getFormByIdService(params.id);
621
+ if (!form)
622
+ return errorResponse('Form not found', 404);
623
+ const settings = await getFormNotificationSettingsService(params.id);
624
+ const primaryRecipient = (settings?.recipients ?? [])[0];
625
+ if (!settings?.enabled || !primaryRecipient) {
626
+ return errorResponse('Configure at least one recipient before sending a test', 400);
627
+ }
628
+ // Sample values so operators see a realistic email. The autoresponder is
629
+ // suppressed for tests so we never email an arbitrary sample address.
630
+ const fields = form.fields ?? [];
631
+ const sampleData = {};
632
+ for (const field of fields) {
633
+ if (field.type === 'honeypot' || field.type === 'divider' || field.type === 'richtext') {
634
+ continue;
635
+ }
636
+ if (field.type === 'email')
637
+ sampleData[field.key] = primaryRecipient;
638
+ else if (field.type === 'checkbox' || field.type === 'consent')
639
+ sampleData[field.key] = true;
640
+ else
641
+ sampleData[field.key] = `Sample ${field.label || field.key}`;
642
+ }
643
+ const ctx = {
644
+ formId: form.id,
645
+ formName: form.name,
646
+ entryNumber: null,
647
+ submittedAt: new Date().toISOString(),
648
+ fields,
649
+ data: sampleData,
650
+ senderName: 'Test Submission',
651
+ senderEmail: primaryRecipient,
652
+ };
653
+ const result = await sendFormNotifications({ ...settings, autoresponderEnabled: false }, ctx);
654
+ return json({ data: result });
655
+ }
656
+ catch (err) {
657
+ return mapFormError(err) ?? internalError(err);
658
+ }
659
+ });
660
+ }
661
+ //# sourceMappingURL=forms.js.map