@adula/kit 0.2.0-alpha.4 → 1.2.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/README.md +12 -2
- package/build/agent/AGENTS.template.md +2 -2
- package/build/agent/capabilities.md +54 -7
- package/build/agent/skills/adula-frontend-design/SKILL.md +1 -1
- package/build/agent/skills/idea-review/SKILL.md +26 -1
- package/build/agent/skills/module-review/SKILL.md +22 -0
- package/build/agent/skills/perf-review/SKILL.md +24 -0
- package/build/agent/skills/schema-review/SKILL.md +22 -0
- package/build/agent/skills/security-review/SKILL.md +24 -0
- package/build/agent/skills/ui-review/SKILL.md +22 -0
- package/build/commands/capabilities.d.ts +4 -0
- package/build/commands/capabilities.js +35 -4
- package/build/commands/doctor.js +37 -1
- package/build/commands/gaps.js +16 -6
- package/build/commands/install.js +19 -5
- package/build/commands/main.d.ts +4 -2
- package/build/commands/main.js +2 -0
- package/build/commands/module_add.js +2 -2
- package/build/commands/resource.js +1 -1
- package/build/commands/resource_snapshot.d.ts +15 -0
- package/build/commands/resource_snapshot.js +61 -0
- package/build/database/migrations/1770000000004_kit_collaboration.d.ts +5 -0
- package/build/database/migrations/1770000000004_kit_collaboration.js +10 -0
- package/build/database/migrations/1770000000005_kit_assignments.d.ts +5 -0
- package/build/database/migrations/1770000000005_kit_assignments.js +10 -0
- package/build/database/migrations/1770000000006_kit_messaging.d.ts +5 -0
- package/build/database/migrations/1770000000006_kit_messaging.js +10 -0
- package/build/database/migrations/1770000000007_kit_webhooks.d.ts +5 -0
- package/build/database/migrations/1770000000007_kit_webhooks.js +10 -0
- package/build/database/migrations/1770000000008_kit_imports.d.ts +5 -0
- package/build/database/migrations/1770000000008_kit_imports.js +10 -0
- package/build/database/migrations/1770000000010_kit_workflows.d.ts +5 -0
- package/build/database/migrations/1770000000010_kit_workflows.js +10 -0
- package/build/database/migrations/1770000000011_kit_managed_assignments.d.ts +5 -0
- package/build/database/migrations/1770000000011_kit_managed_assignments.js +10 -0
- package/build/database/migrations/1770000000012_kit_role_keys.d.ts +5 -0
- package/build/database/migrations/1770000000012_kit_role_keys.js +10 -0
- package/build/database/migrations/1770000000013_kit_notification_targets.d.ts +5 -0
- package/build/database/migrations/1770000000013_kit_notification_targets.js +10 -0
- package/build/database/migrations/1770000000014_kit_upload_grants.d.ts +5 -0
- package/build/database/migrations/1770000000014_kit_upload_grants.js +10 -0
- package/build/database/migrations/1770000000015_kit_inbound_webhooks.d.ts +5 -0
- package/build/database/migrations/1770000000015_kit_inbound_webhooks.js +10 -0
- package/build/index.d.ts +31 -2
- package/build/index.js +17 -2
- package/build/src/admin/contracts.d.ts +7 -0
- package/build/src/admin/contracts.js +34 -12
- package/build/src/admin/controller.d.ts +2 -0
- package/build/src/admin/controller.js +52 -1
- package/build/src/admin/presentation.d.ts +6 -0
- package/build/src/admin/record_title.d.ts +14 -0
- package/build/src/admin/record_title.js +50 -0
- package/build/src/admin/resource_service.d.ts +184 -2
- package/build/src/admin/resource_service.js +713 -48
- package/build/src/attachments/attachment_service.d.ts +12 -0
- package/build/src/attachments/attachment_service.js +28 -2
- package/build/src/attachments/upload_grants.d.ts +44 -0
- package/build/src/attachments/upload_grants.js +105 -0
- package/build/src/auth/ability.d.ts +1 -1
- package/build/src/auth/ability.js +4 -1
- package/build/src/auth/actor_store.js +6 -1
- package/build/src/auth/conditions.d.ts +15 -0
- package/build/src/auth/conditions.js +36 -0
- package/build/src/auth/sql.js +6 -2
- package/build/src/collaboration/assignments.d.ts +129 -0
- package/build/src/collaboration/assignments.js +333 -0
- package/build/src/collaboration/record_collaboration.d.ts +86 -0
- package/build/src/collaboration/record_collaboration.js +348 -0
- package/build/src/commands/agent_assets.js +5 -0
- package/build/src/commands/capabilities.d.ts +19 -0
- package/build/src/commands/capabilities.js +179 -0
- package/build/src/commands/doctor.d.ts +31 -0
- package/build/src/commands/doctor.js +114 -0
- package/build/src/commands/gap_report.d.ts +50 -2
- package/build/src/commands/gap_report.js +102 -4
- package/build/src/commands/generator.js +3 -3
- package/build/src/commands/snapshot.d.ts +28 -0
- package/build/src/commands/snapshot.js +48 -0
- package/build/src/commands/source_markers.d.ts +10 -1
- package/build/src/commands/source_markers.js +36 -4
- package/build/src/core/administration_guard.js +3 -1
- package/build/src/core/message_templates.d.ts +83 -0
- package/build/src/core/message_templates.js +293 -0
- package/build/src/core/module_seed.d.ts +15 -0
- package/build/src/core/module_seed.js +31 -0
- package/build/src/core/notifications.d.ts +18 -1
- package/build/src/core/notifications.js +27 -1
- package/build/src/core/roles.d.ts +27 -1
- package/build/src/core/roles.js +133 -5
- package/build/src/database/schema.d.ts +45 -0
- package/build/src/database/schema.js +290 -0
- package/build/src/eslint/index.js +26 -0
- package/build/src/events/outbox.d.ts +1 -0
- package/build/src/events/outbox.js +1 -1
- package/build/src/events/record_mutation.d.ts +11 -0
- package/build/src/events/record_mutation.js +15 -2
- package/build/src/integrations/imports.d.ts +74 -0
- package/build/src/integrations/imports.js +333 -0
- package/build/src/integrations/inbound_webhooks.d.ts +94 -0
- package/build/src/integrations/inbound_webhooks.js +276 -0
- package/build/src/integrations/openapi.d.ts +39 -0
- package/build/src/integrations/openapi.js +323 -0
- package/build/src/integrations/print.d.ts +37 -0
- package/build/src/integrations/print.js +124 -0
- package/build/src/integrations/webhooks.d.ts +98 -0
- package/build/src/integrations/webhooks.js +298 -0
- package/build/src/resource/define_resource.d.ts +1 -0
- package/build/src/resource/define_resource.js +21 -1
- package/build/src/resource/registry.d.ts +3 -0
- package/build/src/resource/registry.js +42 -0
- package/build/src/resource/types.d.ts +67 -1
- package/build/src/resource/values.js +2 -1
- package/build/src/services/settings.d.ts +13 -1
- package/build/src/services/settings.js +9 -2
- package/build/src/workflows/define_workflow.d.ts +124 -0
- package/build/src/workflows/define_workflow.js +123 -0
- package/build/src/workflows/engine.d.ts +141 -0
- package/build/src/workflows/engine.js +752 -0
- package/build/stubs/resource_contract.txt +103 -41
- package/package.json +7 -3
package/build/src/core/roles.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { conditionSql } from '../auth/sql.js';
|
|
2
|
+
import { resolveActorConditions, usesActor } from '../auth/conditions.js';
|
|
2
3
|
import { KitError } from '../admin/errors.js';
|
|
3
4
|
import { logActivity } from './activity.js';
|
|
4
5
|
import { administrationGuard } from './administration_guard.js';
|
|
@@ -11,6 +12,7 @@ function standardConditionFields(resource) {
|
|
|
11
12
|
label: { ar, en },
|
|
12
13
|
type: 'integer',
|
|
13
14
|
conditionable: true,
|
|
15
|
+
...(['createdBy', 'updatedBy'].includes(key) ? { actor: true } : {}),
|
|
14
16
|
});
|
|
15
17
|
return [
|
|
16
18
|
field('id', 'المعرّف', 'ID'),
|
|
@@ -27,6 +29,7 @@ function matrixFields(resource) {
|
|
|
27
29
|
label: field.label,
|
|
28
30
|
type: field.type,
|
|
29
31
|
conditionable: !['hasMany', 'json', 'attachment'].includes(field.type),
|
|
32
|
+
...(field.type === 'user' ? { actor: true } : {}),
|
|
30
33
|
}));
|
|
31
34
|
}
|
|
32
35
|
function roleName(value) {
|
|
@@ -35,6 +38,13 @@ function roleName(value) {
|
|
|
35
38
|
throw new KitError(422, 'E_ROLE_NAME', 'اسم الدور مطلوب (حتى 100 حرف)');
|
|
36
39
|
return name;
|
|
37
40
|
}
|
|
41
|
+
/** A role key: lower-case letters, digits and underscores, starting with a letter. */
|
|
42
|
+
export function roleKey(value) {
|
|
43
|
+
const key = typeof value === 'string' ? value.trim() : '';
|
|
44
|
+
if (!/^[a-z][a-z0-9_]{0,99}$/.test(key))
|
|
45
|
+
throw new KitError(422, 'E_ROLE_KEY', 'مفتاح الدور حروف إنجليزية صغيرة وأرقام وشرطة سفلية، ويبدأ بحرف (مثل project_manager)');
|
|
46
|
+
return key;
|
|
47
|
+
}
|
|
38
48
|
function permissionLevel(value) {
|
|
39
49
|
const level = Number(value ?? 0);
|
|
40
50
|
if (!Number.isSafeInteger(level) || level < 0 || level > 9)
|
|
@@ -60,10 +70,11 @@ export class RolesAdmin {
|
|
|
60
70
|
}
|
|
61
71
|
async list() {
|
|
62
72
|
const rows = await this.db('roles as r')
|
|
63
|
-
.select('r.id', 'r.name', 'r.permission_level', this.db.raw('(SELECT count(*) FROM role_rules rr WHERE rr.role_id = r.id) AS rules'), this.db.raw('(SELECT count(DISTINCT ur.user_id) FROM user_roles ur WHERE ur.role_id = r.id) AS users'))
|
|
73
|
+
.select('r.id', 'r.key', 'r.name', 'r.permission_level', this.db.raw('(SELECT count(*) FROM role_rules rr WHERE rr.role_id = r.id) AS rules'), this.db.raw('(SELECT count(DISTINCT ur.user_id) FROM user_roles ur WHERE ur.role_id = r.id) AS users'))
|
|
64
74
|
.orderBy('r.name');
|
|
65
75
|
return rows.map((row) => ({
|
|
66
76
|
id: row.id,
|
|
77
|
+
key: row.key ?? null,
|
|
67
78
|
name: row.name,
|
|
68
79
|
permissionLevel: row.permission_level,
|
|
69
80
|
rules: Number(row.rules),
|
|
@@ -81,6 +92,7 @@ export class RolesAdmin {
|
|
|
81
92
|
.first();
|
|
82
93
|
return {
|
|
83
94
|
id: role.id,
|
|
95
|
+
key: role.key ?? null,
|
|
84
96
|
name: role.name,
|
|
85
97
|
permissionLevel: role.permission_level,
|
|
86
98
|
users: Number(users?.count ?? 0),
|
|
@@ -89,19 +101,48 @@ export class RolesAdmin {
|
|
|
89
101
|
}
|
|
90
102
|
async create(actorId, input) {
|
|
91
103
|
const name = roleName(input.name);
|
|
104
|
+
const key = input.key === undefined || input.key === null || input.key === '' ? null : roleKey(input.key);
|
|
92
105
|
const level = permissionLevel(input.permissionLevel);
|
|
93
106
|
return this.db.transaction(async (trx) => {
|
|
94
107
|
if (await trx('roles').where('name', name).first())
|
|
95
108
|
throw new KitError(409, 'E_ROLE_EXISTS', 'يوجد دور بهذا الاسم');
|
|
96
|
-
|
|
109
|
+
if (key && (await trx('roles').where('key', key).first()))
|
|
110
|
+
throw new KitError(409, 'E_ROLE_KEY_EXISTS', 'يوجد دور بهذا المفتاح');
|
|
111
|
+
const [role] = await trx('roles')
|
|
112
|
+
.insert({ name, key, permission_level: level })
|
|
113
|
+
.returning('*');
|
|
97
114
|
await logActivity(trx, {
|
|
98
115
|
resource: RESOURCE,
|
|
99
116
|
recordId: role.id,
|
|
100
117
|
actorId,
|
|
101
118
|
action: 'create',
|
|
102
|
-
changes: { name, permissionLevel: level },
|
|
119
|
+
changes: { name, key, permissionLevel: level },
|
|
120
|
+
});
|
|
121
|
+
return { id: role.id, key, name, permissionLevel: level };
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Gives a role its stable key. A key is set once: workflows and module defaults
|
|
126
|
+
* depend on it, so changing it would silently detach them.
|
|
127
|
+
*/
|
|
128
|
+
async setKey(actorId, id, value) {
|
|
129
|
+
const key = roleKey(value);
|
|
130
|
+
return this.db.transaction(async (trx) => {
|
|
131
|
+
const role = await this.find(trx, id);
|
|
132
|
+
if (role.key === key)
|
|
133
|
+
return;
|
|
134
|
+
if (role.key)
|
|
135
|
+
throw new KitError(409, 'E_ROLE_KEY_FIXED', 'مفتاح الدور ثابت بعد تعيينه لأن تدفقات العمل والوحدات تعتمد عليه');
|
|
136
|
+
if (await trx('roles').where('key', key).whereNot('id', id).first())
|
|
137
|
+
throw new KitError(409, 'E_ROLE_KEY_EXISTS', 'يوجد دور بهذا المفتاح');
|
|
138
|
+
await trx('roles').where('id', id).update({ key });
|
|
139
|
+
await logActivity(trx, {
|
|
140
|
+
resource: RESOURCE,
|
|
141
|
+
recordId: id,
|
|
142
|
+
actorId,
|
|
143
|
+
action: 'set_key',
|
|
144
|
+
changes: { key },
|
|
103
145
|
});
|
|
104
|
-
return { id: role.id, name, permissionLevel: level };
|
|
105
146
|
});
|
|
106
147
|
}
|
|
107
148
|
async rename(actorId, id, value) {
|
|
@@ -249,6 +290,81 @@ export class RolesAdmin {
|
|
|
249
290
|
});
|
|
250
291
|
});
|
|
251
292
|
}
|
|
293
|
+
/**
|
|
294
|
+
* Creates module default roles that no role holds the key of yet, with their rules
|
|
295
|
+
* validated exactly like setRule. Existing roles are never modified. A keyless role
|
|
296
|
+
* with the same display name is adopted by receiving the key, rules untouched.
|
|
297
|
+
*/
|
|
298
|
+
async ensureDefaults(actorId, roles) {
|
|
299
|
+
const prepared = roles.map((role) => {
|
|
300
|
+
const key = roleKey(role.key);
|
|
301
|
+
const name = roleName(role.name);
|
|
302
|
+
const level = permissionLevel(role.permissionLevel);
|
|
303
|
+
const rules = role.rules.map((rule) => {
|
|
304
|
+
try {
|
|
305
|
+
return this.validateRule(rule);
|
|
306
|
+
}
|
|
307
|
+
catch (error) {
|
|
308
|
+
throw new Error(`Default role ${key}: ${error.message}`);
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
return { key, name, level, rules };
|
|
312
|
+
});
|
|
313
|
+
const result = { created: [], adopted: [], kept: [] };
|
|
314
|
+
await this.db.transaction(async (trx) => {
|
|
315
|
+
for (const role of prepared) {
|
|
316
|
+
if (await trx('roles').where('key', role.key).first('id')) {
|
|
317
|
+
result.kept.push(role.key);
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
const named = await trx('roles').where('name', role.name).first();
|
|
321
|
+
if (named) {
|
|
322
|
+
// A role with this name but another key belongs to the administrator.
|
|
323
|
+
if (named.key) {
|
|
324
|
+
result.kept.push(role.key);
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
await trx('roles').where('id', named.id).update({ key: role.key });
|
|
328
|
+
await logActivity(trx, {
|
|
329
|
+
resource: RESOURCE,
|
|
330
|
+
recordId: named.id,
|
|
331
|
+
actorId,
|
|
332
|
+
action: 'set_key',
|
|
333
|
+
changes: { key: role.key, source: 'module default' },
|
|
334
|
+
});
|
|
335
|
+
result.adopted.push(role.key);
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
const [created] = await trx('roles')
|
|
339
|
+
.insert({ key: role.key, name: role.name, permission_level: role.level })
|
|
340
|
+
.returning('id');
|
|
341
|
+
for (const rule of role.rules)
|
|
342
|
+
await trx('role_rules').insert({
|
|
343
|
+
role_id: created.id,
|
|
344
|
+
subject: rule.subject,
|
|
345
|
+
action: rule.action,
|
|
346
|
+
inverted: rule.inverted,
|
|
347
|
+
conditions: rule.conditions ? JSON.stringify(rule.conditions) : null,
|
|
348
|
+
fields: rule.fields ? JSON.stringify(rule.fields) : null,
|
|
349
|
+
});
|
|
350
|
+
await logActivity(trx, {
|
|
351
|
+
resource: RESOURCE,
|
|
352
|
+
recordId: created.id,
|
|
353
|
+
actorId,
|
|
354
|
+
action: 'create',
|
|
355
|
+
changes: {
|
|
356
|
+
name: role.name,
|
|
357
|
+
key: role.key,
|
|
358
|
+
permissionLevel: role.level,
|
|
359
|
+
rules: role.rules.length,
|
|
360
|
+
source: 'module default',
|
|
361
|
+
},
|
|
362
|
+
});
|
|
363
|
+
result.created.push(role.key);
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
return result;
|
|
367
|
+
}
|
|
252
368
|
validateRule(input) {
|
|
253
369
|
const subject = typeof input.subject === 'string' ? input.subject : '';
|
|
254
370
|
let resource;
|
|
@@ -287,8 +403,20 @@ export class RolesAdmin {
|
|
|
287
403
|
for (const field of Object.keys(input.conditions))
|
|
288
404
|
if (!conditionable.has(field))
|
|
289
405
|
throw new KitError(422, 'E_RULE_CONDITIONS', `الحقل ${field} لا يقبل الشروط`);
|
|
406
|
+
const actorFields = new Set([...standardConditionFields(resource), ...matrixFields(resource)]
|
|
407
|
+
.filter((field) => field.actor)
|
|
408
|
+
.map((field) => field.key));
|
|
409
|
+
for (const [field, condition] of Object.entries(input.conditions))
|
|
410
|
+
if (usesActor({ [field]: condition })) {
|
|
411
|
+
if (!actorFields.has(field))
|
|
412
|
+
throw new KitError(422, 'E_RULE_CONDITIONS', `المستخدم الحالي يُستخدم مع حقول المستخدمين وأنشأه وعدّله فقط: ${field}`);
|
|
413
|
+
const operators = condition !== null && typeof condition === 'object' ? Object.keys(condition) : ['$eq'];
|
|
414
|
+
if (operators.some((operator) => !['$eq', '$ne', '$in'].includes(operator)))
|
|
415
|
+
throw new KitError(422, 'E_RULE_CONDITIONS', 'المستخدم الحالي يقبل عوامل يساوي ولا يساوي وضمن قائمة فقط');
|
|
416
|
+
}
|
|
290
417
|
try {
|
|
291
|
-
|
|
418
|
+
// The placeholder is an integer id at request time; validate it as one.
|
|
419
|
+
conditionSql(resolveActorConditions(input.conditions, 1), resource);
|
|
292
420
|
}
|
|
293
421
|
catch (error) {
|
|
294
422
|
throw new KitError(422, 'E_RULE_CONDITIONS', `شرط غير مدعوم: ${error.message}`);
|
|
@@ -5,3 +5,48 @@ export declare function createCoreSchema(db: Knex): Promise<void>;
|
|
|
5
5
|
export declare function createAttachmentsSchema(db: Knex): Promise<void>;
|
|
6
6
|
export declare function createSavedViewsSchema(db: Knex): Promise<void>;
|
|
7
7
|
export declare function createResourceTable(db: Knex, resource: Pick<Resource, 'name' | 'scoped' | 'version' | 'submittable' | 'customFields' | 'fields'>): Promise<void>;
|
|
8
|
+
/** Record collaboration (phase 3): comments with mentions, followers, tags and field history. */
|
|
9
|
+
export declare function createCollaborationSchema(db: Knex): Promise<void>;
|
|
10
|
+
/** Work assigned to a user on a record; approval steps of workflows reuse it (phase 4). */
|
|
11
|
+
export declare function createAssignmentsSchema(db: Knex): Promise<void>;
|
|
12
|
+
/**
|
|
13
|
+
* Managed assignments (1.1): opened and closed by module code with the record's state,
|
|
14
|
+
* never completed by hand. The close reason is shown with the closed task.
|
|
15
|
+
*/
|
|
16
|
+
export declare function createManagedAssignmentsSchema(db: Knex): Promise<void>;
|
|
17
|
+
/**
|
|
18
|
+
* Message templates, notification e-mail delivery state and the realtime signal.
|
|
19
|
+
* The trigger's NOTIFY is delivered only when the inserting transaction commits.
|
|
20
|
+
*/
|
|
21
|
+
export declare function createMessagingSchema(db: Knex): Promise<void>;
|
|
22
|
+
/** Outgoing webhooks and their delivery log (exactly one row per webhook and event). */
|
|
23
|
+
export declare function createWebhooksSchema(db: Knex): Promise<void>;
|
|
24
|
+
/** CSV import batches: parsed rows, column mapping, progress and per-row errors. */
|
|
25
|
+
export declare function createImportsSchema(db: Knex): Promise<void>;
|
|
26
|
+
/**
|
|
27
|
+
* Workflow engine state (phase 4): the submission envelope rows gain execution
|
|
28
|
+
* columns, and every transition is appended to workflow_events.
|
|
29
|
+
*/
|
|
30
|
+
export declare function createWorkflowSchema(db: Knex): Promise<void>;
|
|
31
|
+
/**
|
|
32
|
+
* Roles gain a stable key (1.1) that workflows and module defaults address, so an
|
|
33
|
+
* administrator can rename the display name freely. Existing identifier-like names
|
|
34
|
+
* (such as the bootstrap "administrator") become their key.
|
|
35
|
+
*/
|
|
36
|
+
export declare function createRoleKeysSchema(db: Knex): Promise<void>;
|
|
37
|
+
/**
|
|
38
|
+
* Notifications may name the record they are about (1.1). Links are authorized when
|
|
39
|
+
* opened, by the record page itself, never when the notification is written.
|
|
40
|
+
*/
|
|
41
|
+
export declare function createNotificationTargetsSchema(db: Knex): Promise<void>;
|
|
42
|
+
/**
|
|
43
|
+
* Short-lived upload grants (1.1): module code lets one user upload to one attachment field
|
|
44
|
+
* of one record without a role rule. Only the SHA-256 hash of the token is stored.
|
|
45
|
+
*/
|
|
46
|
+
export declare function createUploadGrantsSchema(db: Knex): Promise<void>;
|
|
47
|
+
/**
|
|
48
|
+
* Signed inbound webhooks (1.2): administrator-managed sources with sealed secrets,
|
|
49
|
+
* and a delivery log deduplicated by the sender's delivery id. Accepted deliveries are
|
|
50
|
+
* dispatched as domain events through the outbox.
|
|
51
|
+
*/
|
|
52
|
+
export declare function createInboundWebhooksSchema(db: Knex): Promise<void>;
|
|
@@ -199,6 +199,10 @@ export async function createResourceTable(db, resource) {
|
|
|
199
199
|
column = t.integer(name);
|
|
200
200
|
column.references('id').inTable('attachments').onDelete('RESTRICT').index();
|
|
201
201
|
break;
|
|
202
|
+
case 'user':
|
|
203
|
+
column = t.integer(name);
|
|
204
|
+
column.references('id').inTable('users').onDelete('RESTRICT').index();
|
|
205
|
+
break;
|
|
202
206
|
case 'integer':
|
|
203
207
|
column = t.integer(name);
|
|
204
208
|
break;
|
|
@@ -246,3 +250,289 @@ export async function createResourceTable(db, resource) {
|
|
|
246
250
|
await db.raw('CREATE INDEX ?? ON ?? USING gin(search_vector)', [`${table}_search_gin`, table]);
|
|
247
251
|
}
|
|
248
252
|
}
|
|
253
|
+
/** Record collaboration (phase 3): comments with mentions, followers, tags and field history. */
|
|
254
|
+
export async function createCollaborationSchema(db) {
|
|
255
|
+
await db.schema.createTable('comments', (t) => {
|
|
256
|
+
t.bigIncrements('id');
|
|
257
|
+
t.string('resource').notNullable();
|
|
258
|
+
t.integer('record_id').notNullable();
|
|
259
|
+
t.integer('author_id').notNullable().references('id').inTable('users').onDelete('RESTRICT');
|
|
260
|
+
t.text('body').notNullable();
|
|
261
|
+
t.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
|
262
|
+
t.timestamp('edited_at', { useTz: true });
|
|
263
|
+
t.timestamp('deleted_at', { useTz: true });
|
|
264
|
+
t.index(['resource', 'record_id', 'id']);
|
|
265
|
+
});
|
|
266
|
+
await db.schema.createTable('comment_mentions', (t) => {
|
|
267
|
+
t.bigInteger('comment_id')
|
|
268
|
+
.notNullable()
|
|
269
|
+
.references('id')
|
|
270
|
+
.inTable('comments')
|
|
271
|
+
.onDelete('CASCADE');
|
|
272
|
+
t.integer('user_id').notNullable().references('id').inTable('users').onDelete('CASCADE');
|
|
273
|
+
t.primary(['comment_id', 'user_id']);
|
|
274
|
+
t.index(['user_id']);
|
|
275
|
+
});
|
|
276
|
+
await db.schema.createTable('followers', (t) => {
|
|
277
|
+
t.string('resource').notNullable();
|
|
278
|
+
t.integer('record_id').notNullable();
|
|
279
|
+
t.integer('user_id').notNullable().references('id').inTable('users').onDelete('CASCADE');
|
|
280
|
+
t.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
|
281
|
+
t.primary(['resource', 'record_id', 'user_id']);
|
|
282
|
+
t.index(['user_id']);
|
|
283
|
+
});
|
|
284
|
+
await db.schema.createTable('tags', (t) => {
|
|
285
|
+
t.increments('id');
|
|
286
|
+
t.string('name', 60).notNullable().unique();
|
|
287
|
+
t.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
|
288
|
+
});
|
|
289
|
+
await db.schema.createTable('taggables', (t) => {
|
|
290
|
+
t.integer('tag_id').notNullable().references('id').inTable('tags').onDelete('CASCADE');
|
|
291
|
+
t.string('resource').notNullable();
|
|
292
|
+
t.integer('record_id').notNullable();
|
|
293
|
+
t.primary(['tag_id', 'resource', 'record_id']);
|
|
294
|
+
t.index(['resource', 'record_id']);
|
|
295
|
+
});
|
|
296
|
+
await db.schema.createTable('field_changes', (t) => {
|
|
297
|
+
t.bigIncrements('id');
|
|
298
|
+
t.bigInteger('activity_id')
|
|
299
|
+
.notNullable()
|
|
300
|
+
.references('id')
|
|
301
|
+
.inTable('activities')
|
|
302
|
+
.onDelete('CASCADE')
|
|
303
|
+
.index();
|
|
304
|
+
t.string('resource').notNullable();
|
|
305
|
+
t.integer('record_id').notNullable();
|
|
306
|
+
t.string('field').notNullable();
|
|
307
|
+
t.jsonb('before');
|
|
308
|
+
t.jsonb('after');
|
|
309
|
+
t.index(['resource', 'record_id', 'id']);
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
/** Work assigned to a user on a record; approval steps of workflows reuse it (phase 4). */
|
|
313
|
+
export async function createAssignmentsSchema(db) {
|
|
314
|
+
await db.schema.createTable('assignments', (t) => {
|
|
315
|
+
t.bigIncrements('id');
|
|
316
|
+
t.string('resource').notNullable();
|
|
317
|
+
t.integer('record_id').notNullable();
|
|
318
|
+
t.integer('assignee_id').notNullable().references('id').inTable('users').onDelete('RESTRICT');
|
|
319
|
+
t.integer('assigned_by').references('id').inTable('users').onDelete('RESTRICT');
|
|
320
|
+
t.string('kind', 20).notNullable().defaultTo('task');
|
|
321
|
+
t.string('title', 200).notNullable();
|
|
322
|
+
t.text('note');
|
|
323
|
+
t.date('due_on');
|
|
324
|
+
t.string('status', 20).notNullable().defaultTo('open');
|
|
325
|
+
t.uuid('workflow_run_id');
|
|
326
|
+
t.string('workflow_step', 100);
|
|
327
|
+
t.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
|
328
|
+
t.timestamp('completed_at', { useTz: true });
|
|
329
|
+
t.integer('completed_by').references('id').inTable('users').onDelete('RESTRICT');
|
|
330
|
+
t.string('outcome', 20);
|
|
331
|
+
t.index(['assignee_id', 'status', 'id']);
|
|
332
|
+
t.index(['resource', 'record_id']);
|
|
333
|
+
t.index(['workflow_run_id']);
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Managed assignments (1.1): opened and closed by module code with the record's state,
|
|
338
|
+
* never completed by hand. The close reason is shown with the closed task.
|
|
339
|
+
*/
|
|
340
|
+
export async function createManagedAssignmentsSchema(db) {
|
|
341
|
+
await db.schema.alterTable('assignments', (t) => {
|
|
342
|
+
t.boolean('managed').notNullable().defaultTo(false);
|
|
343
|
+
t.string('close_reason', 500);
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Message templates, notification e-mail delivery state and the realtime signal.
|
|
348
|
+
* The trigger's NOTIFY is delivered only when the inserting transaction commits.
|
|
349
|
+
*/
|
|
350
|
+
export async function createMessagingSchema(db) {
|
|
351
|
+
await db.schema.createTable('message_templates', (t) => {
|
|
352
|
+
t.string('key', 100).primary();
|
|
353
|
+
t.string('subject', 255).notNullable();
|
|
354
|
+
t.text('body').notNullable();
|
|
355
|
+
t.boolean('mail').notNullable().defaultTo(false);
|
|
356
|
+
t.integer('updated_by').references('id').inTable('users').onDelete('SET NULL');
|
|
357
|
+
t.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
|
358
|
+
});
|
|
359
|
+
await db.schema.alterTable('notifications', (t) => {
|
|
360
|
+
t.string('template_key', 100);
|
|
361
|
+
t.string('mail_state', 20);
|
|
362
|
+
t.integer('mail_attempts').notNullable().defaultTo(0);
|
|
363
|
+
t.string('mail_error', 500);
|
|
364
|
+
t.timestamp('mailed_at', { useTz: true });
|
|
365
|
+
});
|
|
366
|
+
await db.raw("CREATE INDEX notifications_mail_pending ON notifications (id) WHERE mail_state = 'pending'");
|
|
367
|
+
await db.raw(`CREATE FUNCTION kit_notification_signal() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN PERFORM pg_notify('kit_notifications', json_build_object('userId', NEW.user_id, 'id', NEW.id)::text); RETURN NULL; END $$`);
|
|
368
|
+
await db.raw('CREATE TRIGGER kit_notification_signal AFTER INSERT ON notifications FOR EACH ROW EXECUTE FUNCTION kit_notification_signal()');
|
|
369
|
+
}
|
|
370
|
+
/** Outgoing webhooks and their delivery log (exactly one row per webhook and event). */
|
|
371
|
+
export async function createWebhooksSchema(db) {
|
|
372
|
+
await db.schema.createTable('webhooks', (t) => {
|
|
373
|
+
t.increments('id');
|
|
374
|
+
t.string('name', 100).notNullable();
|
|
375
|
+
t.string('url', 2000).notNullable();
|
|
376
|
+
t.text('secret').notNullable();
|
|
377
|
+
t.jsonb('events').notNullable();
|
|
378
|
+
t.boolean('active').notNullable().defaultTo(true);
|
|
379
|
+
t.integer('failing').notNullable().defaultTo(0);
|
|
380
|
+
t.integer('created_by').references('id').inTable('users').onDelete('SET NULL');
|
|
381
|
+
t.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
|
382
|
+
t.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
|
383
|
+
t.timestamp('last_delivery_at', { useTz: true });
|
|
384
|
+
});
|
|
385
|
+
await db.raw('CREATE INDEX webhooks_events_gin ON webhooks USING gin(events)');
|
|
386
|
+
await db.schema.createTable('webhook_deliveries', (t) => {
|
|
387
|
+
t.uuid('id').primary();
|
|
388
|
+
t.integer('webhook_id').notNullable().references('id').inTable('webhooks').onDelete('CASCADE');
|
|
389
|
+
t.uuid('event_id').notNullable();
|
|
390
|
+
t.string('event').notNullable();
|
|
391
|
+
t.jsonb('payload').notNullable();
|
|
392
|
+
t.string('status', 20).notNullable().defaultTo('pending');
|
|
393
|
+
t.integer('attempts').notNullable().defaultTo(0);
|
|
394
|
+
t.integer('last_status');
|
|
395
|
+
t.string('last_error', 500);
|
|
396
|
+
t.timestamp('next_attempt_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
|
397
|
+
t.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
|
398
|
+
t.timestamp('delivered_at', { useTz: true });
|
|
399
|
+
t.unique(['webhook_id', 'event_id']);
|
|
400
|
+
t.index(['webhook_id', 'created_at']);
|
|
401
|
+
});
|
|
402
|
+
await db.raw("CREATE INDEX webhook_deliveries_due ON webhook_deliveries (next_attempt_at) WHERE status = 'pending'");
|
|
403
|
+
}
|
|
404
|
+
/** CSV import batches: parsed rows, column mapping, progress and per-row errors. */
|
|
405
|
+
export async function createImportsSchema(db) {
|
|
406
|
+
await db.schema.createTable('import_batches', (t) => {
|
|
407
|
+
t.increments('id');
|
|
408
|
+
t.string('resource').notNullable();
|
|
409
|
+
t.integer('user_id').notNullable().references('id').inTable('users').onDelete('CASCADE');
|
|
410
|
+
t.string('file_name', 200).notNullable();
|
|
411
|
+
t.string('status', 20).notNullable();
|
|
412
|
+
t.jsonb('headers').notNullable();
|
|
413
|
+
t.jsonb('mapping').notNullable();
|
|
414
|
+
t.jsonb('rows').notNullable();
|
|
415
|
+
t.integer('total').notNullable();
|
|
416
|
+
t.integer('processed').notNullable().defaultTo(0);
|
|
417
|
+
t.integer('created').notNullable().defaultTo(0);
|
|
418
|
+
t.integer('failed').notNullable().defaultTo(0);
|
|
419
|
+
t.jsonb('errors').notNullable().defaultTo('[]');
|
|
420
|
+
t.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
|
421
|
+
t.timestamp('started_at', { useTz: true });
|
|
422
|
+
t.timestamp('finished_at', { useTz: true });
|
|
423
|
+
t.index(['user_id', 'id']);
|
|
424
|
+
t.index(['status']);
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Workflow engine state (phase 4): the submission envelope rows gain execution
|
|
429
|
+
* columns, and every transition is appended to workflow_events.
|
|
430
|
+
*/
|
|
431
|
+
export async function createWorkflowSchema(db) {
|
|
432
|
+
await db.schema.alterTable('workflow_runs', (t) => {
|
|
433
|
+
t.string('current_step', 100);
|
|
434
|
+
t.timestamp('wake_at', { useTz: true });
|
|
435
|
+
t.integer('attempts').notNullable().defaultTo(0);
|
|
436
|
+
t.string('last_error', 1000);
|
|
437
|
+
t.string('outcome', 30);
|
|
438
|
+
t.integer('started_by').references('id').inTable('users').onDelete('SET NULL');
|
|
439
|
+
t.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
|
440
|
+
t.timestamp('completed_at', { useTz: true });
|
|
441
|
+
t.index(['resource', 'record_id']);
|
|
442
|
+
});
|
|
443
|
+
await db.raw("CREATE INDEX workflow_runs_due ON workflow_runs (wake_at) WHERE status = 'running'");
|
|
444
|
+
await db.schema.createTable('workflow_events', (t) => {
|
|
445
|
+
t.bigIncrements('id');
|
|
446
|
+
t.uuid('run_id').notNullable().references('id').inTable('workflow_runs').onDelete('CASCADE');
|
|
447
|
+
t.string('step', 100);
|
|
448
|
+
t.string('event', 50).notNullable();
|
|
449
|
+
t.integer('actor_id').references('id').inTable('users').onDelete('SET NULL');
|
|
450
|
+
t.jsonb('detail').notNullable().defaultTo('{}');
|
|
451
|
+
t.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
|
452
|
+
t.index(['run_id', 'id']);
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Roles gain a stable key (1.1) that workflows and module defaults address, so an
|
|
457
|
+
* administrator can rename the display name freely. Existing identifier-like names
|
|
458
|
+
* (such as the bootstrap "administrator") become their key.
|
|
459
|
+
*/
|
|
460
|
+
export async function createRoleKeysSchema(db) {
|
|
461
|
+
await db.schema.alterTable('roles', (t) => {
|
|
462
|
+
t.string('key', 100).unique();
|
|
463
|
+
});
|
|
464
|
+
await db.raw("UPDATE roles SET key = name WHERE name ~ '^[a-z][a-z0-9_]*$'");
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* Notifications may name the record they are about (1.1). Links are authorized when
|
|
468
|
+
* opened, by the record page itself, never when the notification is written.
|
|
469
|
+
*/
|
|
470
|
+
export async function createNotificationTargetsSchema(db) {
|
|
471
|
+
await db.schema.alterTable('notifications', (t) => {
|
|
472
|
+
t.string('resource', 100);
|
|
473
|
+
t.integer('record_id');
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* Short-lived upload grants (1.1): module code lets one user upload to one attachment field
|
|
478
|
+
* of one record without a role rule. Only the SHA-256 hash of the token is stored.
|
|
479
|
+
*/
|
|
480
|
+
export async function createUploadGrantsSchema(db) {
|
|
481
|
+
await db.schema.createTable('upload_grants', (t) => {
|
|
482
|
+
t.increments('id');
|
|
483
|
+
t.string('token_hash', 64).notNullable().unique();
|
|
484
|
+
t.integer('user_id').notNullable().references('users.id').onDelete('CASCADE');
|
|
485
|
+
t.string('resource', 100).notNullable();
|
|
486
|
+
t.integer('record_id').notNullable();
|
|
487
|
+
t.string('field', 100).notNullable();
|
|
488
|
+
t.integer('org_unit_id');
|
|
489
|
+
t.timestamp('expires_at', { useTz: true }).notNullable();
|
|
490
|
+
t.integer('created_by').notNullable();
|
|
491
|
+
t.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
|
492
|
+
t.index(['expires_at']);
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Signed inbound webhooks (1.2): administrator-managed sources with sealed secrets,
|
|
497
|
+
* and a delivery log deduplicated by the sender's delivery id. Accepted deliveries are
|
|
498
|
+
* dispatched as domain events through the outbox.
|
|
499
|
+
*/
|
|
500
|
+
export async function createInboundWebhooksSchema(db) {
|
|
501
|
+
await db.schema.createTable('inbound_sources', (t) => {
|
|
502
|
+
t.increments('id');
|
|
503
|
+
t.string('key', 60).notNullable().unique();
|
|
504
|
+
t.string('name', 100).notNullable();
|
|
505
|
+
t.text('secret').notNullable();
|
|
506
|
+
t.string('algorithm', 10).notNullable().defaultTo('sha256');
|
|
507
|
+
t.string('signature_header', 100).notNullable().defaultTo('x-hub-signature-256');
|
|
508
|
+
t.string('signature_prefix', 20).notNullable().defaultTo('sha256=');
|
|
509
|
+
t.string('event_header', 100).notNullable().defaultTo('x-github-event');
|
|
510
|
+
t.string('delivery_header', 100).notNullable().defaultTo('x-github-delivery');
|
|
511
|
+
t.boolean('dedupe_body').notNullable().defaultTo(true);
|
|
512
|
+
t.boolean('active').notNullable().defaultTo(true);
|
|
513
|
+
t.integer('created_by').references('id').inTable('users').onDelete('SET NULL');
|
|
514
|
+
t.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
|
515
|
+
t.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
|
516
|
+
t.timestamp('last_received_at', { useTz: true });
|
|
517
|
+
});
|
|
518
|
+
await db.schema.createTable('inbound_deliveries', (t) => {
|
|
519
|
+
t.uuid('id').primary();
|
|
520
|
+
t.integer('source_id')
|
|
521
|
+
.notNullable()
|
|
522
|
+
.references('id')
|
|
523
|
+
.inTable('inbound_sources')
|
|
524
|
+
.onDelete('CASCADE');
|
|
525
|
+
t.string('delivery_id', 200).notNullable();
|
|
526
|
+
/** SHA-256 of the raw body while the source deduplicates bodies; null otherwise. */
|
|
527
|
+
t.string('body_key', 64);
|
|
528
|
+
t.string('event', 100).notNullable();
|
|
529
|
+
t.jsonb('payload').notNullable();
|
|
530
|
+
t.uuid('event_id').notNullable();
|
|
531
|
+
t.integer('dispatches').notNullable().defaultTo(1);
|
|
532
|
+
t.timestamp('received_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
|
533
|
+
t.unique(['source_id', 'delivery_id']);
|
|
534
|
+
t.unique(['source_id', 'body_key']);
|
|
535
|
+
t.index(['source_id', 'received_at']);
|
|
536
|
+
t.index(['received_at']);
|
|
537
|
+
});
|
|
538
|
+
}
|
|
@@ -42,6 +42,32 @@ export default {
|
|
|
42
42
|
}
|
|
43
43
|
},
|
|
44
44
|
},
|
|
45
|
+
'no-system-save-in-controllers': {
|
|
46
|
+
meta: {
|
|
47
|
+
type: 'problem',
|
|
48
|
+
schema: [],
|
|
49
|
+
messages: {
|
|
50
|
+
forbidden:
|
|
51
|
+
'systemSave and rehome skip role rules. Call them from listeners or module services after an explicit authorization, never from a controller or route.',
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
create(context) {
|
|
55
|
+
const file = context.filename.replaceAll('\\', '/')
|
|
56
|
+
// Request handlers act for a user, so their writes go through save() and its rules.
|
|
57
|
+
if (!/(?:^|\/)(?:app\/controllers|app\/modules\/[^/]+\/controllers)\/|(?:^|\/)start\/routes\./.test(file))
|
|
58
|
+
return {}
|
|
59
|
+
return {
|
|
60
|
+
CallExpression(node) {
|
|
61
|
+
if (
|
|
62
|
+
node.callee.type === 'MemberExpression' &&
|
|
63
|
+
node.callee.property.type === 'Identifier' &&
|
|
64
|
+
['systemSave', 'rehome'].includes(node.callee.property.name)
|
|
65
|
+
)
|
|
66
|
+
context.report({ node, messageId: 'forbidden' })
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
},
|
|
45
71
|
'no-kit-patching': {
|
|
46
72
|
meta: {
|
|
47
73
|
type: 'problem',
|
|
@@ -17,7 +17,7 @@ export async function publishOutbox(db, jobs, limit = 100) {
|
|
|
17
17
|
});
|
|
18
18
|
}
|
|
19
19
|
export async function consumeEvent(db, listener, event) {
|
|
20
|
-
if (listener.event !== event.event)
|
|
20
|
+
if (listener.event !== '*' && listener.event !== event.event)
|
|
21
21
|
return false;
|
|
22
22
|
return db.transaction(async (trx) => {
|
|
23
23
|
const inserted = await trx('processed_events')
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import type { Knex } from 'knex';
|
|
2
2
|
import type { Action } from '../resource/types.js';
|
|
3
|
+
export type FieldChange = {
|
|
4
|
+
field: string;
|
|
5
|
+
before: unknown;
|
|
6
|
+
after: unknown;
|
|
7
|
+
};
|
|
3
8
|
/** Infrastructure writes for a module-owned mutation, inside its existing transaction. */
|
|
4
9
|
export declare function recordMutation(trx: Knex.Transaction, mutation: {
|
|
5
10
|
module: string;
|
|
@@ -10,4 +15,10 @@ export declare function recordMutation(trx: Knex.Transaction, mutation: {
|
|
|
10
15
|
impersonatorId?: number;
|
|
11
16
|
action: Action;
|
|
12
17
|
fields: string[];
|
|
18
|
+
/** Before/after values of the changed fields; read back per viewer's field access. */
|
|
19
|
+
changes?: FieldChange[];
|
|
20
|
+
/** Written by module code through ResourceService.systemSave, not by a user's edit. */
|
|
21
|
+
system?: boolean;
|
|
22
|
+
/** Why module code made the change; shown with the activity entry. */
|
|
23
|
+
reason?: string;
|
|
13
24
|
}): Promise<void>;
|
|
@@ -19,7 +19,8 @@ export async function recordMutation(trx, mutation) {
|
|
|
19
19
|
actorId: mutation.actorId,
|
|
20
20
|
...(mutation.impersonatorId ? { impersonatorId: mutation.impersonatorId } : {}),
|
|
21
21
|
};
|
|
22
|
-
await trx('activities')
|
|
22
|
+
const [activity] = await trx('activities')
|
|
23
|
+
.insert({
|
|
23
24
|
resource: mutation.resource,
|
|
24
25
|
record_id: mutation.id,
|
|
25
26
|
actor_id: mutation.actorId,
|
|
@@ -27,8 +28,20 @@ export async function recordMutation(trx, mutation) {
|
|
|
27
28
|
changes: JSON.stringify({
|
|
28
29
|
fields: mutation.fields,
|
|
29
30
|
...(mutation.impersonatorId ? { impersonatedBy: mutation.impersonatorId } : {}),
|
|
31
|
+
...(mutation.system ? { system: true } : {}),
|
|
32
|
+
...(mutation.reason ? { reason: mutation.reason } : {}),
|
|
30
33
|
}),
|
|
31
|
-
})
|
|
34
|
+
})
|
|
35
|
+
.returning('id');
|
|
36
|
+
if (mutation.changes?.length)
|
|
37
|
+
await trx('field_changes').insert(mutation.changes.map((change) => ({
|
|
38
|
+
activity_id: activity.id,
|
|
39
|
+
resource: mutation.resource,
|
|
40
|
+
record_id: mutation.id,
|
|
41
|
+
field: change.field,
|
|
42
|
+
before: JSON.stringify(change.before ?? null),
|
|
43
|
+
after: JSON.stringify(change.after ?? null),
|
|
44
|
+
})));
|
|
32
45
|
await trx('outbox').insert({
|
|
33
46
|
id: eventId,
|
|
34
47
|
event,
|