@crombolo/server 0.12.1 → 0.13.2

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.
@@ -0,0 +1,838 @@
1
+ /**
2
+ * Approval before publishing (story 028): which workflow covers a page, the
3
+ * gate that refuses publishing a covered page directly, and the requests that
4
+ * go through a workflow's stages. docs/workflows.md.
5
+ *
6
+ * The graph rules are `@crombolo/core`'s; the record is `ApprovalRepository`'s.
7
+ * This decides: who may act, what an edit does to a waiting request, when a
8
+ * stage is done, and when the page is published.
9
+ */
10
+ import type { Principal } from '@crombolo/api-management'
11
+ import {
12
+ approvalsNeeded,
13
+ DocumentPermissions,
14
+ descendantStages,
15
+ normaliseUuid,
16
+ permissionsForPath,
17
+ type SchemaWorkflow,
18
+ SEND_BACK_TO_EDITOR,
19
+ stagesNowWaiting,
20
+ startStages,
21
+ workflowComplete,
22
+ workflowFor,
23
+ } from '@crombolo/core'
24
+ import {
25
+ type ApprovalAction,
26
+ ApprovalRepository,
27
+ type ApprovalRepository as ApprovalRepositoryType,
28
+ type ApprovalRequest,
29
+ type ApprovalStep,
30
+ AuditLog,
31
+ type Db,
32
+ DocumentRepository,
33
+ NodeRepository,
34
+ type NodeSchemaState,
35
+ readWorkflows,
36
+ type StoredWorkflow,
37
+ UserGroupRepository,
38
+ } from '@crombolo/data'
39
+ import type { EmailPort } from './email.ts'
40
+
41
+ /** What publishing and unpublishing go through when approval says yes. */
42
+ export interface ApprovalPublisher {
43
+ publish(
44
+ key: string,
45
+ cultures: string[] | null,
46
+ ): Promise<{ ok: true } | { ok: false; reason: string }>
47
+ unpublish(
48
+ key: string,
49
+ cultures: string[] | null,
50
+ ): Promise<{ ok: true } | { ok: false; reason: string }>
51
+ }
52
+
53
+ export interface ApprovalsOptions {
54
+ db: Db
55
+ publisher: ApprovalPublisher
56
+ nodeState?: NodeSchemaState
57
+ email?: EmailPort
58
+ /** Where a link in an e-mail points, e.g. `https://site.example/crombolo`. */
59
+ backOfficeUrl?: string
60
+ }
61
+
62
+ /** A refusal a person reads: why, and what to do instead. */
63
+ export class ApprovalRefused extends Error {
64
+ constructor(
65
+ message: string,
66
+ readonly status = 400,
67
+ ) {
68
+ super(message)
69
+ }
70
+ }
71
+
72
+ /** The page a request or a check is about, as the engine needs it. */
73
+ interface PageFacts {
74
+ key: string
75
+ name: string
76
+ chain: string[]
77
+ contentTypeAlias: string | undefined
78
+ variesByCulture: boolean
79
+ }
80
+
81
+ export interface PageApproval {
82
+ /** The workflow that covers the page, as it stands now. */
83
+ workflow: SchemaWorkflow | undefined
84
+ request: RequestView | undefined
85
+ /** Earlier requests, newest first. */
86
+ past: RequestView[]
87
+ can: { send: boolean; requestUnpublish: boolean; publishDirectly: boolean }
88
+ }
89
+
90
+ export interface RequestView {
91
+ key: string
92
+ nodeKey: string
93
+ nodeName: string
94
+ /** The editor's variant segment for the page: `invariant`, or the culture to open. */
95
+ variant: string
96
+ action: ApprovalAction
97
+ cultures: string[] | null
98
+ state: ApprovalRequest['state']
99
+ requestedBy: string
100
+ requestedAt: string
101
+ workflow: SchemaWorkflow
102
+ stages: Array<{
103
+ alias: string
104
+ state: 'waiting' | 'approved' | 'pending' | 'discarded'
105
+ approvals: number
106
+ needed: number
107
+ }>
108
+ history: Array<{
109
+ kind: string
110
+ stage: string | null
111
+ userKey: string | null
112
+ comment: string | null
113
+ at: string
114
+ }>
115
+ /** What the asking user may do now. */
116
+ can: { approve: string[]; sendBack: string[]; withdraw: boolean }
117
+ }
118
+
119
+ export interface Approvals {
120
+ /** The workflows the site has, as last synced. */
121
+ workflows(): Promise<StoredWorkflow[]>
122
+ /** Undefined when the caller may go ahead; otherwise why not. */
123
+ publishRefusal(
124
+ principal: Principal,
125
+ key: string,
126
+ action: ApprovalAction,
127
+ ): Promise<string | undefined>
128
+ /** A branch publish: the root and every page below it. */
129
+ branchRefusal(principal: Principal, key: string): Promise<string | undefined>
130
+ /** For a page being created and published at once: by its parent and type. */
131
+ newPageRefusal(
132
+ principal: Principal,
133
+ parentKey: string | null,
134
+ contentTypeKey: string,
135
+ ): Promise<string | undefined>
136
+ /** Undefined when the save may go ahead; otherwise why not. */
137
+ beforeSave(principal: Principal, key: string): Promise<string | undefined>
138
+ /** What the save does to a waiting request, once it has gone through. */
139
+ afterSave(principal: Principal, key: string): Promise<void>
140
+ send(
141
+ principal: Principal,
142
+ key: string,
143
+ action: ApprovalAction,
144
+ cultures: string[] | null,
145
+ comment?: string,
146
+ ): Promise<RequestView>
147
+ approve(
148
+ principal: Principal,
149
+ requestKey: string,
150
+ stage?: string,
151
+ comment?: string,
152
+ ): Promise<RequestView>
153
+ sendBack(
154
+ principal: Principal,
155
+ requestKey: string,
156
+ stage: string | undefined,
157
+ comment?: string,
158
+ ): Promise<RequestView>
159
+ withdraw(principal: Principal, requestKey: string): Promise<RequestView>
160
+ forPage(principal: Principal, key: string): Promise<PageApproval>
161
+ /** Requests waiting on a stage the user may approve. */
162
+ waitingFor(principal: Principal): Promise<RequestView[]>
163
+ inFlight(principal: Principal): Promise<RequestView[]>
164
+ finished(
165
+ principal: Principal,
166
+ skip: number,
167
+ take: number,
168
+ ): Promise<{ total: number; items: RequestView[] }>
169
+ }
170
+
171
+ export function createApprovals(options: ApprovalsOptions): Approvals {
172
+ const { db } = options
173
+ const requests = (tx: Db = db): ApprovalRepositoryType => new ApprovalRepository(tx)
174
+ const nodes = new NodeRepository(db)
175
+ const groups = new UserGroupRepository(db)
176
+ const audit = new AuditLog(db)
177
+
178
+ const page = async (key: string): Promise<PageFacts | undefined> => {
179
+ const node = await nodes.byKey(key)
180
+ if (!node) return undefined
181
+ const ids = node.path
182
+ .split(',')
183
+ .map(Number)
184
+ .filter((id) => id > 0)
185
+ const rows = await nodes.byIds(ids)
186
+ const keyOf = new Map(rows.map((r) => [r.id, r.key]))
187
+ const type = await db.query<{ alias: string; variations: number }>(
188
+ `SELECT ct.alias, ct.variations FROM content c JOIN content_type ct ON ct.node_id = c.content_type_id WHERE c.node_id = ?`,
189
+ [node.id],
190
+ )
191
+ return {
192
+ key: node.key,
193
+ name: node.text ?? node.key,
194
+ chain: ids.flatMap((id) => keyOf.get(id) ?? []),
195
+ contentTypeAlias: type[0]?.alias,
196
+ // Umbraco's flags: 1 is culture, 2 is segment.
197
+ variesByCulture: (Number(type[0]?.variations ?? 0) & 1) === 1,
198
+ }
199
+ }
200
+
201
+ const covering = async (facts: PageFacts): Promise<SchemaWorkflow | undefined> =>
202
+ workflowFor(
203
+ (await readWorkflows(db)).map((s) => s.workflow),
204
+ facts.chain,
205
+ facts.contentTypeAlias,
206
+ )
207
+
208
+ const groupAliases = async (principal: Principal): Promise<Set<string>> =>
209
+ new Set((await groups.byKeys(principal.groupKeys)).map((g) => g.alias))
210
+
211
+ const mayPublish = (principal: Principal, facts: PageFacts, action: ApprovalAction): boolean =>
212
+ principal.isAdmin ||
213
+ permissionsForPath(principal.groups, facts.chain).has(
214
+ action === 'publish' ? DocumentPermissions.Publish : DocumentPermissions.Unpublish,
215
+ )
216
+
217
+ const covers = (workflow: SchemaWorkflow | undefined, action: ApprovalAction) =>
218
+ workflow !== undefined && (action === 'publish' || workflow.unpublish)
219
+
220
+ const refusalFor = (
221
+ principal: Principal,
222
+ facts: PageFacts,
223
+ workflow: SchemaWorkflow | undefined,
224
+ action: ApprovalAction,
225
+ ): string | undefined => {
226
+ if (!workflow || !covers(workflow, action)) return undefined
227
+ if (workflow.publishersMaySkip && mayPublish(principal, facts, action)) return undefined
228
+ return action === 'publish'
229
+ ? `"${facts.name}" needs approval through "${workflow.name}" before it is published. Send it for approval from its Workflow tab.`
230
+ : `Taking "${facts.name}" down needs approval through "${workflow.name}". Request it from its Workflow tab.`
231
+ }
232
+
233
+ const membersOf = async (aliases: readonly string[]): Promise<number> => {
234
+ if (aliases.length === 0) return 0
235
+ const rows = await db.query<{ n: number }>(
236
+ `SELECT COUNT(DISTINCT m.user_id) AS n FROM user_group_member m
237
+ JOIN user_group g ON g.id = m.user_group_id WHERE g.alias IN (${aliases.map(() => '?').join(', ')})`,
238
+ [...aliases],
239
+ )
240
+ return Number(rows[0]?.n ?? 0)
241
+ }
242
+
243
+ const stageOf = (workflow: SchemaWorkflow, alias: string) =>
244
+ workflow.stages.find((s) => s.alias === alias)
245
+
246
+ /** Approvals given in a stage visit, by distinct user. */
247
+ const approvalsIn = async (
248
+ repo: ApprovalRepositoryType,
249
+ request: ApprovalRequest,
250
+ step: ApprovalStep,
251
+ ) => {
252
+ const history = await repo.history(request.id)
253
+ return new Set(
254
+ history
255
+ .filter((h) => h.kind === 'approved' && h.stepId === step.id && h.userKey)
256
+ .map((h) => h.userKey as string),
257
+ )
258
+ }
259
+
260
+ const notify = async (
261
+ to: 'approvers' | 'editor',
262
+ request: ApprovalRequest,
263
+ stages: readonly string[],
264
+ subject: string,
265
+ body: string,
266
+ ) => {
267
+ if (!options.email) return
268
+ let emails: string[] = []
269
+ if (to === 'editor') {
270
+ const rows = await db.query<{ email: string }>(
271
+ 'SELECT email FROM user_account WHERE key = ?',
272
+ [request.requestedBy],
273
+ )
274
+ emails = rows.map((r) => r.email)
275
+ } else {
276
+ const aliases = stages.flatMap((s) => stageOf(request.workflow, s)?.approvers ?? [])
277
+ if (aliases.length === 0) return
278
+ const rows = await db.query<{ email: string }>(
279
+ `SELECT DISTINCT u.email FROM user_account u
280
+ JOIN user_group_member m ON m.user_id = u.id JOIN user_group g ON g.id = m.user_group_id
281
+ WHERE g.alias IN (${aliases.map(() => '?').join(', ')}) AND u.is_approved = ?`,
282
+ [...aliases, db.dialect.boolValue(true)],
283
+ )
284
+ emails = rows.map((r) => r.email)
285
+ }
286
+ if (emails.length === 0) return
287
+ const link = options.backOfficeUrl
288
+ ? `\n\n${options.backOfficeUrl}/section/content/workspace/document/edit/${request.nodeKey}`
289
+ : ''
290
+ await options.email.send({
291
+ to: emails.map((email) => ({ email })),
292
+ subject,
293
+ text: `${body}${link}`,
294
+ })
295
+ }
296
+
297
+ const auditStep = async (
298
+ principal: Principal | undefined,
299
+ facts: PageFacts | undefined,
300
+ action: string,
301
+ detail: string,
302
+ ) => {
303
+ await audit.record({
304
+ category: 'content',
305
+ action,
306
+ source: 'request',
307
+ userKey: principal?.id ?? null,
308
+ userName: principal?.name ?? null,
309
+ entityType: 'Document',
310
+ entityKey: facts?.key ?? null,
311
+ entityName: facts?.name ?? null,
312
+ detail,
313
+ })
314
+ }
315
+
316
+ /**
317
+ * Stages just reached count the person who made the change as approving
318
+ * when the workflow says so and they are in the stage's groups.
319
+ */
320
+ const reach = async (
321
+ repo: ApprovalRepositoryType,
322
+ request: ApprovalRequest,
323
+ stages: readonly string[],
324
+ ): Promise<void> => {
325
+ for (const stage of stages) {
326
+ const stepId = await repo.addStep(request.id, stage)
327
+ if (request.workflow.selfApproval !== 'automatic') continue
328
+ const approvers = stageOf(request.workflow, stage)?.approvers ?? []
329
+ const member = await db.query<{ n: number }>(
330
+ `SELECT COUNT(*) AS n FROM user_group_member m JOIN user_group g ON g.id = m.user_group_id
331
+ JOIN user_account u ON u.id = m.user_id WHERE u.key = ? AND g.alias IN (${approvers.map(() => '?').join(', ') || "''"})`,
332
+ [request.requestedBy, ...approvers],
333
+ )
334
+ if (Number(member[0]?.n ?? 0) > 0)
335
+ await repo.record(request.id, {
336
+ kind: 'approved',
337
+ stepId,
338
+ stage,
339
+ userKey: request.requestedBy,
340
+ comment: 'counted automatically: they made the change',
341
+ })
342
+ }
343
+ }
344
+
345
+ /** Marks done whatever stages have their approvals, reaches what follows, and publishes at the end. */
346
+ /** True when every last stage has approved: the caller publishes, once its transaction is over. */
347
+ const settle = async (
348
+ repo: ApprovalRepositoryType,
349
+ request: ApprovalRequest,
350
+ ): Promise<boolean> => {
351
+ for (let round = 0; round < request.workflow.stages.length + 1; round++) {
352
+ const steps = await repo.steps(request.id)
353
+ let moved = false
354
+ for (const step of steps.filter((s) => s.state === 'waiting')) {
355
+ const stage = stageOf(request.workflow, step.stage)
356
+ if (!stage) continue
357
+ const given = await approvalsIn(repo, request, step)
358
+ if (given.size >= approvalsNeeded(stage.required, await membersOf(stage.approvers))) {
359
+ await repo.setStepState(step.id, 'approved')
360
+ moved = true
361
+ }
362
+ }
363
+ const now = await repo.steps(request.id)
364
+ const approved = new Set(now.filter((s) => s.state === 'approved').map((s) => s.stage))
365
+ const reached = new Set(
366
+ now.filter((s) => s.state === 'waiting' || s.state === 'approved').map((s) => s.stage),
367
+ )
368
+ if (workflowComplete(request.workflow, approved)) return true
369
+ const next = stagesNowWaiting(request.workflow, approved, reached)
370
+ if (next.length > 0) {
371
+ await reach(repo, request, next)
372
+ const facts = await page(request.nodeKey)
373
+ await notify(
374
+ 'approvers',
375
+ request,
376
+ next,
377
+ `Waiting for your approval: ${facts?.name ?? 'a page'}`,
378
+ `"${facts?.name}" is waiting for approval through "${request.workflow.name}".`,
379
+ )
380
+ moved = true
381
+ }
382
+ if (!moved) return false
383
+ }
384
+ return false
385
+ }
386
+
387
+ /**
388
+ * Publishes (or takes down) a fully approved page, outside any transaction:
389
+ * publishing takes the content tree's lock, which must never be waited on
390
+ * from inside one. A page that cannot be published goes back to its editor
391
+ * with the reason, rather than sitting approved and unpublished.
392
+ */
393
+ const finish = async (
394
+ request: ApprovalRequest,
395
+ principal: Principal | undefined,
396
+ ): Promise<void> => {
397
+ const repo = requests()
398
+ const facts = await page(request.nodeKey)
399
+ const result =
400
+ request.action === 'publish'
401
+ ? await options.publisher.publish(request.nodeKey, request.cultures)
402
+ : await options.publisher.unpublish(request.nodeKey, request.cultures)
403
+ if (!result.ok) {
404
+ await repo.setState(request.id, 'sent-back')
405
+ await repo.record(request.id, {
406
+ kind: 'sent-back',
407
+ comment: `approved, but it could not be ${request.action === 'publish' ? 'published' : 'taken down'}: ${result.reason}`,
408
+ })
409
+ await notify(
410
+ 'editor',
411
+ request,
412
+ [],
413
+ `Could not publish: ${facts?.name ?? 'a page'}`,
414
+ `"${facts?.name}" was approved but could not be published: ${result.reason}`,
415
+ )
416
+ return
417
+ }
418
+ await repo.setState(request.id, 'approved')
419
+ await repo.record(request.id, {
420
+ kind: request.action === 'publish' ? 'published' : 'unpublished',
421
+ userKey: principal?.id ?? null,
422
+ })
423
+ await auditStep(
424
+ principal,
425
+ facts,
426
+ request.action === 'publish' ? 'Published on approval' : 'Unpublished on approval',
427
+ `through "${request.workflow.name}"`,
428
+ )
429
+ await notify(
430
+ 'editor',
431
+ request,
432
+ [],
433
+ `Approved: ${facts?.name ?? 'a page'}`,
434
+ `"${facts?.name}" was approved through "${request.workflow.name}" and is now ${request.action === 'publish' ? 'published' : 'taken down'}.`,
435
+ )
436
+ }
437
+
438
+ const view = async (principal: Principal, request: ApprovalRequest): Promise<RequestView> => {
439
+ const repo = requests()
440
+ const steps = await repo.steps(request.id)
441
+ const history = await repo.history(request.id)
442
+ const mine = await groupAliases(principal)
443
+ const facts = await page(request.nodeKey)
444
+ const current = new Map<string, ApprovalStep>()
445
+ for (const step of steps)
446
+ if (step.state === 'waiting' || step.state === 'approved') current.set(step.stage, step)
447
+ const stages: RequestView['stages'] = []
448
+ for (const stage of request.workflow.stages) {
449
+ const step = current.get(stage.alias)
450
+ const given = step
451
+ ? history.filter((h) => h.kind === 'approved' && h.stepId === step.id).length
452
+ : 0
453
+ stages.push({
454
+ alias: stage.alias,
455
+ state: step
456
+ ? (step.state as 'waiting' | 'approved')
457
+ : steps.some((s) => s.stage === stage.alias)
458
+ ? 'discarded'
459
+ : 'pending',
460
+ approvals: given,
461
+ needed: approvalsNeeded(stage.required, await membersOf(stage.approvers)),
462
+ })
463
+ }
464
+ const waiting = request.state === 'waiting'
465
+ const actionable = waiting
466
+ ? request.workflow.stages.filter(
467
+ (s) => current.get(s.alias)?.state === 'waiting' && s.approvers.some((a) => mine.has(a)),
468
+ )
469
+ : []
470
+ const already = (alias: string) =>
471
+ history.some(
472
+ (h) =>
473
+ h.kind === 'approved' &&
474
+ h.stepId === current.get(alias)?.id &&
475
+ h.userKey === normaliseUuid(principal.id),
476
+ )
477
+ const ownChange = normaliseUuid(principal.id) === request.requestedBy
478
+ let variant = 'invariant'
479
+ if (facts?.variesByCulture) {
480
+ const fallback = await db.query<{ iso_code: string }>(
481
+ 'SELECT iso_code FROM language WHERE is_default = ?',
482
+ [db.dialect.boolValue(true)],
483
+ )
484
+ variant = request.cultures?.[0] ?? fallback[0]?.iso_code ?? 'invariant'
485
+ }
486
+ return {
487
+ key: request.key,
488
+ nodeKey: request.nodeKey,
489
+ nodeName: facts?.name ?? request.nodeKey,
490
+ variant,
491
+ action: request.action,
492
+ cultures: request.cultures,
493
+ state: request.state,
494
+ requestedBy: request.requestedBy,
495
+ requestedAt: request.requestedAt.toISOString(),
496
+ workflow: request.workflow,
497
+ stages,
498
+ history: history.map((h) => ({
499
+ kind: h.kind,
500
+ stage: h.stage,
501
+ userKey: h.userKey,
502
+ comment: h.comment,
503
+ at: h.createDate.toISOString(),
504
+ })),
505
+ can: {
506
+ approve:
507
+ ownChange && request.workflow.selfApproval === 'refused'
508
+ ? []
509
+ : actionable.filter((s) => !already(s.alias)).map((s) => s.alias),
510
+ sendBack: actionable.map((s) => s.alias),
511
+ withdraw: waiting && (ownChange || principal.isAdmin),
512
+ },
513
+ }
514
+ }
515
+
516
+ /** After a decision's transaction: publishes when it completed the workflow, then the request as it stands. */
517
+ const concluded = async (
518
+ principal: Principal,
519
+ outcome: { request: ApprovalRequest; complete: boolean },
520
+ ): Promise<RequestView> => {
521
+ if (outcome.complete) await finish(outcome.request, principal)
522
+ return view(principal, (await requests().byKey(outcome.request.key)) as ApprovalRequest)
523
+ }
524
+
525
+ const needsComment = (
526
+ workflow: SchemaWorkflow,
527
+ decision: 'approve' | 'send-back',
528
+ comment: string | undefined,
529
+ ) =>
530
+ (workflow.comments === 'always' ||
531
+ (workflow.comments === 'on-send-back' && decision === 'send-back')) &&
532
+ !comment?.trim()
533
+
534
+ const waitingRequest = async (requestKey: string): Promise<ApprovalRequest> => {
535
+ const request = await requests().byKey(requestKey)
536
+ if (!request) throw new ApprovalRefused('There is no such request.', 404)
537
+ if (request.state !== 'waiting')
538
+ throw new ApprovalRefused('This request has already been decided.', 409)
539
+ return request
540
+ }
541
+
542
+ return {
543
+ workflows: () => readWorkflows(db),
544
+
545
+ async publishRefusal(principal, key, action) {
546
+ const facts = await page(key)
547
+ if (!facts) return undefined
548
+ return refusalFor(principal, facts, await covering(facts), action)
549
+ },
550
+
551
+ async branchRefusal(principal, key) {
552
+ const root = await nodes.byKey(key)
553
+ if (!root) return undefined
554
+ const workflows = (await readWorkflows(db)).map((s) => s.workflow)
555
+ if (workflows.length === 0) return undefined
556
+ const below = await db.query<{ unique_id: string }>(
557
+ 'SELECT unique_id FROM node WHERE id = ? OR path LIKE ? ORDER BY level',
558
+ [root.id, `${root.path},%`],
559
+ )
560
+ for (const row of below) {
561
+ const facts = await page(String(row.unique_id))
562
+ if (!facts) continue
563
+ const refusal = refusalFor(principal, facts, await covering(facts), 'publish')
564
+ if (refusal) return refusal
565
+ }
566
+ return undefined
567
+ },
568
+
569
+ async newPageRefusal(principal, parentKey, contentTypeKey) {
570
+ const parent = parentKey ? await page(parentKey) : undefined
571
+ const type = await db.query<{ alias: string }>(
572
+ 'SELECT ct.alias FROM content_type ct JOIN node n ON n.id = ct.node_id WHERE n.unique_id = ?',
573
+ [normaliseUuid(contentTypeKey)],
574
+ )
575
+ const facts: PageFacts = {
576
+ key: '',
577
+ name: 'This page',
578
+ chain: parent?.chain ?? [],
579
+ contentTypeAlias: type[0]?.alias,
580
+ variesByCulture: false,
581
+ }
582
+ return refusalFor(principal, facts, await covering(facts), 'publish')
583
+ },
584
+
585
+ async beforeSave(principal, key) {
586
+ const request = await requests().waitingFor(key)
587
+ if (!request || principal.isAdmin) return undefined
588
+ const edit = request.workflow.onEdit
589
+ if (edit === 'lock')
590
+ return 'This page is waiting for approval and cannot be changed until it is decided.'
591
+ if (edit === 'lock-after-first-approval') {
592
+ const history = await requests().history(request.id)
593
+ if (history.some((h) => h.kind === 'approved'))
594
+ return 'This page has been approved by someone already, and cannot be changed until the request is decided.'
595
+ }
596
+ return undefined
597
+ },
598
+
599
+ async afterSave(principal, key) {
600
+ const repo = requests()
601
+ const request = await repo.waitingFor(key)
602
+ if (!request) return
603
+ const head =
604
+ (await new DocumentRepository(db, { nodeState: options.nodeState }).eventMarkers(key))
605
+ ?.head ?? null
606
+ if (head === request.eventId) return
607
+ // Administrators may always edit; the request goes on, on what they saved.
608
+ if (!principal.isAdmin && request.workflow.onEdit === 'withdraw') {
609
+ await repo.setState(request.id, 'withdrawn')
610
+ await repo.record(request.id, {
611
+ kind: 'withdrawn',
612
+ userKey: principal.id,
613
+ comment: 'the page was changed after it was sent',
614
+ })
615
+ return
616
+ }
617
+ await repo.setEvent(request.id, head)
618
+ await repo.record(request.id, { kind: 'edited', userKey: principal.id })
619
+ },
620
+
621
+ async send(principal, key, action, cultures, comment) {
622
+ const facts = await page(key)
623
+ if (!facts) throw new ApprovalRefused('The page could not be found.', 404)
624
+ const workflow = await covering(facts)
625
+ if (!workflow || !covers(workflow, action))
626
+ throw new ApprovalRefused(
627
+ action === 'publish'
628
+ ? 'No workflow covers this page: it can be published directly.'
629
+ : 'Taking this page down needs no approval.',
630
+ )
631
+ const stored = (await readWorkflows(db)).find(
632
+ (s) => (s.workflow.key ?? s.workflow.alias) === (workflow.key ?? workflow.alias),
633
+ )
634
+ const outcome = await db.transaction(async (tx) => {
635
+ const repo = requests(tx)
636
+ if (await repo.waitingFor(key))
637
+ throw new ApprovalRefused('This page is already waiting for approval.', 409)
638
+ const head =
639
+ (await new DocumentRepository(tx, { nodeState: options.nodeState }).eventMarkers(key))
640
+ ?.head ?? null
641
+ const request = await repo.create({
642
+ nodeKey: key,
643
+ cultures,
644
+ action,
645
+ eventId: head,
646
+ workflow,
647
+ workflowVersion: stored?.version ?? '',
648
+ requestedBy: principal.id,
649
+ })
650
+ await repo.record(request.id, {
651
+ kind: 'sent',
652
+ userKey: principal.id,
653
+ comment: comment?.trim() || null,
654
+ })
655
+ const first = startStages(workflow)
656
+ await reach(repo, request, first)
657
+ await notify(
658
+ 'approvers',
659
+ request,
660
+ first,
661
+ `Waiting for your approval: ${facts.name}`,
662
+ `${principal.name} sent "${facts.name}" for approval through "${workflow.name}".${comment ? `\n\n${comment}` : ''}`,
663
+ )
664
+ const complete = await settle(repo, request)
665
+ await auditStep(
666
+ principal,
667
+ facts,
668
+ action === 'publish' ? 'Sent for approval' : 'Requested unpublish',
669
+ `through "${workflow.name}"`,
670
+ )
671
+ return { request, complete }
672
+ })
673
+ return concluded(principal, outcome)
674
+ },
675
+
676
+ async approve(principal, requestKey, stage, comment) {
677
+ const request = await waitingRequest(requestKey)
678
+ const current = await view(principal, request)
679
+ const target = stage ?? current.can.approve[0]
680
+ if (!target || !current.can.approve.includes(target))
681
+ throw new ApprovalRefused(
682
+ normaliseUuid(principal.id) === request.requestedBy &&
683
+ request.workflow.selfApproval === 'refused'
684
+ ? 'You made this change, so someone else has to approve it.'
685
+ : 'There is no stage waiting for your approval on this request.',
686
+ 403,
687
+ )
688
+ if (needsComment(request.workflow, 'approve', comment))
689
+ throw new ApprovalRefused('This workflow asks for a comment with every decision.')
690
+ const outcome = await db.transaction(async (tx) => {
691
+ const repo = requests(tx)
692
+ const step = (await repo.steps(request.id)).find(
693
+ (s) => s.stage === target && s.state === 'waiting',
694
+ )
695
+ await repo.record(request.id, {
696
+ kind: 'approved',
697
+ stepId: step?.id ?? null,
698
+ stage: target,
699
+ userKey: principal.id,
700
+ comment: comment?.trim() || null,
701
+ })
702
+ const complete = await settle(repo, request)
703
+ await auditStep(
704
+ principal,
705
+ await page(request.nodeKey),
706
+ 'Approved',
707
+ `stage "${target}" of "${request.workflow.name}"`,
708
+ )
709
+ return { request, complete }
710
+ })
711
+ return concluded(principal, outcome)
712
+ },
713
+
714
+ async sendBack(principal, requestKey, stage, comment) {
715
+ const request = await waitingRequest(requestKey)
716
+ const current = await view(principal, request)
717
+ const from = stage ?? current.can.sendBack[0]
718
+ if (!from || !current.can.sendBack.includes(from))
719
+ throw new ApprovalRefused(
720
+ 'There is no stage waiting for your decision on this request.',
721
+ 403,
722
+ )
723
+ if (needsComment(request.workflow, 'send-back', comment))
724
+ throw new ApprovalRefused('Say why it is being sent back.')
725
+ const to = stageOf(request.workflow, from)?.sendBack ?? SEND_BACK_TO_EDITOR
726
+ const outcome = await db.transaction(async (tx) => {
727
+ const repo = requests(tx)
728
+ let complete = false
729
+ const steps = await repo.steps(request.id)
730
+ await repo.record(request.id, {
731
+ kind: 'sent-back',
732
+ stage: from,
733
+ userKey: principal.id,
734
+ comment: comment?.trim() || null,
735
+ })
736
+ const facts = await page(request.nodeKey)
737
+ if (to === SEND_BACK_TO_EDITOR) {
738
+ for (const step of steps.filter((s) => s.state === 'waiting' || s.state === 'approved'))
739
+ await repo.setStepState(step.id, step.stage === from ? 'sent-back' : 'discarded')
740
+ await repo.setState(request.id, 'sent-back')
741
+ await notify(
742
+ 'editor',
743
+ request,
744
+ [],
745
+ `Sent back: ${facts?.name ?? 'a page'}`,
746
+ `"${facts?.name}" was sent back${comment ? `:\n\n${comment}` : '.'}`,
747
+ )
748
+ } else {
749
+ // The stage it goes back to runs again, and so does everything after it.
750
+ const redo = new Set([to, ...descendantStages(request.workflow, to)])
751
+ for (const step of steps.filter(
752
+ (s) => (s.state === 'waiting' || s.state === 'approved') && redo.has(s.stage),
753
+ ))
754
+ await repo.setStepState(step.id, step.stage === from ? 'sent-back' : 'discarded')
755
+ await reach(repo, request, [to])
756
+ await notify(
757
+ 'approvers',
758
+ request,
759
+ [to],
760
+ `Back for your approval: ${facts?.name ?? 'a page'}`,
761
+ `"${facts?.name}" was sent back to you${comment ? `:\n\n${comment}` : '.'}`,
762
+ )
763
+ complete = await settle(repo, request)
764
+ }
765
+ await auditStep(
766
+ principal,
767
+ facts,
768
+ 'Sent back',
769
+ `from stage "${from}" of "${request.workflow.name}" to ${to === SEND_BACK_TO_EDITOR ? 'the editor' : `"${to}"`}`,
770
+ )
771
+ return { request, complete }
772
+ })
773
+ return concluded(principal, outcome)
774
+ },
775
+
776
+ async withdraw(principal, requestKey) {
777
+ const request = await waitingRequest(requestKey)
778
+ if (normaliseUuid(principal.id) !== request.requestedBy && !principal.isAdmin)
779
+ throw new ApprovalRefused(
780
+ 'Only the person who sent it, or an administrator, can withdraw it.',
781
+ 403,
782
+ )
783
+ await requests().setState(request.id, 'withdrawn')
784
+ await requests().record(request.id, { kind: 'withdrawn', userKey: principal.id })
785
+ await auditStep(
786
+ principal,
787
+ await page(request.nodeKey),
788
+ 'Withdrew approval request',
789
+ `under "${request.workflow.name}"`,
790
+ )
791
+ return view(principal, (await requests().byKey(request.key)) as ApprovalRequest)
792
+ },
793
+
794
+ async forPage(principal, key) {
795
+ const facts = await page(key)
796
+ if (!facts) throw new ApprovalRefused('The page could not be found.', 404)
797
+ const workflow = await covering(facts)
798
+ const all = await requests().forNode(key)
799
+ const waiting = all.find((r) => r.state === 'waiting')
800
+ return {
801
+ workflow,
802
+ request: waiting ? await view(principal, waiting) : undefined,
803
+ past: await Promise.all(
804
+ all
805
+ .filter((r) => r !== waiting)
806
+ .slice(0, 10)
807
+ .map((r) => view(principal, r)),
808
+ ),
809
+ can: {
810
+ send: !waiting && covers(workflow, 'publish'),
811
+ requestUnpublish: !waiting && covers(workflow, 'unpublish'),
812
+ publishDirectly: refusalFor(principal, facts, workflow, 'publish') === undefined,
813
+ },
814
+ }
815
+ },
816
+
817
+ async waitingFor(principal) {
818
+ const out: RequestView[] = []
819
+ for (const request of await requests().waiting()) {
820
+ const v = await view(principal, request)
821
+ if (v.can.approve.length > 0 || v.can.sendBack.length > 0) out.push(v)
822
+ }
823
+ return out
824
+ },
825
+
826
+ async inFlight(principal) {
827
+ return Promise.all((await requests().waiting()).map((r) => view(principal, r)))
828
+ },
829
+
830
+ async finished(principal, skip, take) {
831
+ const page = await requests().finished(skip, take)
832
+ return {
833
+ total: page.total,
834
+ items: await Promise.all(page.items.map((r) => view(principal, r))),
835
+ }
836
+ },
837
+ }
838
+ }