@adula/kit 0.2.0-alpha.4 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -2
- package/build/agent/capabilities.md +49 -7
- package/build/agent/skills/idea-review/SKILL.md +26 -1
- package/build/agent/skills/module-review/SKILL.md +22 -0
- package/build/agent/skills/perf-review/SKILL.md +24 -0
- package/build/agent/skills/schema-review/SKILL.md +22 -0
- package/build/agent/skills/security-review/SKILL.md +24 -0
- package/build/agent/skills/ui-review/SKILL.md +22 -0
- package/build/commands/capabilities.d.ts +4 -0
- package/build/commands/capabilities.js +35 -4
- package/build/commands/main.d.ts +4 -2
- package/build/database/migrations/1770000000004_kit_collaboration.d.ts +5 -0
- package/build/database/migrations/1770000000004_kit_collaboration.js +10 -0
- package/build/database/migrations/1770000000005_kit_assignments.d.ts +5 -0
- package/build/database/migrations/1770000000005_kit_assignments.js +10 -0
- package/build/database/migrations/1770000000006_kit_messaging.d.ts +5 -0
- package/build/database/migrations/1770000000006_kit_messaging.js +10 -0
- package/build/database/migrations/1770000000007_kit_webhooks.d.ts +5 -0
- package/build/database/migrations/1770000000007_kit_webhooks.js +10 -0
- package/build/database/migrations/1770000000008_kit_imports.d.ts +5 -0
- package/build/database/migrations/1770000000008_kit_imports.js +10 -0
- package/build/database/migrations/1770000000010_kit_workflows.d.ts +5 -0
- package/build/database/migrations/1770000000010_kit_workflows.js +10 -0
- package/build/index.d.ts +21 -1
- package/build/index.js +12 -1
- package/build/src/admin/contracts.js +19 -10
- package/build/src/admin/controller.d.ts +1 -0
- package/build/src/admin/controller.js +6 -0
- package/build/src/admin/resource_service.d.ts +21 -0
- package/build/src/admin/resource_service.js +163 -7
- package/build/src/collaboration/assignments.d.ts +78 -0
- package/build/src/collaboration/assignments.js +219 -0
- package/build/src/collaboration/record_collaboration.d.ts +86 -0
- package/build/src/collaboration/record_collaboration.js +360 -0
- package/build/src/commands/agent_assets.js +5 -0
- package/build/src/commands/capabilities.d.ts +19 -0
- package/build/src/commands/capabilities.js +160 -0
- package/build/src/core/message_templates.d.ts +80 -0
- package/build/src/core/message_templates.js +288 -0
- package/build/src/database/schema.d.ts +18 -0
- package/build/src/database/schema.js +192 -0
- package/build/src/events/outbox.d.ts +1 -0
- package/build/src/events/outbox.js +1 -1
- package/build/src/events/record_mutation.d.ts +7 -0
- package/build/src/events/record_mutation.js +13 -2
- package/build/src/integrations/imports.d.ts +74 -0
- package/build/src/integrations/imports.js +331 -0
- package/build/src/integrations/openapi.d.ts +39 -0
- package/build/src/integrations/openapi.js +256 -0
- package/build/src/integrations/print.d.ts +37 -0
- package/build/src/integrations/print.js +123 -0
- package/build/src/integrations/webhooks.d.ts +98 -0
- package/build/src/integrations/webhooks.js +298 -0
- package/build/src/resource/define_resource.d.ts +1 -0
- package/build/src/resource/define_resource.js +9 -1
- package/build/src/resource/registry.d.ts +2 -0
- package/build/src/resource/registry.js +9 -0
- package/build/src/resource/types.d.ts +3 -0
- package/build/src/workflows/define_workflow.d.ts +95 -0
- package/build/src/workflows/define_workflow.js +83 -0
- package/build/src/workflows/engine.d.ts +119 -0
- package/build/src/workflows/engine.js +687 -0
- package/package.json +7 -3
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import Papa from 'papaparse';
|
|
2
|
+
import { subject } from '@casl/ability';
|
|
3
|
+
import { buildAbility } from '../auth/ability.js';
|
|
4
|
+
import { KitError } from '../admin/errors.js';
|
|
5
|
+
import { notifyWithTemplate } from '../core/message_templates.js';
|
|
6
|
+
export const IMPORT_ROW_LIMIT = 5000;
|
|
7
|
+
const COLUMN_LIMIT = 100;
|
|
8
|
+
const ERROR_LIMIT = 200;
|
|
9
|
+
const FILE_LIMIT = 5 * 1024 * 1024;
|
|
10
|
+
const UNSUPPORTED = new Set(['hasMany', 'attachment', 'json']);
|
|
11
|
+
const normalize = (value) => value
|
|
12
|
+
.trim()
|
|
13
|
+
.toLowerCase()
|
|
14
|
+
.replace(/[\s_-]+/g, '');
|
|
15
|
+
/** Converts one CSV cell to the field's stored representation, or throws a readable error. */
|
|
16
|
+
export function importCell(field, raw, lookups) {
|
|
17
|
+
const value = raw.trim();
|
|
18
|
+
if (value === '')
|
|
19
|
+
return null;
|
|
20
|
+
const latin = value.replace(/[٠-٩]/g, (digit) => String(digit.charCodeAt(0) - 0x0660));
|
|
21
|
+
switch (field.type) {
|
|
22
|
+
case 'integer':
|
|
23
|
+
case 'belongsTo': {
|
|
24
|
+
if (!/^-?\d+$/.test(latin))
|
|
25
|
+
throw new Error('رقم صحيح مطلوب');
|
|
26
|
+
return Number(latin);
|
|
27
|
+
}
|
|
28
|
+
case 'money': {
|
|
29
|
+
const plain = latin.replace(/[,٬\s]/g, '').replace('٫', '.');
|
|
30
|
+
if (!/^-?\d+(\.\d{1,2})?$/.test(plain))
|
|
31
|
+
throw new Error('مبلغ غير صالح');
|
|
32
|
+
const [whole, fraction = ''] = plain.replace('-', '').split('.');
|
|
33
|
+
const minor = BigInt(whole) * 100n + BigInt(fraction.padEnd(2, '0') || '0');
|
|
34
|
+
return String(plain.startsWith('-') ? -minor : minor);
|
|
35
|
+
}
|
|
36
|
+
case 'boolean': {
|
|
37
|
+
const truthy = ['1', 'true', 'yes', 'نعم', 'صح'];
|
|
38
|
+
const falsy = ['0', 'false', 'no', 'لا', 'خطأ'];
|
|
39
|
+
if (truthy.includes(value.toLowerCase()))
|
|
40
|
+
return true;
|
|
41
|
+
if (falsy.includes(value.toLowerCase()))
|
|
42
|
+
return false;
|
|
43
|
+
throw new Error('قيمة نعم/لا غير صالحة');
|
|
44
|
+
}
|
|
45
|
+
case 'date': {
|
|
46
|
+
const iso = /^(\d{4})-(\d{2})-(\d{2})$/.exec(latin);
|
|
47
|
+
const local = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/.exec(latin);
|
|
48
|
+
const [y, m, d] = iso
|
|
49
|
+
? [iso[1], iso[2], iso[3]]
|
|
50
|
+
: local
|
|
51
|
+
? [local[3], local[2].padStart(2, '0'), local[1].padStart(2, '0')]
|
|
52
|
+
: [];
|
|
53
|
+
const date = y ? new Date(`${y}-${m}-${d}T00:00:00Z`) : null;
|
|
54
|
+
if (!date || Number.isNaN(date.getTime()) || date.getUTCDate() !== Number(d))
|
|
55
|
+
throw new Error('تاريخ غير صالح (YYYY-MM-DD أو DD/MM/YYYY)');
|
|
56
|
+
return `${y}-${m}-${d}`;
|
|
57
|
+
}
|
|
58
|
+
case 'datetime': {
|
|
59
|
+
const date = new Date(latin);
|
|
60
|
+
if (Number.isNaN(date.getTime()))
|
|
61
|
+
throw new Error('تاريخ ووقت غير صالحين');
|
|
62
|
+
return date.toISOString();
|
|
63
|
+
}
|
|
64
|
+
case 'lookup': {
|
|
65
|
+
const key = lookups.get(normalize(value));
|
|
66
|
+
if (!key)
|
|
67
|
+
throw new Error('قيمة غير موجودة في القائمة');
|
|
68
|
+
return key;
|
|
69
|
+
}
|
|
70
|
+
default:
|
|
71
|
+
return value;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* CSV imports in batches: the upload is parsed and stored, the user maps columns
|
|
76
|
+
* to writable fields, and a worker saves each row through ResourceService with
|
|
77
|
+
* the importing user's current permissions. Row failures never stop the batch.
|
|
78
|
+
*/
|
|
79
|
+
export class ImportBatches {
|
|
80
|
+
db;
|
|
81
|
+
registry;
|
|
82
|
+
resources;
|
|
83
|
+
actors;
|
|
84
|
+
constructor(db, registry, resources, actors) {
|
|
85
|
+
this.db = db;
|
|
86
|
+
this.registry = registry;
|
|
87
|
+
this.resources = resources;
|
|
88
|
+
this.actors = actors;
|
|
89
|
+
}
|
|
90
|
+
/** Writable, importable fields for this actor. */
|
|
91
|
+
targets(name, actor) {
|
|
92
|
+
const resource = this.registry.get(name);
|
|
93
|
+
const ability = buildAbility(actor.rules, this.registry.all());
|
|
94
|
+
if (!resource.actions.includes('create') || !ability.can('create', name))
|
|
95
|
+
throw new KitError(403, 'E_FORBIDDEN', 'ليس لديك صلاحية الإضافة إلى هذا الكيان');
|
|
96
|
+
const fields = resource.form
|
|
97
|
+
.filter((key) => {
|
|
98
|
+
const field = resource.fields[key];
|
|
99
|
+
return (!UNSUPPORTED.has(field.type) &&
|
|
100
|
+
!field.sequence &&
|
|
101
|
+
actor.permissionLevel >=
|
|
102
|
+
Math.max(field.permissionLevel ?? 0, resource.hidden?.includes(key) ? 1 : 0) &&
|
|
103
|
+
ability.can('create', subject(name, {}), key));
|
|
104
|
+
})
|
|
105
|
+
.map((key) => ({
|
|
106
|
+
key,
|
|
107
|
+
label: resource.fields[key].label.ar,
|
|
108
|
+
type: resource.fields[key].type,
|
|
109
|
+
required: Boolean(resource.fields[key].required),
|
|
110
|
+
}));
|
|
111
|
+
if (resource.scoped)
|
|
112
|
+
fields.push({
|
|
113
|
+
key: 'orgUnitId',
|
|
114
|
+
label: 'الوحدة التنظيمية (رقم)',
|
|
115
|
+
type: 'integer',
|
|
116
|
+
required: true,
|
|
117
|
+
});
|
|
118
|
+
return fields;
|
|
119
|
+
}
|
|
120
|
+
async create(name, actor, input) {
|
|
121
|
+
const targets = this.targets(name, actor);
|
|
122
|
+
const fileName = typeof input.fileName === 'string' ? input.fileName.slice(0, 200) : 'import.csv';
|
|
123
|
+
if (typeof input.content !== 'string' || !input.content.trim())
|
|
124
|
+
throw new KitError(422, 'E_IMPORT_FILE', 'الملف فارغ');
|
|
125
|
+
if (Buffer.byteLength(input.content) > FILE_LIMIT)
|
|
126
|
+
throw new KitError(422, 'E_IMPORT_FILE', 'حجم الملف يتجاوز 5 ميغابايت');
|
|
127
|
+
const parsed = Papa.parse(input.content.replace(/^\uFEFF/, ''), {
|
|
128
|
+
skipEmptyLines: 'greedy',
|
|
129
|
+
});
|
|
130
|
+
if (parsed.errors.some((error) => error.type !== 'Delimiter'))
|
|
131
|
+
throw new KitError(422, 'E_IMPORT_FILE', `تعذر قراءة CSV: ${parsed.errors[0].message}`);
|
|
132
|
+
const [headers = [], ...rows] = parsed.data;
|
|
133
|
+
if (!headers.length || headers.length > COLUMN_LIMIT)
|
|
134
|
+
throw new KitError(422, 'E_IMPORT_FILE', 'يجب أن يحتوي الملف صف عناوين (100 عمود كحد أقصى)');
|
|
135
|
+
if (!rows.length || rows.length > IMPORT_ROW_LIMIT)
|
|
136
|
+
throw new KitError(422, 'E_IMPORT_ROWS', `عدد الصفوف بين 1 و${IMPORT_ROW_LIMIT}`);
|
|
137
|
+
const mapping = {};
|
|
138
|
+
headers.forEach((header, index) => {
|
|
139
|
+
const target = targets.find((field) => [field.key, field.label].some((alias) => normalize(alias) === normalize(header)));
|
|
140
|
+
if (target && !Object.values(mapping).includes(target.key))
|
|
141
|
+
mapping[String(index)] = target.key;
|
|
142
|
+
});
|
|
143
|
+
const [row] = await this.db('import_batches')
|
|
144
|
+
.insert({
|
|
145
|
+
resource: name,
|
|
146
|
+
user_id: actor.id,
|
|
147
|
+
file_name: fileName,
|
|
148
|
+
status: 'mapping',
|
|
149
|
+
headers: JSON.stringify(headers),
|
|
150
|
+
mapping: JSON.stringify(mapping),
|
|
151
|
+
rows: JSON.stringify(rows.map((cells) => cells.slice(0, headers.length))),
|
|
152
|
+
total: rows.length,
|
|
153
|
+
})
|
|
154
|
+
.returning('*');
|
|
155
|
+
return this.present(row, targets);
|
|
156
|
+
}
|
|
157
|
+
async show(id, actor) {
|
|
158
|
+
const row = await this.owned(id, actor);
|
|
159
|
+
return this.present(row, this.safeTargets(row.resource, actor));
|
|
160
|
+
}
|
|
161
|
+
async list(actor) {
|
|
162
|
+
const rows = await this.db('import_batches')
|
|
163
|
+
.where('user_id', actor.id)
|
|
164
|
+
.orderBy('id', 'desc')
|
|
165
|
+
.limit(50)
|
|
166
|
+
.select('id', 'resource', 'file_name', 'status', 'headers', 'mapping', 'total', 'processed', 'created', 'failed', 'errors', 'created_at', 'finished_at');
|
|
167
|
+
return rows.map((row) => this.present({ ...row, rows: [] }, []));
|
|
168
|
+
}
|
|
169
|
+
/** Stores the column mapping and queues the batch for the worker. */
|
|
170
|
+
async start(id, actor, mapping) {
|
|
171
|
+
const row = await this.owned(id, actor);
|
|
172
|
+
if (row.status !== 'mapping')
|
|
173
|
+
throw new KitError(409, 'E_IMPORT_STATE', 'بدأت معالجة هذه الدفعة بالفعل');
|
|
174
|
+
const targets = this.targets(row.resource, actor);
|
|
175
|
+
if (!mapping || typeof mapping !== 'object' || Array.isArray(mapping))
|
|
176
|
+
throw new KitError(422, 'E_IMPORT_MAPPING', 'مطابقة الأعمدة غير صالحة');
|
|
177
|
+
const clean = {};
|
|
178
|
+
const used = new Set();
|
|
179
|
+
for (const [column, key] of Object.entries(mapping)) {
|
|
180
|
+
if (key === '' || key === null || key === undefined)
|
|
181
|
+
continue;
|
|
182
|
+
const index = Number(column);
|
|
183
|
+
if (!Number.isInteger(index) || index < 0 || index >= row.headers.length)
|
|
184
|
+
throw new KitError(422, 'E_IMPORT_MAPPING', 'عمود غير موجود');
|
|
185
|
+
if (typeof key !== 'string' || !targets.some((target) => target.key === key) || used.has(key))
|
|
186
|
+
throw new KitError(422, 'E_IMPORT_MAPPING', `حقل غير قابل للاستيراد أو مكرر: ${String(key)}`);
|
|
187
|
+
used.add(key);
|
|
188
|
+
clean[String(index)] = key;
|
|
189
|
+
}
|
|
190
|
+
const missing = targets.filter((target) => target.required && !used.has(target.key));
|
|
191
|
+
if (missing.length)
|
|
192
|
+
throw new KitError(422, 'E_IMPORT_MAPPING', `حقول مطلوبة بلا عمود: ${missing.map((target) => target.label).join('، ')}`);
|
|
193
|
+
await this.db('import_batches')
|
|
194
|
+
.where('id', row.id)
|
|
195
|
+
.update({ mapping: JSON.stringify(clean), status: 'queued' });
|
|
196
|
+
return this.show(row.id, actor);
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Worker step: claims one queued or running batch and imports up to `chunk`
|
|
200
|
+
* rows. Progress is committed per row, so a crash resumes after the last one.
|
|
201
|
+
*/
|
|
202
|
+
async process(chunk = 200) {
|
|
203
|
+
const batch = await this.db.transaction(async (trx) => {
|
|
204
|
+
const row = await trx('import_batches')
|
|
205
|
+
.whereIn('status', ['queued', 'running'])
|
|
206
|
+
.orderBy('id')
|
|
207
|
+
.forUpdate()
|
|
208
|
+
.skipLocked()
|
|
209
|
+
.first();
|
|
210
|
+
if (!row)
|
|
211
|
+
return null;
|
|
212
|
+
await trx('import_batches')
|
|
213
|
+
.where('id', row.id)
|
|
214
|
+
.update({ status: 'running', started_at: row.started_at ?? trx.fn.now() });
|
|
215
|
+
return row;
|
|
216
|
+
});
|
|
217
|
+
if (!batch)
|
|
218
|
+
return null;
|
|
219
|
+
const actor = await this.actors.load(Number(batch.user_id));
|
|
220
|
+
const resource = this.registry.get(batch.resource);
|
|
221
|
+
const lookupGroups = new Map();
|
|
222
|
+
for (const [key, field] of Object.entries(resource.fields)) {
|
|
223
|
+
if (field.type !== 'lookup')
|
|
224
|
+
continue;
|
|
225
|
+
const rows = await this.db('lookups')
|
|
226
|
+
.where({ group: field.group, active: true })
|
|
227
|
+
.select('key', 'label_ar', 'label_en');
|
|
228
|
+
const map = new Map();
|
|
229
|
+
for (const entry of rows)
|
|
230
|
+
for (const name of [entry.key, entry.label_ar, entry.label_en])
|
|
231
|
+
map.set(normalize(String(name)), entry.key);
|
|
232
|
+
lookupGroups.set(key, map);
|
|
233
|
+
}
|
|
234
|
+
const mapping = batch.mapping;
|
|
235
|
+
const rows = batch.rows;
|
|
236
|
+
let { processed, created, failed } = batch;
|
|
237
|
+
const errors = [...batch.errors];
|
|
238
|
+
const end = Math.min(rows.length, processed + chunk);
|
|
239
|
+
for (let index = processed; index < end; index++) {
|
|
240
|
+
const cells = rows[index];
|
|
241
|
+
const input = {};
|
|
242
|
+
try {
|
|
243
|
+
for (const [column, key] of Object.entries(mapping)) {
|
|
244
|
+
const raw = cells[Number(column)] ?? '';
|
|
245
|
+
if (key === 'orgUnitId') {
|
|
246
|
+
input.orgUnitId = raw.trim() === '' ? null : Number(raw.trim());
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
input[key] = importCell(resource.fields[key], raw, lookupGroups.get(key) ?? new Map());
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
throw new Error(`${resource.fields[key].label.ar}: ${error.message}`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
await this.resources.save(batch.resource, actor, input);
|
|
257
|
+
created++;
|
|
258
|
+
}
|
|
259
|
+
catch (error) {
|
|
260
|
+
failed++;
|
|
261
|
+
if (errors.length < ERROR_LIMIT)
|
|
262
|
+
errors.push({
|
|
263
|
+
row: index + 2,
|
|
264
|
+
message: error instanceof KitError || error instanceof Error
|
|
265
|
+
? error.message.slice(0, 300)
|
|
266
|
+
: 'خطأ غير معروف',
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
processed = index + 1;
|
|
270
|
+
await this.db('import_batches')
|
|
271
|
+
.where('id', batch.id)
|
|
272
|
+
.update({ processed, created, failed, errors: JSON.stringify(errors) });
|
|
273
|
+
}
|
|
274
|
+
if (processed >= rows.length) {
|
|
275
|
+
await this.db.transaction(async (trx) => {
|
|
276
|
+
await trx('import_batches')
|
|
277
|
+
.where('id', batch.id)
|
|
278
|
+
// Row data is only needed while importing; the log keeps counts and errors.
|
|
279
|
+
.update({ status: 'done', finished_at: trx.fn.now(), rows: JSON.stringify([]) });
|
|
280
|
+
await notifyWithTemplate(trx, Number(batch.user_id), 'import.finished', {
|
|
281
|
+
resource: resource.label.ar,
|
|
282
|
+
created,
|
|
283
|
+
failed,
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
return { id: Number(batch.id), processed, created, failed };
|
|
288
|
+
}
|
|
289
|
+
safeTargets(name, actor) {
|
|
290
|
+
try {
|
|
291
|
+
return this.targets(name, actor);
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
return [];
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
async owned(id, actor) {
|
|
298
|
+
const batchId = Number(id);
|
|
299
|
+
if (!Number.isSafeInteger(batchId) || batchId <= 0)
|
|
300
|
+
throw new KitError(404, 'E_IMPORT_NOT_FOUND', 'الدفعة غير موجودة');
|
|
301
|
+
const row = await this.db('import_batches').where({ id: batchId, user_id: actor.id }).first();
|
|
302
|
+
if (!row)
|
|
303
|
+
throw new KitError(404, 'E_IMPORT_NOT_FOUND', 'الدفعة غير موجودة');
|
|
304
|
+
return row;
|
|
305
|
+
}
|
|
306
|
+
present(row, targets) {
|
|
307
|
+
let label = String(row.resource);
|
|
308
|
+
try {
|
|
309
|
+
label = this.registry.get(row.resource).label.ar;
|
|
310
|
+
}
|
|
311
|
+
catch { }
|
|
312
|
+
return {
|
|
313
|
+
id: Number(row.id),
|
|
314
|
+
resource: String(row.resource),
|
|
315
|
+
resourceLabel: label,
|
|
316
|
+
fileName: String(row.file_name),
|
|
317
|
+
status: row.status,
|
|
318
|
+
headers: row.headers ?? [],
|
|
319
|
+
mapping: row.mapping ?? {},
|
|
320
|
+
sample: (row.rows ?? []).slice(0, 5),
|
|
321
|
+
total: Number(row.total),
|
|
322
|
+
processed: Number(row.processed ?? 0),
|
|
323
|
+
created: Number(row.created ?? 0),
|
|
324
|
+
failed: Number(row.failed ?? 0),
|
|
325
|
+
errors: row.errors ?? [],
|
|
326
|
+
createdAt: new Date(row.created_at).toISOString(),
|
|
327
|
+
finishedAt: row.finished_at ? new Date(row.finished_at).toISOString() : null,
|
|
328
|
+
targets,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type Actor } from '../auth/ability.js';
|
|
2
|
+
import type { ResourceRegistry } from '../resource/registry.js';
|
|
3
|
+
type Schema = Record<string, unknown>;
|
|
4
|
+
/**
|
|
5
|
+
* OpenAPI 3.1 description of the resource API, generated from the registry. When
|
|
6
|
+
* an actor is given, only resources and actions it may use are described.
|
|
7
|
+
*/
|
|
8
|
+
export declare function openApiDocument(registry: ResourceRegistry, options: {
|
|
9
|
+
title: string;
|
|
10
|
+
version: string;
|
|
11
|
+
serverUrl: string;
|
|
12
|
+
basePath?: string;
|
|
13
|
+
/** Describe only what this actor may use (row conditions still apply at runtime). */
|
|
14
|
+
actor?: Actor;
|
|
15
|
+
}): {
|
|
16
|
+
openapi: string;
|
|
17
|
+
info: {
|
|
18
|
+
title: string;
|
|
19
|
+
version: string;
|
|
20
|
+
};
|
|
21
|
+
servers: {
|
|
22
|
+
url: string;
|
|
23
|
+
}[];
|
|
24
|
+
security: {
|
|
25
|
+
bearer: never[];
|
|
26
|
+
}[];
|
|
27
|
+
paths: Record<string, Schema>;
|
|
28
|
+
components: {
|
|
29
|
+
schemas: Record<string, Schema>;
|
|
30
|
+
securitySchemes: {
|
|
31
|
+
bearer: {
|
|
32
|
+
type: string;
|
|
33
|
+
scheme: string;
|
|
34
|
+
description: string;
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
export {};
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { buildAbility } from '../auth/ability.js';
|
|
2
|
+
function fieldSchema(field, mode, registry) {
|
|
3
|
+
const description = field.label.ar;
|
|
4
|
+
switch (field.type) {
|
|
5
|
+
case 'integer':
|
|
6
|
+
return { type: 'integer', description };
|
|
7
|
+
case 'money':
|
|
8
|
+
return {
|
|
9
|
+
type: 'string',
|
|
10
|
+
pattern: '^-?\\d+$',
|
|
11
|
+
description: `${description} (minor units as a decimal string)`,
|
|
12
|
+
};
|
|
13
|
+
case 'boolean':
|
|
14
|
+
return { type: 'boolean', description };
|
|
15
|
+
case 'date':
|
|
16
|
+
return { type: 'string', format: 'date', description };
|
|
17
|
+
case 'datetime':
|
|
18
|
+
return { type: 'string', format: 'date-time', description };
|
|
19
|
+
case 'json':
|
|
20
|
+
return { description };
|
|
21
|
+
case 'belongsTo':
|
|
22
|
+
return { type: 'integer', description: `${description} → ${field.resource}` };
|
|
23
|
+
case 'lookup':
|
|
24
|
+
return { type: 'string', description: `${description} (lookup group ${field.group})` };
|
|
25
|
+
case 'attachment':
|
|
26
|
+
return mode === 'write'
|
|
27
|
+
? { type: 'integer', description: `${description} (id returned by POST /attachments)` }
|
|
28
|
+
: {
|
|
29
|
+
type: 'object',
|
|
30
|
+
description,
|
|
31
|
+
properties: {
|
|
32
|
+
id: { type: 'integer' },
|
|
33
|
+
name: { type: 'string' },
|
|
34
|
+
size: { type: 'integer' },
|
|
35
|
+
mimeType: { type: 'string' },
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
case 'hasMany': {
|
|
39
|
+
const child = registry.get(field.resource);
|
|
40
|
+
return {
|
|
41
|
+
type: 'array',
|
|
42
|
+
maxItems: 100,
|
|
43
|
+
description,
|
|
44
|
+
items: { $ref: `#/components/schemas/${child.name}_input` },
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
default:
|
|
48
|
+
return { type: 'string', description };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function nullable(schema, required) {
|
|
52
|
+
if (required)
|
|
53
|
+
return schema;
|
|
54
|
+
if (typeof schema.type === 'string')
|
|
55
|
+
return { ...schema, type: [schema.type, 'null'] };
|
|
56
|
+
return schema;
|
|
57
|
+
}
|
|
58
|
+
function schemas(resource, registry) {
|
|
59
|
+
const readKeys = (resource.serialize ?? [...new Set([...resource.list, ...resource.show])]).filter((key) => resource.fields[key]?.type !== 'hasMany');
|
|
60
|
+
const read = {
|
|
61
|
+
type: 'object',
|
|
62
|
+
description: `${resource.label.ar}. Fields the caller may not read are omitted.`,
|
|
63
|
+
properties: {
|
|
64
|
+
id: { type: 'integer' },
|
|
65
|
+
...Object.fromEntries(readKeys.map((key) => [
|
|
66
|
+
key,
|
|
67
|
+
nullable(fieldSchema(resource.fields[key], 'read', registry), false),
|
|
68
|
+
])),
|
|
69
|
+
...(resource.version ? { version: { type: 'integer' } } : {}),
|
|
70
|
+
...(resource.submittable
|
|
71
|
+
? {
|
|
72
|
+
docStatus: {
|
|
73
|
+
type: 'integer',
|
|
74
|
+
enum: [0, 1, 2],
|
|
75
|
+
description: '0 draft, 1 submitted, 2 cancelled',
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
: {}),
|
|
79
|
+
...(resource.scoped ? { orgUnitId: { type: 'integer' } } : {}),
|
|
80
|
+
},
|
|
81
|
+
required: ['id'],
|
|
82
|
+
};
|
|
83
|
+
const writable = resource.form.filter((key) => !resource.fields[key].sequence);
|
|
84
|
+
const input = {
|
|
85
|
+
type: 'object',
|
|
86
|
+
additionalProperties: false,
|
|
87
|
+
properties: {
|
|
88
|
+
...Object.fromEntries(writable.map((key) => [
|
|
89
|
+
key,
|
|
90
|
+
nullable(fieldSchema(resource.fields[key], 'write', registry), Boolean(resource.fields[key].required)),
|
|
91
|
+
])),
|
|
92
|
+
...(resource.scoped ? { orgUnitId: { type: 'integer' } } : {}),
|
|
93
|
+
...(resource.version
|
|
94
|
+
? { version: { type: 'integer', description: 'Required on update (optimistic locking)' } }
|
|
95
|
+
: {}),
|
|
96
|
+
},
|
|
97
|
+
required: [
|
|
98
|
+
...writable.filter((key) => resource.fields[key].required),
|
|
99
|
+
...(resource.scoped ? ['orgUnitId'] : []),
|
|
100
|
+
],
|
|
101
|
+
};
|
|
102
|
+
return { read, input };
|
|
103
|
+
}
|
|
104
|
+
const error = {
|
|
105
|
+
description: 'Error',
|
|
106
|
+
content: {
|
|
107
|
+
'application/json': {
|
|
108
|
+
schema: {
|
|
109
|
+
type: 'object',
|
|
110
|
+
properties: {
|
|
111
|
+
error: {
|
|
112
|
+
type: 'object',
|
|
113
|
+
properties: { code: { type: 'string' }, message: { type: 'string' } },
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
/**
|
|
121
|
+
* OpenAPI 3.1 description of the resource API, generated from the registry. When
|
|
122
|
+
* an actor is given, only resources and actions it may use are described.
|
|
123
|
+
*/
|
|
124
|
+
export function openApiDocument(registry, options) {
|
|
125
|
+
const base = options.basePath ?? '/api/v1';
|
|
126
|
+
const paths = {};
|
|
127
|
+
const components = {};
|
|
128
|
+
const children = new Set(registry
|
|
129
|
+
.all()
|
|
130
|
+
.flatMap((resource) => Object.values(resource.fields).flatMap((field) => field.type === 'hasMany' ? [field.resource] : [])));
|
|
131
|
+
for (const resource of registry.all()) {
|
|
132
|
+
const ability = options.actor ? buildAbility(options.actor.rules, registry.all()) : undefined;
|
|
133
|
+
const allowed = new Set(resource.actions.filter((action) => !ability || ability.can(action, resource.name)));
|
|
134
|
+
if (ability && !allowed.has('view'))
|
|
135
|
+
continue;
|
|
136
|
+
const { read, input } = schemas(resource, registry);
|
|
137
|
+
components[resource.name] = read;
|
|
138
|
+
components[`${resource.name}_input`] = input;
|
|
139
|
+
if (children.has(resource.name))
|
|
140
|
+
continue;
|
|
141
|
+
const ref = { $ref: `#/components/schemas/${resource.name}` };
|
|
142
|
+
const inputRef = { $ref: `#/components/schemas/${resource.name}_input` };
|
|
143
|
+
const tag = resource.label.en;
|
|
144
|
+
const one = (description) => ({
|
|
145
|
+
description,
|
|
146
|
+
content: { 'application/json': { schema: { type: 'object', properties: { data: ref } } } },
|
|
147
|
+
});
|
|
148
|
+
const idParam = { name: 'id', in: 'path', required: true, schema: { type: 'integer' } };
|
|
149
|
+
const collection = {};
|
|
150
|
+
const item = {};
|
|
151
|
+
if (allowed.has('view')) {
|
|
152
|
+
collection.get = {
|
|
153
|
+
tags: [tag],
|
|
154
|
+
summary: `List ${resource.label.en}`,
|
|
155
|
+
parameters: [
|
|
156
|
+
{ name: 'limit', in: 'query', schema: { type: 'integer', maximum: 100 } },
|
|
157
|
+
{ name: 'cursor', in: 'query', schema: { type: 'string' } },
|
|
158
|
+
{ name: 'search', in: 'query', schema: { type: 'string' } },
|
|
159
|
+
{ name: 'sort', in: 'query', schema: { type: 'string' } },
|
|
160
|
+
{ name: 'direction', in: 'query', schema: { enum: ['asc', 'desc'] } },
|
|
161
|
+
{ name: 'tag', in: 'query', schema: { type: 'string' } },
|
|
162
|
+
],
|
|
163
|
+
responses: {
|
|
164
|
+
200: {
|
|
165
|
+
description: 'Keyset page',
|
|
166
|
+
content: {
|
|
167
|
+
'application/json': {
|
|
168
|
+
schema: {
|
|
169
|
+
type: 'object',
|
|
170
|
+
properties: {
|
|
171
|
+
data: { type: 'array', items: ref },
|
|
172
|
+
meta: {
|
|
173
|
+
type: 'object',
|
|
174
|
+
properties: {
|
|
175
|
+
limit: { type: 'integer' },
|
|
176
|
+
nextCursor: { type: ['string', 'null'] },
|
|
177
|
+
estimatedTotal: { type: 'integer' },
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
403: error,
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
item.get = {
|
|
189
|
+
tags: [tag],
|
|
190
|
+
summary: `Show one ${resource.label.en} record`,
|
|
191
|
+
parameters: [idParam],
|
|
192
|
+
responses: { 200: one('Record'), 403: error, 404: error },
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
if (allowed.has('create'))
|
|
196
|
+
collection.post = {
|
|
197
|
+
tags: [tag],
|
|
198
|
+
summary: `Create ${resource.label.en}`,
|
|
199
|
+
requestBody: { required: true, content: { 'application/json': { schema: inputRef } } },
|
|
200
|
+
responses: { 201: one('Created'), 403: error, 409: error, 422: error },
|
|
201
|
+
};
|
|
202
|
+
if (allowed.has('update'))
|
|
203
|
+
item.patch = {
|
|
204
|
+
tags: [tag],
|
|
205
|
+
summary: `Update ${resource.label.en}`,
|
|
206
|
+
parameters: [idParam],
|
|
207
|
+
requestBody: { required: true, content: { 'application/json': { schema: inputRef } } },
|
|
208
|
+
responses: { 200: one('Updated'), 403: error, 404: error, 409: error, 422: error },
|
|
209
|
+
};
|
|
210
|
+
if (allowed.has('delete'))
|
|
211
|
+
item.delete = {
|
|
212
|
+
tags: [tag],
|
|
213
|
+
summary: `Soft-delete ${resource.label.en}`,
|
|
214
|
+
parameters: [idParam],
|
|
215
|
+
responses: { 200: one('Deleted'), 403: error, 404: error, 409: error },
|
|
216
|
+
};
|
|
217
|
+
if (Object.keys(collection).length)
|
|
218
|
+
paths[`${base}/resources/${resource.name}`] = collection;
|
|
219
|
+
if (Object.keys(item).length)
|
|
220
|
+
paths[`${base}/resources/${resource.name}/{id}`] = item;
|
|
221
|
+
for (const action of ['submit', 'cancel'])
|
|
222
|
+
if (resource.submittable && allowed.has(action))
|
|
223
|
+
paths[`${base}/resources/${resource.name}/{id}/${action}`] = {
|
|
224
|
+
post: {
|
|
225
|
+
tags: [tag],
|
|
226
|
+
summary: `${action === 'submit' ? 'Submit' : 'Cancel'} ${resource.label.en}`,
|
|
227
|
+
parameters: [idParam],
|
|
228
|
+
requestBody: {
|
|
229
|
+
content: {
|
|
230
|
+
'application/json': {
|
|
231
|
+
schema: { type: 'object', properties: { version: { type: 'integer' } } },
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
responses: { 200: one('Transitioned'), 403: error, 404: error, 409: error },
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
return {
|
|
240
|
+
openapi: '3.1.0',
|
|
241
|
+
info: { title: options.title, version: options.version },
|
|
242
|
+
servers: [{ url: options.serverUrl }],
|
|
243
|
+
security: [{ bearer: [] }],
|
|
244
|
+
paths,
|
|
245
|
+
components: {
|
|
246
|
+
schemas: components,
|
|
247
|
+
securitySchemes: {
|
|
248
|
+
bearer: {
|
|
249
|
+
type: 'http',
|
|
250
|
+
scheme: 'bearer',
|
|
251
|
+
description: 'Personal API token. Read tokens are limited to GET requests.',
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ResourceDescription } from '../admin/presentation.js';
|
|
2
|
+
import type { SerializedRecord } from '../resource/types.js';
|
|
3
|
+
export type PrintIdentity = {
|
|
4
|
+
name: string;
|
|
5
|
+
logoUrl?: string | null;
|
|
6
|
+
};
|
|
7
|
+
export type PrintInput = {
|
|
8
|
+
identity: PrintIdentity;
|
|
9
|
+
resource: ResourceDescription;
|
|
10
|
+
record: SerializedRecord;
|
|
11
|
+
related?: Record<string, SerializedRecord[]>;
|
|
12
|
+
lookups?: Record<string, {
|
|
13
|
+
value: string;
|
|
14
|
+
label: string;
|
|
15
|
+
}[]>;
|
|
16
|
+
children?: Record<string, {
|
|
17
|
+
rows: SerializedRecord[];
|
|
18
|
+
fields: ResourceDescription['fields'];
|
|
19
|
+
}>;
|
|
20
|
+
printedBy: string;
|
|
21
|
+
printedAt?: Date;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* The generic printable view of one record: RTL A4 HTML, every value escaped.
|
|
25
|
+
* Only fields already serialized for the caller are printed, so printing can
|
|
26
|
+
* never reveal more than the detail page.
|
|
27
|
+
*/
|
|
28
|
+
export declare function renderPrintHtml(input: PrintInput): string;
|
|
29
|
+
/**
|
|
30
|
+
* Converts printable HTML to PDF with an optional Gotenberg service
|
|
31
|
+
* (POST /forms/chromium/convert/html). Without it, pages use the browser's print.
|
|
32
|
+
*/
|
|
33
|
+
export declare function htmlToPdf(html: string, options: {
|
|
34
|
+
gotenbergUrl: string;
|
|
35
|
+
timeoutMs?: number;
|
|
36
|
+
fetch?: typeof fetch;
|
|
37
|
+
}): Promise<Buffer<ArrayBuffer>>;
|