-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathapproval-service.ts
More file actions
519 lines (491 loc) · 21.3 KB
/
Copy pathapproval-service.ts
File metadata and controls
519 lines (491 loc) · 21.3 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
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
// 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;
/**
* #3447 P2: the node's author-declared decision-output keys
* (`config.decisionOutputs`), surfaced from the config snapshot so a
* decision UI can render one input per key and POST `outputs` with the
* decision. Absent when the node declares none. Kept as the bare KEY list
* for version skew — an older console renders these as text inputs.
*/
decision_outputs?: string[];
/**
* #3447 P2 follow-up: the normalized TYPED declarations behind
* `decision_outputs` — `{ key, label?, type?, multiple? }` — so a
* picker-aware decision UI renders a sys_user / department / position /
* team record picker (id values; `multiple` → id array) instead of free
* text. Always parallel to `decision_outputs`; consumers prefer this and
* fall back to the key list.
*/
decision_output_defs?: Array<{
key: string;
label?: string;
type?: 'text' | 'user' | 'department' | 'position' | 'team';
multiple?: boolean;
/**
* The approver must supply this one to APPROVE (objectui#2955) — enforced
* by `decide()`, so a decision UI should block the approve action on a
* blank value rather than letting the server reject the round trip.
* Never enforced on reject.
*/
required?: boolean;
}>;
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>;
/**
* Group membership of each STILL-PENDING approver, for `per_group` (会签)
* requests only (objectui#2807). Maps an approver id in `pending_approvers`
* to the group key(s) it fills — e.g. `{ "u_devadmin": ["finance", "legal"] }`
* — so a client can label each "waiting on" chip with the group it represents
* instead of showing duplicate, context-free names. Resolved from the same
* open-time `__approverGroups` snapshot the `decision_progress` groups use, so
* the two never disagree. Absent for non-`per_group` behaviors and for slots
* whose group was synthetic (unnamed). Display-only.
*/
pending_approver_groups?: 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>;
/**
* Display labels for `payload` fields (field key → target object's field
* label), so inbox summaries show the human field name (e.g. "考核状态")
* instead of a title-cased machine key ("Assessment Status"). Resolved from
* the target object's schema — for a single-locale project the schema label
* IS the localized string; symmetric with `payload_display` (which resolves
* the values). Absent keys fall back to the client's prettified key.
*/
payload_labels?: 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;
/**
* Whether THIS node's pending request locks the target record from edits
* (objectui#2902). Mirrors the `lockRecord` policy the record-lock
* `beforeUpdate` hook enforces, read from the same `node_config_json`
* snapshot the hook reads — so a client never has to guess, and never
* disagrees with the server.
*
* `lockRecord` defaults to `true` (see `ApprovalNodeConfigSchema`), so this
* is `false` only when the node explicitly opted out. Always present on a
* service read; a client that gets `undefined` is talking to a pre-#3814
* backend and should fail closed (assume locked) rather than offer an edit
* the server will reject with `RECORD_LOCKED`.
*
* Node-scoped, not request-scoped in spirit: a flow chaining several
* approval nodes with different policies produces one request per node, and
* each carries its own value.
*/
lock_record?: boolean;
/**
* 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.
* - `can_override` (#3424) — the caller is a platform/tenant admin who may act
* on a *pending* request (approve / reject / reassign / recall it) despite
* holding no approver slot. The in-product recovery path for an approval
* routed to an unstaffed position, or whose approvers have all since left,
* which would otherwise leave the request undecidable and the record locked
* forever. Clients OR it into the decision actions' `visible` gate; the
* service re-checks the same privilege before applying any override.
*
* 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;
can_override: 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';
/**
* A file attached to a decision action (#3266) — the READ shape of one
* `sys_approval_action.attachments` entry.
*
* The column **stores an opaque `sys_file` id** (ADR-0104 D3: that is the
* stored form of every media field). The name, size, MIME type and URL are not
* stored alongside it — the ObjectQL read path resolves the id into its
* expanded `FileValueSchema` form on the way out, and this interface is that
* form plus the id. So a consumer gets everything it needs to label and open an
* attachment without read access to the system `sys_file` object, while the
* write side stays a plain id (see `ApprovalDecisionInput.attachments`).
*
* Field names follow the expanded form — `mimeType`, not `mime_type`.
*/
export interface ApprovalActionAttachment {
/** The `sys_file` id — pass to `GET /storage/files/:id/url` for a signed URL. */
id: string;
/** Original filename, for the chip label. */
name?: string;
/** Stable download URL (`/api/v1/storage/files/:id`); may be relative. */
url?: string;
mimeType?: string;
size?: number;
}
/** Audit row. */
export interface ApprovalActionRow {
id: string;
request_id: string;
step_name?: string;
step_index?: number;
action: ApprovalActionKind;
actor_id?: string;
comment?: string;
/** Files attached to this action (decision attachments, #3266). */
attachments?: ApprovalActionAttachment[];
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[];
/**
* #3447 P2: structured outputs the approver hands to the flow with their
* decision. Keys MUST be declared on the node's `decisionOutputs` config —
* the author declares keys, approvers only fill values (a `screen` node's
* trust model); a decision carrying undeclared keys is rejected, and
* `decision` / `requestId` are reserved. Accepted outputs resume the run as
* `<nodeId>.<key>` flow variables, where a later approval node's
* `expression` approver can read them (`vars.<nodeId>.picked_departments`).
*
* An output declared `required` must carry a non-blank value on an APPROVE
* (objectui#2955); the decision is rejected before any write otherwise. A
* reject never requires them.
*/
outputs?: Record<string, unknown>;
}
/** 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[]>;
}