@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.
Files changed (141) hide show
  1. package/LICENSE.md +10 -0
  2. package/README.md +31 -0
  3. package/build/agent/AGENTS.template.md +28 -0
  4. package/build/agent/capabilities.md +13 -0
  5. package/build/agent/skills/adula-frontend-design/SKILL.md +59 -0
  6. package/build/agent/skills/idea-review/SKILL.md +6 -0
  7. package/build/commands/capabilities.d.ts +6 -0
  8. package/build/commands/capabilities.js +9 -0
  9. package/build/commands/doctor.d.ts +9 -0
  10. package/build/commands/doctor.js +32 -0
  11. package/build/commands/gaps.d.ts +12 -0
  12. package/build/commands/gaps.js +46 -0
  13. package/build/commands/install.d.ts +9 -0
  14. package/build/commands/install.js +91 -0
  15. package/build/commands/main.d.ts +6 -0
  16. package/build/commands/main.js +27 -0
  17. package/build/commands/module_add.d.ts +8 -0
  18. package/build/commands/module_add.js +57 -0
  19. package/build/commands/module_remove.d.ts +10 -0
  20. package/build/commands/module_remove.js +94 -0
  21. package/build/commands/resource.d.ts +8 -0
  22. package/build/commands/resource.js +29 -0
  23. package/build/commands/storage_migrate.d.ts +12 -0
  24. package/build/commands/storage_migrate.js +39 -0
  25. package/build/commands/ui.d.ts +11 -0
  26. package/build/commands/ui.js +210 -0
  27. package/build/configure.d.ts +2 -0
  28. package/build/configure.js +79 -0
  29. package/build/database/migrations/1770000000000_kit_core.d.ts +5 -0
  30. package/build/database/migrations/1770000000000_kit_core.js +10 -0
  31. package/build/database/migrations/1770000000001_kit_attachments.d.ts +5 -0
  32. package/build/database/migrations/1770000000001_kit_attachments.js +10 -0
  33. package/build/database/migrations/1770000000002_kit_saved_views.d.ts +5 -0
  34. package/build/database/migrations/1770000000002_kit_saved_views.js +10 -0
  35. package/build/database/migrations/1770000000003_kit_user_invitations.d.ts +5 -0
  36. package/build/database/migrations/1770000000003_kit_user_invitations.js +18 -0
  37. package/build/index.d.ts +51 -0
  38. package/build/index.js +34 -0
  39. package/build/providers/kit_provider.d.ts +6 -0
  40. package/build/providers/kit_provider.js +10 -0
  41. package/build/src/admin/contracts.d.ts +12 -0
  42. package/build/src/admin/contracts.js +93 -0
  43. package/build/src/admin/controller.d.ts +30 -0
  44. package/build/src/admin/controller.js +116 -0
  45. package/build/src/admin/errors.d.ts +5 -0
  46. package/build/src/admin/errors.js +9 -0
  47. package/build/src/admin/presentation.d.ts +22 -0
  48. package/build/src/admin/presentation.js +1 -0
  49. package/build/src/admin/resource_service.d.ts +170 -0
  50. package/build/src/admin/resource_service.js +763 -0
  51. package/build/src/attachments/attachment_service.d.ts +71 -0
  52. package/build/src/attachments/attachment_service.js +182 -0
  53. package/build/src/attachments/doctor.d.ts +4 -0
  54. package/build/src/attachments/doctor.js +28 -0
  55. package/build/src/attachments/storage_migrate.d.ts +39 -0
  56. package/build/src/attachments/storage_migrate.js +64 -0
  57. package/build/src/auth/ability.d.ts +22 -0
  58. package/build/src/auth/ability.js +31 -0
  59. package/build/src/auth/actor_store.d.ts +14 -0
  60. package/build/src/auth/actor_store.js +65 -0
  61. package/build/src/auth/conditions.d.ts +11 -0
  62. package/build/src/auth/conditions.js +81 -0
  63. package/build/src/auth/packed_rules.d.ts +4 -0
  64. package/build/src/auth/packed_rules.js +13 -0
  65. package/build/src/auth/sql.d.ts +11 -0
  66. package/build/src/auth/sql.js +114 -0
  67. package/build/src/commands/agent_assets.d.ts +7 -0
  68. package/build/src/commands/agent_assets.js +54 -0
  69. package/build/src/commands/doctor.d.ts +11 -0
  70. package/build/src/commands/doctor.js +261 -0
  71. package/build/src/commands/generator.d.ts +1 -0
  72. package/build/src/commands/generator.js +70 -0
  73. package/build/src/commands/source_markers.d.ts +2 -0
  74. package/build/src/commands/source_markers.js +9 -0
  75. package/build/src/core/activity.d.ts +43 -0
  76. package/build/src/core/activity.js +77 -0
  77. package/build/src/core/administration_guard.d.ts +3 -0
  78. package/build/src/core/administration_guard.js +37 -0
  79. package/build/src/core/health.d.ts +42 -0
  80. package/build/src/core/health.js +44 -0
  81. package/build/src/core/mail_test.d.ts +18 -0
  82. package/build/src/core/mail_test.js +88 -0
  83. package/build/src/core/notifications.d.ts +25 -0
  84. package/build/src/core/notifications.js +65 -0
  85. package/build/src/core/org_units.d.ts +30 -0
  86. package/build/src/core/org_units.js +123 -0
  87. package/build/src/core/roles.d.ts +72 -0
  88. package/build/src/core/roles.js +319 -0
  89. package/build/src/core/saved_views.d.ts +37 -0
  90. package/build/src/core/saved_views.js +139 -0
  91. package/build/src/core/settings.d.ts +29 -0
  92. package/build/src/core/settings.js +113 -0
  93. package/build/src/core/setup.d.ts +14 -0
  94. package/build/src/core/setup.js +89 -0
  95. package/build/src/core/ui_preferences.d.ts +12 -0
  96. package/build/src/core/ui_preferences.js +30 -0
  97. package/build/src/core/user_invitations.d.ts +23 -0
  98. package/build/src/core/user_invitations.js +144 -0
  99. package/build/src/core/users.d.ts +44 -0
  100. package/build/src/core/users.js +182 -0
  101. package/build/src/database/configure.d.ts +3 -0
  102. package/build/src/database/configure.js +41 -0
  103. package/build/src/database/schema.d.ts +7 -0
  104. package/build/src/database/schema.js +248 -0
  105. package/build/src/eslint/index.d.ts +3 -0
  106. package/build/src/eslint/index.js +64 -0
  107. package/build/src/events/adonis_jobs.d.ts +9 -0
  108. package/build/src/events/adonis_jobs.js +13 -0
  109. package/build/src/events/outbox.d.ts +18 -0
  110. package/build/src/events/outbox.js +33 -0
  111. package/build/src/events/record_mutation.d.ts +11 -0
  112. package/build/src/events/record_mutation.js +45 -0
  113. package/build/src/mcp/tools.d.ts +8 -0
  114. package/build/src/mcp/tools.js +156 -0
  115. package/build/src/org/org_service.d.ts +2 -0
  116. package/build/src/org/org_service.js +16 -0
  117. package/build/src/resource/define_resource.d.ts +4 -0
  118. package/build/src/resource/define_resource.js +48 -0
  119. package/build/src/resource/registry.d.ts +9 -0
  120. package/build/src/resource/registry.js +59 -0
  121. package/build/src/resource/types.d.ts +80 -0
  122. package/build/src/resource/types.js +1 -0
  123. package/build/src/resource/values.d.ts +5 -0
  124. package/build/src/resource/values.js +87 -0
  125. package/build/src/services/backup.d.ts +14 -0
  126. package/build/src/services/backup.js +59 -0
  127. package/build/src/services/settings.d.ts +9 -0
  128. package/build/src/services/settings.js +23 -0
  129. package/build/src/version.d.ts +1 -0
  130. package/build/src/version.js +2 -0
  131. package/build/stubs/README.md +6 -0
  132. package/build/stubs/controller.stub +8 -0
  133. package/build/stubs/main.d.ts +5 -0
  134. package/build/stubs/main.js +5 -0
  135. package/build/stubs/main.ts +5 -0
  136. package/build/stubs/modules.stub +6 -0
  137. package/build/stubs/resource_contract.stub +1 -0
  138. package/build/stubs/resource_contract.txt +390 -0
  139. package/build/stubs/routes.stub +16 -0
  140. package/build/stubs/service.stub +9 -0
  141. package/package.json +104 -0
