@adula/kit 0.2.0-alpha.4 → 1.2.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 (120) hide show
  1. package/README.md +12 -2
  2. package/build/agent/AGENTS.template.md +2 -2
  3. package/build/agent/capabilities.md +54 -7
  4. package/build/agent/skills/adula-frontend-design/SKILL.md +1 -1
  5. package/build/agent/skills/idea-review/SKILL.md +26 -1
  6. package/build/agent/skills/module-review/SKILL.md +22 -0
  7. package/build/agent/skills/perf-review/SKILL.md +24 -0
  8. package/build/agent/skills/schema-review/SKILL.md +22 -0
  9. package/build/agent/skills/security-review/SKILL.md +24 -0
  10. package/build/agent/skills/ui-review/SKILL.md +22 -0
  11. package/build/commands/capabilities.d.ts +4 -0
  12. package/build/commands/capabilities.js +35 -4
  13. package/build/commands/doctor.js +37 -1
  14. package/build/commands/gaps.js +16 -6
  15. package/build/commands/install.js +19 -5
  16. package/build/commands/main.d.ts +4 -2
  17. package/build/commands/main.js +2 -0
  18. package/build/commands/module_add.js +2 -2
  19. package/build/commands/resource.js +1 -1
  20. package/build/commands/resource_snapshot.d.ts +15 -0
  21. package/build/commands/resource_snapshot.js +61 -0
  22. package/build/database/migrations/1770000000004_kit_collaboration.d.ts +5 -0
  23. package/build/database/migrations/1770000000004_kit_collaboration.js +10 -0
  24. package/build/database/migrations/1770000000005_kit_assignments.d.ts +5 -0
  25. package/build/database/migrations/1770000000005_kit_assignments.js +10 -0
  26. package/build/database/migrations/1770000000006_kit_messaging.d.ts +5 -0
  27. package/build/database/migrations/1770000000006_kit_messaging.js +10 -0
  28. package/build/database/migrations/1770000000007_kit_webhooks.d.ts +5 -0
  29. package/build/database/migrations/1770000000007_kit_webhooks.js +10 -0
  30. package/build/database/migrations/1770000000008_kit_imports.d.ts +5 -0
  31. package/build/database/migrations/1770000000008_kit_imports.js +10 -0
  32. package/build/database/migrations/1770000000010_kit_workflows.d.ts +5 -0
  33. package/build/database/migrations/1770000000010_kit_workflows.js +10 -0
  34. package/build/database/migrations/1770000000011_kit_managed_assignments.d.ts +5 -0
  35. package/build/database/migrations/1770000000011_kit_managed_assignments.js +10 -0
  36. package/build/database/migrations/1770000000012_kit_role_keys.d.ts +5 -0
  37. package/build/database/migrations/1770000000012_kit_role_keys.js +10 -0
  38. package/build/database/migrations/1770000000013_kit_notification_targets.d.ts +5 -0
  39. package/build/database/migrations/1770000000013_kit_notification_targets.js +10 -0
  40. package/build/database/migrations/1770000000014_kit_upload_grants.d.ts +5 -0
  41. package/build/database/migrations/1770000000014_kit_upload_grants.js +10 -0
  42. package/build/database/migrations/1770000000015_kit_inbound_webhooks.d.ts +5 -0
  43. package/build/database/migrations/1770000000015_kit_inbound_webhooks.js +10 -0
  44. package/build/index.d.ts +31 -2
  45. package/build/index.js +17 -2
  46. package/build/src/admin/contracts.d.ts +7 -0
  47. package/build/src/admin/contracts.js +34 -12
  48. package/build/src/admin/controller.d.ts +2 -0
  49. package/build/src/admin/controller.js +52 -1
  50. package/build/src/admin/presentation.d.ts +6 -0
  51. package/build/src/admin/record_title.d.ts +14 -0
  52. package/build/src/admin/record_title.js +50 -0
  53. package/build/src/admin/resource_service.d.ts +184 -2
  54. package/build/src/admin/resource_service.js +713 -48
  55. package/build/src/attachments/attachment_service.d.ts +12 -0
  56. package/build/src/attachments/attachment_service.js +28 -2
  57. package/build/src/attachments/upload_grants.d.ts +44 -0
  58. package/build/src/attachments/upload_grants.js +105 -0
  59. package/build/src/auth/ability.d.ts +1 -1
  60. package/build/src/auth/ability.js +4 -1
  61. package/build/src/auth/actor_store.js +6 -1
  62. package/build/src/auth/conditions.d.ts +15 -0
  63. package/build/src/auth/conditions.js +36 -0
  64. package/build/src/auth/sql.js +6 -2
  65. package/build/src/collaboration/assignments.d.ts +129 -0
  66. package/build/src/collaboration/assignments.js +333 -0
  67. package/build/src/collaboration/record_collaboration.d.ts +86 -0
  68. package/build/src/collaboration/record_collaboration.js +348 -0
  69. package/build/src/commands/agent_assets.js +5 -0
  70. package/build/src/commands/capabilities.d.ts +19 -0
  71. package/build/src/commands/capabilities.js +179 -0
  72. package/build/src/commands/doctor.d.ts +31 -0
  73. package/build/src/commands/doctor.js +114 -0
  74. package/build/src/commands/gap_report.d.ts +50 -2
  75. package/build/src/commands/gap_report.js +102 -4
  76. package/build/src/commands/generator.js +3 -3
  77. package/build/src/commands/snapshot.d.ts +28 -0
  78. package/build/src/commands/snapshot.js +48 -0
  79. package/build/src/commands/source_markers.d.ts +10 -1
  80. package/build/src/commands/source_markers.js +36 -4
  81. package/build/src/core/administration_guard.js +3 -1
  82. package/build/src/core/message_templates.d.ts +83 -0
  83. package/build/src/core/message_templates.js +293 -0
  84. package/build/src/core/module_seed.d.ts +15 -0
  85. package/build/src/core/module_seed.js +31 -0
  86. package/build/src/core/notifications.d.ts +18 -1
  87. package/build/src/core/notifications.js +27 -1
  88. package/build/src/core/roles.d.ts +27 -1
  89. package/build/src/core/roles.js +133 -5
  90. package/build/src/database/schema.d.ts +45 -0
  91. package/build/src/database/schema.js +290 -0
  92. package/build/src/eslint/index.js +26 -0
  93. package/build/src/events/outbox.d.ts +1 -0
  94. package/build/src/events/outbox.js +1 -1
  95. package/build/src/events/record_mutation.d.ts +11 -0
  96. package/build/src/events/record_mutation.js +15 -2
  97. package/build/src/integrations/imports.d.ts +74 -0
  98. package/build/src/integrations/imports.js +333 -0
  99. package/build/src/integrations/inbound_webhooks.d.ts +94 -0
  100. package/build/src/integrations/inbound_webhooks.js +276 -0
  101. package/build/src/integrations/openapi.d.ts +39 -0
  102. package/build/src/integrations/openapi.js +323 -0
  103. package/build/src/integrations/print.d.ts +37 -0
  104. package/build/src/integrations/print.js +124 -0
  105. package/build/src/integrations/webhooks.d.ts +98 -0
  106. package/build/src/integrations/webhooks.js +298 -0
  107. package/build/src/resource/define_resource.d.ts +1 -0
  108. package/build/src/resource/define_resource.js +21 -1
  109. package/build/src/resource/registry.d.ts +3 -0
  110. package/build/src/resource/registry.js +42 -0
  111. package/build/src/resource/types.d.ts +67 -1
  112. package/build/src/resource/values.js +2 -1
  113. package/build/src/services/settings.d.ts +13 -1
  114. package/build/src/services/settings.js +9 -2
  115. package/build/src/workflows/define_workflow.d.ts +124 -0
  116. package/build/src/workflows/define_workflow.js +123 -0
  117. package/build/src/workflows/engine.d.ts +141 -0
  118. package/build/src/workflows/engine.js +752 -0
  119. package/build/stubs/resource_contract.txt +103 -41
  120. package/package.json +7 -3
