@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.
- package/package.json +12 -12
- package/src/adapters/bundles.ts +2 -1
- package/src/approvals-api.ts +189 -0
- package/src/approvals.ts +838 -0
- package/src/config.ts +22 -1
- package/src/marketplace.ts +37 -0
- package/src/ports.ts +15 -0
- package/src/server.ts +74 -31
package/src/approvals.ts
ADDED
|
@@ -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
|
+
}
|