@objectstack/plugin-approvals 9.2.0 → 9.4.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.
@@ -3,13 +3,32 @@
3
3
  import type { Plugin, PluginContext } from '@objectstack/core';
4
4
  import { SysApprovalRequest } from './sys-approval-request.object.js';
5
5
  import { SysApprovalAction } from './sys-approval-action.object.js';
6
- import { ApprovalService, type ApprovalEngine } from './approval-service.js';
6
+ import { SysApprovalApprover } from './sys-approval-approver.object.js';
7
+ import { SysApprovalToken } from './sys-approval-token.object.js';
8
+ import { renderConfirmPage, renderResultPage } from './action-link-pages.js';
9
+ import {
10
+ ApprovalService,
11
+ ESCALATION_JOB_NAME,
12
+ ESCALATION_SCAN_INTERVAL_MS,
13
+ type ApprovalEngine,
14
+ } from './approval-service.js';
7
15
  import { bindApprovalLockHook, unbindAllHooks } from './lifecycle-hooks.js';
8
16
  import { registerApprovalNode, type ApprovalAutomationSurface } from './approval-node.js';
9
17
 
10
18
  export interface ApprovalsPluginOptions {
11
19
  /** Disable runtime registration (schemas still register). */
12
20
  disableService?: boolean;
21
+ /**
22
+ * Interval between SLA escalation scans (ADR-0042). Defaults to
23
+ * {@link ESCALATION_SCAN_INTERVAL_MS} (5 min). Only takes effect when a
24
+ * `job` service is installed; without one, SLA stays display-only.
25
+ */
26
+ escalationScanIntervalMs?: number;
27
+ /**
28
+ * Absolute origin for actionable links in outbound notifications
29
+ * (ADR-0043), e.g. `https://app.example.com`. Relative by default.
30
+ */
31
+ publicBaseUrl?: string;
13
32
  /**
14
33
  * Disable the record-lock hook. Schema + service stay intact; only the
15
34
  * engine-level lock wiring is suppressed. Useful when a caller wants the
@@ -36,6 +55,7 @@ export class ApprovalsServicePlugin implements Plugin {
36
55
  private readonly options: ApprovalsPluginOptions;
37
56
  private service?: ApprovalService;
38
57
  private engine?: any;
58
+ private escalationJobScheduled = false;
39
59
 
40
60
  constructor(options: ApprovalsPluginOptions = {}) {
41
61
  this.options = options;
@@ -50,7 +70,7 @@ export class ApprovalsServicePlugin implements Plugin {
50
70
  scope: 'system',
51
71
  defaultDatasource: 'cloud',
52
72
  namespace: 'sys',
53
- objects: [SysApprovalRequest, SysApprovalAction],
73
+ objects: [SysApprovalRequest, SysApprovalAction, SysApprovalApprover, SysApprovalToken],
54
74
  // ADR-0029 D7 — contribute the Approvals entries into the Setup app's
55
75
  // `group_approvals` slot. This plugin owns these objects (K2.b), so it
56
76
  // ships their menu too; when the plugin isn't installed the slot is empty.
@@ -98,6 +118,7 @@ export class ApprovalsServicePlugin implements Plugin {
98
118
  this.service = new ApprovalService({
99
119
  engine: engine as ApprovalEngine,
100
120
  logger: ctx.logger,
121
+ publicBaseUrl: this.options.publicBaseUrl,
101
122
  });
102
123
 
103
124
  // Record lock: block edits to a record while it has a pending request.
@@ -113,6 +134,99 @@ export class ApprovalsServicePlugin implements Plugin {
113
134
  ctx.registerService('approvals', this.service);
114
135
  ctx.logger.info('ApprovalsServicePlugin: service registered');
115
136
 
137
+ // Optional messaging service (ADR-0012): thread interactions (reassign /
138
+ // remind / request-info / comment) notify users when present; without it
139
+ // they degrade to audit-only.
140
+ try {
141
+ const messaging = ctx.getService<any>('messaging');
142
+ if (messaging && typeof messaging.emit === 'function') {
143
+ this.service.attachMessaging(messaging);
144
+ }
145
+ } catch { /* messaging not installed */ }
146
+
147
+ // SLA escalation clock (ADR-0042): a plugin-internal job, deliberately
148
+ // NOT a flow trigger (ADR-0041 §1). Interval sweep + one catch-up scan at
149
+ // boot so a restart doesn't extend a breach by a scan period. Wired on
150
+ // kernel:ready — the job service may start after this plugin. No `job`
151
+ // service → SLA stays display-only.
152
+ const wireEscalationClock = async () => {
153
+ try {
154
+ const jobs = ctx.getService<any>('job');
155
+ if (!jobs || typeof jobs.schedule !== 'function' || !this.service) return;
156
+ const svc = this.service;
157
+ const intervalMs = this.options.escalationScanIntervalMs ?? ESCALATION_SCAN_INTERVAL_MS;
158
+ await jobs.schedule(ESCALATION_JOB_NAME, { type: 'interval', intervalMs }, async () => {
159
+ await svc.runEscalations();
160
+ });
161
+ this.escalationJobScheduled = true;
162
+ void svc.runEscalations().catch((err: any) => {
163
+ ctx.logger.warn?.('[approvals] boot escalation sweep failed', { error: err?.message });
164
+ });
165
+ ctx.logger.info('ApprovalsServicePlugin: SLA escalation scan scheduled', { intervalMs });
166
+ } catch { /* job service not installed */ }
167
+ };
168
+ // Actionable-link pages (ADR-0043): session-less confirm + redemption,
169
+ // mounted straight on the host Hono app. GET only renders; the decision
170
+ // happens exclusively on the POST (mail-gateway prefetch safe).
171
+ const mountActionPages = async () => {
172
+ try {
173
+ const http = ctx.getService<any>('http-server');
174
+ const rawApp = http && typeof http.getRawApp === 'function' ? http.getRawApp() : null;
175
+ if (!rawApp || !this.service) return;
176
+ const svc = this.service;
177
+ const ACT_PATH = '/api/v1/approvals/act';
178
+ const html = (c: any, body: string, status = 200) =>
179
+ c.body(body, status, { 'Content-Type': 'text/html; charset=utf-8' });
180
+ rawApp.get(ACT_PATH, async (c: any) => {
181
+ const token = String(c.req.query('token') ?? '');
182
+ const peek = await svc.peekActionToken(token);
183
+ if (!peek.ok) return html(c, renderResultPage(peek.reason, peek.request), 200);
184
+ return html(c, renderConfirmPage({
185
+ request: peek.request, action: peek.action, approverId: peek.approverId,
186
+ token, actPath: ACT_PATH,
187
+ }));
188
+ });
189
+ rawApp.post(ACT_PATH, async (c: any) => {
190
+ let token = '';
191
+ try {
192
+ const body = await c.req.parseBody();
193
+ token = String(body?.token ?? '');
194
+ } catch { /* fall through to invalid */ }
195
+ const out = await svc.redeemActionToken(token);
196
+ if (!out.ok) return html(c, renderResultPage(out.reason, out.request), 200);
197
+ return html(c, renderResultPage(out.action === 'approve' ? 'approved' : 'rejected', out.request));
198
+ });
199
+ ctx.logger.info(`ApprovalsServicePlugin: actionable-link pages mounted at ${ACT_PATH}`);
200
+ } catch { /* http server not installed */ }
201
+ };
202
+
203
+ // Pending-approver index backfill (issue #1745): rebuild the normalized
204
+ // sys_approval_approver rows from the pending_approvers CSV so requests
205
+ // written before the index existed (or drifted past a crashed sync) are
206
+ // queryable. Idempotent; cost tracks the live pending queue.
207
+ const backfillApproverIndex = async () => {
208
+ try {
209
+ const svc = this.service;
210
+ if (!svc) return;
211
+ const out = await svc.rebuildApproverIndex();
212
+ if (out.inserted > 0 || out.deleted > 0) {
213
+ ctx.logger.info('ApprovalsServicePlugin: approver index rebuilt', out);
214
+ }
215
+ } catch (err: any) {
216
+ ctx.logger.warn?.('[approvals] approver index backfill failed', { error: err?.message });
217
+ }
218
+ };
219
+
220
+ if (typeof (ctx as any).hook === 'function') {
221
+ (ctx as any).hook('kernel:ready', wireEscalationClock);
222
+ (ctx as any).hook('kernel:ready', mountActionPages);
223
+ (ctx as any).hook('kernel:ready', backfillApproverIndex);
224
+ } else {
225
+ await wireEscalationClock();
226
+ await mountActionPages();
227
+ await backfillApproverIndex();
228
+ }
229
+
116
230
  // ADR-0019: contribute the `approval` node to the flow engine when one is
