@crombolo/server 0.0.0-stage → 0.10.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 (85) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +69 -0
  3. package/package.json +41 -4
  4. package/src/access.ts +114 -0
  5. package/src/adapters/assistant.ts +229 -0
  6. package/src/adapters/blueprints.ts +163 -0
  7. package/src/adapters/bundles.ts +507 -0
  8. package/src/adapters/content.ts +783 -0
  9. package/src/adapters/dictionary.ts +76 -0
  10. package/src/adapters/documents.ts +637 -0
  11. package/src/adapters/elements.ts +403 -0
  12. package/src/adapters/file-system.ts +195 -0
  13. package/src/adapters/media.ts +347 -0
  14. package/src/adapters/member-groups.ts +90 -0
  15. package/src/adapters/members.ts +261 -0
  16. package/src/adapters/public-access.ts +116 -0
  17. package/src/adapters/redirects.ts +73 -0
  18. package/src/adapters/references.ts +67 -0
  19. package/src/adapters/schema-files.ts +204 -0
  20. package/src/adapters/schema-proposals.ts +180 -0
  21. package/src/adapters/template-files.ts +58 -0
  22. package/src/adapters/template-query.ts +127 -0
  23. package/src/adapters/users.ts +963 -0
  24. package/src/assistant.ts +487 -0
  25. package/src/backup.ts +52 -0
  26. package/src/bundle-install.ts +417 -0
  27. package/src/bundle-sections.ts +424 -0
  28. package/src/bundle-tables.ts +161 -0
  29. package/src/bundles.ts +609 -0
  30. package/src/component-snippets.ts +163 -0
  31. package/src/components.ts +266 -0
  32. package/src/config.ts +556 -0
  33. package/src/content-type-udt.ts +256 -0
  34. package/src/database.ts +148 -0
  35. package/src/diagnostics.ts +143 -0
  36. package/src/dictionary-transfer.ts +123 -0
  37. package/src/domains.ts +384 -0
  38. package/src/editor-types.ts +108 -0
  39. package/src/email-http.ts +79 -0
  40. package/src/email-notice.ts +36 -0
  41. package/src/email-postmark.ts +78 -0
  42. package/src/email-resend.ts +69 -0
  43. package/src/email-ses.ts +307 -0
  44. package/src/email.ts +402 -0
  45. package/src/extensions-notice.ts +35 -0
  46. package/src/file-intake.ts +84 -0
  47. package/src/form-files.ts +241 -0
  48. package/src/form-submit.ts +397 -0
  49. package/src/form-workflows.ts +397 -0
  50. package/src/forms.ts +170 -0
  51. package/src/git-github.ts +171 -0
  52. package/src/git-routes.ts +154 -0
  53. package/src/git.ts +88 -0
  54. package/src/imaging.ts +434 -0
  55. package/src/index.ts +65 -0
  56. package/src/jobs.ts +140 -0
  57. package/src/listen.ts +36 -0
  58. package/src/log-expressions.ts +524 -0
  59. package/src/log-viewer.ts +190 -0
  60. package/src/logging.ts +126 -0
  61. package/src/marketplace.ts +382 -0
  62. package/src/media-files.ts +276 -0
  63. package/src/media-store-azure.ts +218 -0
  64. package/src/media-store-s3.ts +113 -0
  65. package/src/media-store.ts +248 -0
  66. package/src/member-auth.ts +384 -0
  67. package/src/models-builder.ts +106 -0
  68. package/src/no-nodes.ts +64 -0
  69. package/src/not-implemented.ts +44 -0
  70. package/src/oembed.ts +144 -0
  71. package/src/ports.ts +553 -0
  72. package/src/redirects.ts +132 -0
  73. package/src/schema-classify.ts +82 -0
  74. package/src/schema-store.ts +145 -0
  75. package/src/schema.ts +388 -0
  76. package/src/seo.ts +371 -0
  77. package/src/server-events.ts +131 -0
  78. package/src/server.ts +1642 -0
  79. package/src/status.ts +275 -0
  80. package/src/tar.ts +85 -0
  81. package/src/udt.ts +168 -0
  82. package/src/value-schemas.ts +64 -0
  83. package/src/view-runtime.ts +42 -0
  84. package/src/zip.ts +152 -0
  85. package/README.md +0 -3
