@adula/kit 1.0.0 → 1.2.0
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/build/agent/AGENTS.template.md +2 -2
- package/build/agent/capabilities.md +10 -5
- package/build/agent/skills/adula-frontend-design/SKILL.md +1 -1
- package/build/commands/doctor.js +37 -1
- package/build/commands/gaps.js +16 -6
- package/build/commands/install.js +19 -5
- package/build/commands/main.js +2 -0
- package/build/commands/module_add.js +2 -2
- package/build/commands/resource.js +1 -1
- package/build/commands/resource_snapshot.d.ts +15 -0
- package/build/commands/resource_snapshot.js +61 -0
- package/build/database/migrations/1770000000011_kit_managed_assignments.d.ts +5 -0
- package/build/database/migrations/1770000000011_kit_managed_assignments.js +10 -0
- package/build/database/migrations/1770000000012_kit_role_keys.d.ts +5 -0
- package/build/database/migrations/1770000000012_kit_role_keys.js +10 -0
- package/build/database/migrations/1770000000013_kit_notification_targets.d.ts +5 -0
- package/build/database/migrations/1770000000013_kit_notification_targets.js +10 -0
- package/build/database/migrations/1770000000014_kit_upload_grants.d.ts +5 -0
- package/build/database/migrations/1770000000014_kit_upload_grants.js +10 -0
- package/build/database/migrations/1770000000015_kit_inbound_webhooks.d.ts +5 -0
- package/build/database/migrations/1770000000015_kit_inbound_webhooks.js +10 -0
- package/build/index.d.ts +14 -5
- package/build/index.js +6 -2
- package/build/src/admin/contracts.d.ts +7 -0
- package/build/src/admin/contracts.js +15 -2
- package/build/src/admin/controller.d.ts +1 -0
- package/build/src/admin/controller.js +46 -1
- package/build/src/admin/presentation.d.ts +6 -0
- package/build/src/admin/record_title.d.ts +14 -0
- package/build/src/admin/record_title.js +50 -0
- package/build/src/admin/resource_service.d.ts +163 -2
- package/build/src/admin/resource_service.js +552 -43
- package/build/src/attachments/attachment_service.d.ts +12 -0
- package/build/src/attachments/attachment_service.js +28 -2
- package/build/src/attachments/upload_grants.d.ts +44 -0
- package/build/src/attachments/upload_grants.js +105 -0
- package/build/src/auth/ability.d.ts +1 -1
- package/build/src/auth/ability.js +4 -1
- package/build/src/auth/actor_store.js +6 -1
- package/build/src/auth/conditions.d.ts +15 -0
- package/build/src/auth/conditions.js +36 -0
- package/build/src/auth/sql.js +6 -2
- package/build/src/collaboration/assignments.d.ts +54 -3
- package/build/src/collaboration/assignments.js +125 -11
- package/build/src/collaboration/record_collaboration.js +5 -17
- package/build/src/commands/capabilities.js +22 -3
- package/build/src/commands/doctor.d.ts +31 -0
- package/build/src/commands/doctor.js +114 -0
- package/build/src/commands/gap_report.d.ts +50 -2
- package/build/src/commands/gap_report.js +102 -4
- package/build/src/commands/generator.js +3 -3
- package/build/src/commands/snapshot.d.ts +28 -0
- package/build/src/commands/snapshot.js +48 -0
- package/build/src/commands/source_markers.d.ts +10 -1
- package/build/src/commands/source_markers.js +36 -4
- package/build/src/core/administration_guard.js +3 -1
- package/build/src/core/message_templates.d.ts +4 -1
- package/build/src/core/message_templates.js +7 -2
- package/build/src/core/module_seed.d.ts +15 -0
- package/build/src/core/module_seed.js +31 -0
- package/build/src/core/notifications.d.ts +18 -1
- package/build/src/core/notifications.js +27 -1
- package/build/src/core/roles.d.ts +27 -1
- package/build/src/core/roles.js +133 -5
- package/build/src/database/schema.d.ts +27 -0
- package/build/src/database/schema.js +98 -0
- package/build/src/eslint/index.js +26 -0
- package/build/src/events/record_mutation.d.ts +4 -0
- package/build/src/events/record_mutation.js +2 -0
- package/build/src/integrations/imports.js +4 -2
- package/build/src/integrations/inbound_webhooks.d.ts +94 -0
- package/build/src/integrations/inbound_webhooks.js +276 -0
- package/build/src/integrations/openapi.js +69 -2
- package/build/src/integrations/print.js +1 -0
- package/build/src/resource/define_resource.js +12 -0
- package/build/src/resource/registry.d.ts +1 -0
- package/build/src/resource/registry.js +33 -0
- package/build/src/resource/types.d.ts +64 -1
- package/build/src/resource/values.js +2 -1
- package/build/src/services/settings.d.ts +13 -1
- package/build/src/services/settings.js +9 -2
- package/build/src/workflows/define_workflow.d.ts +30 -1
- package/build/src/workflows/define_workflow.js +44 -4
- package/build/src/workflows/engine.d.ts +24 -2
- package/build/src/workflows/engine.js +89 -24
- package/build/stubs/resource_contract.txt +103 -41
- package/package.json +1 -1
|
@@ -12,11 +12,23 @@ export type StepContext = {
|
|
|
12
12
|
};
|
|
13
13
|
db: Knex;
|
|
14
14
|
};
|
|
15
|
-
export type Recipients = 'creator' | 'submitter'
|
|
15
|
+
export type Recipients = 'creator' | 'submitter'
|
|
16
|
+
/**
|
|
17
|
+
* The members of a role, addressed by its stable key (roles.key). A role without a
|
|
18
|
+
* key is still found by its display name, which administrators can rename.
|
|
19
|
+
*/
|
|
20
|
+
| {
|
|
16
21
|
role: string;
|
|
17
22
|
} | {
|
|
18
23
|
users: number[];
|
|
19
24
|
} | ((context: StepContext) => number[] | Promise<number[]>);
|
|
25
|
+
/** One decision an approver can take at a decision step (#42). */
|
|
26
|
+
export type DecisionOutcome = {
|
|
27
|
+
label: string;
|
|
28
|
+
next: string;
|
|
29
|
+
/** Whether the decision needs a comment; defaults to optional. */
|
|
30
|
+
comment?: 'optional' | 'required';
|
|
31
|
+
};
|
|
20
32
|
export type WorkflowStep = {
|
|
21
33
|
type: 'condition';
|
|
22
34
|
label?: string;
|
|
@@ -43,6 +55,17 @@ export type WorkflowStep = {
|
|
|
43
55
|
dueInDays?: number;
|
|
44
56
|
approve: string;
|
|
45
57
|
reject: string;
|
|
58
|
+
} | {
|
|
59
|
+
/**
|
|
60
|
+
* An approval with named outcomes, for example approve, reject and return. Each
|
|
61
|
+
* outcome names its next step (a later or an earlier one) and may require a comment.
|
|
62
|
+
* The submitted document stays locked: a decision never changes its fields.
|
|
63
|
+
*/
|
|
64
|
+
type: 'decision';
|
|
65
|
+
label: string;
|
|
66
|
+
assignees: Recipients;
|
|
67
|
+
dueInDays?: number;
|
|
68
|
+
outcomes: Record<string, DecisionOutcome>;
|
|
46
69
|
} | {
|
|
47
70
|
type: 'delay';
|
|
48
71
|
label?: string;
|
|
@@ -62,11 +85,13 @@ export type WorkflowStep = {
|
|
|
62
85
|
cancelDocument?: boolean;
|
|
63
86
|
};
|
|
64
87
|
export type WorkflowInput = {
|
|
88
|
+
/** Lower-case letters, digits and underscores, starting with a letter (for example release_approval). */
|
|
65
89
|
name: string;
|
|
66
90
|
version: number;
|
|
67
91
|
resource: string;
|
|
68
92
|
label: string;
|
|
69
93
|
start: string;
|
|
94
|
+
/** Step names follow the workflow name rule (for example notify_approved). */
|
|
70
95
|
steps: Record<string, WorkflowStep>;
|
|
71
96
|
/** Failed step attempts before the run stops as failed (default 5). */
|
|
72
97
|
maxAttempts?: number;
|
|
@@ -83,7 +108,11 @@ export type WorkflowEvent = {
|
|
|
83
108
|
type: 'APPROVE';
|
|
84
109
|
} | {
|
|
85
110
|
type: 'REJECT';
|
|
111
|
+
} | {
|
|
112
|
+
type: 'DECIDE';
|
|
113
|
+
outcome: string;
|
|
86
114
|
};
|
|
115
|
+
export declare const WORKFLOW_NAME_RULE = "use lower-case letters, digits and underscores, starting with a letter";
|
|
87
116
|
/**
|
|
88
117
|
* Declares a versioned workflow. Steps compile to an XState machine; the engine
|
|
89
118
|
* performs each step's effect and asks the machine for the next step. Runs keep
|
|
@@ -1,11 +1,30 @@
|
|
|
1
1
|
import { createMachine, getNextSnapshot } from 'xstate';
|
|
2
2
|
const IDENTIFIER = /^[a-z][a-z0-9_]*$/;
|
|
3
|
+
export const WORKFLOW_NAME_RULE = 'use lower-case letters, digits and underscores, starting with a letter';
|
|
4
|
+
/** Names the rule and a valid spelling, since dotted event-style names are a natural first try. */
|
|
5
|
+
function assertIdentifier(kind, value) {
|
|
6
|
+
if (IDENTIFIER.test(value))
|
|
7
|
+
return;
|
|
8
|
+
const suggestion = value
|
|
9
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
|
10
|
+
.toLowerCase()
|
|
11
|
+
.replace(/[^a-z0-9]+/g, '_')
|
|
12
|
+
.replace(/^[^a-z]+|_+$/g, '');
|
|
13
|
+
const example = IDENTIFIER.test(suggestion)
|
|
14
|
+
? suggestion
|
|
15
|
+
: kind === 'workflow'
|
|
16
|
+
? 'release_approval'
|
|
17
|
+
: 'notify_approved';
|
|
18
|
+
throw new Error(`Invalid ${kind} name "${value}": ${WORKFLOW_NAME_RULE} (for example ${example}).`);
|
|
19
|
+
}
|
|
3
20
|
function targets(step) {
|
|
4
21
|
switch (step.type) {
|
|
5
22
|
case 'condition':
|
|
6
23
|
return [step.then, step.else];
|
|
7
24
|
case 'approval':
|
|
8
25
|
return [step.approve, step.reject];
|
|
26
|
+
case 'decision':
|
|
27
|
+
return Object.values(step.outcomes).map((outcome) => outcome.next);
|
|
9
28
|
case 'end':
|
|
10
29
|
return [];
|
|
11
30
|
default:
|
|
@@ -19,22 +38,31 @@ function targets(step) {
|
|
|
19
38
|
* running workflow (migrate explicitly with WorkflowEngine.migrateRuns).
|
|
20
39
|
*/
|
|
21
40
|
export function defineWorkflow(input) {
|
|
22
|
-
|
|
23
|
-
throw new Error(`Invalid workflow name: ${input.name}`);
|
|
41
|
+
assertIdentifier('workflow', input.name);
|
|
24
42
|
if (!Number.isInteger(input.version) || input.version < 1)
|
|
25
43
|
throw new Error('Workflow version must be a positive integer');
|
|
26
44
|
if (!(input.start in input.steps))
|
|
27
45
|
throw new Error(`Unknown start step: ${input.start}`);
|
|
28
46
|
const keys = Object.keys(input.steps);
|
|
29
47
|
for (const key of keys) {
|
|
30
|
-
|
|
31
|
-
throw new Error(`Invalid step name: ${key}`);
|
|
48
|
+
assertIdentifier('step', key);
|
|
32
49
|
for (const target of targets(input.steps[key]))
|
|
33
50
|
if (!(target in input.steps))
|
|
34
51
|
throw new Error(`Step ${key} points to unknown step ${target}`);
|
|
35
52
|
const step = input.steps[key];
|
|
36
53
|
if (step.type === 'delay' && (!Number.isInteger(step.ms) || step.ms < 0))
|
|
37
54
|
throw new Error(`Step ${key} needs a non-negative integer delay`);
|
|
55
|
+
if (step.type === 'decision') {
|
|
56
|
+
const outcomes = Object.entries(step.outcomes ?? {});
|
|
57
|
+
if (outcomes.length < 2)
|
|
58
|
+
throw new Error(`Decision step ${key} needs at least two outcomes`);
|
|
59
|
+
for (const [name, outcome] of outcomes) {
|
|
60
|
+
if (!IDENTIFIER.test(name) || name.length > 20)
|
|
61
|
+
throw new Error(`Invalid outcome name "${name}" in step ${key}: ${WORKFLOW_NAME_RULE}, at most 20 characters`);
|
|
62
|
+
if (!outcome.label)
|
|
63
|
+
throw new Error(`Outcome ${name} of step ${key} needs a label`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
38
66
|
}
|
|
39
67
|
if (!keys.some((key) => input.steps[key].type === 'end'))
|
|
40
68
|
throw new Error('A workflow needs at least one end step');
|
|
@@ -61,6 +89,18 @@ export function defineWorkflow(input) {
|
|
|
61
89
|
];
|
|
62
90
|
case 'approval':
|
|
63
91
|
return [key, { on: { APPROVE: step.approve, REJECT: step.reject } }];
|
|
92
|
+
case 'decision':
|
|
93
|
+
return [
|
|
94
|
+
key,
|
|
95
|
+
{
|
|
96
|
+
on: {
|
|
97
|
+
DECIDE: Object.entries(step.outcomes).map(([name, outcome]) => ({
|
|
98
|
+
target: outcome.next,
|
|
99
|
+
guard: ({ event }) => event.type === 'DECIDE' && event.outcome === name,
|
|
100
|
+
})),
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
];
|
|
64
104
|
case 'end':
|
|
65
105
|
return [key, { type: 'final' }];
|
|
66
106
|
default:
|
|
@@ -13,6 +13,8 @@ export type WorkflowRun = {
|
|
|
13
13
|
resource: string;
|
|
14
14
|
resourceLabel: string;
|
|
15
15
|
recordId: number;
|
|
16
|
+
/** The record title for the approver (#32); null in admin views or when not readable. */
|
|
17
|
+
recordTitle: string | null;
|
|
16
18
|
definition: string;
|
|
17
19
|
label: string;
|
|
18
20
|
version: number;
|
|
@@ -38,8 +40,18 @@ export type WorkflowRun = {
|
|
|
38
40
|
myApproval: {
|
|
39
41
|
assignmentId: number;
|
|
40
42
|
title: string;
|
|
43
|
+
/** Set at a decision step: its outcomes in declaration order (#42). */
|
|
44
|
+
decision?: WorkflowDecisionForm;
|
|
41
45
|
} | null;
|
|
42
46
|
};
|
|
47
|
+
/** What an approver needs to take a decision at a decision step. */
|
|
48
|
+
export type WorkflowDecisionForm = {
|
|
49
|
+
outcomes: {
|
|
50
|
+
key: string;
|
|
51
|
+
label: string;
|
|
52
|
+
comment: 'optional' | 'required';
|
|
53
|
+
}[];
|
|
54
|
+
};
|
|
43
55
|
export type WorkflowOptions = {
|
|
44
56
|
post?: HttpPoster;
|
|
45
57
|
/** Test hook simulating a crash after a step's effect and before its commit. */
|
|
@@ -87,7 +99,7 @@ export declare class WorkflowEngine {
|
|
|
87
99
|
* Records an approver's decision. Only an open approval assigned to the actor
|
|
88
100
|
* counts, and the run is locked so concurrent decisions cannot both apply.
|
|
89
101
|
*/
|
|
90
|
-
decide(runId: string, actor: Actor, decision:
|
|
102
|
+
decide(runId: string, actor: Actor, decision: string, comment?: unknown): Promise<WorkflowRun>;
|
|
91
103
|
/** Administrators put a failed run back to its failed step. */
|
|
92
104
|
retry(runId: string, actorId: number): Promise<void>;
|
|
93
105
|
/**
|
|
@@ -98,9 +110,17 @@ export declare class WorkflowEngine {
|
|
|
98
110
|
runsFor(name: string, id: number, actor: Actor): Promise<WorkflowRun[]>;
|
|
99
111
|
run(runId: string, actor: Actor): Promise<WorkflowRun>;
|
|
100
112
|
/** Runs waiting for the actor's decision, newest first, on records they can still read. */
|
|
101
|
-
|
|
113
|
+
/**
|
|
114
|
+
* Runs waiting for this user's decision, newest first (at most 100). `runIds` narrows the
|
|
115
|
+
* inbox to those runs, for pages that list their own rows (My tasks).
|
|
116
|
+
*/
|
|
117
|
+
inbox(actor: Actor, options?: {
|
|
118
|
+
runIds?: readonly string[];
|
|
119
|
+
}): Promise<WorkflowRun[]>;
|
|
102
120
|
failed(limit?: number): Promise<WorkflowRun[]>;
|
|
103
121
|
private present;
|
|
122
|
+
/** The outcomes an approver can choose at a decision step. */
|
|
123
|
+
private decisionForm;
|
|
104
124
|
private fail;
|
|
105
125
|
private cancelRuns;
|
|
106
126
|
private context;
|
|
@@ -108,6 +128,8 @@ export declare class WorkflowEngine {
|
|
|
108
128
|
private updateRecord;
|
|
109
129
|
private cancelDocument;
|
|
110
130
|
private recipients;
|
|
131
|
+
/** A role reference is its stable key; for compatibility a display name also matches. */
|
|
132
|
+
private roleId;
|
|
111
133
|
private canView;
|
|
112
134
|
private log;
|
|
113
135
|
private now;
|
|
@@ -7,6 +7,12 @@ import { nextStep, } from './define_workflow.js';
|
|
|
7
7
|
const MAX_STEPS_PER_TICK = 25;
|
|
8
8
|
const BACKOFF_SECONDS = [30, 120, 600, 1800, 7200];
|
|
9
9
|
class StepFailure extends Error {
|
|
10
|
+
permanent;
|
|
11
|
+
/** A permanent failure stops the run now instead of retrying a configuration error. */
|
|
12
|
+
constructor(message, permanent = false) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.permanent = permanent;
|
|
15
|
+
}
|
|
10
16
|
}
|
|
11
17
|
/**
|
|
12
18
|
* Durable workflow engine over workflow_runs. Every step runs inside the run's
|
|
@@ -174,7 +180,7 @@ export class WorkflowEngine {
|
|
|
174
180
|
return;
|
|
175
181
|
}
|
|
176
182
|
catch (error) {
|
|
177
|
-
await this.fail(trx, run, error,
|
|
183
|
+
await this.fail(trx, run, error, error instanceof StepFailure && error.permanent);
|
|
178
184
|
return;
|
|
179
185
|
}
|
|
180
186
|
}
|
|
@@ -216,21 +222,27 @@ export class WorkflowEngine {
|
|
|
216
222
|
id: run.record_id,
|
|
217
223
|
workflow: definition.label,
|
|
218
224
|
step: step.label ?? stepName,
|
|
219
|
-
});
|
|
225
|
+
}, undefined, { resource: String(run.resource), recordId: Number(run.record_id) });
|
|
220
226
|
return move({ type: 'DONE' }, { recipients: users.length });
|
|
221
227
|
}
|
|
222
|
-
case 'approval':
|
|
228
|
+
case 'approval':
|
|
229
|
+
case 'decision': {
|
|
223
230
|
const open = await trx('assignments')
|
|
224
231
|
.where({ workflow_run_id: run.id, workflow_step: stepName, status: 'open' })
|
|
225
232
|
.first('id');
|
|
226
233
|
if (!open) {
|
|
227
|
-
const
|
|
234
|
+
const assignees = step.assignees;
|
|
235
|
+
if (typeof assignees === 'object' && 'role' in assignees)
|
|
236
|
+
if ((await this.roleId(trx, assignees.role)) === null)
|
|
237
|
+
throw new StepFailure(`Approval step ${stepName} is addressed to role "${assignees.role}", which matches no role key or name. Give the role that key (or update the workflow), then retry the run.`, true);
|
|
238
|
+
const users = await this.recipients(trx, run, context, assignees);
|
|
228
239
|
const eligible = [];
|
|
229
240
|
for (const userId of users)
|
|
230
241
|
if (await this.canView(run.resource, run.record_id, userId))
|
|
231
242
|
eligible.push(userId);
|
|
243
|
+
// Waiting on nobody would hide the run for hours of retries; fail it where admins look.
|
|
232
244
|
if (!eligible.length)
|
|
233
|
-
throw new StepFailure(`No eligible approver for ${stepName}`);
|
|
245
|
+
throw new StepFailure(`No eligible approver for ${stepName}: ${users.length ? `${users.length} recipients cannot view the record` : 'the recipients resolve to no active user'}. Fix the recipients, then retry the run.`, true);
|
|
234
246
|
const due = step.dueInDays
|
|
235
247
|
? new Date(this.now().getTime() + step.dueInDays * 86400000).toISOString().slice(0, 10)
|
|
236
248
|
: null;
|
|
@@ -317,7 +329,7 @@ export class WorkflowEngine {
|
|
|
317
329
|
* counts, and the run is locked so concurrent decisions cannot both apply.
|
|
318
330
|
*/
|
|
319
331
|
async decide(runId, actor, decision, comment) {
|
|
320
|
-
if (
|
|
332
|
+
if (typeof decision !== 'string' || !decision)
|
|
321
333
|
throw new KitError(422, 'E_WORKFLOW_DECISION', 'القرار غير صالح');
|
|
322
334
|
const note = typeof comment === 'string' ? comment.trim().slice(0, 1000) : '';
|
|
323
335
|
await this.db.transaction(async (trx) => {
|
|
@@ -337,9 +349,25 @@ export class WorkflowEngine {
|
|
|
337
349
|
if (run.status !== 'waiting' || !assignment)
|
|
338
350
|
throw new KitError(409, 'E_WORKFLOW_STATE', 'لا توجد موافقة مطلوبة منك في هذه الخطوة');
|
|
339
351
|
const definition = this.definition(run.definition, run.definition_version);
|
|
340
|
-
const
|
|
341
|
-
|
|
342
|
-
|
|
352
|
+
const step = definition.steps[run.current_step];
|
|
353
|
+
let event;
|
|
354
|
+
let logged;
|
|
355
|
+
if (step?.type === 'decision') {
|
|
356
|
+
const outcome = Object.hasOwn(step.outcomes, decision) ? step.outcomes[decision] : undefined;
|
|
357
|
+
if (!outcome)
|
|
358
|
+
throw new KitError(422, 'E_WORKFLOW_DECISION', 'القرار غير صالح');
|
|
359
|
+
if (outcome.comment === 'required' && !note)
|
|
360
|
+
throw new KitError(422, 'E_WORKFLOW_COMMENT', 'اكتب ملاحظة القرار');
|
|
361
|
+
event = { type: 'DECIDE', outcome: decision };
|
|
362
|
+
logged = 'decided';
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
if (decision !== 'approve' && decision !== 'reject')
|
|
366
|
+
throw new KitError(422, 'E_WORKFLOW_DECISION', 'القرار غير صالح');
|
|
367
|
+
event = { type: decision === 'approve' ? 'APPROVE' : 'REJECT' };
|
|
368
|
+
logged = decision === 'approve' ? 'approved' : 'rejected';
|
|
369
|
+
}
|
|
370
|
+
const target = nextStep(definition, run.current_step, event);
|
|
343
371
|
await trx('assignments').where('id', assignment.id).update({
|
|
344
372
|
status: 'done',
|
|
345
373
|
outcome: decision,
|
|
@@ -361,7 +389,8 @@ export class WorkflowEngine {
|
|
|
361
389
|
last_error: null,
|
|
362
390
|
updated_at: trx.fn.now(),
|
|
363
391
|
});
|
|
364
|
-
await this.log(trx, run.id, run.current_step,
|
|
392
|
+
await this.log(trx, run.id, run.current_step, logged, actor.id, {
|
|
393
|
+
...(logged === 'decided' ? { outcome: decision } : {}),
|
|
365
394
|
...(note ? { comment: note } : {}),
|
|
366
395
|
next: target,
|
|
367
396
|
});
|
|
@@ -425,14 +454,20 @@ export class WorkflowEngine {
|
|
|
425
454
|
return this.present(row, actor);
|
|
426
455
|
}
|
|
427
456
|
/** Runs waiting for the actor's decision, newest first, on records they can still read. */
|
|
428
|
-
|
|
429
|
-
|
|
457
|
+
/**
|
|
458
|
+
* Runs waiting for this user's decision, newest first (at most 100). `runIds` narrows the
|
|
459
|
+
* inbox to those runs, for pages that list their own rows (My tasks).
|
|
460
|
+
*/
|
|
461
|
+
async inbox(actor, options = {}) {
|
|
462
|
+
if (options.runIds && !options.runIds.length)
|
|
463
|
+
return [];
|
|
464
|
+
const query = this.db('workflow_runs as r')
|
|
430
465
|
.join('assignments as a', 'a.workflow_run_id', 'r.id')
|
|
431
466
|
.where({ 'a.assignee_id': actor.id, 'a.status': 'open', 'r.status': 'waiting' })
|
|
432
|
-
.whereRaw('a.workflow_step = r.current_step')
|
|
433
|
-
|
|
434
|
-
.
|
|
435
|
-
|
|
467
|
+
.whereRaw('a.workflow_step = r.current_step');
|
|
468
|
+
if (options.runIds)
|
|
469
|
+
query.whereIn('r.id', [...new Set(options.runIds)].slice(0, 100));
|
|
470
|
+
const rows = await query.orderBy('a.id', 'desc').limit(100).select('r.*');
|
|
436
471
|
const runs = [];
|
|
437
472
|
for (const row of rows)
|
|
438
473
|
if (await this.resources.permits(row.resource, Number(row.record_id), actor))
|
|
@@ -469,6 +504,9 @@ export class WorkflowEngine {
|
|
|
469
504
|
.first('id', 'title')
|
|
470
505
|
: undefined;
|
|
471
506
|
const step = row.current_step ? definition?.steps[row.current_step] : undefined;
|
|
507
|
+
const titles = actor
|
|
508
|
+
? await this.resources.titles(row.resource, [Number(row.record_id)], actor)
|
|
509
|
+
: undefined;
|
|
472
510
|
let resourceLabel = String(row.resource);
|
|
473
511
|
try {
|
|
474
512
|
resourceLabel = this.resources.label(row.resource);
|
|
@@ -479,6 +517,7 @@ export class WorkflowEngine {
|
|
|
479
517
|
resource: String(row.resource),
|
|
480
518
|
resourceLabel,
|
|
481
519
|
recordId: Number(row.record_id),
|
|
520
|
+
recordTitle: titles?.get(Number(row.record_id)) ?? null,
|
|
482
521
|
definition: String(row.definition),
|
|
483
522
|
label: definition?.label ?? String(row.definition),
|
|
484
523
|
version: Number(row.definition_version),
|
|
@@ -498,7 +537,23 @@ export class WorkflowEngine {
|
|
|
498
537
|
detail: (event.detail ?? {}),
|
|
499
538
|
at: new Date(event.created_at).toISOString(),
|
|
500
539
|
})),
|
|
501
|
-
myApproval: mine
|
|
540
|
+
myApproval: mine
|
|
541
|
+
? {
|
|
542
|
+
assignmentId: Number(mine.id),
|
|
543
|
+
title: String(mine.title),
|
|
544
|
+
...(step?.type === 'decision' ? { decision: this.decisionForm(step) } : {}),
|
|
545
|
+
}
|
|
546
|
+
: null,
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
/** The outcomes an approver can choose at a decision step. */
|
|
550
|
+
decisionForm(step) {
|
|
551
|
+
return {
|
|
552
|
+
outcomes: Object.entries(step.outcomes).map(([key, outcome]) => ({
|
|
553
|
+
key,
|
|
554
|
+
label: outcome.label,
|
|
555
|
+
comment: outcome.comment ?? 'optional',
|
|
556
|
+
})),
|
|
502
557
|
};
|
|
503
558
|
}
|
|
504
559
|
async fail(trx, run, error, permanent) {
|
|
@@ -532,7 +587,7 @@ export class WorkflowEngine {
|
|
|
532
587
|
resource: this.resources.label(run.resource),
|
|
533
588
|
id: run.record_id,
|
|
534
589
|
error: message,
|
|
535
|
-
});
|
|
590
|
+
}, undefined, { resource: String(run.resource), recordId: Number(run.record_id) });
|
|
536
591
|
}
|
|
537
592
|
async cancelRuns(trx, resource, id, actorId) {
|
|
538
593
|
const runs = await trx('workflow_runs')
|
|
@@ -635,16 +690,26 @@ export class WorkflowEngine {
|
|
|
635
690
|
else if ('users' in to)
|
|
636
691
|
ids = to.users;
|
|
637
692
|
else {
|
|
638
|
-
const
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
693
|
+
const roleId = await this.roleId(trx, to.role);
|
|
694
|
+
const members = roleId === null
|
|
695
|
+
? []
|
|
696
|
+
: await trx('user_roles as ur')
|
|
697
|
+
.join('users as u', 'u.id', 'ur.user_id')
|
|
698
|
+
.where('ur.role_id', roleId)
|
|
699
|
+
.whereNull('u.disabled_at')
|
|
700
|
+
.distinct('ur.user_id');
|
|
644
701
|
ids = members.map((row) => Number(row.user_id));
|
|
645
702
|
}
|
|
646
703
|
return [...new Set(ids.filter((id) => Number.isSafeInteger(id) && id > 0))];
|
|
647
704
|
}
|
|
705
|
+
/** A role reference is its stable key; for compatibility a display name also matches. */
|
|
706
|
+
async roleId(db, reference) {
|
|
707
|
+
const byKey = await db('roles').where('key', reference).first('id');
|
|
708
|
+
if (byKey)
|
|
709
|
+
return Number(byKey.id);
|
|
710
|
+
const byName = await db('roles').where('name', reference).first('id');
|
|
711
|
+
return byName ? Number(byName.id) : null;
|
|
712
|
+
}
|
|
648
713
|
async canView(resource, id, userId) {
|
|
649
714
|
const user = await this.db('users').where('id', userId).first('disabled_at');
|
|
650
715
|
if (!user || user.disabled_at)
|
|
@@ -5,7 +5,7 @@ import db from '@adonisjs/lucid/services/db'
|
|
|
5
5
|
import { kit } from '#services/kit'
|
|
6
6
|
import { randomUUID } from 'node:crypto'
|
|
7
7
|
import { columnName } from '@adula/kit'
|
|
8
|
-
import type { Field, RecordData, Resource, SerializedRecord } from '@adula/kit'
|
|
8
|
+
import type { Action, Field, RecordData, Resource, SerializedRecord } from '@adula/kit'
|
|
9
9
|
|
|
10
10
|
export type FixtureContext = { userId: number; orgUnitId: number; unique: string }
|
|
11
11
|
export type ContractFixture = {
|
|
@@ -16,9 +16,12 @@ export type ContractFixture = {
|
|
|
16
16
|
stored?: RecordData
|
|
17
17
|
/** All live child rows after creation, ordered by ID, using public field names. */
|
|
18
18
|
inline?: Record<string, RecordData[]>
|
|
19
|
-
/**
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
/**
|
|
20
|
+
* A valid update for this same record, including required validator fields. Required
|
|
21
|
+
* when the resource declares the `update` action.
|
|
22
|
+
*/
|
|
23
|
+
update?: RecordData
|
|
24
|
+
updated?: SerializedRecord
|
|
22
25
|
updatedStored?: RecordData
|
|
23
26
|
updatedInline?: Record<string, RecordData[]>
|
|
24
27
|
}
|
|
@@ -48,8 +51,13 @@ export function resourceContract(name: string, fixture: ResourceFixture) {
|
|
|
48
51
|
let resource: Resource
|
|
49
52
|
const base = `/resources/${name}`
|
|
50
53
|
const fresh = () => fixture({ userId: writer.id, orgUnitId, unique: randomUUID() })
|
|
51
|
-
const input = (values: RecordData) => ({
|
|
54
|
+
const input = (values: RecordData) => ({
|
|
55
|
+
...values,
|
|
56
|
+
...(resource.scoped && !resource.scope ? { orgUnitId } : {}),
|
|
57
|
+
})
|
|
52
58
|
const version = (row: SerializedRecord) => (resource.version ? { version: row.version } : {})
|
|
59
|
+
// Actions the resource does not declare are refused even to a role that manages all.
|
|
60
|
+
const allows = (action: Action) => resource.actions.includes(action)
|
|
53
61
|
|
|
54
62
|
group.setup(async () => {
|
|
55
63
|
const knex = db.connection().getWriteClient()
|
|
@@ -124,10 +132,27 @@ export function resourceContract(name: string, fixture: ResourceFixture) {
|
|
|
124
132
|
const response = await client.get(base).header('Accept', 'application/json')
|
|
125
133
|
response.assertStatus(401)
|
|
126
134
|
})
|
|
135
|
+
test('undeclared actions are refused to a role that manages all resources', async ({
|
|
136
|
+
client,
|
|
137
|
+
}) => {
|
|
138
|
+
for (const [action, method, suffix] of [
|
|
139
|
+
['create', 'post', ''],
|
|
140
|
+
['update', 'patch', '/999999'],
|
|
141
|
+
['delete', 'delete', '/999999'],
|
|
142
|
+
] as const) {
|
|
143
|
+
if (allows(action)) continue
|
|
144
|
+
const response = await client[method](`${base}${suffix}`)
|
|
145
|
+
.loginAs(writer)
|
|
146
|
+
.withCsrfToken()
|
|
147
|
+
.header('Accept', 'application/json')
|
|
148
|
+
response.assertStatus(403)
|
|
149
|
+
}
|
|
150
|
+
})
|
|
127
151
|
test('existing records enforce scope on reads and writes; central resources stay shared', async ({
|
|
128
152
|
client,
|
|
129
153
|
assert,
|
|
130
154
|
}) => {
|
|
155
|
+
if (!allows('create')) return
|
|
131
156
|
const values = await fresh()
|
|
132
157
|
const created = await client
|
|
133
158
|
.post(base)
|
|
@@ -159,21 +184,21 @@ export function resourceContract(name: string, fixture: ResourceFixture) {
|
|
|
159
184
|
.get(`${base}/${row.id}/edit`)
|
|
160
185
|
.loginAs(outsider)
|
|
161
186
|
.header('Accept', 'application/json')
|
|
162
|
-
edit.assertStatus(404)
|
|
187
|
+
edit.assertStatus(allows('update') ? 404 : 403)
|
|
163
188
|
const changed = await client
|
|
164
189
|
.patch(`${base}/${row.id}`)
|
|
165
190
|
.loginAs(outsider)
|
|
166
191
|
.withCsrfToken()
|
|
167
192
|
.header('Accept', 'application/json')
|
|
168
|
-
.json({ ...values.update, ...version(row) })
|
|
169
|
-
changed.assertStatus(404)
|
|
193
|
+
.json({ ...(values.update ?? values.input), ...version(row) })
|
|
194
|
+
changed.assertStatus(allows('update') ? 404 : 403)
|
|
170
195
|
const deleted = await client
|
|
171
196
|
.delete(`${base}/${row.id}`)
|
|
172
197
|
.loginAs(outsider)
|
|
173
198
|
.withCsrfToken()
|
|
174
199
|
.header('Accept', 'application/json')
|
|
175
200
|
.json(version(row))
|
|
176
|
-
deleted.assertStatus(404)
|
|
201
|
+
deleted.assertStatus(allows('delete') ? 404 : 403)
|
|
177
202
|
const after = await client
|
|
178
203
|
.get(`${base}/${row.id}`)
|
|
179
204
|
.loginAs(writer)
|
|
@@ -185,6 +210,7 @@ export function resourceContract(name: string, fixture: ResourceFixture) {
|
|
|
185
210
|
client,
|
|
186
211
|
assert,
|
|
187
212
|
}) => {
|
|
213
|
+
if (!allows('create')) return
|
|
188
214
|
const values = await fresh()
|
|
189
215
|
for (const key of resource.form)
|
|
190
216
|
assert.property(values.input, key, `Missing ${name}.${key} fixture`)
|
|
@@ -268,64 +294,99 @@ export function resourceContract(name: string, fixture: ResourceFixture) {
|
|
|
268
294
|
if (field.permissionLevel || resource.hidden?.includes(key))
|
|
269
295
|
assert.notProperty(shown.body().data, key)
|
|
270
296
|
}
|
|
271
|
-
|
|
272
|
-
|
|
297
|
+
const stored = async () => db.connection().getWriteClient()(name).where('id', row.id).first()
|
|
298
|
+
const inserted = await stored()
|
|
299
|
+
assert.equal(inserted.created_by, writer.id)
|
|
300
|
+
if (!allows('update')) {
|
|
301
|
+
const refused = await client
|
|
273
302
|
.patch(`${base}/${row.id}`)
|
|
274
303
|
.loginAs(writer)
|
|
275
304
|
.withCsrfToken()
|
|
276
305
|
.header('Accept', 'application/json')
|
|
277
|
-
.json({ ...values.
|
|
278
|
-
|
|
279
|
-
assert.equal(invalid.body().error.code, 'E_FIELD_NOT_WRITABLE')
|
|
306
|
+
.json({ ...values.input, ...version(row) })
|
|
307
|
+
refused.assertStatus(403)
|
|
280
308
|
}
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
.header('Accept', 'application/json')
|
|
286
|
-
.json({ ...values.update, ...version(row) })
|
|
287
|
-
updated.assertStatus(200)
|
|
288
|
-
for (const [key, value] of Object.entries(values.updated))
|
|
289
|
-
assert.deepEqual(updated.body().data[key], value, key)
|
|
290
|
-
await assertStored(values.update, values.updated, values.updatedStored, values.updatedInline)
|
|
291
|
-
if (resource.version) {
|
|
292
|
-
assert.equal(updated.body().data.version, Number(row.version) + 1)
|
|
293
|
-
const stale = await client
|
|
294
|
-
.patch(`${base}/${row.id}`)
|
|
309
|
+
const current = allows('update') ? await updateRecord() : row
|
|
310
|
+
if (!allows('delete')) {
|
|
311
|
+
const refused = await client
|
|
312
|
+
.delete(`${base}/${row.id}`)
|
|
295
313
|
.loginAs(writer)
|
|
296
314
|
.withCsrfToken()
|
|
297
315
|
.header('Accept', 'application/json')
|
|
298
|
-
.json(
|
|
299
|
-
|
|
316
|
+
.json(version(current))
|
|
317
|
+
refused.assertStatus(403)
|
|
318
|
+
const kept = await client
|
|
319
|
+
.get(`${base}/${row.id}`)
|
|
320
|
+
.loginAs(writer)
|
|
321
|
+
.header('Accept', 'application/json')
|
|
322
|
+
kept.assertStatus(200)
|
|
323
|
+
assert.deepEqual(kept.body().data, current)
|
|
324
|
+
return
|
|
300
325
|
}
|
|
301
|
-
const persisted = await client
|
|
302
|
-
.get(`${base}/${row.id}`)
|
|
303
|
-
.loginAs(writer)
|
|
304
|
-
.header('Accept', 'application/json')
|
|
305
|
-
persisted.assertStatus(200)
|
|
306
|
-
assert.deepEqual(persisted.body().data, updated.body().data)
|
|
307
326
|
const removed = await client
|
|
308
327
|
.delete(`${base}/${row.id}`)
|
|
309
328
|
.loginAs(writer)
|
|
310
329
|
.withCsrfToken()
|
|
311
330
|
.header('Accept', 'application/json')
|
|
312
|
-
.json(version(
|
|
331
|
+
.json(version(current))
|
|
313
332
|
removed.assertStatus(200)
|
|
314
|
-
const
|
|
315
|
-
assert.exists(
|
|
316
|
-
assert.equal(stored.created_by, writer.id)
|
|
333
|
+
const deleted = await stored()
|
|
334
|
+
assert.exists(deleted.deleted_at)
|
|
317
335
|
const missing = await client
|
|
318
336
|
.get(`${base}/${row.id}`)
|
|
319
337
|
.loginAs(writer)
|
|
320
338
|
.header('Accept', 'application/json')
|
|
321
339
|
missing.assertStatus(404)
|
|
340
|
+
|
|
341
|
+
async function updateRecord() {
|
|
342
|
+
assert.exists(values.update, `Missing ${name} update fixture`)
|
|
343
|
+
assert.exists(values.updated, `Missing ${name} updated fixture`)
|
|
344
|
+
const update = values.update!
|
|
345
|
+
for (const key of ['createdBy', 'updatedBy', 'deletedAt', 'orgPath', 'searchVector']) {
|
|
346
|
+
const invalid = await client
|
|
347
|
+
.patch(`${base}/${row.id}`)
|
|
348
|
+
.loginAs(writer)
|
|
349
|
+
.withCsrfToken()
|
|
350
|
+
.header('Accept', 'application/json')
|
|
351
|
+
.json({ ...update, ...version(row), [key]: writer.id })
|
|
352
|
+
invalid.assertStatus(422)
|
|
353
|
+
assert.equal(invalid.body().error.code, 'E_FIELD_NOT_WRITABLE')
|
|
354
|
+
}
|
|
355
|
+
const updated = await client
|
|
356
|
+
.patch(`${base}/${row.id}`)
|
|
357
|
+
.loginAs(writer)
|
|
358
|
+
.withCsrfToken()
|
|
359
|
+
.header('Accept', 'application/json')
|
|
360
|
+
.json({ ...update, ...version(row) })
|
|
361
|
+
updated.assertStatus(200)
|
|
362
|
+
for (const [key, value] of Object.entries(values.updated!))
|
|
363
|
+
assert.deepEqual(updated.body().data[key], value, key)
|
|
364
|
+
await assertStored(update, values.updated!, values.updatedStored, values.updatedInline)
|
|
365
|
+
if (resource.version) {
|
|
366
|
+
assert.equal(updated.body().data.version, Number(row.version) + 1)
|
|
367
|
+
const stale = await client
|
|
368
|
+
.patch(`${base}/${row.id}`)
|
|
369
|
+
.loginAs(writer)
|
|
370
|
+
.withCsrfToken()
|
|
371
|
+
.header('Accept', 'application/json')
|
|
372
|
+
.json({ ...update, ...version(row) })
|
|
373
|
+
stale.assertStatus(409)
|
|
374
|
+
}
|
|
375
|
+
const persisted = await client
|
|
376
|
+
.get(`${base}/${row.id}`)
|
|
377
|
+
.loginAs(writer)
|
|
378
|
+
.header('Accept', 'application/json')
|
|
379
|
+
persisted.assertStatus(200)
|
|
380
|
+
assert.deepEqual(persisted.body().data, updated.body().data)
|
|
381
|
+
return updated.body().data as SerializedRecord
|
|
382
|
+
}
|
|
322
383
|
})
|
|
323
384
|
test('unique constraints reject duplicates and allow reuse after soft deletion', async ({
|
|
324
385
|
client,
|
|
325
386
|
assert,
|
|
326
387
|
}) => {
|
|
327
388
|
const keys = Object.entries(resource.fields).filter(([, field]) => field.unique)
|
|
328
|
-
if (!keys.length) return
|
|
389
|
+
if (!keys.length || !allows('create')) return
|
|
329
390
|
const values = await fresh()
|
|
330
391
|
const created = await client
|
|
331
392
|
.post(base)
|
|
@@ -369,6 +430,7 @@ export function resourceContract(name: string, fixture: ResourceFixture) {
|
|
|
369
430
|
rejected.assertStatus(409)
|
|
370
431
|
assert.equal(rejected.body().error.code, 'E_DUPLICATE')
|
|
371
432
|
}
|
|
433
|
+
if (!allows('delete')) return
|
|
372
434
|
const removed = await client
|
|
373
435
|
.delete(`${base}/${row.id}`)
|
|
374
436
|
.loginAs(writer)
|