@crombolo/server 0.0.0-stage → 0.9.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/LICENSE +21 -0
- package/NOTICE +69 -0
- package/package.json +41 -4
- package/src/access.ts +114 -0
- package/src/adapters/assistant.ts +229 -0
- package/src/adapters/blueprints.ts +163 -0
- package/src/adapters/bundles.ts +432 -0
- package/src/adapters/content.ts +783 -0
- package/src/adapters/dictionary.ts +76 -0
- package/src/adapters/documents.ts +637 -0
- package/src/adapters/elements.ts +403 -0
- package/src/adapters/file-system.ts +195 -0
- package/src/adapters/media.ts +347 -0
- package/src/adapters/member-groups.ts +90 -0
- package/src/adapters/members.ts +261 -0
- package/src/adapters/public-access.ts +116 -0
- package/src/adapters/redirects.ts +73 -0
- package/src/adapters/references.ts +67 -0
- package/src/adapters/schema-files.ts +204 -0
- package/src/adapters/schema-proposals.ts +180 -0
- package/src/adapters/template-files.ts +58 -0
- package/src/adapters/template-query.ts +127 -0
- package/src/adapters/users.ts +963 -0
- package/src/assistant.ts +487 -0
- package/src/backup.ts +52 -0
- package/src/bundles.ts +508 -0
- package/src/component-snippets.ts +163 -0
- package/src/components.ts +266 -0
- package/src/config.ts +548 -0
- package/src/content-type-udt.ts +256 -0
- package/src/database.ts +109 -0
- package/src/diagnostics.ts +143 -0
- package/src/dictionary-transfer.ts +123 -0
- package/src/domains.ts +384 -0
- package/src/editor-types.ts +108 -0
- package/src/email-http.ts +79 -0
- package/src/email-notice.ts +36 -0
- package/src/email-postmark.ts +78 -0
- package/src/email-resend.ts +69 -0
- package/src/email-ses.ts +307 -0
- package/src/email.ts +402 -0
- package/src/extensions-notice.ts +35 -0
- package/src/file-intake.ts +84 -0
- package/src/form-files.ts +241 -0
- package/src/form-submit.ts +397 -0
- package/src/form-workflows.ts +397 -0
- package/src/forms.ts +170 -0
- package/src/git-github.ts +171 -0
- package/src/git-routes.ts +154 -0
- package/src/git.ts +88 -0
- package/src/imaging.ts +434 -0
- package/src/index.ts +62 -0
- package/src/jobs.ts +140 -0
- package/src/listen.ts +36 -0
- package/src/log-expressions.ts +524 -0
- package/src/log-viewer.ts +190 -0
- package/src/logging.ts +126 -0
- package/src/marketplace.ts +297 -0
- package/src/media-files.ts +276 -0
- package/src/media-store-azure.ts +218 -0
- package/src/media-store-s3.ts +113 -0
- package/src/media-store.ts +248 -0
- package/src/member-auth.ts +384 -0
- package/src/models-builder.ts +106 -0
- package/src/no-nodes.ts +64 -0
- package/src/not-implemented.ts +44 -0
- package/src/oembed.ts +144 -0
- package/src/ports.ts +553 -0
- package/src/redirects.ts +132 -0
- package/src/schema-classify.ts +82 -0
- package/src/schema-store.ts +145 -0
- package/src/schema.ts +388 -0
- package/src/seo.ts +371 -0
- package/src/server-events.ts +131 -0
- package/src/server.ts +1579 -0
- package/src/status.ts +246 -0
- package/src/udt.ts +168 -0
- package/src/value-schemas.ts +64 -0
- package/src/view-runtime.ts +42 -0
- package/src/zip.ts +152 -0
- package/README.md +0 -3
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Running a form's workflows. `docs/18-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/18-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/18-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
|
+
}
|