@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,333 @@
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 REASON_LIMIT = 500;
6
+ const DATE = /^\d{4}-\d{2}-\d{2}$/;
7
+ function dateOnly(value) {
8
+ if (value === null || value === undefined)
9
+ return null;
10
+ if (value instanceof Date)
11
+ return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}`;
12
+ return String(value);
13
+ }
14
+ /**
15
+ * Assignments put a record on someone's "my tasks" list. Assigning needs update
16
+ * permission on the record, the assignee must be able to read it, and every read
17
+ * re-checks the record so lost access hides the task instead of leaking it.
18
+ */
19
+ export class Assignments {
20
+ db;
21
+ resources;
22
+ actors;
23
+ options;
24
+ constructor(db, resources, actors, options = {}) {
25
+ this.db = db;
26
+ this.resources = resources;
27
+ this.actors = actors;
28
+ this.options = options;
29
+ }
30
+ async forRecord(name, id, actor) {
31
+ await this.resources.access(name, id, actor);
32
+ const rows = await this.query()
33
+ .where({ 'a.resource': name, 'a.record_id': id })
34
+ .orderByRaw("(a.status = 'open') DESC, a.id DESC")
35
+ .limit(100);
36
+ return this.titled(rows.map((row) => this.present(row, actor)), actor);
37
+ }
38
+ async assign(name, id, actor, input) {
39
+ await this.resources.access(name, id, actor, 'update');
40
+ const assigneeId = Number(input.assigneeId);
41
+ if (!Number.isSafeInteger(assigneeId) || assigneeId <= 0)
42
+ throw new KitError(422, 'E_ASSIGNEE', 'اختر المستخدم المكلف');
43
+ const title = typeof input.title === 'string' ? input.title.trim() : '';
44
+ if (!title || title.length > TITLE_LIMIT)
45
+ throw new KitError(422, 'E_ASSIGNMENT_TITLE', 'عنوان المهمة مطلوب ولا يتجاوز 200 حرف');
46
+ const note = input.note === undefined || input.note === null || input.note === ''
47
+ ? null
48
+ : typeof input.note === 'string' && input.note.length <= NOTE_LIMIT
49
+ ? input.note.trim()
50
+ : null;
51
+ if (input.note && note === null)
52
+ throw new KitError(422, 'E_ASSIGNMENT_NOTE', 'الملاحظة لا تتجاوز 2000 حرف');
53
+ const dueOn = input.dueOn === undefined || input.dueOn === null || input.dueOn === ''
54
+ ? null
55
+ : typeof input.dueOn === 'string' &&
56
+ DATE.test(input.dueOn) &&
57
+ !Number.isNaN(Date.parse(input.dueOn))
58
+ ? input.dueOn
59
+ : undefined;
60
+ if (dueOn === undefined)
61
+ throw new KitError(422, 'E_ASSIGNMENT_DUE', 'تاريخ الاستحقاق غير صالح');
62
+ if (!(await this.canView(name, id, assigneeId)))
63
+ throw new KitError(422, 'E_ASSIGNEE', 'لا يمكن تكليف مستخدم لا يملك صلاحية عرض السجل');
64
+ return this.create(this.db, {
65
+ resource: name,
66
+ recordId: id,
67
+ assigneeId,
68
+ assignedBy: actor.id,
69
+ title,
70
+ note,
71
+ dueOn,
72
+ }).then((created) => this.present(created, actor));
73
+ }
74
+ /**
75
+ * Inserts an assignment and its notification. Used by assign() and by workflow
76
+ * approval steps inside their own transaction; callers authorize beforehand.
77
+ */
78
+ async create(db, input) {
79
+ const insert = async (trx) => {
80
+ const [row] = await trx('assignments')
81
+ .insert({
82
+ resource: input.resource,
83
+ record_id: input.recordId,
84
+ assignee_id: input.assigneeId,
85
+ assigned_by: input.assignedBy,
86
+ kind: input.kind ?? 'task',
87
+ title: input.title,
88
+ note: input.note ?? null,
89
+ due_on: input.dueOn ?? null,
90
+ workflow_run_id: input.workflowRunId ?? null,
91
+ workflow_step: input.workflowStep ?? null,
92
+ managed: input.managed ?? false,
93
+ })
94
+ .returning('id');
95
+ await notifyWithTemplate(trx, input.assigneeId, input.kind === 'approval' ? 'assignment.approval' : 'assignment.created', {
96
+ title: input.title,
97
+ resource: this.label(input.resource),
98
+ id: input.recordId,
99
+ due: input.dueOn ?? '',
100
+ }, undefined, { resource: input.resource, recordId: input.recordId });
101
+ return this.query(trx).where('a.id', row.id).first();
102
+ };
103
+ return 'isTransaction' in db && db.isTransaction ? insert(db) : this.db.transaction(insert);
104
+ }
105
+ /** The signed-in user's own tasks, open first; records they can no longer read are hidden. */
106
+ async mine(actor, options = {}) {
107
+ const status = options.status === 'done' || options.status === 'all' ? options.status : 'open';
108
+ const limit = Math.max(1, Math.min(100, Math.floor(Number(options.limit) || 50)));
109
+ const query = this.query().where('a.assignee_id', actor.id).orderBy('a.id', 'desc');
110
+ if (status === 'open')
111
+ query.where('a.status', 'open');
112
+ if (status === 'done')
113
+ query.whereNot('a.status', 'open');
114
+ if (options.kind === 'approval')
115
+ query.whereNotNull('a.workflow_run_id');
116
+ if (options.kind === 'task')
117
+ query.whereNull('a.workflow_run_id');
118
+ let last = null;
119
+ if (options.cursor !== undefined && options.cursor !== '') {
120
+ last = Number(options.cursor);
121
+ if (!Number.isSafeInteger(last) || last <= 0)
122
+ throw new KitError(422, 'E_CURSOR', 'مؤشر الصفحة غير صالح');
123
+ }
124
+ const data = [];
125
+ let more = true;
126
+ // Access is re-checked per record; scan bounded batches to fill the page.
127
+ for (let round = 0; round < 10 && more && data.length < limit; round++) {
128
+ const rows = await query
129
+ .clone()
130
+ .modify((q) => {
131
+ if (last !== null)
132
+ q.where('a.id', '<', last);
133
+ })
134
+ .limit(100);
135
+ more = rows.length === 100;
136
+ for (const row of rows) {
137
+ if (data.length >= limit) {
138
+ more = true;
139
+ break;
140
+ }
141
+ last = Number(row.id);
142
+ if (await this.resources.permits(row.resource, Number(row.record_id), actor))
143
+ data.push(this.present(row, actor));
144
+ }
145
+ }
146
+ const [{ count, approvals }] = await this.db('assignments')
147
+ .where({ assignee_id: actor.id, status: 'open' })
148
+ .select(this.db.raw('count(*) as count'), this.db.raw('count(workflow_run_id) as approvals'));
149
+ return {
150
+ data: await this.titled(data, actor),
151
+ nextCursor: more && last !== null ? String(last) : null,
152
+ open: Number(count),
153
+ approvals: Number(approvals),
154
+ };
155
+ }
156
+ /**
157
+ * The assignee marks the task done, or the assigner cancels it, with a closing note. The
158
+ * note is required when the application's policy says so and for managed tasks.
159
+ */
160
+ async complete(assignmentId, actor, outcome = 'done', input = {}) {
161
+ const id = Number(assignmentId);
162
+ if (!Number.isSafeInteger(id) || id <= 0)
163
+ throw new KitError(404, 'E_ASSIGNMENT_NOT_FOUND', 'المهمة غير موجودة');
164
+ if (input.note !== undefined && input.note !== null && typeof input.note !== 'string')
165
+ throw new KitError(422, 'E_ASSIGNMENT_NOTE', 'ملاحظة الإغلاق غير صالحة');
166
+ const note = typeof input.note === 'string' ? input.note.trim() : '';
167
+ if (note.length > REASON_LIMIT)
168
+ throw new KitError(422, 'E_ASSIGNMENT_NOTE', 'ملاحظة الإغلاق لا تتجاوز 500 حرف');
169
+ return this.db.transaction(async (trx) => {
170
+ const row = await trx('assignments').where('id', id).forUpdate().first();
171
+ if (!row)
172
+ throw new KitError(404, 'E_ASSIGNMENT_NOT_FOUND', 'المهمة غير موجودة');
173
+ const involved = row.assignee_id === actor.id || row.assigned_by === actor.id;
174
+ if (!involved || !(await this.resources.permits(row.resource, Number(row.record_id), actor)))
175
+ throw new KitError(404, 'E_ASSIGNMENT_NOT_FOUND', 'المهمة غير موجودة');
176
+ if (row.workflow_run_id)
177
+ throw new KitError(409, 'E_ASSIGNMENT_WORKFLOW', 'تُحسم خطوات الموافقة من صندوق الموافقات');
178
+ if (outcome === 'done' && row.assignee_id !== actor.id)
179
+ throw new KitError(403, 'E_FORBIDDEN', 'يُنجز المهمة المكلف بها فقط');
180
+ if (outcome === 'cancelled' && row.assigned_by !== actor.id)
181
+ throw new KitError(403, 'E_FORBIDDEN', 'يلغي المهمة من أسندها فقط');
182
+ if (row.status !== 'open')
183
+ throw new KitError(409, 'E_ASSIGNMENT_CLOSED', 'المهمة مغلقة بالفعل');
184
+ if (!note && this.closeNote(row) === 'required')
185
+ throw new KitError(422, 'E_ASSIGNMENT_NOTE', 'اكتب ملاحظة الإغلاق قبل إغلاق المهمة');
186
+ await trx('assignments')
187
+ .where('id', id)
188
+ .update({
189
+ status: outcome,
190
+ completed_at: trx.fn.now(),
191
+ completed_by: actor.id,
192
+ close_reason: note || null,
193
+ });
194
+ // A managed task closed by hand leaves the note in the record's history.
195
+ if (row.managed)
196
+ await trx('activities').insert({
197
+ resource: row.resource,
198
+ record_id: row.record_id,
199
+ actor_id: actor.id,
200
+ action: 'assignment_closed',
201
+ changes: JSON.stringify({
202
+ fields: [],
203
+ assignmentId: id,
204
+ outcome,
205
+ reason: note,
206
+ }),
207
+ });
208
+ const notify = outcome === 'done' ? row.assigned_by : row.assignee_id;
209
+ if (notify && notify !== actor.id)
210
+ await notifyWithTemplate(trx, notify, outcome === 'done' ? 'assignment.done' : 'assignment.cancelled', { title: row.title, resource: this.label(row.resource), id: row.record_id }, undefined, { resource: String(row.resource), recordId: Number(row.record_id) });
211
+ });
212
+ }
213
+ /**
214
+ * Closes the open managed tasks of a record when module code decides that its work is
215
+ * finished (or no longer needed), for example from a listener on the record's final
216
+ * state. Recorded in the record's activity log with the reason; the assigner (done) or
217
+ * the assignee (cancelled) is notified as for a manual close. Returns the closed count.
218
+ */
219
+ async close(resource, recordId, options) {
220
+ const outcome = options.outcome ?? 'done';
221
+ if (outcome !== 'done' && outcome !== 'cancelled')
222
+ throw new KitError(422, 'E_ASSIGNMENT_OUTCOME', 'Unsupported assignment outcome');
223
+ if (!Number.isSafeInteger(options.actorId) || options.actorId <= 0)
224
+ throw new KitError(422, 'E_ACTOR', 'Closing tasks requires the author user id');
225
+ const reason = options.reason?.trim() || null;
226
+ if (reason && reason.length > REASON_LIMIT)
227
+ throw new KitError(422, 'E_ASSIGNMENT_REASON', 'سبب الإغلاق لا يتجاوز 500 حرف');
228
+ const run = async (trx) => {
229
+ const rows = await trx('assignments')
230
+ .where({ resource, record_id: recordId, status: 'open', managed: true })
231
+ .whereNull('workflow_run_id')
232
+ .forUpdate()
233
+ .orderBy('id');
234
+ for (const row of rows) {
235
+ await trx('assignments').where('id', row.id).update({
236
+ status: outcome,
237
+ completed_at: trx.fn.now(),
238
+ completed_by: options.actorId,
239
+ close_reason: reason,
240
+ });
241
+ await trx('activities').insert({
242
+ resource,
243
+ record_id: recordId,
244
+ actor_id: options.actorId,
245
+ action: 'assignment_closed',
246
+ changes: JSON.stringify({
247
+ fields: [],
248
+ system: true,
249
+ assignmentId: Number(row.id),
250
+ outcome,
251
+ ...(reason ? { reason } : {}),
252
+ }),
253
+ });
254
+ const notify = outcome === 'done' ? row.assigned_by : row.assignee_id;
255
+ if (notify && notify !== options.actorId)
256
+ await notifyWithTemplate(trx, notify, outcome === 'done' ? 'assignment.done' : 'assignment.cancelled', { title: row.title, resource: this.label(row.resource), id: row.record_id }, undefined, { resource: String(row.resource), recordId: Number(row.record_id) });
257
+ }
258
+ return rows.length;
259
+ };
260
+ return options.trx ? run(options.trx) : this.db.transaction(run);
261
+ }
262
+ query(db = this.db) {
263
+ return db('assignments as a')
264
+ .leftJoin('users as u', 'u.id', 'a.assignee_id')
265
+ .leftJoin('users as b', 'b.id', 'a.assigned_by')
266
+ .leftJoin('workflow_runs as wr', 'wr.id', 'a.workflow_run_id')
267
+ .select('a.*', 'u.full_name as assignee_name', 'b.full_name as assigned_by_name', 'wr.status as run_status', 'wr.current_step as run_step');
268
+ }
269
+ present(row, actor) {
270
+ const open = row.status === 'open';
271
+ return {
272
+ id: Number(row.id),
273
+ resource: String(row.resource),
274
+ resourceLabel: this.label(row.resource),
275
+ recordId: Number(row.record_id),
276
+ recordTitle: null,
277
+ assigneeId: Number(row.assignee_id),
278
+ assigneeName: row.assignee_name ? String(row.assignee_name) : null,
279
+ assignedBy: row.assigned_by === null ? null : Number(row.assigned_by),
280
+ assignedByName: row.assigned_by_name ? String(row.assigned_by_name) : null,
281
+ kind: String(row.kind),
282
+ title: String(row.title),
283
+ note: row.note === null ? null : String(row.note),
284
+ dueOn: dateOnly(row.due_on),
285
+ status: row.status,
286
+ outcome: row.outcome === null || row.outcome === undefined ? null : String(row.outcome),
287
+ createdAt: new Date(row.created_at).toISOString(),
288
+ completedAt: row.completed_at ? new Date(row.completed_at).toISOString() : null,
289
+ workflowRunId: row.workflow_run_id ? String(row.workflow_run_id) : null,
290
+ managed: Boolean(row.managed),
291
+ closeNote: this.closeNote(row),
292
+ closeReason: row.close_reason ? String(row.close_reason) : null,
293
+ canComplete: open && !row.workflow_run_id && Number(row.assignee_id) === actor.id,
294
+ canCancel: open && !row.workflow_run_id && Number(row.assigned_by) === actor.id,
295
+ // The same checks WorkflowEngine.decide() applies before accepting a decision.
296
+ canDecide: open &&
297
+ Boolean(row.workflow_run_id) &&
298
+ row.run_status === 'waiting' &&
299
+ row.run_step === row.workflow_step &&
300
+ Number(row.assignee_id) === actor.id,
301
+ };
302
+ }
303
+ /** Adds record titles in one read per resource, under the viewer's field access. */
304
+ async titled(list, actor) {
305
+ const byResource = new Map();
306
+ for (const item of list)
307
+ byResource.set(item.resource, [...(byResource.get(item.resource) ?? []), item.recordId]);
308
+ for (const [resource, ids] of byResource) {
309
+ const titles = await this.resources.titles(resource, ids, actor);
310
+ for (const item of list)
311
+ if (item.resource === resource)
312
+ item.recordTitle = titles.get(item.recordId) ?? null;
313
+ }
314
+ return list;
315
+ }
316
+ closeNote(row) {
317
+ return row.managed || this.options.closeNote === 'required' ? 'required' : 'optional';
318
+ }
319
+ label(name) {
320
+ try {
321
+ return this.resources.label(name);
322
+ }
323
+ catch {
324
+ return name;
325
+ }
326
+ }
327
+ async canView(name, id, userId) {
328
+ const user = await this.db('users').where('id', userId).first('disabled_at');
329
+ if (!user || user.disabled_at)
330
+ return false;
331
+ return this.resources.permits(name, id, await this.actors.load(userId));
332
+ }
333
+ }
@@ -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[];