@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
|
@@ -2,12 +2,19 @@ import { subject } from '@casl/ability';
|
|
|
2
2
|
import { columnName } from '../resource/define_resource.js';
|
|
3
3
|
import { buildAbility, canRecord, inOrgScope, } from '../auth/ability.js';
|
|
4
4
|
import { accessibleBy, conditionSql } from '../auth/sql.js';
|
|
5
|
-
import { fromRow, selectedFields, serialize, writableInput } from './contracts.js';
|
|
5
|
+
import { fromRow, jsonValue, selectedFields, serialize, systemInput, writableInput, } from './contracts.js';
|
|
6
|
+
import { resolveActorConditions } from '../auth/conditions.js';
|
|
6
7
|
import { KitError } from './errors.js';
|
|
7
8
|
import { sequence } from '../services/settings.js';
|
|
8
9
|
import { recordMutation } from '../events/record_mutation.js';
|
|
10
|
+
import { formatTitle, titleFields } from './record_title.js';
|
|
9
11
|
import { fieldValue } from '../resource/values.js';
|
|
10
12
|
import { claimAttachment, isAttachmentId, loadAttachments, releaseAttachment, } from '../attachments/attachment_service.js';
|
|
13
|
+
/** Per-process cache of planner row estimates for identical list queries. */
|
|
14
|
+
const estimates = new Map();
|
|
15
|
+
const ESTIMATE_TTL_MS = 30_000;
|
|
16
|
+
const ESTIMATE_CACHE_LIMIT = 500;
|
|
17
|
+
const AGGREGATE_GROUP_LIMIT = 1000;
|
|
11
18
|
/**
|
|
12
19
|
* Whether an actor may sort, filter or search by a field. Querying reveals values
|
|
13
20
|
* indirectly, so it needs unconditional view access at the field's level.
|
|
@@ -28,6 +35,10 @@ export class ResourceService {
|
|
|
28
35
|
this.db = db;
|
|
29
36
|
this.registry = registry;
|
|
30
37
|
}
|
|
38
|
+
/** The Arabic label of a registered resource, for notifications and titles. */
|
|
39
|
+
label(name) {
|
|
40
|
+
return this.registry.get(name).label.ar;
|
|
41
|
+
}
|
|
31
42
|
/** Project navigation is derived from registered resources and the current actor. */
|
|
32
43
|
navigation(actor) {
|
|
33
44
|
const ability = buildAbility(actor.rules, this.registry.all());
|
|
@@ -45,6 +56,10 @@ export class ResourceService {
|
|
|
45
56
|
label: resource.label.ar,
|
|
46
57
|
href: `/resources/${resource.name}`,
|
|
47
58
|
module: this.registry.owner(resource.name),
|
|
59
|
+
moduleLabel: this.registry
|
|
60
|
+
.modules()
|
|
61
|
+
.find((module) => module.name === this.registry.owner(resource.name))?.label.ar ??
|
|
62
|
+
this.registry.owner(resource.name),
|
|
48
63
|
}));
|
|
49
64
|
}
|
|
50
65
|
describe(name, actor) {
|
|
@@ -67,6 +82,8 @@ export class ResourceService {
|
|
|
67
82
|
return {
|
|
68
83
|
name,
|
|
69
84
|
label: resource.label.ar,
|
|
85
|
+
recordLabel: resource.recordLabel?.ar ?? null,
|
|
86
|
+
createLabel: resource.createLabel?.ar ?? null,
|
|
70
87
|
fields,
|
|
71
88
|
list: resource.list.filter((key) => visible.has(key)),
|
|
72
89
|
show: resource.show.filter((key) => visible.has(key)),
|
|
@@ -85,7 +102,7 @@ export class ResourceService {
|
|
|
85
102
|
!['update', 'delete', 'submit'].includes(action) ||
|
|
86
103
|
record.docStatus === 0) &&
|
|
87
104
|
(action !== 'cancel' || (resource.submittable && record.docStatus === 1)) &&
|
|
88
|
-
action !== 'amend',
|
|
105
|
+
(action !== 'amend' || (resource.submittable && record.docStatus === 2)),
|
|
89
106
|
]));
|
|
90
107
|
}
|
|
91
108
|
normalizeValues(resource, record) {
|
|
@@ -154,12 +171,56 @@ export class ResourceService {
|
|
|
154
171
|
throw new KitError(404, 'E_NOT_FOUND', 'السجل غير موجود');
|
|
155
172
|
return fromRow(row, resource);
|
|
156
173
|
}
|
|
174
|
+
/** A live record regardless of any actor's scope, for system writes. */
|
|
175
|
+
async findAny(db, resource, id, lock = false) {
|
|
176
|
+
const query = db(`${resource.name} as r`).whereNull('r.deleted_at').where('r.id', id);
|
|
177
|
+
query.select(selectedFields(resource, buildAbility([], []), { write: true }).map((key) => `r.${resource.fields[key]?.column ?? columnName(key)}`));
|
|
178
|
+
if (resource.scoped)
|
|
179
|
+
query.leftJoin('org_units as ou', 'ou.id', 'r.org_unit_id').select('ou.path as org_path');
|
|
180
|
+
if (lock)
|
|
181
|
+
query.forUpdate('r');
|
|
182
|
+
const row = await query.first();
|
|
183
|
+
if (!row)
|
|
184
|
+
throw new KitError(404, 'E_NOT_FOUND', 'السجل غير موجود');
|
|
185
|
+
return fromRow(row, resource);
|
|
186
|
+
}
|
|
157
187
|
requireRecord(ability, actor, resource, action, record) {
|
|
158
188
|
if (resource.scoped && !inOrgScope(record.orgPath, actor.orgPaths))
|
|
159
189
|
throw new KitError(404, 'E_NOT_FOUND', 'السجل غير موجود');
|
|
160
190
|
if (!canRecord(ability, actor, resource, action, record))
|
|
161
191
|
throw new KitError(403, 'E_FORBIDDEN', 'ليس لديك صلاحية لهذا الإجراء');
|
|
162
192
|
}
|
|
193
|
+
/**
|
|
194
|
+
* Authorizes one record for collaboration features (comments, tags, assignments...).
|
|
195
|
+
* Scope and conditional rules apply exactly as for show/update.
|
|
196
|
+
*/
|
|
197
|
+
async access(name, id, actor, action = 'view', db = this.db) {
|
|
198
|
+
let resource;
|
|
199
|
+
try {
|
|
200
|
+
resource = this.registry.get(name);
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
throw new KitError(404, 'E_NOT_FOUND', 'الكيان غير موجود');
|
|
204
|
+
}
|
|
205
|
+
if (!Number.isSafeInteger(id) || id <= 0)
|
|
206
|
+
throw new KitError(404, 'E_NOT_FOUND', 'السجل غير موجود');
|
|
207
|
+
const ability = this.authorizeAction(resource, actor, action);
|
|
208
|
+
const record = await this.find(db, resource, actor, ability, id);
|
|
209
|
+
this.requireRecord(ability, actor, resource, action, record);
|
|
210
|
+
return { resource, record, ability };
|
|
211
|
+
}
|
|
212
|
+
/** Whether a (possibly other) actor may perform an action on a record; never throws for denial. */
|
|
213
|
+
async permits(name, id, actor, action = 'view') {
|
|
214
|
+
try {
|
|
215
|
+
await this.access(name, id, actor, action);
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
if (error instanceof KitError && [403, 404].includes(error.status))
|
|
220
|
+
return false;
|
|
221
|
+
throw error;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
163
224
|
async list(name, actor, options = {}) {
|
|
164
225
|
const resource = this.registry.get(name);
|
|
165
226
|
const ability = this.authorizeAction(resource, actor, 'view');
|
|
@@ -190,15 +251,36 @@ export class ResourceService {
|
|
|
190
251
|
throw new KitError(422, 'E_FILTER', 'Invalid filter value');
|
|
191
252
|
query.where(`r.${resource.fields[key].column ?? columnName(key)}`, value);
|
|
192
253
|
}
|
|
254
|
+
if (options.tag !== undefined && options.tag !== null && options.tag !== '') {
|
|
255
|
+
if (typeof options.tag !== 'string' || options.tag.length > 60)
|
|
256
|
+
throw new KitError(422, 'E_FILTER', 'Invalid tag filter');
|
|
257
|
+
query.whereExists((exists) => exists
|
|
258
|
+
.from('taggables as tg')
|
|
259
|
+
.join('tags as tn', 'tn.id', 'tg.tag_id')
|
|
260
|
+
.where('tg.resource', name)
|
|
261
|
+
.whereRaw('tg.record_id = r.id')
|
|
262
|
+
.where('tn.name', options.tag));
|
|
263
|
+
}
|
|
193
264
|
// Estimate the authorized, filtered query, never the deployment-wide table cardinality.
|
|
194
265
|
// Only the first page pays for the extra planner round trip; later pages keep its figure.
|
|
195
266
|
let estimatedTotal;
|
|
196
267
|
if (options.estimate !== false && !options.cursor) {
|
|
197
268
|
const compiled = query.clone().clearSelect().select('r.id').toSQL();
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
269
|
+
// The planner estimate is approximate by nature; reuse it briefly for the same
|
|
270
|
+
// authorized, filtered query instead of planning it on every first page.
|
|
271
|
+
const key = `${compiled.sql}\u0000${JSON.stringify(compiled.bindings)}`;
|
|
272
|
+
const cached = estimates.get(key);
|
|
273
|
+
if (cached && cached.at > Date.now() - ESTIMATE_TTL_MS)
|
|
274
|
+
estimatedTotal = cached.rows;
|
|
275
|
+
else {
|
|
276
|
+
const estimate = await this.db.raw(`EXPLAIN (FORMAT JSON) ${compiled.sql}`, [
|
|
277
|
+
...compiled.bindings,
|
|
278
|
+
]);
|
|
279
|
+
estimatedTotal = Number(estimate.rows[0]['QUERY PLAN'][0].Plan['Plan Rows']);
|
|
280
|
+
if (estimates.size >= ESTIMATE_CACHE_LIMIT)
|
|
281
|
+
estimates.delete(estimates.keys().next().value);
|
|
282
|
+
estimates.set(key, { rows: estimatedTotal, at: Date.now() });
|
|
283
|
+
}
|
|
202
284
|
}
|
|
203
285
|
if (options.cursor) {
|
|
204
286
|
let cursor;
|
|
@@ -258,10 +340,128 @@ export class ResourceService {
|
|
|
258
340
|
canQueryField(resource, actor, ability, key) {
|
|
259
341
|
return canQueryField(resource, actor, ability, key);
|
|
260
342
|
}
|
|
343
|
+
/**
|
|
344
|
+
* Counts and totals over the records the actor may view, grouped by queryable fields,
|
|
345
|
+
* for dashboards. Authorization is list()'s: the same rules, organization scope and
|
|
346
|
+
* soft-delete filter apply, and every grouped, totalled or filtered field must be one
|
|
347
|
+
* the actor may query (E_FIELD_FORBIDDEN otherwise), so no hidden value leaks.
|
|
348
|
+
*/
|
|
349
|
+
async aggregate(name, actor, options = {}) {
|
|
350
|
+
const resource = this.registry.get(name);
|
|
351
|
+
const ability = this.authorizeAction(resource, actor, 'view');
|
|
352
|
+
const standard = new Set([
|
|
353
|
+
...(resource.submittable ? ['docStatus'] : []),
|
|
354
|
+
...(resource.scoped ? ['orgUnitId'] : []),
|
|
355
|
+
]);
|
|
356
|
+
const column = (key) => `r.${resource.fields[key]?.column ?? columnName(key)}`;
|
|
357
|
+
const queryable = (key, kinds) => {
|
|
358
|
+
if (typeof key !== 'string')
|
|
359
|
+
return false;
|
|
360
|
+
if (standard.has(key))
|
|
361
|
+
return !kinds;
|
|
362
|
+
// Own keys only: names such as "constructor" are not fields.
|
|
363
|
+
if (!Object.hasOwn(resource.fields, key))
|
|
364
|
+
return false;
|
|
365
|
+
const field = resource.fields[key];
|
|
366
|
+
return (Boolean(field) &&
|
|
367
|
+
!['hasMany', 'json', 'attachment'].includes(field.type) &&
|
|
368
|
+
(!kinds || kinds.includes(field.type)) &&
|
|
369
|
+
this.canQueryField(resource, actor, ability, key));
|
|
370
|
+
};
|
|
371
|
+
const groupBy = options.groupBy ?? [];
|
|
372
|
+
const sums = options.sum ?? [];
|
|
373
|
+
if (!Array.isArray(groupBy) || groupBy.length > 3 || !Array.isArray(sums) || sums.length > 5)
|
|
374
|
+
throw new KitError(422, 'E_AGGREGATE', 'Group by up to three fields and total up to five');
|
|
375
|
+
for (const key of groupBy)
|
|
376
|
+
if (!queryable(key))
|
|
377
|
+
throw new KitError(403, 'E_FIELD_FORBIDDEN', `Field cannot be grouped: ${String(key)}`);
|
|
378
|
+
for (const key of sums)
|
|
379
|
+
if (!queryable(key, ['integer', 'money']))
|
|
380
|
+
throw new KitError(403, 'E_FIELD_FORBIDDEN', `Field cannot be totalled: ${String(key)}`);
|
|
381
|
+
const query = accessibleBy(this.db(`${name} as r`).whereNull('r.deleted_at'), ability, actor, 'view', resource);
|
|
382
|
+
for (const [key, value] of Object.entries(options.filters ?? {})) {
|
|
383
|
+
if (!resource.fields[key]?.filterable || !queryable(key))
|
|
384
|
+
throw new KitError(422, 'E_FILTER', 'Unsupported filter');
|
|
385
|
+
if (value !== null && !['string', 'number', 'boolean'].includes(typeof value))
|
|
386
|
+
throw new KitError(422, 'E_FILTER', 'Invalid filter value');
|
|
387
|
+
query.where(column(key), value);
|
|
388
|
+
}
|
|
389
|
+
if (options.where !== undefined && options.where !== null) {
|
|
390
|
+
if (typeof options.where !== 'object' || Array.isArray(options.where))
|
|
391
|
+
throw new KitError(422, 'E_FILTER', 'Conditions must be an object');
|
|
392
|
+
for (const key of Object.keys(options.where))
|
|
393
|
+
if (!queryable(key))
|
|
394
|
+
throw new KitError(403, 'E_FIELD_FORBIDDEN', `Field cannot be filtered: ${key}`);
|
|
395
|
+
let where;
|
|
396
|
+
try {
|
|
397
|
+
where = conditionSql(resolveActorConditions(options.where, actor.id), resource);
|
|
398
|
+
}
|
|
399
|
+
catch (error) {
|
|
400
|
+
throw new KitError(422, 'E_FILTER', error.message);
|
|
401
|
+
}
|
|
402
|
+
query.whereRaw(where.text, where.bindings);
|
|
403
|
+
}
|
|
404
|
+
if (options.search) {
|
|
405
|
+
const searchable = Object.entries(resource.fields).filter(([, f]) => f.searchable);
|
|
406
|
+
if (!searchable.length || searchable.some(([key]) => !queryable(key)))
|
|
407
|
+
throw new KitError(403, 'E_SEARCH', 'Search is unavailable');
|
|
408
|
+
query.whereRaw("r.search_vector @@ plainto_tsquery('simple', ?)", [options.search]);
|
|
409
|
+
}
|
|
410
|
+
const selects = groupBy.map((key, index) => `${key === 'docStatus' ? 'r.doc_status' : column(key)} as g${index}`);
|
|
411
|
+
if (options.count !== false)
|
|
412
|
+
selects.push(this.db.raw('count(*)::int as count'));
|
|
413
|
+
sums.forEach((key, index) => selects.push(this.db.raw(`sum(??)::text as s${index}`, [column(key)])));
|
|
414
|
+
query.select(selects);
|
|
415
|
+
groupBy.forEach((_, index) => query.groupByRaw(`${index + 1}`));
|
|
416
|
+
groupBy.forEach((_, index) => query.orderByRaw(`${index + 1} ASC NULLS LAST`));
|
|
417
|
+
const rows = await query.limit(AGGREGATE_GROUP_LIMIT + 1);
|
|
418
|
+
return {
|
|
419
|
+
truncated: rows.length > AGGREGATE_GROUP_LIMIT,
|
|
420
|
+
rows: rows.slice(0, AGGREGATE_GROUP_LIMIT).map((row) => {
|
|
421
|
+
const group = fromRow(Object.fromEntries(groupBy.map((key, index) => [
|
|
422
|
+
key === 'docStatus'
|
|
423
|
+
? 'doc_status'
|
|
424
|
+
: key === 'orgUnitId'
|
|
425
|
+
? 'org_unit_id'
|
|
426
|
+
: (resource.fields[key].column ?? columnName(key)),
|
|
427
|
+
row[`g${index}`],
|
|
428
|
+
])), resource);
|
|
429
|
+
return {
|
|
430
|
+
group: Object.fromEntries(groupBy.map((key) => [key, jsonValue(group[key])])),
|
|
431
|
+
...(options.count !== false ? { count: Number(row.count) } : {}),
|
|
432
|
+
...(sums.length
|
|
433
|
+
? {
|
|
434
|
+
sum: Object.fromEntries(sums.map((key, index) => [
|
|
435
|
+
key,
|
|
436
|
+
row[`s${index}`] === null ? null : String(row[`s${index}`]),
|
|
437
|
+
])),
|
|
438
|
+
}
|
|
439
|
+
: {}),
|
|
440
|
+
};
|
|
441
|
+
}),
|
|
442
|
+
};
|
|
443
|
+
}
|
|
261
444
|
async preload(resource, records, actor) {
|
|
262
445
|
const related = {};
|
|
263
446
|
const ability = buildAbility(actor.rules, this.registry.all());
|
|
264
447
|
for (const [key, field] of Object.entries(resource.fields)) {
|
|
448
|
+
if (field.type === 'user' && records.length) {
|
|
449
|
+
const ids = records
|
|
450
|
+
.filter((record) => key in serialize(resource, record, ability, actor))
|
|
451
|
+
.map((record) => record[key])
|
|
452
|
+
.filter((v) => v !== null && v !== undefined);
|
|
453
|
+
// A display name only: readers of the record need not read account e-mails.
|
|
454
|
+
if (ids.length) {
|
|
455
|
+
const users = await this.db('users')
|
|
456
|
+
.whereIn('id', [...new Set(ids)])
|
|
457
|
+
.select('id', 'full_name');
|
|
458
|
+
related[key] = users.map((row) => ({
|
|
459
|
+
id: Number(row.id),
|
|
460
|
+
fullName: String(row.full_name ?? ''),
|
|
461
|
+
}));
|
|
462
|
+
}
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
265
465
|
if (field.type !== 'belongsTo' || !records.length)
|
|
266
466
|
continue;
|
|
267
467
|
const target = this.registry.get(field.resource);
|
|
@@ -278,10 +478,61 @@ export class ResourceService {
|
|
|
278
478
|
query.select('ou.path as org_path');
|
|
279
479
|
const rows = await query;
|
|
280
480
|
const targets = await this.hydrate(this.db, target, rows.map((r) => fromRow(r, target)));
|
|
281
|
-
|
|
481
|
+
const title = await this.titler(target);
|
|
482
|
+
related[key] = targets.map((r) => {
|
|
483
|
+
const row = serialize(target, r, ability, actor);
|
|
484
|
+
// `_title` cannot collide with a field key; clients label the relation with it.
|
|
485
|
+
return { ...row, _title: title(row) };
|
|
486
|
+
});
|
|
282
487
|
}
|
|
283
488
|
return related;
|
|
284
489
|
}
|
|
490
|
+
/** Formats record titles of one resource, loading the labels of its title lookups once. */
|
|
491
|
+
async titler(resource) {
|
|
492
|
+
const groups = titleFields(resource).flatMap((key) => {
|
|
493
|
+
const field = resource.fields[key];
|
|
494
|
+
return field.type === 'lookup' ? [field.group] : [];
|
|
495
|
+
});
|
|
496
|
+
const labels = new Map();
|
|
497
|
+
if (groups.length)
|
|
498
|
+
for (const row of await this.db('lookups')
|
|
499
|
+
.whereIn('group', groups)
|
|
500
|
+
.select('group', 'key', 'label_ar'))
|
|
501
|
+
labels.set(`${row.group}\u0000${row.key}`, String(row.label_ar));
|
|
502
|
+
return (record) => formatTitle(resource, record, (group, key) => labels.get(`${group}\u0000${key}`));
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Titles of records the actor may view, read under the actor's field access (#32).
|
|
506
|
+
* Records the actor cannot view are left out; callers fall back to the record id.
|
|
507
|
+
*/
|
|
508
|
+
async titles(name, ids, actor) {
|
|
509
|
+
const result = new Map();
|
|
510
|
+
let resource;
|
|
511
|
+
try {
|
|
512
|
+
resource = this.registry.get(name);
|
|
513
|
+
}
|
|
514
|
+
catch {
|
|
515
|
+
return result;
|
|
516
|
+
}
|
|
517
|
+
const wanted = [...new Set(ids.filter((id) => Number.isSafeInteger(id) && id > 0))];
|
|
518
|
+
const ability = buildAbility(actor.rules, this.registry.all());
|
|
519
|
+
if (!wanted.length || !resource.actions.includes('view') || !ability.can('view', name))
|
|
520
|
+
return result;
|
|
521
|
+
const query = accessibleBy(this.db(`${resource.name} as r`).whereIn('r.id', wanted).whereNull('r.deleted_at'), ability, actor, 'view', resource).select(this.columns(resource, ability));
|
|
522
|
+
if (resource.scoped)
|
|
523
|
+
query.select('ou.path as org_path');
|
|
524
|
+
const rows = await query;
|
|
525
|
+
const records = rows.map((row) => fromRow(row, resource));
|
|
526
|
+
const title = await this.titler(resource);
|
|
527
|
+
for (const record of records) {
|
|
528
|
+
if (!canRecord(ability, actor, resource, 'view', record))
|
|
529
|
+
continue;
|
|
530
|
+
const text = title(serialize(resource, record, ability, actor));
|
|
531
|
+
if (text)
|
|
532
|
+
result.set(Number(record.id), text);
|
|
533
|
+
}
|
|
534
|
+
return result;
|
|
535
|
+
}
|
|
285
536
|
async show(name, id, actor) {
|
|
286
537
|
const resource = this.registry.get(name);
|
|
287
538
|
const ability = this.authorizeAction(resource, actor, 'view');
|
|
@@ -295,6 +546,8 @@ export class ResourceService {
|
|
|
295
546
|
};
|
|
296
547
|
}
|
|
297
548
|
async relationOptions(name, key, actor, options = {}) {
|
|
549
|
+
if (this.registry.get(name).fields[key]?.type === 'user')
|
|
550
|
+
return this.userOptions(name, key, actor, options);
|
|
298
551
|
const editor = await this.editor(name, actor, options.id);
|
|
299
552
|
const field = editor.fields.find((entry) => entry.key === key);
|
|
300
553
|
if (!field || field.type !== 'belongsTo')
|
|
@@ -306,20 +559,107 @@ export class ResourceService {
|
|
|
306
559
|
limit: 50,
|
|
307
560
|
estimate: false,
|
|
308
561
|
});
|
|
562
|
+
const title = await this.titler(target);
|
|
309
563
|
return {
|
|
310
564
|
data: page.data.map((row) => ({
|
|
311
565
|
value: String(row.id),
|
|
312
|
-
label:
|
|
566
|
+
label: title(row) ?? `#${row.id}`,
|
|
313
567
|
})),
|
|
314
568
|
nextCursor: page.meta.nextCursor,
|
|
315
569
|
};
|
|
316
570
|
}
|
|
571
|
+
/**
|
|
572
|
+
* Choices for a user field. A form lists active members of the record's unit or its
|
|
573
|
+
* ancestors (the users the record is visible to through membership); a filter lists
|
|
574
|
+
* active users who share organization scope with the actor. Only names are returned.
|
|
575
|
+
*/
|
|
576
|
+
async userOptions(name, key, actor, options) {
|
|
577
|
+
const resource = this.registry.get(name);
|
|
578
|
+
let path = null;
|
|
579
|
+
if (options.purpose === 'filter') {
|
|
580
|
+
const field = this.describe(name, actor).fields.find((entry) => entry.key === key);
|
|
581
|
+
if (!field?.filterable)
|
|
582
|
+
throw new KitError(403, 'E_FIELD_FORBIDDEN', 'ليس لديك صلاحية لهذا الحقل');
|
|
583
|
+
}
|
|
584
|
+
else {
|
|
585
|
+
const editor = await this.editor(name, actor, options.id);
|
|
586
|
+
if (!editor.fields.some((entry) => entry.key === key))
|
|
587
|
+
throw new KitError(403, 'E_FIELD_FORBIDDEN', 'ليس لديك صلاحية لهذا الحقل');
|
|
588
|
+
if (resource.scoped) {
|
|
589
|
+
const unit = options.orgUnitId ??
|
|
590
|
+
(editor.record?.orgUnitId === undefined ? undefined : Number(editor.record.orgUnitId));
|
|
591
|
+
// Without a unit (for example an inline row) the choices follow the actor's scope;
|
|
592
|
+
// saving still checks the record's unit.
|
|
593
|
+
if (unit !== undefined) {
|
|
594
|
+
const row = await this.db('org_units').where('id', unit).first('path');
|
|
595
|
+
// Only units the actor works in (or the record's current unit) may be probed.
|
|
596
|
+
if (!row ||
|
|
597
|
+
(!inOrgScope(row.path, actor.orgPaths) &&
|
|
598
|
+
Number(unit) !== Number(editor.record?.orgUnitId)))
|
|
599
|
+
return { data: [], nextCursor: null };
|
|
600
|
+
path = String(row.path);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
let after = 0;
|
|
605
|
+
if (options.cursor !== undefined && options.cursor !== '') {
|
|
606
|
+
after = Number(options.cursor);
|
|
607
|
+
if (!Number.isSafeInteger(after) || after < 0)
|
|
608
|
+
throw new KitError(422, 'E_CURSOR', 'Invalid cursor');
|
|
609
|
+
}
|
|
610
|
+
const query = this.eligibleUsers(this.db, path, actor)
|
|
611
|
+
.where('u.id', '>', after)
|
|
612
|
+
.orderBy('u.id')
|
|
613
|
+
.limit(51)
|
|
614
|
+
.select('u.id', 'u.full_name');
|
|
615
|
+
if (options.search)
|
|
616
|
+
query.whereILike('u.full_name', `%${options.search.replace(/[\\%_]/g, '\\$&')}%`);
|
|
617
|
+
const rows = await query;
|
|
618
|
+
return {
|
|
619
|
+
data: rows.slice(0, 50).map((row) => ({
|
|
620
|
+
value: String(row.id),
|
|
621
|
+
label: String(row.full_name || `#${row.id}`),
|
|
622
|
+
})),
|
|
623
|
+
nextCursor: rows.length > 50 ? String(rows[49].id) : null,
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* Active users eligible for a user field: members of the unit at `path` or of one of
|
|
628
|
+
* its ancestors, or (without a path) users sharing organization scope with the actor.
|
|
629
|
+
* A system write on an unscoped resource passes no actor: any active member qualifies.
|
|
630
|
+
*/
|
|
631
|
+
eligibleUsers(db, path, actor) {
|
|
632
|
+
return db('users as u')
|
|
633
|
+
.whereNull('u.disabled_at')
|
|
634
|
+
.whereExists((members) => {
|
|
635
|
+
members
|
|
636
|
+
.from('user_org_units as m')
|
|
637
|
+
.join('org_units as o', 'o.id', 'm.org_unit_id')
|
|
638
|
+
.whereRaw('m.user_id = u.id');
|
|
639
|
+
if (path !== null)
|
|
640
|
+
members.whereRaw('?::ltree <@ o.path', [path]);
|
|
641
|
+
else if (actor)
|
|
642
|
+
members.where((scope) => {
|
|
643
|
+
if (!actor.orgPaths.length)
|
|
644
|
+
scope.whereRaw('FALSE');
|
|
645
|
+
for (const own of actor.orgPaths)
|
|
646
|
+
scope.orWhereRaw('o.path <@ ?::ltree', [own]).orWhereRaw('o.path @> ?::ltree', [own]);
|
|
647
|
+
});
|
|
648
|
+
})
|
|
649
|
+
.distinct();
|
|
650
|
+
}
|
|
317
651
|
/** Deferred relation reads re-authorize the parent and each child on every request. */
|
|
318
652
|
async children(name, id, actor) {
|
|
319
653
|
const resource = this.registry.get(name);
|
|
320
654
|
const ability = this.authorizeAction(resource, actor, 'view');
|
|
321
655
|
const parent = await this.find(this.db, resource, actor, ability, id);
|
|
322
656
|
this.requireRecord(ability, actor, resource, 'view', parent);
|
|
657
|
+
return this.childrenOf(resource, parent, actor, ability);
|
|
658
|
+
}
|
|
659
|
+
/** Inline children of an already authorized parent record. */
|
|
660
|
+
async childrenOf(resource, parent, actor, ability) {
|
|
661
|
+
const name = resource.name;
|
|
662
|
+
const id = Number(parent.id);
|
|
323
663
|
const result = {};
|
|
324
664
|
for (const key of resource.show) {
|
|
325
665
|
const field = resource.fields[key];
|
|
@@ -366,6 +706,38 @@ export class ResourceService {
|
|
|
366
706
|
const ability = this.authorizeAction(resource, actor, 'view');
|
|
367
707
|
const record = await this.find(this.db, resource, actor, ability, id);
|
|
368
708
|
this.requireRecord(ability, actor, resource, 'view', record);
|
|
709
|
+
return this.activityOf(name, id);
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* Inline children and recent activity of one record, authorized with a single record
|
|
713
|
+
* read (for deferred page props that would otherwise read the record twice).
|
|
714
|
+
*/
|
|
715
|
+
async details(name, id, actor) {
|
|
716
|
+
const resource = this.registry.get(name);
|
|
717
|
+
const ability = this.authorizeAction(resource, actor, 'view');
|
|
718
|
+
const record = await this.find(this.db, resource, actor, ability, id);
|
|
719
|
+
this.requireRecord(ability, actor, resource, 'view', record);
|
|
720
|
+
return {
|
|
721
|
+
children: await this.childrenOf(resource, record, actor, ability),
|
|
722
|
+
activity: await this.activityOf(name, id),
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
/** show(), children() and activity() of one record from a single record read. */
|
|
726
|
+
async record(name, id, actor) {
|
|
727
|
+
const resource = this.registry.get(name);
|
|
728
|
+
const ability = this.authorizeAction(resource, actor, 'view');
|
|
729
|
+
const record = await this.find(this.db, resource, actor, ability, id);
|
|
730
|
+
this.requireRecord(ability, actor, resource, 'view', record);
|
|
731
|
+
const [hydrated] = await this.hydrate(this.db, resource, [record]);
|
|
732
|
+
return {
|
|
733
|
+
data: serialize(resource, hydrated, ability, actor),
|
|
734
|
+
permissions: this.permissions(resource, record, actor, ability),
|
|
735
|
+
related: await this.preload(resource, [record], actor),
|
|
736
|
+
children: await this.childrenOf(resource, record, actor, ability),
|
|
737
|
+
activity: await this.activityOf(name, id),
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
async activityOf(name, id) {
|
|
369
741
|
const rows = await this.db('activities as a')
|
|
370
742
|
.leftJoin('users as u', 'u.id', 'a.actor_id')
|
|
371
743
|
.where({ 'a.resource': name, 'a.record_id': id })
|
|
@@ -387,7 +759,13 @@ export class ResourceService {
|
|
|
387
759
|
createdAt: new Date(row.created_at).toISOString(),
|
|
388
760
|
}));
|
|
389
761
|
}
|
|
390
|
-
|
|
762
|
+
/**
|
|
763
|
+
* The form description for create (no id) or update. `defaults` pre-fills a create form
|
|
764
|
+
* (#48), for example `?defaults[violation]=13`: only visible form fields are used, a
|
|
765
|
+
* related record must be viewable by the actor and a lookup must be active. Anything else
|
|
766
|
+
* is dropped. Defaults are only initial values; saving validates as usual.
|
|
767
|
+
*/
|
|
768
|
+
async editor(name, actor, id, request = {}) {
|
|
391
769
|
const resource = this.registry.get(name);
|
|
392
770
|
const action = id === undefined ? 'create' : 'update';
|
|
393
771
|
const ability = this.authorizeAction(resource, actor, action);
|
|
@@ -461,9 +839,10 @@ export class ResourceService {
|
|
|
461
839
|
if (ability.can('view', target.name)) {
|
|
462
840
|
relationSearch[field.key] = this.describe(target.name, actor).searchable;
|
|
463
841
|
const page = await this.list(target.name, actor, { limit: 50, estimate: false });
|
|
842
|
+
const title = await this.titler(target);
|
|
464
843
|
options[field.key] = page.data.map((row) => ({
|
|
465
844
|
value: String(row.id),
|
|
466
|
-
label:
|
|
845
|
+
label: title(row) ?? `#${row.id}`,
|
|
467
846
|
}));
|
|
468
847
|
const selected = record?.[field.key];
|
|
469
848
|
if (selected !== null &&
|
|
@@ -473,7 +852,7 @@ export class ResourceService {
|
|
|
473
852
|
const current = await this.show(target.name, Number(selected), actor);
|
|
474
853
|
options[field.key].push({
|
|
475
854
|
value: String(current.data.id),
|
|
476
|
-
label:
|
|
855
|
+
label: title(current.data) ?? `#${current.data.id}`,
|
|
477
856
|
});
|
|
478
857
|
}
|
|
479
858
|
catch (error) {
|
|
@@ -485,8 +864,24 @@ export class ResourceService {
|
|
|
485
864
|
else
|
|
486
865
|
options[field.key] = [];
|
|
487
866
|
}
|
|
867
|
+
else if (field.type === 'user') {
|
|
868
|
+
// Choices depend on the unit chosen in the form; they load from relationOptions.
|
|
869
|
+
relationSearch[field.key] = true;
|
|
870
|
+
options[field.key] = [];
|
|
871
|
+
const selected = record?.[field.key];
|
|
872
|
+
if (selected !== null && selected !== undefined) {
|
|
873
|
+
const current = await this.db('users')
|
|
874
|
+
.where('id', Number(selected))
|
|
875
|
+
.first('id', 'full_name');
|
|
876
|
+
if (current)
|
|
877
|
+
options[field.key].push({
|
|
878
|
+
value: String(current.id),
|
|
879
|
+
label: String(current.full_name || `#${current.id}`),
|
|
880
|
+
});
|
|
881
|
+
}
|
|
882
|
+
}
|
|
488
883
|
}
|
|
489
|
-
const units = resource.scoped
|
|
884
|
+
const units = resource.scoped && !resource.scope
|
|
490
885
|
? await this.db('org_units')
|
|
491
886
|
.where((query) => {
|
|
492
887
|
if (!actor.orgPaths.length)
|
|
@@ -505,16 +900,78 @@ export class ResourceService {
|
|
|
505
900
|
if (current)
|
|
506
901
|
units.push(current);
|
|
507
902
|
}
|
|
903
|
+
const defaults = {};
|
|
904
|
+
const requested = request.defaults;
|
|
905
|
+
if (id === undefined && requested && typeof requested === 'object')
|
|
906
|
+
for (const field of fields) {
|
|
907
|
+
const raw = Object.hasOwn(requested, field.key) ? requested[field.key] : undefined;
|
|
908
|
+
if (typeof raw !== 'string' && typeof raw !== 'number' && typeof raw !== 'boolean')
|
|
909
|
+
continue;
|
|
910
|
+
let value = raw;
|
|
911
|
+
try {
|
|
912
|
+
if (['integer', 'belongsTo', 'user'].includes(field.type))
|
|
913
|
+
value = Number(raw);
|
|
914
|
+
else if (field.type === 'boolean')
|
|
915
|
+
value = raw === true || raw === 'true';
|
|
916
|
+
else
|
|
917
|
+
value = String(raw);
|
|
918
|
+
value = fieldValue(field, value);
|
|
919
|
+
}
|
|
920
|
+
catch {
|
|
921
|
+
continue;
|
|
922
|
+
}
|
|
923
|
+
if (['attachment', 'json', 'hasMany'].includes(field.type))
|
|
924
|
+
continue;
|
|
925
|
+
if (field.type === 'user') {
|
|
926
|
+
// The unit is chosen later in the form; saving checks the record's unit.
|
|
927
|
+
const user = await this.eligibleUsers(this.db, null, actor)
|
|
928
|
+
.where('u.id', Number(value))
|
|
929
|
+
.first('u.id', 'u.full_name');
|
|
930
|
+
if (!user)
|
|
931
|
+
continue;
|
|
932
|
+
const choices = options[field.key] ?? (options[field.key] = []);
|
|
933
|
+
if (!choices.some((option) => option.value === String(user.id)))
|
|
934
|
+
choices.push({ value: String(user.id), label: String(user.full_name || `#${user.id}`) });
|
|
935
|
+
}
|
|
936
|
+
if (field.type === 'lookup' || field.type === 'belongsTo') {
|
|
937
|
+
const choices = options[field.key] ?? [];
|
|
938
|
+
if (!choices.some((option) => option.value === String(value))) {
|
|
939
|
+
if (field.type === 'lookup')
|
|
940
|
+
continue;
|
|
941
|
+
try {
|
|
942
|
+
// Authorized exactly like opening the related record.
|
|
943
|
+
const target = this.registry.get(field.resource);
|
|
944
|
+
const current = await this.show(target.name, Number(value), actor);
|
|
945
|
+
const title = await this.titler(target);
|
|
946
|
+
choices.push({
|
|
947
|
+
value: String(current.data.id),
|
|
948
|
+
label: title(current.data) ?? `#${current.data.id}`,
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
catch (error) {
|
|
952
|
+
if (!(error instanceof KitError) || ![403, 404].includes(error.status))
|
|
953
|
+
throw error;
|
|
954
|
+
continue;
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
defaults[field.key] = value;
|
|
959
|
+
}
|
|
508
960
|
return {
|
|
509
961
|
mode: action,
|
|
510
962
|
name,
|
|
511
963
|
label: resource.label.ar,
|
|
964
|
+
recordLabel: resource.recordLabel?.ar ?? null,
|
|
512
965
|
fields,
|
|
966
|
+
/** Initial values of a create form, already checked against the actor's access. */
|
|
967
|
+
defaults,
|
|
513
968
|
inline,
|
|
514
969
|
options,
|
|
515
970
|
relationSearch,
|
|
516
971
|
orgUnits: units.map((row) => ({ value: String(row.id), label: String(row.name) })),
|
|
517
972
|
scoped: resource.scoped,
|
|
973
|
+
/** The belongsTo field whose record decides the unit; the form shows no unit picker. */
|
|
974
|
+
scopeFrom: resource.scope?.from ?? null,
|
|
518
975
|
record: record
|
|
519
976
|
? serialize(resource, await this.hydrateOne(this.db, resource, record), ability, actor)
|
|
520
977
|
: null,
|
|
@@ -523,20 +980,84 @@ export class ResourceService {
|
|
|
523
980
|
async save(name, actor, input, id, transaction) {
|
|
524
981
|
return this.persist(name, actor, input, id, transaction);
|
|
525
982
|
}
|
|
526
|
-
|
|
983
|
+
/**
|
|
984
|
+
* A write decided by module code rather than by a user's role rules: state transitions,
|
|
985
|
+
* snapshots and listener updates. It runs the validator, hooks, lookup, relation and
|
|
986
|
+
* attachment checks, versioning and the audit trail exactly like save(), and skips only
|
|
987
|
+
* the actor's role rules and organization scope. Values may set any stored field except
|
|
988
|
+
* sequences and inline children. Updates merge the given values into the stored record.
|
|
989
|
+
* Returns the full stored record; module code must not send it to users unfiltered.
|
|
990
|
+
*/
|
|
991
|
+
async systemSave(name, values, id, options) {
|
|
992
|
+
if (!Number.isSafeInteger(options.actorId) || options.actorId <= 0)
|
|
993
|
+
throw new KitError(422, 'E_ACTOR', 'systemSave requires the author user id');
|
|
994
|
+
const actor = { id: options.actorId, orgPaths: [], permissionLevel: 0, rules: [] };
|
|
995
|
+
return this.persist(name, actor, values, id, options.trx, undefined, {
|
|
996
|
+
reason: options.reason,
|
|
997
|
+
version: options.version,
|
|
998
|
+
chooser: options.chooser,
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
/**
|
|
1002
|
+
* Moves the records whose scope follows this parent (`scope: { from }`) to the parent's
|
|
1003
|
+
* current organization unit, through systemSave. Call it from a listener on the parent's
|
|
1004
|
+
* `updated` event, or right after moving the parent. Returns the number of moved records.
|
|
1005
|
+
*/
|
|
1006
|
+
async rehome(parentName, parentId, options) {
|
|
1007
|
+
const run = async (trx) => {
|
|
1008
|
+
const parent = await this.findAny(trx, this.registry.get(parentName), parentId);
|
|
1009
|
+
let moved = 0;
|
|
1010
|
+
for (const child of this.registry.all()) {
|
|
1011
|
+
const field = child.scope && child.fields[child.scope.from];
|
|
1012
|
+
if (!field || field.type !== 'belongsTo' || field.resource !== parentName)
|
|
1013
|
+
continue;
|
|
1014
|
+
const column = field.column ?? columnName(child.scope.from);
|
|
1015
|
+
const ids = await trx(child.name)
|
|
1016
|
+
.where(column, parentId)
|
|
1017
|
+
.whereNull('deleted_at')
|
|
1018
|
+
.whereNot('org_unit_id', Number(parent.orgUnitId))
|
|
1019
|
+
.orderBy('id')
|
|
1020
|
+
.pluck('id');
|
|
1021
|
+
for (const id of ids) {
|
|
1022
|
+
await this.systemSave(child.name, {}, Number(id), { ...options, trx });
|
|
1023
|
+
moved++;
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
return moved;
|
|
1027
|
+
};
|
|
1028
|
+
return options.trx ? run(options.trx) : this.db.transaction(run);
|
|
1029
|
+
}
|
|
1030
|
+
async persist(name, actor, input, id, transaction, parentWrite, system) {
|
|
527
1031
|
const resource = this.registry.get(name);
|
|
528
1032
|
const action = id === undefined ? 'create' : 'update';
|
|
529
|
-
|
|
530
|
-
const
|
|
531
|
-
|
|
1033
|
+
// System writes carry no role rules; this ability is used only for serialization shape.
|
|
1034
|
+
const ability = system
|
|
1035
|
+
? buildAbility([{ action: 'manage', subject: 'all' }], this.registry.all())
|
|
1036
|
+
: this.authorizeAction(resource, actor, action);
|
|
1037
|
+
const form = system ? systemInput(resource, input) : writableInput(resource, input);
|
|
1038
|
+
const load = (db, target, key, lock = false) => system
|
|
1039
|
+
? this.findAny(db, target, key, lock)
|
|
1040
|
+
: this.find(db, target, actor, ability, key, lock);
|
|
1041
|
+
const check = (target, act, record) => {
|
|
1042
|
+
if (!system)
|
|
1043
|
+
this.requireRecord(ability, actor, target, act, record);
|
|
1044
|
+
};
|
|
1045
|
+
const validated = system ? {} : await resource.validator.validate(form);
|
|
532
1046
|
const work = async (trx) => {
|
|
533
|
-
const existing = id === undefined ? {} : await
|
|
1047
|
+
const existing = id === undefined ? {} : await load(trx, resource, id, true);
|
|
534
1048
|
if (id !== undefined) {
|
|
535
|
-
|
|
536
|
-
|
|
1049
|
+
check(resource, action, existing);
|
|
1050
|
+
if (!system || system.version !== undefined)
|
|
1051
|
+
this.requireVersion(resource, existing, system ? system.version : input.version);
|
|
537
1052
|
if (resource.submittable && existing.docStatus !== 0)
|
|
538
1053
|
throw new KitError(409, 'E_DOCUMENT_LOCKED', 'Only draft documents can be edited');
|
|
539
1054
|
}
|
|
1055
|
+
if (system) {
|
|
1056
|
+
// The validator sees the complete form, so a partial update keeps required values.
|
|
1057
|
+
const formKeys = resource.form.filter((key) => resource.fields[key].type !== 'hasMany');
|
|
1058
|
+
const pick = (record) => Object.fromEntries(formKeys.filter((key) => key in record).map((key) => [key, record[key]]));
|
|
1059
|
+
Object.assign(validated, await resource.validator.validate({ ...pick(existing), ...pick(form) }), Object.fromEntries(Object.entries(form).filter(([key]) => !formKeys.includes(key))));
|
|
1060
|
+
}
|
|
540
1061
|
const candidate = {
|
|
541
1062
|
...existing,
|
|
542
1063
|
...validated,
|
|
@@ -546,19 +1067,38 @@ export class ResourceService {
|
|
|
546
1067
|
if (resource.submittable)
|
|
547
1068
|
candidate.docStatus = existing.docStatus ?? 0;
|
|
548
1069
|
this.normalizeValues(resource, candidate);
|
|
549
|
-
|
|
550
|
-
|
|
1070
|
+
const locate = async () => {
|
|
1071
|
+
if (!resource.scoped)
|
|
1072
|
+
return;
|
|
1073
|
+
if (resource.scope) {
|
|
1074
|
+
// Inherited scope: the parent decides the unit before any authorization (#45).
|
|
1075
|
+
const field = resource.fields[resource.scope.from];
|
|
1076
|
+
const parentId = Number(candidate[resource.scope.from]);
|
|
1077
|
+
if (field.type !== 'belongsTo' || !Number.isSafeInteger(parentId) || parentId <= 0)
|
|
1078
|
+
throw new KitError(422, 'E_REQUIRED', `Required field: ${resource.scope.from}`);
|
|
1079
|
+
const parentResource = this.registry.get(field.resource);
|
|
1080
|
+
const parent = await load(trx, parentResource, parentId);
|
|
1081
|
+
check(parentResource, 'view', parent);
|
|
1082
|
+
candidate.orgUnitId = parent.orgUnitId;
|
|
1083
|
+
}
|
|
1084
|
+
else if (!system || 'orgUnitId' in input || id === undefined)
|
|
1085
|
+
candidate.orgUnitId = input.orgUnitId ?? existing.orgUnitId;
|
|
551
1086
|
const unit = await trx('org_units')
|
|
552
1087
|
.where('id', Number(candidate.orgUnitId) || -1)
|
|
553
1088
|
.first('path');
|
|
1089
|
+
if (system && !unit)
|
|
1090
|
+
throw new KitError(422, 'E_ORG_UNIT', 'الوحدة التنظيمية غير موجودة');
|
|
554
1091
|
candidate.orgPath = unit?.path;
|
|
555
|
-
}
|
|
556
|
-
|
|
1092
|
+
};
|
|
1093
|
+
await locate();
|
|
1094
|
+
check(resource, action, candidate);
|
|
557
1095
|
// Validators may add fields (defaults, transforms); those are written too, so check them.
|
|
558
|
-
const written = new Set(
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
1096
|
+
const written = new Set(system
|
|
1097
|
+
? []
|
|
1098
|
+
: [
|
|
1099
|
+
...Object.keys(form),
|
|
1100
|
+
...Object.keys(validated).filter((key) => key in resource.fields),
|
|
1101
|
+
]);
|
|
562
1102
|
for (const key of written) {
|
|
563
1103
|
const field = resource.fields[key];
|
|
564
1104
|
if (!ability.can(action, subject(name, candidate), key) ||
|
|
@@ -575,6 +1115,16 @@ export class ResourceService {
|
|
|
575
1115
|
const context = { trx, userId: actor.id, action };
|
|
576
1116
|
await resource.hooks?.beforeSave?.(candidate, context);
|
|
577
1117
|
this.normalizeValues(resource, candidate);
|
|
1118
|
+
// A hook may change the parent of an inherited scope; the unit follows it.
|
|
1119
|
+
if (resource.scope)
|
|
1120
|
+
await locate();
|
|
1121
|
+
// A hook may also move the record: later checks use the unit it chose.
|
|
1122
|
+
if (resource.scoped) {
|
|
1123
|
+
const unit = await trx('org_units')
|
|
1124
|
+
.where('id', Number(candidate.orgUnitId) || -1)
|
|
1125
|
+
.first('path');
|
|
1126
|
+
candidate.orgPath = unit?.path;
|
|
1127
|
+
}
|
|
578
1128
|
for (const [key, field] of Object.entries(resource.fields)) {
|
|
579
1129
|
if (field.required &&
|
|
580
1130
|
field.type !== 'hasMany' &&
|
|
@@ -582,8 +1132,23 @@ export class ResourceService {
|
|
|
582
1132
|
throw new KitError(422, 'E_REQUIRED', `Required field: ${key}`);
|
|
583
1133
|
if (field.type === 'belongsTo' && candidate[key] !== null && candidate[key] !== undefined) {
|
|
584
1134
|
const related = this.registry.get(field.resource);
|
|
585
|
-
const target = await
|
|
586
|
-
|
|
1135
|
+
const target = await load(trx, related, Number(candidate[key]));
|
|
1136
|
+
check(related, 'view', target);
|
|
1137
|
+
}
|
|
1138
|
+
// A new user, or a record moved by a user to another unit, needs an eligible user.
|
|
1139
|
+
// An unchanged value never blocks other edits, even after the account is disabled.
|
|
1140
|
+
if (field.type === 'user' &&
|
|
1141
|
+
candidate[key] !== null &&
|
|
1142
|
+
candidate[key] !== undefined &&
|
|
1143
|
+
(Number(candidate[key]) !== Number(existing[key]) ||
|
|
1144
|
+
(!system &&
|
|
1145
|
+
resource.scoped &&
|
|
1146
|
+
Number(candidate.orgUnitId) !== Number(existing.orgUnitId)))) {
|
|
1147
|
+
const eligible = await this.eligibleUsers(trx, resource.scoped ? String(candidate.orgPath ?? '') || null : null, system ? (system.chooser ?? null) : actor)
|
|
1148
|
+
.where('u.id', Number(candidate[key]))
|
|
1149
|
+
.first('u.id');
|
|
1150
|
+
if (!eligible || (resource.scoped && !candidate.orgPath))
|
|
1151
|
+
throw new KitError(422, 'E_USER_FIELD', `${key}: اختر مستخدماً نشطاً من الوحدة التنظيمية للسجل أو الوحدات الأعلى منها`);
|
|
587
1152
|
}
|
|
588
1153
|
if (field.type === 'lookup' && candidate[key] !== null && candidate[key] !== undefined) {
|
|
589
1154
|
if (!(await trx('lookups')
|
|
@@ -605,15 +1170,9 @@ export class ResourceService {
|
|
|
605
1170
|
scoped: resource.scoped,
|
|
606
1171
|
});
|
|
607
1172
|
}
|
|
608
|
-
if (resource.scoped) {
|
|
609
|
-
const unit = await trx('org_units')
|
|
610
|
-
.where('id', Number(candidate.orgUnitId) || -1)
|
|
611
|
-
.first('path');
|
|
612
|
-
candidate.orgPath = unit?.path;
|
|
613
|
-
}
|
|
614
1173
|
// Hooks may calculate fields, but may never move a record past authorization.
|
|
615
|
-
|
|
616
|
-
await this.requireInlineParents(trx, resource, actor, candidate, existing, parentWrite);
|
|
1174
|
+
check(resource, action, candidate);
|
|
1175
|
+
await this.requireInlineParents(trx, resource, actor, candidate, existing, parentWrite, Boolean(system));
|
|
617
1176
|
if (id !== undefined &&
|
|
618
1177
|
resource.scoped &&
|
|
619
1178
|
Number(candidate.orgUnitId) !== Number(existing.orgUnitId)) {
|
|
@@ -696,12 +1255,26 @@ export class ResourceService {
|
|
|
696
1255
|
await this.persist(field.resource, actor, {
|
|
697
1256
|
...data,
|
|
698
1257
|
[field.foreignKey]: row.id,
|
|
699
|
-
...(childResource.scoped
|
|
1258
|
+
...(childResource.scoped && !childResource.scope
|
|
1259
|
+
? { orgUnitId: candidate.orgUnitId }
|
|
1260
|
+
: {}),
|
|
700
1261
|
}, childId === undefined ? undefined : Number(childId), trx, { name, id: Number(row.id), action });
|
|
701
1262
|
}
|
|
702
1263
|
}
|
|
703
|
-
|
|
1264
|
+
const changes = [];
|
|
1265
|
+
if (id !== undefined)
|
|
1266
|
+
for (const key of Object.keys(resource.fields)) {
|
|
1267
|
+
if (resource.fields[key].type === 'hasMany' || !(key in saved))
|
|
1268
|
+
continue;
|
|
1269
|
+
const before = existing[key] ?? null;
|
|
1270
|
+
const after = saved[key] ?? null;
|
|
1271
|
+
if (JSON.stringify(before) !== JSON.stringify(after))
|
|
1272
|
+
changes.push({ field: key, before, after });
|
|
1273
|
+
}
|
|
1274
|
+
await this.audit(trx, resource, actor, action, saved, Object.keys(form), changes, system);
|
|
704
1275
|
await resource.hooks?.afterSave?.(saved, context);
|
|
1276
|
+
if (system)
|
|
1277
|
+
return (await this.hydrateOne(trx, resource, saved));
|
|
705
1278
|
return serialize(resource, await this.hydrateOne(trx, resource, saved), ability, actor);
|
|
706
1279
|
};
|
|
707
1280
|
return transaction ? work(transaction) : this.db.transaction(work);
|
|
@@ -711,7 +1284,7 @@ export class ResourceService {
|
|
|
711
1284
|
throw new KitError(409, 'E_VERSION_CONFLICT', 'تم تعديل السجل بواسطة مستخدم آخر. حدّث الصفحة وحاول مجدداً.');
|
|
712
1285
|
}
|
|
713
1286
|
/** Inline children share the parent's draft state, organization and update authority. */
|
|
714
|
-
async requireInlineParents(trx, resource, actor, candidate, existing = {}, parentWrite) {
|
|
1287
|
+
async requireInlineParents(trx, resource, actor, candidate, existing = {}, parentWrite, system = false) {
|
|
715
1288
|
for (const parent of this.registry.all()) {
|
|
716
1289
|
for (const [key, field] of Object.entries(parent.fields)) {
|
|
717
1290
|
if (field.type !== 'hasMany' || !field.inline || field.resource !== resource.name)
|
|
@@ -725,13 +1298,18 @@ export class ResourceService {
|
|
|
725
1298
|
const action = parentWrite?.name === parent.name && parentWrite.id === Number(parentId)
|
|
726
1299
|
? parentWrite.action
|
|
727
1300
|
: 'update';
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
1301
|
+
let record;
|
|
1302
|
+
if (system)
|
|
1303
|
+
record = await this.findAny(trx, parent, Number(parentId), true);
|
|
1304
|
+
else {
|
|
1305
|
+
const ability = this.authorizeAction(parent, actor, action);
|
|
1306
|
+
record = await this.find(trx, parent, actor, ability, Number(parentId), true);
|
|
1307
|
+
this.requireRecord(ability, actor, parent, action, record);
|
|
1308
|
+
if (!ability.can(action, subject(parent.name, record), key) ||
|
|
1309
|
+
actor.permissionLevel <
|
|
1310
|
+
Math.max(field.permissionLevel ?? 0, parent.hidden?.includes(key) ? 1 : 0))
|
|
1311
|
+
throw new KitError(403, 'E_FIELD_FORBIDDEN', 'ليس لديك صلاحية تعديل البنود');
|
|
1312
|
+
}
|
|
735
1313
|
if (parent.submittable && record.docStatus !== 0)
|
|
736
1314
|
throw new KitError(409, 'E_DOCUMENT_LOCKED', 'Only draft document lines can be edited');
|
|
737
1315
|
if (parent.scoped &&
|
|
@@ -769,8 +1347,94 @@ export class ResourceService {
|
|
|
769
1347
|
};
|
|
770
1348
|
return transaction ? work(transaction) : this.db.transaction(work);
|
|
771
1349
|
}
|
|
772
|
-
|
|
1350
|
+
/**
|
|
1351
|
+
* Amend-by-copy: a cancelled document is copied into a new draft that points to
|
|
1352
|
+
* it through amended_from_id, together with its inline lines. The original stays
|
|
1353
|
+
* cancelled and unchanged; sequence fields receive new numbers.
|
|
1354
|
+
*/
|
|
1355
|
+
async amend(name, id, actor, transaction) {
|
|
1356
|
+
const resource = this.registry.get(name);
|
|
1357
|
+
if (!resource.submittable)
|
|
1358
|
+
throw new KitError(409, 'E_DOCUMENT_STATE', 'Only submittable documents can be amended');
|
|
1359
|
+
const ability = this.authorizeAction(resource, actor, 'amend');
|
|
1360
|
+
const work = async (trx) => {
|
|
1361
|
+
const source = await this.find(trx, resource, actor, ability, id, true);
|
|
1362
|
+
this.requireRecord(ability, actor, resource, 'amend', source);
|
|
1363
|
+
if (source.docStatus !== 2)
|
|
1364
|
+
throw new KitError(409, 'E_DOCUMENT_STATE', 'Only cancelled documents can be amended');
|
|
1365
|
+
const existing = await trx(name)
|
|
1366
|
+
.where('amended_from_id', id)
|
|
1367
|
+
.whereNull('deleted_at')
|
|
1368
|
+
.first('id');
|
|
1369
|
+
if (existing)
|
|
1370
|
+
throw new KitError(409, 'E_ALREADY_AMENDED', 'تم تعديل هذا المستند بالنسخ من قبل');
|
|
1371
|
+
const values = {
|
|
1372
|
+
created_by: actor.id,
|
|
1373
|
+
updated_by: actor.id,
|
|
1374
|
+
doc_status: 0,
|
|
1375
|
+
amended_from_id: id,
|
|
1376
|
+
};
|
|
1377
|
+
if (resource.scoped)
|
|
1378
|
+
values.org_unit_id = source.orgUnitId;
|
|
1379
|
+
if (resource.version)
|
|
1380
|
+
values.version = 1;
|
|
1381
|
+
const copied = [];
|
|
1382
|
+
for (const [key, field] of Object.entries(resource.fields)) {
|
|
1383
|
+
if (field.type === 'hasMany' || !(key in source))
|
|
1384
|
+
continue;
|
|
1385
|
+
const column = field.column ?? columnName(key);
|
|
1386
|
+
if (field.sequence)
|
|
1387
|
+
values[column] = await sequence(trx, field.sequence);
|
|
1388
|
+
else if (field.type === 'attachment')
|
|
1389
|
+
continue;
|
|
1390
|
+
else {
|
|
1391
|
+
values[column] = field.type === 'json' ? JSON.stringify(source[key]) : source[key];
|
|
1392
|
+
copied.push(key);
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
const [row] = await trx(name).insert(values).returning('*');
|
|
1396
|
+
const saved = { ...fromRow(row, resource), orgPath: source.orgPath };
|
|
1397
|
+
for (const field of Object.values(resource.fields)) {
|
|
1398
|
+
if (field.type !== 'hasMany' || !field.inline)
|
|
1399
|
+
continue;
|
|
1400
|
+
const child = this.registry.get(field.resource);
|
|
1401
|
+
const foreign = child.fields[field.foreignKey].column ?? columnName(field.foreignKey);
|
|
1402
|
+
const lines = await trx(child.name).where(foreign, id).whereNull('deleted_at').orderBy('id');
|
|
1403
|
+
for (const line of lines) {
|
|
1404
|
+
const copy = {
|
|
1405
|
+
created_by: actor.id,
|
|
1406
|
+
updated_by: actor.id,
|
|
1407
|
+
[foreign]: row.id,
|
|
1408
|
+
};
|
|
1409
|
+
if (child.scoped)
|
|
1410
|
+
copy.org_unit_id = line.org_unit_id;
|
|
1411
|
+
if (child.version)
|
|
1412
|
+
copy.version = 1;
|
|
1413
|
+
for (const [key, childField] of Object.entries(child.fields)) {
|
|
1414
|
+
const column = childField.column ?? columnName(key);
|
|
1415
|
+
if (key === field.foreignKey ||
|
|
1416
|
+
childField.type === 'hasMany' ||
|
|
1417
|
+
childField.type === 'attachment' ||
|
|
1418
|
+
!(column in line))
|
|
1419
|
+
continue;
|
|
1420
|
+
copy[column] = childField.sequence
|
|
1421
|
+
? await sequence(trx, childField.sequence)
|
|
1422
|
+
: childField.type === 'json'
|
|
1423
|
+
? JSON.stringify(line[column])
|
|
1424
|
+
: line[column];
|
|
1425
|
+
}
|
|
1426
|
+
const [inserted] = await trx(child.name).insert(copy).returning('id');
|
|
1427
|
+
await this.audit(trx, child, actor, 'create', { id: inserted.id }, Object.keys(child.fields));
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
await this.audit(trx, resource, actor, 'amend', saved, copied);
|
|
1431
|
+
return serialize(resource, await this.hydrateOne(trx, resource, saved), ability, actor);
|
|
1432
|
+
};
|
|
1433
|
+
return transaction ? work(transaction) : this.db.transaction(work);
|
|
1434
|
+
}
|
|
1435
|
+
async audit(trx, resource, actor, action, record, fields, changes, system) {
|
|
773
1436
|
await recordMutation(trx, {
|
|
1437
|
+
...(system ? { system: true, reason: system.reason } : {}),
|
|
774
1438
|
module: this.registry.owner(resource.name),
|
|
775
1439
|
resource: resource.name,
|
|
776
1440
|
id: record.id,
|
|
@@ -778,6 +1442,7 @@ export class ResourceService {
|
|
|
778
1442
|
impersonatorId: actor.impersonatorId,
|
|
779
1443
|
action,
|
|
780
1444
|
fields,
|
|
1445
|
+
changes,
|
|
781
1446
|
});
|
|
782
1447
|
}
|
|
783
1448
|
}
|