@@ -0,0 +1,319 @@
1
+ import { conditionSql } from '../auth/sql.js';
2
+ import { KitError } from '../admin/errors.js';
3
+ import { logActivity } from './activity.js';
4
+ import { administrationGuard } from './administration_guard.js';
5
+ const RESOURCE = 'core.roles';
6
+ const ACTION_ORDER = ['view', 'create', 'update', 'delete', 'submit', 'cancel', 'amend'];
7
+ export const ALL_SUBJECT_LABEL = { ar: 'كل الكيانات', en: 'All resources' };
8
+ function standardConditionFields(resource) {
9
+ const field = (key, ar, en) => ({
10
+ key,
11
+ label: { ar, en },
12
+ type: 'integer',
13
+ conditionable: true,
14
+ });
15
+ return [
16
+ field('id', 'المعرّف', 'ID'),
17
+ field('createdBy', 'أنشأه', 'Created by'),
18
+ field('updatedBy', 'عدّله', 'Updated by'),
19
+ ...(resource.scoped ? [field('orgUnitId', 'الوحدة التنظيمية', 'Organization unit')] : []),
20
+ ...(resource.submittable ? [field('docStatus', 'حالة المستند', 'Document status')] : []),
21
+ ...(resource.version ? [field('version', 'الإصدار', 'Version')] : []),
22
+ ];
23
+ }
24
+ function matrixFields(resource) {
25
+ return Object.entries(resource.fields).map(([key, field]) => ({
26
+ key,
27
+ label: field.label,
28
+ type: field.type,
29
+ conditionable: !['hasMany', 'json', 'attachment'].includes(field.type),
30
+ }));
31
+ }
32
+ function roleName(value) {
33
+ const name = typeof value === 'string' ? value.trim() : '';
34
+ if (!name || name.length > 100)
35
+ throw new KitError(422, 'E_ROLE_NAME', 'اسم الدور مطلوب (حتى 100 حرف)');
36
+ return name;
37
+ }
38
+ function permissionLevel(value) {
39
+ const level = Number(value ?? 0);
40
+ if (!Number.isSafeInteger(level) || level < 0 || level > 9)
41
+ throw new KitError(422, 'E_ROLE_LEVEL', 'مستوى الصلاحية رقم بين 0 و9');
42
+ return level;
43
+ }
44
+ function toRule(row) {
45
+ return {
46
+ id: Number(row.id),
47
+ subject: String(row.subject),
48
+ action: String(row.action),
49
+ inverted: Boolean(row.inverted),
50
+ conditions: row.conditions ?? null,
51
+ fields: row.fields ?? null,
52
+ };
53
+ }
54
+ export class RolesAdmin {
55
+ db;
56
+ registry;
57
+ constructor(db, registry) {
58
+ this.db = db;
59
+ this.registry = registry;
60
+ }
61
+ async list() {
62
+ const rows = await this.db('roles as r')
63
+ .select('r.id', 'r.name', 'r.permission_level', this.db.raw('(SELECT count(*) FROM role_rules rr WHERE rr.role_id = r.id) AS rules'), this.db.raw('(SELECT count(DISTINCT ur.user_id) FROM user_roles ur WHERE ur.role_id = r.id) AS users'))
64
+ .orderBy('r.name');
65
+ return rows.map((row) => ({
66
+ id: row.id,
67
+ name: row.name,
68
+ permissionLevel: row.permission_level,
69
+ rules: Number(row.rules),
70
+ users: Number(row.users),
71
+ }));
72
+ }
73
+ async get(id) {
74
+ const role = await this.find(this.db, id);
75
+ const rules = await this.db('role_rules')
76
+ .where('role_id', id)
77
+ .orderBy(['subject', 'action', 'inverted']);
78
+ const users = await this.db('user_roles')
79
+ .where('role_id', id)
80
+ .countDistinct('user_id as count')
81
+ .first();
82
+ return {
83
+ id: role.id,
84
+ name: role.name,
85
+ permissionLevel: role.permission_level,
86
+ users: Number(users?.count ?? 0),
87
+ rules: rules.map(toRule),
88
+ };
89
+ }
90
+ async create(actorId, input) {
91
+ const name = roleName(input.name);
92
+ const level = permissionLevel(input.permissionLevel);
93
+ return this.db.transaction(async (trx) => {
94
+ if (await trx('roles').where('name', name).first())
95
+ throw new KitError(409, 'E_ROLE_EXISTS', 'يوجد دور بهذا الاسم');
96
+ const [role] = await trx('roles').insert({ name, permission_level: level }).returning('*');
97
+ await logActivity(trx, {
98
+ resource: RESOURCE,
99
+ recordId: role.id,
100
+ actorId,
101
+ action: 'create',
102
+ changes: { name, permissionLevel: level },
103
+ });
104
+ return { id: role.id, name, permissionLevel: level };
105
+ });
106
+ }
107
+ async rename(actorId, id, value) {
108
+ const name = roleName(value);
109
+ return this.db.transaction(async (trx) => {
110
+ const role = await this.find(trx, id);
111
+ if (role.name === 'administrator' && name !== role.name)
112
+ throw new KitError(409, 'E_BOOTSTRAP_ROLE', 'اسم دور مدير النظام الأساسي محمي لاستعادة الوصول عند الحاجة');
113
+ if (await trx('roles').where('name', name).whereNot('id', id).first())
114
+ throw new KitError(409, 'E_ROLE_EXISTS', 'يوجد دور بهذا الاسم');
115
+ await trx('roles').where('id', id).update({ name });
116
+ await logActivity(trx, {
117
+ resource: RESOURCE,
118
+ recordId: id,
119
+ actorId,
120
+ action: 'rename',
121
+ changes: { from: role.name, to: name },
122
+ });
123
+ });
124
+ }
125
+ async setPermissionLevel(actorId, id, value) {
126
+ const level = permissionLevel(value);
127
+ return this.db.transaction(async (trx) => {
128
+ const role = await this.find(trx, id);
129
+ await trx('roles').where('id', id).update({ permission_level: level });
130
+ await logActivity(trx, {
131
+ resource: RESOURCE,
132
+ recordId: id,
133
+ actorId,
134
+ action: 'set_level',
135
+ changes: { from: role.permission_level, to: level },
136
+ });
137
+ });
138
+ }
139
+ async delete(actorId, id) {
140
+ return this.db.transaction(async (trx) => {
141
+ const role = await this.find(trx, id);
142
+ const assigned = await trx('user_roles').where('role_id', id).first();
143
+ if (assigned)
144
+ throw new KitError(409, 'E_ROLE_IN_USE', 'لا يمكن حذف دور مسند إلى مستخدمين');
145
+ await trx('roles').where('id', id).delete();
146
+ await logActivity(trx, {
147
+ resource: RESOURCE,
148
+ recordId: id,
149
+ actorId,
150
+ action: 'delete',
151
+ changes: { name: role.name },
152
+ });
153
+ });
154
+ }
155
+ /** Subjects × actions derived from the registry; the UI never hard-codes resources. */
156
+ matrix() {
157
+ const resources = this.registry.all();
158
+ const union = ACTION_ORDER.filter((action) => resources.some((resource) => resource.actions.includes(action)));
159
+ return {
160
+ actions: [...union, 'invite', 'manage'],
161
+ subjects: [
162
+ {
163
+ name: 'all',
164
+ label: ALL_SUBJECT_LABEL,
165
+ actions: [...union, 'invite', 'manage'],
166
+ fields: [],
167
+ conditionFields: [],
168
+ },
169
+ {
170
+ name: 'core.users',
171
+ label: { ar: 'دعوات المستخدمين', en: 'User invitations' },
172
+ actions: ['invite', 'manage'],
173
+ fields: [],
174
+ conditionFields: [],
175
+ },
176
+ ...resources.map((resource) => {
177
+ const fields = matrixFields(resource);
178
+ return {
179
+ name: resource.name,
180
+ label: resource.label,
181
+ actions: [...ACTION_ORDER.filter((a) => resource.actions.includes(a)), 'manage'],
182
+ fields,
183
+ conditionFields: [
184
+ ...standardConditionFields(resource),
185
+ ...fields.filter((field) => field.conditionable),
186
+ ],
187
+ };
188
+ }),
189
+ ],
190
+ };
191
+ }
192
+ async setRule(actorId, roleId, input) {
193
+ const rule = this.validateRule(input);
194
+ return this.db.transaction(async (trx) => {
195
+ const verifyAccess = await administrationGuard(trx, actorId);
196
+ await this.find(trx, roleId);
197
+ const values = {
198
+ conditions: rule.conditions ? JSON.stringify(rule.conditions) : null,
199
+ fields: rule.fields ? JSON.stringify(rule.fields) : null,
200
+ };
201
+ const existing = await trx('role_rules')
202
+ .where({
203
+ role_id: roleId,
204
+ subject: rule.subject,
205
+ action: rule.action,
206
+ inverted: rule.inverted,
207
+ })
208
+ .first();
209
+ const [row] = existing
210
+ ? await trx('role_rules').where('id', existing.id).update(values).returning('*')
211
+ : await trx('role_rules')
212
+ .insert({
213
+ role_id: roleId,
214
+ subject: rule.subject,
215
+ action: rule.action,
216
+ inverted: rule.inverted,
217
+ ...values,
218
+ })
219
+ .returning('*');
220
+ const saved = toRule(row);
221
+ await verifyAccess();
222
+ await logActivity(trx, {
223
+ resource: RESOURCE,
224
+ recordId: roleId,
225
+ actorId,
226
+ action: 'set_rule',
227
+ changes: { rule: saved },
228
+ });
229
+ return saved;
230
+ });
231
+ }
232
+ async removeRule(actorId, roleId, ruleId) {
233
+ return this.db.transaction(async (trx) => {
234
+ const verifyAccess = await administrationGuard(trx, actorId);
235
+ await this.find(trx, roleId);
236
+ const [row] = await trx('role_rules')
237
+ .where({ id: ruleId, role_id: roleId })
238
+ .delete()
239
+ .returning('*');
240
+ if (!row)
241
+ throw new KitError(404, 'E_RULE_NOT_FOUND', 'القاعدة غير موجودة');
242
+ await verifyAccess();
243
+ await logActivity(trx, {
244
+ resource: RESOURCE,
245
+ recordId: roleId,
246
+ actorId,
247
+ action: 'remove_rule',
248
+ changes: { rule: toRule(row) },
249
+ });
250
+ });
251
+ }
252
+ validateRule(input) {
253
+ const subject = typeof input.subject === 'string' ? input.subject : '';
254
+ let resource;
255
+ if (subject !== 'all' && subject !== 'core.users') {
256
+ try {
257
+ resource = this.registry.get(subject);
258
+ }
259
+ catch {
260
+ throw new KitError(422, 'E_RULE_SUBJECT', 'الكيان غير معروف');
261
+ }
262
+ }
263
+ const action = typeof input.action === 'string' ? input.action : '';
264
+ const allowed = subject === 'core.users'
265
+ ? ['invite', 'manage']
266
+ : resource
267
+ ? [...resource.actions, 'manage']
268
+ : [...new Set(this.registry.all().flatMap((r) => [...r.actions])), 'invite', 'manage'];
269
+ if (!allowed.includes(action))
270
+ throw new KitError(422, 'E_RULE_ACTION', 'الإجراء غير متاح لهذا الكيان');
271
+ const inverted = Boolean(input.inverted);
272
+ let conditions = null;
273
+ if (input.conditions !== undefined &&
274
+ input.conditions !== null &&
275
+ !(typeof input.conditions === 'object' &&
276
+ !Array.isArray(input.conditions) &&
277
+ !Object.keys(input.conditions).length)) {
278
+ if (!resource)
279
+ throw new KitError(422, 'E_RULE_CONDITIONS', 'الشروط تتطلب اختيار كيان محدد');
280
+ if (typeof input.conditions !== 'object' || Array.isArray(input.conditions))
281
+ throw new KitError(422, 'E_RULE_CONDITIONS', 'الشروط يجب أن تكون كائناً');
282
+ if ('orgPath' in input.conditions)
283
+ throw new KitError(422, 'E_RULE_CONDITIONS', 'orgPath محجوز لنطاق الدور');
284
+ const conditionable = new Set([...standardConditionFields(resource), ...matrixFields(resource)]
285
+ .filter((field) => field.conditionable)
286
+ .map((field) => field.key));
287
+ for (const field of Object.keys(input.conditions))
288
+ if (!conditionable.has(field))
289
+ throw new KitError(422, 'E_RULE_CONDITIONS', `الحقل ${field} لا يقبل الشروط`);
290
+ try {
291
+ conditionSql(input.conditions, resource);
292
+ }
293
+ catch (error) {
294
+ throw new KitError(422, 'E_RULE_CONDITIONS', `شرط غير مدعوم: ${error.message}`);
295
+ }
296
+ conditions = input.conditions;
297
+ }
298
+ let fields = null;
299
+ if (input.fields !== undefined &&
300
+ input.fields !== null &&
301
+ !(Array.isArray(input.fields) && !input.fields.length)) {
302
+ if (!resource)
303
+ throw new KitError(422, 'E_RULE_FIELDS', 'قائمة الحقول تتطلب اختيار كيان محدد');
304
+ if (!Array.isArray(input.fields) || !input.fields.every((f) => typeof f === 'string'))
305
+ throw new KitError(422, 'E_RULE_FIELDS', 'قائمة الحقول غير صالحة');
306
+ for (const field of input.fields)
307
+ if (!(field in resource.fields))
308
+ throw new KitError(422, 'E_RULE_FIELDS', `الحقل ${field} غير معروف`);
309
+ fields = [...new Set(input.fields)];
310
+ }
311
+ return { subject, action, inverted, conditions, fields };
312
+ }
313
+ async find(db, id) {
314
+ const role = await db('roles').where('id', id).first();
315
+ if (!role)
316
+ throw new KitError(404, 'E_ROLE_NOT_FOUND', 'الدور غير موجود');
317
+ return role;
318
+ }
319
+ }
@@ -0,0 +1,37 @@
1
+ import type { Knex } from 'knex';
2
+ import type { ResourceRegistry } from '../resource/registry.js';
3
+ import { type Actor } from '../auth/ability.js';
4
+ export type SavedViewQuery = {
5
+ search?: string;
6
+ sort?: string;
7
+ direction?: 'asc' | 'desc';
8
+ filters?: Record<string, string | number | boolean | null>;
9
+ };
10
+ export type SavedView = {
11
+ id: number;
12
+ name: string;
13
+ query: SavedViewQuery;
14
+ shared: boolean;
15
+ own: boolean;
16
+ };
17
+ /**
18
+ * A saved view is stored user input that later drives a list query, so every key
19
+ * is validated against the resource definition before it is written and again
20
+ * when it is read back: a field that stopped being sortable or filterable must
21
+ * not resurrect as an unchecked query parameter after an upgrade.
22
+ */
23
+ export declare class SavedViews {
24
+ private db;
25
+ private registry;
26
+ constructor(db: Knex, registry: ResourceRegistry);
27
+ list(name: string, actor: Actor): Promise<SavedView[]>;
28
+ save(name: string, actor: Actor, input: {
29
+ name: string;
30
+ query: unknown;
31
+ shared?: boolean;
32
+ }): Promise<SavedView>;
33
+ remove(name: string, actor: Actor, id: number): Promise<void>;
34
+ private authorize;
35
+ /** Returns null for a stored view the resource no longer supports; throws on user input. */
36
+ private sanitize;
37
+ }
@@ -0,0 +1,139 @@
1
+ import { buildAbility } from '../auth/ability.js';
2
+ import { KitError } from '../admin/errors.js';
3
+ const NAME_LIMIT = 60;
4
+ const VIEW_LIMIT = 50;
5
+ /**
6
+ * A saved view is stored user input that later drives a list query, so every key
7
+ * is validated against the resource definition before it is written and again
8
+ * when it is read back: a field that stopped being sortable or filterable must
9
+ * not resurrect as an unchecked query parameter after an upgrade.
10
+ */
11
+ export class SavedViews {
12
+ db;
13
+ registry;
14
+ constructor(db, registry) {
15
+ this.db = db;
16
+ this.registry = registry;
17
+ }
18
+ async list(name, actor) {
19
+ const resource = this.authorize(name, actor);
20
+ const rows = await this.db('saved_views')
21
+ .where('resource', resource.name)
22
+ .where((where) => where.where('user_id', actor.id).orWhere('shared', true))
23
+ .orderBy('name')
24
+ .limit(VIEW_LIMIT * 2);
25
+ return rows.flatMap((row) => {
26
+ const query = this.sanitize(resource, row.query);
27
+ return query
28
+ ? [
29
+ {
30
+ id: Number(row.id),
31
+ name: String(row.name),
32
+ query,
33
+ shared: Boolean(row.shared),
34
+ own: Number(row.user_id) === actor.id,
35
+ },
36
+ ]
37
+ : [];
38
+ });
39
+ }
40
+ async save(name, actor, input) {
41
+ const resource = this.authorize(name, actor);
42
+ const label = typeof input.name === 'string' ? input.name.trim() : '';
43
+ if (!label || label.length > NAME_LIMIT)
44
+ throw new KitError(422, 'E_VIEW_NAME', 'اسم العرض مطلوب ولا يتجاوز 60 حرفاً');
45
+ const query = this.sanitize(resource, input.query, true);
46
+ const shared = input.shared === true;
47
+ const existing = await this.db('saved_views')
48
+ .where({ user_id: actor.id, resource: resource.name, name: label })
49
+ .first('id');
50
+ if (!existing) {
51
+ const [{ count }] = await this.db('saved_views')
52
+ .where({ user_id: actor.id, resource: resource.name })
53
+ .count('* as count');
54
+ if (Number(count) >= VIEW_LIMIT)
55
+ throw new KitError(422, 'E_VIEW_LIMIT', 'بلغت الحد الأقصى للعروض المحفوظة لهذا الكيان');
56
+ }
57
+ const values = {
58
+ user_id: actor.id,
59
+ resource: resource.name,
60
+ name: label,
61
+ query: JSON.stringify(query),
62
+ shared,
63
+ updated_at: this.db.fn.now(),
64
+ };
65
+ const [row] = existing
66
+ ? await this.db('saved_views').where('id', existing.id).update(values).returning('*')
67
+ : await this.db('saved_views').insert(values).returning('*');
68
+ return { id: Number(row.id), name: label, query, shared, own: true };
69
+ }
70
+ async remove(name, actor, id) {
71
+ const resource = this.authorize(name, actor);
72
+ const removed = await this.db('saved_views')
73
+ .where({ id, resource: resource.name, user_id: actor.id })
74
+ .del();
75
+ if (!removed)
76
+ throw new KitError(404, 'E_VIEW_NOT_FOUND', 'العرض المحفوظ غير موجود');
77
+ }
78
+ authorize(name, actor) {
79
+ const resource = this.registry.get(name);
80
+ const ability = buildAbility(actor.rules, this.registry.all());
81
+ if (!resource.actions.includes('view') || !ability.can('view', resource.name))
82
+ throw new KitError(403, 'E_FORBIDDEN', 'ليس لديك صلاحية لهذا الكيان');
83
+ return resource;
84
+ }
85
+ /** Returns null for a stored view the resource no longer supports; throws on user input. */
86
+ sanitize(resource, value, strict = false) {
87
+ const reject = (code, message) => {
88
+ if (strict)
89
+ throw new KitError(422, code, message);
90
+ return null;
91
+ };
92
+ if (!value || typeof value !== 'object' || Array.isArray(value))
93
+ return reject('E_VIEW_QUERY', 'تعريف العرض غير صالح');
94
+ const input = value;
95
+ for (const key of Object.keys(input))
96
+ if (!['search', 'sort', 'direction', 'filters'].includes(key))
97
+ return reject('E_VIEW_QUERY', `مفتاح غير مدعوم في العرض: ${key}`);
98
+ const query = {};
99
+ if (input.search !== undefined && input.search !== null && input.search !== '') {
100
+ if (typeof input.search !== 'string' || input.search.length > 200)
101
+ return reject('E_VIEW_QUERY', 'نص البحث غير صالح');
102
+ if (!Object.values(resource.fields).some((field) => field.searchable))
103
+ return reject('E_VIEW_QUERY', 'هذا الكيان لا يدعم البحث النصي');
104
+ query.search = input.search;
105
+ }
106
+ if (input.sort !== undefined && input.sort !== null && input.sort !== '') {
107
+ if (typeof input.sort !== 'string' || !resource.fields[input.sort]?.sortable)
108
+ return reject('E_VIEW_QUERY', 'حقل الفرز غير مدعوم');
109
+ query.sort = input.sort;
110
+ if (input.direction !== undefined && input.direction !== null) {
111
+ if (input.direction !== 'asc' && input.direction !== 'desc')
112
+ return reject('E_VIEW_QUERY', 'اتجاه الفرز غير صالح');
113
+ query.direction = input.direction;
114
+ }
115
+ }
116
+ else if (input.direction !== undefined && input.direction !== null && input.direction !== '')
117
+ return reject('E_VIEW_QUERY', 'اتجاه الفرز يحتاج حقل فرز');
118
+ if (input.filters !== undefined && input.filters !== null) {
119
+ if (typeof input.filters !== 'object' || Array.isArray(input.filters))
120
+ return reject('E_VIEW_QUERY', 'تصفية العرض غير صالحة');
121
+ const filters = {};
122
+ for (const [key, entry] of Object.entries(input.filters)) {
123
+ if (!resource.fields[key]?.filterable)
124
+ return reject('E_VIEW_QUERY', `حقل التصفية غير مدعوم: ${key}`);
125
+ if (entry === undefined || entry === '')
126
+ continue;
127
+ if (entry !== null &&
128
+ typeof entry !== 'string' &&
129
+ typeof entry !== 'number' &&
130
+ typeof entry !== 'boolean')
131
+ return reject('E_VIEW_QUERY', `قيمة التصفية غير صالحة: ${key}`);
132
+ filters[key] = entry;
133
+ }
134
+ if (Object.keys(filters).length)
135
+ query.filters = filters;
136
+ }
137
+ return query;
138
+ }
139
+ }
@@ -0,0 +1,29 @@
1
+ import type { Knex } from 'knex';
2
+ import type { JsonValue } from '../resource/types.js';
3
+ export type SettingScope = 'system' | 'org_unit' | 'user';
4
+ export type SettingRow = {
5
+ id: number;
6
+ key: string;
7
+ scope: SettingScope;
8
+ scopeId: string;
9
+ value: JsonValue;
10
+ readOnly: boolean;
11
+ };
12
+ export type SettingInput = {
13
+ key: string;
14
+ scope: SettingScope;
15
+ scopeId?: string;
16
+ value: unknown;
17
+ };
18
+ export declare const SETTING_SCOPES: SettingScope[];
19
+ export declare const PROTECTED_SETTING_KEYS: string[];
20
+ export declare function isProtectedSetting(key: string): boolean;
21
+ export declare function parseSettingValue(text: string): unknown;
22
+ export declare class SettingsAdmin {
23
+ private db;
24
+ constructor(db: Knex);
25
+ list(scope: SettingScope, scopeId?: string): Promise<SettingRow[]>;
26
+ upsert(actorId: number, input: SettingInput): Promise<SettingRow>;
27
+ delete(actorId: number, id: number): Promise<void>;
28
+ private scopeId;
29
+ }
@@ -0,0 +1,113 @@
1
+ import { KitError } from '../admin/errors.js';
2
+ import { logActivity } from './activity.js';
3
+ import { UI_PREFERENCES_KEY, validateUiPreferences } from './ui_preferences.js';
4
+ export const SETTING_SCOPES = ['system', 'org_unit', 'user'];
5
+ // Written by install, scheduler, worker and backup verification; never by the settings screen.
6
+ export const PROTECTED_SETTING_KEYS = [
7
+ 'kit.version',
8
+ 'scheduler.heartbeat',
9
+ 'worker.heartbeat',
10
+ 'mail.delivery_test',
11
+ ];
12
+ const RESOURCE = 'core.settings';
13
+ export function isProtectedSetting(key) {
14
+ return (PROTECTED_SETTING_KEYS.includes(key) || key.startsWith('backup.') || key.startsWith('setup.'));
15
+ }
16
+ export function parseSettingValue(text) {
17
+ try {
18
+ return JSON.parse(text);
19
+ }
20
+ catch {
21
+ throw new KitError(422, 'E_SETTING_JSON', 'القيمة يجب أن تكون JSON صالحاً');
22
+ }
23
+ }
24
+ function settingKey(value) {
25
+ if (typeof value !== 'string' || !/^[a-z][a-z0-9_.-]{0,99}$/i.test(value))
26
+ throw new KitError(422, 'E_SETTING_KEY', 'المفتاح يبدأ بحرف ويحوي حروفاً وأرقاماً ونقاطاً فقط');
27
+ return value;
28
+ }
29
+ function settingScope(value) {
30
+ if (!SETTING_SCOPES.includes(value))
31
+ throw new KitError(422, 'E_SETTING_SCOPE', 'النطاق غير معروف');
32
+ return value;
33
+ }
34
+ function toRow(row) {
35
+ return {
36
+ id: Number(row.id),
37
+ key: String(row.key),
38
+ scope: row.scope,
39
+ scopeId: String(row.scope_id),
40
+ value: row.value,
41
+ readOnly: isProtectedSetting(String(row.key)),
42
+ };
43
+ }
44
+ export class SettingsAdmin {
45
+ db;
46
+ constructor(db) {
47
+ this.db = db;
48
+ }
49
+ async list(scope, scopeId = '0') {
50
+ const rows = await this.db('settings')
51
+ .where({ scope: settingScope(scope), scope_id: scopeId })
52
+ .orderBy('key');
53
+ return rows.map(toRow);
54
+ }
55
+ async upsert(actorId, input) {
56
+ const key = settingKey(input.key);
57
+ const scope = settingScope(input.scope);
58
+ if (key === UI_PREFERENCES_KEY) {
59
+ if (scope !== 'system')
60
+ throw new KitError(422, 'E_SETTING_SCOPE', 'تفضيلات الواجهة تُضبط على مستوى النظام');
61
+ validateUiPreferences(input.value);
62
+ }
63
+ if (isProtectedSetting(key))
64
+ throw new KitError(403, 'E_SETTING_READ_ONLY', 'هذا المفتاح تشغيلي للقراءة فقط');
65
+ const serialized = JSON.stringify(input.value);
66
+ if (serialized === undefined)
67
+ throw new KitError(422, 'E_SETTING_JSON', 'القيمة يجب أن تكون JSON صالحاً');
68
+ return this.db.transaction(async (trx) => {
69
+ const scopeId = await this.scopeId(trx, scope, input.scopeId);
70
+ const [row] = await trx('settings')
71
+ .insert({ key, scope, scope_id: scopeId, value: serialized })
72
+ .onConflict(['key', 'scope', 'scope_id'])
73
+ .merge(['value'])
74
+ .returning('*');
75
+ await logActivity(trx, {
76
+ resource: RESOURCE,
77
+ recordId: row.id,
78
+ actorId,
79
+ action: 'upsert',
80
+ changes: { key, scope, scopeId, value: input.value },
81
+ });
82
+ return toRow(row);
83
+ });
84
+ }
85
+ async delete(actorId, id) {
86
+ return this.db.transaction(async (trx) => {
87
+ const row = await trx('settings').where('id', id).first();
88
+ if (!row)
89
+ throw new KitError(404, 'E_SETTING_NOT_FOUND', 'الإعداد غير موجود');
90
+ if (isProtectedSetting(row.key))
91
+ throw new KitError(403, 'E_SETTING_READ_ONLY', 'هذا المفتاح تشغيلي للقراءة فقط');
92
+ await trx('settings').where('id', id).delete();
93
+ await logActivity(trx, {
94
+ resource: RESOURCE,
95
+ recordId: id,
96
+ actorId,
97
+ action: 'delete',
98
+ changes: { key: row.key, scope: row.scope, scopeId: row.scope_id },
99
+ });
100
+ });
101
+ }
102
+ async scopeId(db, scope, value) {
103
+ if (scope === 'system')
104
+ return '0';
105
+ const id = Number(value);
106
+ if (!Number.isSafeInteger(id) || id <= 0)
107
+ throw new KitError(422, 'E_SETTING_SCOPE', 'معرّف النطاق مطلوب');
108
+ const table = scope === 'org_unit' ? 'org_units' : 'users';
109
+ if (!(await db(table).where('id', id).first('id')))
110
+ throw new KitError(404, 'E_SETTING_SCOPE', scope === 'org_unit' ? 'الوحدة التنظيمية غير موجودة' : 'المستخدم غير موجود');
111
+ return String(id);
112
+ }
113
+ }
@@ -0,0 +1,14 @@
1
+ import type { Knex } from 'knex';
2
+ export type SetupCheck = {
3
+ status: 'checking' | 'passed' | 'failed';
4
+ checkedAt: string;
5
+ fingerprint: string;
6
+ attempt: string;
7
+ };
8
+ export declare class InitialSetup {
9
+ private db;
10
+ constructor(db: Knex);
11
+ check(actorId: number, name: 'storage' | 'infrastructure', fingerprint: string, probe: () => Promise<void>): Promise<void>;
12
+ acknowledgeIdentity(actorId: number, fingerprint: string): Promise<void>;
13
+ testNotification(actorId: number): Promise<number>;
14
+ }