@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.
Files changed (120) hide show
  1. package/README.md +12 -2
  2. package/build/agent/AGENTS.template.md +2 -2
  3. package/build/agent/capabilities.md +54 -7
  4. package/build/agent/skills/adula-frontend-design/SKILL.md +1 -1
  5. package/build/agent/skills/idea-review/SKILL.md +26 -1
  6. package/build/agent/skills/module-review/SKILL.md +22 -0
  7. package/build/agent/skills/perf-review/SKILL.md +24 -0
  8. package/build/agent/skills/schema-review/SKILL.md +22 -0
  9. package/build/agent/skills/security-review/SKILL.md +24 -0
  10. package/build/agent/skills/ui-review/SKILL.md +22 -0
  11. package/build/commands/capabilities.d.ts +4 -0
  12. package/build/commands/capabilities.js +35 -4
  13. package/build/commands/doctor.js +37 -1
  14. package/build/commands/gaps.js +16 -6
  15. package/build/commands/install.js +19 -5
  16. package/build/commands/main.d.ts +4 -2
  17. package/build/commands/main.js +2 -0
  18. package/build/commands/module_add.js +2 -2
  19. package/build/commands/resource.js +1 -1
  20. package/build/commands/resource_snapshot.d.ts +15 -0
  21. package/build/commands/resource_snapshot.js +61 -0
  22. package/build/database/migrations/1770000000004_kit_collaboration.d.ts +5 -0
  23. package/build/database/migrations/1770000000004_kit_collaboration.js +10 -0
  24. package/build/database/migrations/1770000000005_kit_assignments.d.ts +5 -0
  25. package/build/database/migrations/1770000000005_kit_assignments.js +10 -0
  26. package/build/database/migrations/1770000000006_kit_messaging.d.ts +5 -0
  27. package/build/database/migrations/1770000000006_kit_messaging.js +10 -0
  28. package/build/database/migrations/1770000000007_kit_webhooks.d.ts +5 -0
  29. package/build/database/migrations/1770000000007_kit_webhooks.js +10 -0
  30. package/build/database/migrations/1770000000008_kit_imports.d.ts +5 -0
  31. package/build/database/migrations/1770000000008_kit_imports.js +10 -0
  32. package/build/database/migrations/1770000000010_kit_workflows.d.ts +5 -0
  33. package/build/database/migrations/1770000000010_kit_workflows.js +10 -0
  34. package/build/database/migrations/1770000000011_kit_managed_assignments.d.ts +5 -0
  35. package/build/database/migrations/1770000000011_kit_managed_assignments.js +10 -0
  36. package/build/database/migrations/1770000000012_kit_role_keys.d.ts +5 -0
  37. package/build/database/migrations/1770000000012_kit_role_keys.js +10 -0
  38. package/build/database/migrations/1770000000013_kit_notification_targets.d.ts +5 -0
  39. package/build/database/migrations/1770000000013_kit_notification_targets.js +10 -0
  40. package/build/database/migrations/1770000000014_kit_upload_grants.d.ts +5 -0
  41. package/build/database/migrations/1770000000014_kit_upload_grants.js +10 -0
  42. package/build/database/migrations/1770000000015_kit_inbound_webhooks.d.ts +5 -0
  43. package/build/database/migrations/1770000000015_kit_inbound_webhooks.js +10 -0
  44. package/build/index.d.ts +31 -2
  45. package/build/index.js +17 -2
  46. package/build/src/admin/contracts.d.ts +7 -0
  47. package/build/src/admin/contracts.js +34 -12
  48. package/build/src/admin/controller.d.ts +2 -0
  49. package/build/src/admin/controller.js +52 -1
  50. package/build/src/admin/presentation.d.ts +6 -0
  51. package/build/src/admin/record_title.d.ts +14 -0
  52. package/build/src/admin/record_title.js +50 -0
  53. package/build/src/admin/resource_service.d.ts +184 -2
  54. package/build/src/admin/resource_service.js +713 -48
  55. package/build/src/attachments/attachment_service.d.ts +12 -0
  56. package/build/src/attachments/attachment_service.js +28 -2
  57. package/build/src/attachments/upload_grants.d.ts +44 -0
  58. package/build/src/attachments/upload_grants.js +105 -0
  59. package/build/src/auth/ability.d.ts +1 -1
  60. package/build/src/auth/ability.js +4 -1
  61. package/build/src/auth/actor_store.js +6 -1
  62. package/build/src/auth/conditions.d.ts +15 -0
  63. package/build/src/auth/conditions.js +36 -0
  64. package/build/src/auth/sql.js +6 -2
  65. package/build/src/collaboration/assignments.d.ts +129 -0
  66. package/build/src/collaboration/assignments.js +333 -0
  67. package/build/src/collaboration/record_collaboration.d.ts +86 -0
  68. package/build/src/collaboration/record_collaboration.js +348 -0
  69. package/build/src/commands/agent_assets.js +5 -0
  70. package/build/src/commands/capabilities.d.ts +19 -0
  71. package/build/src/commands/capabilities.js +179 -0
  72. package/build/src/commands/doctor.d.ts +31 -0
  73. package/build/src/commands/doctor.js +114 -0
  74. package/build/src/commands/gap_report.d.ts +50 -2
  75. package/build/src/commands/gap_report.js +102 -4
  76. package/build/src/commands/generator.js +3 -3
  77. package/build/src/commands/snapshot.d.ts +28 -0
  78. package/build/src/commands/snapshot.js +48 -0
  79. package/build/src/commands/source_markers.d.ts +10 -1
  80. package/build/src/commands/source_markers.js +36 -4
  81. package/build/src/core/administration_guard.js +3 -1
  82. package/build/src/core/message_templates.d.ts +83 -0
  83. package/build/src/core/message_templates.js +293 -0
  84. package/build/src/core/module_seed.d.ts +15 -0
  85. package/build/src/core/module_seed.js +31 -0
  86. package/build/src/core/notifications.d.ts +18 -1
  87. package/build/src/core/notifications.js +27 -1
  88. package/build/src/core/roles.d.ts +27 -1
  89. package/build/src/core/roles.js +133 -5
  90. package/build/src/database/schema.d.ts +45 -0
  91. package/build/src/database/schema.js +290 -0
  92. package/build/src/eslint/index.js +26 -0
  93. package/build/src/events/outbox.d.ts +1 -0
  94. package/build/src/events/outbox.js +1 -1
  95. package/build/src/events/record_mutation.d.ts +11 -0
  96. package/build/src/events/record_mutation.js +15 -2
  97. package/build/src/integrations/imports.d.ts +74 -0
  98. package/build/src/integrations/imports.js +333 -0
  99. package/build/src/integrations/inbound_webhooks.d.ts +94 -0
  100. package/build/src/integrations/inbound_webhooks.js +276 -0
  101. package/build/src/integrations/openapi.d.ts +39 -0
  102. package/build/src/integrations/openapi.js +323 -0
  103. package/build/src/integrations/print.d.ts +37 -0
  104. package/build/src/integrations/print.js +124 -0
  105. package/build/src/integrations/webhooks.d.ts +98 -0
  106. package/build/src/integrations/webhooks.js +298 -0
  107. package/build/src/resource/define_resource.d.ts +1 -0
  108. package/build/src/resource/define_resource.js +21 -1
  109. package/build/src/resource/registry.d.ts +3 -0
  110. package/build/src/resource/registry.js +42 -0
  111. package/build/src/resource/types.d.ts +67 -1
  112. package/build/src/resource/values.js +2 -1
  113. package/build/src/services/settings.d.ts +13 -1
  114. package/build/src/services/settings.js +9 -2
  115. package/build/src/workflows/define_workflow.d.ts +124 -0
  116. package/build/src/workflows/define_workflow.js +123 -0
  117. package/build/src/workflows/engine.d.ts +141 -0
  118. package/build/src/workflows/engine.js +752 -0
  119. package/build/stubs/resource_contract.txt +103 -41
  120. 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
