@actuate-media/cms-core 0.32.0 → 0.34.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.
Files changed (60) hide show
  1. package/dist/__tests__/api/form-webhooks.test.d.ts +2 -0
  2. package/dist/__tests__/api/form-webhooks.test.d.ts.map +1 -0
  3. package/dist/__tests__/api/form-webhooks.test.js +162 -0
  4. package/dist/__tests__/api/form-webhooks.test.js.map +1 -0
  5. package/dist/__tests__/cron/cron.test.js +19 -0
  6. package/dist/__tests__/cron/cron.test.js.map +1 -1
  7. package/dist/__tests__/forms/encryption.test.d.ts +2 -0
  8. package/dist/__tests__/forms/encryption.test.d.ts.map +1 -0
  9. package/dist/__tests__/forms/encryption.test.js +84 -0
  10. package/dist/__tests__/forms/encryption.test.js.map +1 -0
  11. package/dist/__tests__/forms/entries-export.test.d.ts +2 -0
  12. package/dist/__tests__/forms/entries-export.test.d.ts.map +1 -0
  13. package/dist/__tests__/forms/entries-export.test.js +69 -0
  14. package/dist/__tests__/forms/entries-export.test.js.map +1 -0
  15. package/dist/__tests__/forms/notify.test.d.ts +2 -0
  16. package/dist/__tests__/forms/notify.test.d.ts.map +1 -0
  17. package/dist/__tests__/forms/notify.test.js +114 -0
  18. package/dist/__tests__/forms/notify.test.js.map +1 -0
  19. package/dist/__tests__/forms/webhooks.test.d.ts +2 -0
  20. package/dist/__tests__/forms/webhooks.test.d.ts.map +1 -0
  21. package/dist/__tests__/forms/webhooks.test.js +203 -0
  22. package/dist/__tests__/forms/webhooks.test.js.map +1 -0
  23. package/dist/api/handlers.d.ts.map +1 -1
  24. package/dist/api/handlers.js +193 -2
  25. package/dist/api/handlers.js.map +1 -1
  26. package/dist/config/types.d.ts +11 -6
  27. package/dist/config/types.d.ts.map +1 -1
  28. package/dist/cron/index.d.ts +7 -0
  29. package/dist/cron/index.d.ts.map +1 -1
  30. package/dist/cron/index.js +15 -0
  31. package/dist/cron/index.js.map +1 -1
  32. package/dist/forms/encryption.d.ts +20 -0
  33. package/dist/forms/encryption.d.ts.map +1 -0
  34. package/dist/forms/encryption.js +80 -0
  35. package/dist/forms/encryption.js.map +1 -0
  36. package/dist/forms/entries.d.ts +12 -0
  37. package/dist/forms/entries.d.ts.map +1 -1
  38. package/dist/forms/entries.js +99 -3
  39. package/dist/forms/entries.js.map +1 -1
  40. package/dist/forms/index.d.ts +4 -1
  41. package/dist/forms/index.d.ts.map +1 -1
  42. package/dist/forms/index.js +4 -1
  43. package/dist/forms/index.js.map +1 -1
  44. package/dist/forms/notify.d.ts +37 -0
  45. package/dist/forms/notify.d.ts.map +1 -0
  46. package/dist/forms/notify.js +198 -0
  47. package/dist/forms/notify.js.map +1 -0
  48. package/dist/forms/submission.d.ts +21 -0
  49. package/dist/forms/submission.d.ts.map +1 -1
  50. package/dist/forms/submission.js +66 -7
  51. package/dist/forms/submission.js.map +1 -1
  52. package/dist/forms/webhooks.d.ts +54 -0
  53. package/dist/forms/webhooks.d.ts.map +1 -0
  54. package/dist/forms/webhooks.js +292 -0
  55. package/dist/forms/webhooks.js.map +1 -0
  56. package/dist/index.d.ts +2 -2
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +1 -1
  59. package/dist/index.js.map +1 -1
  60. package/package.json +1 -1
