@adula/kit 0.2.0-alpha.3 → 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.
Files changed (72) hide show
  1. package/README.md +12 -2
  2. package/build/agent/capabilities.md +49 -7
  3. package/build/agent/skills/idea-review/SKILL.md +26 -1
  4. package/build/agent/skills/module-review/SKILL.md +22 -0
  5. package/build/agent/skills/perf-review/SKILL.md +24 -0
  6. package/build/agent/skills/schema-review/SKILL.md +22 -0
  7. package/build/agent/skills/security-review/SKILL.md +24 -0
  8. package/build/agent/skills/ui-review/SKILL.md +22 -0
  9. package/build/commands/capabilities.d.ts +4 -0
  10. package/build/commands/capabilities.js +35 -4
  11. package/build/commands/gaps.js +5 -8
  12. package/build/commands/main.d.ts +4 -2
  13. package/build/commands/main.js +2 -0
  14. package/build/database/migrations/1770000000004_kit_collaboration.d.ts +5 -0
  15. package/build/database/migrations/1770000000004_kit_collaboration.js +10 -0
  16. package/build/database/migrations/1770000000005_kit_assignments.d.ts +5 -0
  17. package/build/database/migrations/1770000000005_kit_assignments.js +10 -0
  18. package/build/database/migrations/1770000000006_kit_messaging.d.ts +5 -0
  19. package/build/database/migrations/1770000000006_kit_messaging.js +10 -0
  20. package/build/database/migrations/1770000000007_kit_webhooks.d.ts +5 -0
  21. package/build/database/migrations/1770000000007_kit_webhooks.js +10 -0
  22. package/build/database/migrations/1770000000008_kit_imports.d.ts +5 -0
  23. package/build/database/migrations/1770000000008_kit_imports.js +10 -0
  24. package/build/database/migrations/1770000000010_kit_workflows.d.ts +5 -0
  25. package/build/database/migrations/1770000000010_kit_workflows.js +10 -0
  26. package/build/index.d.ts +22 -2
  27. package/build/index.js +13 -2
  28. package/build/src/admin/contracts.js +19 -10
  29. package/build/src/admin/controller.d.ts +1 -0
  30. package/build/src/admin/controller.js +6 -0
  31. package/build/src/admin/resource_service.d.ts +48 -3
  32. package/build/src/admin/resource_service.js +194 -18
  33. package/build/src/attachments/attachment_service.d.ts +18 -1
  34. package/build/src/attachments/attachment_service.js +62 -0
  35. package/build/src/auth/ability.d.ts +2 -0
  36. package/build/src/collaboration/assignments.d.ts +78 -0
  37. package/build/src/collaboration/assignments.js +219 -0
  38. package/build/src/collaboration/record_collaboration.d.ts +86 -0
  39. package/build/src/collaboration/record_collaboration.js +360 -0
  40. package/build/src/commands/agent_assets.js +5 -0
  41. package/build/src/commands/capabilities.d.ts +19 -0
  42. package/build/src/commands/capabilities.js +160 -0
  43. package/build/src/commands/gap_report.d.ts +8 -0
  44. package/build/src/commands/gap_report.js +12 -0
  45. package/build/src/core/message_templates.d.ts +80 -0
  46. package/build/src/core/message_templates.js +288 -0
  47. package/build/src/core/saved_views.js +31 -6
  48. package/build/src/core/user_invitations.js +2 -2
  49. package/build/src/database/schema.d.ts +18 -0
  50. package/build/src/database/schema.js +192 -0
  51. package/build/src/events/outbox.d.ts +1 -0
  52. package/build/src/events/outbox.js +1 -1
  53. package/build/src/events/record_mutation.d.ts +9 -0
  54. package/build/src/events/record_mutation.js +18 -3
  55. package/build/src/integrations/imports.d.ts +74 -0
  56. package/build/src/integrations/imports.js +331 -0
  57. package/build/src/integrations/openapi.d.ts +39 -0
  58. package/build/src/integrations/openapi.js +256 -0
  59. package/build/src/integrations/print.d.ts +37 -0
  60. package/build/src/integrations/print.js +123 -0
  61. package/build/src/integrations/webhooks.d.ts +98 -0
  62. package/build/src/integrations/webhooks.js +298 -0
  63. package/build/src/resource/define_resource.d.ts +1 -0
  64. package/build/src/resource/define_resource.js +9 -1
  65. package/build/src/resource/registry.d.ts +2 -0
  66. package/build/src/resource/registry.js +9 -0
  67. package/build/src/resource/types.d.ts +13 -1
  68. package/build/src/workflows/define_workflow.d.ts +95 -0
  69. package/build/src/workflows/define_workflow.js +83 -0
  70. package/build/src/workflows/engine.d.ts +119 -0
  71. package/build/src/workflows/engine.js +687 -0
  72. package/package.json +7 -13
@@ -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>>;