@objectstack/plugin-approvals 17.0.0-rc.0 → 17.0.0-rc.2
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/CHANGELOG.md +863 -0
- package/dist/index.d.mts +2236 -2688
- package/dist/index.d.ts +2236 -2688
- package/dist/index.js +591 -128
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +590 -127
- package/dist/index.mjs.map +1 -1
- package/package.json +17 -10
- package/.turbo/turbo-build.log +0 -22
- package/scripts/i18n-extract.config.ts +0 -38
- package/src/action-link-pages.ts +0 -102
- package/src/approval-actor-impersonation.test.ts +0 -330
- package/src/approval-node.test.ts +0 -356
- package/src/approval-node.ts +0 -196
- package/src/approval-revise.test.ts +0 -418
- package/src/approval-service.test.ts +0 -2858
- package/src/approval-service.ts +0 -3617
- package/src/approvals-plugin.ts +0 -294
- package/src/approver-cross-org.integration.test.ts +0 -206
- package/src/approver-org-scope.test.ts +0 -201
- package/src/approver-org-scope.ts +0 -261
- package/src/index.ts +0 -42
- package/src/lifecycle-hooks.ts +0 -201
- package/src/nav-contribution.test.ts +0 -50
- package/src/record-lock-schedule-run.integration.test.ts +0 -206
- package/src/status-mirror-cascade.integration.test.ts +0 -224
- package/src/sys-approval-action.object.ts +0 -149
- package/src/sys-approval-approver.object.ts +0 -85
- package/src/sys-approval-delegation.object.test.ts +0 -42
- package/src/sys-approval-delegation.object.ts +0 -142
- package/src/sys-approval-request.object.test.ts +0 -116
- package/src/sys-approval-request.object.ts +0 -413
- package/src/sys-approval-token.object.ts +0 -101
- package/src/translations/bundle-ownership.test.ts +0 -48
- package/src/translations/en.objects.generated.ts +0 -311
- package/src/translations/es-ES.objects.generated.ts +0 -311
- package/src/translations/index.ts +0 -23
- package/src/translations/ja-JP.objects.generated.ts +0 -311
- package/src/translations/zh-CN.objects.generated.ts +0 -311
- package/tsconfig.json +0 -10
|
@@ -1,413 +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`. The core decision levers
|
|
244
|
-
// (approve/reject/reassign) additionally OR in `record.viewer.can_override`
|
|
245
|
-
// (#3424) so a platform/tenant admin can rescue a request routed to an
|
|
246
|
-
// unstaffed position — otherwise undecidable, locking the record forever — by
|
|
247
|
-
// approving, rejecting, or reassigning it to a real approver. `viewer` is
|
|
248
|
-
// attached by getRequest/listRequests; where it is absent the predicate fails
|
|
249
|
-
// closed.
|
|
250
|
-
actions: [
|
|
251
|
-
{
|
|
252
|
-
name: 'approval_approve',
|
|
253
|
-
label: 'Approve',
|
|
254
|
-
icon: 'check-circle',
|
|
255
|
-
// Primary decision — the console renders this filled/highlighted so it
|
|
256
|
-
// stands out from the secondary levers in the drawer's action bar,
|
|
257
|
-
// matching the mobile card hierarchy (objectui#2762 P1-5).
|
|
258
|
-
variant: 'primary',
|
|
259
|
-
type: 'api',
|
|
260
|
-
method: 'POST',
|
|
261
|
-
target: '/api/v1/approvals/requests/{id}/approve',
|
|
262
|
-
params: [
|
|
263
|
-
{ name: 'comment', label: 'Comment', type: 'textarea', required: false },
|
|
264
|
-
// Decision attachments (#3266). The console renders `type:'file'` params
|
|
265
|
-
// through the shared upload widget and POSTs the resolved `attachments:
|
|
266
|
-
// string[]`; the decision route persists them on `sys_approval_action`.
|
|
267
|
-
{ name: 'attachments', label: 'Attachments', type: 'file', multiple: true, required: false },
|
|
268
|
-
],
|
|
269
|
-
visible: 'record.viewer.can_act || record.viewer.can_override',
|
|
270
|
-
locations: ['record_section', 'list_item'],
|
|
271
|
-
successMessage: 'Approved.',
|
|
272
|
-
refreshAfter: true,
|
|
273
|
-
},
|
|
274
|
-
{
|
|
275
|
-
name: 'approval_reject',
|
|
276
|
-
label: 'Reject',
|
|
277
|
-
icon: 'x-circle',
|
|
278
|
-
// Destructive decision — rendered in the console's danger styling so it
|
|
279
|
-
// reads as the irreversible action it is (objectui#2762 P1-5).
|
|
280
|
-
variant: 'danger',
|
|
281
|
-
type: 'api',
|
|
282
|
-
method: 'POST',
|
|
283
|
-
target: '/api/v1/approvals/requests/{id}/reject',
|
|
284
|
-
params: [
|
|
285
|
-
{ name: 'comment', label: 'Comment', type: 'textarea', required: false },
|
|
286
|
-
{ name: 'attachments', label: 'Attachments', type: 'file', multiple: true, required: false },
|
|
287
|
-
],
|
|
288
|
-
visible: 'record.viewer.can_act || record.viewer.can_override',
|
|
289
|
-
confirmText: 'Reject this request? A rejection is final for every approver.',
|
|
290
|
-
locations: ['record_section', 'list_item'],
|
|
291
|
-
successMessage: 'Rejected.',
|
|
292
|
-
refreshAfter: true,
|
|
293
|
-
},
|
|
294
|
-
{
|
|
295
|
-
name: 'approval_reassign',
|
|
296
|
-
label: 'Reassign',
|
|
297
|
-
icon: 'arrow-right-left',
|
|
298
|
-
type: 'api',
|
|
299
|
-
method: 'POST',
|
|
300
|
-
target: '/api/v1/approvals/requests/{id}/reassign',
|
|
301
|
-
params: [
|
|
302
|
-
// Field-backed on `submitter_id` (the object's only `sys_user` lookup):
|
|
303
|
-
// the console resolves its lookup config (`reference_to: sys_user`) so the
|
|
304
|
-
// dialog renders a real user picker, while `name: 'to'` overrides the
|
|
305
|
-
// request-body key to the `to` the reassign route expects. This is a
|
|
306
|
-
// config-borrow, not a submitter pre-fill (`defaultFromRow` stays off).
|
|
307
|
-
{ field: 'submitter_id', name: 'to', label: 'New approver', required: true, helpText: 'User to hand this step to' },
|
|
308
|
-
{ name: 'comment', label: 'Comment', type: 'textarea', required: false },
|
|
309
|
-
],
|
|
310
|
-
visible: 'record.viewer.can_act || record.viewer.can_override',
|
|
311
|
-
locations: ['record_section'],
|
|
312
|
-
successMessage: 'Reassigned.',
|
|
313
|
-
refreshAfter: true,
|
|
314
|
-
},
|
|
315
|
-
|
|
316
|
-
// ── Approver secondary decisions ────────────────────────────────
|
|
317
|
-
// Send back for revision / request more info (ADR-0044). Both are approver
|
|
318
|
-
// actions, so `visible` gates on `record.viewer.can_act` (a current pending
|
|
319
|
-
// approver) — same as approve/reject. The service stays the authority.
|
|
320
|
-
{
|
|
321
|
-
name: 'approval_send_back',
|
|
322
|
-
label: 'Send back',
|
|
323
|
-
icon: 'corner-up-left',
|
|
324
|
-
type: 'api',
|
|
325
|
-
method: 'POST',
|
|
326
|
-
target: '/api/v1/approvals/requests/{id}/revise',
|
|
327
|
-
params: [
|
|
328
|
-
{ name: 'comment', label: 'Reason', type: 'textarea', required: false },
|
|
329
|
-
],
|
|
330
|
-
visible: 'record.viewer.can_act',
|
|
331
|
-
locations: ['record_section'],
|
|
332
|
-
successMessage: 'Sent back for revision.',
|
|
333
|
-
refreshAfter: true,
|
|
334
|
-
},
|
|
335
|
-
{
|
|
336
|
-
name: 'approval_request_info',
|
|
337
|
-
label: 'Request info',
|
|
338
|
-
icon: 'help-circle',
|
|
339
|
-
type: 'api',
|
|
340
|
-
method: 'POST',
|
|
341
|
-
target: '/api/v1/approvals/requests/{id}/request-info',
|
|
342
|
-
params: [
|
|
343
|
-
{ name: 'comment', label: 'What do you need?', type: 'textarea', required: true },
|
|
344
|
-
],
|
|
345
|
-
visible: 'record.viewer.can_act',
|
|
346
|
-
locations: ['record_section'],
|
|
347
|
-
successMessage: 'Information requested.',
|
|
348
|
-
refreshAfter: true,
|
|
349
|
-
},
|
|
350
|
-
|
|
351
|
-
// ── Submitter continuity actions ────────────────────────────────
|
|
352
|
-
// Remind / recall (pending) and resubmit / recall (returned). These are the
|
|
353
|
-
// submitter's own levers, so `visible` gates on `record.viewer.is_submitter`
|
|
354
|
-
// (server-computed on the current viewer). The service re-checks ownership;
|
|
355
|
-
// the predicate keeps a non-submitter from ever seeing a button they cannot
|
|
356
|
-
// use.
|
|
357
|
-
{
|
|
358
|
-
name: 'approval_remind',
|
|
359
|
-
label: 'Send reminder',
|
|
360
|
-
icon: 'bell-ring',
|
|
361
|
-
type: 'api',
|
|
362
|
-
method: 'POST',
|
|
363
|
-
target: '/api/v1/approvals/requests/{id}/remind',
|
|
364
|
-
params: [
|
|
365
|
-
{ name: 'comment', label: 'Note', type: 'textarea', required: false },
|
|
366
|
-
],
|
|
367
|
-
visible: 'record.status == "pending" && record.viewer.is_submitter',
|
|
368
|
-
locations: ['record_section'],
|
|
369
|
-
successMessage: 'Reminder sent.',
|
|
370
|
-
refreshAfter: true,
|
|
371
|
-
},
|
|
372
|
-
{
|
|
373
|
-
name: 'approval_recall',
|
|
374
|
-
label: 'Recall',
|
|
375
|
-
icon: 'undo-2',
|
|
376
|
-
type: 'api',
|
|
377
|
-
method: 'POST',
|
|
378
|
-
target: '/api/v1/approvals/requests/{id}/recall',
|
|
379
|
-
params: [
|
|
380
|
-
{ name: 'comment', label: 'Comment', type: 'textarea', required: false },
|
|
381
|
-
],
|
|
382
|
-
// Recall applies while the request is live for the submitter — pending
|
|
383
|
-
// (withdraw) or returned (abandon the revision instead of resubmitting).
|
|
384
|
-
visible: '(record.status == "pending" || record.status == "returned") && record.viewer.is_submitter',
|
|
385
|
-
confirmText: 'Recall this request? Approvers can no longer act on it and the record is unlocked.',
|
|
386
|
-
locations: ['record_section'],
|
|
387
|
-
successMessage: 'Recalled.',
|
|
388
|
-
refreshAfter: true,
|
|
389
|
-
},
|
|
390
|
-
{
|
|
391
|
-
name: 'approval_resubmit',
|
|
392
|
-
label: 'Resubmit',
|
|
393
|
-
icon: 'refresh-cw',
|
|
394
|
-
type: 'api',
|
|
395
|
-
method: 'POST',
|
|
396
|
-
target: '/api/v1/approvals/requests/{id}/resubmit',
|
|
397
|
-
params: [
|
|
398
|
-
{ name: 'comment', label: 'What changed?', type: 'textarea', required: false },
|
|
399
|
-
],
|
|
400
|
-
visible: 'record.status == "returned" && record.viewer.is_submitter',
|
|
401
|
-
locations: ['record_section'],
|
|
402
|
-
successMessage: 'Resubmitted.',
|
|
403
|
-
refreshAfter: true,
|
|
404
|
-
},
|
|
405
|
-
],
|
|
406
|
-
|
|
407
|
-
enable: {
|
|
408
|
-
// [ADR-0103] Engine-owned: the approval engine owns the request lifecycle
|
|
409
|
-
// (SYSTEM_CTX); users act via domain actions (Submit/Approve/Recall), never
|
|
410
|
-
// generic CRUD. Reads stay open.
|
|
411
|
-
apiMethods: ['get', 'list'],
|
|
412
|
-
},
|
|
413
|
-
});
|
|
@@ -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
|
-
});
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
-
//
|
|
3
|
-
// Bundle-ownership guard (#2834 ⑤ / ADR-0029 D8): this package's generated
|
|
4
|
-
// object-translation bundles must carry ONLY objects its extract config
|
|
5
|
-
// (`scripts/i18n-extract.config.ts`) actually imports. When an object moves to
|
|
6
|
-
// another package, its translations move with it — a leftover copy here
|
|
7
|
-
// silently DIES on the next `os i18n extract` run, taking curated translations
|
|
8
|
-
// with it (the sys_audit_log incident). This test turns that silent loss into a
|
|
9
|
-
// red build: an object present in the bundle but not in the ownership list below
|
|
10
|
-
// means either (a) the extract config gained an object — add it here — or (b) a
|
|
11
|
-
// moved/removed object's keys were left behind — remove them from the bundles
|
|
12
|
-
// (or migrate them to the owning package), then keep this list in sync.
|
|
13
|
-
//
|
|
14
|
-
// NB: this package defines more approval objects (sys_approval_approver,
|
|
15
|
-
// sys_approval_token) that the extract config deliberately does NOT translate —
|
|
16
|
-
// they are absent from both the config and the bundles, so they are correctly
|
|
17
|
-
// out of scope here.
|
|
18
|
-
|
|
19
|
-
import { describe, it, expect } from 'vitest';
|
|
20
|
-
import { enObjects } from './en.objects.generated.js';
|
|
21
|
-
|
|
22
|
-
// Objects the extract config (scripts/i18n-extract.config.ts) imports —
|
|
23
|
-
// keep the two lists in sync when adding/moving objects.
|
|
24
|
-
const OWNED_OBJECTS = new Set([
|
|
25
|
-
'sys_approval_request',
|
|
26
|
-
'sys_approval_action',
|
|
27
|
-
'sys_approval_delegation',
|
|
28
|
-
]);
|
|
29
|
-
|
|
30
|
-
describe('objects translation bundle ownership (ADR-0029 D8)', () => {
|
|
31
|
-
it('the en bundle contains no objects owned by other packages', () => {
|
|
32
|
-
const strays = Object.keys(enObjects).filter((o) => !OWNED_OBJECTS.has(o));
|
|
33
|
-
expect(
|
|
34
|
-
strays,
|
|
35
|
-
`bundle carries objects this package's extract config does not own: ${strays.join(', ')} — ` +
|
|
36
|
-
'their curated translations would be silently deleted on the next `os i18n extract`. ' +
|
|
37
|
-
'Remove the dead block from the four locale bundles, or add the object to the extract config + this list.',
|
|
38
|
-
).toEqual([]);
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it('every owned object is present in the bundle (extract config regression)', () => {
|
|
42
|
-
const missing = [...OWNED_OBJECTS].filter((o) => !(o in enObjects));
|
|
43
|
-
expect(
|
|
44
|
-
missing,
|
|
45
|
-
`objects the extract config should emit are missing from the bundle: ${missing.join(', ')} — was an import dropped from scripts/i18n-extract.config.ts?`,
|
|
46
|
-
).toEqual([]);
|
|
47
|
-
});
|
|
48
|
-
});
|