@@ -0,0 +1,203 @@
1
+ import { describe, expect, it, vi, beforeEach } from 'vitest';
2
+ const dbHolder = { current: {} };
3
+ vi.mock('../../db.js', () => ({ getDB: () => dbHolder.current }));
4
+ vi.mock('../../security/audit.js', () => ({ logEvent: vi.fn(async () => { }) }));
5
+ // Identity secret storage so the "encrypted" value equals the plaintext in tests.
6
+ vi.mock('../../security/secret-storage.js', () => ({
7
+ encryptSecret: async (s) => s,
8
+ decryptSecret: async (s) => s,
9
+ }));
10
+ const { SsrfBlockedError, safeFetchMock } = vi.hoisted(() => {
11
+ class SsrfBlockedError extends Error {
12
+ }
13
+ return { SsrfBlockedError, safeFetchMock: vi.fn() };
14
+ });
15
+ vi.mock('../../security/safe-fetch.js', () => ({
16
+ safeFetch: (...args) => safeFetchMock(...args),
17
+ SsrfBlockedError,
18
+ }));
19
+ import { createFormWebhook, listFormWebhooks, updateFormWebhook, deleteFormWebhook, dispatchFormWebhooks, testFormWebhook, FormWebhookError, } from '../../forms/webhooks.js';
20
+ import { signPayload } from '../../webhooks/index.js';
21
+ const ctx = { userId: 'u1', role: 'admin', locale: 'en', db: {} };
22
+ function fakeDb() {
23
+ const rows = [];
24
+ return {
25
+ rows,
26
+ formWebhook: {
27
+ create: async ({ data }) => {
28
+ const row = {
29
+ id: `wh_${rows.length + 1}`,
30
+ createdAt: new Date(),
31
+ updatedAt: new Date(),
32
+ lastDeliveryAt: null,
33
+ lastDeliveryStatus: null,
34
+ ...data,
35
+ };
36
+ rows.push(row);
37
+ return row;
38
+ },
39
+ findMany: async ({ where }) => rows.filter((r) => r.formId === where.formId &&
40
+ (where.enabled === undefined || r.enabled === where.enabled)),
41
+ findUnique: async ({ where }) => rows.find((r) => r.id === where.id) ?? null,
42
+ update: async ({ where, data }) => {
43
+ const r = rows.find((x) => x.id === where.id);
44
+ Object.assign(r, data);
45
+ return r;
46
+ },
47
+ delete: async ({ where }) => {
48
+ const i = rows.findIndex((x) => x.id === where.id);
49
+ rows.splice(i, 1);
50
+ return {};
51
+ },
52
+ },
53
+ };
54
+ }
55
+ function httpResponse(status) {
56
+ return { ok: status >= 200 && status < 300, status, text: async () => '' };
57
+ }
58
+ beforeEach(() => {
59
+ dbHolder.current = fakeDb();
60
+ safeFetchMock.mockReset();
61
+ });
62
+ describe('form webhook CRUD', () => {
63
+ it('creates a webhook, auto-generates a signing secret, and returns it once', async () => {
64
+ const wh = await createFormWebhook(ctx, 'form_1', { url: 'https://hooks.example.com/x' });
65
+ expect(wh.secret).toMatch(/^whsec_/);
66
+ expect(wh.events).toEqual(['form.submitted']);
67
+ expect(wh.enabled).toBe(true);
68
+ });
69
+ it('masks the secret in list responses', async () => {
70
+ await createFormWebhook(ctx, 'form_1', { url: 'https://hooks.example.com/x', secret: 's3cret' });
71
+ const list = await listFormWebhooks('form_1');
72
+ expect(list).toHaveLength(1);
73
+ expect(list[0].secret).toBeUndefined();
74
+ });
75
+ it('rejects SSRF / private webhook URLs', async () => {
76
+ await expect(createFormWebhook(ctx, 'form_1', { url: 'http://localhost:3000/hook' })).rejects.toBeInstanceOf(FormWebhookError);
77
+ await expect(createFormWebhook(ctx, 'form_1', { url: 'http://169.254.169.254/latest' })).rejects.toBeInstanceOf(FormWebhookError);
78
+ });
79
+ it('clamps retryCount and normalizes events on update', async () => {
80
+ const wh = await createFormWebhook(ctx, 'form_1', { url: 'https://hooks.example.com/x' });
81
+ const updated = await updateFormWebhook(ctx, wh.id, {
82
+ retryCount: 99,
83
+ events: ['form.submitted', 'bogus'],
84
+ });
85
+ expect(updated.retryCount).toBe(10);
86
+ expect(updated.events).toEqual(['form.submitted']);
87
+ });
88
+ it('deletes a webhook', async () => {
89
+ const wh = await createFormWebhook(ctx, 'form_1', { url: 'https://hooks.example.com/x' });
90
+ await deleteFormWebhook(ctx, wh.id);
91
+ expect(await listFormWebhooks('form_1')).toHaveLength(0);
92
+ });
93
+ });
94
+ describe('dispatchFormWebhooks', () => {
95
+ it('delivers a signed payload to matching enabled webhooks', async () => {
96
+ safeFetchMock.mockResolvedValue(httpResponse(200));
97
+ await createFormWebhook(ctx, 'form_1', {
98
+ url: 'https://hooks.example.com/x',
99
+ secret: 'shh',
100
+ events: ['form.submitted'],
101
+ });
102
+ const res = await dispatchFormWebhooks('form_1', {
103
+ event: 'form.submitted',
104
+ formId: 'form_1',
105
+ submissionId: 'sub_1',
106
+ data: { name: 'Ada' },
107
+ });
108
+ expect(res).toEqual({ matched: 1, delivered: 1 });
109
+ expect(safeFetchMock).toHaveBeenCalledTimes(1);
110
+ const [url, opts] = safeFetchMock.mock.calls[0];
111
+ expect(url).toBe('https://hooks.example.com/x');
112
+ const expectedSig = await signPayload(opts.body, 'shh');
113
+ expect(opts.headers['X-Actuate-Signature']).toBe(expectedSig);
114
+ expect(opts.headers['X-Actuate-Event']).toBe('form.submitted');
115
+ expect(dbHolder.current.rows[0].lastDeliveryStatus).toBe('success');
116
+ });
117
+ it('ignores webhooks not subscribed to the event', async () => {
118
+ safeFetchMock.mockResolvedValue(httpResponse(200));
119
+ await createFormWebhook(ctx, 'form_1', {
120
+ url: 'https://hooks.example.com/x',
121
+ events: ['form.entry.starred'],
122
+ });
123
+ const res = await dispatchFormWebhooks('form_1', {
124
+ event: 'form.submitted',
125
+ formId: 'form_1',
126
+ });
127
+ expect(res.matched).toBe(0);
128
+ expect(safeFetchMock).not.toHaveBeenCalled();
129
+ });
130
+ it('does not deliver to disabled webhooks', async () => {
131
+ safeFetchMock.mockResolvedValue(httpResponse(200));
132
+ await createFormWebhook(ctx, 'form_1', {
133
+ url: 'https://hooks.example.com/x',
134
+ enabled: false,
135
+ });
136
+ const res = await dispatchFormWebhooks('form_1', { event: 'form.submitted', formId: 'form_1' });
137
+ expect(res.matched).toBe(0);
138
+ });
139
+ it('stops retrying on a 4xx response', async () => {
140
+ safeFetchMock.mockResolvedValue(httpResponse(400));
141
+ await createFormWebhook(ctx, 'form_1', {
142
+ url: 'https://hooks.example.com/x',
143
+ retryCount: 3,
144
+ });
145
+ const res = await dispatchFormWebhooks('form_1', { event: 'form.submitted', formId: 'form_1' });
146
+ expect(res.delivered).toBe(0);
147
+ expect(safeFetchMock).toHaveBeenCalledTimes(1);
148
+ expect(dbHolder.current.rows[0].lastDeliveryStatus).toContain('failed');
149
+ });
150
+ it('retries on a 5xx response and succeeds with backoff', async () => {
151
+ // Fire backoff timers immediately so the test doesn't wait real seconds.
152
+ const timeoutSpy = vi.spyOn(global, 'setTimeout').mockImplementation(((fn) => {
153
+ fn();
154
+ return 0;
155
+ }));
156
+ try {
157
+ safeFetchMock
158
+ .mockResolvedValueOnce(httpResponse(503))
159
+ .mockResolvedValueOnce(httpResponse(200));
160
+ await createFormWebhook(ctx, 'form_1', {
161
+ url: 'https://hooks.example.com/x',
162
+ retryCount: 2,
163
+ });
164
+ const res = await dispatchFormWebhooks('form_1', {
165
+ event: 'form.submitted',
166
+ formId: 'form_1',
167
+ });
168
+ expect(res.delivered).toBe(1);
169
+ expect(safeFetchMock).toHaveBeenCalledTimes(2);
170
+ }
171
+ finally {
172
+ timeoutSpy.mockRestore();
173
+ }
174
+ });
175
+ it('treats an SSRF block as a permanent failure (no retry)', async () => {
176
+ safeFetchMock.mockRejectedValue(new SsrfBlockedError('blocked'));
177
+ await createFormWebhook(ctx, 'form_1', {
178
+ url: 'https://hooks.example.com/x',
179
+ retryCount: 3,
180
+ });
181
+ const res = await dispatchFormWebhooks('form_1', { event: 'form.submitted', formId: 'form_1' });
182
+ expect(res.delivered).toBe(0);
183
+ expect(safeFetchMock).toHaveBeenCalledTimes(1);
184
+ });
185
+ });
186
+ describe('testFormWebhook', () => {
187
+ it('sends a single sample delivery and records the result', async () => {
188
+ safeFetchMock.mockResolvedValue(httpResponse(200));
189
+ const wh = await createFormWebhook(ctx, 'form_1', {
190
+ url: 'https://hooks.example.com/x',
191
+ secret: 'shh',
192
+ });
193
+ const result = await testFormWebhook(ctx, wh.id);
194
+ expect(result.ok).toBe(true);
195
+ expect(result.attempts).toBe(1);
196
+ const [, opts] = safeFetchMock.mock.calls[0];
197
+ expect(JSON.parse(opts.body).data._test).toBe(true);
198
+ });
199
+ it('throws a 404 FormWebhookError for an unknown webhook', async () => {
200
+ await expect(testFormWebhook(ctx, 'missing')).rejects.toMatchObject({ status: 404 });
201
+ });
202
+ });
203
+ //# sourceMappingURL=webhooks.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"webhooks.test.js","sourceRoot":"","sources":["../../../src/__tests__/forms/webhooks.test.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"handlers.d.ts","sourceRoot":"","sources":["../../src/api/handlers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AAk/B5C,iFAAiF;AACjF,wBAAgB,wBAAwB,IAAI,IAAI,CAE/C;AA8JD,wBAAgB,iBAAiB,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAS9E;AAkQD,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CAugUzD"}
1
+ {"version":3,"file":"handlers.d.ts","sourceRoot":"","sources":["../../src/api/handlers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AA+/B5C,iFAAiF;AACjF,wBAAgB,wBAAwB,IAAI,IAAI,CAE/C;AA8JD,wBAAgB,iBAAiB,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAS9E;AAkQD,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CAkrUzD"}
@@ -24,10 +24,12 @@ import { isAuthorizedCronRequest, processCleanup, processSeoScan } from '../cron
24
24
  import { processContentHealthScan } from '../content-health/cron.js';
25
25
  import { verifyCaptcha, getCaptchaConfig } from '../security/captcha.js';
26
26
  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, FormNotFoundError, FormValidationError, FormConflictError, FormHasSubmissionsError, } from '../forms/service.js';
