@crombolo/data 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 +2 -2
- package/src/index.ts +3 -0
- package/src/repositories/approvals.ts +277 -0
- package/src/repositories/redirects.ts +30 -45
- package/src/schema/approvals.ts +77 -0
- package/src/schema/plan.ts +3 -0
- package/src/schema/seed.ts +3 -2
- package/src/workflow-store.ts +114 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crombolo/data",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.1",
|
|
4
4
|
"description": "SQLite and Postgres storage for crombolo: schema, migrations and repositories.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Crombolo",
|
|
@@ -28,6 +28,6 @@
|
|
|
28
28
|
"access": "public"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@crombolo/core": "0.
|
|
31
|
+
"@crombolo/core": "0.13.1"
|
|
32
32
|
}
|
|
33
33
|
}
|
package/src/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ export * from './migrations.ts'
|
|
|
12
12
|
export * from './node-ref.ts'
|
|
13
13
|
export * from './pending.ts'
|
|
14
14
|
export * from './relations.ts'
|
|
15
|
+
export * from './repositories/approvals.ts'
|
|
15
16
|
export * from './repositories/bundles.ts'
|
|
16
17
|
export * from './repositories/content-types.ts'
|
|
17
18
|
export * from './repositories/data-types.ts'
|
|
@@ -38,6 +39,7 @@ export * from './repositories/transfer-runs.ts'
|
|
|
38
39
|
export * from './repositories/user-data.ts'
|
|
39
40
|
export * from './repositories/users.ts'
|
|
40
41
|
export * from './repositories/webhooks.ts'
|
|
42
|
+
export * from './schema/approvals.ts'
|
|
41
43
|
export * from './schema/assistant.ts'
|
|
42
44
|
export * from './schema/audit-log.ts'
|
|
43
45
|
export * from './schema/built-in-content-types.ts'
|
|
@@ -81,3 +83,4 @@ export * from './search-text.ts'
|
|
|
81
83
|
export * from './setup.ts'
|
|
82
84
|
export * from './tree-invariants.ts'
|
|
83
85
|
export * from './url-options.ts'
|
|
86
|
+
export * from './workflow-store.ts'
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Approval requests (story 028): a page sent for approval, the stages it has
|
|
3
|
+
* reached, and its history. What the rules decide is the engine's
|
|
4
|
+
* (`packages/server/src/approvals.ts`); this only keeps the record.
|
|
5
|
+
* docs/workflows.md.
|
|
6
|
+
*/
|
|
7
|
+
import { normaliseUuid, type SchemaWorkflow } from '@crombolo/core'
|
|
8
|
+
import type { Db } from '../database.ts'
|
|
9
|
+
import { DbDate } from '../dialect.ts'
|
|
10
|
+
|
|
11
|
+
export type ApprovalAction = 'publish' | 'unpublish'
|
|
12
|
+
export type ApprovalState = 'waiting' | 'approved' | 'sent-back' | 'withdrawn'
|
|
13
|
+
export type ApprovalStepState = 'waiting' | 'approved' | 'sent-back' | 'discarded'
|
|
14
|
+
export type ApprovalHistoryKind =
|
|
15
|
+
| 'sent'
|
|
16
|
+
| 'approved'
|
|
17
|
+
| 'sent-back'
|
|
18
|
+
| 'withdrawn'
|
|
19
|
+
| 'edited'
|
|
20
|
+
| 'restarted'
|
|
21
|
+
| 'published'
|
|
22
|
+
| 'unpublished'
|
|
23
|
+
|
|
24
|
+
export interface ApprovalRequest {
|
|
25
|
+
id: number
|
|
26
|
+
key: string
|
|
27
|
+
nodeKey: string
|
|
28
|
+
/** Null for the invariant variant, or the whole page. */
|
|
29
|
+
cultures: string[] | null
|
|
30
|
+
action: ApprovalAction
|
|
31
|
+
/** The page's head event when it was sent, or after the last edit the workflow let through. */
|
|
32
|
+
eventId: number | null
|
|
33
|
+
workflowKey: string
|
|
34
|
+
workflowVersion: string
|
|
35
|
+
/** The workflow as it stood when the request started, or was restarted. */
|
|
36
|
+
workflow: SchemaWorkflow
|
|
37
|
+
state: ApprovalState
|
|
38
|
+
/** The user's key. */
|
|
39
|
+
requestedBy: string
|
|
40
|
+
requestedAt: Date
|
|
41
|
+
finishedAt: Date | null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ApprovalStep {
|
|
45
|
+
id: number
|
|
46
|
+
requestId: number
|
|
47
|
+
stage: string
|
|
48
|
+
state: ApprovalStepState
|
|
49
|
+
reachedAt: Date
|
|
50
|
+
doneAt: Date | null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ApprovalHistoryEntry {
|
|
54
|
+
id: number
|
|
55
|
+
requestId: number
|
|
56
|
+
/** The stage visit it belongs to: approvals count only toward the visit they were given in. */
|
|
57
|
+
stepId: number | null
|
|
58
|
+
stage: string | null
|
|
59
|
+
kind: ApprovalHistoryKind
|
|
60
|
+
userKey: string | null
|
|
61
|
+
comment: string | null
|
|
62
|
+
createDate: Date
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const requestFrom = (row: Record<string, unknown>): ApprovalRequest => ({
|
|
66
|
+
id: Number(row.id),
|
|
67
|
+
key: normaliseUuid(String(row.key)),
|
|
68
|
+
nodeKey: normaliseUuid(String(row.node_key)),
|
|
69
|
+
cultures: row.cultures ? (JSON.parse(String(row.cultures)) as string[]) : null,
|
|
70
|
+
action: row.action === 'unpublish' ? 'unpublish' : 'publish',
|
|
71
|
+
eventId: row.event_id === null || row.event_id === undefined ? null : Number(row.event_id),
|
|
72
|
+
workflowKey: String(row.workflow_key),
|
|
73
|
+
workflowVersion: String(row.workflow_version),
|
|
74
|
+
workflow: JSON.parse(String(row.workflow)) as SchemaWorkflow,
|
|
75
|
+
state: String(row.state) as ApprovalState,
|
|
76
|
+
requestedBy: normaliseUuid(String(row.requested_by)),
|
|
77
|
+
requestedAt: DbDate.fromDb(row.requested_at) ?? new Date(0),
|
|
78
|
+
finishedAt: DbDate.fromDb(row.finished_at) ?? null,
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
const stepFrom = (row: Record<string, unknown>): ApprovalStep => ({
|
|
82
|
+
id: Number(row.id),
|
|
83
|
+
requestId: Number(row.request_id),
|
|
84
|
+
stage: String(row.stage),
|
|
85
|
+
state: String(row.state) as ApprovalStepState,
|
|
86
|
+
reachedAt: DbDate.fromDb(row.reached_at) ?? new Date(0),
|
|
87
|
+
doneAt: DbDate.fromDb(row.done_at) ?? null,
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
const historyFrom = (row: Record<string, unknown>): ApprovalHistoryEntry => ({
|
|
91
|
+
id: Number(row.id),
|
|
92
|
+
requestId: Number(row.request_id),
|
|
93
|
+
stepId: row.step_id === null || row.step_id === undefined ? null : Number(row.step_id),
|
|
94
|
+
stage: row.stage === null || row.stage === undefined ? null : String(row.stage),
|
|
95
|
+
kind: String(row.kind) as ApprovalHistoryKind,
|
|
96
|
+
userKey:
|
|
97
|
+
row.user_key === null || row.user_key === undefined
|
|
98
|
+
? null
|
|
99
|
+
: normaliseUuid(String(row.user_key)),
|
|
100
|
+
comment: row.comment === null || row.comment === undefined ? null : String(row.comment),
|
|
101
|
+
createDate: DbDate.fromDb(row.create_date) ?? new Date(0),
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
/** The workflow's identity on a request: its key, or its alias when it has none. Pure. */
|
|
105
|
+
export const workflowIdentity = (workflow: SchemaWorkflow): string => workflow.key ?? workflow.alias
|
|
106
|
+
|
|
107
|
+
export class ApprovalRepository {
|
|
108
|
+
#db: Db
|
|
109
|
+
constructor(db: Db) {
|
|
110
|
+
this.#db = db
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async create(input: {
|
|
114
|
+
nodeKey: string
|
|
115
|
+
cultures: string[] | null
|
|
116
|
+
action: ApprovalAction
|
|
117
|
+
eventId: number | null
|
|
118
|
+
workflow: SchemaWorkflow
|
|
119
|
+
workflowVersion: string
|
|
120
|
+
/** The user's key. */
|
|
121
|
+
requestedBy: string
|
|
122
|
+
}): Promise<ApprovalRequest> {
|
|
123
|
+
const key = crypto.randomUUID()
|
|
124
|
+
await this.#db.exec(
|
|
125
|
+
`INSERT INTO approval_request (key, node_key, cultures, action, event_id, workflow_key,
|
|
126
|
+
workflow_version, workflow, state, requested_by, requested_at)
|
|
127
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'waiting', ?, ?)`,
|
|
128
|
+
[
|
|
129
|
+
key,
|
|
130
|
+
normaliseUuid(input.nodeKey),
|
|
131
|
+
input.cultures ? JSON.stringify(input.cultures) : null,
|
|
132
|
+
input.action,
|
|
133
|
+
input.eventId,
|
|
134
|
+
workflowIdentity(input.workflow),
|
|
135
|
+
input.workflowVersion,
|
|
136
|
+
JSON.stringify(input.workflow),
|
|
137
|
+
normaliseUuid(input.requestedBy),
|
|
138
|
+
DbDate.toDb(new Date()),
|
|
139
|
+
],
|
|
140
|
+
)
|
|
141
|
+
return (await this.byKey(key)) as ApprovalRequest
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async byKey(key: string): Promise<ApprovalRequest | undefined> {
|
|
145
|
+
const rows = await this.#db.query('SELECT * FROM approval_request WHERE key = ?', [
|
|
146
|
+
normaliseUuid(key),
|
|
147
|
+
])
|
|
148
|
+
return rows[0] ? requestFrom(rows[0]) : undefined
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The request a page is waiting on, if any: at most one at a time. */
|
|
152
|
+
async waitingFor(nodeKey: string): Promise<ApprovalRequest | undefined> {
|
|
153
|
+
const rows = await this.#db.query(
|
|
154
|
+
"SELECT * FROM approval_request WHERE node_key = ? AND state = 'waiting' ORDER BY id DESC",
|
|
155
|
+
[normaliseUuid(nodeKey)],
|
|
156
|
+
)
|
|
157
|
+
return rows[0] ? requestFrom(rows[0]) : undefined
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** A page's requests, newest first. */
|
|
161
|
+
async forNode(nodeKey: string, limit = 20): Promise<ApprovalRequest[]> {
|
|
162
|
+
const rows = await this.#db.query(
|
|
163
|
+
'SELECT * FROM approval_request WHERE node_key = ? ORDER BY id DESC LIMIT ?',
|
|
164
|
+
[normaliseUuid(nodeKey), limit],
|
|
165
|
+
)
|
|
166
|
+
return rows.map(requestFrom)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Every request still waiting, oldest first. */
|
|
170
|
+
async waiting(): Promise<ApprovalRequest[]> {
|
|
171
|
+
const rows = await this.#db.query(
|
|
172
|
+
"SELECT * FROM approval_request WHERE state = 'waiting' ORDER BY id",
|
|
173
|
+
)
|
|
174
|
+
return rows.map(requestFrom)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Requests that have finished, newest first. */
|
|
178
|
+
async finished(skip: number, take: number): Promise<{ total: number; items: ApprovalRequest[] }> {
|
|
179
|
+
const counted = await this.#db.query<{ n: number }>(
|
|
180
|
+
"SELECT COUNT(*) AS n FROM approval_request WHERE state <> 'waiting'",
|
|
181
|
+
)
|
|
182
|
+
const rows = await this.#db.query(
|
|
183
|
+
"SELECT * FROM approval_request WHERE state <> 'waiting' ORDER BY id DESC LIMIT ? OFFSET ?",
|
|
184
|
+
[take, skip],
|
|
185
|
+
)
|
|
186
|
+
return { total: Number(counted[0]?.n ?? 0), items: rows.map(requestFrom) }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async waitingUnder(workflowKey: string): Promise<ApprovalRequest[]> {
|
|
190
|
+
const rows = await this.#db.query(
|
|
191
|
+
"SELECT * FROM approval_request WHERE workflow_key = ? AND state = 'waiting' ORDER BY id",
|
|
192
|
+
[workflowKey],
|
|
193
|
+
)
|
|
194
|
+
return rows.map(requestFrom)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async setState(id: number, state: ApprovalState): Promise<void> {
|
|
198
|
+
await this.#db.exec('UPDATE approval_request SET state = ?, finished_at = ? WHERE id = ?', [
|
|
199
|
+
state,
|
|
200
|
+
state === 'waiting' ? null : DbDate.toDb(new Date()),
|
|
201
|
+
id,
|
|
202
|
+
])
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async setEvent(id: number, eventId: number | null): Promise<void> {
|
|
206
|
+
await this.#db.exec('UPDATE approval_request SET event_id = ? WHERE id = ?', [eventId, id])
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Puts a request under another version of its workflow, starting over: its
|
|
211
|
+
* stage visits are cleared, and its history keeps what happened.
|
|
212
|
+
*/
|
|
213
|
+
async rebase(id: number, workflow: SchemaWorkflow, version: string): Promise<void> {
|
|
214
|
+
await this.#db.exec(
|
|
215
|
+
'UPDATE approval_request SET workflow = ?, workflow_version = ? WHERE id = ?',
|
|
216
|
+
[JSON.stringify(workflow), version, id],
|
|
217
|
+
)
|
|
218
|
+
await this.#db.exec('DELETE FROM approval_step WHERE request_id = ?', [id])
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async steps(requestId: number): Promise<ApprovalStep[]> {
|
|
222
|
+
const rows = await this.#db.query(
|
|
223
|
+
'SELECT * FROM approval_step WHERE request_id = ? ORDER BY id',
|
|
224
|
+
[requestId],
|
|
225
|
+
)
|
|
226
|
+
return rows.map(stepFrom)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async addStep(requestId: number, stage: string): Promise<number> {
|
|
230
|
+
const rows = await this.#db.query<{ id: number }>(
|
|
231
|
+
"INSERT INTO approval_step (request_id, stage, state, reached_at) VALUES (?, ?, 'waiting', ?) RETURNING id",
|
|
232
|
+
[requestId, stage, DbDate.toDb(new Date())],
|
|
233
|
+
)
|
|
234
|
+
return Number(rows[0]?.id)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async setStepState(id: number, state: ApprovalStepState): Promise<void> {
|
|
238
|
+
await this.#db.exec('UPDATE approval_step SET state = ?, done_at = ? WHERE id = ?', [
|
|
239
|
+
state,
|
|
240
|
+
state === 'waiting' ? null : DbDate.toDb(new Date()),
|
|
241
|
+
id,
|
|
242
|
+
])
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async record(
|
|
246
|
+
requestId: number,
|
|
247
|
+
entry: {
|
|
248
|
+
kind: ApprovalHistoryKind
|
|
249
|
+
stepId?: number | null
|
|
250
|
+
stage?: string | null
|
|
251
|
+
userKey?: string | null
|
|
252
|
+
comment?: string | null
|
|
253
|
+
},
|
|
254
|
+
): Promise<void> {
|
|
255
|
+
await this.#db.exec(
|
|
256
|
+
`INSERT INTO approval_history (request_id, step_id, stage, kind, user_key, comment, create_date)
|
|
257
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
258
|
+
[
|
|
259
|
+
requestId,
|
|
260
|
+
entry.stepId ?? null,
|
|
261
|
+
entry.stage ?? null,
|
|
262
|
+
entry.kind,
|
|
263
|
+
entry.userKey ? normaliseUuid(entry.userKey) : null,
|
|
264
|
+
entry.comment ?? null,
|
|
265
|
+
DbDate.toDb(new Date()),
|
|
266
|
+
],
|
|
267
|
+
)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async history(requestId: number): Promise<ApprovalHistoryEntry[]> {
|
|
271
|
+
const rows = await this.#db.query(
|
|
272
|
+
'SELECT * FROM approval_history WHERE request_id = ? ORDER BY id',
|
|
273
|
+
[requestId],
|
|
274
|
+
)
|
|
275
|
+
return rows.map(historyFrom)
|
|
276
|
+
}
|
|
277
|
+
}
|
|
@@ -163,55 +163,40 @@ export class RedirectRepository {
|
|
|
163
163
|
/**
|
|
164
164
|
* Stores a rule, replacing any existing one matching the same thing. The
|
|
165
165
|
* returned row carries the key, which is a fresh one only when the rule is new.
|
|
166
|
+
*
|
|
167
|
+
* One statement, not a read then a write: two nodes recording the same retired
|
|
168
|
+
* route at once would both find nothing and the second insert would hit the
|
|
169
|
+
* unique index. The upsert lets the database decide which one is new.
|
|
166
170
|
*/
|
|
167
171
|
async save(input: RedirectInput): Promise<RedirectRow> {
|
|
168
172
|
const hash = matchHash(input)
|
|
169
173
|
const now = new Date()
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
target_kind, target, status_code, sort_order, match_hash, create_date)
|
|
197
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
198
|
-
[
|
|
199
|
-
key,
|
|
200
|
-
input.source,
|
|
201
|
-
input.matchKind,
|
|
202
|
-
input.pattern,
|
|
203
|
-
input.rootKey,
|
|
204
|
-
input.culture,
|
|
205
|
-
input.targetKind,
|
|
206
|
-
input.target,
|
|
207
|
-
input.statusCode,
|
|
208
|
-
input.sortOrder,
|
|
209
|
-
hash,
|
|
210
|
-
DbDate.toDb(now),
|
|
211
|
-
],
|
|
212
|
-
)
|
|
213
|
-
return { ...input, key, createDate: now }
|
|
214
|
-
})
|
|
174
|
+
const rows = await this.#db.query<{ key: string }>(
|
|
175
|
+
`INSERT INTO redirect_url
|
|
176
|
+
(key, source, match_kind, pattern, root_key, culture,
|
|
177
|
+
target_kind, target, status_code, sort_order, match_hash, create_date)
|
|
178
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
179
|
+
ON CONFLICT (match_hash) DO UPDATE SET
|
|
180
|
+
target_kind = excluded.target_kind, target = excluded.target,
|
|
181
|
+
status_code = excluded.status_code, sort_order = excluded.sort_order,
|
|
182
|
+
create_date = excluded.create_date
|
|
183
|
+
RETURNING key`,
|
|
184
|
+
[
|
|
185
|
+
crypto.randomUUID(),
|
|
186
|
+
input.source,
|
|
187
|
+
input.matchKind,
|
|
188
|
+
input.pattern,
|
|
189
|
+
input.rootKey,
|
|
190
|
+
input.culture,
|
|
191
|
+
input.targetKind,
|
|
192
|
+
input.target,
|
|
193
|
+
input.statusCode,
|
|
194
|
+
input.sortOrder,
|
|
195
|
+
hash,
|
|
196
|
+
DbDate.toDb(now),
|
|
197
|
+
],
|
|
198
|
+
)
|
|
199
|
+
return { ...input, key: normaliseUuid(String(rows[0]?.key)), createDate: now }
|
|
215
200
|
}
|
|
216
201
|
|
|
217
202
|
/** Deletes one rule. `false` means there was nothing with that key. */
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migration 038 — approval requests (story 028): a request per page sent for
|
|
3
|
+
* approval, a row per stage it reaches, and its history. The workflow it runs
|
|
4
|
+
* under is copied onto the request, so a workflow changed later does not
|
|
5
|
+
* change one already in flight unless the workflow says so. Also gives the
|
|
6
|
+
* groups that edit content the Workflow section. docs/workflows.md.
|
|
7
|
+
*/
|
|
8
|
+
import type { Db } from '../database.ts'
|
|
9
|
+
import type { Migration } from '../migrations.ts'
|
|
10
|
+
import { STATE_DELIVERY_MEMBER_TOKENS } from './delivery-member-tokens.ts'
|
|
11
|
+
|
|
12
|
+
export const STATE_APPROVALS = '{8e4c0a3d-0038-4b62-9c07-3d5f2b0e6c48}'
|
|
13
|
+
|
|
14
|
+
/** The section the Workflow screens live in, given where content is edited. */
|
|
15
|
+
export const WORKFLOW_SECTION_GROUPS = ['admin', 'editor', 'writer']
|
|
16
|
+
|
|
17
|
+
export const approvalsMigration: Migration = {
|
|
18
|
+
from: STATE_DELIVERY_MEMBER_TOKENS,
|
|
19
|
+
to: STATE_APPROVALS,
|
|
20
|
+
name: 'AddApprovals',
|
|
21
|
+
kind: 'expand',
|
|
22
|
+
release: '0.13.1',
|
|
23
|
+
async up(db: Db) {
|
|
24
|
+
const t = db.dialect.types
|
|
25
|
+
await db.exec(
|
|
26
|
+
`CREATE TABLE approval_request (
|
|
27
|
+
id ${t.identity},
|
|
28
|
+
key ${t.uuid} NOT NULL,
|
|
29
|
+
node_key ${t.uuid} NOT NULL,
|
|
30
|
+
cultures ${t.text},
|
|
31
|
+
action ${t.varchar(16)} NOT NULL,
|
|
32
|
+
event_id ${t.bigint},
|
|
33
|
+
workflow_key ${t.varchar(64)} NOT NULL,
|
|
34
|
+
workflow_version ${t.varchar(32)} NOT NULL,
|
|
35
|
+
workflow ${t.json} NOT NULL,
|
|
36
|
+
state ${t.varchar(16)} NOT NULL,
|
|
37
|
+
requested_by ${t.varchar(64)} NOT NULL,
|
|
38
|
+
requested_at ${t.timestamp} NOT NULL,
|
|
39
|
+
finished_at ${t.timestamp}
|
|
40
|
+
)`,
|
|
41
|
+
)
|
|
42
|
+
await db.exec('CREATE UNIQUE INDEX ux_approval_request_key ON approval_request (key)')
|
|
43
|
+
await db.exec('CREATE INDEX ix_approval_request_node ON approval_request (node_key, state)')
|
|
44
|
+
await db.exec(
|
|
45
|
+
`CREATE TABLE approval_step (
|
|
46
|
+
id ${t.identity},
|
|
47
|
+
request_id ${t.integer} NOT NULL,
|
|
48
|
+
stage ${t.varchar(64)} NOT NULL,
|
|
49
|
+
state ${t.varchar(16)} NOT NULL,
|
|
50
|
+
reached_at ${t.timestamp} NOT NULL,
|
|
51
|
+
done_at ${t.timestamp}
|
|
52
|
+
)`,
|
|
53
|
+
)
|
|
54
|
+
await db.exec('CREATE INDEX ix_approval_step_request ON approval_step (request_id, state)')
|
|
55
|
+
await db.exec(
|
|
56
|
+
`CREATE TABLE approval_history (
|
|
57
|
+
id ${t.identity},
|
|
58
|
+
request_id ${t.integer} NOT NULL,
|
|
59
|
+
step_id ${t.integer},
|
|
60
|
+
stage ${t.varchar(64)},
|
|
61
|
+
kind ${t.varchar(16)} NOT NULL,
|
|
62
|
+
user_key ${t.varchar(64)},
|
|
63
|
+
comment ${t.text},
|
|
64
|
+
create_date ${t.timestamp} NOT NULL
|
|
65
|
+
)`,
|
|
66
|
+
)
|
|
67
|
+
await db.exec('CREATE INDEX ix_approval_history_request ON approval_history (request_id)')
|
|
68
|
+
for (const alias of WORKFLOW_SECTION_GROUPS)
|
|
69
|
+
await db.exec(
|
|
70
|
+
`INSERT INTO user_group_section (user_group_id, app_alias)
|
|
71
|
+
SELECT g.id, 'workflow' FROM user_group g
|
|
72
|
+
WHERE g.alias = ? AND NOT EXISTS (
|
|
73
|
+
SELECT 1 FROM user_group_section s WHERE s.user_group_id = g.id AND s.app_alias = 'workflow')`,
|
|
74
|
+
[alias],
|
|
75
|
+
)
|
|
76
|
+
},
|
|
77
|
+
}
|
package/src/schema/plan.ts
CHANGED
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
* may only ship in a major (`x.0.0`), because every minor and patch is rolled
|
|
12
12
|
* under live nodes. The first contract will therefore name 1.0.0 at the earliest.
|
|
13
13
|
*/
|
|
14
|
+
|
|
14
15
|
import { MigrationPlan } from '../migrations.ts'
|
|
16
|
+
import { approvalsMigration } from './approvals.ts'
|
|
15
17
|
import { assistantMigration } from './assistant.ts'
|
|
16
18
|
import { auditLogMigration } from './audit-log.ts'
|
|
17
19
|
import { bundleComponentsMigration } from './bundle-components.ts'
|
|
@@ -88,4 +90,5 @@ export const cromboloPlan = new MigrationPlan([
|
|
|
88
90
|
webhooksMigration,
|
|
89
91
|
transferAnswersMigration,
|
|
90
92
|
deliveryMemberTokensMigration,
|
|
93
|
+
approvalsMigration,
|
|
91
94
|
])
|
package/src/schema/seed.ts
CHANGED
|
@@ -160,6 +160,7 @@ export const BUILT_IN_GROUPS = [
|
|
|
160
160
|
'forms',
|
|
161
161
|
'translation',
|
|
162
162
|
'library',
|
|
163
|
+
'workflow',
|
|
163
164
|
],
|
|
164
165
|
},
|
|
165
166
|
{
|
|
@@ -169,7 +170,7 @@ export const BUILT_IN_GROUPS = [
|
|
|
169
170
|
name: 'Writers',
|
|
170
171
|
icon: 'icon-edit',
|
|
171
172
|
hasAccessToAllLanguages: true,
|
|
172
|
-
sections: ['content', 'library'],
|
|
173
|
+
sections: ['content', 'library', 'workflow'],
|
|
173
174
|
},
|
|
174
175
|
{
|
|
175
176
|
id: 3,
|
|
@@ -178,7 +179,7 @@ export const BUILT_IN_GROUPS = [
|
|
|
178
179
|
name: 'Editors',
|
|
179
180
|
icon: 'icon-tools',
|
|
180
181
|
hasAccessToAllLanguages: true,
|
|
181
|
-
sections: ['content', 'media', 'forms', 'library'],
|
|
182
|
+
sections: ['content', 'media', 'forms', 'library', 'workflow'],
|
|
182
183
|
},
|
|
183
184
|
{
|
|
184
185
|
id: 4,
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The approval workflows as last synced from `schema/workflows/` (story 028),
|
|
3
|
+
* each with its version, so a running node reads them without the files and a
|
|
4
|
+
* sync can tell which changed. docs/workflows.md.
|
|
5
|
+
*/
|
|
6
|
+
import { type SchemaWorkflow, startStages } from '@crombolo/core'
|
|
7
|
+
import type { Db } from './database.ts'
|
|
8
|
+
import { ApprovalRepository } from './repositories/approvals.ts'
|
|
9
|
+
|
|
10
|
+
export const WORKFLOWS_KEY = 'crombolo:workflows'
|
|
11
|
+
|
|
12
|
+
export interface StoredWorkflow {
|
|
13
|
+
workflow: SchemaWorkflow
|
|
14
|
+
version: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Stored workflows read leniently: anything unreadable is none. Pure. */
|
|
18
|
+
export function parseStoredWorkflows(value: string | undefined): StoredWorkflow[] {
|
|
19
|
+
if (!value) return []
|
|
20
|
+
try {
|
|
21
|
+
const parsed = JSON.parse(value) as unknown
|
|
22
|
+
return Array.isArray(parsed)
|
|
23
|
+
? parsed.filter(
|
|
24
|
+
(w): w is StoredWorkflow =>
|
|
25
|
+
!!w && typeof w === 'object' && 'workflow' in w && 'version' in w,
|
|
26
|
+
)
|
|
27
|
+
: []
|
|
28
|
+
} catch {
|
|
29
|
+
return []
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function readWorkflows(db: Db): Promise<StoredWorkflow[]> {
|
|
34
|
+
const rows = await db.query<{ value: string }>('SELECT value FROM key_value WHERE key = ?', [
|
|
35
|
+
WORKFLOWS_KEY,
|
|
36
|
+
])
|
|
37
|
+
return parseStoredWorkflows(rows[0]?.value)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Stores the workflows; answers what was there before. */
|
|
41
|
+
export async function writeWorkflows(
|
|
42
|
+
db: Db,
|
|
43
|
+
workflows: readonly StoredWorkflow[],
|
|
44
|
+
): Promise<StoredWorkflow[]> {
|
|
45
|
+
return db.transaction(async (tx) => {
|
|
46
|
+
const before = await readWorkflows(tx)
|
|
47
|
+
const value = JSON.stringify(workflows)
|
|
48
|
+
const now = tx.dialect.now()
|
|
49
|
+
const updated = await tx.query<{ key: string }>(
|
|
50
|
+
`UPDATE key_value SET value = ?, update_date = ${now} WHERE key = ? RETURNING key`,
|
|
51
|
+
[value, WORKFLOWS_KEY],
|
|
52
|
+
)
|
|
53
|
+
if (updated.length === 0)
|
|
54
|
+
await tx.exec(`INSERT INTO key_value (key, value, update_date) VALUES (?, ?, ${now})`, [
|
|
55
|
+
WORKFLOWS_KEY,
|
|
56
|
+
value,
|
|
57
|
+
])
|
|
58
|
+
return before
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface WorkflowSyncReport {
|
|
63
|
+
/** Aliases whose meaning changed, or that are new or gone. */
|
|
64
|
+
changed: string[]
|
|
65
|
+
/** What `on-change` did to requests in flight, by request key. */
|
|
66
|
+
requests: Array<{ key: string; workflow: string; outcome: 'kept' | 'restarted' | 'withdrawn' }>
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Stores the workflows and applies each changed one's `on-change` to the
|
|
71
|
+
* requests in flight under it: `keep` leaves them on the version they started
|
|
72
|
+
* with, `restart` starts them again from the first stage of the new one, and
|
|
73
|
+
* `withdraw` ends them. A workflow that is gone cannot be restarted under, so
|
|
74
|
+
* its requests are kept or withdrawn as it said.
|
|
75
|
+
*/
|
|
76
|
+
export async function syncWorkflows(
|
|
77
|
+
db: Db,
|
|
78
|
+
workflows: readonly StoredWorkflow[],
|
|
79
|
+
): Promise<WorkflowSyncReport> {
|
|
80
|
+
const report: WorkflowSyncReport = { changed: [], requests: [] }
|
|
81
|
+
const before = await writeWorkflows(db, workflows)
|
|
82
|
+
const identity = (w: SchemaWorkflow) => w.key ?? w.alias
|
|
83
|
+
const previous = new Map(before.map((s) => [identity(s.workflow), s]))
|
|
84
|
+
const current = new Map(workflows.map((s) => [identity(s.workflow), s]))
|
|
85
|
+
const requests = new ApprovalRepository(db)
|
|
86
|
+
for (const id of new Set([...previous.keys(), ...current.keys()])) {
|
|
87
|
+
const was = previous.get(id)
|
|
88
|
+
const now = current.get(id)
|
|
89
|
+
if (was && now && was.version === now.version) continue
|
|
90
|
+
report.changed.push((now ?? was)?.workflow.alias ?? id)
|
|
91
|
+
const rule = (now ?? was)?.workflow.onChange ?? 'keep'
|
|
92
|
+
for (const request of await requests.waitingUnder(id)) {
|
|
93
|
+
if (request.workflowVersion === now?.version) continue
|
|
94
|
+
if (rule === 'keep') {
|
|
95
|
+
report.requests.push({ key: request.key, workflow: id, outcome: 'kept' })
|
|
96
|
+
continue
|
|
97
|
+
}
|
|
98
|
+
if (rule === 'restart' && now) {
|
|
99
|
+
await requests.rebase(request.id, now.workflow, now.version)
|
|
100
|
+
for (const stage of startStages(now.workflow)) await requests.addStep(request.id, stage)
|
|
101
|
+
await requests.record(request.id, { kind: 'restarted', comment: 'the workflow changed' })
|
|
102
|
+
report.requests.push({ key: request.key, workflow: id, outcome: 'restarted' })
|
|
103
|
+
continue
|
|
104
|
+
}
|
|
105
|
+
await requests.setState(request.id, 'withdrawn')
|
|
106
|
+
await requests.record(request.id, {
|
|
107
|
+
kind: 'withdrawn',
|
|
108
|
+
comment: now ? 'the workflow changed' : 'the workflow was removed',
|
|
109
|
+
})
|
|
110
|
+
report.requests.push({ key: request.key, workflow: id, outcome: 'withdrawn' })
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return report
|
|
114
|
+
}
|