@crombolo/server 0.10.0 → 0.12.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.
- package/package.json +12 -11
- package/src/adapters/audit.ts +186 -0
- package/src/adapters/content.ts +2 -0
- package/src/adapters/documents.ts +28 -25
- package/src/adapters/elements.ts +3 -0
- package/src/adapters/health.ts +333 -0
- package/src/adapters/media.ts +3 -0
- package/src/adapters/members.ts +10 -2
- package/src/adapters/references.ts +13 -0
- package/src/adapters/relations.ts +48 -0
- package/src/adapters/schema-files.ts +41 -11
- package/src/adapters/schema-proposals.ts +13 -6
- package/src/adapters/search-index.ts +31 -0
- package/src/adapters/webhooks.ts +76 -0
- package/src/boot-failure.ts +147 -0
- package/src/bundle-install.ts +148 -13
- package/src/bundles.ts +14 -0
- package/src/busy.ts +13 -0
- package/src/config.ts +46 -2
- package/src/database.ts +9 -7
- package/src/delivery-members.ts +387 -0
- package/src/editor-types.ts +7 -2
- package/src/extensions-notice.ts +3 -2
- package/src/form-files.ts +38 -3
- package/src/form-submit.ts +62 -5
- package/src/form-types.ts +187 -0
- package/src/form-workflows.ts +66 -6
- package/src/forms.ts +15 -7
- package/src/index.ts +12 -0
- package/src/jobs.ts +91 -10
- package/src/media-files.ts +3 -1
- package/src/member-auth.ts +17 -1
- package/src/models-builder.ts +12 -8
- package/src/ports.ts +57 -2
- package/src/schema.ts +58 -11
- package/src/secrets.ts +36 -0
- package/src/server.ts +388 -29
- package/src/setup.ts +288 -0
- package/src/status.ts +5 -2
- package/src/transfer-dashboard.ts +219 -0
- package/src/upgrade-summary.ts +43 -0
- package/src/url-settings.ts +91 -0
- package/src/webhook-files.ts +138 -0
- package/src/webhooks.ts +544 -0
package/src/form-files.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
|
14
14
|
import { join } from 'node:path'
|
|
15
|
-
import type { SchemaForm } from '@crombolo/core'
|
|
15
|
+
import type { FormTypes, SchemaForm } from '@crombolo/core'
|
|
16
16
|
import {
|
|
17
17
|
fileNameFor,
|
|
18
18
|
loadSchemaDirectory,
|
|
@@ -28,6 +28,8 @@ export interface FormFileOptions {
|
|
|
28
28
|
writable: boolean
|
|
29
29
|
/** Publishes the directory to a shared store, when there is one. */
|
|
30
30
|
onChanged?: () => Promise<void>
|
|
31
|
+
/** The field and workflow types the site's wired bundles add. */
|
|
32
|
+
formTypes?: FormTypes
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
export type FormWriteResult =
|
|
@@ -77,6 +79,7 @@ export function coerceForm(value: unknown): SchemaForm {
|
|
|
77
79
|
notes: text('notes'),
|
|
78
80
|
storeEntries: flag('storeEntries', true),
|
|
79
81
|
requiresApproval: flag('requiresApproval', false),
|
|
82
|
+
...(body.stepPages === true ? { stepPages: true } : {}),
|
|
80
83
|
submitLabel: text('submitLabel'),
|
|
81
84
|
nextLabel: text('nextLabel'),
|
|
82
85
|
previousLabel: text('previousLabel'),
|
|
@@ -140,6 +143,8 @@ function coerceField(value: unknown): Field {
|
|
|
140
143
|
cssClass: text('cssClass'),
|
|
141
144
|
autocomplete: text('autocomplete'),
|
|
142
145
|
values: strings(body.values),
|
|
146
|
+
...coerceSource(body.source),
|
|
147
|
+
...coerceSettings(body.settings),
|
|
143
148
|
placeholder: text('placeholder'),
|
|
144
149
|
defaultValue: text('defaultValue'),
|
|
145
150
|
content: text('content'),
|
|
@@ -155,6 +160,35 @@ function coerceField(value: unknown): Field {
|
|
|
155
160
|
}
|
|
156
161
|
}
|
|
157
162
|
|
|
163
|
+
/** Kept as sent: the validator names a kind it does not know. */
|
|
164
|
+
/** A bundle type's settings, kept as the designer sent them; the validator holds them to the declarations. */
|
|
165
|
+
function coerceSettings(value: unknown): { settings?: Field['settings'] } {
|
|
166
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
|
|
167
|
+
const settings: NonNullable<Field['settings']> = {}
|
|
168
|
+
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
|
|
169
|
+
if (entry === '' || entry === null || entry === undefined) continue
|
|
170
|
+
if (
|
|
171
|
+
typeof entry === 'string' ||
|
|
172
|
+
typeof entry === 'boolean' ||
|
|
173
|
+
(typeof entry === 'number' && Number.isFinite(entry))
|
|
174
|
+
)
|
|
175
|
+
settings[key] = entry
|
|
176
|
+
else if (Array.isArray(entry)) settings[key] = entry.filter((item) => typeof item === 'string')
|
|
177
|
+
}
|
|
178
|
+
return Object.keys(settings).length > 0 ? { settings } : {}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function coerceSource(value: unknown): { source?: Field['source'] } {
|
|
182
|
+
if (!value || typeof value !== 'object') return {}
|
|
183
|
+
const body = value as Record<string, unknown>
|
|
184
|
+
return {
|
|
185
|
+
source: {
|
|
186
|
+
kind: String(body.kind ?? '') as NonNullable<Field['source']>['kind'],
|
|
187
|
+
parent: typeof body.parent === 'string' ? body.parent : '',
|
|
188
|
+
},
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
158
192
|
function coerceCondition(value: unknown): Field['condition'] {
|
|
159
193
|
if (!value || typeof value !== 'object') return undefined
|
|
160
194
|
const body = value as Record<string, unknown>
|
|
@@ -196,18 +230,19 @@ export async function saveForm(
|
|
|
196
230
|
// Written then read back with the strict parser, so the file the designer
|
|
197
231
|
// would produce is held to exactly what a hand-written one is.
|
|
198
232
|
const name = formFileName(candidate.alias || 'form')
|
|
199
|
-
const reparsed = parseForm(`schema/forms/${name}`, writeForm(candidate))
|
|
233
|
+
const reparsed = parseForm(`schema/forms/${name}`, writeForm(candidate), options.formTypes)
|
|
200
234
|
if (!reparsed.value) return { ok: false, status: 'invalid', problems: reparsed.problems }
|
|
201
235
|
const form = reparsed.value
|
|
202
236
|
|
|
203
237
|
// Against the whole set, because an alias has to be unique across forms and a
|
|
204
238
|
// workflow is held to a content type that exists.
|
|
205
|
-
const loaded = loadSchemaDirectory(options.schemaDir)
|
|
239
|
+
const loaded = loadSchemaDirectory(options.schemaDir, { formTypes: options.formTypes })
|
|
206
240
|
const others = (loaded.set.forms ?? []).filter(
|
|
207
241
|
(candidate) => candidate.alias.toLowerCase() !== form.alias.toLowerCase(),
|
|
208
242
|
)
|
|
209
243
|
const problems = validateForms([...others, form], {
|
|
210
244
|
contentTypeAliases: new Set(loaded.set.contentTypes.map((type) => type.alias)),
|
|
245
|
+
types: options.formTypes,
|
|
211
246
|
})
|
|
212
247
|
if (problems.length > 0) return { ok: false, status: 'invalid', problems }
|
|
213
248
|
|
package/src/form-submit.ts
CHANGED
|
@@ -18,13 +18,16 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import {
|
|
20
20
|
allFormFields,
|
|
21
|
+
BUILT_IN_FORM_TYPES,
|
|
21
22
|
DEFAULT_UPLOAD_SETTINGS,
|
|
22
23
|
FORM_HONEYPOT_FIELD,
|
|
24
|
+
FORM_PAGE_FIELD,
|
|
23
25
|
FORM_RENDERED_FIELD,
|
|
24
26
|
FORM_RESERVED_FIELDS,
|
|
25
27
|
FORM_TOKEN_FIELD,
|
|
26
28
|
type FormFieldError,
|
|
27
29
|
type FormSubmissionState,
|
|
30
|
+
type FormTypes,
|
|
28
31
|
isUploadAllowed,
|
|
29
32
|
type SchemaForm,
|
|
30
33
|
type SubmittedValues,
|
|
@@ -58,6 +61,8 @@ export interface FormRegistry {
|
|
|
58
61
|
byKey(key: string): SchemaForm | undefined
|
|
59
62
|
/** Drops the memo, for a test or after a schema write. */
|
|
60
63
|
invalidate(): void
|
|
64
|
+
/** The field and workflow types the forms were read with. */
|
|
65
|
+
readonly types: FormTypes
|
|
61
66
|
}
|
|
62
67
|
|
|
63
68
|
/**
|
|
@@ -70,8 +75,9 @@ export interface FormRegistry {
|
|
|
70
75
|
*/
|
|
71
76
|
export function createFormRegistry(
|
|
72
77
|
schemaDir: string,
|
|
73
|
-
options: { ttlMs?: number } = {},
|
|
78
|
+
options: { ttlMs?: number; formTypes?: FormTypes } = {},
|
|
74
79
|
): FormRegistry {
|
|
80
|
+
const types = options.formTypes ?? BUILT_IN_FORM_TYPES
|
|
75
81
|
const ttl = options.ttlMs ?? 1000
|
|
76
82
|
let at = 0
|
|
77
83
|
let forms: readonly SchemaForm[] = []
|
|
@@ -80,7 +86,7 @@ export function createFormRegistry(
|
|
|
80
86
|
const refresh = () => {
|
|
81
87
|
if (at !== 0 && Date.now() - at < ttl) return
|
|
82
88
|
try {
|
|
83
|
-
forms = loadSchemaDirectory(schemaDir).set.forms ?? []
|
|
89
|
+
forms = loadSchemaDirectory(schemaDir, { formTypes: types }).set.forms ?? []
|
|
84
90
|
byKey = new Map(forms.map((form) => [form.key ?? form.alias, form]))
|
|
85
91
|
} catch (error) {
|
|
86
92
|
// A broken schema directory fails the boot, so reaching here means it
|
|
@@ -105,6 +111,7 @@ export function createFormRegistry(
|
|
|
105
111
|
invalidate() {
|
|
106
112
|
at = 0
|
|
107
113
|
},
|
|
114
|
+
types,
|
|
108
115
|
}
|
|
109
116
|
}
|
|
110
117
|
|
|
@@ -206,6 +213,8 @@ export interface FormSubmitDeps {
|
|
|
206
213
|
uploads?: UploadSettings
|
|
207
214
|
/** Resolves a document key to the URL to send the visitor to. */
|
|
208
215
|
urlOf?: (key: string) => Promise<string | undefined>
|
|
216
|
+
/** Fills in the options of choices drawn from a source, as the page rendered them. */
|
|
217
|
+
resolveForm?: (form: SchemaForm) => Promise<SchemaForm>
|
|
209
218
|
}
|
|
210
219
|
|
|
211
220
|
export type FormSubmitOutcome =
|
|
@@ -213,6 +222,8 @@ export type FormSubmitOutcome =
|
|
|
213
222
|
| { status: 'badToken' }
|
|
214
223
|
| { status: 'full'; form: SchemaForm }
|
|
215
224
|
| { status: 'invalid'; form: SchemaForm; state: FormSubmissionState }
|
|
225
|
+
/** One page of a stepped form checked; nothing was stored, uploaded or run. */
|
|
226
|
+
| { status: 'checked'; form: SchemaForm; errors: FormFieldError[] }
|
|
216
227
|
| {
|
|
217
228
|
status: 'ok'
|
|
218
229
|
form: SchemaForm
|
|
@@ -255,8 +266,9 @@ export async function submitForm(
|
|
|
255
266
|
formKey: string,
|
|
256
267
|
options: { ip?: string; now?: number } = {},
|
|
257
268
|
): Promise<FormSubmitOutcome> {
|
|
258
|
-
const
|
|
259
|
-
if (!
|
|
269
|
+
const definition = deps.forms.byKey(formKey)
|
|
270
|
+
if (!definition) return { status: 'notFound' }
|
|
271
|
+
const form = deps.resolveForm ? await deps.resolveForm(definition) : definition
|
|
260
272
|
|
|
261
273
|
const secret = await formSigningSecret(deps.db)
|
|
262
274
|
const data = await request.formData()
|
|
@@ -268,6 +280,10 @@ export async function submitForm(
|
|
|
268
280
|
// submission without a good one did not come from a page this site rendered.
|
|
269
281
|
if (!verifyFormToken(secret, formKey, renderedAt, token)) return { status: 'badToken' }
|
|
270
282
|
|
|
283
|
+
// A stepped form moving forward: this page's answers checked, and nothing else.
|
|
284
|
+
const checking = data.get(FORM_PAGE_FIELD)
|
|
285
|
+
if (checking !== null) return checkPage(deps, form, data, Number(checking))
|
|
286
|
+
|
|
271
287
|
// Two guards, both silent. A bot is told the submission worked, because
|
|
272
288
|
// telling it otherwise is how it learns to get past them.
|
|
273
289
|
const honeypot = String(data.get(FORM_HONEYPOT_FIELD) ?? '').trim() !== ''
|
|
@@ -317,7 +333,7 @@ export async function submitForm(
|
|
|
317
333
|
if (placed.length > 0) submitted[field.alias] = placed
|
|
318
334
|
}
|
|
319
335
|
|
|
320
|
-
const result = validateSubmission(form, submitted)
|
|
336
|
+
const result = validateSubmission(form, submitted, { types: deps.forms.types })
|
|
321
337
|
// What the file rules refused, which core cannot know: it has no notion of a
|
|
322
338
|
// file's bytes or of what a server could be made to execute.
|
|
323
339
|
result.errors.push(...refusedUploads)
|
|
@@ -388,6 +404,47 @@ export async function submitForm(
|
|
|
388
404
|
}
|
|
389
405
|
}
|
|
390
406
|
|
|
407
|
+
/**
|
|
408
|
+
* One page of a stepped form, checked the way the final submission will be.
|
|
409
|
+
* An upload is judged by its name and size and then dropped: it is written
|
|
410
|
+
* nowhere until the form is submitted.
|
|
411
|
+
*/
|
|
412
|
+
function checkPage(
|
|
413
|
+
deps: FormSubmitDeps,
|
|
414
|
+
form: SchemaForm,
|
|
415
|
+
data: FormData,
|
|
416
|
+
page: number,
|
|
417
|
+
): FormSubmitOutcome {
|
|
418
|
+
const fields = form.pages[page]?.groups.flatMap((group) => group.fields)
|
|
419
|
+
if (!fields) return { status: 'checked', form, errors: [] }
|
|
420
|
+
const onPage = new Set(fields.map((field) => field.alias))
|
|
421
|
+
const submitted = readBody(data)
|
|
422
|
+
const errors: FormFieldError[] = []
|
|
423
|
+
for (const field of fields.filter((candidate) => candidate.type === 'fileUpload')) {
|
|
424
|
+
const files = data.getAll(field.alias).filter((value): value is File => value instanceof File)
|
|
425
|
+
const name = field.caption || field.alias
|
|
426
|
+
const named: string[] = []
|
|
427
|
+
for (const file of files) {
|
|
428
|
+
if (file.size === 0) continue
|
|
429
|
+
if (!isUploadAllowed(file.name, deps.uploads ?? DEFAULT_UPLOAD_SETTINGS))
|
|
430
|
+
errors.push({
|
|
431
|
+
field: field.alias,
|
|
432
|
+
message: `${name} cannot be a ${extensionOf(file.name)} file`,
|
|
433
|
+
})
|
|
434
|
+
else if (field.maxSizeKb !== undefined && file.size > field.maxSizeKb * 1024)
|
|
435
|
+
errors.push({
|
|
436
|
+
field: field.alias,
|
|
437
|
+
message: `${name} must be smaller than ${field.maxSizeKb}KB`,
|
|
438
|
+
})
|
|
439
|
+
else named.push(file.name)
|
|
440
|
+
}
|
|
441
|
+
submitted[field.alias] = named
|
|
442
|
+
}
|
|
443
|
+
const result = validateSubmission(form, submitted, { page, types: deps.forms.types })
|
|
444
|
+
errors.push(...result.errors.filter((error) => onPage.has(error.field)))
|
|
445
|
+
return { status: 'checked', form, errors }
|
|
446
|
+
}
|
|
447
|
+
|
|
391
448
|
/** Whether the caller wants JSON rather than a redirect back to the page. */
|
|
392
449
|
export function wantsJson(request: Request): boolean {
|
|
393
450
|
if (request.headers.get('x-requested-with') === 'fetch') return true
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The public contract for a form field type or workflow type a server bundle
|
|
3
|
+
* adds (story 095, `docs/forms.md`, "Types from a bundle"). A bundle exports
|
|
4
|
+
* definitions; `collectFormTypes` turns the wired bundles' into the registry
|
|
5
|
+
* the parser, validator, renderer and runner are handed.
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
BUILT_IN_FORM_TYPES,
|
|
9
|
+
type FormSettingDeclaration,
|
|
10
|
+
type FormSettingValue,
|
|
11
|
+
type FormTypes,
|
|
12
|
+
formTypesWith,
|
|
13
|
+
} from '@crombolo/core'
|
|
14
|
+
import type { FormFieldRenderer } from '@crombolo/render'
|
|
15
|
+
import type { EmailMessage, EmailResult } from './email.ts'
|
|
16
|
+
|
|
17
|
+
export type { FormFieldRenderer, FormFieldRenderProps } from '@crombolo/render'
|
|
18
|
+
export type { FormSettingDeclaration, FormSettingValue }
|
|
19
|
+
|
|
20
|
+
/** A field type: what its settings are, how its value is checked, and how it draws. */
|
|
21
|
+
export interface FormFieldTypeDefinition {
|
|
22
|
+
/** camelCase, unique across the built-ins and every wired bundle. */
|
|
23
|
+
type: string
|
|
24
|
+
/** What the designer's type list calls it. */
|
|
25
|
+
label: string
|
|
26
|
+
/**
|
|
27
|
+
* Which built-in kind it stores as, which decides coercion and the entry: a
|
|
28
|
+
* value is always a list of strings, so entries, exports and workflows read it
|
|
29
|
+
* as they read any other field. `none` collects nothing, as a heading does.
|
|
30
|
+
*/
|
|
31
|
+
stores: 'string' | 'strings' | 'number' | 'boolean' | 'none'
|
|
32
|
+
/** Whether it takes a choice list (`values` or a `source`). */
|
|
33
|
+
choices?: boolean
|
|
34
|
+
/** Edited in the designer from these, and held to them in the file. */
|
|
35
|
+
settings?: readonly FormSettingDeclaration[]
|
|
36
|
+
/** After the kind's own checks: a message when the value is wrong. */
|
|
37
|
+
validate?(
|
|
38
|
+
values: readonly string[],
|
|
39
|
+
settings: Readonly<Record<string, FormSettingValue>>,
|
|
40
|
+
): string | undefined
|
|
41
|
+
/** The control, in the form's markup. The label, help and error are drawn around it. */
|
|
42
|
+
render: FormFieldRenderer
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** What a bundle's workflow handler is given: the submission and narrow capabilities, no database. */
|
|
46
|
+
export interface FormWorkflowRunContext {
|
|
47
|
+
form: { key: string; alias: string; name: string }
|
|
48
|
+
/** Each stored field's values, by alias; with labels where a field's choices have them. */
|
|
49
|
+
values: Readonly<Record<string, readonly string[]>>
|
|
50
|
+
labels: Readonly<Record<string, readonly string[]>>
|
|
51
|
+
entryId: string | null
|
|
52
|
+
settings: Readonly<Record<string, unknown>>
|
|
53
|
+
/** Which attempt this is, from 1; the queue makes up to five. */
|
|
54
|
+
attempt: number
|
|
55
|
+
/** Aborted with `signal` unless the call passes its own. */
|
|
56
|
+
fetch(input: string | URL | Request, init?: RequestInit): Promise<Response>
|
|
57
|
+
/** Answers `ok: false` when the site has no e-mail configured. */
|
|
58
|
+
sendEmail(message: EmailMessage): Promise<EmailResult>
|
|
59
|
+
log: {
|
|
60
|
+
info(message: string, properties?: Record<string, unknown>): void
|
|
61
|
+
warn(message: string, properties?: Record<string, unknown>): void
|
|
62
|
+
error(message: string, properties?: Record<string, unknown>): void
|
|
63
|
+
}
|
|
64
|
+
/** Aborted when the run has taken too long. */
|
|
65
|
+
signal: AbortSignal
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Done, or failed: `retry` true tries again on the built-ins' backoff (1, 5, 15
|
|
70
|
+
* and 60 minutes, five attempts in all), false gives up at once. A throw counts
|
|
71
|
+
* as a failure worth retrying.
|
|
72
|
+
*/
|
|
73
|
+
export type FormWorkflowRunResult =
|
|
74
|
+
| { ok: true; detail?: string }
|
|
75
|
+
| { ok: false; error: string; retry: boolean }
|
|
76
|
+
|
|
77
|
+
export interface FormWorkflowTypeDefinition {
|
|
78
|
+
type: string
|
|
79
|
+
label: string
|
|
80
|
+
settings?: readonly FormSettingDeclaration[]
|
|
81
|
+
/** Across its settings, after the declarations pass: a message when they are wrong. */
|
|
82
|
+
validate?(settings: Readonly<Record<string, unknown>>): string | undefined
|
|
83
|
+
run(context: FormWorkflowRunContext): FormWorkflowRunResult | Promise<FormWorkflowRunResult>
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** What a server bundle's `forms` adds. */
|
|
87
|
+
export interface BundleForms {
|
|
88
|
+
fields?: readonly FormFieldTypeDefinition[]
|
|
89
|
+
workflows?: readonly FormWorkflowTypeDefinition[]
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface CollectedFormTypes {
|
|
93
|
+
types: FormTypes
|
|
94
|
+
renderers: ReadonlyMap<string, FormFieldRenderer>
|
|
95
|
+
workflows: ReadonlyMap<string, FormWorkflowTypeDefinition>
|
|
96
|
+
problems: string[]
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const STORES = new Set(['string', 'strings', 'number', 'boolean', 'none'])
|
|
100
|
+
|
|
101
|
+
/** Every wired bundle's form types, merged over the built-ins, or why they cannot be. Pure. */
|
|
102
|
+
export function collectFormTypes(
|
|
103
|
+
bundles: readonly { id: string; forms?: BundleForms }[],
|
|
104
|
+
): CollectedFormTypes {
|
|
105
|
+
let types = BUILT_IN_FORM_TYPES
|
|
106
|
+
const renderers = new Map<string, FormFieldRenderer>()
|
|
107
|
+
const workflows = new Map<string, FormWorkflowTypeDefinition>()
|
|
108
|
+
const problems: string[] = []
|
|
109
|
+
for (const bundle of bundles) {
|
|
110
|
+
const forms = bundle.forms
|
|
111
|
+
if (!forms) continue
|
|
112
|
+
const fields = (forms.fields ?? []).filter((field) => {
|
|
113
|
+
if (STORES.has(field.stores) && typeof field.render === 'function') return true
|
|
114
|
+
problems.push(
|
|
115
|
+
`The bundle "${bundle.id}"'s field type "${field.type}" needs a render function and stores of string, strings, number, boolean or none.`,
|
|
116
|
+
)
|
|
117
|
+
return false
|
|
118
|
+
})
|
|
119
|
+
const added = formTypesWith(types, bundle.id, {
|
|
120
|
+
fields: fields.map((field) => ({
|
|
121
|
+
type: field.type,
|
|
122
|
+
label: field.label,
|
|
123
|
+
stores: field.stores,
|
|
124
|
+
choices: field.choices ?? false,
|
|
125
|
+
presentational: field.stores === 'none',
|
|
126
|
+
declared: field.settings ?? [],
|
|
127
|
+
validate: field.validate,
|
|
128
|
+
})),
|
|
129
|
+
workflows: (forms.workflows ?? []).map((workflow) => ({
|
|
130
|
+
type: workflow.type,
|
|
131
|
+
label: workflow.label,
|
|
132
|
+
declared: workflow.settings ?? [],
|
|
133
|
+
validate: workflow.validate,
|
|
134
|
+
})),
|
|
135
|
+
})
|
|
136
|
+
problems.push(...added.problems)
|
|
137
|
+
types = added.types
|
|
138
|
+
for (const field of fields)
|
|
139
|
+
if (types.fields.get(field.type)?.bundle === bundle.id)
|
|
140
|
+
renderers.set(field.type, field.render)
|
|
141
|
+
for (const workflow of forms.workflows ?? [])
|
|
142
|
+
if (types.workflows.get(workflow.type)?.bundle === bundle.id)
|
|
143
|
+
workflows.set(workflow.type, workflow)
|
|
144
|
+
}
|
|
145
|
+
return { types, renderers, workflows, problems }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const collected = new WeakMap<readonly unknown[], CollectedFormTypes>()
|
|
149
|
+
const NONE: readonly { id: string; forms?: BundleForms }[] = []
|
|
150
|
+
|
|
151
|
+
/** The form types a site's wired bundles add, worked out once per bundle list. */
|
|
152
|
+
export function formTypesOf(config: {
|
|
153
|
+
bundles?: readonly { id: string; forms?: BundleForms }[]
|
|
154
|
+
}): CollectedFormTypes {
|
|
155
|
+
const bundles = config.bundles ?? NONE
|
|
156
|
+
const known = collected.get(bundles)
|
|
157
|
+
if (known) return known
|
|
158
|
+
const result = collectFormTypes(bundles)
|
|
159
|
+
collected.set(bundles, result)
|
|
160
|
+
return result
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** As `formTypesOf`, the registry alone: what schema loading and validation take. */
|
|
164
|
+
export const formTypesFor = (config: {
|
|
165
|
+
bundles?: readonly { id: string; forms?: BundleForms }[]
|
|
166
|
+
}): FormTypes => formTypesOf(config).types
|
|
167
|
+
|
|
168
|
+
/** The types as the designer reads them: what each is called, stores, and which settings it edits. */
|
|
169
|
+
export function describeFormTypes(types: FormTypes) {
|
|
170
|
+
return {
|
|
171
|
+
fields: [...types.fields.values()].map((info) => ({
|
|
172
|
+
type: info.type,
|
|
173
|
+
label: info.label,
|
|
174
|
+
stores: info.stores,
|
|
175
|
+
choices: info.choices,
|
|
176
|
+
presentational: info.presentational,
|
|
177
|
+
settings: info.declared,
|
|
178
|
+
builtIn: info.bundle === undefined,
|
|
179
|
+
})),
|
|
180
|
+
workflows: [...types.workflows.values()].map((info) => ({
|
|
181
|
+
type: info.type,
|
|
182
|
+
label: info.label,
|
|
183
|
+
settings: info.declared,
|
|
184
|
+
builtIn: info.bundle === undefined,
|
|
185
|
+
})),
|
|
186
|
+
}
|
|
187
|
+
}
|
package/src/form-workflows.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
type NodeSchemaState,
|
|
27
27
|
} from '@crombolo/data'
|
|
28
28
|
import type { EmailAttachment, EmailPort } from './email.ts'
|
|
29
|
+
import type { FormWorkflowTypeDefinition } from './form-types.ts'
|
|
29
30
|
import { logger } from './logging.ts'
|
|
30
31
|
import type { MediaFileStore } from './media-files.ts'
|
|
31
32
|
|
|
@@ -40,8 +41,13 @@ export interface WorkflowContext {
|
|
|
40
41
|
form: SchemaForm
|
|
41
42
|
values: readonly FormValueToStore[]
|
|
42
43
|
entryId: string | null
|
|
44
|
+
/** Which attempt this is, from 1. */
|
|
45
|
+
attempt?: number
|
|
43
46
|
}
|
|
44
47
|
|
|
48
|
+
/** How long a bundle's workflow may run before its signal aborts. */
|
|
49
|
+
const BUNDLE_WORKFLOW_TIMEOUT_MS = 60_000
|
|
50
|
+
|
|
45
51
|
export interface WorkflowDeps {
|
|
46
52
|
db: Db
|
|
47
53
|
/** Absent when the site has no e-mail; a `sendEmail` workflow then says so. */
|
|
@@ -52,6 +58,8 @@ export interface WorkflowDeps {
|
|
|
52
58
|
nodeId?: string
|
|
53
59
|
/** Told when a workflow published something, so the cache is dropped. */
|
|
54
60
|
onContentChanged?: () => void
|
|
61
|
+
/** The workflow types wired bundles add, by type. */
|
|
62
|
+
bundleWorkflows?: ReadonlyMap<string, FormWorkflowTypeDefinition>
|
|
55
63
|
}
|
|
56
64
|
|
|
57
65
|
export type WorkflowResult =
|
|
@@ -61,10 +69,13 @@ export type WorkflowResult =
|
|
|
61
69
|
|
|
62
70
|
// ------------------------------------------------------------------ substitution
|
|
63
71
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
72
|
+
/** What a person reads for a value: the label a visitor chose, for a choice from a source. */
|
|
73
|
+
const shown = (value: FormValueToStore): string[] => value.labels ?? value.values
|
|
74
|
+
|
|
75
|
+
const firstValue = (values: readonly FormValueToStore[], alias: string): string => {
|
|
76
|
+
const found = values.find((value) => value.fieldAlias.toLowerCase() === alias.toLowerCase())
|
|
77
|
+
return found ? shown(found).join(', ') : ''
|
|
78
|
+
}
|
|
68
79
|
|
|
69
80
|
/**
|
|
70
81
|
* `{fieldAlias}` in a workflow setting becomes what was submitted for it.
|
|
@@ -79,7 +90,7 @@ export function substitute(text: string, values: readonly FormValueToStore[]): s
|
|
|
79
90
|
const found = values.find((value) => value.fieldAlias.toLowerCase() === alias.toLowerCase())
|
|
80
91
|
// An unknown alias is left as it was written, so a stray brace in a subject
|
|
81
92
|
// line reads as itself rather than vanishing.
|
|
82
|
-
return found ? found.
|
|
93
|
+
return found ? shown(found).join(', ') : whole
|
|
83
94
|
})
|
|
84
95
|
}
|
|
85
96
|
|
|
@@ -102,7 +113,7 @@ export function plainSummary(context: WorkflowContext): string {
|
|
|
102
113
|
const lines = context.values.map(
|
|
103
114
|
(value) =>
|
|
104
115
|
`${captions.get(value.fieldAlias) ?? value.fieldAlias}: ${
|
|
105
|
-
value.values.length > 0 ? value.
|
|
116
|
+
value.values.length > 0 ? shown(value).join(', ') : '(not answered)'
|
|
106
117
|
}`,
|
|
107
118
|
)
|
|
108
119
|
return [`${context.form.name}`, '', ...lines].join('\n')
|
|
@@ -298,6 +309,54 @@ export async function runWorkflow(
|
|
|
298
309
|
case 'sendToUrl':
|
|
299
310
|
return sendToUrl(workflow, context, deps)
|
|
300
311
|
}
|
|
312
|
+
const bundled = deps.bundleWorkflows?.get(workflow.type)
|
|
313
|
+
if (!bundled)
|
|
314
|
+
return {
|
|
315
|
+
ok: false,
|
|
316
|
+
retry: false,
|
|
317
|
+
error: `No wired bundle provides the workflow type "${workflow.type}".`,
|
|
318
|
+
}
|
|
319
|
+
return runBundleWorkflow(bundled, workflow, context, deps)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** A bundle's handler, given the submission and narrow capabilities rather than the deps. */
|
|
323
|
+
async function runBundleWorkflow(
|
|
324
|
+
definition: FormWorkflowTypeDefinition,
|
|
325
|
+
workflow: SchemaFormWorkflow,
|
|
326
|
+
context: WorkflowContext,
|
|
327
|
+
deps: WorkflowDeps,
|
|
328
|
+
): Promise<WorkflowResult> {
|
|
329
|
+
const values: Record<string, readonly string[]> = {}
|
|
330
|
+
const labels: Record<string, readonly string[]> = {}
|
|
331
|
+
for (const value of context.values) {
|
|
332
|
+
values[value.fieldAlias] = value.values
|
|
333
|
+
if (value.labels) labels[value.fieldAlias] = value.labels
|
|
334
|
+
}
|
|
335
|
+
const named = logger('forms', workflow.type)
|
|
336
|
+
const signal = AbortSignal.timeout(BUNDLE_WORKFLOW_TIMEOUT_MS)
|
|
337
|
+
return definition.run({
|
|
338
|
+
form: {
|
|
339
|
+
key: context.form.key ?? context.form.alias,
|
|
340
|
+
alias: context.form.alias,
|
|
341
|
+
name: context.form.name,
|
|
342
|
+
},
|
|
343
|
+
values,
|
|
344
|
+
labels,
|
|
345
|
+
entryId: context.entryId,
|
|
346
|
+
settings: workflow.settings,
|
|
347
|
+
attempt: context.attempt ?? 1,
|
|
348
|
+
fetch: (input, init) => (deps.fetch ?? fetch)(input, { signal, ...init }),
|
|
349
|
+
sendEmail: async (message) =>
|
|
350
|
+
deps.email
|
|
351
|
+
? deps.email.send(message)
|
|
352
|
+
: { ok: false, error: 'This site has no e-mail set up.' },
|
|
353
|
+
log: {
|
|
354
|
+
info: (message, properties) => named.info(message, properties),
|
|
355
|
+
warn: (message, properties) => named.warn(message, properties),
|
|
356
|
+
error: (message, properties) => named.error(message, properties),
|
|
357
|
+
},
|
|
358
|
+
signal,
|
|
359
|
+
})
|
|
301
360
|
}
|
|
302
361
|
|
|
303
362
|
export interface WorkflowRunReport {
|
|
@@ -344,6 +403,7 @@ export function createWorkflowRunner(
|
|
|
344
403
|
form,
|
|
345
404
|
values: run.payload,
|
|
346
405
|
entryId: run.entryId,
|
|
406
|
+
attempt: run.attempts,
|
|
347
407
|
}
|
|
348
408
|
let result: WorkflowResult
|
|
349
409
|
try {
|
package/src/forms.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* what a render would use.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import type { FormTypes } from '@crombolo/core'
|
|
13
14
|
import type { FormEntryState } from '@crombolo/data'
|
|
14
15
|
import { allFormFields, type SchemaForm, storingFormFields } from '@crombolo/schema'
|
|
15
16
|
|
|
@@ -26,14 +27,14 @@ export interface FormSummary {
|
|
|
26
27
|
workflowCount: number
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
export function formSummary(form: SchemaForm): FormSummary {
|
|
30
|
+
export function formSummary(form: SchemaForm, types?: FormTypes): FormSummary {
|
|
30
31
|
return {
|
|
31
32
|
// A form with no key in its file still has to be addressable, and its alias
|
|
32
33
|
// is unique within the set, so it stands in until a key is written.
|
|
33
34
|
key: form.key ?? form.alias,
|
|
34
35
|
alias: form.alias,
|
|
35
36
|
name: form.name,
|
|
36
|
-
fieldCount: storingFormFields(form).length,
|
|
37
|
+
fieldCount: storingFormFields(form, types).length,
|
|
37
38
|
pageCount: form.pages.length,
|
|
38
39
|
storeEntries: form.storeEntries,
|
|
39
40
|
requiresApproval: form.requiresApproval,
|
|
@@ -59,6 +60,7 @@ export function formDetail(form: SchemaForm): Record<string, unknown> {
|
|
|
59
60
|
mandatory: field.mandatory,
|
|
60
61
|
sensitive: field.sensitive,
|
|
61
62
|
values: field.values,
|
|
63
|
+
source: field.source ?? null,
|
|
62
64
|
})),
|
|
63
65
|
workflows: form.workflows.map((workflow) => ({
|
|
64
66
|
type: workflow.type,
|
|
@@ -82,10 +84,10 @@ export function formsByKey(forms: readonly SchemaForm[]): Map<string, SchemaForm
|
|
|
82
84
|
* not allowed them, so the redaction is on the way out of the server.
|
|
83
85
|
*/
|
|
84
86
|
export function redactSensitive(
|
|
85
|
-
entry: { values: { fieldAlias: string; values: string[] }[] },
|
|
87
|
+
entry: { values: { fieldAlias: string; values: string[]; labels?: string[] }[] },
|
|
86
88
|
form: SchemaForm | undefined,
|
|
87
89
|
maySeeSensitive: boolean,
|
|
88
|
-
): { fieldAlias: string; values: string[]; redacted: boolean }[] {
|
|
90
|
+
): { fieldAlias: string; values: string[]; labels?: string[]; redacted: boolean }[] {
|
|
89
91
|
const sensitive = new Set(
|
|
90
92
|
(form ? allFormFields(form) : [])
|
|
91
93
|
.filter((field) => field.sensitive)
|
|
@@ -96,6 +98,7 @@ export function redactSensitive(
|
|
|
96
98
|
return {
|
|
97
99
|
fieldAlias: value.fieldAlias,
|
|
98
100
|
values: hide ? [] : value.values,
|
|
101
|
+
...(value.labels && !hide ? { labels: value.labels } : {}),
|
|
99
102
|
redacted: hide,
|
|
100
103
|
}
|
|
101
104
|
})
|
|
@@ -117,6 +120,8 @@ function csvCell(value: string): string {
|
|
|
117
120
|
}
|
|
118
121
|
|
|
119
122
|
export interface EntriesCsvOptions {
|
|
123
|
+
/** The field types in play, so a bundle's type that collects nothing gets no column. */
|
|
124
|
+
types?: FormTypes
|
|
120
125
|
/** Without it, a sensitive column is refused rather than silently blank. */
|
|
121
126
|
includeSensitive: boolean
|
|
122
127
|
}
|
|
@@ -138,11 +143,11 @@ export function entriesCsv(
|
|
|
138
143
|
createDate: Date
|
|
139
144
|
state: string
|
|
140
145
|
spam: boolean
|
|
141
|
-
values: { fieldAlias: string; values: string[] }[]
|
|
146
|
+
values: { fieldAlias: string; values: string[]; labels?: string[] }[]
|
|
142
147
|
}[],
|
|
143
148
|
options: EntriesCsvOptions,
|
|
144
149
|
): EntriesCsvResult {
|
|
145
|
-
const fields = storingFormFields(form)
|
|
150
|
+
const fields = storingFormFields(form, options.types)
|
|
146
151
|
const sensitive = fields.filter((field) => field.sensitive)
|
|
147
152
|
if (sensitive.length > 0 && !options.includeSensitive)
|
|
148
153
|
return {
|
|
@@ -158,7 +163,10 @@ export function entriesCsv(
|
|
|
158
163
|
entry.state,
|
|
159
164
|
String(entry.spam),
|
|
160
165
|
...fields.map((field) =>
|
|
161
|
-
(
|
|
166
|
+
(() => {
|
|
167
|
+
const value = entry.values.find((candidate) => candidate.fieldAlias === field.alias)
|
|
168
|
+
return (value?.labels ?? value?.values ?? []).join('; ')
|
|
169
|
+
})(),
|
|
162
170
|
),
|
|
163
171
|
])
|
|
164
172
|
return {
|
package/src/index.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
export * from './adapters/audit.ts'
|
|
1
2
|
export * from './adapters/blueprints.ts'
|
|
2
3
|
export * from './adapters/bundles.ts'
|
|
3
4
|
export * from './adapters/content.ts'
|
|
4
5
|
export * from './adapters/documents.ts'
|
|
5
6
|
export * from './adapters/elements.ts'
|
|
7
|
+
export * from './adapters/health.ts'
|
|
6
8
|
export * from './adapters/media.ts'
|
|
7
9
|
export * from './adapters/member-groups.ts'
|
|
8
10
|
export * from './adapters/members.ts'
|
|
@@ -11,10 +13,12 @@ export * from './adapters/redirects.ts'
|
|
|
11
13
|
export * from './adapters/references.ts'
|
|
12
14
|
export * from './adapters/template-files.ts'
|
|
13
15
|
export * from './backup.ts'
|
|
16
|
+
export * from './boot-failure.ts'
|
|
14
17
|
export * from './bundle-install.ts'
|
|
15
18
|
export * from './bundle-sections.ts'
|
|
16
19
|
export * from './bundle-tables.ts'
|
|
17
20
|
export * from './bundles.ts'
|
|
21
|
+
export * from './busy.ts'
|
|
18
22
|
export * from './components.ts'
|
|
19
23
|
export * from './config.ts'
|
|
20
24
|
export * from './content-type-udt.ts'
|
|
@@ -33,6 +37,7 @@ export * from './extensions-notice.ts'
|
|
|
33
37
|
export * from './file-intake.ts'
|
|
34
38
|
export * from './form-files.ts'
|
|
35
39
|
export * from './form-submit.ts'
|
|
40
|
+
export * from './form-types.ts'
|
|
36
41
|
export * from './form-workflows.ts'
|
|
37
42
|
export * from './forms.ts'
|
|
38
43
|
export * from './git.ts'
|
|
@@ -57,9 +62,16 @@ export * from './ports.ts'
|
|
|
57
62
|
export * from './redirects.ts'
|
|
58
63
|
export * from './schema.ts'
|
|
59
64
|
export * from './schema-store.ts'
|
|
65
|
+
export * from './secrets.ts'
|
|
60
66
|
export * from './seo.ts'
|
|
61
67
|
export * from './server.ts'
|
|
62
68
|
export * from './server-events.ts'
|
|
69
|
+
export * from './setup.ts'
|
|
63
70
|
export * from './status.ts'
|
|
71
|
+
export * from './transfer-dashboard.ts'
|
|
64
72
|
export * from './udt.ts'
|
|
73
|
+
export * from './upgrade-summary.ts'
|
|
74
|
+
export * from './url-settings.ts'
|
|
65
75
|
export * from './view-runtime.ts'
|
|
76
|
+
export * from './webhook-files.ts'
|
|
77
|
+
export * from './webhooks.ts'
|