27
- import { listEntries as listEntriesService, getEntryWithSchema as getEntryWithSchemaService, getEntryCounts as getEntryCountsService, updateEntry as updateEntryService, bulkUpdateEntries as bulkUpdateEntriesService, deleteEntry as deleteEntryService, } from '../forms/entries.js';
27
+ 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';
28
28
  import { processSubmission, buildPublicForm, SubmissionValidationError, } from '../forms/submission.js';
29
29
  import { validateSubmissionFiles, FORM_UPLOAD_ALLOWED_MIME_TYPES, DEFAULT_MAX_FILE_BYTES, } from '../forms/files.js';
30
30
  import { resolveSubmissionFields } from '../forms/submission.js';
31
+ import { createFormWebhook, listFormWebhooks, updateFormWebhook, deleteFormWebhook, testFormWebhook, FormWebhookError, } from '../forms/webhooks.js';
32
+ import { sendFormNotifications } from '../forms/notify.js';
31
33
  import { checkForUpdates } from '../upgrade/version-check.js';
32
34
  import { createUpgradePR } from '../upgrade/upgrade-pr.js';
33
35
  import { encryptField, decryptField } from '../security/encrypted-fields.js';
@@ -290,6 +292,8 @@ function mapFormError(err) {
290
292
  if (err instanceof FormHasSubmissionsError) {
291
293
  return json({ error: err.message, code: 'FORM_HAS_SUBMISSIONS', submissionCount: err.submissionCount }, 409);
292
294
  }
295
+ if (err instanceof FormWebhookError)
296
+ return errorResponse(err.message, err.status);
293
297
  return null;
294
298
  }