117
231
  // present. The node lets a flow suspend on an approval and resume on
118
232
  // decision; the service is wired to the same engine so `decide()` can
@@ -128,7 +242,14 @@ export class ApprovalsServicePlugin implements Plugin {
128
242
  }
129
243
  }
130
244
 
131
- async stop(_ctx: PluginContext): Promise<void> {
245
+ async stop(ctx: PluginContext): Promise<void> {
246
+ if (this.escalationJobScheduled) {
247
+ try {
248
+ const jobs = ctx.getService<any>('job');
249
+ await jobs?.cancel?.(ESCALATION_JOB_NAME);
250
+ } catch { /* ignore */ }
251
+ this.escalationJobScheduled = false;
252
+ }
132
253
  if (this.engine) {
133
254
  try { unbindAllHooks(this.engine); } catch { /* ignore */ }
134
255
  }
package/src/index.ts CHANGED
@@ -12,6 +12,7 @@
12
12
 
13
13
  export { SysApprovalRequest } from './sys-approval-request.object.js';
14
14
  export { SysApprovalAction } from './sys-approval-action.object.js';
15
+ export { SysApprovalApprover } from './sys-approval-approver.object.js';
15
16
  export {
16
17
  ApprovalService,
17
18
  type ApprovalEngine,
@@ -24,10 +24,12 @@ describe('ApprovalsServicePlugin schema + nav contribution (ADR-0029 K2.b)', ()
24
24
  expect(registered).toHaveLength(1);
25
25
  const manifest = registered[0];
26
26
 
27
- // Owns both approval objects (moved out of platform-objects).
27
+ // Owns the approval objects (moved out of platform-objects).
28
28
  expect(manifest.objects.map((o: any) => o.name).sort()).toEqual([
29
29
  'sys_approval_action',
30
+ 'sys_approval_approver',
30
31
  'sys_approval_request',
32
+ 'sys_approval_token',
31
33
  ]);
32
34
 
33
35
  // Contributes its menu into the Setup app's approvals slot.
@@ -88,7 +88,11 @@ export const SysApprovalAction = ObjectSchema.create({
88
88
  }),
89
89
 
90
90
  action: Field.select(
91
- ['submit', 'approve', 'reject', 'recall', 'escalate'],
91
+ // Keep in sync with `ApprovalActionKind` (spec/contracts). reassign /
92
+ // remind / request_info / comment are thread interactions — they never
93
+ // move the flow. revise / resubmit (ADR-0044) DO move it: send back for
94
+ // revision and the later resubmission.
95
+ ['submit', 'approve', 'reject', 'recall', 'escalate', 'reassign', 'remind', 'request_info', 'comment', 'revise', 'resubmit'],
92
96
  {
93
97
  label: 'Action',
94
98
  required: true,
@@ -0,0 +1,78 @@
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_approver — Pending-approver index (issue #1745).
7
+ *
8
+ * One row per (request, approver identity) while the request is **pending**.
9
+ * `sys_approval_request.pending_approvers` stays the human-readable source of
10
+ * truth (a CSV column), but CSV substring matching can neither be indexed nor
11
+ * pushed into an engine query — which made "my pending" a post-filter in
12
+ * memory and broke pagination beyond the scan window.
13
+ *
14
+ * This table is that CSV, normalized: the service mirrors every change to
15
+ * `pending_approvers` here (open / decide / recall / send-back / reassign /
16
+ * escalate), and clears the rows when the request leaves `pending`. So the
17
+ * table only ever holds the live work queue — its size tracks the number of
18
+ * open approvals, not the append-only request history.
19
+ *
20
+ * `approver` holds one identity literal exactly as it appears in the CSV:
21
+ * a user id, an email, or a `role:<name>` / `team:<name>` style literal.
22
+ * Equality (or `$in`) on this column is the indexed replacement for the old
23
+ * per-row substring match.
24
+ *
25
+ * @namespace sys
26
+ */
27
+ export const SysApprovalApprover = ObjectSchema.create({
28
+ name: 'sys_approval_approver',
29
+ label: 'Approval Approver',
30
+ pluralLabel: 'Approval Approvers',
31
+ icon: 'users',
32
+ isSystem: true,
33
+ managedBy: 'system',
34
+ description: 'Normalized pending-approver rows for indexed inbox queries',
35
+ displayNameField: 'id',
36
+ titleFormat: '{approver} · {request_id}',
37
+ compactLayout: ['request_id', 'approver', 'created_at'],
38
+
39
+ fields: {
40
+ id: Field.text({ label: 'Row ID', required: true, readonly: true, group: 'System' }),
41
+
42
+ organization_id: Field.lookup('sys_organization', {
43
+ label: 'Organization',
44
+ required: false,
45
+ group: 'System',
46
+ description: 'Tenant that owns this row (mirrors the parent request)',
47
+ }),
48
+
49
+ request_id: Field.lookup('sys_approval_request', {
50
+ label: 'Request',
51
+ required: true,
52
+ group: 'Target',
53
+ }),
54
+
55
+ approver: Field.text({
56
+ label: 'Approver',
57
+ required: true,
58
+ maxLength: 255,
59
+ description: 'One pending-approver identity: user id, email, or role:/team: literal',
60
+ group: 'Target',
61
+ }),
62
+
63
+ created_at: Field.datetime({
64
+ label: 'Created At',
65
+ required: true,
66
+ defaultValue: 'NOW()',
67
+ readonly: true,
68
+ group: 'System',
69
+ }),
70
+ },
71
+
72
+ indexes: [
73
+ // "My pending" inbox: equality on the identity literal, scoped by tenant.
74
+ { fields: ['approver', 'organization_id'] },
75
+ // Sync path: rewrite all rows of one request on each approver-set change.
76
+ { fields: ['request_id'] },
77
+ ],
78
+ });
@@ -127,7 +127,9 @@ export const SysApprovalRequest = ObjectSchema.create({
127
127
  }),
128
128
 
129
129
  status: Field.select(
130
- ['pending', 'approved', 'rejected', 'recalled'],
130
+ // Keep in sync with `ApprovalStatus` (spec/contracts). `returned` =
131
+ // sent back for revision (ADR-0044) — terminal for this round.
132
+ ['pending', 'approved', 'rejected', 'recalled', 'returned'],
131
133
  {
132
134
  label: 'Status',
133
135
  required: true,
@@ -218,9 +220,11 @@ export const SysApprovalRequest = ObjectSchema.create({
218
220
  // guard on submit and on edit-while-locked checks.
219
221
  { fields: ['object_name', 'record_id'] },
220
222
  { fields: ['status', 'object_name'] },
221
- // "My approvals" inbox pending_approvers is a CSV string so this
222
- // index only helps with status pre-filtering; the engine does a
223
- // post-filter substring match per row.
223
+ // Status-windowed listings (escalation sweep, "All" tab ordering).
224
+ // "My approvals" matching no longer scans this table: the service keeps
225
+ // a normalized per-approver index in `sys_approval_approver` (#1745) and
226
+ // resolves approver filters there; `pending_approvers` stays the
227
+ // human-readable CSV source of truth only.
224
228
  { fields: ['status', 'updated_at'] },
225
229
  { fields: ['submitter_id', 'status'] },
226
230
  ],
@@ -0,0 +1,94 @@
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: 'system',
23
+ description: 'Single-use tokens behind actionable approval links',
24
+ displayNameField: 'id',
25
+
26
+ fields: {
27
+ id: Field.text({ label: 'Token ID', required: true, readonly: true, group: 'System' }),
28
+
29
+ organization_id: Field.lookup('sys_organization', {
30
+ label: 'Organization',
31
+ required: false,
32
+ group: 'System',
33
+ }),
34
+
35
+ token_hash: Field.text({
36
+ label: 'Token Hash',
37
+ required: true,
38
+ maxLength: 100,
39
+ readonly: true,
40
+ description: 'SHA-256 hex of the raw token — the raw value is never stored',
41
+ group: 'Token',
42
+ }),
43
+
44
+ request_id: Field.text({
45
+ label: 'Request',
46
+ required: true,
47
+ maxLength: 100,
48
+ readonly: true,
49
+ group: 'Token',
50
+ }),
51
+
52
+ action: Field.select(['approve', 'reject'], {
53
+ label: 'Action',
54
+ required: true,
55
+ readonly: true,
56
+ group: 'Token',
57
+ }),
58
+
59
+ approver_id: Field.text({
60
+ label: 'Approver',
61
+ required: true,
62
+ maxLength: 200,
63
+ readonly: true,
64
+ description: 'Identity the token is bound to; the decision is audited as this approver',
65
+ group: 'Token',
66
+ }),
67
+
68
+ expires_at: Field.datetime({
69
+ label: 'Expires At',
70
+ required: true,
71
+ readonly: true,
72
+ group: 'Lifecycle',
73
+ }),
74
+
75
+ consumed_at: Field.datetime({
76
+ label: 'Consumed At',
77
+ required: false,
78
+ group: 'Lifecycle',
79
+ }),
80
+
81
+ created_at: Field.datetime({
82
+ label: 'Created At',
83
+ required: true,
84
+ defaultValue: 'NOW()',
85
+ readonly: true,
86
+ group: 'System',
87
+ }),
88
+ },
89
+
90
+ indexes: [
91
+ { fields: ['token_hash'] },
92
+ { fields: ['request_id'] },
93
+ ],
94
+ });