- const estimate = await this.db.raw(`EXPLAIN (FORMAT JSON) ${compiled.sql}`, [
199
- ...compiled.bindings,
200
- ]);
201
- estimatedTotal = Number(estimate.rows[0]['QUERY PLAN'][0].Plan['Plan Rows']);
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
- related[key] = targets.map((r) => serialize(target, r, ability, actor));
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: String(row[target.list[0]] ?? row.id),
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
- async editor(name, actor, id) {
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: String(row[target.list[0]] ?? row.id),
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: String(current.data[target.list[0]] ?? current.data.id),
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
- async persist(name, actor, input, id, transaction, parentWrite) {
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
- const ability = this.authorizeAction(resource, actor, action);
530
- const form = writableInput(resource, input);
531
- const validated = await resource.validator.validate(form);
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 this.find(trx, resource, actor, ability, id, true);
1047
+ const existing = id === undefined ? {} : await load(trx, resource, id, true);
534
1048
  if (id !== undefined) {
535
- this.requireRecord(ability, actor, resource, action, existing);
536
- this.requireVersion(resource, existing, input.version);
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
- if (resource.scoped) {
550
- candidate.orgUnitId = input.orgUnitId ?? existing.orgUnitId;
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
- this.requireRecord(ability, actor, resource, action, candidate);
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
- ...Object.keys(form),
560
- ...Object.keys(validated).filter((key) => key in resource.fields),
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 this.find(trx, related, actor, ability, Number(candidate[key]));
586
- this.requireRecord(ability, actor, related, 'view', target);
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
- this.requireRecord(ability, actor, resource, action, candidate);
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 ? { orgUnitId: candidate.orgUnitId } : {}),
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
- await this.audit(trx, resource, actor, action, saved, Object.keys(form));
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
- const ability = this.authorizeAction(parent, actor, action);
729
- const record = await this.find(trx, parent, actor, ability, Number(parentId), true);
730
- this.requireRecord(ability, actor, parent, action, record);
731
- if (!ability.can(action, subject(parent.name, record), key) ||
732
- actor.permissionLevel <
733
- Math.max(field.permissionLevel ?? 0, parent.hidden?.includes(key) ? 1 : 0))
734
- throw new KitError(403, 'E_FIELD_FORBIDDEN', 'ليس لديك صلاحية تعديل البنود');
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
- async audit(trx, resource, actor, action, record, fields) {
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
  }