@@ -0,0 +1,293 @@
1
+ import { KitError } from '../admin/errors.js';
2
+ import { targetColumns } from '../services/settings.js';
3
+ /**
4
+ * Package-owned defaults. Projects edit wording in the message_templates table;
5
+ * an upgrade can change a default without overwriting a project's edit.
6
+ */
7
+ export const DEFAULT_TEMPLATES = {
8
+ 'comment.mentioned': {
9
+ label: 'إشارة في تعليق',
10
+ subject: 'أشار إليك {{author}}',
11
+ body: 'في {{resource}} #{{id}}: {{excerpt}}',
12
+ variables: ['author', 'resource', 'id', 'excerpt'],
13
+ mail: true,
14
+ },
15
+ 'comment.created': {
16
+ label: 'تعليق على سجل تتابعه',
17
+ subject: 'تعليق جديد على {{resource}} #{{id}}',
18
+ body: '{{author}}: {{excerpt}}',
19
+ variables: ['author', 'resource', 'id', 'excerpt'],
20
+ mail: false,
21
+ },
22
+ 'record.changed': {
23
+ label: 'تغيير سجل تتابعه',
24
+ subject: '{{change}} {{resource}} #{{id}}',
25
+ body: 'سجل تتابعه تغيّر.',
26
+ variables: ['change', 'resource', 'id'],
27
+ mail: false,
28
+ },
29
+ 'assignment.created': {
30
+ label: 'مهمة جديدة',
31
+ subject: 'مهمة جديدة مسندة إليك',
32
+ body: '{{title}} — {{resource}} #{{id}}',
33
+ variables: ['title', 'resource', 'id', 'due'],
34
+ mail: true,
35
+ },
36
+ 'assignment.approval': {
37
+ label: 'طلب موافقة',
38
+ subject: 'موافقة مطلوبة منك',
39
+ body: '{{title}} — {{resource}} #{{id}}',
40
+ variables: ['title', 'resource', 'id', 'due'],
41
+ mail: true,
42
+ },
43
+ 'assignment.done': {
44
+ label: 'إنجاز مهمة أسندتها',
45
+ subject: 'أُنجزت مهمة أسندتها',
46
+ body: '{{title}} — {{resource}} #{{id}}',
47
+ variables: ['title', 'resource', 'id'],
48
+ mail: false,
49
+ },
50
+ 'assignment.cancelled': {
51
+ label: 'إلغاء مهمة مسندة إليك',
52
+ subject: 'أُلغيت مهمة مسندة إليك',
53
+ body: '{{title}} — {{resource}} #{{id}}',
54
+ variables: ['title', 'resource', 'id'],
55
+ mail: false,
56
+ },
57
+ 'workflow.decided': {
58
+ label: 'نتيجة تدفق عمل',
59
+ subject: '{{outcome}}: {{resource}} #{{id}}',
60
+ body: '{{workflow}} — {{step}}',
61
+ variables: ['outcome', 'resource', 'id', 'workflow', 'step'],
62
+ mail: true,
63
+ },
64
+ 'workflow.failed': {
65
+ label: 'فشل خطوة تدفق',
66
+ subject: 'فشلت خطوة في تدفق {{workflow}}',
67
+ body: '{{resource}} #{{id}} — {{error}}',
68
+ variables: ['workflow', 'resource', 'id', 'error'],
69
+ mail: true,
70
+ },
71
+ 'webhook.failed': {
72
+ label: 'تعطل Webhook',
73
+ subject: 'تعذّر تسليم Webhook: {{name}}',
74
+ body: '{{event}} — {{error}}',
75
+ variables: ['name', 'event', 'error'],
76
+ mail: true,
77
+ },
78
+ 'import.finished': {
79
+ label: 'اكتمال استيراد',
80
+ subject: 'اكتمل استيراد {{resource}}',
81
+ body: 'نجح {{created}} صفاً، وفشل {{failed}}.',
82
+ variables: ['resource', 'created', 'failed'],
83
+ mail: false,
84
+ },
85
+ };
86
+ const PLACEHOLDER = /\{\{\s*([a-zA-Z][a-zA-Z0-9_]*)\s*\}\}/g;
87
+ const SUBJECT_LIMIT = 200;
88
+ const BODY_LIMIT = 4000;
89
+ export function renderTemplate(text, variables) {
90
+ return text.replace(PLACEHOLDER, (_, name) => {
91
+ const value = variables[name];
92
+ return value === null || value === undefined ? '' : String(value);
93
+ });
94
+ }
95
+ /** Message templates: package defaults plus per-deployment overrides edited by administrators. */
96
+ export class MessageTemplates {
97
+ db;
98
+ definitions;
99
+ constructor(db, definitions = DEFAULT_TEMPLATES) {
100
+ this.db = db;
101
+ this.definitions = definitions;
102
+ }
103
+ async list() {
104
+ const rows = await this.db('message_templates').select('*');
105
+ const overrides = new Map(rows.map((row) => [String(row.key), row]));
106
+ return Object.entries(this.definitions).map(([key, definition]) => {
107
+ const row = overrides.get(key);
108
+ return {
109
+ key,
110
+ ...definition,
111
+ subject: row ? String(row.subject) : definition.subject,
112
+ body: row ? String(row.body) : definition.body,
113
+ mail: row ? Boolean(row.mail) : definition.mail,
114
+ customized: Boolean(row),
115
+ updatedAt: row ? new Date(row.updated_at).toISOString() : null,
116
+ };
117
+ });
118
+ }
119
+ async render(key, variables, db = this.db) {
120
+ const definition = this.definition(key);
121
+ const row = await db('message_templates').where('key', key).first();
122
+ return {
123
+ subject: renderTemplate(row ? String(row.subject) : definition.subject, variables).slice(0, 255),
124
+ body: renderTemplate(row ? String(row.body) : definition.body, variables),
125
+ mail: row ? Boolean(row.mail) : definition.mail,
126
+ };
127
+ }
128
+ async update(key, input, actorId) {
129
+ const definition = this.definition(key);
130
+ const subject = typeof input.subject === 'string' ? input.subject.trim() : '';
131
+ const body = typeof input.body === 'string' ? input.body.trim() : '';
132
+ if (!subject || subject.length > SUBJECT_LIMIT)
133
+ throw new KitError(422, 'E_TEMPLATE_SUBJECT', 'العنوان مطلوب ولا يتجاوز 200 حرف');
134
+ if (!body || body.length > BODY_LIMIT)
135
+ throw new KitError(422, 'E_TEMPLATE_BODY', 'نص الرسالة مطلوب ولا يتجاوز 4000 حرف');
136
+ for (const text of [subject, body])
137
+ for (const [, name] of text.matchAll(PLACEHOLDER))
138
+ if (!definition.variables.includes(name))
139
+ throw new KitError(422, 'E_TEMPLATE_VARIABLE', `متغير غير معروف في القالب: ${name}`);
140
+ const mail = input.mail === undefined ? definition.mail : input.mail === true;
141
+ await this.db('message_templates')
142
+ .insert({ key, subject, body, mail, updated_by: actorId, updated_at: this.db.fn.now() })
143
+ .onConflict('key')
144
+ .merge(['subject', 'body', 'mail', 'updated_by', 'updated_at']);
145
+ }
146
+ async reset(key) {
147
+ this.definition(key);
148
+ await this.db('message_templates').where('key', key).del();
149
+ }
150
+ /** Example output with sample values, for the editor preview. */
151
+ preview(key, input) {
152
+ const definition = this.definition(key);
153
+ const sample = Object.fromEntries(definition.variables.map((name) => [name, `‹${name}›`]));
154
+ return {
155
+ subject: renderTemplate(input.subject, sample),
156
+ body: renderTemplate(input.body, sample),
157
+ };
158
+ }
159
+ definition(key) {
160
+ const definition = Object.hasOwn(this.definitions, key) ? this.definitions[key] : undefined;
161
+ if (!definition)
162
+ throw new KitError(404, 'E_TEMPLATE_NOT_FOUND', 'القالب غير موجود');
163
+ return definition;
164
+ }
165
+ }
166
+ /**
167
+ * Inserts a templated notification in the caller's transaction. Templates with
168
+ * mail enabled mark the row for the mail delivery worker (deliverNotificationMail).
169
+ */
170
+ export async function notifyWithTemplate(db, userId, key, variables, templates = new MessageTemplates(db), target) {
171
+ const message = await templates.render(key, variables, db);
172
+ await db('notifications').insert({
173
+ user_id: userId,
174
+ title: message.subject,
175
+ body: message.body,
176
+ template_key: key,
177
+ mail_state: message.mail ? 'pending' : null,
178
+ ...targetColumns(target),
179
+ });
180
+ }
181
+ /**
182
+ * Delivers pending notification e-mails. Rows are claimed with SKIP LOCKED so
183
+ * several workers never send the same message; failures are retried three times.
184
+ */
185
+ export async function deliverNotificationMail(db, send, limit = 50) {
186
+ let sent = 0;
187
+ let failed = 0;
188
+ await db.transaction(async (trx) => {
189
+ const rows = await trx('notifications as n')
190
+ .join('users as u', 'u.id', 'n.user_id')
191
+ .where('n.mail_state', 'pending')
192
+ .orderBy('n.id')
193
+ .limit(limit)
194
+ .forUpdate('n')
195
+ .skipLocked()
196
+ .select('n.id', 'n.title', 'n.body', 'n.mail_attempts', 'n.resource', 'n.record_id', 'u.email', 'u.full_name', 'u.disabled_at');
197
+ for (const row of rows) {
198
+ if (row.disabled_at) {
199
+ await trx('notifications').where('id', row.id).update({ mail_state: 'skipped' });
200
+ continue;
201
+ }
202
+ try {
203
+ await send({
204
+ to: String(row.email),
205
+ name: row.full_name ? String(row.full_name) : null,
206
+ subject: String(row.title),
207
+ text: String(row.body),
208
+ target: row.resource && row.record_id !== null
209
+ ? { resource: String(row.resource), recordId: Number(row.record_id) }
210
+ : null,
211
+ });
212
+ await trx('notifications')
213
+ .where('id', row.id)
214
+ .update({ mail_state: 'sent', mailed_at: trx.fn.now() });
215
+ sent++;
216
+ }
217
+ catch (error) {
218
+ const attempts = Number(row.mail_attempts ?? 0) + 1;
219
+ await trx('notifications')
220
+ .where('id', row.id)
221
+ .update({
222
+ mail_state: attempts >= 3 ? 'failed' : 'pending',
223
+ mail_attempts: attempts,
224
+ mail_error: String(error.message ?? error).slice(0, 500),
225
+ });
226
+ failed++;
227
+ }
228
+ }
229
+ });
230
+ return { sent, failed };
231
+ }
232
+ /**
233
+ * Holds one database connection that LISTENs for committed notifications and
234
+ * calls onSignal for each. Reconnects after connection loss. Returns a stop function.
235
+ */
236
+ export function listenForNotifications(db, onSignal, options = {}) {
237
+ let stopped = false;
238
+ let connection;
239
+ let timer;
240
+ const connect = async () => {
241
+ try {
242
+ connection = await db.client.acquireConnection();
243
+ connection.on('notification', (message) => {
244
+ if (message.channel !== 'kit_notifications' || !message.payload)
245
+ return;
246
+ try {
247
+ const parsed = JSON.parse(message.payload);
248
+ if (Number.isSafeInteger(parsed.userId) && Number.isSafeInteger(parsed.id))
249
+ onSignal({ userId: parsed.userId, id: parsed.id });
250
+ }
251
+ catch (error) {
252
+ options.onError?.(error);
253
+ }
254
+ });
255
+ connection.once('error', (error) => {
256
+ options.onError?.(error);
257
+ void reconnect();
258
+ });
259
+ await connection.query('LISTEN kit_notifications');
260
+ }
261
+ catch (error) {
262
+ options.onError?.(error);
263
+ void reconnect();
264
+ }
265
+ };
266
+ const release = async () => {
267
+ const current = connection;
268
+ connection = undefined;
269
+ if (!current)
270
+ return;
271
+ current.removeAllListeners('notification');
272
+ try {
273
+ await current.query('UNLISTEN *');
274
+ }
275
+ catch { }
276
+ // A broken connection is destroyed rather than returned to the pool.
277
+ await db.client.releaseConnection(current).catch(() => { });
278
+ };
279
+ const reconnect = async () => {
280
+ await release();
281
+ if (stopped)
282
+ return;
283
+ timer = setTimeout(() => void connect(), options.retryMs ?? 2000);
284
+ timer.unref?.();
285
+ };
286
+ void connect();
287
+ return async () => {
288
+ stopped = true;
289
+ if (timer)
290
+ clearTimeout(timer);
291
+ await release();
292
+ };
293
+ }
@@ -0,0 +1,15 @@
1
+ import type { Knex } from 'knex';
2
+ import type { ResourceRegistry } from '../resource/registry.js';
3
+ export type ModuleSeedResult = {
4
+ /** Lookup rows inserted now; rows that already existed are left as they are. */
5
+ lookups: number;
6
+ created: string[];
7
+ adopted: string[];
8
+ kept: string[];
9
+ };
10
+ /**
11
+ * Applies the lookups and default roles declared by registered modules. It is
12
+ * idempotent and additive: existing lookup rows and roles are never changed, so
13
+ * administrator edits survive every later installation (adula:install runs it).
14
+ */
15
+ export declare function seedModules(db: Knex, registry: ResourceRegistry, actorId: number): Promise<ModuleSeedResult>;
@@ -0,0 +1,31 @@
1
+ import { RolesAdmin } from './roles.js';
2
+ /**
3
+ * Applies the lookups and default roles declared by registered modules. It is
4
+ * idempotent and additive: existing lookup rows and roles are never changed, so
5
+ * administrator edits survive every later installation (adula:install runs it).
6
+ */
7
+ export async function seedModules(db, registry, actorId) {
8
+ return db.transaction(async (trx) => {
9
+ await trx.raw('SELECT pg_advisory_xact_lock(717012)');
10
+ let lookups = 0;
11
+ for (const module of registry.modules())
12
+ for (const [group, rows] of Object.entries(module.lookups ?? {}))
13
+ for (const [index, row] of rows.entries()) {
14
+ const inserted = await trx('lookups')
15
+ .insert({
16
+ group,
17
+ key: row.key,
18
+ label_ar: row.label.ar,
19
+ label_en: row.label.en,
20
+ sort: row.sort ?? index,
21
+ active: true,
22
+ })
23
+ .onConflict(['group', 'key'])
24
+ .ignore()
25
+ .returning('id');
26
+ lookups += inserted.length;
27
+ }
28
+ const roles = await new RolesAdmin(trx, registry).ensureDefaults(actorId, registry.modules().flatMap((module) => [...(module.defaultRoles ?? [])]));
29
+ return { lookups, ...roles };
30
+ });
31
+ }
@@ -1,10 +1,20 @@
1
1
  import type { Knex } from 'knex';