295
299
  function parseFormsQuery(url) {
@@ -4428,6 +4432,41 @@ export function registerCMSRoutes(router) {
4428
4432
  return internalError(err);
4429
4433
  }
4430
4434
  });
4435
+ // Defined before `/forms/entries/:entryId` so "export" is not parsed as an id.
4436
+ router.get('/forms/entries/export', async (request) => {
4437
+ try {
4438
+ const auth = await requireAuth(request);
4439
+ if (auth.error)
4440
+ return auth.error;
4441
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
4442
+ if (roleErr)
4443
+ return roleErr;
4444
+ const formId = new URL(request.url).searchParams.get('formId');
4445
+ if (!formId)
4446
+ return errorResponse('"formId" query parameter is required', 400);
4447
+ const result = await exportFormEntriesService(formId);
4448
+ if (!result)
4449
+ return errorResponse('Form not found', 404);
4450
+ // PII access audit: a bulk export of decrypted submission data.
4451
+ void logEvent({
4452
+ event: 'form.entries.exported',
4453
+ userId: auth.session.userId,
4454
+ ipAddress: getClientIp(request),
4455
+ details: { formId, count: result.count },
4456
+ });
4457
+ const filename = `${result.formSlug || 'form'}-entries.csv`;
4458
+ return new Response(result.csv, {
4459
+ status: 200,
4460
+ headers: {
4461
+ 'Content-Type': 'text/csv; charset=utf-8',
4462
+ 'Content-Disposition': `attachment; filename="${filename}"`,
4463
+ },
4464
+ });
4465
+ }
4466
+ catch (err) {
4467
+ return internalError(err);
4468
+ }
4469
+ });
4431
4470
  router.get('/forms/entries/:entryId', async (request, params) => {
4432
4471
  try {
4433
4472
  const auth = await requireAuth(request);
@@ -4439,6 +4478,13 @@ export function registerCMSRoutes(router) {
4439
4478
  const result = await getEntryWithSchemaService(params.entryId);
4440
4479
  if (!result)
4441
4480
  return errorResponse('Entry not found', 404);
4481
+ // PII access audit: viewing a single entry exposes its decrypted fields.
4482
+ void logEvent({
4483
+ event: 'form.entry.viewed',
4484
+ userId: auth.session.userId,
4485
+ ipAddress: getClientIp(request),
4486
+ details: { entryId: params.entryId, formId: result.entry.formId },
4487
+ });
4442
4488
  return json({ data: result });
4443
4489
  }
4444
4490
  catch (err) {
@@ -4720,6 +4766,147 @@ export function registerCMSRoutes(router) {
4720
4766
  return mapFormError(err) ?? internalError(err);
4721
4767
  }
4722
4768
  });
4769
+ // ── Per-form webhooks (CRUD + test) ──────────────────────────────────────
4770
+ router.get('/forms/:id/webhooks', async (request, params) => {
4771
+ try {
4772
+ const auth = await requireAuth(request);
4773
+ if (auth.error)
4774
+ return auth.error;
4775
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
4776
+ if (roleErr)
4777
+ return roleErr;
4778
+ const form = await getFormByIdService(params.id);
4779
+ if (!form)
4780
+ return errorResponse('Form not found', 404);
4781
+ const webhooks = await listFormWebhooks(params.id);
4782
+ return json({ data: webhooks });
4783
+ }
4784
+ catch (err) {
4785
+ return mapFormError(err) ?? internalError(err);
4786
+ }
4787
+ });
4788
+ router.post('/forms/:id/webhooks', async (request, params) => {
4789
+ try {
4790
+ const auth = await requireAuth(request);
4791
+ if (auth.error)
4792
+ return auth.error;
4793
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
4794
+ if (roleErr)
4795
+ return roleErr;
4796
+ const form = await getFormByIdService(params.id);
4797
+ if (!form)
4798
+ return errorResponse('Form not found', 404);
4799
+ const body = (await request.json().catch(() => ({})));
4800
+ if (!body.url || typeof body.url !== 'string') {
4801
+ return errorResponse('A webhook url is required', 400);
4802
+ }
4803
+ const ctx = buildActionContext(auth.session, db());
4804
+ const webhook = await createFormWebhook(ctx, params.id, body);
4805
+ // The plaintext secret is returned exactly once here, on creation.
4806
+ return json({ data: webhook }, 201);
4807
+ }
4808
+ catch (err) {
4809
+ return mapFormError(err) ?? internalError(err);
4810
+ }
4811
+ });
4812
+ router.patch('/forms/:id/webhooks/:webhookId', async (request, params) => {
4813
+ try {
4814
+ const auth = await requireAuth(request);
4815
+ if (auth.error)
4816
+ return auth.error;
4817
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
4818
+ if (roleErr)
4819
+ return roleErr;
4820
+ const body = (await request.json().catch(() => ({})));
4821
+ const ctx = buildActionContext(auth.session, db());
4822
+ const webhook = await updateFormWebhook(ctx, params.webhookId, body);
4823
+ return json({ data: webhook });
4824
+ }
4825
+ catch (err) {
4826
+ return mapFormError(err) ?? internalError(err);
4827
+ }
4828
+ });
4829
+ router.delete('/forms/:id/webhooks/:webhookId', async (request, params) => {
4830
+ try {
4831
+ const auth = await requireAuth(request);
4832
+ if (auth.error)
4833
+ return auth.error;
4834
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
4835
+ if (roleErr)
4836
+ return roleErr;
4837
+ const ctx = buildActionContext(auth.session, db());
4838
+ await deleteFormWebhook(ctx, params.webhookId);
4839
+ return json({ data: { success: true } });
4840
+ }
4841
+ catch (err) {
4842
+ return mapFormError(err) ?? internalError(err);
4843
+ }
4844
+ });
4845
+ router.post('/forms/:id/webhooks/:webhookId/test', async (request, params) => {
4846
+ try {
4847
+ const auth = await requireAuth(request);
4848
+ if (auth.error)
4849
+ return auth.error;
4850
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
4851
+ if (roleErr)
4852
+ return roleErr;
4853
+ const ctx = buildActionContext(auth.session, db());
4854
+ const result = await testFormWebhook(ctx, params.webhookId);
4855
+ return json({ data: result });
4856
+ }
4857
+ catch (err) {
4858
+ return mapFormError(err) ?? internalError(err);
4859
+ }
4860
+ });
4861
+ // ── Test notification email ──────────────────────────────────────────────
4862
+ router.post('/forms/:id/notifications/test', async (request, params) => {
4863
+ try {
4864
+ const auth = await requireAuth(request);
4865
+ if (auth.error)
4866
+ return auth.error;
4867
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
4868
+ if (roleErr)
4869
+ return roleErr;
4870
+ const form = await getFormByIdService(params.id);
4871
+ if (!form)
4872
+ return errorResponse('Form not found', 404);
4873
+ const settings = await getFormNotificationSettingsService(params.id);
4874
+ const primaryRecipient = (settings?.recipients ?? [])[0];
4875
+ if (!settings?.enabled || !primaryRecipient) {
4876
+ return errorResponse('Configure at least one recipient before sending a test', 400);
4877
+ }
4878
+ // Sample values so operators see a realistic email. The autoresponder is
4879
+ // suppressed for tests so we never email an arbitrary sample address.
4880
+ const fields = form.fields ?? [];
4881
+ const sampleData = {};
4882
+ for (const field of fields) {
4883
+ if (field.type === 'honeypot' || field.type === 'divider' || field.type === 'richtext') {
4884
+ continue;
4885
+ }
4886
+ if (field.type === 'email')
4887
+ sampleData[field.key] = primaryRecipient;
4888
+ else if (field.type === 'checkbox' || field.type === 'consent')
4889
+ sampleData[field.key] = true;
4890
+ else
4891
+ sampleData[field.key] = `Sample ${field.label || field.key}`;
4892
+ }
4893
+ const ctx = {
4894
+ formId: form.id,
4895
+ formName: form.name,
4896
+ entryNumber: null,
4897
+ submittedAt: new Date().toISOString(),
4898
+ fields,
4899
+ data: sampleData,
4900
+ senderName: 'Test Submission',
4901
+ senderEmail: primaryRecipient,
4902
+ };
4903
+ const result = await sendFormNotifications({ ...settings, autoresponderEnabled: false }, ctx);
4904
+ return json({ data: result });
4905
+ }
4906
+ catch (err) {
4907
+ return mapFormError(err) ?? internalError(err);
4908
+ }
4909
+ });
4723
4910
  // ---------------------------------------------------------------------------
4724
4911
  // Redirects routes
4725
4912
  // ---------------------------------------------------------------------------
@@ -8576,7 +8763,11 @@ export function registerCMSRoutes(router) {
8576
8763
  if (!isAuthorizedCronRequest(request.headers.get('authorization'))) {
8577
8764
  return errorResponse('Unauthorized', 401);
8578
8765
  }
8579
- const result = await processCleanup(db());
8766
+ const retentionDays = getActuateConfig()?.forms?.retentionDays;
8767
+ const formSubmissionRetentionMs = typeof retentionDays === 'number' && retentionDays > 0
8768
+ ? retentionDays * 24 * 60 * 60 * 1000
8769
+ : 0;
8770
+ const result = await processCleanup(db(), { formSubmissionRetentionMs });
8580
8771
  return json({ data: result });
8581
8772
  }
8582
8773
  catch (err) {