@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.
- package/README.md +12 -2
- package/build/agent/capabilities.md +49 -7
- package/build/agent/skills/idea-review/SKILL.md +26 -1
- package/build/agent/skills/module-review/SKILL.md +22 -0
- package/build/agent/skills/perf-review/SKILL.md +24 -0
- package/build/agent/skills/schema-review/SKILL.md +22 -0
- package/build/agent/skills/security-review/SKILL.md +24 -0
- package/build/agent/skills/ui-review/SKILL.md +22 -0
- package/build/commands/capabilities.d.ts +4 -0
- package/build/commands/capabilities.js +35 -4
- package/build/commands/main.d.ts +4 -2
- package/build/database/migrations/1770000000004_kit_collaboration.d.ts +5 -0
- package/build/database/migrations/1770000000004_kit_collaboration.js +10 -0
- package/build/database/migrations/1770000000005_kit_assignments.d.ts +5 -0
- package/build/database/migrations/1770000000005_kit_assignments.js +10 -0
- package/build/database/migrations/1770000000006_kit_messaging.d.ts +5 -0
- package/build/database/migrations/1770000000006_kit_messaging.js +10 -0
- package/build/database/migrations/1770000000007_kit_webhooks.d.ts +5 -0
- package/build/database/migrations/1770000000007_kit_webhooks.js +10 -0
- package/build/database/migrations/1770000000008_kit_imports.d.ts +5 -0
- package/build/database/migrations/1770000000008_kit_imports.js +10 -0
- package/build/database/migrations/1770000000010_kit_workflows.d.ts +5 -0
- package/build/database/migrations/1770000000010_kit_workflows.js +10 -0
- package/build/index.d.ts +21 -1
- package/build/index.js +12 -1
- package/build/src/admin/contracts.js +19 -10
- package/build/src/admin/controller.d.ts +1 -0
- package/build/src/admin/controller.js +6 -0
- package/build/src/admin/resource_service.d.ts +21 -0
- package/build/src/admin/resource_service.js +163 -7
- package/build/src/collaboration/assignments.d.ts +78 -0
- package/build/src/collaboration/assignments.js +219 -0
- package/build/src/collaboration/record_collaboration.d.ts +86 -0
- package/build/src/collaboration/record_collaboration.js +360 -0
- package/build/src/commands/agent_assets.js +5 -0
- package/build/src/commands/capabilities.d.ts +19 -0
- package/build/src/commands/capabilities.js +160 -0
- package/build/src/core/message_templates.d.ts +80 -0
- package/build/src/core/message_templates.js +288 -0
- package/build/src/database/schema.d.ts +18 -0
- package/build/src/database/schema.js +192 -0
- package/build/src/events/outbox.d.ts +1 -0
- package/build/src/events/outbox.js +1 -1
- package/build/src/events/record_mutation.d.ts +7 -0
- package/build/src/events/record_mutation.js +13 -2
- package/build/src/integrations/imports.d.ts +74 -0
- package/build/src/integrations/imports.js +331 -0
- package/build/src/integrations/openapi.d.ts +39 -0
- package/build/src/integrations/openapi.js +256 -0
- package/build/src/integrations/print.d.ts +37 -0
- package/build/src/integrations/print.js +123 -0
- package/build/src/integrations/webhooks.d.ts +98 -0
- package/build/src/integrations/webhooks.js +298 -0
- package/build/src/resource/define_resource.d.ts +1 -0
- package/build/src/resource/define_resource.js +9 -1
- package/build/src/resource/registry.d.ts +2 -0
- package/build/src/resource/registry.js +9 -0
- package/build/src/resource/types.d.ts +3 -0
- package/build/src/workflows/define_workflow.d.ts +95 -0
- package/build/src/workflows/define_workflow.js +83 -0
- package/build/src/workflows/engine.d.ts +119 -0
- package/build/src/workflows/engine.js +687 -0
- package/package.json +7 -3
|
@@ -8,6 +8,10 @@ import { sequence } from '../services/settings.js';
|
|
|
8
8
|
import { recordMutation } from '../events/record_mutation.js';
|
|
9
9
|
import { fieldValue } from '../resource/values.js';
|
|
10
10
|
import { claimAttachment, isAttachmentId, loadAttachments, releaseAttachment, } from '../attachments/attachment_service.js';
|
|
11
|
+
/** Per-process cache of planner row estimates for identical list queries. */
|
|
12
|
+
const estimates = new Map();
|
|
13
|
+
const ESTIMATE_TTL_MS = 30_000;
|
|
14
|
+
const ESTIMATE_CACHE_LIMIT = 500;
|
|
11
15
|
/**
|
|
12
16
|
* Whether an actor may sort, filter or search by a field. Querying reveals values
|
|
13
17
|
* indirectly, so it needs unconditional view access at the field's level.
|
|
@@ -28,6 +32,10 @@ export class ResourceService {
|
|
|
28
32
|
this.db = db;
|
|
29
33
|
this.registry = registry;
|
|
30
34
|
}
|
|
35
|
+
/** The Arabic label of a registered resource, for notifications and titles. */
|
|
36
|
+
label(name) {
|
|
37
|
+
return this.registry.get(name).label.ar;
|
|
38
|
+
}
|
|
31
39
|
/** Project navigation is derived from registered resources and the current actor. */
|
|
32
40
|
navigation(actor) {
|
|
33
41
|
const ability = buildAbility(actor.rules, this.registry.all());
|
|
@@ -85,7 +93,7 @@ export class ResourceService {
|
|
|
85
93
|
!['update', 'delete', 'submit'].includes(action) ||
|
|
86
94
|
record.docStatus === 0) &&
|
|
87
95
|
(action !== 'cancel' || (resource.submittable && record.docStatus === 1)) &&
|
|
88
|
-
action !== 'amend',
|
|
96
|
+
(action !== 'amend' || (resource.submittable && record.docStatus === 2)),
|
|
89
97
|
]));
|
|
90
98
|
}
|
|
91
99
|
normalizeValues(resource, record) {
|
|
@@ -160,6 +168,37 @@ export class ResourceService {
|
|
|
160
168
|
if (!canRecord(ability, actor, resource, action, record))
|
|
161
169
|
throw new KitError(403, 'E_FORBIDDEN', 'ليس لديك صلاحية لهذا الإجراء');
|
|
162
170
|
}
|
|
171
|
+
/**
|
|
172
|
+
* Authorizes one record for collaboration features (comments, tags, assignments...).
|
|
173
|
+
* Scope and conditional rules apply exactly as for show/update.
|
|
174
|
+
*/
|
|
175
|
+
async access(name, id, actor, action = 'view', db = this.db) {
|
|
176
|
+
let resource;
|
|
177
|
+
try {
|
|
178
|
+
resource = this.registry.get(name);
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
throw new KitError(404, 'E_NOT_FOUND', 'الكيان غير موجود');
|
|
182
|
+
}
|
|
183
|
+
if (!Number.isSafeInteger(id) || id <= 0)
|
|
184
|
+
throw new KitError(404, 'E_NOT_FOUND', 'السجل غير موجود');
|
|
185
|
+
const ability = this.authorizeAction(resource, actor, action);
|
|
186
|
+
const record = await this.find(db, resource, actor, ability, id);
|
|
187
|
+
this.requireRecord(ability, actor, resource, action, record);
|
|
188
|
+
return { resource, record, ability };
|
|
189
|
+
}
|
|
190
|
+
/** Whether a (possibly other) actor may perform an action on a record; never throws for denial. */
|
|
191
|
+
async permits(name, id, actor, action = 'view') {
|
|
192
|
+
try {
|
|
193
|
+
await this.access(name, id, actor, action);
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
if (error instanceof KitError && [403, 404].includes(error.status))
|
|
198
|
+
return false;
|
|
199
|
+
throw error;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
163
202
|
async list(name, actor, options = {}) {
|
|
164
203
|
const resource = this.registry.get(name);
|
|
165
204
|
const ability = this.authorizeAction(resource, actor, 'view');
|
|
@@ -190,15 +229,36 @@ export class ResourceService {
|
|
|
190
229
|
throw new KitError(422, 'E_FILTER', 'Invalid filter value');
|
|
191
230
|
query.where(`r.${resource.fields[key].column ?? columnName(key)}`, value);
|
|
192
231
|
}
|
|
232
|
+
if (options.tag !== undefined && options.tag !== null && options.tag !== '') {
|
|
233
|
+
if (typeof options.tag !== 'string' || options.tag.length > 60)
|
|
234
|
+
throw new KitError(422, 'E_FILTER', 'Invalid tag filter');
|
|
235
|
+
query.whereExists((exists) => exists
|
|
236
|
+
.from('taggables as tg')
|
|
237
|
+
.join('tags as tn', 'tn.id', 'tg.tag_id')
|
|
238
|
+
.where('tg.resource', name)
|
|
239
|
+
.whereRaw('tg.record_id = r.id')
|
|
240
|
+
.where('tn.name', options.tag));
|
|
241
|
+
}
|
|
193
242
|
// Estimate the authorized, filtered query, never the deployment-wide table cardinality.
|
|
194
243
|
// Only the first page pays for the extra planner round trip; later pages keep its figure.
|
|
195
244
|
let estimatedTotal;
|
|
196
245
|
if (options.estimate !== false && !options.cursor) {
|
|
197
246
|
const compiled = query.clone().clearSelect().select('r.id').toSQL();
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
247
|
+
// The planner estimate is approximate by nature; reuse it briefly for the same
|
|
248
|
+
// authorized, filtered query instead of planning it on every first page.
|
|
249
|
+
const key = `${compiled.sql}\u0000${JSON.stringify(compiled.bindings)}`;
|
|
250
|
+
const cached = estimates.get(key);
|
|
251
|
+
if (cached && cached.at > Date.now() - ESTIMATE_TTL_MS)
|
|
252
|
+
estimatedTotal = cached.rows;
|
|
253
|
+
else {
|
|
254
|
+
const estimate = await this.db.raw(`EXPLAIN (FORMAT JSON) ${compiled.sql}`, [
|
|
255
|
+
...compiled.bindings,
|
|
256
|
+
]);
|
|
257
|
+
estimatedTotal = Number(estimate.rows[0]['QUERY PLAN'][0].Plan['Plan Rows']);
|
|
258
|
+
if (estimates.size >= ESTIMATE_CACHE_LIMIT)
|
|
259
|
+
estimates.delete(estimates.keys().next().value);
|
|
260
|
+
estimates.set(key, { rows: estimatedTotal, at: Date.now() });
|
|
261
|
+
}
|
|
202
262
|
}
|
|
203
263
|
if (options.cursor) {
|
|
204
264
|
let cursor;
|
|
@@ -700,7 +760,17 @@ export class ResourceService {
|
|
|
700
760
|
}, childId === undefined ? undefined : Number(childId), trx, { name, id: Number(row.id), action });
|
|
701
761
|
}
|
|
702
762
|
}
|
|
703
|
-
|
|
763
|
+
const changes = [];
|
|
764
|
+
if (id !== undefined)
|
|
765
|
+
for (const key of Object.keys(resource.fields)) {
|
|
766
|
+
if (resource.fields[key].type === 'hasMany' || !(key in saved))
|
|
767
|
+
continue;
|
|
768
|
+
const before = existing[key] ?? null;
|
|
769
|
+
const after = saved[key] ?? null;
|
|
770
|
+
if (JSON.stringify(before) !== JSON.stringify(after))
|
|
771
|
+
changes.push({ field: key, before, after });
|
|
772
|
+
}
|
|
773
|
+
await this.audit(trx, resource, actor, action, saved, Object.keys(form), changes);
|
|
704
774
|
await resource.hooks?.afterSave?.(saved, context);
|
|
705
775
|
return serialize(resource, await this.hydrateOne(trx, resource, saved), ability, actor);
|
|
706
776
|
};
|
|
@@ -769,7 +839,92 @@ export class ResourceService {
|
|
|
769
839
|
};
|
|
770
840
|
return transaction ? work(transaction) : this.db.transaction(work);
|
|
771
841
|
}
|
|
772
|
-
|
|
842
|
+
/**
|
|
843
|
+
* Amend-by-copy: a cancelled document is copied into a new draft that points to
|
|
844
|
+
* it through amended_from_id, together with its inline lines. The original stays
|
|
845
|
+
* cancelled and unchanged; sequence fields receive new numbers.
|
|
846
|
+
*/
|
|
847
|
+
async amend(name, id, actor, transaction) {
|
|
848
|
+
const resource = this.registry.get(name);
|
|
849
|
+
if (!resource.submittable)
|
|
850
|
+
throw new KitError(409, 'E_DOCUMENT_STATE', 'Only submittable documents can be amended');
|
|
851
|
+
const ability = this.authorizeAction(resource, actor, 'amend');
|
|
852
|
+
const work = async (trx) => {
|
|
853
|
+
const source = await this.find(trx, resource, actor, ability, id, true);
|
|
854
|
+
this.requireRecord(ability, actor, resource, 'amend', source);
|
|
855
|
+
if (source.docStatus !== 2)
|
|
856
|
+
throw new KitError(409, 'E_DOCUMENT_STATE', 'Only cancelled documents can be amended');
|
|
857
|
+
const existing = await trx(name)
|
|
858
|
+
.where('amended_from_id', id)
|
|
859
|
+
.whereNull('deleted_at')
|
|
860
|
+
.first('id');
|
|
861
|
+
if (existing)
|
|
862
|
+
throw new KitError(409, 'E_ALREADY_AMENDED', 'تم تعديل هذا المستند بالنسخ من قبل');
|
|
863
|
+
const values = {
|
|
864
|
+
created_by: actor.id,
|
|
865
|
+
updated_by: actor.id,
|
|
866
|
+
doc_status: 0,
|
|
867
|
+
amended_from_id: id,
|
|
868
|
+
};
|
|
869
|
+
if (resource.scoped)
|
|
870
|
+
values.org_unit_id = source.orgUnitId;
|
|
871
|
+
if (resource.version)
|
|
872
|
+
values.version = 1;
|
|
873
|
+
const copied = [];
|
|
874
|
+
for (const [key, field] of Object.entries(resource.fields)) {
|
|
875
|
+
if (field.type === 'hasMany' || !(key in source))
|
|
876
|
+
continue;
|
|
877
|
+
const column = field.column ?? columnName(key);
|
|
878
|
+
if (field.sequence)
|
|
879
|
+
values[column] = await sequence(trx, field.sequence);
|
|
880
|
+
else if (field.type === 'attachment')
|
|
881
|
+
continue;
|
|
882
|
+
else {
|
|
883
|
+
values[column] = field.type === 'json' ? JSON.stringify(source[key]) : source[key];
|
|
884
|
+
copied.push(key);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
const [row] = await trx(name).insert(values).returning('*');
|
|
888
|
+
const saved = { ...fromRow(row, resource), orgPath: source.orgPath };
|
|
889
|
+
for (const field of Object.values(resource.fields)) {
|
|
890
|
+
if (field.type !== 'hasMany' || !field.inline)
|
|
891
|
+
continue;
|
|
892
|
+
const child = this.registry.get(field.resource);
|
|
893
|
+
const foreign = child.fields[field.foreignKey].column ?? columnName(field.foreignKey);
|
|
894
|
+
const lines = await trx(child.name).where(foreign, id).whereNull('deleted_at').orderBy('id');
|
|
895
|
+
for (const line of lines) {
|
|
896
|
+
const copy = {
|
|
897
|
+
created_by: actor.id,
|
|
898
|
+
updated_by: actor.id,
|
|
899
|
+
[foreign]: row.id,
|
|
900
|
+
};
|
|
901
|
+
if (child.scoped)
|
|
902
|
+
copy.org_unit_id = line.org_unit_id;
|
|
903
|
+
if (child.version)
|
|
904
|
+
copy.version = 1;
|
|
905
|
+
for (const [key, childField] of Object.entries(child.fields)) {
|
|
906
|
+
const column = childField.column ?? columnName(key);
|
|
907
|
+
if (key === field.foreignKey ||
|
|
908
|
+
childField.type === 'hasMany' ||
|
|
909
|
+
childField.type === 'attachment' ||
|
|
910
|
+
!(column in line))
|
|
911
|
+
continue;
|
|
912
|
+
copy[column] = childField.sequence
|
|
913
|
+
? await sequence(trx, childField.sequence)
|
|
914
|
+
: childField.type === 'json'
|
|
915
|
+
? JSON.stringify(line[column])
|
|
916
|
+
: line[column];
|
|
917
|
+
}
|
|
918
|
+
const [inserted] = await trx(child.name).insert(copy).returning('id');
|
|
919
|
+
await this.audit(trx, child, actor, 'create', { id: inserted.id }, Object.keys(child.fields));
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
await this.audit(trx, resource, actor, 'amend', saved, copied);
|
|
923
|
+
return serialize(resource, await this.hydrateOne(trx, resource, saved), ability, actor);
|
|
924
|
+
};
|
|
925
|
+
return transaction ? work(transaction) : this.db.transaction(work);
|
|
926
|
+
}
|
|
927
|
+
async audit(trx, resource, actor, action, record, fields, changes) {
|
|
773
928
|
await recordMutation(trx, {
|
|
774
929
|
module: this.registry.owner(resource.name),
|
|
775
930
|
resource: resource.name,
|
|
@@ -778,6 +933,7 @@ export class ResourceService {
|
|
|
778
933
|
impersonatorId: actor.impersonatorId,
|
|
779
934
|
action,
|
|
780
935
|
fields,
|
|
936
|
+
changes,
|
|
781
937
|
});
|
|
782
938
|
}
|
|
783
939
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
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 { ActorLoader } from './record_collaboration.js';
|
|
5
|
+
export type AssignmentStatus = 'open' | 'done' | 'cancelled';
|
|
6
|
+
export type Assignment = {
|
|
7
|
+
id: number;
|
|
8
|
+
resource: string;
|
|
9
|
+
resourceLabel: string;
|
|
10
|
+
recordId: number;
|
|
11
|
+
assigneeId: number;
|
|
12
|
+
assigneeName: string | null;
|
|
13
|
+
assignedBy: number | null;
|
|
14
|
+
assignedByName: string | null;
|
|
15
|
+
kind: string;
|
|
16
|
+
title: string;
|
|
17
|
+
note: string | null;
|
|
18
|
+
dueOn: string | null;
|
|
19
|
+
status: AssignmentStatus;
|
|
20
|
+
outcome: string | null;
|
|
21
|
+
createdAt: string;
|
|
22
|
+
completedAt: string | null;
|
|
23
|
+
/** Set for approval steps; completion goes through the workflow engine instead. */
|
|
24
|
+
workflowRunId: string | null;
|
|
25
|
+
canComplete: boolean;
|
|
26
|
+
canCancel: boolean;
|
|
27
|
+
};
|
|
28
|
+
export type AssignmentPage = {
|
|
29
|
+
data: Assignment[];
|
|
30
|
+
nextCursor: string | null;
|
|
31
|
+
open: number;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Assignments put a record on someone's "my tasks" list. Assigning needs update
|
|
35
|
+
* permission on the record, the assignee must be able to read it, and every read
|
|
36
|
+
* re-checks the record so lost access hides the task instead of leaking it.
|
|
37
|
+
*/
|
|
38
|
+
export declare class Assignments {
|
|
39
|
+
private db;
|
|
40
|
+
private resources;
|
|
41
|
+
private actors;
|
|
42
|
+
constructor(db: Knex, resources: ResourceService, actors: ActorLoader);
|
|
43
|
+
forRecord(name: string, id: number, actor: Actor): Promise<Assignment[]>;
|
|
44
|
+
assign(name: string, id: number, actor: Actor, input: {
|
|
45
|
+
assigneeId: unknown;
|
|
46
|
+
title: unknown;
|
|
47
|
+
note?: unknown;
|
|
48
|
+
dueOn?: unknown;
|
|
49
|
+
}): Promise<Assignment>;
|
|
50
|
+
/**
|
|
51
|
+
* Inserts an assignment and its notification. Used by assign() and by workflow
|
|
52
|
+
* approval steps inside their own transaction; callers authorize beforehand.
|
|
53
|
+
*/
|
|
54
|
+
create(db: Knex, input: {
|
|
55
|
+
resource: string;
|
|
56
|
+
recordId: number;
|
|
57
|
+
assigneeId: number;
|
|
58
|
+
assignedBy: number | null;
|
|
59
|
+
title: string;
|
|
60
|
+
note?: string | null;
|
|
61
|
+
dueOn?: string | null;
|
|
62
|
+
kind?: string;
|
|
63
|
+
workflowRunId?: string;
|
|
64
|
+
workflowStep?: string;
|
|
65
|
+
}): Promise<any>;
|
|
66
|
+
/** The signed-in user's own tasks, open first; records they can no longer read are hidden. */
|
|
67
|
+
mine(actor: Actor, options?: {
|
|
68
|
+
status?: string;
|
|
69
|
+
cursor?: string;
|
|
70
|
+
limit?: number;
|
|
71
|
+
}): Promise<AssignmentPage>;
|
|
72
|
+
/** The assignee marks the task done, or the assigner cancels it. */
|
|
73
|
+
complete(assignmentId: unknown, actor: Actor, outcome?: 'done' | 'cancelled'): Promise<void>;
|
|
74
|
+
private query;
|
|
75
|
+
private present;
|
|
76
|
+
private label;
|
|
77
|
+
private canView;
|
|
78
|
+
}
|
|
@@ -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[];
|