@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.
Files changed (63) 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/main.d.ts +4 -2
  12. package/build/database/migrations/1770000000004_kit_collaboration.d.ts +5 -0
  13. package/build/database/migrations/1770000000004_kit_collaboration.js +10 -0
  14. package/build/database/migrations/1770000000005_kit_assignments.d.ts +5 -0
  15. package/build/database/migrations/1770000000005_kit_assignments.js +10 -0
  16. package/build/database/migrations/1770000000006_kit_messaging.d.ts +5 -0
  17. package/build/database/migrations/1770000000006_kit_messaging.js +10 -0
  18. package/build/database/migrations/1770000000007_kit_webhooks.d.ts +5 -0
  19. package/build/database/migrations/1770000000007_kit_webhooks.js +10 -0
  20. package/build/database/migrations/1770000000008_kit_imports.d.ts +5 -0
  21. package/build/database/migrations/1770000000008_kit_imports.js +10 -0
  22. package/build/database/migrations/1770000000010_kit_workflows.d.ts +5 -0
  23. package/build/database/migrations/1770000000010_kit_workflows.js +10 -0
  24. package/build/index.d.ts +21 -1
  25. package/build/index.js +12 -1
  26. package/build/src/admin/contracts.js +19 -10
  27. package/build/src/admin/controller.d.ts +1 -0
  28. package/build/src/admin/controller.js +6 -0
  29. package/build/src/admin/resource_service.d.ts +21 -0
  30. package/build/src/admin/resource_service.js +163 -7
  31. package/build/src/collaboration/assignments.d.ts +78 -0
  32. package/build/src/collaboration/assignments.js +219 -0
  33. package/build/src/collaboration/record_collaboration.d.ts +86 -0
  34. package/build/src/collaboration/record_collaboration.js +360 -0
  35. package/build/src/commands/agent_assets.js +5 -0
  36. package/build/src/commands/capabilities.d.ts +19 -0
  37. package/build/src/commands/capabilities.js +160 -0
  38. package/build/src/core/message_templates.d.ts +80 -0
  39. package/build/src/core/message_templates.js +288 -0
  40. package/build/src/database/schema.d.ts +18 -0
  41. package/build/src/database/schema.js +192 -0
  42. package/build/src/events/outbox.d.ts +1 -0
  43. package/build/src/events/outbox.js +1 -1
  44. package/build/src/events/record_mutation.d.ts +7 -0
  45. package/build/src/events/record_mutation.js +13 -2
  46. package/build/src/integrations/imports.d.ts +74 -0
  47. package/build/src/integrations/imports.js +331 -0
  48. package/build/src/integrations/openapi.d.ts +39 -0
  49. package/build/src/integrations/openapi.js +256 -0
  50. package/build/src/integrations/print.d.ts +37 -0
  51. package/build/src/integrations/print.js +123 -0
  52. package/build/src/integrations/webhooks.d.ts +98 -0
  53. package/build/src/integrations/webhooks.js +298 -0
  54. package/build/src/resource/define_resource.d.ts +1 -0
  55. package/build/src/resource/define_resource.js +9 -1
  56. package/build/src/resource/registry.d.ts +2 -0
  57. package/build/src/resource/registry.js +9 -0
  58. package/build/src/resource/types.d.ts +3 -0
  59. package/build/src/workflows/define_workflow.d.ts +95 -0
  60. package/build/src/workflows/define_workflow.js +83 -0
  61. package/build/src/workflows/engine.d.ts +119 -0
  62. package/build/src/workflows/engine.js +687 -0
  63. package/package.json +7 -3
