-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathapproval-service.ts
More file actions
405 lines (378 loc) · 15.5 KB
/
Copy pathapproval-service.ts
File metadata and controls
405 lines (378 loc) · 15.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
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
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
/**
* @objectstack/spec/contracts/approval-service
*
* Cross-package contract for the approval runtime. The default
* implementation lives in `@objectstack/plugin-approvals` and is registered
* as the `approvals` service.
*
* ADR-0019: approval is no longer a standalone engine. An approval is a
* **flow node** (`type: 'approval'`) — the flow opens a request on the node
* and suspends; a human decision finalises it and resumes the flow down the
* matching `approve` / `reject` edge. This service owns the runtime state
* (`sys_approval_request` / `sys_approval_action`, approver resolution, record
* lock, status mirror) and the decision API. There is no standalone process
* authoring type, submit, or step machinery anymore.
*/
import type { SharingExecutionContext } from './sharing-service.js';
/**
* Lifecycle state of an approval request.
*
* `returned` (ADR-0044): the approver sent the request back for revision —
* terminal for THIS request/round; the flow walks the `revise` edge to a wait
* point, and a later resubmit opens a fresh `pending` request (next round).
* Distinct from `recalled` (submitter-initiated withdrawal).
*
* Dual-source: keep in sync with the `sys_approval_request` status select.
*/
export type ApprovalStatus = 'pending' | 'approved' | 'rejected' | 'recalled' | 'returned';
/** Live request row. */
export interface ApprovalRequestRow {
id: string;
/** Origin of the request — `flow:<flowName|nodeId>` for node-driven approvals. */
process_name: string;
object_name: string;
record_id: string;
submitter_id?: string;
submitter_comment?: string;
status: ApprovalStatus;
/** The flow node id that opened the request (mirrors `flow_node_id`). */
current_step?: string;
current_step_index?: number;
pending_approvers?: string[];
payload?: unknown;
/** ADR-0019 correlation: the suspended flow run this request belongs to. */
flow_run_id?: string;
flow_node_id?: string;
completed_at?: string;
created_at?: string;
updated_at?: string;
/**
* When the request was opened. Alias of `created_at` — the row is created
* at submission time. Kept as its own field so inbox clients have a stable
* name that survives any future split between row-creation and submission.
*/
submitted_at?: string;
// ── Display enrichment (inbox-facing; resolved by the service) ─────
/** Human label of the originating flow (e.g. "Project Budget Approval"). */
process_label?: string;
/** Human label of the approval step / node (e.g. "Manager Review"). */
step_label?: string;
/** Display name of the target record (its name/title field), when resolvable. */
record_title?: string;
/** Display name of the submitter (`sys_user.name`), when resolvable. */
submitter_name?: string;
/** Schema label of the target object (e.g. "Project" for `showcase_project`). */
object_label?: string;
/**
* Display names for user-id entries in `pending_approvers`
* (id → `sys_user.name`). Emails and `role:<r>` entries are not mapped —
* they are already human-readable.
*/
pending_approver_names?: Record<string, string>;
/**
* Display values for lookup fields in `payload` (field key → referenced
* record's display name), so inbox summaries never show foreign-key ids.
*/
payload_display?: Record<string, string>;
/**
* SLA deadline, when the node config carries `escalation.timeoutHours`:
* `created_at + timeoutHours`. Display-only for now — automatic escalation
* needs a scheduler pass and is not yet wired.
*/
sla_due_at?: string;
/**
* The owning flow's approval steps in graph order, for progress display
* (resolved on single-request reads when the automation engine is
* attached). `state` is relative to this request's node.
*/
flow_steps?: Array<{ id: string; label: string; state: 'done' | 'current' | 'upcoming' }>;
/**
* ADR-0044 revision round of this request on its (run, node): 1 (or absent)
* for the first round, 2 after one send-back-and-resubmit, … Carried in the
* `node_config_json` snapshot (`__round`), so no schema migration.
*/
round?: number;
/**
* Server-computed decision aggregation progress (#3266, single-request reads
* of PENDING requests only). Present when the node's behavior aggregates
* multiple approvals: `unanimous` (got/need = approvals of total),
* `quorum` (got/need = approvals of the M threshold), `per_group`
* (got/need = satisfied groups of total groups, plus per-group detail).
* Absent for `first_response`. Display-only — the engine's finalization
* tally in decideNode stays authoritative.
*/
decision_progress?: {
behavior: 'unanimous' | 'quorum' | 'per_group';
got: number;
need: number;
groups?: Array<{ group: string; got: number; need: number; satisfied: boolean }>;
};
/**
* Server-computed capability for THE CURRENT VIEWER (#3310), attached by
* `getRequest` / `listRequests` from the caller's context. Lets a client gate
* decision actions precisely without re-deriving identity resolution:
* declared approver actions use `record.viewer.can_act`, submitter actions use
* `record.viewer.is_submitter`.
*
* - `can_act` — the caller is a *current pending approver* (their user id is in
* the request's resolved `pending_approvers` while it is still `pending`).
* This mirrors the exact check the service uses to authorize a decision, so
* it is strictly more accurate than a client-side identity guess (it already
* reflects position/team/manager resolution baked into `pending_approvers`).
* - `is_submitter` — the caller submitted the request.
*
* Absent when the row is surfaced outside a service read with a user context
* (e.g. a raw data-API grid); a `record.viewer.*` predicate then fails closed.
*/
viewer?: {
can_act: boolean;
is_submitter: boolean;
};
}
/** Kinds of entries on a request's audit trail. */
export type ApprovalActionKind =
| 'submit'
| 'approve'
| 'reject'
| 'recall'
| 'escalate'
/** A pending approver handed their slot to someone else. */
| 'reassign'
/** The submitter nudged the pending approvers. */
| 'remind'
/** An approver asked the submitter for more information (request stays pending). */
| 'request_info'
/** A free-form reply on the thread (submitter or approver). */
| 'comment'
/** ADR-0044: an approver sent the request back for revision (request finalizes `returned`). */
| 'revise'
/** ADR-0044: the submitter resubmitted after rework (the next round's request opens with its own `submit`). */
| 'resubmit'
/** #1322 M1: an out-of-office approver's slot was auto-rerouted to their delegate at resolution time. */
| 'ooo_substitute';
/** Audit row. */
export interface ApprovalActionRow {
id: string;
request_id: string;
step_name?: string;
step_index?: number;
action: ApprovalActionKind;
actor_id?: string;
comment?: string;
/** File references attached to this action (decision attachments, #3266). */
attachments?: string[];
created_at?: string;
/** Display name of the actor (`sys_user.name`), when resolvable. */
actor_name?: string;
}
/** Input for a decision on an approval request. */
export interface ApprovalDecisionInput {
decision: 'approve' | 'reject';
actorId: string;
comment?: string;
/**
* File references (already stored via the storage service) to attach to this
* decision — e.g. a signed contract or an evidence PDF (#3266). Recorded on
* the `sys_approval_action` audit row's `attachments` field.
*/
attachments?: string[];
}
/** Input for recalling (withdrawing) a pending request. */
export interface ApprovalRecallInput {
/** Must be the request's submitter (or a system context). */
actorId: string;
comment?: string;
}
/** Result of a recall. */
export interface ApprovalRecallResult {
request: ApprovalRequestRow;
/** The suspended flow run this request gated, if any. */
runId?: string | null;
/**
* True when the owning flow run was resumed (down the `reject` branch with
* `output.decision = 'recall'`) so it doesn't stay suspended forever. The
* engine has no run-cancel primitive yet; the reject edge is the closest
* "did not pass" semantics.
*/
resumed?: boolean;
}
/** Input for sending a pending request back for revision (ADR-0044). */
export interface ApprovalSendBackInput {
/** Must be a pending approver on the request (or a system context). */
actorId: string;
/** Why the material needs rework — shown to the submitter. */
comment?: string;
}
/** Result of a send-back (ADR-0044). */
export interface ApprovalSendBackResult {
request: ApprovalRequestRow;
/** The suspended flow run this request gated, if any. */
runId?: string | null;
/** True when the owning flow run was resumed (down `revise`, or `reject` on auto-reject). */
resumed?: boolean;
/**
* True when the send-back exceeded the node's `maxRevisions` budget and the
* request was auto-rejected instead (resumed down `reject` with
* `output.autoRejected = true`).
*/
autoRejected?: boolean;
}
/** Input for resubmitting a returned request after rework (ADR-0044). */
export interface ApprovalResubmitInput {
/** Must be the request's submitter (or a system context). */
actorId: string;
comment?: string;
}
/** Result of a resubmit (ADR-0044). */
export interface ApprovalResubmitResult {
/** The round-N request the resubmit was recorded on (stays `returned`). */
request: ApprovalRequestRow;
runId?: string | null;
/** True when the owning flow run was resumed (it re-enters the approval node and opens round N+1). */
resumed?: boolean;
}
/** Result of a decision that resumes the owning flow when finalised. */
export interface ApprovalDecisionResult {
request: ApprovalRequestRow;
/** True when this call moved the request to a terminal state. */
finalized: boolean;
decision: 'approve' | 'reject';
/** The suspended flow run that was (or will be) resumed, if any. */
runId?: string | null;
/** True when the owning flow run was resumed as a result of this decision. */
resumed?: boolean;
}
/**
* Public contract — the node-era approval runtime.
*/
export interface IApprovalService {
/**
* "My approvals" inbox. Supports filtering by status, target object,
* record id, or by the user expected to act next.
*/
listRequests(
filter: {
object?: string;
recordId?: string;
status?: ApprovalStatus | ApprovalStatus[];
/**
* Match requests where ANY of these identities is a pending approver.
* Accepts a single id or a list (a user typically has several
* identities: their user id, email, and `role:<r>` entries). Passing
* the list lets a caller resolve "my pending approvals" in ONE request
* instead of one request per identity.
*/
approverId?: string | string[];
submitterId?: string;
/**
* Free-text search, pushed into the engine query: matches the source
* name, object, record id, submitter, and the payload snapshot (which
* carries record titles), case behavior per the underlying driver.
*/
q?: string;
/**
* Page window. Honoured as an engine-level window when the filter is
* fully pushable; an `approverId` / status-array filter still
* post-filters in memory (bounded personal queues), where the window
* is applied after filtering.
*/
limit?: number;
offset?: number;
} | undefined,
context: SharingExecutionContext,
): Promise<ApprovalRequestRow[]>;
/**
* Total rows matching a {@link listRequests} filter (ignoring
* `limit`/`offset`) — the pagination companion.
*/
countRequests(
filter: Parameters<IApprovalService['listRequests']>[0],
context: SharingExecutionContext,
): Promise<number>;
getRequest(requestId: string, context: SharingExecutionContext): Promise<ApprovalRequestRow | null>;
/**
* Record a decision on a node-driven request. Honours the node's
* `unanimous` behaviour, finalises the request when satisfied, and resumes
* the owning flow run down the matching `approve` / `reject` edge.
*/
decide(requestId: string, input: ApprovalDecisionInput, context: SharingExecutionContext): Promise<ApprovalDecisionResult>;
/**
* Withdraw a pending request. Only the submitter (or a system context) may
* recall. Finalises the request as `recalled` and resumes the owning flow
* run down the `reject` branch with `output.decision = 'recall'`.
*
* ADR-0044: also valid on the LATEST `returned` request of its (run, node)
* — the submitter abandons the revision window instead of resubmitting; the
* request flips `returned → recalled` and the run resumes down `reject` the
* same way.
*/
recall(requestId: string, input: ApprovalRecallInput, context: SharingExecutionContext): Promise<ApprovalRecallResult>;
/**
* ADR-0044 send back for revision. Finalises the pending request as
* `returned` and resumes the owning flow run down its `revise` edge to a
* wait point (record unlocks; the submitter reworks the data and
* {@link resubmit}s). Requires the approval node to declare a `revise`
* out-edge; past the node's `maxRevisions` budget the request auto-rejects
* instead. Audited as `revise`.
*/
sendBack(
requestId: string,
input: ApprovalSendBackInput,
context: SharingExecutionContext,
): Promise<ApprovalSendBackResult>;
/**
* ADR-0044 resubmit after rework. Valid on the LATEST `returned` request of
* its (run, node), submitter-only. Resumes the suspended run from the wait
* point; traversal re-enters the approval node via the declared back-edge
* and opens the next round's request (fresh approver slate, record
* re-locks). Audited as `resubmit` on the returned request.
*/
resubmit(
requestId: string,
input: ApprovalResubmitInput,
context: SharingExecutionContext,
): Promise<ApprovalResubmitResult>;
/**
* Hand a pending-approver slot to someone else. The actor must currently
* be a pending approver (or system); `from` defaults to the actor's own
* matching identity. Audits a `reassign` action and notifies the new
* approver when a messaging service is attached.
*/
reassign(
requestId: string,
input: { actorId: string; to: string; from?: string; comment?: string },
context: SharingExecutionContext,
): Promise<{ request: ApprovalRequestRow }>;
/**
* Submitter nudge: notify every pending approver. Throttled — repeat
* reminders inside the cool-down window are rejected (`THROTTLED`).
* Audits a `remind` action.
*/
remind(
requestId: string,
input: { actorId: string; comment?: string },
context: SharingExecutionContext,
): Promise<{ request: ApprovalRequestRow; notified: number }>;
/**
* Approver asks the submitter for more information. The request STAYS
* pending (no flow movement) — this is a thread interaction, audited as
* `request_info`, with the submitter notified when messaging is attached.
*/
requestInfo(
requestId: string,
input: { actorId: string; comment: string },
context: SharingExecutionContext,
): Promise<{ request: ApprovalRequestRow }>;
/**
* Free-form reply on the request thread (submitter or any pending
* approver). Audited as `comment`.
*/
comment(
requestId: string,
input: { actorId: string; comment: string },
context: SharingExecutionContext,
): Promise<{ request: ApprovalRequestRow }>;
/** Audit trail for a request. */
listActions(requestId: string, context: SharingExecutionContext): Promise<ApprovalActionRow[]>;
}