@cogenta/forms 0.2.4

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 (54) hide show
  1. package/LICENSE +373 -0
  2. package/dist/anti-abuse.d.ts +16 -0
  3. package/dist/anti-abuse.d.ts.map +1 -0
  4. package/dist/anti-abuse.js +58 -0
  5. package/dist/anti-abuse.js.map +1 -0
  6. package/dist/captcha.d.ts +18 -0
  7. package/dist/captcha.d.ts.map +1 -0
  8. package/dist/captcha.js +69 -0
  9. package/dist/captcha.js.map +1 -0
  10. package/dist/conditions.d.ts +6 -0
  11. package/dist/conditions.d.ts.map +1 -0
  12. package/dist/conditions.js +52 -0
  13. package/dist/conditions.js.map +1 -0
  14. package/dist/csv.d.ts +10 -0
  15. package/dist/csv.d.ts.map +1 -0
  16. package/dist/csv.js +56 -0
  17. package/dist/csv.js.map +1 -0
  18. package/dist/file-field.d.ts +64 -0
  19. package/dist/file-field.d.ts.map +1 -0
  20. package/dist/file-field.js +169 -0
  21. package/dist/file-field.js.map +1 -0
  22. package/dist/index.d.ts +18 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +12 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/ip.d.ts +12 -0
  27. package/dist/ip.d.ts.map +1 -0
  28. package/dist/ip.js +15 -0
  29. package/dist/ip.js.map +1 -0
  30. package/dist/notify.d.ts +52 -0
  31. package/dist/notify.d.ts.map +1 -0
  32. package/dist/notify.js +113 -0
  33. package/dist/notify.js.map +1 -0
  34. package/dist/rows.d.ts +13 -0
  35. package/dist/rows.d.ts.map +1 -0
  36. package/dist/rows.js +72 -0
  37. package/dist/rows.js.map +1 -0
  38. package/dist/store.d.ts +71 -0
  39. package/dist/store.d.ts.map +1 -0
  40. package/dist/store.js +509 -0
  41. package/dist/store.js.map +1 -0
  42. package/dist/tables.d.ts +21 -0
  43. package/dist/tables.d.ts.map +1 -0
  44. package/dist/tables.js +131 -0
  45. package/dist/tables.js.map +1 -0
  46. package/dist/types.d.ts +181 -0
  47. package/dist/types.d.ts.map +1 -0
  48. package/dist/types.js +56 -0
  49. package/dist/types.js.map +1 -0
  50. package/dist/validate.d.ts +20 -0
  51. package/dist/validate.d.ts.map +1 -0
  52. package/dist/validate.js +353 -0
  53. package/dist/validate.js.map +1 -0
  54. package/package.json +43 -0