@@ -0,0 +1,397 @@
1
+ /**
2
+ * Running a form's workflows. `docs/forms.md`.
3
+ *
4
+ * The submission is already stored by the time anything here runs: these are
5
+ * the side effects, and a side effect that fails must never cost the entry.
6
+ * Each run is a queue row, claimed by one node and retried with a backoff, so
7
+ * a mail server that is down for ten minutes delays an email rather than
8
+ * losing it — and a multi-node site sends it once.
9
+ *
10
+ * Three types. The XSLT ones Umbraco has are gone (`docs/forms.md` says
11
+ * why), and Slack is `sendToUrl` with a webhook URL rather than a type of its
12
+ * own.
13
+ */
14
+ import {
15
+ allFormFields,
16
+ type FormValueToStore,
17
+ type SchemaForm,
18
+ type SchemaFormWorkflow,
19
+ } from '@crombolo/core'
20
+ import {
21
+ ContentTypeRepository,
22
+ type Db,
23
+ DocumentRepository,
24
+ FormWorkflowRepository,
25
+ type FormWorkflowRun,
26
+ type NodeSchemaState,
27
+ } from '@crombolo/data'
28
+ import type { EmailAttachment, EmailPort } from './email.ts'
29
+ import { logger } from './logging.ts'
30
+ import type { MediaFileStore } from './media-files.ts'
31
+
32
+ const log = logger('forms')
33
+
34
+ /** Attempts before a run is left for a person to look at. */
35
+ export const MAX_WORKFLOW_ATTEMPTS = 5
36
+ /** How long after each failed attempt to try again. */
37
+ const BACKOFF_MS = [60_000, 5 * 60_000, 15 * 60_000, 60 * 60_000]
38
+
39
+ export interface WorkflowContext {
40
+ form: SchemaForm
41
+ values: readonly FormValueToStore[]
42
+ entryId: string | null
43
+ }
44
+
45
+ export interface WorkflowDeps {
46
+ db: Db
47
+ /** Absent when the site has no e-mail; a `sendEmail` workflow then says so. */
48
+ email?: EmailPort
49
+ mediaFiles?: MediaFileStore
50
+ fetch?: typeof fetch
51
+ nodeState?: NodeSchemaState
52
+ nodeId?: string
53
+ /** Told when a workflow published something, so the cache is dropped. */
54
+ onContentChanged?: () => void
55
+ }
56
+
57
+ export type WorkflowResult =
58
+ | { ok: true; detail?: string }
59
+ /** `retry` false means trying again cannot help until something changes. */
60
+ | { ok: false; error: string; retry: boolean }
61
+
62
+ // ------------------------------------------------------------------ substitution
63
+
64
+ const firstValue = (values: readonly FormValueToStore[], alias: string): string =>
65
+ values
66
+ .find((value) => value.fieldAlias.toLowerCase() === alias.toLowerCase())
67
+ ?.values.join(', ') ?? ''
68
+
69
+ /**
70
+ * `{fieldAlias}` in a workflow setting becomes what was submitted for it.
71
+ *
72
+ * This is all that is left of Umbraco Forms' magic strings, and deliberately:
73
+ * there the seven syntaxes exist because Razor cannot reach into the record. A
74
+ * view here is TSX and reads the model directly, so the only place substitution
75
+ * is needed is a workflow's own settings.
76
+ */
77
+ export function substitute(text: string, values: readonly FormValueToStore[]): string {
78
+ return text.replace(/\{([A-Za-z][A-Za-z0-9]*)\}/g, (whole, alias: string) => {
79
+ const found = values.find((value) => value.fieldAlias.toLowerCase() === alias.toLowerCase())
80
+ // An unknown alias is left as it was written, so a stray brace in a subject
81
+ // line reads as itself rather than vanishing.
82
+ return found ? found.values.join(', ') : whole
83
+ })
84
+ }
85
+
86
+ const asString = (value: unknown): string | undefined =>
87
+ typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined
88
+
89
+ const asList = (value: unknown): string[] =>
90
+ typeof value === 'string'
91
+ ? value
92
+ .split(',')
93
+ .map((part) => part.trim())
94
+ .filter(Boolean)
95
+ : []
96
+
97
+ /** The submission as a reader sees it: caption, then what was entered. */
98
+ export function plainSummary(context: WorkflowContext): string {
99
+ const captions = new Map(
100
+ allFormFields(context.form).map((field) => [field.alias, field.caption || field.alias]),
101
+ )
102
+ const lines = context.values.map(
103
+ (value) =>
104
+ `${captions.get(value.fieldAlias) ?? value.fieldAlias}: ${
105
+ value.values.length > 0 ? value.values.join(', ') : '(not answered)'
106
+ }`,
107
+ )
108
+ return [`${context.form.name}`, '', ...lines].join('\n')
109
+ }
110
+
111
+ // ------------------------------------------------------------------ send email
112
+
113
+ async function sendEmail(
114
+ workflow: SchemaFormWorkflow,
115
+ context: WorkflowContext,
116
+ deps: WorkflowDeps,
117
+ ): Promise<WorkflowResult> {
118
+ if (!deps.email)
119
+ return {
120
+ ok: false,
121
+ // Retrying cannot help: this is configuration, not weather.
122
+ retry: false,
123
+ error:
124
+ 'This site has no e-mail provider, so the workflow cannot send. Configure CROMBOLO_EMAIL_PROVIDER, or `email` in crombolo.config.ts.',
125
+ }
126
+
127
+ const settings = workflow.settings
128
+ const to = asList(substitute(asString(settings.to) ?? '', context.values))
129
+ if (to.length === 0) return { ok: false, retry: false, error: 'The workflow has no recipient.' }
130
+
131
+ const attachments: EmailAttachment[] = []
132
+ if (settings['attach-uploads'] === true && deps.mediaFiles) {
133
+ const uploadAliases = new Set(
134
+ allFormFields(context.form)
135
+ .filter((field) => field.type === 'fileUpload')
136
+ .map((field) => field.alias),
137
+ )
138
+ for (const value of context.values) {
139
+ if (!uploadAliases.has(value.fieldAlias)) continue
140
+ for (const src of value.values) {
141
+ const file = await deps.mediaFiles.open(src)
142
+ if (!file) continue
143
+ attachments.push({
144
+ filename: src.split('/').pop() ?? 'attachment',
145
+ contentType: file.contentType,
146
+ bytes: await file.bytes(),
147
+ })
148
+ }
149
+ }
150
+ }
151
+
152
+ const result = await deps.email.send({
153
+ to: to.map((email) => ({ email })),
154
+ cc: asList(asString(settings.cc) ?? '').map((email) => ({ email })),
155
+ bcc: asList(asString(settings.bcc) ?? '').map((email) => ({ email })),
156
+ replyTo: (() => {
157
+ const replyTo = asString(settings['reply-to'])
158
+ const resolved = replyTo ? substitute(replyTo, context.values) : undefined
159
+ return resolved ? { email: resolved } : undefined
160
+ })(),
161
+ from: (() => {
162
+ const from = asString(settings.from)
163
+ return from ? { email: from } : undefined
164
+ })(),
165
+ subject: substitute(asString(settings.subject) ?? context.form.name, context.values),
166
+ text: settings.body ? substitute(String(settings.body), context.values) : plainSummary(context),
167
+ attachments: attachments.length > 0 ? attachments : undefined,
168
+ })
169
+ // A provider that refused is worth another go: a 500 or a timeout is weather.
170
+ return result.ok ? { ok: true } : { ok: false, retry: true, error: result.error }
171
+ }
172
+
173
+ // ------------------------------------------------------------------ save as content
174
+
175
+ async function saveAsContent(
176
+ workflow: SchemaFormWorkflow,
177
+ context: WorkflowContext,
178
+ deps: WorkflowDeps,
179
+ ): Promise<WorkflowResult> {
180
+ const settings = workflow.settings
181
+ const alias = asString(settings['content-type'])
182
+ if (!alias) return { ok: false, retry: false, error: 'The workflow names no content type.' }
183
+
184
+ const types = new ContentTypeRepository(deps.db)
185
+ const type = await types.byAlias(alias)
186
+ if (!type) return { ok: false, retry: false, error: `There is no content type '${alias}'.` }
187
+
188
+ const parent = asString(settings.parent)
189
+ const map = (
190
+ settings.map && typeof settings.map === 'object' && !Array.isArray(settings.map)
191
+ ? (settings.map as Record<string, unknown>)
192
+ : {}
193
+ ) as Record<string, string>
194
+
195
+ const nameField = asString(settings['name-field'])
196
+ const name =
197
+ (nameField ? firstValue(context.values, nameField) : '') ||
198
+ `${context.form.name} ${new Date().toISOString().slice(0, 16).replace('T', ' ')}`
199
+
200
+ const documents = new DocumentRepository(deps.db, {
201
+ nodeState: deps.nodeState,
202
+ nodeId: deps.nodeId,
203
+ })
204
+ try {
205
+ const created = await documents.create({
206
+ key: crypto.randomUUID(),
207
+ contentTypeKey: type.key,
208
+ componentKey: null,
209
+ // `root` or nothing both mean the top of the tree.
210
+ parentKey: parent && parent !== 'root' ? parent : null,
211
+ values: Object.entries(map).map(([fieldAlias, propertyAlias]) => ({
212
+ alias: propertyAlias,
213
+ culture: null,
214
+ segment: null,
215
+ value: firstValue(context.values, fieldAlias),
216
+ })),
217
+ variants: [{ culture: null, segment: null, name }],
218
+ })
219
+ if (settings.publish === true) {
220
+ await documents.publish(created.key, null)
221
+ deps.onContentChanged?.()
222
+ }
223
+ return { ok: true, detail: created.key }
224
+ } catch (error) {
225
+ const message = error instanceof Error ? error.message : String(error)
226
+ // A node behind the schema cannot write; that resolves on its own when the
227
+ // deploy finishes, so it is worth retrying. Anything else is not.
228
+ const retry = message.includes('may not write') || message.includes('not published')
229
+ return { ok: false, retry, error: message }
230
+ }
231
+ }
232
+
233
+ // ------------------------------------------------------------------ send to url
234
+
235
+ async function sendToUrl(
236
+ workflow: SchemaFormWorkflow,
237
+ context: WorkflowContext,
238
+ deps: WorkflowDeps,
239
+ ): Promise<WorkflowResult> {
240
+ const settings = workflow.settings
241
+ const url = asString(settings.url)
242
+ if (!url) return { ok: false, retry: false, error: 'The workflow names no URL.' }
243
+ const method = settings.method === 'PUT' ? 'PUT' : 'POST'
244
+ const headers =
245
+ settings.headers && typeof settings.headers === 'object' && !Array.isArray(settings.headers)
246
+ ? (settings.headers as Record<string, string>)
247
+ : {}
248
+
249
+ const fields: Record<string, string | string[]> = {}
250
+ for (const value of context.values)
251
+ fields[value.fieldAlias] =
252
+ value.values.length === 1 ? (value.values[0] as string) : value.values
253
+
254
+ const body: Record<string, unknown> = { fields }
255
+ if (settings['include-standard-fields'] !== false) {
256
+ body.form = context.form.alias
257
+ body.formName = context.form.name
258
+ body.entryId = context.entryId
259
+ body.submittedAt = new Date().toISOString()
260
+ }
261
+
262
+ const call = deps.fetch ?? fetch
263
+ try {
264
+ const response = await call(url, {
265
+ method,
266
+ headers: { 'content-type': 'application/json', ...headers },
267
+ body: JSON.stringify(body),
268
+ })
269
+ if (response.ok) return { ok: true, detail: String(response.status) }
270
+ const detail = (await response.text().catch(() => '')).trim().slice(0, 300)
271
+ // A 4xx will not fix itself; a 5xx might.
272
+ return {
273
+ ok: false,
274
+ retry: response.status >= 500,
275
+ error: `the endpoint answered ${response.status}${detail ? `: ${detail}` : ''}`,
276
+ }
277
+ } catch (error) {
278
+ return {
279
+ ok: false,
280
+ retry: true,
281
+ error: error instanceof Error ? error.message : String(error),
282
+ }
283
+ }
284
+ }
285
+
286
+ // ------------------------------------------------------------------ the runner
287
+
288
+ export async function runWorkflow(
289
+ workflow: SchemaFormWorkflow,
290
+ context: WorkflowContext,
291
+ deps: WorkflowDeps,
292
+ ): Promise<WorkflowResult> {
293
+ switch (workflow.type) {
294
+ case 'sendEmail':
295
+ return sendEmail(workflow, context, deps)
296
+ case 'saveAsContent':
297
+ return saveAsContent(workflow, context, deps)
298
+ case 'sendToUrl':
299
+ return sendToUrl(workflow, context, deps)
300
+ }
301
+ }
302
+
303
+ export interface WorkflowRunReport {
304
+ claimed: number
305
+ done: number
306
+ retrying: number
307
+ failed: number
308
+ }
309
+
310
+ export interface WorkflowRunner {
311
+ /** Runs what is due. Called by the job timer, and directly by tests. */
312
+ runDue(now?: Date, limit?: number): Promise<WorkflowRunReport>
313
+ }
314
+
315
+ /** When to try again after this many attempts, or undefined to give up. */
316
+ export function retryAfter(attempts: number, now: Date): Date | undefined {
317
+ if (attempts >= MAX_WORKFLOW_ATTEMPTS) return undefined
318
+ const delay = BACKOFF_MS[Math.min(attempts - 1, BACKOFF_MS.length - 1)] ?? 60_000
319
+ return new Date(now.getTime() + delay)
320
+ }
321
+
322
+ export function createWorkflowRunner(
323
+ deps: WorkflowDeps & { forms: { byKey(key: string): SchemaForm | undefined } },
324
+ ): WorkflowRunner {
325
+ const runs = new FormWorkflowRepository(deps.db)
326
+
327
+ const execute = async (run: FormWorkflowRun, now: Date): Promise<keyof WorkflowRunReport> => {
328
+ const form = deps.forms.byKey(run.formKey)
329
+ if (!form) {
330
+ // The definition is a file, and it is gone. There is nothing to run and
331
+ // nothing to retry, so the row records why rather than sitting pending.
332
+ await runs.fail(run.id, `The form '${run.formAlias}' is no longer in schema/forms/.`)
333
+ return 'failed'
334
+ }
335
+ const workflow = form.workflows.find(
336
+ (candidate) => candidate.name.toLowerCase() === run.workflowName.toLowerCase(),
337
+ )
338
+ if (!workflow) {
339
+ await runs.fail(run.id, `'${run.workflowName}' is no longer a workflow on this form.`)
340
+ return 'failed'
341
+ }
342
+
343
+ const context: WorkflowContext = {
344
+ form,
345
+ values: run.payload,
346
+ entryId: run.entryId,
347
+ }
348
+ let result: WorkflowResult
349
+ try {
350
+ result = await runWorkflow(workflow, context, deps)
351
+ } catch (error) {
352
+ // A workflow that throws is a bug, not a refusal; it is still retried,
353
+ // because the usual cause is something transient underneath it.
354
+ result = {
355
+ ok: false,
356
+ retry: true,
357
+ error: error instanceof Error ? (error.stack ?? error.message) : String(error),
358
+ }
359
+ }
360
+
361
+ if (result.ok) {
362
+ await runs.succeed(run.id)
363
+ return 'done'
364
+ }
365
+ const again = result.retry ? retryAfter(run.attempts, now) : undefined
366
+ await runs.fail(run.id, result.error, again)
367
+ if (again) {
368
+ log.warning("'{workflow}' on {form} failed and will be retried: {detail}", {
369
+ workflow: run.workflowName,
370
+ form: run.formAlias,
371
+ detail: result.error,
372
+ })
373
+ return 'retrying'
374
+ }
375
+ log.error("'{workflow}' on {form} failed: {detail}", {
376
+ workflow: run.workflowName,
377
+ form: run.formAlias,
378
+ detail: result.error,
379
+ })
380
+ return 'failed'
381
+ }
382
+
383
+ return {
384
+ async runDue(now = new Date(), limit = 25) {
385
+ const report: WorkflowRunReport = { claimed: 0, done: 0, retrying: 0, failed: 0 }
386
+ for (const due of await runs.due(now, limit)) {
387
+ // Another node may have taken it between the read and here, which is
388
+ // exactly what the claim is for.
389
+ const claimed = await runs.claim(due.id)
390
+ if (!claimed) continue
391
+ report.claimed++
392
+ report[await execute(claimed, now)]++
393
+ }
394
+ return report
395
+ },
396
+ }
397
+ }
package/src/forms.ts ADDED
@@ -0,0 +1,170 @@
1
+ /**
2
+ * What the backoffice reads about forms. `docs/forms.md`.
3
+ *
4
+ * Non-contract, under the plugin path, because there is no form operation in
5
+ * the vendored OpenAPI document — Umbraco Forms ships its own API and its own
6
+ * client, and neither is here.
7
+ *
8
+ * Definitions come from the loaded schema rather than the database: the files
9
+ * are the truth, and reading them here means the picker cannot disagree with
10
+ * what a render would use.
11
+ */
12
+
13
+ import type { FormEntryState } from '@crombolo/data'
14
+ import { allFormFields, type SchemaForm, storingFormFields } from '@crombolo/schema'
15
+
16
+ /** A form as the picker and the tree list it. */
17
+ export interface FormSummary {
18
+ key: string
19
+ alias: string
20
+ name: string
21
+ /** How many fields collect something, which is the useful size of a form. */
22
+ fieldCount: number
23
+ pageCount: number
24
+ storeEntries: boolean
25
+ requiresApproval: boolean
26
+ workflowCount: number
27
+ }
28
+
29
+ export function formSummary(form: SchemaForm): FormSummary {
30
+ return {
31
+ // A form with no key in its file still has to be addressable, and its alias
32
+ // is unique within the set, so it stands in until a key is written.
33
+ key: form.key ?? form.alias,
34
+ alias: form.alias,
35
+ name: form.name,
36
+ fieldCount: storingFormFields(form).length,
37
+ pageCount: form.pages.length,
38
+ storeEntries: form.storeEntries,
39
+ requiresApproval: form.requiresApproval,
40
+ workflowCount: form.workflows.length,
41
+ }
42
+ }
43
+
44
+ /** The full definition, for the designer and for an entries view's columns. */
45
+ export function formDetail(form: SchemaForm): Record<string, unknown> {
46
+ return {
47
+ ...formSummary(form),
48
+ notes: form.notes ?? null,
49
+ theme: form.theme ?? null,
50
+ submitLabel: form.submitLabel ?? null,
51
+ messageOnSubmit: form.messageOnSubmit ?? null,
52
+ redirectTo: form.redirectTo ?? null,
53
+ honeypot: form.honeypot,
54
+ maxEntries: form.maxEntries ?? null,
55
+ fields: allFormFields(form).map((field) => ({
56
+ alias: field.alias,
57
+ type: field.type,
58
+ caption: field.caption,
59
+ mandatory: field.mandatory,
60
+ sensitive: field.sensitive,
61
+ values: field.values,
62
+ })),
63
+ workflows: form.workflows.map((workflow) => ({
64
+ type: workflow.type,
65
+ name: workflow.name,
66
+ on: workflow.on,
67
+ })),
68
+ }
69
+ }
70
+
71
+ /** Which forms a site has, by key, so an entry can be matched to its definition. */
72
+ export function formsByKey(forms: readonly SchemaForm[]): Map<string, SchemaForm> {
73
+ const out = new Map<string, SchemaForm>()
74
+ for (const form of forms) out.set(form.key ?? form.alias, form)
75
+ return out
76
+ }
77
+
78
+ /**
79
+ * Redacts the values of sensitive fields unless the reader may see them.
80
+ *
81
+ * Done here rather than in the UI: the values must not reach a browser that is
82
+ * not allowed them, so the redaction is on the way out of the server.
83
+ */
84
+ export function redactSensitive(
85
+ entry: { values: { fieldAlias: string; values: string[] }[] },
86
+ form: SchemaForm | undefined,
87
+ maySeeSensitive: boolean,
88
+ ): { fieldAlias: string; values: string[]; redacted: boolean }[] {
89
+ const sensitive = new Set(
90
+ (form ? allFormFields(form) : [])
91
+ .filter((field) => field.sensitive)
92
+ .map((field) => field.alias.toLowerCase()),
93
+ )
94
+ return entry.values.map((value) => {
95
+ const hide = !maySeeSensitive && sensitive.has(value.fieldAlias.toLowerCase())
96
+ return {
97
+ fieldAlias: value.fieldAlias,
98
+ values: hide ? [] : value.values,
99
+ redacted: hide,
100
+ }
101
+ })
102
+ }
103
+
104
+ /** The states the entries view may filter by, validated off the query string. */
105
+ export function parseEntryState(value: string | null): FormEntryState | undefined {
106
+ return value === 'submitted' || value === 'approved' || value === 'rejected' ? value : undefined
107
+ }
108
+
109
+ // ------------------------------------------------------------------ export
110
+
111
+ /** A CSV cell: quoted when it has to be, and never able to start a formula. */
112
+ function csvCell(value: string): string {
113
+ // A leading =, +, - or @ makes a spreadsheet treat the cell as a formula, so
114
+ // an exported answer could run when somebody opens the file.
115
+ const safe = /^[=+\-@\t\r]/.test(value) ? `'${value}` : value
116
+ return /[",\n\r]/.test(safe) ? `"${safe.replaceAll('"', '""')}"` : safe
117
+ }
118
+
119
+ export interface EntriesCsvOptions {
120
+ /** Without it, a sensitive column is refused rather than silently blank. */
121
+ includeSensitive: boolean
122
+ }
123
+
124
+ export type EntriesCsvResult =
125
+ | { ok: true; csv: string; fileName: string }
126
+ | { ok: false; reason: string }
127
+
128
+ /**
129
+ * A form's entries as CSV, one row per entry.
130
+ *
131
+ * A sensitive field makes the whole export refuse rather than exporting blanks:
132
+ * a file that looks complete and quietly is not is worse than an error, because
133
+ * somebody will act on it. The permission, not the column, is the thing to fix.
134
+ */
135
+ export function entriesCsv(
136
+ form: SchemaForm,
137
+ entries: readonly {
138
+ createDate: Date
139
+ state: string
140
+ spam: boolean
141
+ values: { fieldAlias: string; values: string[] }[]
142
+ }[],
143
+ options: EntriesCsvOptions,
144
+ ): EntriesCsvResult {
145
+ const fields = storingFormFields(form)
146
+ const sensitive = fields.filter((field) => field.sensitive)
147
+ if (sensitive.length > 0 && !options.includeSensitive)
148
+ return {
149
+ ok: false,
150
+ reason: `This form has sensitive fields (${sensitive
151
+ .map((field) => field.caption || field.alias)
152
+ .join(', ')}), so exporting it needs sensitive-data access.`,
153
+ }
154
+
155
+ const header = ['Submitted', 'State', 'Spam', ...fields.map((f) => f.caption || f.alias)]
156
+ const rows = entries.map((entry) => [
157
+ entry.createDate.toISOString(),
158
+ entry.state,
159
+ String(entry.spam),
160
+ ...fields.map((field) =>
161
+ (entry.values.find((value) => value.fieldAlias === field.alias)?.values ?? []).join('; '),
162
+ ),
163
+ ])
164
+ return {
165
+ ok: true,
166
+ // CRLF, which is what the format says and what Excel wants.
167
+ csv: [header, ...rows].map((row) => row.map(csvCell).join(',')).join('\r\n'),
168
+ fileName: `${form.alias}-entries.csv`,
169
+ }
170
+ }