@crombolo/core 0.12.1 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crombolo/core",
3
- "version": "0.12.1",
3
+ "version": "0.13.1",
4
4
  "description": "Domain model, entity keys and shared contracts for crombolo. No I/O.",
5
5
  "license": "MIT",
6
6
  "author": "Crombolo",
package/src/index.ts CHANGED
@@ -14,3 +14,4 @@ export * from './return-urls.ts'
14
14
  export * from './sections.ts'
15
15
  export * from './uploads.ts'
16
16
  export * from './users.ts'
17
+ export * from './workflows.ts'
package/src/sections.ts CHANGED
@@ -25,6 +25,8 @@ export const SECTION_ALIASES = {
25
25
  // commercial add-on there. Form building is in core here, so the section is
26
26
  // real and the alias resolves (`docs/forms.md`).
27
27
  forms: 'Crombolo.Section.Forms',
28
+ // Approval workflows: Umbraco Workflow's section, in core here (story 028).
29
+ workflow: 'Crombolo.Section.Workflow',
28
30
  } as const
29
31
 
30
32
  export type AppAlias = keyof typeof SECTION_ALIASES
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Approval workflows (story 028): a workflow is an acyclic graph of stages, each
3
+ * approved by named user groups. Arrows out of a stage are followed in
4
+ * parallel; a stage with several arrows in waits for all of them; when every
5
+ * last stage has approved, the page is published. `docs/workflows.md`.
6
+ *
7
+ * The model and the graph rules are here, pure, so the schema check, the
8
+ * engine and the designer agree on what a workflow means.
9
+ */
10
+
11
+ /** How many of a stage's approvers must approve: one, a majority, all, or a number. */
12
+ export type StageRequirement = 'one' | 'majority' | 'all' | number
13
+
14
+ /** What saving a page does while it waits for approval. */
15
+ export type WorkflowOnEdit = 'withdraw' | 'approve-latest' | 'lock' | 'lock-after-first-approval'
16
+
17
+ /** Whether the person who made the change may approve it. */
18
+ export type WorkflowSelfApproval = 'refused' | 'allowed' | 'automatic'
19
+
20
+ /** When a decision needs a comment. */
21
+ export type WorkflowComments = 'optional' | 'on-send-back' | 'always'
22
+
23
+ /** What a request in flight does when its workflow changes. */
24
+ export type WorkflowOnChange = 'keep' | 'restart' | 'withdraw'
25
+
26
+ export const WORKFLOW_ON_EDIT: readonly WorkflowOnEdit[] = [
27
+ 'withdraw',
28
+ 'approve-latest',
29
+ 'lock',
30
+ 'lock-after-first-approval',
31
+ ]
32
+ export const WORKFLOW_SELF_APPROVAL: readonly WorkflowSelfApproval[] = [
33
+ 'refused',
34
+ 'allowed',
35
+ 'automatic',
36
+ ]
37
+ export const WORKFLOW_COMMENTS: readonly WorkflowComments[] = ['optional', 'on-send-back', 'always']
38
+ export const WORKFLOW_ON_CHANGE: readonly WorkflowOnChange[] = ['keep', 'restart', 'withdraw']
39
+
40
+ /** Where a send-back from a stage goes. */
41
+ export const SEND_BACK_TO_EDITOR = 'editor'
42
+
43
+ export interface WorkflowStage {
44
+ alias: string
45
+ name?: string
46
+ /** User group aliases. */
47
+ approvers: string[]
48
+ required: StageRequirement
49
+ /** Stage aliases that follow this one; empty for a last stage. */
50
+ next: string[]
51
+ /** `editor`, or the alias of an earlier stage on this one's path. */
52
+ sendBack: string
53
+ /** Where the designer drew it. */
54
+ position?: { x: number; y: number }
55
+ }
56
+
57
+ export interface SchemaWorkflow {
58
+ key?: string
59
+ alias: string
60
+ name: string
61
+ appliesTo: { branches: string[]; contentTypes: string[] }
62
+ publishersMaySkip: boolean
63
+ onEdit: WorkflowOnEdit
64
+ selfApproval: WorkflowSelfApproval
65
+ comments: WorkflowComments
66
+ unpublish: boolean
67
+ onChange: WorkflowOnChange
68
+ stages: WorkflowStage[]
69
+ }
70
+
71
+ const byAlias = (workflow: SchemaWorkflow) => new Map(workflow.stages.map((s) => [s.alias, s]))
72
+
73
+ /** The stages with no arrow in: a valid workflow has exactly one. Pure. */
74
+ export function startStages(workflow: SchemaWorkflow): string[] {
75
+ const reached = new Set(workflow.stages.flatMap((s) => s.next))
76
+ return workflow.stages.filter((s) => !reached.has(s.alias)).map((s) => s.alias)
77
+ }
78
+
79
+ /** The stages with an arrow into this one. Pure. */
80
+ export function incomingStages(workflow: SchemaWorkflow, alias: string): string[] {
81
+ return workflow.stages.filter((s) => s.next.includes(alias)).map((s) => s.alias)
82
+ }
83
+
84
+ /** Every stage this one can be reached from. Pure. */
85
+ export function ancestorStages(workflow: SchemaWorkflow, alias: string): Set<string> {
86
+ const found = new Set<string>()
87
+ const visit = (at: string) => {
88
+ for (const parent of incomingStages(workflow, at)) {
89
+ if (found.has(parent)) continue
90
+ found.add(parent)
91
+ visit(parent)
92
+ }
93
+ }
94
+ visit(alias)
95
+ return found
96
+ }
97
+
98
+ /** Every stage reachable from this one, itself excluded. Pure. */
99
+ export function descendantStages(workflow: SchemaWorkflow, alias: string): Set<string> {
100
+ const stages = byAlias(workflow)
101
+ const found = new Set<string>()
102
+ const visit = (at: string) => {
103
+ for (const child of stages.get(at)?.next ?? []) {
104
+ if (found.has(child)) continue
105
+ found.add(child)
106
+ visit(child)
107
+ }
108
+ }
109
+ visit(alias)
110
+ return found
111
+ }
112
+
113
+ /** Whether adding an arrow `from` → `to` would close a loop. Pure. */
114
+ export function wouldLoop(workflow: SchemaWorkflow, from: string, to: string): boolean {
115
+ return from === to || descendantStages(workflow, to).has(from)
116
+ }
117
+
118
+ /** What is wrong with a workflow's graph, in words; empty when it is sound. Pure. */
119
+ export function workflowGraphProblems(
120
+ workflow: SchemaWorkflow,
121
+ ): Array<{ path: string; message: string }> {
122
+ const problems: Array<{ path: string; message: string }> = []
123
+ const seen = new Set<string>()
124
+ workflow.stages.forEach((stage, i) => {
125
+ if (seen.has(stage.alias))
126
+ problems.push({ path: `stage[${i}].alias`, message: `"${stage.alias}" names two stages` })
127
+ seen.add(stage.alias)
128
+ })
129
+ if (workflow.stages.length === 0)
130
+ return [{ path: 'stage', message: 'a workflow needs at least one stage' }]
131
+ workflow.stages.forEach((stage, i) => {
132
+ for (const next of stage.next)
133
+ if (!seen.has(next))
134
+ problems.push({ path: `stage[${i}].next`, message: `there is no stage "${next}"` })
135
+ if (stage.approvers.length === 0)
136
+ problems.push({ path: `stage[${i}].approvers`, message: 'a stage needs an approver group' })
137
+ if (
138
+ typeof stage.required === 'number' &&
139
+ (!Number.isInteger(stage.required) || stage.required < 1)
140
+ )
141
+ problems.push({ path: `stage[${i}].required`, message: 'a number of approvers is 1 or more' })
142
+ })
143
+ if (problems.length > 0) return problems
144
+
145
+ // A loop first: the rest of the rules assume there is none.
146
+ const state = new Map<string, 'visiting' | 'done'>()
147
+ const stages = byAlias(workflow)
148
+ let loop: string | undefined
149
+ const visit = (alias: string): void => {
150
+ if (loop) return
151
+ state.set(alias, 'visiting')
152
+ for (const next of stages.get(alias)?.next ?? []) {
153
+ if (state.get(next) === 'visiting') loop = `${alias} → ${next}`
154
+ else if (!state.has(next)) visit(next)
155
+ }
156
+ state.set(alias, 'done')
157
+ }
158
+ for (const stage of workflow.stages) if (!state.has(stage.alias)) visit(stage.alias)
159
+ if (loop) return [{ path: 'stage', message: `the stages loop (${loop})` }]
160
+
161
+ const starts = startStages(workflow)
162
+ if (starts.length !== 1)
163
+ problems.push({
164
+ path: 'stage',
165
+ message: `a workflow starts at one stage, and this one starts at ${starts.length}: ${starts.join(', ')}`,
166
+ })
167
+ workflow.stages.forEach((stage, i) => {
168
+ if (stage.sendBack === SEND_BACK_TO_EDITOR) return
169
+ if (!ancestorStages(workflow, stage.alias).has(stage.sendBack))
170
+ problems.push({
171
+ path: `stage[${i}].send-back`,
172
+ message: `"${stage.alias}" can send back only to the editor or a stage before it, and "${stage.sendBack}" is not`,
173
+ })
174
+ })
175
+ return problems
176
+ }
177
+
178
+ /** How many approvals a stage needs, given how many people its groups hold. Pure. */
179
+ export function approvalsNeeded(required: StageRequirement, members: number): number {
180
+ const available = Math.max(members, 1)
181
+ if (required === 'one') return 1
182
+ if (required === 'all') return available
183
+ if (required === 'majority') return Math.floor(available / 2) + 1
184
+ // More than the groups hold could never be met; the groups are the ceiling.
185
+ return Math.min(required, available)
186
+ }
187
+
188
+ /**
189
+ * The stages that become waiting now that `approved` are done: every stage
190
+ * whose arrows in all come from approved stages, not already reached. Pure.
191
+ */
192
+ export function stagesNowWaiting(
193
+ workflow: SchemaWorkflow,
194
+ approved: ReadonlySet<string>,
195
+ reached: ReadonlySet<string>,
196
+ ): string[] {
197
+ return workflow.stages
198
+ .filter((stage) => !reached.has(stage.alias))
199
+ .filter((stage) => {
200
+ const from = incomingStages(workflow, stage.alias)
201
+ return from.length > 0 && from.every((alias) => approved.has(alias))
202
+ })
203
+ .map((stage) => stage.alias)
204
+ }
205
+
206
+ /** Whether every last stage has approved, which publishes the page. Pure. */
207
+ export function workflowComplete(workflow: SchemaWorkflow, approved: ReadonlySet<string>): boolean {
208
+ return workflow.stages.filter((s) => s.next.length === 0).every((s) => approved.has(s.alias))
209
+ }
210
+
211
+ /**
212
+ * The workflow that covers a page: the nearest branch root among the page and
213
+ * its ancestors (nearest first), and only then one naming its content type. Pure.
214
+ */
215
+ export function workflowFor(
216
+ workflows: readonly SchemaWorkflow[],
217
+ chain: readonly string[],
218
+ contentTypeAlias: string | undefined,
219
+ ): SchemaWorkflow | undefined {
220
+ const lower = (value: string) => value.toLowerCase()
221
+ for (const key of [...chain].reverse()) {
222
+ const found = workflows.find((w) => w.appliesTo.branches.some((b) => lower(b) === lower(key)))
223
+ if (found) return found
224
+ }
225
+ if (!contentTypeAlias) return undefined
226
+ return workflows.find((w) => w.appliesTo.contentTypes.includes(contentTypeAlias))
227
+ }