package/dist/rows.js ADDED
@@ -0,0 +1,72 @@
1
+ import { CogentaError } from '@cogenta/core';
2
+ /**
3
+ * Reading rows back out of three databases that disagree about them — the
4
+ * same trouble `@cogenta/commerce`'s `rows.ts` documents, restated here
5
+ * because this package owns its own tables and does not depend on commerce.
6
+ */
7
+ export function toText(value, what) {
8
+ if (typeof value === 'string')
9
+ return value;
10
+ if (typeof value === 'number' || typeof value === 'bigint')
11
+ return String(value);
12
+ throw new CogentaError({
13
+ code: 'INTERNAL',
14
+ message: `The stored value of ${what} is not text.`,
15
+ hint: 'This row was not written by this package.',
16
+ details: { column: what, type: typeof value },
17
+ });
18
+ }
19
+ export function toNullableText(value) {
20
+ if (value === null || value === undefined || value === '')
21
+ return null;
22
+ if (typeof value === 'string')
23
+ return value;
24
+ return String(value);
25
+ }
26
+ export function toInt(value, what) {
27
+ if (typeof value === 'number' && Number.isFinite(value))
28
+ return Math.trunc(value);
29
+ if (typeof value === 'bigint')
30
+ return Number(value);
31
+ if (typeof value === 'string' && value.trim() !== '') {
32
+ const parsed = Number(value);
33
+ if (Number.isFinite(parsed))
34
+ return Math.trunc(parsed);
35
+ }
36
+ throw new CogentaError({
37
+ code: 'INTERNAL',
38
+ message: `The stored value of ${what} is not a usable integer.`,
39
+ hint: 'This row was not written by this package, or the column type was changed by hand.',
40
+ details: { column: what, type: typeof value },
41
+ });
42
+ }
43
+ export function toBool(value) {
44
+ if (typeof value === 'boolean')
45
+ return value;
46
+ if (typeof value === 'number')
47
+ return value !== 0;
48
+ if (typeof value === 'bigint')
49
+ return value !== 0n;
50
+ if (typeof value === 'string')
51
+ return value !== '' && value !== '0' && value !== 'false';
52
+ return false;
53
+ }
54
+ /** Postgres wants a real boolean; MySQL/SQLite want 0/1. */
55
+ export function fromBool(value, dialect) {
56
+ return dialect === 'postgres' ? value : value ? 1 : 0;
57
+ }
58
+ export function toJson(value, what) {
59
+ const text = toText(value, what);
60
+ try {
61
+ return JSON.parse(text);
62
+ }
63
+ catch {
64
+ throw new CogentaError({
65
+ code: 'INTERNAL',
66
+ message: `The stored value of ${what} is not valid JSON.`,
67
+ hint: 'This row was not written by this package.',
68
+ details: { column: what },
69
+ });
70
+ }
71
+ }
72
+ //# sourceMappingURL=rows.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rows.js","sourceRoot":"","sources":["../src/rows.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,71 @@
1
+ import { type DatabaseHandle } from '@cogenta/core';
2
+ import type { CreateFormDefinitionInput, FormDefinition, FormSubmission, FormSubmissionNote, FormSubmissionStatus, UpdateFormDefinitionInput } from './types.js';
3
+ export interface SubmitOptions {
4
+ readonly ip?: string | null;
5
+ readonly referrer?: string | null;
6
+ readonly userAgent?: string | null;
7
+ }
8
+ export interface ListSubmissionsOptions {
9
+ readonly formId?: string;
10
+ readonly status?: FormSubmissionStatus;
11
+ readonly limit?: number;
12
+ readonly cursor?: string;
13
+ /** Task 7 — an ISO instant; only submissions at or after it. Applied in SQL, same as `cursor`. */
14
+ readonly from?: string;
15
+ /** Task 7 — an ISO instant; only submissions at or before it. */
16
+ readonly to?: string;
17
+ /**
18
+ * Task 7 — free text, matched case-insensitively against a submission's
19
+ * own field values and consent text. Applied in application code after the
20
+ * SQL filters above narrow the scan (see `MAX_QUERY_SCAN_ROWS`'s own
21
+ * comment) — the honest cost of a form whose values are free-form JSON
22
+ * with no dialect-portable full-text index.
23
+ */
24
+ readonly query?: string;
25
+ }
26
+ export interface ListSubmissionsResult {
27
+ readonly items: readonly FormSubmission[];
28
+ readonly nextCursor: string | null;
29
+ }
30
+ export interface PurgeReport {
31
+ readonly purged: number;
32
+ }
33
+ export interface FormDefinitionStore {
34
+ create(input: CreateFormDefinitionInput): Promise<FormDefinition>;
35
+ read(id: string): Promise<FormDefinition | null>;
36
+ readByName(name: string): Promise<FormDefinition | null>;
37
+ list(): Promise<readonly FormDefinition[]>;
38
+ update(id: string, input: UpdateFormDefinitionInput): Promise<FormDefinition>;
39
+ remove(id: string): Promise<void>;
40
+ /** Task 11 — a real, independent copy: its own id, an available name derived from the original's, inactive by default so a duplicate never starts silently accepting submissions, and no submissions carried over. */
41
+ duplicate(id: string): Promise<FormDefinition>;
42
+ }
43
+ export interface FormSubmissionStore {
44
+ /** Full server-side validation happens here — `validateSubmission` — never trusting whatever the client already checked. */
45
+ submit(formName: string, rawValues: Readonly<Record<string, unknown>>, options?: SubmitOptions): Promise<FormSubmission>;
46
+ read(id: string): Promise<FormSubmission | null>;
47
+ list(options?: ListSubmissionsOptions): Promise<ListSubmissionsResult>;
48
+ markStatus(id: string, status: FormSubmissionStatus): Promise<FormSubmission>;
49
+ bulkMarkStatus(ids: readonly string[], status: FormSubmissionStatus): Promise<number>;
50
+ remove(id: string): Promise<void>;
51
+ bulkRemove(ids: readonly string[]): Promise<number>;
52
+ unreadCount(): Promise<number>;
53
+ /** GDPR task 7's minimum: an e-mail-based search across submissions, for export/deletion requests. */
54
+ searchByEmail(email: string): Promise<readonly FormSubmission[]>;
55
+ /** GDPR erasure: every submission naming this e-mail address, gone. */
56
+ deleteByEmail(email: string): Promise<number>;
57
+ /** Removes submissions older than each form's own `retainDays` (ADR-0022's `purgeExpired` model, applied to submissions rather than content). */
58
+ purgeExpired(): Promise<PurgeReport>;
59
+ /** Task 8 — an operator's own note. Never shown to the visitor, never included in a CSV export. */
60
+ addNote(submissionId: string, body: string, author: {
61
+ id: string | null;
62
+ label: string;
63
+ }): Promise<FormSubmissionNote>;
64
+ listNotes(submissionId: string): Promise<readonly FormSubmissionNote[]>;
65
+ }
66
+ export interface FormStore {
67
+ readonly definitions: FormDefinitionStore;
68
+ readonly submissions: FormSubmissionStore;
69
+ }
70
+ export declare function createFormStore(db: DatabaseHandle, now?: () => number): FormStore;
71
+ //# sourceMappingURL=store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"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"}
package/dist/store.js ADDED
@@ -0,0 +1,509 @@
1
+ import { CogentaError, identifier, newId, sql, unsafeRaw, } from '@cogenta/core';
2
+ import { fromBool, toBool, toInt, toJson, toNullableText, toText } from './rows.js';
3
+ import { TABLES } from './tables.js';
4
+ import { emailValueOf, FORM_FIELD_KINDS } from './types.js';
5
+ import { validateCaptchaConfig, validateDefinitionFields, validateFormSteps, validateNotifyChannels, validateSubmission, } from './validate.js';
6
+ // Reserved: `forms-router.ts` mounts submission management under
7
+ // `/api/forms/submissions/*`, so a form literally named "submissions" would
8
+ // make `GET /api/forms/submissions` ambiguous between "the definition named
9
+ // submissions" and "every submission across every form".
10
+ const RESERVED_NAMES = new Set(['submissions']);
11
+ const DEFAULT_RETAIN_DAYS = 180;
12
+ const DEFAULT_CONFIRMATION = 'Thank you — your message has been received.';
13
+ const DEFAULT_AUTORESPONDER = { enabled: false };
14
+ const DEFAULT_CAPTCHA = { enabled: false };
15
+ const SUBMISSION_STATUSES = ['new', 'read', 'archived', 'spam'];
16
+ const DAY_MS = 24 * 60 * 60 * 1000;
17
+ // Fiche 47 task 7: a text search across `values_json`/`consents_json` is a
18
+ // full application-side scan (the same honest tradeoff `searchByEmail`
19
+ // already makes) rather than a dialect-specific `LIKE`, whose case
20
+ // sensitivity differs across SQLite/Postgres/MySQL. Bounded so an
21
+ // operator-triggered search on a very large form cannot become an unbounded
22
+ // read — this route is never a hot path.
23
+ const MAX_QUERY_SCAN_ROWS = 5_000;
24
+ function slugName(raw) {
25
+ return raw
26
+ .trim()
27
+ .toLowerCase()
28
+ .replace(/[^a-z0-9]+/gu, '-')
29
+ .replace(/^-+|-+$/gu, '');
30
+ }
31
+ function toJsonOrDefault(value, what, fallback) {
32
+ if (value === null || value === undefined || value === '')
33
+ return fallback;
34
+ return toJson(value, what);
35
+ }
36
+ function decodeDefinition(row) {
37
+ return {
38
+ id: toText(row.id, 'form.id'),
39
+ name: toText(row.name, 'form.name'),
40
+ label: toText(row.label, 'form.label'),
41
+ fields: toJson(row.fields, 'form.fields'),
42
+ active: toBool(row.active),
43
+ confirmationMessage: toText(row.confirmation_message, 'form.confirmation_message'),
44
+ redirectTo: toNullableText(row.redirect_to),
45
+ notifyEmails: toJson(row.notify_emails, 'form.notify_emails'),
46
+ autoresponder: toJson(row.autoresponder, 'form.autoresponder'),
47
+ retainDays: toInt(row.retain_days, 'form.retain_days'),
48
+ steps: toJsonOrDefault(row.steps, 'form.steps', []),
49
+ notifyChannels: toJsonOrDefault(row.notify_channels, 'form.notify_channels', []),
50
+ captcha: toJsonOrDefault(row.captcha, 'form.captcha', DEFAULT_CAPTCHA),
51
+ createdAt: toText(row.created_at, 'form.created_at'),
52
+ updatedAt: toText(row.updated_at, 'form.updated_at'),
53
+ };
54
+ }
55
+ function decodeSubmission(row) {
56
+ return {
57
+ id: toText(row.id, 'submission.id'),
58
+ formId: toText(row.form_id, 'submission.form_id'),
59
+ formName: toText(row.form_name, 'submission.form_name'),
60
+ values: toJson(row.values_json, 'submission.values_json'),
61
+ consents: toJson(row.consents_json, 'submission.consents_json'),
62
+ status: toText(row.status, 'submission.status'),
63
+ ipHash: toNullableText(row.ip_hash),
64
+ referrer: toNullableText(row.referrer),
65
+ userAgent: toNullableText(row.user_agent),
66
+ submittedAt: toText(row.submitted_at, 'submission.submitted_at'),
67
+ };
68
+ }
69
+ function decodeNote(row) {
70
+ return {
71
+ id: toText(row.id, 'note.id'),
72
+ submissionId: toText(row.submission_id, 'note.submission_id'),
73
+ authorId: toNullableText(row.author_id),
74
+ authorLabel: toText(row.author_label, 'note.author_label'),
75
+ body: toText(row.body, 'note.body'),
76
+ createdAt: toText(row.created_at, 'note.created_at'),
77
+ };
78
+ }
79
+ function formUnknown(name) {
80
+ return new CogentaError({
81
+ code: 'FORM_UNKNOWN',
82
+ message: `No form named "${name}".`,
83
+ hint: 'Check the form name, or create it first.',
84
+ });
85
+ }
86
+ /** A submission value as plain text — a file field contributes its filename, never its bytes. */
87
+ function submissionValueText(value) {
88
+ if (typeof value === 'string')
89
+ return value;
90
+ if (Array.isArray(value))
91
+ return value.join(' ');
92
+ return value.filename;
93
+ }
94
+ /** Task 7's text search: every value and every consent's recorded wording, matched case-insensitively. */
95
+ function submissionMatchesQuery(submission, needleLower) {
96
+ for (const value of Object.values(submission.values)) {
97
+ if (submissionValueText(value).toLowerCase().includes(needleLower))
98
+ return true;
99
+ }
100
+ for (const consent of submission.consents) {
101
+ if (consent.text.toLowerCase().includes(needleLower))
102
+ return true;
103
+ }
104
+ return false;
105
+ }
106
+ export function createFormStore(db, now = Date.now) {
107
+ const d = db.dialect;
108
+ const definitionsTable = identifier(TABLES.definitions, d);
109
+ const submissionsTable = identifier(TABLES.submissions, d);
110
+ const notesTable = identifier(TABLES.submissionNotes, d);
111
+ async function readDefinitionRow(id) {
112
+ const result = await db.query(sql `select * from ${definitionsTable} where id = ${id}`);
113
+ const row = result.rows[0];
114
+ return row === undefined ? null : decodeDefinition(row);
115
+ }
116
+ async function readDefinitionByNameRow(name) {
117
+ const result = await db.query(sql `select * from ${definitionsTable} where name = ${name}`);
118
+ const row = result.rows[0];
119
+ return row === undefined ? null : decodeDefinition(row);
120
+ }
121
+ const definitions = {
122
+ create: async (input) => {
123
+ const name = slugName(input.name);
124
+ if (name === '') {
125
+ throw new CogentaError({
126
+ code: 'FORM_DEFINITION_INVALID',
127
+ message: 'A form needs a usable name.',
128
+ hint: 'Give the form a label with at least one letter or digit.',
129
+ });
130
+ }
131
+ if (RESERVED_NAMES.has(name)) {
132
+ throw new CogentaError({
133
+ code: 'FORM_NAME_TAKEN',
134
+ message: `"${name}" is a reserved name and cannot be used for a form.`,
135
+ hint: 'Choose a different name.',
136
+ });
137
+ }
138
+ for (const field of input.fields) {
139
+ if (!FORM_FIELD_KINDS.includes(field.kind)) {
140
+ throw new CogentaError({
141
+ code: 'FORM_DEFINITION_INVALID',
142
+ message: `"${field.kind}" is not a form field kind.`,
143
+ hint: `Use one of: ${FORM_FIELD_KINDS.join(', ')}.`,
144
+ });
145
+ }
146
+ }
147
+ validateDefinitionFields(input.fields);
148
+ validateFormSteps(input.fields, input.steps ?? []);
149
+ validateNotifyChannels(input.notifyChannels ?? []);
150
+ validateCaptchaConfig(input.captcha ?? DEFAULT_CAPTCHA);
151
+ if ((await readDefinitionByNameRow(name)) !== null) {
152
+ throw new CogentaError({
153
+ code: 'FORM_NAME_TAKEN',
154
+ message: `A form named "${name}" already exists.`,
155
+ hint: 'Choose a different name, or edit the existing form.',
156
+ });
157
+ }
158
+ const id = newId(now);
159
+ const at = new Date(now()).toISOString();
160
+ await db.query(sql `
161
+ insert into ${definitionsTable}
162
+ (id, name, label, fields, active, confirmation_message, redirect_to,
163
+ notify_emails, autoresponder, retain_days, steps, notify_channels, captcha,
164
+ created_at, updated_at)
165
+ values (${id}, ${name}, ${input.label}, ${JSON.stringify(input.fields)},
166
+ ${fromBool(input.active ?? true, d)}, ${input.confirmationMessage ?? DEFAULT_CONFIRMATION},
167
+ ${input.redirectTo ?? null}, ${JSON.stringify(input.notifyEmails ?? [])},
168
+ ${JSON.stringify(input.autoresponder ?? DEFAULT_AUTORESPONDER)},
169
+ ${input.retainDays ?? DEFAULT_RETAIN_DAYS}, ${JSON.stringify(input.steps ?? [])},
170
+ ${JSON.stringify(input.notifyChannels ?? [])},
171
+ ${JSON.stringify(input.captcha ?? DEFAULT_CAPTCHA)}, ${at}, ${at})`);
172
+ const created = await readDefinitionRow(id);
173
+ if (created === null) {
174
+ throw new CogentaError({
175
+ code: 'INTERNAL',
176
+ message: 'The form was not stored.',
177
+ hint: 'Check that the forms tables exist (ensureFormsTables).',
178
+ });
179
+ }
180
+ return created;
181
+ },
182
+ read: readDefinitionRow,
183
+ readByName: readDefinitionByNameRow,
184
+ list: async () => {
185
+ const result = await db.query(sql `select * from ${definitionsTable} order by created_at desc`);
186
+ return result.rows.map(decodeDefinition);
187
+ },
188
+ update: async (id, input) => {
189
+ const existing = await readDefinitionRow(id);
190
+ if (existing === null) {
191
+ throw new CogentaError({
192
+ code: 'FORM_UNKNOWN',
193
+ message: `No form with id "${id}".`,
194
+ hint: 'It may have been deleted.',
195
+ });
196
+ }
197
+ const nextFields = input.fields ?? existing.fields;
198
+ if (input.fields !== undefined) {
199
+ for (const field of nextFields) {
200
+ if (!FORM_FIELD_KINDS.includes(field.kind)) {
201
+ throw new CogentaError({
202
+ code: 'FORM_DEFINITION_INVALID',
203
+ message: `"${field.kind}" is not a form field kind.`,
204
+ hint: `Use one of: ${FORM_FIELD_KINDS.join(', ')}.`,
205
+ });
206
+ }
207
+ }
208
+ validateDefinitionFields(nextFields);
209
+ }
210
+ const nextSteps = input.steps ?? existing.steps;
211
+ if (input.steps !== undefined || input.fields !== undefined) {
212
+ validateFormSteps(nextFields, nextSteps);
213
+ }
214
+ const nextNotifyChannels = input.notifyChannels ?? existing.notifyChannels;
215
+ if (input.notifyChannels !== undefined)
216
+ validateNotifyChannels(nextNotifyChannels);
217
+ const nextCaptcha = input.captcha ?? existing.captcha;
218
+ if (input.captcha !== undefined)
219
+ validateCaptchaConfig(nextCaptcha);
220
+ let nextName = existing.name;
221
+ if (input.name !== undefined) {
222
+ nextName = slugName(input.name);
223
+ if (RESERVED_NAMES.has(nextName)) {
224
+ throw new CogentaError({
225
+ code: 'FORM_NAME_TAKEN',
226
+ message: `"${nextName}" is a reserved name and cannot be used for a form.`,
227
+ hint: 'Choose a different name.',
228
+ });
229
+ }
230
+ const clash = await readDefinitionByNameRow(nextName);
231
+ if (clash !== null && clash.id !== id) {
232
+ throw new CogentaError({
233
+ code: 'FORM_NAME_TAKEN',
234
+ message: `A form named "${nextName}" already exists.`,
235
+ hint: 'Choose a different name.',
236
+ });
237
+ }
238
+ }
239
+ const at = new Date(now()).toISOString();
240
+ await db.query(sql `
241
+ update ${definitionsTable} set
242
+ name = ${nextName},
243
+ label = ${input.label ?? existing.label},
244
+ fields = ${JSON.stringify(nextFields)},
245
+ active = ${fromBool(input.active ?? existing.active, d)},
246
+ confirmation_message = ${input.confirmationMessage ?? existing.confirmationMessage},
247
+ redirect_to = ${input.redirectTo === undefined ? existing.redirectTo : input.redirectTo},
248
+ notify_emails = ${JSON.stringify(input.notifyEmails ?? existing.notifyEmails)},
249
+ autoresponder = ${JSON.stringify(input.autoresponder ?? existing.autoresponder)},
250
+ retain_days = ${input.retainDays ?? existing.retainDays},
251
+ steps = ${JSON.stringify(nextSteps)},
252
+ notify_channels = ${JSON.stringify(nextNotifyChannels)},
253
+ captcha = ${JSON.stringify(nextCaptcha)},
254
+ updated_at = ${at}
255
+ where id = ${id}`);
256
+ const updated = await readDefinitionRow(id);
257
+ if (updated === null) {
258
+ throw new CogentaError({
259
+ code: 'FORM_UNKNOWN',
260
+ message: `No form with id "${id}".`,
261
+ hint: 'It may have been deleted.',
262
+ });
263
+ }
264
+ return updated;
265
+ },
266
+ remove: async (id) => {
267
+ await db.query(sql `delete from ${submissionsTable} where form_id = ${id}`);
268
+ await db.query(sql `delete from ${definitionsTable} where id = ${id}`);
269
+ },
270
+ duplicate: async (id) => {
271
+ const existing = await readDefinitionRow(id);
272
+ if (existing === null) {
273
+ throw new CogentaError({
274
+ code: 'FORM_UNKNOWN',
275
+ message: `No form with id "${id}".`,
276
+ hint: 'It may have been deleted.',
277
+ });
278
+ }
279
+ let candidate = `${existing.name}-copy`;
280
+ let suffix = 2;
281
+ while ((await readDefinitionByNameRow(candidate)) !== null) {
282
+ candidate = `${existing.name}-copy-${suffix}`;
283
+ suffix += 1;
284
+ }
285
+ return definitions.create({
286
+ name: candidate,
287
+ label: `${existing.label} (copy)`,
288
+ fields: existing.fields,
289
+ // Never active: a duplicate must not start accepting real
290
+ // submissions before an operator has reviewed the copy (its route,
291
+ // its notifications, its CAPTCHA secret) — the same caution
292
+ // `active: true` by default elsewhere would defeat.
293
+ active: false,
294
+ confirmationMessage: existing.confirmationMessage,
295
+ redirectTo: existing.redirectTo,
296
+ notifyEmails: existing.notifyEmails,
297
+ autoresponder: existing.autoresponder,
298
+ retainDays: existing.retainDays,
299
+ steps: existing.steps,
300
+ notifyChannels: existing.notifyChannels,
301
+ captcha: existing.captcha,
302
+ });
303
+ },
304
+ };
305
+ async function readSubmissionRow(id) {
306
+ const result = await db.query(sql `select * from ${submissionsTable} where id = ${id}`);
307
+ const row = result.rows[0];
308
+ return row === undefined ? null : decodeSubmission(row);
309
+ }
310
+ const submissions = {
311
+ submit: async (formName, rawValues, options = {}) => {
312
+ const name = slugName(formName);
313
+ const definition = await readDefinitionByNameRow(name);
314
+ if (definition === null)
315
+ throw formUnknown(formName);
316
+ const validated = validateSubmission(definition, rawValues, now);
317
+ const id = newId(now);
318
+ const at = new Date(now()).toISOString();
319
+ await db.query(sql `
320
+ insert into ${submissionsTable}
321
+ (id, form_id, form_name, values_json, consents_json, status, ip_hash, referrer, user_agent, submitted_at)
322
+ values (${id}, ${definition.id}, ${definition.name}, ${JSON.stringify(validated.values)},
323
+ ${JSON.stringify(validated.consents)}, ${'new'},
324
+ ${options.ip ?? null}, ${options.referrer ?? null}, ${options.userAgent ?? null}, ${at})`);
325
+ const created = await readSubmissionRow(id);
326
+ if (created === null) {
327
+ throw new CogentaError({
328
+ code: 'INTERNAL',
329
+ message: 'The submission was not stored.',
330
+ hint: 'Check that the forms tables exist (ensureFormsTables).',
331
+ });
332
+ }
333
+ return created;
334
+ },
335
+ read: readSubmissionRow,
336
+ list: async (options = {}) => {
337
+ const limit = Math.min(options.limit ?? 50, 200);
338
+ const clauses = [];
339
+ if (options.formId !== undefined)
340
+ clauses.push(sql `form_id = ${options.formId}`);
341
+ if (options.status !== undefined)
342
+ clauses.push(sql `status = ${options.status}`);
343
+ if (options.cursor !== undefined)
344
+ clauses.push(sql `submitted_at < ${options.cursor}`);
345
+ if (options.from !== undefined)
346
+ clauses.push(sql `submitted_at >= ${options.from}`);
347
+ if (options.to !== undefined)
348
+ clauses.push(sql `submitted_at <= ${options.to}`);
349
+ let where = unsafeRaw('');
350
+ for (const [index, clause] of clauses.entries()) {
351
+ where = index === 0 ? sql `where ${clause}` : sql `${where} and ${clause}`;
352
+ }
353
+ const needle = options.query?.trim().toLowerCase() ?? '';
354
+ const hasQuery = needle !== '';
355
+ // With a text query, the row count after filtering is not knowable in
356
+ // SQL, so the cheap `limit + 1` "is there another page" trick is
357
+ // replaced by pulling a bounded superset and filtering/paginating in
358
+ // memory (see `MAX_QUERY_SCAN_ROWS`'s own comment above).
359
+ const sqlLimit = hasQuery ? MAX_QUERY_SCAN_ROWS : limit + 1;
360
+ const result = await db.query(sql `select * from ${submissionsTable} ${where} order by submitted_at desc limit ${sqlLimit}`);
361
+ let rows = result.rows.map(decodeSubmission);
362
+ if (hasQuery)
363
+ rows = rows.filter((submission) => submissionMatchesQuery(submission, needle));
364
+ const hasMore = rows.length > limit;
365
+ const items = hasMore ? rows.slice(0, limit) : rows;
366
+ return { items, nextCursor: hasMore ? (items[items.length - 1]?.submittedAt ?? null) : null };
367
+ },
368
+ markStatus: async (id, status) => {
369
+ if (!SUBMISSION_STATUSES.includes(status)) {
370
+ throw new CogentaError({
371
+ code: 'FORM_SUBMISSION_INVALID',
372
+ message: `"${status}" is not a submission status.`,
373
+ hint: `Use one of: ${SUBMISSION_STATUSES.join(', ')}.`,
374
+ });
375
+ }
376
+ await db.query(sql `update ${submissionsTable} set status = ${status} where id = ${id}`);
377
+ const updated = await readSubmissionRow(id);
378
+ if (updated === null) {
379
+ throw new CogentaError({
380
+ code: 'FORM_SUBMISSION_NOT_FOUND',
381
+ message: `No submission with id "${id}".`,
382
+ hint: 'It may already have been deleted.',
383
+ });
384
+ }
385
+ return updated;
386
+ },
387
+ bulkMarkStatus: async (ids, status) => {
388
+ if (!SUBMISSION_STATUSES.includes(status)) {
389
+ throw new CogentaError({
390
+ code: 'FORM_SUBMISSION_INVALID',
391
+ message: `"${status}" is not a submission status.`,
392
+ hint: `Use one of: ${SUBMISSION_STATUSES.join(', ')}.`,
393
+ });
394
+ }
395
+ let count = 0;
396
+ for (const id of ids) {
397
+ const result = await db.query(sql `update ${submissionsTable} set status = ${status} where id = ${id}`);
398
+ count += result.rowsAffected;
399
+ }
400
+ return count;
401
+ },
402
+ remove: async (id) => {
403
+ await db.query(sql `delete from ${notesTable} where submission_id = ${id}`);
404
+ await db.query(sql `delete from ${submissionsTable} where id = ${id}`);
405
+ },
406
+ bulkRemove: async (ids) => {
407
+ let count = 0;
408
+ for (const id of ids) {
409
+ await db.query(sql `delete from ${notesTable} where submission_id = ${id}`);
410
+ const result = await db.query(sql `delete from ${submissionsTable} where id = ${id}`);
411
+ count += result.rowsAffected;
412
+ }
413
+ return count;
414
+ },
415
+ unreadCount: async () => {
416
+ const result = await db.query(sql `select count(*) as n from ${submissionsTable} where status = ${'new'}`);
417
+ return toInt(result.rows[0]?.n ?? 0, 'unread count');
418
+ },
419
+ searchByEmail: async (email) => {
420
+ const normalised = email.trim().toLowerCase();
421
+ // The e-mail lives inside `values_json`, one key per form's own `email`
422
+ // field — there is no dedicated column to index it under, since a form
423
+ // is not required to collect an e-mail at all. A full scan is the
424
+ // honest cost of that flexibility; this route is an operator-triggered
425
+ // GDPR request, never a hot path.
426
+ const result = await db.query(sql `select * from ${submissionsTable}`);
427
+ const definitionsById = new Map();
428
+ const matches = [];
429
+ for (const row of result.rows) {
430
+ const submission = decodeSubmission(row);
431
+ let definition = definitionsById.get(submission.formId);
432
+ if (definition === undefined) {
433
+ const found = await readDefinitionRow(submission.formId);
434
+ if (found !== null) {
435
+ definition = found;
436
+ definitionsById.set(submission.formId, found);
437
+ }
438
+ }
439
+ const value = definition === undefined ? null : emailValueOf(submission, definition.fields);
440
+ if (value === normalised)
441
+ matches.push(submission);
442
+ }
443
+ return matches;
444
+ },
445
+ deleteByEmail: async (email) => {
446
+ const matches = await submissions.searchByEmail(email);
447
+ let count = 0;
448
+ for (const match of matches) {
449
+ await db.query(sql `delete from ${notesTable} where submission_id = ${match.id}`);
450
+ const result = await db.query(sql `delete from ${submissionsTable} where id = ${match.id}`);
451
+ count += result.rowsAffected;
452
+ }
453
+ return count;
454
+ },
455
+ purgeExpired: async () => {
456
+ const forms = await definitions.list();
457
+ let purged = 0;
458
+ for (const form of forms) {
459
+ const cutoff = new Date(now() - form.retainDays * DAY_MS).toISOString();
460
+ const expiredResult = await db.query(sql `select id from ${submissionsTable} where form_id = ${form.id} and submitted_at < ${cutoff}`);
461
+ for (const row of expiredResult.rows) {
462
+ await db.query(sql `delete from ${notesTable} where submission_id = ${row.id}`);
463
+ }
464
+ const result = await db.query(sql `delete from ${submissionsTable} where form_id = ${form.id} and submitted_at < ${cutoff}`);
465
+ purged += result.rowsAffected;
466
+ }
467
+ return { purged };
468
+ },
469
+ addNote: async (submissionId, body, author) => {
470
+ const submission = await readSubmissionRow(submissionId);
471
+ if (submission === null) {
472
+ throw new CogentaError({
473
+ code: 'FORM_SUBMISSION_NOT_FOUND',
474
+ message: `No submission with id "${submissionId}".`,
475
+ hint: 'It may have been deleted.',
476
+ });
477
+ }
478
+ const trimmed = body.trim();
479
+ if (trimmed === '') {
480
+ throw new CogentaError({
481
+ code: 'FORM_SUBMISSION_INVALID',
482
+ message: 'A note needs some text.',
483
+ hint: 'Write something before saving the note.',
484
+ });
485
+ }
486
+ const id = newId(now);
487
+ const at = new Date(now()).toISOString();
488
+ await db.query(sql `
489
+ insert into ${notesTable} (id, submission_id, author_id, author_label, body, created_at)
490
+ values (${id}, ${submissionId}, ${author.id}, ${author.label}, ${trimmed}, ${at})`);
491
+ const result = await db.query(sql `select * from ${notesTable} where id = ${id}`);
492
+ const row = result.rows[0];
493
+ if (row === undefined) {
494
+ throw new CogentaError({
495
+ code: 'INTERNAL',
496
+ message: 'The note was not stored.',
497
+ hint: 'Check that the forms tables exist (ensureFormsTables).',
498
+ });
499
+ }
500
+ return decodeNote(row);
501
+ },
502
+ listNotes: async (submissionId) => {
503
+ const result = await db.query(sql `select * from ${notesTable} where submission_id = ${submissionId} order by created_at asc`);
504
+ return result.rows.map(decodeNote);
505
+ },
506
+ };
507
+ return { definitions, submissions };
508
+ }
509
+ //# sourceMappingURL=store.js.map