@objectstack/plugin-approvals 16.1.0 → 17.0.0-rc.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.
@@ -1,401 +0,0 @@
1
- // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- import { ObjectSchema, Field } from '@objectstack/spec/data';
4
-
5
- /**
6
- * sys_approval_request — Live approval instance.
7
- *
8
- * ADR-0019: opened by a flow's **Approval node** when the run reaches it; the
9
- * run suspends until a decision is recorded. The row's lifecycle:
10
- *
11
- * `pending` → (per-approver decisions) → `approved` | `rejected`
12
- * `pending` → recalled by submitter → `recalled`
13
- *
14
- * `flow_run_id` / `flow_node_id` tie the request back to the suspended run so a
15
- * decision can resume it; `current_step` mirrors the node id. `node_config_json`
16
- * snapshots the Approval node config (approvers / behaviour) the request was
17
- * opened with.
18
- *
19
- * `payload_json` captures a snapshot of the target record at submission
20
- * time — used by notifications so they can render before the record is
21
- * locked or changed.
22
- *
23
- * @namespace sys
24
- */
25
- export const SysApprovalRequest = ObjectSchema.create({
26
- name: 'sys_approval_request',
27
- label: 'Approval Request',
28
- pluralLabel: 'Approval Requests',
29
- icon: 'inbox',
30
- isSystem: true,
31
- managedBy: 'engine-owned',
32
- description: 'Live approval instance tracked per submission',
33
- displayNameField: 'id',
34
- nameField: 'id', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
35
- titleFormat: '{process_name} · {record_id}',
36
- highlightFields: ['process_name', 'object_name', 'record_id', 'status', 'current_step', 'submitter_id', 'updated_at'],
37
-
38
- // Curated built-in list views — render as segmented tabs in the console.
39
- // Filters use {current_user_id} substitution wired by the console.
40
- listViews: {
41
- my_pending: {
42
- type: 'grid',
43
- name: 'my_pending',
44
- label: 'My Pending',
45
- data: { provider: 'object', object: 'sys_approval_request' },
46
- columns: ['process_name', 'object_name', 'record_id', 'current_step', 'submitter_id', 'updated_at'],
47
- filter: [
48
- { field: 'status', operator: 'equals', value: 'pending' },
49
- { field: 'pending_approvers', operator: 'contains', value: '{current_user_id}' },
50
- ],
51
- sort: [{ field: 'updated_at', order: 'desc' }],
52
- pagination: { pageSize: 25 },
53
- emptyState: { title: 'No pending approvals', message: 'You\'re all caught up.' },
54
- },
55
- submitted_by_me: {
56
- type: 'grid',
57
- name: 'submitted_by_me',
58
- label: 'I Submitted',
59
- data: { provider: 'object', object: 'sys_approval_request' },
60
- columns: ['process_name', 'object_name', 'record_id', 'status', 'current_step', 'updated_at'],
61
- filter: [{ field: 'submitter_id', operator: 'equals', value: '{current_user_id}' }],
62
- sort: [{ field: 'updated_at', order: 'desc' }],
63
- pagination: { pageSize: 25 },
64
- },
65
- completed: {
66
- type: 'grid',
67
- name: 'completed',
68
- label: 'Completed',
69
- data: { provider: 'object', object: 'sys_approval_request' },
70
- columns: ['process_name', 'object_name', 'record_id', 'status', 'submitter_id', 'completed_at'],
71
- filter: [{ field: 'status', operator: 'in', value: ['approved', 'rejected', 'recalled'] }],
72
- sort: [{ field: 'completed_at', order: 'desc' }],
73
- pagination: { pageSize: 25 },
74
- },
75
- all_requests: {
76
- type: 'grid',
77
- name: 'all_requests',
78
- label: 'All',
79
- data: { provider: 'object', object: 'sys_approval_request' },
80
- columns: ['process_name', 'object_name', 'record_id', 'status', 'current_step', 'submitter_id', 'updated_at'],
81
- sort: [{ field: 'updated_at', order: 'desc' }],
82
- pagination: { pageSize: 50 },
83
- },
84
- },
85
-
86
- fields: {
87
- id: Field.text({ label: 'Request ID', required: true, readonly: true, group: 'System' }),
88
-
89
- organization_id: Field.lookup('sys_organization', {
90
- label: 'Organization',
91
- required: false,
92
- group: 'System',
93
- description: 'Tenant that owns this approval request (propagated from submitter context)',
94
- }),
95
-
96
- process_name: Field.text({
97
- label: 'Source',
98
- required: true,
99
- maxLength: 100,
100
- description: 'Origin of the request — `flow:<flowName|nodeId>` for node-driven approvals',
101
- group: 'Target',
102
- }),
103
-
104
- object_name: Field.text({
105
- label: 'Object',
106
- required: true,
107
- maxLength: 100,
108
- group: 'Target',
109
- }),
110
-
111
- record_id: Field.text({
112
- label: 'Record ID',
113
- required: true,
114
- maxLength: 100,
115
- group: 'Target',
116
- }),
117
-
118
- submitter_id: Field.lookup('sys_user', {
119
- label: 'Submitter',
120
- required: false,
121
- group: 'Target',
122
- }),
123
-
124
- submitter_comment: Field.textarea({
125
- label: 'Submitter Comment',
126
- required: false,
127
- group: 'Target',
128
- }),
129
-
130
- status: Field.select(
131
- // Keep in sync with `ApprovalStatus` (spec/contracts). `returned` =
132
- // sent back for revision (ADR-0044) — terminal for this round.
133
- ['pending', 'approved', 'rejected', 'recalled', 'returned'],
134
- {
135
- label: 'Status',
136
- required: true,
137
- defaultValue: 'pending',
138
- description: 'Lifecycle state of the request',
139
- group: 'State',
140
- },
141
- ),
142
-
143
- current_step: Field.text({
144
- label: 'Current Step',
145
- required: false,
146
- maxLength: 100,
147
- description: 'Machine name of the step awaiting approval',
148
- group: 'State',
149
- }),
150
-
151
- current_step_index: Field.number({
152
- label: 'Current Step Index',
153
- required: false,
154
- defaultValue: 0,
155
- group: 'State',
156
- }),
157
-
158
- pending_approvers: Field.textarea({
159
- label: 'Pending Approvers',
160
- required: false,
161
- description: 'Comma-separated user ids who can act on the current step',
162
- group: 'State',
163
- }),
164
-
165
- payload_json: Field.textarea({
166
- label: 'Snapshot',
167
- required: false,
168
- description: 'Record snapshot at submission time',
169
- group: 'State',
170
- }),
171
-
172
- // ── ADR-0019: approval-as-flow-node correlation ──────────────────
173
- // When a request is opened by an Approval *node* (rather than a standalone
174
- // process), these tie it back to the suspended flow run so a decision can
175
- // resume it. Null for legacy process-driven requests.
176
- flow_run_id: Field.text({
177
- label: 'Flow Run',
178
- required: false,
179
- maxLength: 100,
180
- readonly: true,
181
- description: 'Suspended automation run id this request gates (ADR-0019). The decision resumes it.',
182
- group: 'State',
183
- }),
184
-
185
- flow_node_id: Field.text({
186
- label: 'Flow Node',
187
- required: false,
188
- maxLength: 100,
189
- readonly: true,
190
- description: 'Approval node id within the flow that opened this request (ADR-0019).',
191
- group: 'State',
192
- }),
193
-
194
- node_config_json: Field.textarea({
195
- label: 'Node Config',
196
- required: false,
197
- readonly: true,
198
- description: 'Snapshot of the Approval node config (approvers/behavior) for node-driven requests (ADR-0019).',
199
- group: 'State',
200
- }),
201
-
202
- completed_at: Field.datetime({
203
- label: 'Completed At',
204
- required: false,
205
- group: 'State',
206
- }),
207
-
208
- created_at: Field.datetime({
209
- label: 'Created At',
210
- required: true,
211
- defaultValue: 'NOW()',
212
- readonly: true,
213
- group: 'System',
214
- }),
215
-
216
- updated_at: Field.datetime({ label: 'Updated At', required: false, group: 'System' }),
217
- },
218
-
219
- indexes: [
220
- // Look up "is there a pending request for this record?" — common
221
- // guard on submit and on edit-while-locked checks.
222
- { fields: ['object_name', 'record_id'] },
223
- { fields: ['status', 'object_name'] },
224
- // Status-windowed listings (escalation sweep, "All" tab ordering).
225
- // "My approvals" matching no longer scans this table: the service keeps
226
- // a normalized per-approver index in `sys_approval_approver` (#1745) and
227
- // resolves approver filters there; `pending_approvers` stays the
228
- // human-readable CSV source of truth only.
229
- { fields: ['status', 'updated_at'] },
230
- { fields: ['submitter_id', 'status'] },
231
- ],
232
-
233
- // Server-declared decision actions (objectui#2678 P2-4). The console's
234
- // generic action runtime renders and executes these wherever this object is
235
- // surfaced — the approvals inbox included — so new decision capabilities
236
- // (and their params) ship as metadata, not as hand-written buttons. Each
237
- // targets the existing approvals REST route; `{id}` resolves from the row
238
- // and `actorId` defaults to the caller server-side. The service remains the
239
- // authority on who may act; `visible` gates on the server-computed
240
- // per-viewer block (#3310): approver actions on `record.viewer.can_act`
241
- // (the caller is a current pending approver — same check the service
242
- // authorizes a decision with, so position/team approvers resolve correctly),
243
- // submitter actions on `record.viewer.is_submitter`. `viewer` is attached by
244
- // getRequest/listRequests; where it is absent the predicate fails closed.
245
- actions: [
246
- {
247
- name: 'approval_approve',
248
- label: 'Approve',
249
- icon: 'check-circle',
250
- type: 'api',
251
- method: 'POST',
252
- target: '/api/v1/approvals/requests/{id}/approve',
253
- params: [
254
- { name: 'comment', label: 'Comment', type: 'textarea', required: false },
255
- // Decision attachments (#3266). The console renders `type:'file'` params
256
- // through the shared upload widget and POSTs the resolved `attachments:
257
- // string[]`; the decision route persists them on `sys_approval_action`.
258
- { name: 'attachments', label: 'Attachments', type: 'file', multiple: true, required: false },
259
- ],
260
- visible: 'record.viewer.can_act',
261
- locations: ['record_section', 'list_item'],
262
- successMessage: 'Approved.',
263
- refreshAfter: true,
264
- },
265
- {
266
- name: 'approval_reject',
267
- label: 'Reject',
268
- icon: 'x-circle',
269
- type: 'api',
270
- method: 'POST',
271
- target: '/api/v1/approvals/requests/{id}/reject',
272
- params: [
273
- { name: 'comment', label: 'Comment', type: 'textarea', required: false },
274
- { name: 'attachments', label: 'Attachments', type: 'file', multiple: true, required: false },
275
- ],
276
- visible: 'record.viewer.can_act',
277
- confirmText: 'Reject this request? A rejection is final for every approver.',
278
- locations: ['record_section', 'list_item'],
279
- successMessage: 'Rejected.',
280
- refreshAfter: true,
281
- },
282
- {
283
- name: 'approval_reassign',
284
- label: 'Reassign',
285
- icon: 'arrow-right-left',
286
- type: 'api',
287
- method: 'POST',
288
- target: '/api/v1/approvals/requests/{id}/reassign',
289
- params: [
290
- // Field-backed on `submitter_id` (the object's only `sys_user` lookup):
291
- // the console resolves its lookup config (`reference_to: sys_user`) so the
292
- // dialog renders a real user picker, while `name: 'to'` overrides the
293
- // request-body key to the `to` the reassign route expects. This is a
294
- // config-borrow, not a submitter pre-fill (`defaultFromRow` stays off).
295
- { field: 'submitter_id', name: 'to', label: 'New approver', required: true, helpText: 'User to hand this step to' },
296
- { name: 'comment', label: 'Comment', type: 'textarea', required: false },
297
- ],
298
- visible: 'record.viewer.can_act',
299
- locations: ['record_section'],
300
- successMessage: 'Reassigned.',
301
- refreshAfter: true,
302
- },
303
-
304
- // ── Approver secondary decisions ────────────────────────────────
305
- // Send back for revision / request more info (ADR-0044). Both are approver
306
- // actions, so `visible` gates on `record.viewer.can_act` (a current pending
307
- // approver) — same as approve/reject. The service stays the authority.
308
- {
309
- name: 'approval_send_back',
310
- label: 'Send back',
311
- icon: 'corner-up-left',
312
- type: 'api',
313
- method: 'POST',
314
- target: '/api/v1/approvals/requests/{id}/revise',
315
- params: [
316
- { name: 'comment', label: 'Reason', type: 'textarea', required: false },
317
- ],
318
- visible: 'record.viewer.can_act',
319
- locations: ['record_section'],
320
- successMessage: 'Sent back for revision.',
321
- refreshAfter: true,
322
- },
323
- {
324
- name: 'approval_request_info',
325
- label: 'Request info',
326
- icon: 'help-circle',
327
- type: 'api',
328
- method: 'POST',
329
- target: '/api/v1/approvals/requests/{id}/request-info',
330
- params: [
331
- { name: 'comment', label: 'What do you need?', type: 'textarea', required: true },
332
- ],
333
- visible: 'record.viewer.can_act',
334
- locations: ['record_section'],
335
- successMessage: 'Information requested.',
336
- refreshAfter: true,
337
- },
338
-
339
- // ── Submitter continuity actions ────────────────────────────────
340
- // Remind / recall (pending) and resubmit / recall (returned). These are the
341
- // submitter's own levers, so `visible` gates on `record.viewer.is_submitter`
342
- // (server-computed on the current viewer). The service re-checks ownership;
343
- // the predicate keeps a non-submitter from ever seeing a button they cannot
344
- // use.
345
- {
346
- name: 'approval_remind',
347
- label: 'Send reminder',
348
- icon: 'bell-ring',
349
- type: 'api',
350
- method: 'POST',
351
- target: '/api/v1/approvals/requests/{id}/remind',
352
- params: [
353
- { name: 'comment', label: 'Note', type: 'textarea', required: false },
354
- ],
355
- visible: 'record.status == "pending" && record.viewer.is_submitter',
356
- locations: ['record_section'],
357
- successMessage: 'Reminder sent.',
358
- refreshAfter: true,
359
- },
360
- {
361
- name: 'approval_recall',
362
- label: 'Recall',
363
- icon: 'undo-2',
364
- type: 'api',
365
- method: 'POST',
366
- target: '/api/v1/approvals/requests/{id}/recall',
367
- params: [
368
- { name: 'comment', label: 'Comment', type: 'textarea', required: false },
369
- ],
370
- // Recall applies while the request is live for the submitter — pending
371
- // (withdraw) or returned (abandon the revision instead of resubmitting).
372
- visible: '(record.status == "pending" || record.status == "returned") && record.viewer.is_submitter',
373
- confirmText: 'Recall this request? Approvers can no longer act on it and the record is unlocked.',
374
- locations: ['record_section'],
375
- successMessage: 'Recalled.',
376
- refreshAfter: true,
377
- },
378
- {
379
- name: 'approval_resubmit',
380
- label: 'Resubmit',
381
- icon: 'refresh-cw',
382
- type: 'api',
383
- method: 'POST',
384
- target: '/api/v1/approvals/requests/{id}/resubmit',
385
- params: [
386
- { name: 'comment', label: 'What changed?', type: 'textarea', required: false },
387
- ],
388
- visible: 'record.status == "returned" && record.viewer.is_submitter',
389
- locations: ['record_section'],
390
- successMessage: 'Resubmitted.',
391
- refreshAfter: true,
392
- },
393
- ],
394
-
395
- enable: {
396
- // [ADR-0103] Engine-owned: the approval engine owns the request lifecycle
397
- // (SYSTEM_CTX); users act via domain actions (Submit/Approve/Recall), never
398
- // generic CRUD. Reads stay open.
399
- apiMethods: ['get', 'list'],
400
- },
401
- });
@@ -1,101 +0,0 @@
1
- // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- import { ObjectSchema, Field } from '@objectstack/spec/data';
4
-
5
- /**
6
- * sys_approval_token — single-use actionable-link tokens (ADR-0043).
7
- *
8
- * One row per issued approve/reject link. Only the SHA-256 **hash** of the
9
- * raw token is stored — a database leak yields no usable links. A token is
10
- * dead once any of these holds: `consumed_at` set, `expires_at` passed, the
11
- * request left `pending`, or the bound approver no longer holds a slot
12
- * (the last two are re-checked at redemption, not materialized here).
13
- *
14
- * @namespace sys
15
- */
16
- export const SysApprovalToken = ObjectSchema.create({
17
- name: 'sys_approval_token',
18
- label: 'Approval Action Token',
19
- pluralLabel: 'Approval Action Tokens',
20
- icon: 'key',
21
- isSystem: true,
22
- managedBy: 'engine-owned',
23
- description: 'Single-use tokens behind actionable approval links',
24
- displayNameField: 'id',
25
- nameField: 'id', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
26
-
27
- fields: {
28
- id: Field.text({ label: 'Token ID', required: true, readonly: true, group: 'System' }),
29
-
30
- organization_id: Field.lookup('sys_organization', {
31
- label: 'Organization',
32
- required: false,
33
- group: 'System',
34
- }),
35
-
36
- token_hash: Field.text({
37
- label: 'Token Hash',
38
- required: true,
39
- maxLength: 100,
40
- readonly: true,
41
- description: 'SHA-256 hex of the raw token — the raw value is never stored',
42
- group: 'Token',
43
- }),
44
-
45
- request_id: Field.text({
46
- label: 'Request',
47
- required: true,
48
- maxLength: 100,
49
- readonly: true,
50
- group: 'Token',
51
- }),
52
-
53
- action: Field.select(['approve', 'reject'], {
54
- label: 'Action',
55
- required: true,
56
- readonly: true,
57
- group: 'Token',
58
- }),
59
-
60
- approver_id: Field.text({
61
- label: 'Approver',
62
- required: true,
63
- maxLength: 200,
64
- readonly: true,
65
- description: 'Identity the token is bound to; the decision is audited as this approver',
66
- group: 'Token',
67
- }),
68
-
69
- expires_at: Field.datetime({
70
- label: 'Expires At',
71
- required: true,
72
- readonly: true,
73
- group: 'Lifecycle',
74
- }),
75
-
76
- consumed_at: Field.datetime({
77
- label: 'Consumed At',
78
- required: false,
79
- group: 'Lifecycle',
80
- }),
81
-
82
- created_at: Field.datetime({
83
- label: 'Created At',
84
- required: true,
85
- defaultValue: 'NOW()',
86
- readonly: true,
87
- group: 'System',
88
- }),
89
- },
90
-
91
- indexes: [
92
- { fields: ['token_hash'] },
93
- { fields: ['request_id'] },
94
- ],
95
-
96
- enable: {
97
- // [ADR-0103] Engine-owned: one-time email-approval tokens are minted and
98
- // consumed by the approval engine (SYSTEM_CTX), never via the data API.
99
- apiMethods: ['get', 'list'],
100
- },
101
- });