@adula/kit 1.0.0 → 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/build/agent/AGENTS.template.md +2 -2
- package/build/agent/capabilities.md +10 -5
- package/build/agent/skills/adula-frontend-design/SKILL.md +1 -1
- 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.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/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 +14 -5
- package/build/index.js +6 -2
- package/build/src/admin/contracts.d.ts +7 -0
- package/build/src/admin/contracts.js +15 -2
- package/build/src/admin/controller.d.ts +1 -0
- package/build/src/admin/controller.js +46 -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 +163 -2
- package/build/src/admin/resource_service.js +552 -43
- 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 +54 -3
- package/build/src/collaboration/assignments.js +125 -11
- package/build/src/collaboration/record_collaboration.js +5 -17
- package/build/src/commands/capabilities.js +22 -3
- 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 +4 -1
- package/build/src/core/message_templates.js +7 -2
- 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 +27 -0
- package/build/src/database/schema.js +98 -0
- package/build/src/eslint/index.js +26 -0
- package/build/src/events/record_mutation.d.ts +4 -0
- package/build/src/events/record_mutation.js +2 -0
- package/build/src/integrations/imports.js +4 -2
- package/build/src/integrations/inbound_webhooks.d.ts +94 -0
- package/build/src/integrations/inbound_webhooks.js +276 -0
- package/build/src/integrations/openapi.js +69 -2
- package/build/src/integrations/print.js +1 -0
- package/build/src/resource/define_resource.js +12 -0
- package/build/src/resource/registry.d.ts +1 -0
- package/build/src/resource/registry.js +33 -0
- package/build/src/resource/types.d.ts +64 -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 +30 -1
- package/build/src/workflows/define_workflow.js +44 -4
- package/build/src/workflows/engine.d.ts +24 -2
- package/build/src/workflows/engine.js +89 -24
- package/build/stubs/resource_contract.txt +103 -41
- package/package.json +1 -1
|
@@ -2,16 +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';
|
|
11
13
|
/** Per-process cache of planner row estimates for identical list queries. */
|
|
12
14
|
const estimates = new Map();
|
|
13
15
|
const ESTIMATE_TTL_MS = 30_000;
|
|
14
16
|
const ESTIMATE_CACHE_LIMIT = 500;
|
|
17
|
+
const AGGREGATE_GROUP_LIMIT = 1000;
|
|
15
18
|
/**
|
|
16
19
|
* Whether an actor may sort, filter or search by a field. Querying reveals values
|
|
17
20
|
* indirectly, so it needs unconditional view access at the field's level.
|
|
@@ -53,6 +56,10 @@ export class ResourceService {
|
|
|
53
56
|
label: resource.label.ar,
|
|
54
57
|
href: `/resources/${resource.name}`,
|
|
55
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),
|
|
56
63
|
}));
|
|
57
64
|
}
|
|
58
65
|
describe(name, actor) {
|
|
@@ -75,6 +82,8 @@ export class ResourceService {
|
|
|
75
82
|
return {
|
|
76
83
|
name,
|
|
77
84
|
label: resource.label.ar,
|
|
85
|
+
recordLabel: resource.recordLabel?.ar ?? null,
|
|
86
|
+
createLabel: resource.createLabel?.ar ?? null,
|
|
78
87
|
fields,
|
|
79
88
|
list: resource.list.filter((key) => visible.has(key)),
|
|
80
89
|
show: resource.show.filter((key) => visible.has(key)),
|
|
@@ -162,6 +171,19 @@ export class ResourceService {
|
|
|
162
171
|
throw new KitError(404, 'E_NOT_FOUND', 'السجل غير موجود');
|
|
163
172
|
return fromRow(row, resource);
|
|
164
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
|
+
}
|
|
165
187
|
requireRecord(ability, actor, resource, action, record) {
|
|
166
188
|
if (resource.scoped && !inOrgScope(record.orgPath, actor.orgPaths))
|
|
167
189
|
throw new KitError(404, 'E_NOT_FOUND', 'السجل غير موجود');
|
|
@@ -318,10 +340,128 @@ export class ResourceService {
|
|
|
318
340
|
canQueryField(resource, actor, ability, key) {
|
|
319
341
|
return canQueryField(resource, actor, ability, key);
|
|
320
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
|
+
}
|
|
321
444
|
async preload(resource, records, actor) {
|
|
322
445
|
const related = {};
|
|
323
446
|
const ability = buildAbility(actor.rules, this.registry.all());
|
|
324
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
|
+
}
|
|
325
465
|
if (field.type !== 'belongsTo' || !records.length)
|
|
326
466
|
continue;
|
|
327
467
|
const target = this.registry.get(field.resource);
|
|
@@ -338,10 +478,61 @@ export class ResourceService {
|
|
|
338
478
|
query.select('ou.path as org_path');
|
|
339
479
|
const rows = await query;
|
|
340
480
|
const targets = await this.hydrate(this.db, target, rows.map((r) => fromRow(r, target)));
|
|
341
|
-
|
|
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
|
+
});
|
|
342
487
|
}
|
|
343
488
|
return related;
|
|
344
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
|
+
}
|
|
345
536
|
async show(name, id, actor) {
|
|
346
537
|
const resource = this.registry.get(name);
|
|
347
538
|
const ability = this.authorizeAction(resource, actor, 'view');
|
|
@@ -355,6 +546,8 @@ export class ResourceService {
|
|
|
355
546
|
};
|
|
356
547
|
}
|
|
357
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);
|
|
358
551
|
const editor = await this.editor(name, actor, options.id);
|
|
359
552
|
const field = editor.fields.find((entry) => entry.key === key);
|
|
360
553
|
if (!field || field.type !== 'belongsTo')
|
|
@@ -366,20 +559,107 @@ export class ResourceService {
|
|
|
366
559
|
limit: 50,
|
|
367
560
|
estimate: false,
|
|
368
561
|
});
|
|
562
|
+
const title = await this.titler(target);
|
|
369
563
|
return {
|
|
370
564
|
data: page.data.map((row) => ({
|
|
371
565
|
value: String(row.id),
|
|
372
|
-
label:
|
|
566
|
+
label: title(row) ?? `#${row.id}`,
|
|
373
567
|
})),
|
|
374
568
|
nextCursor: page.meta.nextCursor,
|
|
375
569
|
};
|
|
376
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
|
+
}
|
|
377
651
|
/** Deferred relation reads re-authorize the parent and each child on every request. */
|
|
378
652
|
async children(name, id, actor) {
|
|
379
653
|
const resource = this.registry.get(name);
|
|
380
654
|
const ability = this.authorizeAction(resource, actor, 'view');
|
|
381
655
|
const parent = await this.find(this.db, resource, actor, ability, id);
|
|
382
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);
|
|
383
663
|
const result = {};
|
|
384
664
|
for (const key of resource.show) {
|
|
385
665
|
const field = resource.fields[key];
|
|
@@ -426,6 +706,38 @@ export class ResourceService {
|
|
|
426
706
|
const ability = this.authorizeAction(resource, actor, 'view');
|
|
427
707
|
const record = await this.find(this.db, resource, actor, ability, id);
|
|
428
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) {
|
|
429
741
|
const rows = await this.db('activities as a')
|
|
430
742
|
.leftJoin('users as u', 'u.id', 'a.actor_id')
|
|
431
743
|
.where({ 'a.resource': name, 'a.record_id': id })
|
|
@@ -447,7 +759,13 @@ export class ResourceService {
|
|
|
447
759
|
createdAt: new Date(row.created_at).toISOString(),
|
|
448
760
|
}));
|
|
449
761
|
}
|
|
450
|
-
|
|
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 = {}) {
|
|
451
769
|
const resource = this.registry.get(name);
|
|
452
770
|
const action = id === undefined ? 'create' : 'update';
|
|
453
771
|
const ability = this.authorizeAction(resource, actor, action);
|
|
@@ -521,9 +839,10 @@ export class ResourceService {
|
|
|
521
839
|
if (ability.can('view', target.name)) {
|
|
522
840
|
relationSearch[field.key] = this.describe(target.name, actor).searchable;
|
|
523
841
|
const page = await this.list(target.name, actor, { limit: 50, estimate: false });
|
|
842
|
+
const title = await this.titler(target);
|
|
524
843
|
options[field.key] = page.data.map((row) => ({
|
|
525
844
|
value: String(row.id),
|
|
526
|
-
label:
|
|
845
|
+
label: title(row) ?? `#${row.id}`,
|
|
527
846
|
}));
|
|
528
847
|
const selected = record?.[field.key];
|
|
529
848
|
if (selected !== null &&
|
|
@@ -533,7 +852,7 @@ export class ResourceService {
|
|
|
533
852
|
const current = await this.show(target.name, Number(selected), actor);
|
|
534
853
|
options[field.key].push({
|
|
535
854
|
value: String(current.data.id),
|
|
536
|
-
label:
|
|
855
|
+
label: title(current.data) ?? `#${current.data.id}`,
|
|
537
856
|
});
|
|
538
857
|
}
|
|
539
858
|
catch (error) {
|
|
@@ -545,8 +864,24 @@ export class ResourceService {
|
|
|
545
864
|
else
|
|
546
865
|
options[field.key] = [];
|
|
547
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
|
+
}
|
|
548
883
|
}
|
|
549
|
-
const units = resource.scoped
|
|
884
|
+
const units = resource.scoped && !resource.scope
|
|
550
885
|
? await this.db('org_units')
|
|
551
886
|
.where((query) => {
|
|
552
887
|
if (!actor.orgPaths.length)
|
|
@@ -565,16 +900,78 @@ export class ResourceService {
|
|
|
565
900
|
if (current)
|
|
566
901
|
units.push(current);
|
|
567
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
|
+
}
|
|
568
960
|
return {
|
|
569
961
|
mode: action,
|
|
570
962
|
name,
|
|
571
963
|
label: resource.label.ar,
|
|
964
|
+
recordLabel: resource.recordLabel?.ar ?? null,
|
|
572
965
|
fields,
|
|
966
|
+
/** Initial values of a create form, already checked against the actor's access. */
|
|
967
|
+
defaults,
|
|
573
968
|
inline,
|
|
574
969
|
options,
|
|
575
970
|
relationSearch,
|
|
576
971
|
orgUnits: units.map((row) => ({ value: String(row.id), label: String(row.name) })),
|
|
577
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,
|
|
578
975
|
record: record
|
|
579
976
|
? serialize(resource, await this.hydrateOne(this.db, resource, record), ability, actor)
|
|
580
977
|
: null,
|
|
@@ -583,20 +980,84 @@ export class ResourceService {
|
|
|
583
980
|
async save(name, actor, input, id, transaction) {
|
|
584
981
|
return this.persist(name, actor, input, id, transaction);
|
|
585
982
|
}
|
|
586
|
-
|
|
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) {
|
|
587
1031
|
const resource = this.registry.get(name);
|
|
588
1032
|
const action = id === undefined ? 'create' : 'update';
|
|
589
|
-
|
|
590
|
-
const
|
|
591
|
-
|
|
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);
|
|
592
1046
|
const work = async (trx) => {
|
|
593
|
-
const existing = id === undefined ? {} : await
|
|
1047
|
+
const existing = id === undefined ? {} : await load(trx, resource, id, true);
|
|
594
1048
|
if (id !== undefined) {
|
|
595
|
-
|
|
596
|
-
|
|
1049
|
+
check(resource, action, existing);
|
|
1050
|
+
if (!system || system.version !== undefined)
|
|
1051
|
+
this.requireVersion(resource, existing, system ? system.version : input.version);
|
|
597
1052
|
if (resource.submittable && existing.docStatus !== 0)
|
|
598
1053
|
throw new KitError(409, 'E_DOCUMENT_LOCKED', 'Only draft documents can be edited');
|
|
599
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
|
+
}
|
|
600
1061
|
const candidate = {
|
|
601
1062
|
...existing,
|
|
602
1063
|
...validated,
|
|
@@ -606,19 +1067,38 @@ export class ResourceService {
|
|
|
606
1067
|
if (resource.submittable)
|
|
607
1068
|
candidate.docStatus = existing.docStatus ?? 0;
|
|
608
1069
|
this.normalizeValues(resource, candidate);
|
|
609
|
-
|
|
610
|
-
|
|
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;
|
|
611
1086
|
const unit = await trx('org_units')
|
|
612
1087
|
.where('id', Number(candidate.orgUnitId) || -1)
|
|
613
1088
|
.first('path');
|
|
1089
|
+
if (system && !unit)
|
|
1090
|
+
throw new KitError(422, 'E_ORG_UNIT', 'الوحدة التنظيمية غير موجودة');
|
|
614
1091
|
candidate.orgPath = unit?.path;
|
|
615
|
-
}
|
|
616
|
-
|
|
1092
|
+
};
|
|
1093
|
+
await locate();
|
|
1094
|
+
check(resource, action, candidate);
|
|
617
1095
|
// Validators may add fields (defaults, transforms); those are written too, so check them.
|
|
618
|
-
const written = new Set(
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
1096
|
+
const written = new Set(system
|
|
1097
|
+
? []
|
|
1098
|
+
: [
|
|
1099
|
+
...Object.keys(form),
|
|
1100
|
+
...Object.keys(validated).filter((key) => key in resource.fields),
|
|
1101
|
+
]);
|
|
622
1102
|
for (const key of written) {
|
|
623
1103
|
const field = resource.fields[key];
|
|
624
1104
|
if (!ability.can(action, subject(name, candidate), key) ||
|
|
@@ -635,6 +1115,16 @@ export class ResourceService {
|
|
|
635
1115
|
const context = { trx, userId: actor.id, action };
|
|
636
1116
|
await resource.hooks?.beforeSave?.(candidate, context);
|
|
637
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
|
+
}
|
|
638
1128
|
for (const [key, field] of Object.entries(resource.fields)) {
|
|
639
1129
|
if (field.required &&
|
|
640
1130
|
field.type !== 'hasMany' &&
|
|
@@ -642,8 +1132,23 @@ export class ResourceService {
|
|
|
642
1132
|
throw new KitError(422, 'E_REQUIRED', `Required field: ${key}`);
|
|
643
1133
|
if (field.type === 'belongsTo' && candidate[key] !== null && candidate[key] !== undefined) {
|
|
644
1134
|
const related = this.registry.get(field.resource);
|
|
645
|
-
const target = await
|
|
646
|
-
|
|
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}: اختر مستخدماً نشطاً من الوحدة التنظيمية للسجل أو الوحدات الأعلى منها`);
|
|
647
1152
|
}
|
|
648
1153
|
if (field.type === 'lookup' && candidate[key] !== null && candidate[key] !== undefined) {
|
|
649
1154
|
if (!(await trx('lookups')
|
|
@@ -665,15 +1170,9 @@ export class ResourceService {
|
|
|
665
1170
|
scoped: resource.scoped,
|
|
666
1171
|
});
|
|
667
1172
|
}
|
|
668
|
-
if (resource.scoped) {
|
|
669
|
-
const unit = await trx('org_units')
|
|
670
|
-
.where('id', Number(candidate.orgUnitId) || -1)
|
|
671
|
-
.first('path');
|
|
672
|
-
candidate.orgPath = unit?.path;
|
|
673
|
-
}
|
|
674
1173
|
// Hooks may calculate fields, but may never move a record past authorization.
|
|
675
|
-
|
|
676
|
-
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));
|
|
677
1176
|
if (id !== undefined &&
|
|
678
1177
|
resource.scoped &&
|
|
679
1178
|
Number(candidate.orgUnitId) !== Number(existing.orgUnitId)) {
|
|
@@ -756,7 +1255,9 @@ export class ResourceService {
|
|
|
756
1255
|
await this.persist(field.resource, actor, {
|
|
757
1256
|
...data,
|
|
758
1257
|
[field.foreignKey]: row.id,
|
|
759
|
-
...(childResource.scoped
|
|
1258
|
+
...(childResource.scoped && !childResource.scope
|
|
1259
|
+
? { orgUnitId: candidate.orgUnitId }
|
|
1260
|
+
: {}),
|
|
760
1261
|
}, childId === undefined ? undefined : Number(childId), trx, { name, id: Number(row.id), action });
|
|
761
1262
|
}
|
|
762
1263
|
}
|
|
@@ -770,8 +1271,10 @@ export class ResourceService {
|
|
|
770
1271
|
if (JSON.stringify(before) !== JSON.stringify(after))
|
|
771
1272
|
changes.push({ field: key, before, after });
|
|
772
1273
|
}
|
|
773
|
-
await this.audit(trx, resource, actor, action, saved, Object.keys(form), changes);
|
|
1274
|
+
await this.audit(trx, resource, actor, action, saved, Object.keys(form), changes, system);
|
|
774
1275
|
await resource.hooks?.afterSave?.(saved, context);
|
|
1276
|
+
if (system)
|
|
1277
|
+
return (await this.hydrateOne(trx, resource, saved));
|
|
775
1278
|
return serialize(resource, await this.hydrateOne(trx, resource, saved), ability, actor);
|
|
776
1279
|
};
|
|
777
1280
|
return transaction ? work(transaction) : this.db.transaction(work);
|
|
@@ -781,7 +1284,7 @@ export class ResourceService {
|
|
|
781
1284
|
throw new KitError(409, 'E_VERSION_CONFLICT', 'تم تعديل السجل بواسطة مستخدم آخر. حدّث الصفحة وحاول مجدداً.');
|
|
782
1285
|
}
|
|
783
1286
|
/** Inline children share the parent's draft state, organization and update authority. */
|
|
784
|
-
async requireInlineParents(trx, resource, actor, candidate, existing = {}, parentWrite) {
|
|
1287
|
+
async requireInlineParents(trx, resource, actor, candidate, existing = {}, parentWrite, system = false) {
|
|
785
1288
|
for (const parent of this.registry.all()) {
|
|
786
1289
|
for (const [key, field] of Object.entries(parent.fields)) {
|
|
787
1290
|
if (field.type !== 'hasMany' || !field.inline || field.resource !== resource.name)
|
|
@@ -795,13 +1298,18 @@ export class ResourceService {
|
|
|
795
1298
|
const action = parentWrite?.name === parent.name && parentWrite.id === Number(parentId)
|
|
796
1299
|
? parentWrite.action
|
|
797
1300
|
: 'update';
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
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
|
+
}
|
|
805
1313
|
if (parent.submittable && record.docStatus !== 0)
|
|
806
1314
|
throw new KitError(409, 'E_DOCUMENT_LOCKED', 'Only draft document lines can be edited');
|
|
807
1315
|
if (parent.scoped &&
|
|
@@ -924,8 +1432,9 @@ export class ResourceService {
|
|
|
924
1432
|
};
|
|
925
1433
|
return transaction ? work(transaction) : this.db.transaction(work);
|
|
926
1434
|
}
|
|
927
|
-
async audit(trx, resource, actor, action, record, fields, changes) {
|
|
1435
|
+
async audit(trx, resource, actor, action, record, fields, changes, system) {
|
|
928
1436
|
await recordMutation(trx, {
|
|
1437
|
+
...(system ? { system: true, reason: system.reason } : {}),
|
|
929
1438
|
module: this.registry.owner(resource.name),
|
|
930
1439
|
resource: resource.name,
|
|
931
1440
|
id: record.id,
|