@objectstack/plugin-approvals 9.1.0 → 9.3.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.
- package/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +39 -0
- package/dist/index.d.mts +2343 -166
- package/dist/index.d.ts +2343 -166
- package/dist/index.js +1515 -151
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1514 -151
- package/dist/index.mjs.map +1 -1
- package/package.json +7 -7
- package/src/action-link-pages.ts +102 -0
- package/src/approval-revise.test.ts +411 -0
- package/src/approval-service.test.ts +483 -4
- package/src/approval-service.ts +1262 -63
- package/src/approvals-plugin.ts +124 -3
- package/src/index.ts +1 -0
- package/src/nav-contribution.test.ts +3 -1
- package/src/sys-approval-action.object.ts +5 -1
- package/src/sys-approval-approver.object.ts +78 -0
- package/src/sys-approval-request.object.ts +8 -4
- package/src/sys-approval-token.object.ts +94 -0
package/src/approvals-plugin.ts
CHANGED
|
@@ -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 {
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
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
|
+
});
|