@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,219 @@
1
+ import { KitError } from '../admin/errors.js';
2
+ import { notifyWithTemplate } from '../core/message_templates.js';
3
+ const TITLE_LIMIT = 200;
4
+ const NOTE_LIMIT = 2000;
5
+ const DATE = /^\d{4}-\d{2}-\d{2}$/;
6
+ function dateOnly(value) {
7
+ if (value === null || value === undefined)
8
+ return null;
9
+ if (value instanceof Date)
10
+ return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}`;
11
+ return String(value);
12
+ }
13
+ /**
14
+ * Assignments put a record on someone's "my tasks" list. Assigning needs update
15
+ * permission on the record, the assignee must be able to read it, and every read
16
+ * re-checks the record so lost access hides the task instead of leaking it.
17
+ */
18
+ export class Assignments {
19
+ db;
20
+ resources;
21
+ actors;
22
+ constructor(db, resources, actors) {
23
+ this.db = db;
24
+ this.resources = resources;
25
+ this.actors = actors;
26
+ }
27
+ async forRecord(name, id, actor) {
28
+ await this.resources.access(name, id, actor);
29
+ const rows = await this.query()
30
+ .where({ 'a.resource': name, 'a.record_id': id })
31
+ .orderByRaw("(a.status = 'open') DESC, a.id DESC")
32
+ .limit(100);
33
+ return rows.map((row) => this.present(row, actor));
34
+ }
35
+ async assign(name, id, actor, input) {
36
+ await this.resources.access(name, id, actor, 'update');
37
+ const assigneeId = Number(input.assigneeId);
38
+ if (!Number.isSafeInteger(assigneeId) || assigneeId <= 0)
39
+ throw new KitError(422, 'E_ASSIGNEE', 'اختر المستخدم المكلف');
40
+ const title = typeof input.title === 'string' ? input.title.trim() : '';
41
+ if (!title || title.length > TITLE_LIMIT)
42
+ throw new KitError(422, 'E_ASSIGNMENT_TITLE', 'عنوان المهمة مطلوب ولا يتجاوز 200 حرف');
43
+ const note = input.note === undefined || input.note === null || input.note === ''
44
+ ? null
45
+ : typeof input.note === 'string' && input.note.length <= NOTE_LIMIT
46
+ ? input.note.trim()
47
+ : null;
48
+ if (input.note && note === null)
49
+ throw new KitError(422, 'E_ASSIGNMENT_NOTE', 'الملاحظة لا تتجاوز 2000 حرف');
50
+ const dueOn = input.dueOn === undefined || input.dueOn === null || input.dueOn === ''
51
+ ? null
52
+ : typeof input.dueOn === 'string' &&
53
+ DATE.test(input.dueOn) &&
54
+ !Number.isNaN(Date.parse(input.dueOn))
55
+ ? input.dueOn
56
+ : undefined;
57
+ if (dueOn === undefined)
58
+ throw new KitError(422, 'E_ASSIGNMENT_DUE', 'تاريخ الاستحقاق غير صالح');
59
+ if (!(await this.canView(name, id, assigneeId)))
60
+ throw new KitError(422, 'E_ASSIGNEE', 'لا يمكن تكليف مستخدم لا يملك صلاحية عرض السجل');
61
+ return this.create(this.db, {
62
+ resource: name,
63
+ recordId: id,
64
+ assigneeId,
65
+ assignedBy: actor.id,
66
+ title,
67
+ note,
68
+ dueOn,
69
+ }).then((created) => this.present(created, actor));
70
+ }
71
+ /**
72
+ * Inserts an assignment and its notification. Used by assign() and by workflow
73
+ * approval steps inside their own transaction; callers authorize beforehand.
74
+ */
75
+ async create(db, input) {
76
+ const insert = async (trx) => {
77
+ const [row] = await trx('assignments')
78
+ .insert({
79
+ resource: input.resource,
80
+ record_id: input.recordId,
81
+ assignee_id: input.assigneeId,
82
+ assigned_by: input.assignedBy,
83
+ kind: input.kind ?? 'task',
84
+ title: input.title,
85
+ note: input.note ?? null,
86
+ due_on: input.dueOn ?? null,
87
+ workflow_run_id: input.workflowRunId ?? null,
88
+ workflow_step: input.workflowStep ?? null,
89
+ })
90
+ .returning('id');
91
+ await notifyWithTemplate(trx, input.assigneeId, input.kind === 'approval' ? 'assignment.approval' : 'assignment.created', {
92
+ title: input.title,
93
+ resource: this.label(input.resource),
94
+ id: input.recordId,
95
+ due: input.dueOn ?? '',
96
+ });
97
+ return this.query(trx).where('a.id', row.id).first();
98
+ };
99
+ return 'isTransaction' in db && db.isTransaction ? insert(db) : this.db.transaction(insert);
100
+ }
101
+ /** The signed-in user's own tasks, open first; records they can no longer read are hidden. */
102
+ async mine(actor, options = {}) {
103
+ const status = options.status === 'done' || options.status === 'all' ? options.status : 'open';
104
+ const limit = Math.max(1, Math.min(100, Math.floor(Number(options.limit) || 50)));
105
+ const query = this.query().where('a.assignee_id', actor.id).orderBy('a.id', 'desc');
106
+ if (status === 'open')
107
+ query.where('a.status', 'open');
108
+ if (status === 'done')
109
+ query.whereNot('a.status', 'open');
110
+ let last = null;
111
+ if (options.cursor !== undefined && options.cursor !== '') {
112
+ last = Number(options.cursor);
113
+ if (!Number.isSafeInteger(last) || last <= 0)
114
+ throw new KitError(422, 'E_CURSOR', 'مؤشر الصفحة غير صالح');
115
+ }
116
+ const data = [];
117
+ let more = true;
118
+ // Access is re-checked per record; scan bounded batches to fill the page.
119
+ for (let round = 0; round < 10 && more && data.length < limit; round++) {
120
+ const rows = await query
121
+ .clone()
122
+ .modify((q) => {
123
+ if (last !== null)
124
+ q.where('a.id', '<', last);
125
+ })
126
+ .limit(100);
127
+ more = rows.length === 100;
128
+ for (const row of rows) {
129
+ if (data.length >= limit) {
130
+ more = true;
131
+ break;
132
+ }
133
+ last = Number(row.id);
134
+ if (await this.resources.permits(row.resource, Number(row.record_id), actor))
135
+ data.push(this.present(row, actor));
136
+ }
137
+ }
138
+ const [{ count }] = await this.db('assignments')
139
+ .where({ assignee_id: actor.id, status: 'open' })
140
+ .count('* as count');
141
+ return {
142
+ data,
143
+ nextCursor: more && last !== null ? String(last) : null,
144
+ open: Number(count),
145
+ };
146
+ }
147
+ /** The assignee marks the task done, or the assigner cancels it. */
148
+ async complete(assignmentId, actor, outcome = 'done') {
149
+ const id = Number(assignmentId);
150
+ if (!Number.isSafeInteger(id) || id <= 0)
151
+ throw new KitError(404, 'E_ASSIGNMENT_NOT_FOUND', 'المهمة غير موجودة');
152
+ return this.db.transaction(async (trx) => {
153
+ const row = await trx('assignments').where('id', id).forUpdate().first();
154
+ if (!row)
155
+ throw new KitError(404, 'E_ASSIGNMENT_NOT_FOUND', 'المهمة غير موجودة');
156
+ const involved = row.assignee_id === actor.id || row.assigned_by === actor.id;
157
+ if (!involved || !(await this.resources.permits(row.resource, Number(row.record_id), actor)))
158
+ throw new KitError(404, 'E_ASSIGNMENT_NOT_FOUND', 'المهمة غير موجودة');
159
+ if (row.workflow_run_id)
160
+ throw new KitError(409, 'E_ASSIGNMENT_WORKFLOW', 'تُحسم خطوات الموافقة من صندوق الموافقات');
161
+ if (outcome === 'done' && row.assignee_id !== actor.id)
162
+ throw new KitError(403, 'E_FORBIDDEN', 'يُنجز المهمة المكلف بها فقط');
163
+ if (outcome === 'cancelled' && row.assigned_by !== actor.id)
164
+ throw new KitError(403, 'E_FORBIDDEN', 'يلغي المهمة من أسندها فقط');
165
+ if (row.status !== 'open')
166
+ throw new KitError(409, 'E_ASSIGNMENT_CLOSED', 'المهمة مغلقة بالفعل');
167
+ await trx('assignments')
168
+ .where('id', id)
169
+ .update({ status: outcome, completed_at: trx.fn.now(), completed_by: actor.id });
170
+ const notify = outcome === 'done' ? row.assigned_by : row.assignee_id;
171
+ if (notify && notify !== actor.id)
172
+ await notifyWithTemplate(trx, notify, outcome === 'done' ? 'assignment.done' : 'assignment.cancelled', { title: row.title, resource: this.label(row.resource), id: row.record_id });
173
+ });
174
+ }
175
+ query(db = this.db) {
176
+ return db('assignments as a')
177
+ .leftJoin('users as u', 'u.id', 'a.assignee_id')
178
+ .leftJoin('users as b', 'b.id', 'a.assigned_by')
179
+ .select('a.*', 'u.full_name as assignee_name', 'b.full_name as assigned_by_name');
180
+ }
181
+ present(row, actor) {
182
+ const open = row.status === 'open';
183
+ return {
184
+ id: Number(row.id),
185
+ resource: String(row.resource),
186
+ resourceLabel: this.label(row.resource),
187
+ recordId: Number(row.record_id),
188
+ assigneeId: Number(row.assignee_id),
189
+ assigneeName: row.assignee_name ? String(row.assignee_name) : null,
190
+ assignedBy: row.assigned_by === null ? null : Number(row.assigned_by),
191
+ assignedByName: row.assigned_by_name ? String(row.assigned_by_name) : null,
192
+ kind: String(row.kind),
193
+ title: String(row.title),
194
+ note: row.note === null ? null : String(row.note),
195
+ dueOn: dateOnly(row.due_on),
196
+ status: row.status,
197
+ outcome: row.outcome === null || row.outcome === undefined ? null : String(row.outcome),
198
+ createdAt: new Date(row.created_at).toISOString(),
199
+ completedAt: row.completed_at ? new Date(row.completed_at).toISOString() : null,
200
+ workflowRunId: row.workflow_run_id ? String(row.workflow_run_id) : null,
201
+ canComplete: open && !row.workflow_run_id && Number(row.assignee_id) === actor.id,
202
+ canCancel: open && !row.workflow_run_id && Number(row.assigned_by) === actor.id,
203
+ };
204
+ }
205
+ label(name) {
206
+ try {
207
+ return this.resources.label(name);
208
+ }
209
+ catch {
210
+ return name;
211
+ }
212
+ }
213
+ async canView(name, id, userId) {
214
+ const user = await this.db('users').where('id', userId).first('disabled_at');
215
+ if (!user || user.disabled_at)
216
+ return false;
217
+ return this.resources.permits(name, id, await this.actors.load(userId));
218
+ }
219
+ }
@@ -0,0 +1,86 @@
1
+ import type { Knex } from 'knex';
2
+ import type { Actor } from '../auth/ability.js';
3
+ import type { ResourceService } from '../admin/resource_service.js';
4
+ import type { JsonValue, Resource } from '../resource/types.js';
5
+ import type { Listener } from '../events/outbox.js';
6
+ export type ActorLoader = {
7
+ load(id: number): Promise<Actor>;
8
+ };
9
+ export type CommentEntry = {
10
+ id: number;
11
+ body: string;
12
+ authorId: number;
13
+ authorName: string | null;
14
+ mentions: {
15
+ id: number;
16
+ name: string;
17
+ }[];
18
+ createdAt: string;
19
+ editedAt: string | null;
20
+ own: boolean;
21
+ };
22
+ export type FieldChangeEntry = {
23
+ id: number;
24
+ field: string;
25
+ before: JsonValue;
26
+ after: JsonValue;
27
+ actorName: string | null;
28
+ createdAt: string;
29
+ };
30
+ export type RecordCollaborationState = {
31
+ comments: CommentEntry[];
32
+ hasMoreComments: boolean;
33
+ tags: string[];
34
+ following: boolean;
35
+ followers: number;
36
+ changes: FieldChangeEntry[];
37
+ canComment: boolean;
38
+ canTag: boolean;
39
+ };
40
+ export type MentionCandidate = {
41
+ id: number;
42
+ name: string;
43
+ };
44
+ /**
45
+ * Collaboration around a record: comments with mentions, followers, tags and the
46
+ * per-field change history. Every read and write re-authorizes the record itself
47
+ * through ResourceService, so these features can never widen record access.
48
+ */
49
+ export declare class RecordCollaboration {
50
+ private db;
51
+ private resources;
52
+ private actors;
53
+ constructor(db: Knex, resources: ResourceService, actors: ActorLoader);
54
+ state(name: string, id: number, actor: Actor): Promise<RecordCollaborationState>;
55
+ private readChanges;
56
+ comment(name: string, id: number, actor: Actor, input: {
57
+ body: unknown;
58
+ mentions?: unknown;
59
+ }): Promise<CommentEntry>;
60
+ /** Authors edit or remove their own comments while they can still read the record. */
61
+ editComment(name: string, id: number, comment: unknown, actor: Actor, body: unknown): Promise<void>;
62
+ deleteComment(name: string, id: number, comment: unknown, actor: Actor): Promise<void>;
63
+ follow(name: string, id: number, actor: Actor, following: boolean): Promise<void>;
64
+ /** Replaces the record's tags. Tagging changes how a record is found, so it needs update. */
65
+ setTags(name: string, id: number, actor: Actor, input: unknown): Promise<string[]>;
66
+ tags(name: string, id: number): Promise<string[]>;
67
+ /** Tag vocabulary used on records of a resource the actor may list. */
68
+ tagOptions(name: string, actor: Actor): Promise<string[]>;
69
+ /** Active users that may read the record, for the mention picker. */
70
+ mentionCandidates(name: string, id: number, actor: Actor, search?: string): Promise<MentionCandidate[]>;
71
+ /**
72
+ * Listener factory: followers hear about updates and document transitions of the
73
+ * records they follow, but only while they can still read the record.
74
+ */
75
+ followerListener(module: string, resource: string, event: string): Listener;
76
+ private label;
77
+ private canView;
78
+ /** Parsed after the record is authorized, so malformed ids never bypass the 403. */
79
+ private commentId;
80
+ private userIds;
81
+ }
82
+ /** Follower notifications for every registered resource's update and document events. */
83
+ export declare function followerListeners(registry: {
84
+ all(): Resource[];
85
+ owner(name: string): string;
86
+ }, collaboration: () => RecordCollaboration): Listener[];
@@ -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
  }