@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.
- 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/gaps.js +5 -8
- package/build/commands/main.d.ts +4 -2
- package/build/commands/main.js +2 -0
- 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 +22 -2
- package/build/index.js +13 -2
- 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 +48 -3
- package/build/src/admin/resource_service.js +194 -18
- package/build/src/attachments/attachment_service.d.ts +18 -1
- package/build/src/attachments/attachment_service.js +62 -0
- package/build/src/auth/ability.d.ts +2 -0
- 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/commands/gap_report.d.ts +8 -0
- package/build/src/commands/gap_report.js +12 -0
- package/build/src/core/message_templates.d.ts +80 -0
- package/build/src/core/message_templates.js +288 -0
- package/build/src/core/saved_views.js +31 -6
- package/build/src/core/user_invitations.js +2 -2
- 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 +9 -0
- package/build/src/events/record_mutation.js +18 -3
- 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 +13 -1
- 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 -13
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Knex } from 'knex';
|
|
2
2
|
import type { ResourceRegistry } from '../resource/registry.js';
|
|
3
|
-
import type { Action, RecordData, SerializedRecord } from '../resource/types.js';
|
|
4
|
-
import { type Actor } from '../auth/ability.js';
|
|
3
|
+
import type { Action, RecordData, Resource, SerializedRecord } from '../resource/types.js';
|
|
4
|
+
import { type Actor, type KitAbility } from '../auth/ability.js';
|
|
5
5
|
import type { ResourceDescription, ResourceNavigation, ResourceField } from './presentation.js';
|
|
6
6
|
export type ListOptions = {
|
|
7
7
|
limit?: number;
|
|
@@ -10,12 +10,21 @@ export type ListOptions = {
|
|
|
10
10
|
sort?: string;
|
|
11
11
|
direction?: 'asc' | 'desc';
|
|
12
12
|
filters?: Record<string, unknown>;
|
|
13
|
+
/** Only records carrying this tag (see RecordCollaboration.setTags). */
|
|
14
|
+
tag?: string;
|
|
13
15
|
estimate?: boolean;
|
|
14
16
|
};
|
|
17
|
+
/**
|
|
18
|
+
* Whether an actor may sort, filter or search by a field. Querying reveals values
|
|
19
|
+
* indirectly, so it needs unconditional view access at the field's level.
|
|
20
|
+
*/
|
|
21
|
+
export declare function canQueryField(resource: Resource, actor: Actor, ability: KitAbility, key: string): boolean;
|
|
15
22
|
export declare class ResourceService {
|
|
16
23
|
private db;
|
|
17
24
|
private registry;
|
|
18
25
|
constructor(db: Knex, registry: ResourceRegistry);
|
|
26
|
+
/** The Arabic label of a registered resource, for notifications and titles. */
|
|
27
|
+
label(name: string): string;
|
|
19
28
|
/** Project navigation is derived from registered resources and the current actor. */
|
|
20
29
|
navigation(actor: Actor): ResourceNavigation;
|
|
21
30
|
describe(name: string, actor: Actor): ResourceDescription;
|
|
@@ -29,6 +38,17 @@ export declare class ResourceService {
|
|
|
29
38
|
private columns;
|
|
30
39
|
private find;
|
|
31
40
|
private requireRecord;
|
|
41
|
+
/**
|
|
42
|
+
* Authorizes one record for collaboration features (comments, tags, assignments...).
|
|
43
|
+
* Scope and conditional rules apply exactly as for show/update.
|
|
44
|
+
*/
|
|
45
|
+
access(name: string, id: number, actor: Actor, action?: Action, db?: Knex): Promise<{
|
|
46
|
+
resource: Resource;
|
|
47
|
+
record: RecordData;
|
|
48
|
+
ability: KitAbility;
|
|
49
|
+
}>;
|
|
50
|
+
/** Whether a (possibly other) actor may perform an action on a record; never throws for denial. */
|
|
51
|
+
permits(name: string, id: number, actor: Actor, action?: Action): Promise<boolean>;
|
|
32
52
|
list(name: string, actor: Actor, options?: ListOptions): Promise<{
|
|
33
53
|
data: SerializedRecord[];
|
|
34
54
|
permissions: {
|
|
@@ -75,6 +95,11 @@ export declare class ResourceService {
|
|
|
75
95
|
action: Action;
|
|
76
96
|
fields: string[];
|
|
77
97
|
actorId: number;
|
|
98
|
+
actorName: string | null;
|
|
99
|
+
/**
|
|
100
|
+
* @deprecated Always null. Kept so project-owned copies of resource-show from
|
|
101
|
+
* earlier releases still compile; they fall back to the actor id.
|
|
102
|
+
*/
|
|
78
103
|
actorEmail: string | null;
|
|
79
104
|
createdAt: string;
|
|
80
105
|
}[]>;
|
|
@@ -92,7 +117,21 @@ export declare class ResourceService {
|
|
|
92
117
|
filterable?: boolean;
|
|
93
118
|
permissionLevel?: number;
|
|
94
119
|
sequence?: string;
|
|
95
|
-
type: "string" | "text" | "integer" | "money" | "boolean" | "date" | "datetime" | "json"
|
|
120
|
+
type: "string" | "text" | "integer" | "money" | "boolean" | "date" | "datetime" | "json";
|
|
121
|
+
key: string;
|
|
122
|
+
} | {
|
|
123
|
+
label: import("../resource/types.js").Label;
|
|
124
|
+
column?: string;
|
|
125
|
+
required?: boolean;
|
|
126
|
+
unique?: boolean;
|
|
127
|
+
sortable?: boolean;
|
|
128
|
+
searchable?: boolean;
|
|
129
|
+
filterable?: boolean;
|
|
130
|
+
permissionLevel?: number;
|
|
131
|
+
sequence?: string;
|
|
132
|
+
type: "attachment";
|
|
133
|
+
accept?: string[] | "any";
|
|
134
|
+
maxSize?: string;
|
|
96
135
|
key: string;
|
|
97
136
|
} | {
|
|
98
137
|
label: import("../resource/types.js").Label;
|
|
@@ -166,5 +205,11 @@ export declare class ResourceService {
|
|
|
166
205
|
/** Inline children share the parent's draft state, organization and update authority. */
|
|
167
206
|
private requireInlineParents;
|
|
168
207
|
transition(name: string, id: number, actor: Actor, action: 'delete' | 'submit' | 'cancel', version?: unknown, transaction?: Knex.Transaction): Promise<SerializedRecord>;
|
|
208
|
+
/**
|
|
209
|
+
* Amend-by-copy: a cancelled document is copied into a new draft that points to
|
|
210
|
+
* it through amended_from_id, together with its inline lines. The original stays
|
|
211
|
+
* cancelled and unchanged; sequence fields receive new numbers.
|
|
212
|
+
*/
|
|
213
|
+
amend(name: string, id: number, actor: Actor, transaction?: Knex.Transaction): Promise<SerializedRecord>;
|
|
169
214
|
private audit;
|
|
170
215
|
}
|
|
@@ -8,6 +8,23 @@ 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;
|
|
15
|
+
/**
|
|
16
|
+
* Whether an actor may sort, filter or search by a field. Querying reveals values
|
|
17
|
+
* indirectly, so it needs unconditional view access at the field's level.
|
|
18
|
+
*/
|
|
19
|
+
export function canQueryField(resource, actor, ability, key) {
|
|
20
|
+
const level = Math.max(resource.fields[key].permissionLevel ?? 0, resource.hidden?.includes(key) ? 1 : 0);
|
|
21
|
+
// Conditional field rules cannot safely authorize a global sort/search.
|
|
22
|
+
return (actor.permissionLevel >= level &&
|
|
23
|
+
ability.can('view', resource.name, key) &&
|
|
24
|
+
!ability.rules.some((rule) => rule.conditions &&
|
|
25
|
+
(!rule.fields || rule.fields.includes(key)) &&
|
|
26
|
+
(rule.subject === resource.name || rule.subject === 'all')));
|
|
27
|
+
}
|
|
11
28
|
export class ResourceService {
|
|
12
29
|
db;
|
|
13
30
|
registry;
|
|
@@ -15,6 +32,10 @@ export class ResourceService {
|
|
|
15
32
|
this.db = db;
|
|
16
33
|
this.registry = registry;
|
|
17
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
|
+
}
|
|
18
39
|
/** Project navigation is derived from registered resources and the current actor. */
|
|
19
40
|
navigation(actor) {
|
|
20
41
|
const ability = buildAbility(actor.rules, this.registry.all());
|
|
@@ -72,7 +93,7 @@ export class ResourceService {
|
|
|
72
93
|
!['update', 'delete', 'submit'].includes(action) ||
|
|
73
94
|
record.docStatus === 0) &&
|
|
74
95
|
(action !== 'cancel' || (resource.submittable && record.docStatus === 1)) &&
|
|
75
|
-
action !== 'amend',
|
|
96
|
+
(action !== 'amend' || (resource.submittable && record.docStatus === 2)),
|
|
76
97
|
]));
|
|
77
98
|
}
|
|
78
99
|
normalizeValues(resource, record) {
|
|
@@ -147,6 +168,37 @@ export class ResourceService {
|
|
|
147
168
|
if (!canRecord(ability, actor, resource, action, record))
|
|
148
169
|
throw new KitError(403, 'E_FORBIDDEN', 'ليس لديك صلاحية لهذا الإجراء');
|
|
149
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
|
+
}
|
|
150
202
|
async list(name, actor, options = {}) {
|
|
151
203
|
const resource = this.registry.get(name);
|
|
152
204
|
const ability = this.authorizeAction(resource, actor, 'view');
|
|
@@ -177,14 +229,36 @@ export class ResourceService {
|
|
|
177
229
|
throw new KitError(422, 'E_FILTER', 'Invalid filter value');
|
|
178
230
|
query.where(`r.${resource.fields[key].column ?? columnName(key)}`, value);
|
|
179
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
|
+
}
|
|
180
242
|
// Estimate the authorized, filtered query, never the deployment-wide table cardinality.
|
|
243
|
+
// Only the first page pays for the extra planner round trip; later pages keep its figure.
|
|
181
244
|
let estimatedTotal;
|
|
182
|
-
if (options.estimate !== false) {
|
|
245
|
+
if (options.estimate !== false && !options.cursor) {
|
|
183
246
|
const compiled = query.clone().clearSelect().select('r.id').toSQL();
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
+
}
|
|
188
262
|
}
|
|
189
263
|
if (options.cursor) {
|
|
190
264
|
let cursor;
|
|
@@ -242,13 +316,7 @@ export class ResourceService {
|
|
|
242
316
|
};
|
|
243
317
|
}
|
|
244
318
|
canQueryField(resource, actor, ability, key) {
|
|
245
|
-
|
|
246
|
-
// Conditional field rules cannot safely authorize a global sort/search.
|
|
247
|
-
return (actor.permissionLevel >= level &&
|
|
248
|
-
ability.can('view', resource.name, key) &&
|
|
249
|
-
!ability.rules.some((rule) => rule.conditions &&
|
|
250
|
-
(!rule.fields || rule.fields.includes(key)) &&
|
|
251
|
-
(rule.subject === resource.name || rule.subject === 'all')));
|
|
319
|
+
return canQueryField(resource, actor, ability, key);
|
|
252
320
|
}
|
|
253
321
|
async preload(resource, records, actor) {
|
|
254
322
|
const related = {};
|
|
@@ -363,13 +431,19 @@ export class ResourceService {
|
|
|
363
431
|
.where({ 'a.resource': name, 'a.record_id': id })
|
|
364
432
|
.orderBy('a.id', 'desc')
|
|
365
433
|
.limit(50)
|
|
366
|
-
.select('a.id', 'a.action', 'a.changes', 'a.actor_id', 'a.created_at', 'u.
|
|
434
|
+
.select('a.id', 'a.action', 'a.changes', 'a.actor_id', 'a.created_at', 'u.full_name as actor_name');
|
|
367
435
|
return rows.map((row) => ({
|
|
368
436
|
id: Number(row.id),
|
|
369
437
|
action: String(row.action),
|
|
370
438
|
fields: Array.isArray(row.changes?.fields) ? row.changes.fields : [],
|
|
371
439
|
actorId: Number(row.actor_id),
|
|
372
|
-
|
|
440
|
+
// A display name only: record viewers need not be able to read account e-mails.
|
|
441
|
+
actorName: row.actor_name ? String(row.actor_name) : null,
|
|
442
|
+
/**
|
|
443
|
+
* @deprecated Always null. Kept so project-owned copies of resource-show from
|
|
444
|
+
* earlier releases still compile; they fall back to the actor id.
|
|
445
|
+
*/
|
|
446
|
+
actorEmail: null,
|
|
373
447
|
createdAt: new Date(row.created_at).toISOString(),
|
|
374
448
|
}));
|
|
375
449
|
}
|
|
@@ -540,7 +614,12 @@ export class ResourceService {
|
|
|
540
614
|
candidate.orgPath = unit?.path;
|
|
541
615
|
}
|
|
542
616
|
this.requireRecord(ability, actor, resource, action, candidate);
|
|
543
|
-
|
|
617
|
+
// Validators may add fields (defaults, transforms); those are written too, so check them.
|
|
618
|
+
const written = new Set([
|
|
619
|
+
...Object.keys(form),
|
|
620
|
+
...Object.keys(validated).filter((key) => key in resource.fields),
|
|
621
|
+
]);
|
|
622
|
+
for (const key of written) {
|
|
544
623
|
const field = resource.fields[key];
|
|
545
624
|
if (!ability.can(action, subject(name, candidate), key) ||
|
|
546
625
|
(id !== undefined && !ability.can(action, subject(name, existing), key)) ||
|
|
@@ -681,7 +760,17 @@ export class ResourceService {
|
|
|
681
760
|
}, childId === undefined ? undefined : Number(childId), trx, { name, id: Number(row.id), action });
|
|
682
761
|
}
|
|
683
762
|
}
|
|
684
|
-
|
|
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);
|
|
685
774
|
await resource.hooks?.afterSave?.(saved, context);
|
|
686
775
|
return serialize(resource, await this.hydrateOne(trx, resource, saved), ability, actor);
|
|
687
776
|
};
|
|
@@ -750,14 +839,101 @@ export class ResourceService {
|
|
|
750
839
|
};
|
|
751
840
|
return transaction ? work(transaction) : this.db.transaction(work);
|
|
752
841
|
}
|
|
753
|
-
|
|
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) {
|
|
754
928
|
await recordMutation(trx, {
|
|
755
929
|
module: this.registry.owner(resource.name),
|
|
756
930
|
resource: resource.name,
|
|
757
931
|
id: record.id,
|
|
758
932
|
actorId: actor.id,
|
|
933
|
+
impersonatorId: actor.impersonatorId,
|
|
759
934
|
action,
|
|
760
935
|
fields,
|
|
936
|
+
changes,
|
|
761
937
|
});
|
|
762
938
|
}
|
|
763
939
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Knex } from 'knex';
|
|
2
|
-
import type { JsonValue } from '../resource/types.js';
|
|
2
|
+
import type { Field, JsonValue } from '../resource/types.js';
|
|
3
3
|
export type AttachmentSummary = {
|
|
4
4
|
id: number;
|
|
5
5
|
name: string;
|
|
@@ -51,6 +51,23 @@ export type ClaimInput = {
|
|
|
51
51
|
orgUnitId?: number | null;
|
|
52
52
|
scoped: boolean;
|
|
53
53
|
};
|
|
54
|
+
/** Business documents, images and archives; a field widens or narrows it with `accept`. */
|
|
55
|
+
export declare const DEFAULT_ATTACHMENT_EXTENSIONS: readonly string[];
|
|
56
|
+
/** Unbound uploads one user may hold at once; each is removed by pruning after a day. */
|
|
57
|
+
export declare const PENDING_UPLOAD_LIMIT = 50;
|
|
58
|
+
/** Uploads never bound to a record are removed after this age. */
|
|
59
|
+
export declare const UNBOUND_UPLOAD_TTL_MS: number;
|
|
60
|
+
/** Upload constraints of an attachment field: accepted extensions (undefined = any) and size. */
|
|
61
|
+
export declare function attachmentPolicy(field: Field): {
|
|
62
|
+
extnames?: string[];
|
|
63
|
+
size: string;
|
|
64
|
+
};
|
|
65
|
+
/** Unbound uploads of one user; hosts refuse new uploads beyond PENDING_UPLOAD_LIMIT. */
|
|
66
|
+
export declare function pendingUploadCount(db: Knex, userId: number): Promise<number>;
|
|
67
|
+
/** Uploads that were never bound to a record and are older than the cutoff. */
|
|
68
|
+
export declare function staleUploads(db: Knex, olderThan: Date, limit?: number): Promise<AttachmentRecord[]>;
|
|
69
|
+
/** Deletes the row of an upload that is still unbound; the host removes the file first. */
|
|
70
|
+
export declare function forgetUpload(db: Knex, id: number): Promise<boolean>;
|
|
54
71
|
export declare function attachmentUrl(id: number): string;
|
|
55
72
|
export declare function isAttachmentId(value: unknown): value is number;
|
|
56
73
|
/** Disk keys stay relative and slash-separated so files move between disks unchanged. */
|
|
@@ -2,6 +2,68 @@ import { KitError } from '../admin/errors.js';
|
|
|
2
2
|
import { identifier } from '../resource/define_resource.js';
|
|
3
3
|
const MAX_ID = 2147483647;
|
|
4
4
|
const REJECTED = 'المرفق غير موجود أو لا يخصك';
|
|
5
|
+
/** Business documents, images and archives; a field widens or narrows it with `accept`. */
|
|
6
|
+
export const DEFAULT_ATTACHMENT_EXTENSIONS = Object.freeze([
|
|
7
|
+
'pdf',
|
|
8
|
+
'png',
|
|
9
|
+
'jpg',
|
|
10
|
+
'jpeg',
|
|
11
|
+
'gif',
|
|
12
|
+
'webp',
|
|
13
|
+
'txt',
|
|
14
|
+
'csv',
|
|
15
|
+
'doc',
|
|
16
|
+
'docx',
|
|
17
|
+
'xls',
|
|
18
|
+
'xlsx',
|
|
19
|
+
'ppt',
|
|
20
|
+
'pptx',
|
|
21
|
+
'odt',
|
|
22
|
+
'ods',
|
|
23
|
+
'odp',
|
|
24
|
+
'rtf',
|
|
25
|
+
'zip',
|
|
26
|
+
]);
|
|
27
|
+
/** Unbound uploads one user may hold at once; each is removed by pruning after a day. */
|
|
28
|
+
export const PENDING_UPLOAD_LIMIT = 50;
|
|
29
|
+
/** Uploads never bound to a record are removed after this age. */
|
|
30
|
+
export const UNBOUND_UPLOAD_TTL_MS = 24 * 60 * 60 * 1000;
|
|
31
|
+
/** Upload constraints of an attachment field: accepted extensions (undefined = any) and size. */
|
|
32
|
+
export function attachmentPolicy(field) {
|
|
33
|
+
if (field.type !== 'attachment')
|
|
34
|
+
throw new KitError(422, 'E_FIELD_INVALID', 'الحقل ليس حقل مرفقات');
|
|
35
|
+
const accept = field.accept ?? DEFAULT_ATTACHMENT_EXTENSIONS;
|
|
36
|
+
return {
|
|
37
|
+
extnames: accept === 'any'
|
|
38
|
+
? undefined
|
|
39
|
+
: accept.map((extname) => extname.toLowerCase().replace(/^\./, '')),
|
|
40
|
+
size: field.maxSize ?? '20mb',
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/** Unbound uploads of one user; hosts refuse new uploads beyond PENDING_UPLOAD_LIMIT. */
|
|
44
|
+
export async function pendingUploadCount(db, userId) {
|
|
45
|
+
const [{ count }] = await db('attachments')
|
|
46
|
+
.where('uploaded_by', userId)
|
|
47
|
+
.whereNull('record_id')
|
|
48
|
+
.whereNull('deleted_at')
|
|
49
|
+
.count('* as count');
|
|
50
|
+
return Number(count);
|
|
51
|
+
}
|
|
52
|
+
/** Uploads that were never bound to a record and are older than the cutoff. */
|
|
53
|
+
export async function staleUploads(db, olderThan, limit = 500) {
|
|
54
|
+
const rows = await db('attachments')
|
|
55
|
+
.whereNull('record_id')
|
|
56
|
+
.where('created_at', '<', olderThan)
|
|
57
|
+
.orderBy('id')
|
|
58
|
+
.limit(limit);
|
|
59
|
+
return rows.map(fromRow);
|
|
60
|
+
}
|
|
61
|
+
/** Deletes the row of an upload that is still unbound; the host removes the file first. */
|
|
62
|
+
export async function forgetUpload(db, id) {
|
|
63
|
+
if (!isAttachmentId(id))
|
|
64
|
+
return false;
|
|
65
|
+
return (await db('attachments').where('id', id).whereNull('record_id').delete()) > 0;
|
|
66
|
+
}
|
|
5
67
|
export function attachmentUrl(id) {
|
|
6
68
|
return `/attachments/${id}`;
|
|
7
69
|
}
|
|
@@ -13,6 +13,8 @@ export type Actor = {
|
|
|
13
13
|
orgPaths: string[];
|
|
14
14
|
permissionLevel: number;
|
|
15
15
|
rules: Rule[];
|
|
16
|
+
/** Set while an administrator acts as this user; recorded with every mutation. */
|
|
17
|
+
impersonatorId?: number;
|
|
16
18
|
};
|
|
17
19
|
export type KitAbility = Ability<[string, any], Conditions>;
|
|
18
20
|
export declare const abilitySchemas: WeakMap<KitAbility, readonly Pick<Resource, "fields" | "name">[]>;
|
|
@@ -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
|
+
}
|