2
+ import type { ResourceRegistry } from '../resource/registry.js';
2
3
  export type Notification = {
3
4
  id: number;
4
5
  title: string;
5
6
  body: string;
6
7
  readAt: string | null;
7
8
  createdAt: string;
9
+ /**
10
+ * The record the notification is about, while its resource is still registered.
11
+ * The record page authorizes the reader when the link is opened.
12
+ */
13
+ target: {
14
+ resource: string;
15
+ recordId: number;
16
+ href: string;
17
+ } | null;
8
18
  };
9
19
  export type NotificationPage = {
10
20
  data: Notification[];
@@ -13,13 +23,20 @@ export type NotificationPage = {
13
23
  };
14
24
  export declare class NotificationsAdmin {
15
25
  private db;
16
- constructor(db: Knex);
26
+ /** Without a registry, stored targets are returned as they are. */
27
+ private registry?;
28
+ constructor(db: Knex,
29
+ /** Without a registry, stored targets are returned as they are. */
30
+ registry?: Pick<ResourceRegistry, "has"> | undefined);
31
+ private target;
17
32
  /** Unread first, newest first; the cursor remembers which of the two segments it is in. */
18
33
  list(userId: number, options?: {
19
34
  cursor?: string;
20
35
  limit?: number;
21
36
  }): Promise<NotificationPage>;
22
37
  markRead(userId: number, id: number): Promise<void>;
38
+ /** Marks the notification read and returns where it points (null without a target). */
39
+ open(userId: number, id: number): Promise<string | null>;
23
40
  markAllRead(userId: number): Promise<number>;
24
41
  unreadCount(userId: number): Promise<number>;
25
42
  }
@@ -2,8 +2,21 @@ import { KitError } from '../admin/errors.js';
2
2
  import { pageLimit } from './activity.js';
3
3
  export class NotificationsAdmin {
4
4
  db;
5
- constructor(db) {
5
+ registry;
6
+ constructor(db,
7
+ /** Without a registry, stored targets are returned as they are. */
8
+ registry) {
6
9
  this.db = db;
10
+ this.registry = registry;
11
+ }
12
+ target(row) {
13
+ if (!row.resource || row.record_id === null || row.record_id === undefined)
14
+ return null;
15
+ const resource = String(row.resource);
16
+ if (this.registry && !this.registry.has(resource))
17
+ return null;
18
+ const recordId = Number(row.record_id);
19
+ return { resource, recordId, href: `/resources/${resource}/${recordId}` };
7
20
  }
8
21
  /** Unread first, newest first; the cursor remembers which of the two segments it is in. */
9
22
  async list(userId, options = {}) {
@@ -34,6 +47,7 @@ export class NotificationsAdmin {
34
47
  body: row.body,
35
48
  readAt: row.read_at ? new Date(row.read_at).toISOString() : null,
36
49
  createdAt: new Date(row.created_at).toISOString(),
50
+ target: this.target(row),
37
51
  })),
38
52
  nextCursor: rows.length > limit ? `${last.read_at ? 'r' : 'u'}:${last.id}` : null,
39
53
  unread: await this.unreadCount(userId),
@@ -48,6 +62,18 @@ export class NotificationsAdmin {
48
62
  .whereNull('read_at')
49
63
  .update({ read_at: this.db.fn.now() });
50
64
  }
65
+ /** Marks the notification read and returns where it points (null without a target). */
66
+ async open(userId, id) {
67
+ const row = await this.db('notifications').where({ id, user_id: userId }).first();
68
+ if (!row)
69
+ throw new KitError(404, 'E_NOTIFICATION_NOT_FOUND', 'الإشعار غير موجود');
70
+ if (!row.read_at)
71
+ await this.db('notifications')
72
+ .where({ id, user_id: userId })
73
+ .whereNull('read_at')
74
+ .update({ read_at: this.db.fn.now() });
75
+ return this.target(row)?.href ?? null;
76
+ }
51
77
  async markAllRead(userId) {
52
78
  return this.db('notifications')
53
79
  .where('user_id', userId)
@@ -1,9 +1,14 @@
1
1
  import type { Knex } from 'knex';
2
2
  import type { ResourceRegistry } from '../resource/registry.js';
3
- import type { Label } from '../resource/types.js';
3
+ import type { Label, ModuleRole } from '../resource/types.js';
4
4
  import type { Conditions } from '../auth/conditions.js';
5
5
  export type RoleSummary = {
6
6
  id: number;
7
+ /**
8
+ * Stable identifier addressed by workflows and module defaults. The display name
9
+ * can change freely; the key is set once.
10
+ */
11
+ key: string | null;
7
12
  name: string;
8
13
  permissionLevel: number;
9
14
  rules: number;
@@ -32,6 +37,8 @@ export type MatrixField = {
32
37
  label: Label;
33
38
  type: string;
34
39
  conditionable: boolean;
40
+ /** Accepts the current user ("$actor.id") as a condition value: user fields, createdBy, updatedBy. */
41
+ actor?: boolean;
35
42
  };
36
43
  export type MatrixSubject = {
37
44
  name: string;
@@ -46,6 +53,8 @@ export type RoleMatrix = {
46
53
  subjects: MatrixSubject[];
47
54
  };
48
55
  export declare const ALL_SUBJECT_LABEL: Label;
56
+ /** A role key: lower-case letters, digits and underscores, starting with a letter. */
57
+ export declare function roleKey(value: unknown): string;
49
58
  export declare class RolesAdmin {
50
59
  private db;
51
60
  private registry;
@@ -54,12 +63,19 @@ export declare class RolesAdmin {
54
63
  get(id: number): Promise<RoleDetail>;
55
64
  create(actorId: number, input: {
56
65
  name: string;
66
+ key?: string | null;
57
67
  permissionLevel?: number;
58
68
  }): Promise<{
59
69
  id: number;
70
+ key: string | null;
60
71
  name: string;
61
72
  permissionLevel: number;
62
73
  }>;
74
+ /**
75
+ * Gives a role its stable key. A key is set once: workflows and module defaults
76
+ * depend on it, so changing it would silently detach them.
77
+ */
78
+ setKey(actorId: number, id: number, value: string): Promise<void>;
63
79
  rename(actorId: number, id: number, value: string): Promise<void>;
64
80
  setPermissionLevel(actorId: number, id: number, value: number): Promise<void>;
65
81
  delete(actorId: number, id: number): Promise<void>;
@@ -67,6 +83,16 @@ export declare class RolesAdmin {
67
83
  matrix(): RoleMatrix;
68
84
  setRule(actorId: number, roleId: number, input: RuleInput): Promise<RoleRule>;
69
85
  removeRule(actorId: number, roleId: number, ruleId: number): Promise<void>;
86
+ /**
87
+ * Creates module default roles that no role holds the key of yet, with their rules
88
+ * validated exactly like setRule. Existing roles are never modified. A keyless role
89
+ * with the same display name is adopted by receiving the key, rules untouched.
90
+ */
91
+ ensureDefaults(actorId: number, roles: readonly ModuleRole[]): Promise<{
92
+ created: string[];
93
+ adopted: string[];
94
+ kept: string[];
95
+ }>;
70
96
  private validateRule;
71
97
  private find;
72
98
  }