@objectstack/plugin-approvals 8.0.1 → 9.0.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.
- package/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +25 -0
- package/dist/index.d.mts +33 -9
- package/dist/index.d.ts +33 -9
- package/dist/index.js +193 -5
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +193 -5
- package/dist/index.mjs.map +1 -1
- package/package.json +8 -8
- package/src/approval-node.test.ts +14 -0
- package/src/approval-node.ts +9 -1
- package/src/approval-service.test.ts +92 -0
- package/src/approval-service.ts +203 -2
|
@@ -122,6 +122,20 @@ describe('Approval node bridge (ADR-0019)', () => {
|
|
|
122
122
|
expect(suspended[0]).toMatchObject({ nodeId: 'approve_step', correlation: requests[0].id });
|
|
123
123
|
});
|
|
124
124
|
|
|
125
|
+
it('carries the flow name + authored labels onto the request row', async () => {
|
|
126
|
+
registerDecisionFlow(automation, [{ type: 'user', value: 'u1' }]);
|
|
127
|
+
await automation.execute('deal_approval', {
|
|
128
|
+
object: 'crm_deal', record: { id: 'd1', amount: 100 }, userId: 'submitter',
|
|
129
|
+
});
|
|
130
|
+
const [raw] = await fake.find('sys_approval_request', { where: { status: 'pending' } });
|
|
131
|
+
// Engine-seeded `$flowName` (not the node id) names the source…
|
|
132
|
+
expect(raw.process_name).toBe('flow:deal_approval');
|
|
133
|
+
// …and authored labels ride the config snapshot for inbox display.
|
|
134
|
+
const req = (await service.listRequests({ status: 'pending' }, { isSystem: true } as any))[0];
|
|
135
|
+
expect(req.process_label).toBe('Deal Approval');
|
|
136
|
+
expect(req.step_label).toBe('Manager Approval');
|
|
137
|
+
});
|
|
138
|
+
|
|
125
139
|
it('resumes down the approve branch on approval', async () => {
|
|
126
140
|
registerDecisionFlow(automation, [{ type: 'user', value: 'u1' }]);
|
|
127
141
|
const paused = await automation.execute('deal_approval', {
|
package/src/approval-node.ts
CHANGED
|
@@ -97,6 +97,12 @@ export function registerApprovalNode(
|
|
|
97
97
|
if (!object) return { success: false, error: `Approval node '${node.id}': no target object in context` };
|
|
98
98
|
if (!recordId) return { success: false, error: `Approval node '${node.id}': no record id in $record` };
|
|
99
99
|
|
|
100
|
+
// Flow identity comes from engine-seeded variables (`$flowName` /
|
|
101
|
+
// `$flowLabel`) so the request row can carry a human-readable origin;
|
|
102
|
+
// `context.flowName` is a legacy fallback for direct callers.
|
|
103
|
+
const flowName = (variables.get('$flowName') as string | undefined) ?? context?.flowName;
|
|
104
|
+
const flowLabel = variables.get('$flowLabel') as string | undefined;
|
|
105
|
+
|
|
100
106
|
try {
|
|
101
107
|
const request = await service.openNodeRequest({
|
|
102
108
|
object,
|
|
@@ -104,7 +110,9 @@ export function registerApprovalNode(
|
|
|
104
110
|
runId: String(runId),
|
|
105
111
|
nodeId: node.id,
|
|
106
112
|
config,
|
|
107
|
-
flowName
|
|
113
|
+
flowName,
|
|
114
|
+
flowLabel,
|
|
115
|
+
nodeLabel: typeof node.label === 'string' ? node.label : undefined,
|
|
108
116
|
submitterId: context?.userId ?? null,
|
|
109
117
|
record,
|
|
110
118
|
organizationId: context?.organizationId ?? context?.tenantId ?? null,
|
|
@@ -290,6 +290,98 @@ describe('ApprovalService (node era)', () => {
|
|
|
290
290
|
it('getRequest: returns null for an unknown id', async () => {
|
|
291
291
|
expect(await svc.getRequest('nope', SYS)).toBeNull();
|
|
292
292
|
});
|
|
293
|
+
|
|
294
|
+
// ── recall ──────────────────────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
it('recall: submitter withdraws a pending request', async () => {
|
|
297
|
+
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
298
|
+
const out = await svc.recall(req.id, { actorId: 'u1', comment: 'changed my mind' }, CTX);
|
|
299
|
+
expect(out.request.status).toBe('recalled');
|
|
300
|
+
expect(out.request.completed_at).toBeTruthy();
|
|
301
|
+
expect(out.request.pending_approvers).toEqual([]);
|
|
302
|
+
const actions = await svc.listActions(req.id, SYS);
|
|
303
|
+
expect(actions.map(a => a.action)).toEqual(['submit', 'recall']);
|
|
304
|
+
expect(actions[1].comment).toBe('changed my mind');
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('recall: blocks a non-submitter in a non-system context', async () => {
|
|
308
|
+
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
309
|
+
await expect(svc.recall(req.id, { actorId: 'u9' }, { roles: [], permissions: [] } as any))
|
|
310
|
+
.rejects.toThrow(/FORBIDDEN/);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('recall: rejects a recall on a non-pending request', async () => {
|
|
314
|
+
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
315
|
+
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, SYS);
|
|
316
|
+
await expect(svc.recall(req.id, { actorId: 'u1' }, SYS)).rejects.toThrow(/INVALID_STATE/);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('recall: resumes the owning run down the reject branch with decision=recall', async () => {
|
|
320
|
+
const resumed: any[] = [];
|
|
321
|
+
svc.attachAutomation({ async resume(runId, signal) { resumed.push({ runId, signal }); } });
|
|
322
|
+
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
323
|
+
const out = await svc.recall(req.id, { actorId: 'u1' }, CTX);
|
|
324
|
+
expect(out.resumed).toBe(true);
|
|
325
|
+
expect(resumed[0]).toMatchObject({
|
|
326
|
+
runId: 'run_1',
|
|
327
|
+
signal: { branchLabel: 'reject', output: { decision: 'recall' } },
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('recall: mirrors `recalled` onto the business record when configured', async () => {
|
|
332
|
+
engine._tables['opportunity'] = [{ id: 'opp1', amount: 100 }];
|
|
333
|
+
const req = await svc.openNodeRequest(openInput(['u9'], {}, { approvalStatusField: 'approval_status' }), CTX);
|
|
334
|
+
await svc.recall(req.id, { actorId: 'u1' }, CTX);
|
|
335
|
+
expect(engine._tables['opportunity'][0].approval_status).toBe('recalled');
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// ── inbox display fields ────────────────────────────────────────
|
|
339
|
+
|
|
340
|
+
it('rows expose submitted_at as an alias of created_at', async () => {
|
|
341
|
+
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
342
|
+
expect(req.submitted_at).toBeTruthy();
|
|
343
|
+
expect(req.submitted_at).toBe(req.created_at);
|
|
344
|
+
const listed = await svc.listRequests({ status: 'pending' }, SYS);
|
|
345
|
+
expect(listed[0].submitted_at).toBe(listed[0].created_at);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('rows carry authored flow/node labels when provided', async () => {
|
|
349
|
+
const req = await svc.openNodeRequest(
|
|
350
|
+
openInput(['u9'], { flowLabel: 'Deal Approval', nodeLabel: 'Manager Review' }), CTX,
|
|
351
|
+
);
|
|
352
|
+
expect(req.process_label).toBe('Deal Approval');
|
|
353
|
+
expect(req.step_label).toBe('Manager Review');
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('rows fall back to prettified machine names when labels are absent', async () => {
|
|
357
|
+
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
358
|
+
expect(req.process_label).toBe('Deal Approval'); // from `flow:deal_approval`
|
|
359
|
+
expect(req.step_label).toBe('Approve Step'); // from `approve_step`
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it('listRequests enriches record_title and submitter_name', async () => {
|
|
363
|
+
engine._tables['opportunity'] = [{ id: 'opp1', name: 'Acme Renewal', amount: 100 }];
|
|
364
|
+
engine._tables['sys_user'] = [{ id: 'u1', name: 'Ada Lovelace', email: 'ada@example.com' }];
|
|
365
|
+
await svc.openNodeRequest(openInput(['u9']), CTX); // submitter_id = u1 (CTX.userId)
|
|
366
|
+
const rows = await svc.listRequests({ status: 'pending' }, SYS);
|
|
367
|
+
expect(rows[0].record_title).toBe('Acme Renewal');
|
|
368
|
+
expect(rows[0].submitter_name).toBe('Ada Lovelace');
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('enrichment falls back to the payload snapshot when the record is gone', async () => {
|
|
372
|
+
await svc.openNodeRequest(
|
|
373
|
+
openInput(['u9'], { record: { id: 'opp1', name: 'Snapshot Title', amount: 1 } }), CTX,
|
|
374
|
+
);
|
|
375
|
+
const rows = await svc.listRequests({ status: 'pending' }, SYS);
|
|
376
|
+
expect(rows[0].record_title).toBe('Snapshot Title');
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it('enrichment resolves an email submitter via sys_user.email', async () => {
|
|
380
|
+
engine._tables['sys_user'] = [{ id: 'u7', name: 'Grace Hopper', email: 'grace@example.com' }];
|
|
381
|
+
await svc.openNodeRequest(openInput(['u9'], { submitterId: 'grace@example.com' }), CTX);
|
|
382
|
+
const rows = await svc.listRequests({ status: 'pending' }, SYS);
|
|
383
|
+
expect(rows[0].submitter_name).toBe('Grace Hopper');
|
|
384
|
+
});
|
|
293
385
|
});
|
|
294
386
|
|
|
295
387
|
describe('record-lock hook (node era)', () => {
|
package/src/approval-service.ts
CHANGED
|
@@ -10,6 +10,8 @@ import type {
|
|
|
10
10
|
ApprovalActionRow,
|
|
11
11
|
ApprovalDecisionInput,
|
|
12
12
|
ApprovalDecisionResult,
|
|
13
|
+
ApprovalRecallInput,
|
|
14
|
+
ApprovalRecallResult,
|
|
13
15
|
ApprovalStatus,
|
|
14
16
|
SharingExecutionContext,
|
|
15
17
|
} from '@objectstack/spec/contracts';
|
|
@@ -69,7 +71,27 @@ function csvSplit(raw: unknown): string[] {
|
|
|
69
71
|
return String(raw).split(',').map(s => s.trim()).filter(Boolean);
|
|
70
72
|
}
|
|
71
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Humanize a machine name for display fallback: strips a `flow:` prefix and
|
|
76
|
+
* title-cases underscore/dash segments (`flow:manager_review` → "Manager
|
|
77
|
+
* Review"). Used only when no authored label was snapshotted on the row.
|
|
78
|
+
*/
|
|
79
|
+
function prettifyMachineName(raw: string | null | undefined): string | undefined {
|
|
80
|
+
if (!raw) return undefined;
|
|
81
|
+
const base = String(raw).replace(/^flow:/, '').trim();
|
|
82
|
+
if (!base) return undefined;
|
|
83
|
+
return base
|
|
84
|
+
.split(/[_\-\s]+/)
|
|
85
|
+
.filter(Boolean)
|
|
86
|
+
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
|
|
87
|
+
.join(' ');
|
|
88
|
+
}
|
|
89
|
+
|
|
72
90
|
function rowFromRequest(row: any): ApprovalRequestRow {
|
|
91
|
+
// Authored display labels ride the node-config snapshot (`__flowLabel` /
|
|
92
|
+
// `__nodeLabel`) so they survive without a schema migration; fall back to a
|
|
93
|
+
// prettified machine name for rows written before labels were captured.
|
|
94
|
+
const cfg = parseJson<any>(row.node_config_json, undefined);
|
|
73
95
|
return {
|
|
74
96
|
id: String(row.id),
|
|
75
97
|
organization_id: row.organization_id ?? undefined,
|
|
@@ -88,6 +110,10 @@ function rowFromRequest(row: any): ApprovalRequestRow {
|
|
|
88
110
|
completed_at: row.completed_at ?? undefined,
|
|
89
111
|
created_at: row.created_at ?? undefined,
|
|
90
112
|
updated_at: row.updated_at ?? undefined,
|
|
113
|
+
// The row is created at submission time; expose the stable inbox-facing name.
|
|
114
|
+
submitted_at: row.created_at ?? undefined,
|
|
115
|
+
process_label: cfg?.__flowLabel ?? prettifyMachineName(row.process_name),
|
|
116
|
+
step_label: cfg?.__nodeLabel ?? prettifyMachineName(row.current_step),
|
|
91
117
|
} as any;
|
|
92
118
|
}
|
|
93
119
|
|
|
@@ -291,6 +317,10 @@ export class ApprovalService implements IApprovalService {
|
|
|
291
317
|
nodeId: string;
|
|
292
318
|
config: ApprovalNodeConfig;
|
|
293
319
|
flowName?: string;
|
|
320
|
+
/** Authored flow label, snapshotted for inbox display. */
|
|
321
|
+
flowLabel?: string;
|
|
322
|
+
/** Authored node label, snapshotted for inbox display. */
|
|
323
|
+
nodeLabel?: string;
|
|
294
324
|
submitterId?: string | null;
|
|
295
325
|
record?: any;
|
|
296
326
|
organizationId?: string | null;
|
|
@@ -316,6 +346,11 @@ export class ApprovalService implements IApprovalService {
|
|
|
316
346
|
const now = this.clock.now().toISOString();
|
|
317
347
|
const id = uid('areq');
|
|
318
348
|
const processName = `flow:${input.flowName ?? input.nodeId}`;
|
|
349
|
+
// Display labels ride the config snapshot (no schema migration needed);
|
|
350
|
+
// `rowFromRequest` surfaces them as `process_label` / `step_label`.
|
|
351
|
+
const configSnapshot: any = { ...input.config };
|
|
352
|
+
if (input.flowLabel) configSnapshot.__flowLabel = input.flowLabel;
|
|
353
|
+
if (input.nodeLabel) configSnapshot.__nodeLabel = input.nodeLabel;
|
|
319
354
|
const row: any = {
|
|
320
355
|
id,
|
|
321
356
|
process_name: processName,
|
|
@@ -329,7 +364,7 @@ export class ApprovalService implements IApprovalService {
|
|
|
329
364
|
payload_json: input.record != null ? JSON.stringify(input.record) : null,
|
|
330
365
|
flow_run_id: input.runId,
|
|
331
366
|
flow_node_id: input.nodeId,
|
|
332
|
-
node_config_json: JSON.stringify(
|
|
367
|
+
node_config_json: JSON.stringify(configSnapshot),
|
|
333
368
|
organization_id: ctxOrg,
|
|
334
369
|
created_at: now,
|
|
335
370
|
updated_at: now,
|
|
@@ -463,6 +498,168 @@ export class ApprovalService implements IApprovalService {
|
|
|
463
498
|
};
|
|
464
499
|
}
|
|
465
500
|
|
|
501
|
+
/**
|
|
502
|
+
* Withdraw a pending request (submitter only). Finalises the row as
|
|
503
|
+
* `recalled`, releases the record lock (keyed on pending status), mirrors
|
|
504
|
+
* the status field when configured, and resumes the owning flow run down
|
|
505
|
+
* the `reject` branch with `output.decision = 'recall'` — the engine has no
|
|
506
|
+
* run-cancel primitive, and leaving the run suspended forever would leak it.
|
|
507
|
+
*/
|
|
508
|
+
async recall(
|
|
509
|
+
requestId: string,
|
|
510
|
+
input: ApprovalRecallInput,
|
|
511
|
+
context: SharingExecutionContext,
|
|
512
|
+
): Promise<ApprovalRecallResult> {
|
|
513
|
+
if (!requestId) throw new Error('VALIDATION_FAILED: requestId is required');
|
|
514
|
+
if (!input?.actorId) throw new Error('VALIDATION_FAILED: actorId is required');
|
|
515
|
+
|
|
516
|
+
const rawRows = await this.engine.find('sys_approval_request', {
|
|
517
|
+
where: { id: requestId }, limit: 1, context: SYSTEM_CTX,
|
|
518
|
+
});
|
|
519
|
+
const raw: any = Array.isArray(rawRows) ? rawRows[0] : null;
|
|
520
|
+
if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
|
|
521
|
+
if (raw.status !== 'pending') throw new Error(`INVALID_STATE: request is ${raw.status}`);
|
|
522
|
+
if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(input.actorId)) {
|
|
523
|
+
throw new Error(`FORBIDDEN: only the submitter may recall this request`);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const config = parseJson<ApprovalNodeConfig>(raw.node_config_json, { approvers: [], behavior: 'first_response' } as any);
|
|
527
|
+
const org = raw.organization_id ?? null;
|
|
528
|
+
const nodeId: string | null = raw.flow_node_id ?? raw.current_step ?? null;
|
|
529
|
+
const runId: string | null = raw.flow_run_id ?? null;
|
|
530
|
+
const now = this.clock.now().toISOString();
|
|
531
|
+
|
|
532
|
+
await this.engine.insert('sys_approval_action', {
|
|
533
|
+
id: uid('aact'), request_id: requestId, organization_id: org,
|
|
534
|
+
step_name: nodeId, step_index: 0, action: 'recall',
|
|
535
|
+
actor_id: input.actorId, comment: input.comment ?? null, created_at: now,
|
|
536
|
+
}, { context: SYSTEM_CTX });
|
|
537
|
+
|
|
538
|
+
await this.engine.update('sys_approval_request', {
|
|
539
|
+
id: requestId, status: 'recalled', pending_approvers: null, completed_at: now, updated_at: now,
|
|
540
|
+
}, { context: SYSTEM_CTX });
|
|
541
|
+
if (config.approvalStatusField) {
|
|
542
|
+
await this.mirrorStatusField(raw.object_name, raw.record_id, config.approvalStatusField, 'recalled');
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
let resumed = false;
|
|
546
|
+
if (runId && typeof this.automation?.resume === 'function') {
|
|
547
|
+
try {
|
|
548
|
+
await this.automation.resume(runId, {
|
|
549
|
+
branchLabel: APPROVAL_BRANCH_LABELS.reject,
|
|
550
|
+
output: { decision: 'recall', requestId },
|
|
551
|
+
});
|
|
552
|
+
resumed = true;
|
|
553
|
+
} catch (err: any) {
|
|
554
|
+
this.logger?.warn?.('[approvals] resume after recall failed', {
|
|
555
|
+
request: requestId, run: runId, error: err?.message ?? String(err),
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const fresh = await this.getRequest(requestId, context);
|
|
561
|
+
return { request: fresh!, runId, resumed };
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// ── Display enrichment ───────────────────────────────────────
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Resolve the schema-declared display field for an object, when the engine
|
|
568
|
+
* exposes schema metadata (`getSchema`). Falls back to common title-ish
|
|
569
|
+
* field names so plain `ApprovalEngine` fakes still enrich sensibly.
|
|
570
|
+
*/
|
|
571
|
+
private resolveDisplayField(object: string): string | undefined {
|
|
572
|
+
try {
|
|
573
|
+
const schema: any = (this.engine as any).getSchema?.(object);
|
|
574
|
+
const fields = schema?.fields ?? {};
|
|
575
|
+
const declared = schema?.displayNameField;
|
|
576
|
+
if (declared && declared !== 'id' && fields[declared]) return declared;
|
|
577
|
+
for (const cand of ['name', 'title', 'subject', 'label']) {
|
|
578
|
+
if (fields[cand]) return cand;
|
|
579
|
+
}
|
|
580
|
+
} catch { /* schema unavailable — heuristics below still apply */ }
|
|
581
|
+
return undefined;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
private static pickTitle(rec: any, displayField?: string): string | undefined {
|
|
585
|
+
const candidates = displayField
|
|
586
|
+
? [displayField, 'name', 'title', 'subject', 'label']
|
|
587
|
+
: ['name', 'title', 'subject', 'label'];
|
|
588
|
+
for (const f of candidates) {
|
|
589
|
+
const v = rec?.[f];
|
|
590
|
+
if (v != null && String(v).trim() && f !== 'id') return String(v);
|
|
591
|
+
}
|
|
592
|
+
return undefined;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Attach inbox display fields (`record_title`, `submitter_name`) to rows.
|
|
597
|
+
* Batched: one query per distinct target object plus one `sys_user` lookup.
|
|
598
|
+
* Best-effort — a deleted record falls back to the payload snapshot, and a
|
|
599
|
+
* lookup failure leaves the field unset rather than failing the list.
|
|
600
|
+
*/
|
|
601
|
+
private async enrichRows(rows: ApprovalRequestRow[]): Promise<void> {
|
|
602
|
+
if (!rows.length) return;
|
|
603
|
+
|
|
604
|
+
// Record titles, batched per object.
|
|
605
|
+
const byObject = new Map<string, Set<string>>();
|
|
606
|
+
for (const r of rows) {
|
|
607
|
+
if (!r.object_name || !r.record_id) continue;
|
|
608
|
+
let set = byObject.get(r.object_name);
|
|
609
|
+
if (!set) { set = new Set(); byObject.set(r.object_name, set); }
|
|
610
|
+
set.add(r.record_id);
|
|
611
|
+
}
|
|
612
|
+
const titles = new Map<string, string>();
|
|
613
|
+
for (const [object, idSet] of byObject) {
|
|
614
|
+
const ids = Array.from(idSet);
|
|
615
|
+
const displayField = this.resolveDisplayField(object);
|
|
616
|
+
try {
|
|
617
|
+
const recs = await this.engine.find(object, {
|
|
618
|
+
where: { id: { $in: ids } }, limit: ids.length, context: SYSTEM_CTX,
|
|
619
|
+
});
|
|
620
|
+
for (const rec of (recs ?? []) as any[]) {
|
|
621
|
+
const title = ApprovalService.pickTitle(rec, displayField);
|
|
622
|
+
if (rec?.id && title) titles.set(`${object}${rec.id}`, title);
|
|
623
|
+
}
|
|
624
|
+
} catch { /* object may be unregistered — payload fallback below */ }
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Submitter display names — submitter_id may be a user id or an email.
|
|
628
|
+
const submitters = Array.from(new Set(rows.map(r => r.submitter_id).filter(Boolean))) as string[];
|
|
629
|
+
const names = new Map<string, string>();
|
|
630
|
+
if (submitters.length) {
|
|
631
|
+
try {
|
|
632
|
+
const users = await this.engine.find('sys_user', {
|
|
633
|
+
where: { id: { $in: submitters } }, fields: ['id', 'name', 'email'],
|
|
634
|
+
limit: submitters.length, context: SYSTEM_CTX,
|
|
635
|
+
});
|
|
636
|
+
for (const u of (users ?? []) as any[]) {
|
|
637
|
+
if (u?.id && (u.name || u.email)) names.set(String(u.id), String(u.name ?? u.email));
|
|
638
|
+
}
|
|
639
|
+
} catch { /* best-effort */ }
|
|
640
|
+
const unresolvedEmails = submitters.filter(s => !names.has(s) && s.includes('@'));
|
|
641
|
+
if (unresolvedEmails.length) {
|
|
642
|
+
try {
|
|
643
|
+
const users = await this.engine.find('sys_user', {
|
|
644
|
+
where: { email: { $in: unresolvedEmails } }, fields: ['email', 'name'],
|
|
645
|
+
limit: unresolvedEmails.length, context: SYSTEM_CTX,
|
|
646
|
+
});
|
|
647
|
+
for (const u of (users ?? []) as any[]) {
|
|
648
|
+
if (u?.email && u.name) names.set(String(u.email), String(u.name));
|
|
649
|
+
}
|
|
650
|
+
} catch { /* best-effort */ }
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
for (const r of rows as any[]) {
|
|
655
|
+
const title = titles.get(`${r.object_name}${r.record_id}`)
|
|
656
|
+
?? ApprovalService.pickTitle(r.payload, undefined);
|
|
657
|
+
if (title) r.record_title = title;
|
|
658
|
+
const name = r.submitter_id ? names.get(String(r.submitter_id)) : undefined;
|
|
659
|
+
if (name) r.submitter_name = name;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
466
663
|
// ── Read API ─────────────────────────────────────────────────
|
|
467
664
|
|
|
468
665
|
async listRequests(
|
|
@@ -513,6 +710,7 @@ export class ApprovalService implements IApprovalService {
|
|
|
513
710
|
});
|
|
514
711
|
}
|
|
515
712
|
}
|
|
713
|
+
await this.enrichRows(list);
|
|
516
714
|
return list;
|
|
517
715
|
}
|
|
518
716
|
|
|
@@ -524,7 +722,10 @@ export class ApprovalService implements IApprovalService {
|
|
|
524
722
|
const rows = await this.engine.find('sys_approval_request', {
|
|
525
723
|
where, limit: 1, context: SYSTEM_CTX,
|
|
526
724
|
});
|
|
527
|
-
|
|
725
|
+
if (!Array.isArray(rows) || !rows[0]) return null;
|
|
726
|
+
const row = rowFromRequest(rows[0]);
|
|
727
|
+
await this.enrichRows([row]);
|
|
728
|
+
return row;
|
|
528
729
|
}
|
|
529
730
|
|
|
530
731
|
async listActions(requestId: string, context: SharingExecutionContext): Promise<ApprovalActionRow[]> {
|