@@ -0,0 +1,360 @@
1
+ import { subject } from '@casl/ability';
2
+ import { jsonValue } from '../admin/contracts.js';
3
+ import { KitError } from '../admin/errors.js';
4
+ import { notifyWithTemplate } from '../core/message_templates.js';
5
+ const BODY_LIMIT = 5000;
6
+ const MENTION_LIMIT = 20;
7
+ const TAG_LIMIT = 20;
8
+ const TAG_PATTERN = /^[\p{L}\p{N}][\p{L}\p{N} _-]{0,59}$/u;
9
+ /** Minimum permission level of a field for the current resource definition. */
10
+ function fieldLevel(resource, key) {
11
+ return Math.max(resource.fields[key]?.permissionLevel ?? 0, resource.hidden?.includes(key) ? 1 : 0);
12
+ }
13
+ function readableField(resource, record, ability, actor, key) {
14
+ const serialized = resource.serialize ?? [...resource.list, ...resource.show];
15
+ return (key in resource.fields &&
16
+ serialized.includes(key) &&
17
+ actor.permissionLevel >= fieldLevel(resource, key) &&
18
+ ability.can('view', subject(resource.name, record), key));
19
+ }
20
+ /**
21
+ * Collaboration around a record: comments with mentions, followers, tags and the
22
+ * per-field change history. Every read and write re-authorizes the record itself
23
+ * through ResourceService, so these features can never widen record access.
24
+ */
25
+ export class RecordCollaboration {
26
+ db;
27
+ resources;
28
+ actors;
29
+ constructor(db, resources, actors) {
30
+ this.db = db;
31
+ this.resources = resources;
32
+ this.actors = actors;
33
+ }
34
+ async state(name, id, actor) {
35
+ const { resource, record, ability } = await this.resources.access(name, id, actor);
36
+ const rows = await this.db('comments as c')
37
+ .leftJoin('users as u', 'u.id', 'c.author_id')
38
+ .where({ 'c.resource': name, 'c.record_id': id })
39
+ .whereNull('c.deleted_at')
40
+ .orderBy('c.id', 'desc')
41
+ .limit(51)
42
+ .select('c.*', 'u.full_name as author_name');
43
+ const page = rows.slice(0, 50);
44
+ const mentions = page.length
45
+ ? await this.db('comment_mentions as m')
46
+ .join('users as u', 'u.id', 'm.user_id')
47
+ .whereIn('m.comment_id', page.map((row) => row.id))
48
+ .select('m.comment_id', 'u.id', 'u.full_name')
49
+ : [];
50
+ const tags = await this.tags(name, id);
51
+ const follow = await this.db('followers')
52
+ .where({ resource: name, record_id: id })
53
+ .select(this.db.raw('count(*)::int as count'), this.db.raw('bool_or(user_id = ?) as own', [actor.id]))
54
+ .first();
55
+ return {
56
+ comments: page.reverse().map((row) => ({
57
+ id: Number(row.id),
58
+ body: String(row.body),
59
+ authorId: Number(row.author_id),
60
+ authorName: row.author_name ? String(row.author_name) : null,
61
+ mentions: mentions
62
+ .filter((mention) => Number(mention.comment_id) === Number(row.id))
63
+ .map((mention) => ({ id: Number(mention.id), name: String(mention.full_name ?? '') })),
64
+ createdAt: new Date(row.created_at).toISOString(),
65
+ editedAt: row.edited_at ? new Date(row.edited_at).toISOString() : null,
66
+ own: Number(row.author_id) === actor.id,
67
+ })),
68
+ hasMoreComments: rows.length > 50,
69
+ tags,
70
+ following: Boolean(follow?.own),
71
+ followers: Number(follow?.count ?? 0),
72
+ changes: await this.readChanges(resource, record, ability, actor, id),
73
+ canComment: true,
74
+ canTag: resource.actions.includes('update') &&
75
+ (await this.resources.permits(name, id, actor, 'update')),
76
+ };
77
+ }
78
+ async readChanges(resource, record, ability, actor, id) {
79
+ const rows = await this.db('field_changes as f')
80
+ .join('activities as a', 'a.id', 'f.activity_id')
81
+ .leftJoin('users as u', 'u.id', 'a.actor_id')
82
+ .where({ 'f.resource': resource.name, 'f.record_id': id })
83
+ .orderBy('f.id', 'desc')
84
+ .limit(200)
85
+ .select('f.*', 'a.created_at', 'u.full_name as actor_name');
86
+ // Values are filtered per viewer: a private field's history is as private as the field.
87
+ return rows
88
+ .filter((row) => readableField(resource, record, ability, actor, String(row.field)))
89
+ .slice(0, 100)
90
+ .map((row) => ({
91
+ id: Number(row.id),
92
+ field: String(row.field),
93
+ before: jsonValue(row.before),
94
+ after: jsonValue(row.after),
95
+ actorName: row.actor_name ? String(row.actor_name) : null,
96
+ createdAt: new Date(row.created_at).toISOString(),
97
+ }));
98
+ }
99
+ async comment(name, id, actor, input) {
100
+ await this.resources.access(name, id, actor);
101
+ const body = typeof input.body === 'string' ? input.body.trim() : '';
102
+ if (!body || body.length > BODY_LIMIT)
103
+ throw new KitError(422, 'E_COMMENT_BODY', 'نص التعليق مطلوب ولا يتجاوز 5000 حرف');
104
+ const requested = this.userIds(input.mentions);
105
+ // Mentioning someone who cannot read the record would leak it through the notification.
106
+ const mentioned = [];
107
+ for (const userId of requested) {
108
+ if (userId === actor.id)
109
+ continue;
110
+ if (await this.canView(name, id, userId))
111
+ mentioned.push(userId);
112
+ else
113
+ throw new KitError(422, 'E_MENTION', 'لا يمكن الإشارة إلى مستخدم لا يملك صلاحية عرض السجل');
114
+ }
115
+ return this.db.transaction(async (trx) => {
116
+ const [row] = await trx('comments')
117
+ .insert({ resource: name, record_id: id, author_id: actor.id, body })
118
+ .returning('*');
119
+ if (mentioned.length)
120
+ await trx('comment_mentions').insert(mentioned.map((userId) => ({ comment_id: row.id, user_id: userId })));
121
+ await trx('followers')
122
+ .insert({ resource: name, record_id: id, user_id: actor.id })
123
+ .onConflict(['resource', 'record_id', 'user_id'])
124
+ .ignore();
125
+ const author = await trx('users').where('id', actor.id).first('full_name');
126
+ const authorName = author?.full_name ? String(author.full_name) : `مستخدم #${actor.id}`;
127
+ const label = this.label(name);
128
+ for (const userId of mentioned)
129
+ await notifyWithTemplate(trx, userId, 'comment.mentioned', {
130
+ author: authorName,
131
+ resource: label,
132
+ id,
133
+ excerpt: body.slice(0, 200),
134
+ });
135
+ const followers = await trx('followers')
136
+ .where({ resource: name, record_id: id })
137
+ .whereNot('user_id', actor.id)
138
+ .whereNotIn('user_id', mentioned.length ? mentioned : [0])
139
+ .pluck('user_id');
140
+ for (const userId of followers) {
141
+ if (!(await this.canView(name, id, Number(userId))))
142
+ continue;
143
+ await notifyWithTemplate(trx, Number(userId), 'comment.created', {
144
+ author: authorName,
145
+ resource: label,
146
+ id,
147
+ excerpt: body.slice(0, 200),
148
+ });
149
+ }
150
+ const names = mentioned.length
151
+ ? await trx('users').whereIn('id', mentioned).select('id', 'full_name')
152
+ : [];
153
+ return {
154
+ id: Number(row.id),
155
+ body,
156
+ authorId: actor.id,
157
+ authorName: author?.full_name ? String(author.full_name) : null,
158
+ mentions: names.map((user) => ({
159
+ id: Number(user.id),
160
+ name: String(user.full_name ?? ''),
161
+ })),
162
+ createdAt: new Date(row.created_at).toISOString(),
163
+ editedAt: null,
164
+ own: true,
165
+ };
166
+ });
167
+ }
168
+ /** Authors edit or remove their own comments while they can still read the record. */
169
+ async editComment(name, id, comment, actor, body) {
170
+ await this.resources.access(name, id, actor);
171
+ const commentId = this.commentId(comment);
172
+ const text = typeof body === 'string' ? body.trim() : '';
173
+ if (!text || text.length > BODY_LIMIT)
174
+ throw new KitError(422, 'E_COMMENT_BODY', 'نص التعليق مطلوب ولا يتجاوز 5000 حرف');
175
+ const updated = await this.db('comments')
176
+ .where({ id: commentId, resource: name, record_id: id, author_id: actor.id })
177
+ .whereNull('deleted_at')
178
+ .update({ body: text, edited_at: this.db.fn.now() });
179
+ if (!updated)
180
+ throw new KitError(404, 'E_COMMENT_NOT_FOUND', 'التعليق غير موجود');
181
+ }
182
+ async deleteComment(name, id, comment, actor) {
183
+ await this.resources.access(name, id, actor);
184
+ const commentId = this.commentId(comment);
185
+ const deleted = await this.db('comments')
186
+ .where({ id: commentId, resource: name, record_id: id, author_id: actor.id })
187
+ .whereNull('deleted_at')
188
+ .update({ deleted_at: this.db.fn.now() });
189
+ if (!deleted)
190
+ throw new KitError(404, 'E_COMMENT_NOT_FOUND', 'التعليق غير موجود');
191
+ }
192
+ async follow(name, id, actor, following) {
193
+ await this.resources.access(name, id, actor);
194
+ if (following)
195
+ await this.db('followers')
196
+ .insert({ resource: name, record_id: id, user_id: actor.id })
197
+ .onConflict(['resource', 'record_id', 'user_id'])
198
+ .ignore();
199
+ else
200
+ await this.db('followers').where({ resource: name, record_id: id, user_id: actor.id }).del();
201
+ }
202
+ /** Replaces the record's tags. Tagging changes how a record is found, so it needs update. */
203
+ async setTags(name, id, actor, input) {
204
+ const { resource } = await this.resources.access(name, id, actor);
205
+ if (!resource.actions.includes('update'))
206
+ throw new KitError(403, 'E_FORBIDDEN', 'ليس لديك صلاحية لهذا الإجراء');
207
+ await this.resources.access(name, id, actor, 'update');
208
+ if (!Array.isArray(input) || input.length > TAG_LIMIT)
209
+ throw new KitError(422, 'E_TAGS', 'الوسوم قائمة لا تتجاوز 20 وسماً');
210
+ const names = [
211
+ ...new Set(input.map((tag) => {
212
+ const value = typeof tag === 'string' ? tag.trim() : '';
213
+ if (!TAG_PATTERN.test(value))
214
+ throw new KitError(422, 'E_TAGS', 'الوسم حروف وأرقام ومسافات فقط ولا يتجاوز 60 حرفاً');
215
+ return value;
216
+ })),
217
+ ];
218
+ await this.db.transaction(async (trx) => {
219
+ if (names.length)
220
+ await trx('tags')
221
+ .insert(names.map((tag) => ({ name: tag })))
222
+ .onConflict('name')
223
+ .ignore();
224
+ const ids = names.length ? await trx('tags').whereIn('name', names).pluck('id') : [];
225
+ await trx('taggables').where({ resource: name, record_id: id }).del();
226
+ if (ids.length)
227
+ await trx('taggables').insert(ids.map((tagId) => ({ tag_id: tagId, resource: name, record_id: id })));
228
+ });
229
+ return this.tags(name, id);
230
+ }
231
+ async tags(name, id) {
232
+ const names = await this.db('taggables as t')
233
+ .join('tags', 'tags.id', 't.tag_id')
234
+ .where({ 't.resource': name, 't.record_id': id })
235
+ .orderBy('tags.name')
236
+ .pluck('tags.name');
237
+ return names.map(String);
238
+ }
239
+ /** Tag vocabulary used on records of a resource the actor may list. */
240
+ async tagOptions(name, actor) {
241
+ this.resources.describe(name, actor);
242
+ const names = await this.db('taggables as t')
243
+ .join('tags', 'tags.id', 't.tag_id')
244
+ .where('t.resource', name)
245
+ .distinct('tags.name')
246
+ .orderBy('tags.name')
247
+ .limit(200)
248
+ .pluck('tags.name');
249
+ return names.map(String);
250
+ }
251
+ /** Active users that may read the record, for the mention picker. */
252
+ async mentionCandidates(name, id, actor, search = '') {
253
+ await this.resources.access(name, id, actor);
254
+ const term = String(search).trim().slice(0, 60);
255
+ const result = [];
256
+ // Readers are filtered per user, so page through candidates until ten readers
257
+ // are found; a bounded scan keeps a large directory from turning into a sweep.
258
+ const page = 100;
259
+ for (let offset = 0; result.length < 10 && offset < 2000; offset += page) {
260
+ const query = this.db('users')
261
+ .whereNot('id', actor.id)
262
+ .whereNull('disabled_at')
263
+ .orderBy([{ column: 'full_name' }, { column: 'id' }])
264
+ .offset(offset)
265
+ .limit(page)
266
+ .select('id', 'full_name');
267
+ if (term)
268
+ query.where((where) => where.whereILike('full_name', `%${term}%`).orWhereILike('email', `${term}%`));
269
+ const users = await query;
270
+ for (const user of users) {
271
+ if (result.length >= 10)
272
+ break;
273
+ if (await this.canView(name, id, Number(user.id)))
274
+ result.push({ id: Number(user.id), name: String(user.full_name ?? `#${user.id}`) });
275
+ }
276
+ if (users.length < page)
277
+ break;
278
+ }
279
+ return result;
280
+ }
281
+ /**
282
+ * Listener factory: followers hear about updates and document transitions of the
283
+ * records they follow, but only while they can still read the record.
284
+ */
285
+ followerListener(module, resource, event) {
286
+ return {
287
+ name: `kit.followers.${module}.${resource}.${event}`,
288
+ event: `${module}.${resource}.${event}`,
289
+ handle: async (domainEvent, trx) => {
290
+ const id = Number(domainEvent.payload.id);
291
+ const actorId = Number(domainEvent.payload.actorId);
292
+ const followers = await trx('followers')
293
+ .where({ resource, record_id: id })
294
+ .whereNot('user_id', actorId)
295
+ .pluck('user_id');
296
+ const verbs = {
297
+ updated: 'تم تعديل',
298
+ submitted: 'تم اعتماد',
299
+ cancelled: 'تم إلغاء',
300
+ deleted: 'تم حذف',
301
+ };
302
+ for (const userId of followers) {
303
+ if (event !== 'deleted' && !(await this.canView(resource, id, Number(userId))))
304
+ continue;
305
+ await notifyWithTemplate(trx, Number(userId), 'record.changed', {
306
+ change: verbs[event] ?? 'تحديث',
307
+ resource: this.label(resource),
308
+ id,
309
+ });
310
+ }
311
+ },
312
+ };
313
+ }
314
+ label(name) {
315
+ try {
316
+ return this.resources.label(name);
317
+ }
318
+ catch {
319
+ return name;
320
+ }
321
+ }
322
+ async canView(name, id, userId) {
323
+ const disabled = await this.db('users').where('id', userId).first('disabled_at');
324
+ if (!disabled || disabled.disabled_at)
325
+ return false;
326
+ return this.resources.permits(name, id, await this.actors.load(userId));
327
+ }
328
+ /** Parsed after the record is authorized, so malformed ids never bypass the 403. */
329
+ commentId(value) {
330
+ const id = Number(value);
331
+ if (!Number.isSafeInteger(id) || id <= 0)
332
+ throw new KitError(404, 'E_COMMENT_NOT_FOUND', 'التعليق غير موجود');
333
+ return id;
334
+ }
335
+ userIds(value) {
336
+ if (value === undefined || value === null)
337
+ return [];
338
+ if (!Array.isArray(value) || value.length > MENTION_LIMIT)
339
+ throw new KitError(422, 'E_MENTION', 'الإشارات قائمة لا تتجاوز 20 مستخدماً');
340
+ return [
341
+ ...new Set(value.map((entry) => {
342
+ const id = Number(entry);
343
+ if (!Number.isSafeInteger(id) || id <= 0)
344
+ throw new KitError(422, 'E_MENTION', 'معرّف المستخدم غير صالح');
345
+ return id;
346
+ })),
347
+ ];
348
+ }
349
+ }
350
+ /** Follower notifications for every registered resource's update and document events. */
351
+ export function followerListeners(registry, collaboration) {
352
+ return registry.all().flatMap((resource) => ['updated', 'submitted', 'cancelled', 'deleted'].map((event) => {
353
+ const module = registry.owner(resource.name);
354
+ return {
355
+ name: `kit.followers.${module}.${resource.name}.${event}`,
356
+ event: `${module}.${resource.name}.${event}`,
357
+ handle: (domainEvent, trx) => collaboration().followerListener(module, resource.name, event).handle(domainEvent, trx),
358
+ };
359
+ }));
360
+ }
@@ -16,6 +16,11 @@ export async function agentAssets() {
16
16
  for (const [source, target] of [
17
17
  ['idea-review', 'adula-idea-review'],
18
18
  ['adula-frontend-design', 'adula-frontend-design'],
19
+ ['module-review', 'adula-module-review'],
20
+ ['security-review', 'adula-security-review'],
21
+ ['schema-review', 'adula-schema-review'],
22
+ ['ui-review', 'adula-ui-review'],
23
+ ['perf-review', 'adula-perf-review'],
19
24
  ]) {
20
25
  skills[`.agents/skills/${target}/SKILL.md`] = await readFile(new URL(`../../agent/skills/${source}/SKILL.md`, import.meta.url), 'utf8');
21
26
  }
@@ -0,0 +1,19 @@
1
+ import type { ResourceRegistry } from '../resource/registry.js';
2
+ /** Field kinds accepted by defineResource, with their storage. */
3
+ export declare const FIELD_TYPES: Record<string, string>;
4
+ export declare const FIELD_OPTIONS: string[];
5
+ export declare const RESOURCE_OPTIONS: string[];
6
+ export declare const CONDITION_OPERATORS: string[];
7
+ export declare const WORKFLOW_STEPS: string[];
8
+ /**
9
+ * Kit services by concern. Every name is a public export of @adula/kit; a test
10
+ * checks this list against the package entry so the catalog cannot drift.
11
+ */
12
+ export declare const SERVICES: Record<string, string[]>;
13
+ export declare const EXTENSION_POINTS: string[];
14
+ export declare const OUTSIDE_THE_KIT: string[];
15
+ /** The capability catalog read by idea-review: generated, never hand-edited. */
16
+ export declare function capabilityCatalog(options?: {
17
+ registry?: ResourceRegistry;
18
+ commands?: string[];
19
+ }): string;
@@ -0,0 +1,160 @@
1
+ import { KIT_VERSION } from '../version.js';
2
+ /** Field kinds accepted by defineResource, with their storage. */
3
+ export const FIELD_TYPES = {
4
+ string: 'varchar',
5
+ text: 'text',
6
+ integer: 'integer',
7
+ money: 'bigint minor units, decimal string in JSON',
8
+ boolean: 'boolean',
9
+ date: 'date (YYYY-MM-DD)',
10
+ datetime: 'timestamptz',
11
+ json: 'jsonb',
12
+ attachment: 'attachments row id; per-field accept/maxSize',
13
+ belongsTo: 'foreign key, preloaded, restrict on delete',
14
+ hasMany: 'child resource; inline rows saved with the parent',
15
+ lookup: 'lookups group key',
16
+ };
17
+ export const FIELD_OPTIONS = [
18
+ 'required',
19
+ 'unique (partial, active rows)',
20
+ 'sortable',
21
+ 'searchable (generated tsvector)',
22
+ 'filterable',
23
+ 'permissionLevel',
24
+ 'sequence',
25
+ 'column',
26
+ ];
27
+ export const RESOURCE_OPTIONS = [
28
+ 'scoped (required)',
29
+ 'submittable (docStatus, submit/cancel/amend-by-copy)',
30
+ 'version (optimistic locking, default with submittable)',
31
+ 'customFields',
32
+ 'list / form / show / serialize / hidden',
33
+ 'actions',
34
+ 'hooks.beforeSave / hooks.afterSave',
35
+ ];
36
+ export const CONDITION_OPERATORS = ['$eq', '$ne', '$in', '$lt', '$gt', '$like'];
37
+ export const WORKFLOW_STEPS = ['condition', 'update', 'notify', 'approval', 'delay', 'http', 'end'];
38
+ /**
39
+ * Kit services by concern. Every name is a public export of @adula/kit; a test
40
+ * checks this list against the package entry so the catalog cannot drift.
41
+ */
42
+ export const SERVICES = {
43
+ 'Resources and authorization': [
44
+ 'defineResource',
45
+ 'ResourceRegistry',
46
+ 'ResourceService',
47
+ 'createResourceController',
48
+ 'buildAbility',
49
+ 'accessibleBy',
50
+ 'ActorStore',
51
+ 'packedResourceRules',
52
+ ],
53
+ 'Records and collaboration': [
54
+ 'RecordCollaboration',
55
+ 'followerListeners',
56
+ 'Assignments',
57
+ 'SavedViews',
58
+ 'logActivity',
59
+ ],
60
+ 'Documents and workflows': ['defineWorkflow', 'WorkflowEngine', 'workflowListeners'],
61
+ 'Notifications and messages': [
62
+ 'notify',
63
+ 'notifyWithTemplate',
64
+ 'MessageTemplates',
65
+ 'deliverNotificationMail',
66
+ 'listenForNotifications',
67
+ 'NotificationsAdmin',
68
+ ],
69
+ 'Integration': [
70
+ 'Webhooks',
71
+ 'signWebhook',
72
+ 'openApiDocument',
73
+ 'ImportBatches',
74
+ 'renderPrintHtml',
75
+ 'htmlToPdf',
76
+ ],
77
+ 'Security': ['UserInvitations', 'UsersAdmin', 'RolesAdmin'],
78
+ 'Data and operations': [
79
+ 'sequence',
80
+ 'Settings',
81
+ 'SettingsAdmin',
82
+ 'publishOutbox',
83
+ 'consumeEvent',
84
+ 'recordMutation',
85
+ 'moveOrgUnit',
86
+ 'migrateStorage',
87
+ 'verifyBackup',
88
+ 'runtimeHealth',
89
+ ],
90
+ };
91
+ export const EXTENSION_POINTS = [
92
+ 'Resource hooks (beforeSave, afterSave) inside the save transaction',
93
+ 'Page override: inertia/pages/<resource>/{index,form,show}.tsx replaces the generated page',
94
+ 'Domain events <module>.<resource>.{created,updated,deleted,submitted,cancelled,amended} with idempotent listeners',
95
+ 'Module workflows (Module.workflows) with versioned definitions',
96
+ 'Message templates edited per deployment',
97
+ 'Outgoing webhooks and the bearer-token /api/v1 API',
98
+ ];
99
+ export const OUTSIDE_THE_KIT = [
100
+ 'Dynamic fields or a field editor screen',
101
+ 'Runtime plugins',
102
+ 'A visual workflow editor',
103
+ 'Multi-tenant SaaS in one database (one deployment per organization)',
104
+ 'XLSX import until a maintained parser passes the dependency rule (GAP-006)',
105
+ ];
106
+ /** The capability catalog read by idea-review: generated, never hand-edited. */
107
+ export function capabilityCatalog(options = {}) {
108
+ const lines = [
109
+ `# adula-kit capabilities (${KIT_VERSION})`,
110
+ '',
111
+ 'Generated by `node ace adula:capabilities`. Read this before proposing a module; anything not listed here is not provided by the kit.',
112
+ '',
113
+ '## Resource definition',
114
+ '',
115
+ '| Field type | Storage |',
116
+ '|---|---|',
117
+ ...Object.entries(FIELD_TYPES).map(([type, storage]) => `| ${type} | ${storage} |`),
118
+ '',
119
+ `Field options: ${FIELD_OPTIONS.join(', ')}.`,
120
+ '',
121
+ `Resource options: ${RESOURCE_OPTIONS.join(', ')}.`,
122
+ '',
123
+ `Role rule conditions: ${CONDITION_OPERATORS.join(', ')} on scalar fields; unsupported conditions are refused. Organization scope is always added with AND.`,
124
+ '',
125
+ `Workflow steps: ${WORKFLOW_STEPS.join(', ')}.`,
126
+ '',
127
+ '## Services',
128
+ '',
129
+ ...Object.entries(SERVICES).map(([area, names]) => `- **${area}:** ${names.map((name) => `\`${name}\``).join(', ')}`),
130
+ '',
131
+ '## Extension points',
132
+ '',
133
+ ...EXTENSION_POINTS.map((point) => `- ${point}`),
134
+ '',
135
+ '## Outside the kit',
136
+ '',
137
+ ...OUTSIDE_THE_KIT.map((point) => `- ${point}`),
138
+ ];
139
+ if (options.commands?.length)
140
+ lines.push('', '## Commands', '', ...options.commands.map((command) => `- \`${command}\``));
141
+ if (options.registry) {
142
+ lines.push('', '## This project', '');
143
+ const modules = options.registry.modules();
144
+ if (!modules.length)
145
+ lines.push('No modules are registered yet.');
146
+ for (const module of modules) {
147
+ lines.push(`### ${module.name} — ${module.label.ar}`, '');
148
+ if (module.dependsOn.length)
149
+ lines.push(`Depends on: ${module.dependsOn.join(', ')}`, '');
150
+ for (const resource of module.resources)
151
+ lines.push(`- \`${resource.name}\` (${resource.label.ar}; ${resource.scoped ? 'scoped' : 'central'}${resource.submittable ? ', submittable' : ''}): ${Object.entries(resource.fields)
152
+ .map(([key, field]) => `${key}:${field.type}${'resource' in field ? `→${field.resource}` : ''}`)
153
+ .join(', ')}`);
154
+ for (const workflow of module.workflows ?? [])
155
+ lines.push(`- workflow \`${workflow.name}@${workflow.version}\` on ${workflow.resource}: ${Object.keys(workflow.steps).join(' → ')}`);
156
+ lines.push('');
157
+ }
158
+ }
159
+ return `${lines.join('\n').trimEnd()}\n`;
160
+ }
@@ -0,0 +1,80 @@
1
+ import type { Knex } from 'knex';
2
+ export type TemplateDefinition = {
3
+ label: string;
4
+ subject: string;
5
+ body: string;
6
+ /** Variables available as {{name}}; unknown placeholders are refused on edit. */
7
+ variables: string[];
8
+ /** Whether notifications from this template are also delivered by e-mail. */
9
+ mail: boolean;
10
+ };
11
+ export type MessageTemplate = TemplateDefinition & {
12
+ key: string;
13
+ customized: boolean;
14
+ updatedAt: string | null;
15
+ };
16
+ export type RenderedMessage = {
17
+ subject: string;
18
+ body: string;
19
+ mail: boolean;
20
+ };
21
+ /**
22
+ * Package-owned defaults. Projects edit wording in the message_templates table;
23
+ * an upgrade can change a default without overwriting a project's edit.
24
+ */
25
+ export declare const DEFAULT_TEMPLATES: Record<string, TemplateDefinition>;
26
+ export declare function renderTemplate(text: string, variables: Record<string, unknown>): string;
27
+ /** Message templates: package defaults plus per-deployment overrides edited by administrators. */
28
+ export declare class MessageTemplates {
29
+ private db;
30
+ private definitions;
31
+ constructor(db: Knex, definitions?: Record<string, TemplateDefinition>);
32
+ list(): Promise<MessageTemplate[]>;
33
+ render(key: string, variables: Record<string, unknown>, db?: Knex): Promise<RenderedMessage>;
34
+ update(key: string, input: {
35
+ subject: unknown;
36
+ body: unknown;
37
+ mail?: unknown;
38
+ }, actorId: number): Promise<void>;
39
+ reset(key: string): Promise<void>;
40
+ /** Example output with sample values, for the editor preview. */
41
+ preview(key: string, input: {
42
+ subject: string;
43
+ body: string;
44
+ }): {
45
+ subject: string;
46
+ body: string;
47
+ };
48
+ private definition;
49
+ }
50
+ /**
51
+ * Inserts a templated notification in the caller's transaction. Templates with
52
+ * mail enabled mark the row for the mail delivery worker (deliverNotificationMail).
53
+ */
54
+ export declare function notifyWithTemplate(db: Knex, userId: number, key: string, variables: Record<string, unknown>, templates?: MessageTemplates): Promise<void>;
55
+ export type MailSender = (message: {
56
+ to: string;
57
+ name: string | null;
58
+ subject: string;
59
+ text: string;
60
+ }) => Promise<void>;
61
+ /**
62
+ * Delivers pending notification e-mails. Rows are claimed with SKIP LOCKED so
63
+ * several workers never send the same message; failures are retried three times.
64
+ */
65
+ export declare function deliverNotificationMail(db: Knex, send: MailSender, limit?: number): Promise<{
66
+ sent: number;
67
+ failed: number;
68
+ }>;
69
+ export type NotificationSignal = {
70
+ userId: number;
71
+ id: number;
72
+ };
73
+ /**
74
+ * Holds one database connection that LISTENs for committed notifications and
75
+ * calls onSignal for each. Reconnects after connection loss. Returns a stop function.
76
+ */
77
+ export declare function listenForNotifications(db: Knex, onSignal: (signal: NotificationSignal) => void, options?: {
78
+ retryMs?: number;
79
+ onError?: (error: unknown) => void;
80
+ }): () => Promise<void>;