@adula/kit 0.2.0-alpha.1
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/LICENSE.md +10 -0
- package/README.md +31 -0
- package/build/agent/AGENTS.template.md +28 -0
- package/build/agent/capabilities.md +13 -0
- package/build/agent/skills/adula-frontend-design/SKILL.md +59 -0
- package/build/agent/skills/idea-review/SKILL.md +6 -0
- package/build/commands/capabilities.d.ts +6 -0
- package/build/commands/capabilities.js +9 -0
- package/build/commands/doctor.d.ts +9 -0
- package/build/commands/doctor.js +32 -0
- package/build/commands/gaps.d.ts +12 -0
- package/build/commands/gaps.js +46 -0
- package/build/commands/install.d.ts +9 -0
- package/build/commands/install.js +91 -0
- package/build/commands/main.d.ts +6 -0
- package/build/commands/main.js +27 -0
- package/build/commands/module_add.d.ts +8 -0
- package/build/commands/module_add.js +57 -0
- package/build/commands/module_remove.d.ts +10 -0
- package/build/commands/module_remove.js +94 -0
- package/build/commands/resource.d.ts +8 -0
- package/build/commands/resource.js +29 -0
- package/build/commands/storage_migrate.d.ts +12 -0
- package/build/commands/storage_migrate.js +39 -0
- package/build/commands/ui.d.ts +11 -0
- package/build/commands/ui.js +210 -0
- package/build/configure.d.ts +2 -0
- package/build/configure.js +79 -0
- package/build/database/migrations/1770000000000_kit_core.d.ts +5 -0
- package/build/database/migrations/1770000000000_kit_core.js +10 -0
- package/build/database/migrations/1770000000001_kit_attachments.d.ts +5 -0
- package/build/database/migrations/1770000000001_kit_attachments.js +10 -0
- package/build/database/migrations/1770000000002_kit_saved_views.d.ts +5 -0
- package/build/database/migrations/1770000000002_kit_saved_views.js +10 -0
- package/build/database/migrations/1770000000003_kit_user_invitations.d.ts +5 -0
- package/build/database/migrations/1770000000003_kit_user_invitations.js +18 -0
- package/build/index.d.ts +51 -0
- package/build/index.js +34 -0
- package/build/providers/kit_provider.d.ts +6 -0
- package/build/providers/kit_provider.js +10 -0
- package/build/src/admin/contracts.d.ts +12 -0
- package/build/src/admin/contracts.js +93 -0
- package/build/src/admin/controller.d.ts +30 -0
- package/build/src/admin/controller.js +116 -0
- package/build/src/admin/errors.d.ts +5 -0
- package/build/src/admin/errors.js +9 -0
- package/build/src/admin/presentation.d.ts +22 -0
- package/build/src/admin/presentation.js +1 -0
- package/build/src/admin/resource_service.d.ts +170 -0
- package/build/src/admin/resource_service.js +763 -0
- package/build/src/attachments/attachment_service.d.ts +71 -0
- package/build/src/attachments/attachment_service.js +182 -0
- package/build/src/attachments/doctor.d.ts +4 -0
- package/build/src/attachments/doctor.js +28 -0
- package/build/src/attachments/storage_migrate.d.ts +39 -0
- package/build/src/attachments/storage_migrate.js +64 -0
- package/build/src/auth/ability.d.ts +22 -0
- package/build/src/auth/ability.js +31 -0
- package/build/src/auth/actor_store.d.ts +14 -0
- package/build/src/auth/actor_store.js +65 -0
- package/build/src/auth/conditions.d.ts +11 -0
- package/build/src/auth/conditions.js +81 -0
- package/build/src/auth/packed_rules.d.ts +4 -0
- package/build/src/auth/packed_rules.js +13 -0
- package/build/src/auth/sql.d.ts +11 -0
- package/build/src/auth/sql.js +114 -0
- package/build/src/commands/agent_assets.d.ts +7 -0
- package/build/src/commands/agent_assets.js +54 -0
- package/build/src/commands/doctor.d.ts +11 -0
- package/build/src/commands/doctor.js +261 -0
- package/build/src/commands/generator.d.ts +1 -0
- package/build/src/commands/generator.js +70 -0
- package/build/src/commands/source_markers.d.ts +2 -0
- package/build/src/commands/source_markers.js +9 -0
- package/build/src/core/activity.d.ts +43 -0
- package/build/src/core/activity.js +77 -0
- package/build/src/core/administration_guard.d.ts +3 -0
- package/build/src/core/administration_guard.js +37 -0
- package/build/src/core/health.d.ts +42 -0
- package/build/src/core/health.js +44 -0
- package/build/src/core/mail_test.d.ts +18 -0
- package/build/src/core/mail_test.js +88 -0
- package/build/src/core/notifications.d.ts +25 -0
- package/build/src/core/notifications.js +65 -0
- package/build/src/core/org_units.d.ts +30 -0
- package/build/src/core/org_units.js +123 -0
- package/build/src/core/roles.d.ts +72 -0
- package/build/src/core/roles.js +319 -0
- package/build/src/core/saved_views.d.ts +37 -0
- package/build/src/core/saved_views.js +139 -0
- package/build/src/core/settings.d.ts +29 -0
- package/build/src/core/settings.js +113 -0
- package/build/src/core/setup.d.ts +14 -0
- package/build/src/core/setup.js +89 -0
- package/build/src/core/ui_preferences.d.ts +12 -0
- package/build/src/core/ui_preferences.js +30 -0
- package/build/src/core/user_invitations.d.ts +23 -0
- package/build/src/core/user_invitations.js +144 -0
- package/build/src/core/users.d.ts +44 -0
- package/build/src/core/users.js +182 -0
- package/build/src/database/configure.d.ts +3 -0
- package/build/src/database/configure.js +41 -0
- package/build/src/database/schema.d.ts +7 -0
- package/build/src/database/schema.js +248 -0
- package/build/src/eslint/index.d.ts +3 -0
- package/build/src/eslint/index.js +64 -0
- package/build/src/events/adonis_jobs.d.ts +9 -0
- package/build/src/events/adonis_jobs.js +13 -0
- package/build/src/events/outbox.d.ts +18 -0
- package/build/src/events/outbox.js +33 -0
- package/build/src/events/record_mutation.d.ts +11 -0
- package/build/src/events/record_mutation.js +45 -0
- package/build/src/mcp/tools.d.ts +8 -0
- package/build/src/mcp/tools.js +156 -0
- package/build/src/org/org_service.d.ts +2 -0
- package/build/src/org/org_service.js +16 -0
- package/build/src/resource/define_resource.d.ts +4 -0
- package/build/src/resource/define_resource.js +48 -0
- package/build/src/resource/registry.d.ts +9 -0
- package/build/src/resource/registry.js +59 -0
- package/build/src/resource/types.d.ts +80 -0
- package/build/src/resource/types.js +1 -0
- package/build/src/resource/values.d.ts +5 -0
- package/build/src/resource/values.js +87 -0
- package/build/src/services/backup.d.ts +14 -0
- package/build/src/services/backup.js +59 -0
- package/build/src/services/settings.d.ts +9 -0
- package/build/src/services/settings.js +23 -0
- package/build/src/version.d.ts +1 -0
- package/build/src/version.js +2 -0
- package/build/stubs/README.md +6 -0
- package/build/stubs/controller.stub +8 -0
- package/build/stubs/main.d.ts +5 -0
- package/build/stubs/main.js +5 -0
- package/build/stubs/main.ts +5 -0
- package/build/stubs/modules.stub +6 -0
- package/build/stubs/resource_contract.stub +1 -0
- package/build/stubs/resource_contract.txt +390 -0
- package/build/stubs/routes.stub +16 -0
- package/build/stubs/service.stub +9 -0
- package/package.json +104 -0
|
@@ -0,0 +1,763 @@
|
|
|
1
|
+
import { subject } from '@casl/ability';
|
|
2
|
+
import { columnName } from '../resource/define_resource.js';
|
|
3
|
+
import { buildAbility, canRecord, inOrgScope, } from '../auth/ability.js';
|
|
4
|
+
import { accessibleBy, conditionSql } from '../auth/sql.js';
|
|
5
|
+
import { fromRow, selectedFields, serialize, writableInput } from './contracts.js';
|
|
6
|
+
import { KitError } from './errors.js';
|
|
7
|
+
import { sequence } from '../services/settings.js';
|
|
8
|
+
import { recordMutation } from '../events/record_mutation.js';
|
|
9
|
+
import { fieldValue } from '../resource/values.js';
|
|
10
|
+
import { claimAttachment, isAttachmentId, loadAttachments, releaseAttachment, } from '../attachments/attachment_service.js';
|
|
11
|
+
export class ResourceService {
|
|
12
|
+
db;
|
|
13
|
+
registry;
|
|
14
|
+
constructor(db, registry) {
|
|
15
|
+
this.db = db;
|
|
16
|
+
this.registry = registry;
|
|
17
|
+
}
|
|
18
|
+
/** Project navigation is derived from registered resources and the current actor. */
|
|
19
|
+
navigation(actor) {
|
|
20
|
+
const ability = buildAbility(actor.rules, this.registry.all());
|
|
21
|
+
const children = new Set(this.registry
|
|
22
|
+
.all()
|
|
23
|
+
.flatMap((resource) => Object.values(resource.fields).flatMap((field) => field.type === 'hasMany' ? [field.resource] : [])));
|
|
24
|
+
return this.registry
|
|
25
|
+
.all()
|
|
26
|
+
.filter((resource) => !children.has(resource.name) &&
|
|
27
|
+
resource.actions.includes('view') &&
|
|
28
|
+
(!resource.scoped || actor.orgPaths.length > 0) &&
|
|
29
|
+
ability.can('view', resource.name))
|
|
30
|
+
.map((resource) => ({
|
|
31
|
+
name: resource.name,
|
|
32
|
+
label: resource.label.ar,
|
|
33
|
+
href: `/resources/${resource.name}`,
|
|
34
|
+
module: this.registry.owner(resource.name),
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
37
|
+
describe(name, actor) {
|
|
38
|
+
const resource = this.registry.get(name);
|
|
39
|
+
const ability = this.authorizeAction(resource, actor, 'view');
|
|
40
|
+
const serialized = new Set(resource.serialize ?? [...resource.list, ...resource.show]);
|
|
41
|
+
const fields = Object.entries(resource.fields)
|
|
42
|
+
.filter(([key, field]) => serialized.has(key) &&
|
|
43
|
+
actor.permissionLevel >=
|
|
44
|
+
Math.max(field.permissionLevel ?? 0, resource.hidden?.includes(key) ? 1 : 0) &&
|
|
45
|
+
ability.can('view', name, key))
|
|
46
|
+
.map(([key, field]) => ({
|
|
47
|
+
...field,
|
|
48
|
+
key,
|
|
49
|
+
sortable: Boolean(field.sortable && this.canQueryField(resource, actor, ability, key)),
|
|
50
|
+
filterable: Boolean(field.filterable && this.canQueryField(resource, actor, ability, key)),
|
|
51
|
+
}));
|
|
52
|
+
const visible = new Set(fields.map((field) => field.key));
|
|
53
|
+
const searchable = Object.entries(resource.fields).filter(([, field]) => field.searchable);
|
|
54
|
+
return {
|
|
55
|
+
name,
|
|
56
|
+
label: resource.label.ar,
|
|
57
|
+
fields,
|
|
58
|
+
list: resource.list.filter((key) => visible.has(key)),
|
|
59
|
+
show: resource.show.filter((key) => visible.has(key)),
|
|
60
|
+
searchable: searchable.length > 0 &&
|
|
61
|
+
searchable.every(([key]) => this.canQueryField(resource, actor, ability, key)),
|
|
62
|
+
canCreate: resource.actions.includes('create') && ability.can('create', name),
|
|
63
|
+
scoped: resource.scoped,
|
|
64
|
+
submittable: Boolean(resource.submittable),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
permissions(resource, record, actor, ability) {
|
|
68
|
+
return Object.fromEntries(resource.actions.map((action) => [
|
|
69
|
+
action,
|
|
70
|
+
canRecord(ability, actor, resource, action, record) &&
|
|
71
|
+
(!resource.submittable ||
|
|
72
|
+
!['update', 'delete', 'submit'].includes(action) ||
|
|
73
|
+
record.docStatus === 0) &&
|
|
74
|
+
(action !== 'cancel' || (resource.submittable && record.docStatus === 1)) &&
|
|
75
|
+
action !== 'amend',
|
|
76
|
+
]));
|
|
77
|
+
}
|
|
78
|
+
normalizeValues(resource, record) {
|
|
79
|
+
for (const [key, field] of Object.entries(resource.fields)) {
|
|
80
|
+
if (!(key in record))
|
|
81
|
+
continue;
|
|
82
|
+
try {
|
|
83
|
+
record[key] = fieldValue(field, record[key]);
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
throw new KitError(422, 'E_FIELD_VALUE', `${key}: ${error.message}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/** Stored attachment ids become summaries for readers; copies keep policy checks on raw rows. */
|
|
91
|
+
async hydrate(db, resource, records) {
|
|
92
|
+
const keys = Object.keys(resource.fields).filter((key) => resource.fields[key].type === 'attachment');
|
|
93
|
+
if (!keys.length || !records.length)
|
|
94
|
+
return records;
|
|
95
|
+
const ids = records.flatMap((record) => keys.map((key) => record[key]).filter(isAttachmentId));
|
|
96
|
+
const summaries = await loadAttachments(db, ids);
|
|
97
|
+
return records.map((record) => {
|
|
98
|
+
const copy = { ...record };
|
|
99
|
+
for (const key of keys)
|
|
100
|
+
if (key in copy)
|
|
101
|
+
copy[key] = isAttachmentId(copy[key]) ? (summaries.get(copy[key]) ?? null) : null;
|
|
102
|
+
return copy;
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
async hydrateOne(db, resource, record) {
|
|
106
|
+
const [hydrated] = await this.hydrate(db, resource, [record]);
|
|
107
|
+
return hydrated;
|
|
108
|
+
}
|
|
109
|
+
authorizeAction(resource, actor, action) {
|
|
110
|
+
const ability = buildAbility(actor.rules, this.registry.all());
|
|
111
|
+
if (!resource.actions.includes(action) || !ability.can(action, resource.name))
|
|
112
|
+
throw new KitError(403, 'E_FORBIDDEN', 'ليس لديك صلاحية لهذا الإجراء');
|
|
113
|
+
for (const rule of ability.rulesFor(action, resource.name))
|
|
114
|
+
conditionSql(rule.conditions, resource);
|
|
115
|
+
return ability;
|
|
116
|
+
}
|
|
117
|
+
scopedQuery(db, resource, actor) {
|
|
118
|
+
const query = db(`${resource.name} as r`).whereNull('r.deleted_at');
|
|
119
|
+
if (resource.scoped) {
|
|
120
|
+
query.join('org_units as ou', 'ou.id', 'r.org_unit_id').where((scope) => {
|
|
121
|
+
if (!actor.orgPaths.length)
|
|
122
|
+
scope.whereRaw('FALSE');
|
|
123
|
+
actor.orgPaths.forEach((path) => scope.orWhereRaw('ou.path <@ ?::ltree', [path]));
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return query;
|
|
127
|
+
}
|
|
128
|
+
columns(resource, ability, write = false, extra = []) {
|
|
129
|
+
return selectedFields(resource, ability, { write, extra }).map((key) => `r.${resource.fields[key]?.column ?? columnName(key)}`);
|
|
130
|
+
}
|
|
131
|
+
async find(db, resource, actor, ability, id, lock = false) {
|
|
132
|
+
const query = this.scopedQuery(db, resource, actor)
|
|
133
|
+
.where('r.id', id)
|
|
134
|
+
.select(this.columns(resource, ability, lock));
|
|
135
|
+
if (resource.scoped)
|
|
136
|
+
query.select('ou.path as org_path');
|
|
137
|
+
if (lock)
|
|
138
|
+
query.forUpdate('r');
|
|
139
|
+
const row = await query.first();
|
|
140
|
+
if (!row)
|
|
141
|
+
throw new KitError(404, 'E_NOT_FOUND', 'السجل غير موجود');
|
|
142
|
+
return fromRow(row, resource);
|
|
143
|
+
}
|
|
144
|
+
requireRecord(ability, actor, resource, action, record) {
|
|
145
|
+
if (resource.scoped && !inOrgScope(record.orgPath, actor.orgPaths))
|
|
146
|
+
throw new KitError(404, 'E_NOT_FOUND', 'السجل غير موجود');
|
|
147
|
+
if (!canRecord(ability, actor, resource, action, record))
|
|
148
|
+
throw new KitError(403, 'E_FORBIDDEN', 'ليس لديك صلاحية لهذا الإجراء');
|
|
149
|
+
}
|
|
150
|
+
async list(name, actor, options = {}) {
|
|
151
|
+
const resource = this.registry.get(name);
|
|
152
|
+
const ability = this.authorizeAction(resource, actor, 'view');
|
|
153
|
+
const limit = Math.max(1, Math.min(100, Math.floor(Number(options.limit) || 25)));
|
|
154
|
+
const sort = options.sort ?? 'id';
|
|
155
|
+
const direction = options.direction === 'desc' ? 'desc' : 'asc';
|
|
156
|
+
if (sort !== 'id' && !resource.fields[sort]?.sortable)
|
|
157
|
+
throw new KitError(422, 'E_SORT', 'Unsupported sort field');
|
|
158
|
+
// Sorting/filtering private fields would otherwise reveal their values indirectly.
|
|
159
|
+
if (sort !== 'id' && !this.canQueryField(resource, actor, ability, sort))
|
|
160
|
+
throw new KitError(403, 'E_FIELD_FORBIDDEN', 'Forbidden sort field');
|
|
161
|
+
const sortColumn = `r.${resource.fields[sort]?.column ?? columnName(sort)}`;
|
|
162
|
+
const query = accessibleBy(this.db(`${name} as r`).whereNull('r.deleted_at'), ability, actor, 'view', resource);
|
|
163
|
+
query.select(this.columns(resource, ability, false, [sort]));
|
|
164
|
+
if (resource.scoped)
|
|
165
|
+
query.select('ou.path as org_path');
|
|
166
|
+
if (options.search) {
|
|
167
|
+
const searchable = Object.entries(resource.fields).filter(([, f]) => f.searchable);
|
|
168
|
+
if (!searchable.length ||
|
|
169
|
+
searchable.some(([key]) => !this.canQueryField(resource, actor, ability, key)))
|
|
170
|
+
throw new KitError(403, 'E_SEARCH', 'Search is unavailable');
|
|
171
|
+
query.whereRaw("r.search_vector @@ plainto_tsquery('simple', ?)", [options.search]);
|
|
172
|
+
}
|
|
173
|
+
for (const [key, value] of Object.entries(options.filters ?? {})) {
|
|
174
|
+
if (!resource.fields[key]?.filterable || !this.canQueryField(resource, actor, ability, key))
|
|
175
|
+
throw new KitError(422, 'E_FILTER', 'Unsupported filter');
|
|
176
|
+
if (value !== null && !['string', 'number', 'boolean'].includes(typeof value))
|
|
177
|
+
throw new KitError(422, 'E_FILTER', 'Invalid filter value');
|
|
178
|
+
query.where(`r.${resource.fields[key].column ?? columnName(key)}`, value);
|
|
179
|
+
}
|
|
180
|
+
// Estimate the authorized, filtered query, never the deployment-wide table cardinality.
|
|
181
|
+
let estimatedTotal;
|
|
182
|
+
if (options.estimate !== false) {
|
|
183
|
+
const compiled = query.clone().clearSelect().select('r.id').toSQL();
|
|
184
|
+
const estimate = await this.db.raw(`EXPLAIN (FORMAT JSON) ${compiled.sql}`, [
|
|
185
|
+
...compiled.bindings,
|
|
186
|
+
]);
|
|
187
|
+
estimatedTotal = Number(estimate.rows[0]['QUERY PLAN'][0].Plan['Plan Rows']);
|
|
188
|
+
}
|
|
189
|
+
if (options.cursor) {
|
|
190
|
+
let cursor;
|
|
191
|
+
try {
|
|
192
|
+
cursor = JSON.parse(Buffer.from(options.cursor, 'base64url').toString());
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
throw new KitError(422, 'E_CURSOR', 'Invalid cursor');
|
|
196
|
+
}
|
|
197
|
+
if (cursor.sort !== sort ||
|
|
198
|
+
cursor.direction !== direction ||
|
|
199
|
+
!Number.isSafeInteger(cursor.id) ||
|
|
200
|
+
(cursor.value !== null && !['number', 'string', 'boolean'].includes(typeof cursor.value)))
|
|
201
|
+
throw new KitError(422, 'E_CURSOR', 'Invalid cursor');
|
|
202
|
+
query.where((q) => {
|
|
203
|
+
if (cursor.value === null)
|
|
204
|
+
q.whereNull(sortColumn).andWhere('r.id', direction === 'asc' ? '>' : '<', cursor.id);
|
|
205
|
+
else
|
|
206
|
+
q.where(sortColumn, direction === 'asc' ? '>' : '<', cursor.value)
|
|
207
|
+
.orWhere((same) => same
|
|
208
|
+
.where(sortColumn, cursor.value)
|
|
209
|
+
.andWhere('r.id', direction === 'asc' ? '>' : '<', cursor.id))
|
|
210
|
+
.orWhereNull(sortColumn);
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
query.orderBy(sortColumn, direction, 'last');
|
|
214
|
+
if (sort !== 'id')
|
|
215
|
+
query.orderBy('r.id', direction);
|
|
216
|
+
const rows = await query.limit(limit + 1);
|
|
217
|
+
const records = rows
|
|
218
|
+
.slice(0, limit)
|
|
219
|
+
.map((row) => fromRow(row, resource));
|
|
220
|
+
const last = records.at(-1);
|
|
221
|
+
if (estimatedTotal !== undefined) {
|
|
222
|
+
estimatedTotal =
|
|
223
|
+
!options.cursor && rows.length <= limit
|
|
224
|
+
? rows.length
|
|
225
|
+
: Math.max(estimatedTotal, rows.length);
|
|
226
|
+
}
|
|
227
|
+
const hydrated = await this.hydrate(this.db, resource, records);
|
|
228
|
+
return {
|
|
229
|
+
data: hydrated.map((record) => serialize(resource, record, ability, actor)),
|
|
230
|
+
permissions: Object.fromEntries(records.map((record) => [
|
|
231
|
+
String(record.id),
|
|
232
|
+
this.permissions(resource, record, actor, ability),
|
|
233
|
+
])),
|
|
234
|
+
meta: {
|
|
235
|
+
limit,
|
|
236
|
+
estimatedTotal,
|
|
237
|
+
nextCursor: rows.length > limit && last
|
|
238
|
+
? Buffer.from(JSON.stringify({ id: last.id, value: last[sort] ?? null, sort, direction })).toString('base64url')
|
|
239
|
+
: null,
|
|
240
|
+
},
|
|
241
|
+
related: await this.preload(resource, records, actor),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
canQueryField(resource, actor, ability, key) {
|
|
245
|
+
const level = Math.max(resource.fields[key].permissionLevel ?? 0, resource.hidden?.includes(key) ? 1 : 0);
|
|
246
|
+
// Conditional field rules cannot safely authorize a global sort/search.
|
|
247
|
+
return (actor.permissionLevel >= level &&
|
|
248
|
+
ability.can('view', resource.name, key) &&
|
|
249
|
+
!ability.rules.some((rule) => rule.conditions &&
|
|
250
|
+
(!rule.fields || rule.fields.includes(key)) &&
|
|
251
|
+
(rule.subject === resource.name || rule.subject === 'all')));
|
|
252
|
+
}
|
|
253
|
+
async preload(resource, records, actor) {
|
|
254
|
+
const related = {};
|
|
255
|
+
const ability = buildAbility(actor.rules, this.registry.all());
|
|
256
|
+
for (const [key, field] of Object.entries(resource.fields)) {
|
|
257
|
+
if (field.type !== 'belongsTo' || !records.length)
|
|
258
|
+
continue;
|
|
259
|
+
const target = this.registry.get(field.resource);
|
|
260
|
+
if (!ability.can('view', target.name))
|
|
261
|
+
continue;
|
|
262
|
+
const ids = records
|
|
263
|
+
.filter((record) => key in serialize(resource, record, ability, actor))
|
|
264
|
+
.map((record) => record[key])
|
|
265
|
+
.filter((v) => v !== null && v !== undefined);
|
|
266
|
+
if (!ids.length)
|
|
267
|
+
continue;
|
|
268
|
+
const query = accessibleBy(this.db(`${target.name} as r`).whereIn('r.id', ids).whereNull('r.deleted_at'), ability, actor, 'view', target).select(this.columns(target, ability));
|
|
269
|
+
if (target.scoped)
|
|
270
|
+
query.select('ou.path as org_path');
|
|
271
|
+
const rows = await query;
|
|
272
|
+
const targets = await this.hydrate(this.db, target, rows.map((r) => fromRow(r, target)));
|
|
273
|
+
related[key] = targets.map((r) => serialize(target, r, ability, actor));
|
|
274
|
+
}
|
|
275
|
+
return related;
|
|
276
|
+
}
|
|
277
|
+
async show(name, id, actor) {
|
|
278
|
+
const resource = this.registry.get(name);
|
|
279
|
+
const ability = this.authorizeAction(resource, actor, 'view');
|
|
280
|
+
const record = await this.find(this.db, resource, actor, ability, id);
|
|
281
|
+
this.requireRecord(ability, actor, resource, 'view', record);
|
|
282
|
+
const [hydrated] = await this.hydrate(this.db, resource, [record]);
|
|
283
|
+
return {
|
|
284
|
+
data: serialize(resource, hydrated, ability, actor),
|
|
285
|
+
permissions: this.permissions(resource, record, actor, ability),
|
|
286
|
+
related: await this.preload(resource, [record], actor),
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
async relationOptions(name, key, actor, options = {}) {
|
|
290
|
+
const editor = await this.editor(name, actor, options.id);
|
|
291
|
+
const field = editor.fields.find((entry) => entry.key === key);
|
|
292
|
+
if (!field || field.type !== 'belongsTo')
|
|
293
|
+
throw new KitError(403, 'E_FIELD_FORBIDDEN', 'ليس لديك صلاحية لهذا الحقل');
|
|
294
|
+
const target = this.registry.get(field.resource);
|
|
295
|
+
const page = await this.list(target.name, actor, {
|
|
296
|
+
search: options.search,
|
|
297
|
+
cursor: options.cursor,
|
|
298
|
+
limit: 50,
|
|
299
|
+
estimate: false,
|
|
300
|
+
});
|
|
301
|
+
return {
|
|
302
|
+
data: page.data.map((row) => ({
|
|
303
|
+
value: String(row.id),
|
|
304
|
+
label: String(row[target.list[0]] ?? row.id),
|
|
305
|
+
})),
|
|
306
|
+
nextCursor: page.meta.nextCursor,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
/** Deferred relation reads re-authorize the parent and each child on every request. */
|
|
310
|
+
async children(name, id, actor) {
|
|
311
|
+
const resource = this.registry.get(name);
|
|
312
|
+
const ability = this.authorizeAction(resource, actor, 'view');
|
|
313
|
+
const parent = await this.find(this.db, resource, actor, ability, id);
|
|
314
|
+
this.requireRecord(ability, actor, resource, 'view', parent);
|
|
315
|
+
const result = {};
|
|
316
|
+
for (const key of resource.show) {
|
|
317
|
+
const field = resource.fields[key];
|
|
318
|
+
if (field.type !== 'hasMany' ||
|
|
319
|
+
!ability.can('view', subject(name, parent), key) ||
|
|
320
|
+
actor.permissionLevel <
|
|
321
|
+
Math.max(field.permissionLevel ?? 0, resource.hidden?.includes(key) ? 1 : 0))
|
|
322
|
+
continue;
|
|
323
|
+
if (resource.serialize && !resource.serialize.includes(key))
|
|
324
|
+
continue;
|
|
325
|
+
const child = this.registry.get(field.resource);
|
|
326
|
+
if (!ability.can('view', child.name) || !child.actions.includes('view'))
|
|
327
|
+
continue;
|
|
328
|
+
const rows = await accessibleBy(this.db(`${child.name} as r`).whereNull('r.deleted_at'), ability, actor, 'view', child)
|
|
329
|
+
.where(`r.${child.fields[field.foreignKey].column ?? columnName(field.foreignKey)}`, id)
|
|
330
|
+
.select([...this.columns(child, ability), ...(child.scoped ? ['ou.path as org_path'] : [])])
|
|
331
|
+
.orderBy('r.id')
|
|
332
|
+
.limit(101);
|
|
333
|
+
const children = await this.hydrate(this.db, child, rows.slice(0, 100).map((row) => fromRow(row, child)));
|
|
334
|
+
result[key] = {
|
|
335
|
+
rows: children.map((row) => serialize(child, row, ability, actor)),
|
|
336
|
+
hasMore: rows.length > 100,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
return result;
|
|
340
|
+
}
|
|
341
|
+
/** Active lookup labels for the lookup fields the actor may read on this resource. */
|
|
342
|
+
async lookups(name, actor) {
|
|
343
|
+
const options = {};
|
|
344
|
+
for (const field of this.describe(name, actor).fields) {
|
|
345
|
+
if (field.type !== 'lookup')
|
|
346
|
+
continue;
|
|
347
|
+
const rows = await this.db('lookups')
|
|
348
|
+
.where({ group: field.group, active: true })
|
|
349
|
+
.orderBy(['sort', 'id'])
|
|
350
|
+
.select('key', 'label_ar');
|
|
351
|
+
options[field.key] = rows.map((row) => ({ value: row.key, label: row.label_ar }));
|
|
352
|
+
}
|
|
353
|
+
return options;
|
|
354
|
+
}
|
|
355
|
+
/** The last 50 recorded mutations of one record, after authorizing the record itself. */
|
|
356
|
+
async activity(name, id, actor) {
|
|
357
|
+
const resource = this.registry.get(name);
|
|
358
|
+
const ability = this.authorizeAction(resource, actor, 'view');
|
|
359
|
+
const record = await this.find(this.db, resource, actor, ability, id);
|
|
360
|
+
this.requireRecord(ability, actor, resource, 'view', record);
|
|
361
|
+
const rows = await this.db('activities as a')
|
|
362
|
+
.leftJoin('users as u', 'u.id', 'a.actor_id')
|
|
363
|
+
.where({ 'a.resource': name, 'a.record_id': id })
|
|
364
|
+
.orderBy('a.id', 'desc')
|
|
365
|
+
.limit(50)
|
|
366
|
+
.select('a.id', 'a.action', 'a.changes', 'a.actor_id', 'a.created_at', 'u.email as actor_email');
|
|
367
|
+
return rows.map((row) => ({
|
|
368
|
+
id: Number(row.id),
|
|
369
|
+
action: String(row.action),
|
|
370
|
+
fields: Array.isArray(row.changes?.fields) ? row.changes.fields : [],
|
|
371
|
+
actorId: Number(row.actor_id),
|
|
372
|
+
actorEmail: row.actor_email === null ? null : String(row.actor_email),
|
|
373
|
+
createdAt: new Date(row.created_at).toISOString(),
|
|
374
|
+
}));
|
|
375
|
+
}
|
|
376
|
+
async editor(name, actor, id) {
|
|
377
|
+
const resource = this.registry.get(name);
|
|
378
|
+
const action = id === undefined ? 'create' : 'update';
|
|
379
|
+
const ability = this.authorizeAction(resource, actor, action);
|
|
380
|
+
const record = id === undefined ? undefined : await this.find(this.db, resource, actor, ability, id);
|
|
381
|
+
if (record) {
|
|
382
|
+
this.requireRecord(ability, actor, resource, action, record);
|
|
383
|
+
if (resource.submittable && record.docStatus !== 0)
|
|
384
|
+
throw new KitError(409, 'E_DOCUMENT_LOCKED', 'Only draft documents can be edited');
|
|
385
|
+
}
|
|
386
|
+
const fields = resource.form
|
|
387
|
+
.filter((key) => {
|
|
388
|
+
const field = resource.fields[key];
|
|
389
|
+
return (!field.sequence &&
|
|
390
|
+
actor.permissionLevel >=
|
|
391
|
+
Math.max(field.permissionLevel ?? 0, resource.hidden?.includes(key) ? 1 : 0) &&
|
|
392
|
+
ability.can(action, record ? subject(name, record) : name, key));
|
|
393
|
+
})
|
|
394
|
+
.map((key) => ({ key, ...resource.fields[key] }));
|
|
395
|
+
const options = {};
|
|
396
|
+
const relationSearch = {};
|
|
397
|
+
const inline = {};
|
|
398
|
+
for (const field of fields) {
|
|
399
|
+
if (field.type === 'hasMany' && field.inline) {
|
|
400
|
+
const child = this.registry.get(field.resource);
|
|
401
|
+
const rows = record && ability.can('view', child.name)
|
|
402
|
+
? await accessibleBy(this.db(`${child.name} as r`).whereNull('r.deleted_at'), ability, actor, 'view', child)
|
|
403
|
+
.where(`r.${child.fields[field.foreignKey].column ?? columnName(field.foreignKey)}`, id)
|
|
404
|
+
.select([
|
|
405
|
+
...this.columns(child, ability),
|
|
406
|
+
...(child.scoped ? ['ou.path as org_path'] : []),
|
|
407
|
+
])
|
|
408
|
+
.orderBy('r.id')
|
|
409
|
+
.limit(101)
|
|
410
|
+
: [];
|
|
411
|
+
const records = rows.slice(0, 100).map((row) => fromRow(row, child));
|
|
412
|
+
const hydratedRows = await this.hydrate(this.db, child, records);
|
|
413
|
+
const childFields = child.form.filter((key) => key !== field.foreignKey &&
|
|
414
|
+
!child.fields[key].sequence &&
|
|
415
|
+
actor.permissionLevel >=
|
|
416
|
+
Math.max(child.fields[key].permissionLevel ?? 0, child.hidden?.includes(key) ? 1 : 0));
|
|
417
|
+
inline[field.key] = {
|
|
418
|
+
fields: childFields.map((key) => ({ key, ...child.fields[key] })),
|
|
419
|
+
createFields: childFields.filter((key) => ability.can('create', child.name, key)),
|
|
420
|
+
updateFields: Object.fromEntries(records.map((row) => [
|
|
421
|
+
String(row.id),
|
|
422
|
+
childFields.filter((key) => ability.can('update', subject(child.name, row), key)),
|
|
423
|
+
])),
|
|
424
|
+
rows: hydratedRows.map((row) => serialize(child, row, ability, actor)),
|
|
425
|
+
hasMore: rows.length > 100,
|
|
426
|
+
canCreate: child.actions.includes('create') && ability.can('create', child.name),
|
|
427
|
+
permissions: Object.fromEntries(records.map((row) => [
|
|
428
|
+
String(row.id),
|
|
429
|
+
{
|
|
430
|
+
update: child.actions.includes('update') &&
|
|
431
|
+
ability.can('update', subject(child.name, row)),
|
|
432
|
+
delete: child.actions.includes('delete') &&
|
|
433
|
+
ability.can('delete', subject(child.name, row)),
|
|
434
|
+
},
|
|
435
|
+
])),
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
if (field.type === 'lookup') {
|
|
439
|
+
const lookups = await this.db('lookups')
|
|
440
|
+
.where({ group: field.group, active: true })
|
|
441
|
+
.orderBy('id')
|
|
442
|
+
.select('key', 'label_ar');
|
|
443
|
+
options[field.key] = lookups.map((row) => ({ value: row.key, label: row.label_ar }));
|
|
444
|
+
}
|
|
445
|
+
else if (field.type === 'belongsTo') {
|
|
446
|
+
const target = this.registry.get(field.resource);
|
|
447
|
+
if (ability.can('view', target.name)) {
|
|
448
|
+
relationSearch[field.key] = this.describe(target.name, actor).searchable;
|
|
449
|
+
const page = await this.list(target.name, actor, { limit: 50, estimate: false });
|
|
450
|
+
options[field.key] = page.data.map((row) => ({
|
|
451
|
+
value: String(row.id),
|
|
452
|
+
label: String(row[target.list[0]] ?? row.id),
|
|
453
|
+
}));
|
|
454
|
+
const selected = record?.[field.key];
|
|
455
|
+
if (selected !== null &&
|
|
456
|
+
selected !== undefined &&
|
|
457
|
+
!options[field.key].some((option) => option.value === String(selected))) {
|
|
458
|
+
try {
|
|
459
|
+
const current = await this.show(target.name, Number(selected), actor);
|
|
460
|
+
options[field.key].push({
|
|
461
|
+
value: String(current.data.id),
|
|
462
|
+
label: String(current.data[target.list[0]] ?? current.data.id),
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
catch (error) {
|
|
466
|
+
if (!(error instanceof KitError) || ![403, 404].includes(error.status))
|
|
467
|
+
throw error;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
else
|
|
472
|
+
options[field.key] = [];
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
const units = resource.scoped
|
|
476
|
+
? await this.db('org_units')
|
|
477
|
+
.where((query) => {
|
|
478
|
+
if (!actor.orgPaths.length)
|
|
479
|
+
query.whereRaw('FALSE');
|
|
480
|
+
for (const path of actor.orgPaths)
|
|
481
|
+
query.orWhereRaw('path <@ ?::ltree', [path]);
|
|
482
|
+
})
|
|
483
|
+
.orderBy('path')
|
|
484
|
+
.limit(100)
|
|
485
|
+
.select('id', 'name')
|
|
486
|
+
: [];
|
|
487
|
+
if (record?.orgUnitId && !units.some((unit) => Number(unit.id) === Number(record.orgUnitId))) {
|
|
488
|
+
const current = await this.db('org_units')
|
|
489
|
+
.where('id', Number(record.orgUnitId))
|
|
490
|
+
.first('id', 'name');
|
|
491
|
+
if (current)
|
|
492
|
+
units.push(current);
|
|
493
|
+
}
|
|
494
|
+
return {
|
|
495
|
+
mode: action,
|
|
496
|
+
name,
|
|
497
|
+
label: resource.label.ar,
|
|
498
|
+
fields,
|
|
499
|
+
inline,
|
|
500
|
+
options,
|
|
501
|
+
relationSearch,
|
|
502
|
+
orgUnits: units.map((row) => ({ value: String(row.id), label: String(row.name) })),
|
|
503
|
+
scoped: resource.scoped,
|
|
504
|
+
record: record
|
|
505
|
+
? serialize(resource, await this.hydrateOne(this.db, resource, record), ability, actor)
|
|
506
|
+
: null,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
async save(name, actor, input, id, transaction) {
|
|
510
|
+
return this.persist(name, actor, input, id, transaction);
|
|
511
|
+
}
|
|
512
|
+
async persist(name, actor, input, id, transaction, parentWrite) {
|
|
513
|
+
const resource = this.registry.get(name);
|
|
514
|
+
const action = id === undefined ? 'create' : 'update';
|
|
515
|
+
const ability = this.authorizeAction(resource, actor, action);
|
|
516
|
+
const form = writableInput(resource, input);
|
|
517
|
+
const validated = await resource.validator.validate(form);
|
|
518
|
+
const work = async (trx) => {
|
|
519
|
+
const existing = id === undefined ? {} : await this.find(trx, resource, actor, ability, id, true);
|
|
520
|
+
if (id !== undefined) {
|
|
521
|
+
this.requireRecord(ability, actor, resource, action, existing);
|
|
522
|
+
this.requireVersion(resource, existing, input.version);
|
|
523
|
+
if (resource.submittable && existing.docStatus !== 0)
|
|
524
|
+
throw new KitError(409, 'E_DOCUMENT_LOCKED', 'Only draft documents can be edited');
|
|
525
|
+
}
|
|
526
|
+
const candidate = {
|
|
527
|
+
...existing,
|
|
528
|
+
...validated,
|
|
529
|
+
createdBy: existing.createdBy ?? actor.id,
|
|
530
|
+
updatedBy: actor.id,
|
|
531
|
+
};
|
|
532
|
+
if (resource.submittable)
|
|
533
|
+
candidate.docStatus = existing.docStatus ?? 0;
|
|
534
|
+
this.normalizeValues(resource, candidate);
|
|
535
|
+
if (resource.scoped) {
|
|
536
|
+
candidate.orgUnitId = input.orgUnitId ?? existing.orgUnitId;
|
|
537
|
+
const unit = await trx('org_units')
|
|
538
|
+
.where('id', Number(candidate.orgUnitId) || -1)
|
|
539
|
+
.first('path');
|
|
540
|
+
candidate.orgPath = unit?.path;
|
|
541
|
+
}
|
|
542
|
+
this.requireRecord(ability, actor, resource, action, candidate);
|
|
543
|
+
for (const key of Object.keys(form)) {
|
|
544
|
+
const field = resource.fields[key];
|
|
545
|
+
if (!ability.can(action, subject(name, candidate), key) ||
|
|
546
|
+
(id !== undefined && !ability.can(action, subject(name, existing), key)) ||
|
|
547
|
+
actor.permissionLevel <
|
|
548
|
+
Math.max(field.permissionLevel ?? 0, resource.hidden?.includes(key) ? 1 : 0))
|
|
549
|
+
throw new KitError(403, 'E_FIELD_FORBIDDEN', `Field is forbidden: ${key}`);
|
|
550
|
+
if (field.sequence)
|
|
551
|
+
throw new KitError(422, 'E_SEQUENCE_READONLY', 'Sequence fields are generated');
|
|
552
|
+
}
|
|
553
|
+
for (const [key, field] of Object.entries(resource.fields))
|
|
554
|
+
if (field.sequence && id === undefined)
|
|
555
|
+
candidate[key] = await sequence(trx, field.sequence);
|
|
556
|
+
const context = { trx, userId: actor.id, action };
|
|
557
|
+
await resource.hooks?.beforeSave?.(candidate, context);
|
|
558
|
+
this.normalizeValues(resource, candidate);
|
|
559
|
+
for (const [key, field] of Object.entries(resource.fields)) {
|
|
560
|
+
if (field.required &&
|
|
561
|
+
field.type !== 'hasMany' &&
|
|
562
|
+
(candidate[key] === null || candidate[key] === undefined))
|
|
563
|
+
throw new KitError(422, 'E_REQUIRED', `Required field: ${key}`);
|
|
564
|
+
if (field.type === 'belongsTo' && candidate[key] !== null && candidate[key] !== undefined) {
|
|
565
|
+
const related = this.registry.get(field.resource);
|
|
566
|
+
const target = await this.find(trx, related, actor, ability, Number(candidate[key]));
|
|
567
|
+
this.requireRecord(ability, actor, related, 'view', target);
|
|
568
|
+
}
|
|
569
|
+
if (field.type === 'lookup' && candidate[key] !== null && candidate[key] !== undefined) {
|
|
570
|
+
if (!(await trx('lookups')
|
|
571
|
+
.where({ group: field.group, key: candidate[key], active: true })
|
|
572
|
+
.first()))
|
|
573
|
+
throw new KitError(422, 'E_LOOKUP', `Invalid lookup: ${key}`);
|
|
574
|
+
}
|
|
575
|
+
if (field.type === 'attachment' &&
|
|
576
|
+
isAttachmentId(candidate[key]) &&
|
|
577
|
+
(candidate[key] !== existing[key] ||
|
|
578
|
+
(resource.scoped && Number(candidate.orgUnitId) !== Number(existing.orgUnitId))))
|
|
579
|
+
await claimAttachment(trx, {
|
|
580
|
+
attachmentId: candidate[key],
|
|
581
|
+
actor,
|
|
582
|
+
resource: name,
|
|
583
|
+
field: key,
|
|
584
|
+
recordId: id,
|
|
585
|
+
orgUnitId: resource.scoped ? Number(candidate.orgUnitId) : null,
|
|
586
|
+
scoped: resource.scoped,
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
if (resource.scoped) {
|
|
590
|
+
const unit = await trx('org_units')
|
|
591
|
+
.where('id', Number(candidate.orgUnitId) || -1)
|
|
592
|
+
.first('path');
|
|
593
|
+
candidate.orgPath = unit?.path;
|
|
594
|
+
}
|
|
595
|
+
// Hooks may calculate fields, but may never move a record past authorization.
|
|
596
|
+
this.requireRecord(ability, actor, resource, action, candidate);
|
|
597
|
+
await this.requireInlineParents(trx, resource, actor, candidate, existing, parentWrite);
|
|
598
|
+
if (id !== undefined &&
|
|
599
|
+
resource.scoped &&
|
|
600
|
+
Number(candidate.orgUnitId) !== Number(existing.orgUnitId)) {
|
|
601
|
+
for (const field of Object.values(resource.fields)) {
|
|
602
|
+
if (field.type !== 'hasMany' || !field.inline)
|
|
603
|
+
continue;
|
|
604
|
+
const child = this.registry.get(field.resource);
|
|
605
|
+
if (await trx(child.name)
|
|
606
|
+
.where(child.fields[field.foreignKey].column ?? columnName(field.foreignKey), id)
|
|
607
|
+
.whereNull('deleted_at')
|
|
608
|
+
.first('id'))
|
|
609
|
+
throw new KitError(422, 'E_INLINE_SCOPE', 'Cannot change organization while inline rows exist');
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
const values = { updated_by: actor.id, updated_at: trx.fn.now() };
|
|
613
|
+
if (id === undefined)
|
|
614
|
+
values.created_by = actor.id;
|
|
615
|
+
if (resource.scoped)
|
|
616
|
+
values.org_unit_id = candidate.orgUnitId;
|
|
617
|
+
if (resource.version)
|
|
618
|
+
values.version = id === undefined ? 1 : Number(existing.version) + 1;
|
|
619
|
+
for (const [key, field] of Object.entries(resource.fields)) {
|
|
620
|
+
if (field.type !== 'hasMany' && key in candidate)
|
|
621
|
+
values[field.column ?? columnName(key)] =
|
|
622
|
+
field.type === 'json' ? JSON.stringify(candidate[key]) : candidate[key];
|
|
623
|
+
}
|
|
624
|
+
const [row] = id === undefined
|
|
625
|
+
? await trx(name).insert(values).returning('*')
|
|
626
|
+
: await trx(name).where({ id }).update(values).returning('*');
|
|
627
|
+
const saved = { ...fromRow(row, resource), orgPath: candidate.orgPath };
|
|
628
|
+
for (const [key, field] of Object.entries(resource.fields)) {
|
|
629
|
+
if (field.type !== 'attachment' || !(key in candidate))
|
|
630
|
+
continue;
|
|
631
|
+
const next = isAttachmentId(candidate[key]) ? candidate[key] : null;
|
|
632
|
+
const previous = isAttachmentId(existing[key]) ? existing[key] : null;
|
|
633
|
+
if (next !== null && id === undefined)
|
|
634
|
+
await claimAttachment(trx, {
|
|
635
|
+
attachmentId: next,
|
|
636
|
+
actor,
|
|
637
|
+
resource: name,
|
|
638
|
+
field: key,
|
|
639
|
+
recordId: Number(row.id),
|
|
640
|
+
orgUnitId: resource.scoped ? Number(candidate.orgUnitId) : null,
|
|
641
|
+
scoped: resource.scoped,
|
|
642
|
+
});
|
|
643
|
+
if (previous !== null && previous !== next)
|
|
644
|
+
await releaseAttachment(trx, previous);
|
|
645
|
+
}
|
|
646
|
+
for (const [key, field] of Object.entries(resource.fields)) {
|
|
647
|
+
if (field.type !== 'hasMany' || !(key in form))
|
|
648
|
+
continue;
|
|
649
|
+
if (!field.inline || !Array.isArray(form[key]) || form[key].length > 100)
|
|
650
|
+
throw new KitError(422, 'E_INLINE', 'Invalid inline rows (maximum 100)');
|
|
651
|
+
const childResource = this.registry.get(field.resource);
|
|
652
|
+
const seen = new Set();
|
|
653
|
+
for (const child of form[key]) {
|
|
654
|
+
if (!child || typeof child !== 'object' || Array.isArray(child))
|
|
655
|
+
throw new KitError(422, 'E_INLINE', 'Inline rows must be objects');
|
|
656
|
+
const { id: childId, _delete: remove, ...data } = child;
|
|
657
|
+
if (remove !== undefined && typeof remove !== 'boolean')
|
|
658
|
+
throw new KitError(422, 'E_INLINE', 'Invalid deletion marker');
|
|
659
|
+
if (childId !== undefined) {
|
|
660
|
+
if (id === undefined ||
|
|
661
|
+
!Number.isSafeInteger(childId) ||
|
|
662
|
+
Number(childId) <= 0 ||
|
|
663
|
+
seen.has(Number(childId)))
|
|
664
|
+
throw new KitError(422, 'E_INLINE', 'Invalid or repeated inline record identifier');
|
|
665
|
+
seen.add(Number(childId));
|
|
666
|
+
const childAbility = this.authorizeAction(childResource, actor, remove ? 'delete' : 'update');
|
|
667
|
+
const owned = await this.find(trx, childResource, actor, childAbility, Number(childId), true);
|
|
668
|
+
if (Number(owned[field.foreignKey]) !== Number(row.id))
|
|
669
|
+
throw new KitError(404, 'E_NOT_FOUND', 'السجل غير موجود');
|
|
670
|
+
}
|
|
671
|
+
if (remove) {
|
|
672
|
+
if (childId === undefined)
|
|
673
|
+
throw new KitError(422, 'E_INLINE', 'Deleting an inline row requires its identifier');
|
|
674
|
+
await this.transition(field.resource, Number(childId), actor, 'delete', data.version, trx);
|
|
675
|
+
continue;
|
|
676
|
+
}
|
|
677
|
+
await this.persist(field.resource, actor, {
|
|
678
|
+
...data,
|
|
679
|
+
[field.foreignKey]: row.id,
|
|
680
|
+
...(childResource.scoped ? { orgUnitId: candidate.orgUnitId } : {}),
|
|
681
|
+
}, childId === undefined ? undefined : Number(childId), trx, { name, id: Number(row.id), action });
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
await this.audit(trx, resource, actor, action, saved, Object.keys(form));
|
|
685
|
+
await resource.hooks?.afterSave?.(saved, context);
|
|
686
|
+
return serialize(resource, await this.hydrateOne(trx, resource, saved), ability, actor);
|
|
687
|
+
};
|
|
688
|
+
return transaction ? work(transaction) : this.db.transaction(work);
|
|
689
|
+
}
|
|
690
|
+
requireVersion(resource, existing, version) {
|
|
691
|
+
if (resource.version && (!Number.isSafeInteger(version) || version !== existing.version))
|
|
692
|
+
throw new KitError(409, 'E_VERSION_CONFLICT', 'تم تعديل السجل بواسطة مستخدم آخر. حدّث الصفحة وحاول مجدداً.');
|
|
693
|
+
}
|
|
694
|
+
/** Inline children share the parent's draft state, organization and update authority. */
|
|
695
|
+
async requireInlineParents(trx, resource, actor, candidate, existing = {}, parentWrite) {
|
|
696
|
+
for (const parent of this.registry.all()) {
|
|
697
|
+
for (const [key, field] of Object.entries(parent.fields)) {
|
|
698
|
+
if (field.type !== 'hasMany' || !field.inline || field.resource !== resource.name)
|
|
699
|
+
continue;
|
|
700
|
+
const parentId = candidate[field.foreignKey];
|
|
701
|
+
if (parentId === undefined || parentId === null)
|
|
702
|
+
continue;
|
|
703
|
+
if (existing[field.foreignKey] !== undefined &&
|
|
704
|
+
Number(existing[field.foreignKey]) !== Number(parentId))
|
|
705
|
+
throw new KitError(422, 'E_INLINE_PARENT', 'Inline records cannot be moved to another parent');
|
|
706
|
+
const action = parentWrite?.name === parent.name && parentWrite.id === Number(parentId)
|
|
707
|
+
? parentWrite.action
|
|
708
|
+
: 'update';
|
|
709
|
+
const ability = this.authorizeAction(parent, actor, action);
|
|
710
|
+
const record = await this.find(trx, parent, actor, ability, Number(parentId), true);
|
|
711
|
+
this.requireRecord(ability, actor, parent, action, record);
|
|
712
|
+
if (!ability.can(action, subject(parent.name, record), key) ||
|
|
713
|
+
actor.permissionLevel <
|
|
714
|
+
Math.max(field.permissionLevel ?? 0, parent.hidden?.includes(key) ? 1 : 0))
|
|
715
|
+
throw new KitError(403, 'E_FIELD_FORBIDDEN', 'ليس لديك صلاحية تعديل البنود');
|
|
716
|
+
if (parent.submittable && record.docStatus !== 0)
|
|
717
|
+
throw new KitError(409, 'E_DOCUMENT_LOCKED', 'Only draft document lines can be edited');
|
|
718
|
+
if (parent.scoped &&
|
|
719
|
+
resource.scoped &&
|
|
720
|
+
Number(record.orgUnitId) !== Number(candidate.orgUnitId))
|
|
721
|
+
throw new KitError(422, 'E_INLINE_SCOPE', 'Inline records must use the parent organization');
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
async transition(name, id, actor, action, version, transaction) {
|
|
726
|
+
const resource = this.registry.get(name);
|
|
727
|
+
const ability = this.authorizeAction(resource, actor, action);
|
|
728
|
+
const work = async (trx) => {
|
|
729
|
+
const record = await this.find(trx, resource, actor, ability, id, true);
|
|
730
|
+
this.requireRecord(ability, actor, resource, action, record);
|
|
731
|
+
this.requireVersion(resource, record, version);
|
|
732
|
+
await this.requireInlineParents(trx, resource, actor, record);
|
|
733
|
+
const values = { updated_by: actor.id, updated_at: trx.fn.now() };
|
|
734
|
+
if (resource.version)
|
|
735
|
+
values.version = Number(record.version) + 1;
|
|
736
|
+
if (action === 'delete') {
|
|
737
|
+
if (resource.submittable && record.docStatus !== 0)
|
|
738
|
+
throw new KitError(409, 'E_DOCUMENT_LOCKED', 'Only drafts can be deleted');
|
|
739
|
+
values.deleted_at = trx.fn.now();
|
|
740
|
+
}
|
|
741
|
+
else {
|
|
742
|
+
if (!resource.submittable || record.docStatus !== (action === 'submit' ? 0 : 1))
|
|
743
|
+
throw new KitError(409, 'E_DOCUMENT_STATE', 'Invalid document transition');
|
|
744
|
+
values.doc_status = action === 'submit' ? 1 : 2;
|
|
745
|
+
}
|
|
746
|
+
const [row] = await trx(name).where({ id }).update(values).returning('*');
|
|
747
|
+
const saved = fromRow(row, resource);
|
|
748
|
+
await this.audit(trx, resource, actor, action, saved, []);
|
|
749
|
+
return serialize(resource, await this.hydrateOne(trx, resource, saved), ability, actor);
|
|
750
|
+
};
|
|
751
|
+
return transaction ? work(transaction) : this.db.transaction(work);
|
|
752
|
+
}
|
|
753
|
+
async audit(trx, resource, actor, action, record, fields) {
|
|
754
|
+
await recordMutation(trx, {
|
|
755
|
+
module: this.registry.owner(resource.name),
|
|
756
|
+
resource: resource.name,
|
|
757
|
+
id: record.id,
|
|
758
|
+
actorId: actor.id,
|
|
759
|
+
action,
|
|
760
|
+
fields,
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
}
|