@adula/kit 1.0.0 → 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 (87) hide show
  1. package/build/agent/AGENTS.template.md +2 -2
  2. package/build/agent/capabilities.md +10 -5
  3. package/build/agent/skills/adula-frontend-design/SKILL.md +1 -1
  4. package/build/commands/doctor.js +37 -1
  5. package/build/commands/gaps.js +16 -6
  6. package/build/commands/install.js +19 -5
  7. package/build/commands/main.js +2 -0
  8. package/build/commands/module_add.js +2 -2
  9. package/build/commands/resource.js +1 -1
  10. package/build/commands/resource_snapshot.d.ts +15 -0
  11. package/build/commands/resource_snapshot.js +61 -0
  12. package/build/database/migrations/1770000000011_kit_managed_assignments.d.ts +5 -0
  13. package/build/database/migrations/1770000000011_kit_managed_assignments.js +10 -0
  14. package/build/database/migrations/1770000000012_kit_role_keys.d.ts +5 -0
  15. package/build/database/migrations/1770000000012_kit_role_keys.js +10 -0
  16. package/build/database/migrations/1770000000013_kit_notification_targets.d.ts +5 -0
  17. package/build/database/migrations/1770000000013_kit_notification_targets.js +10 -0
  18. package/build/database/migrations/1770000000014_kit_upload_grants.d.ts +5 -0
  19. package/build/database/migrations/1770000000014_kit_upload_grants.js +10 -0
  20. package/build/database/migrations/1770000000015_kit_inbound_webhooks.d.ts +5 -0
  21. package/build/database/migrations/1770000000015_kit_inbound_webhooks.js +10 -0
  22. package/build/index.d.ts +14 -5
  23. package/build/index.js +6 -2
  24. package/build/src/admin/contracts.d.ts +7 -0
  25. package/build/src/admin/contracts.js +15 -2
  26. package/build/src/admin/controller.d.ts +1 -0
  27. package/build/src/admin/controller.js +46 -1
  28. package/build/src/admin/presentation.d.ts +6 -0
  29. package/build/src/admin/record_title.d.ts +14 -0
  30. package/build/src/admin/record_title.js +50 -0
  31. package/build/src/admin/resource_service.d.ts +163 -2
  32. package/build/src/admin/resource_service.js +552 -43
  33. package/build/src/attachments/attachment_service.d.ts +12 -0
  34. package/build/src/attachments/attachment_service.js +28 -2
  35. package/build/src/attachments/upload_grants.d.ts +44 -0
  36. package/build/src/attachments/upload_grants.js +105 -0
  37. package/build/src/auth/ability.d.ts +1 -1
  38. package/build/src/auth/ability.js +4 -1
  39. package/build/src/auth/actor_store.js +6 -1
  40. package/build/src/auth/conditions.d.ts +15 -0
  41. package/build/src/auth/conditions.js +36 -0
  42. package/build/src/auth/sql.js +6 -2
  43. package/build/src/collaboration/assignments.d.ts +54 -3
  44. package/build/src/collaboration/assignments.js +125 -11
  45. package/build/src/collaboration/record_collaboration.js +5 -17
  46. package/build/src/commands/capabilities.js +22 -3
  47. package/build/src/commands/doctor.d.ts +31 -0
  48. package/build/src/commands/doctor.js +114 -0
  49. package/build/src/commands/gap_report.d.ts +50 -2
  50. package/build/src/commands/gap_report.js +102 -4
  51. package/build/src/commands/generator.js +3 -3
  52. package/build/src/commands/snapshot.d.ts +28 -0
  53. package/build/src/commands/snapshot.js +48 -0
  54. package/build/src/commands/source_markers.d.ts +10 -1
  55. package/build/src/commands/source_markers.js +36 -4
  56. package/build/src/core/administration_guard.js +3 -1
  57. package/build/src/core/message_templates.d.ts +4 -1
  58. package/build/src/core/message_templates.js +7 -2
  59. package/build/src/core/module_seed.d.ts +15 -0
  60. package/build/src/core/module_seed.js +31 -0
  61. package/build/src/core/notifications.d.ts +18 -1
  62. package/build/src/core/notifications.js +27 -1
  63. package/build/src/core/roles.d.ts +27 -1
  64. package/build/src/core/roles.js +133 -5
  65. package/build/src/database/schema.d.ts +27 -0
  66. package/build/src/database/schema.js +98 -0
  67. package/build/src/eslint/index.js +26 -0
  68. package/build/src/events/record_mutation.d.ts +4 -0
  69. package/build/src/events/record_mutation.js +2 -0
  70. package/build/src/integrations/imports.js +4 -2
  71. package/build/src/integrations/inbound_webhooks.d.ts +94 -0
  72. package/build/src/integrations/inbound_webhooks.js +276 -0
  73. package/build/src/integrations/openapi.js +69 -2
  74. package/build/src/integrations/print.js +1 -0
  75. package/build/src/resource/define_resource.js +12 -0
  76. package/build/src/resource/registry.d.ts +1 -0
  77. package/build/src/resource/registry.js +33 -0
  78. package/build/src/resource/types.d.ts +64 -1
  79. package/build/src/resource/values.js +2 -1
  80. package/build/src/services/settings.d.ts +13 -1
  81. package/build/src/services/settings.js +9 -2
  82. package/build/src/workflows/define_workflow.d.ts +30 -1
  83. package/build/src/workflows/define_workflow.js +44 -4
  84. package/build/src/workflows/engine.d.ts +24 -2
  85. package/build/src/workflows/engine.js +89 -24
  86. package/build/stubs/resource_contract.txt +103 -41
  87. package/package.json +1 -1
@@ -38,6 +38,18 @@ export type UploadInput = {
38
38
  orgUnitId?: number | null;
39
39
  resource?: string | null;
40
40
  field?: string | null;
41
+ /**
42
+ * The redeemed upload grant that authorized this upload instead of a role rule. The upload
43
+ * takes the grant's resource, field and unit, can bind to the granted record only, and is
44
+ * recorded in that record's activity log.
45
+ */
46
+ grant?: {
47
+ id: number;
48
+ resource: string;
49
+ recordId: number;
50
+ field: string;
51
+ orgUnitId: number | null;
52
+ } | null;
41
53
  };
42
54
  export type ClaimInput = {
43
55
  attachmentId: number;
@@ -1,5 +1,6 @@
1
1
  import { KitError } from '../admin/errors.js';
2
2
  import { identifier } from '../resource/define_resource.js';
3
+ import { logActivity } from '../core/activity.js';
3
4
  const MAX_ID = 2147483647;
4
5
  const REJECTED = 'المرفق غير موجود أو لا يخصك';
5
6
  /** Business documents, images and archives; a field widens or narrows it with `accept`. */
@@ -151,6 +152,18 @@ export async function registerUpload(db, input) {
151
152
  }
152
153
  if (input.resource)
153
154
  identifier(input.resource);
155
+ const grant = input.grant;
156
+ if (grant &&
157
+ (!isAttachmentId(grant.id) ||
158
+ !isAttachmentId(grant.recordId) ||
159
+ grant.resource !== input.resource ||
160
+ grant.field !== input.field))
161
+ throw new KitError(422, 'E_ATTACHMENT_INPUT', 'The upload grant names another field');
162
+ // Only a redeemed grant may mark an upload as granted.
163
+ const data = { ...input.data };
164
+ delete data.uploadGrant;
165
+ if (grant)
166
+ data.uploadGrant = { id: grant.id, recordId: grant.recordId };
154
167
  const [row] = await db('attachments')
155
168
  .insert({
156
169
  disk: input.disk,
@@ -160,13 +173,21 @@ export async function registerUpload(db, input) {
160
173
  size: input.size,
161
174
  mime_type: input.mimeType || 'application/octet-stream',
162
175
  extname: input.extname,
163
- data: JSON.stringify(input.data),
176
+ data: JSON.stringify(data),
164
177
  uploaded_by: input.uploadedBy,
165
- org_unit_id: input.orgUnitId ?? null,
178
+ org_unit_id: grant ? grant.orgUnitId : (input.orgUnitId ?? null),
166
179
  resource: input.resource ?? null,
167
180
  field: input.field ?? null,
168
181
  })
169
182
  .returning('*');
183
+ if (grant)
184
+ await logActivity(db, {
185
+ resource: grant.resource,
186
+ recordId: grant.recordId,
187
+ actorId: input.uploadedBy,
188
+ action: 'upload_via_grant',
189
+ changes: { field: grant.field, attachmentId: Number(row.id), grantId: grant.id },
190
+ });
170
191
  return fromRow(row);
171
192
  }
172
193
  export async function findAttachment(db, id) {
@@ -203,6 +224,11 @@ export async function claimAttachment(trx, input) {
203
224
  if ((row.resource !== null && row.resource !== input.resource) ||
204
225
  (row.field !== null && row.field !== input.field))
205
226
  throw new KitError(422, 'E_ATTACHMENT', 'المرفق مخصص لحقل آخر');
227
+ // An upload authorized by a grant binds to the granted record only, never to a new one.
228
+ const granted = row.data?.uploadGrant;
229
+ if (granted &&
230
+ (input.recordId === undefined || Number(granted.recordId) !== Number(input.recordId)))
231
+ throw new KitError(422, 'E_ATTACHMENT', 'المرفق مخصص لسجل آخر');
206
232
  const bound = row.record_id !== null;
207
233
  const sameRecord = bound &&
208
234
  input.recordId !== undefined &&
@@ -0,0 +1,44 @@
1
+ import type { Knex } from 'knex';
2
+ import type { ResourceRegistry } from '../resource/registry.js';
3
+ /** Longest lifetime of an upload grant; module pages ask for one right before the upload. */
4
+ export declare const UPLOAD_GRANT_MAX_TTL_MS: number;
5
+ export type UploadGrantInput = {
6
+ resource: string;
7
+ recordId: number;
8
+ /** An attachment field of the resource. */
9
+ field: string;
10
+ /** The user who may upload with the grant. */
11
+ userId: number;
12
+ /** Who decided the grant (the module's signed-in user or a system actor); defaults to userId. */
13
+ actorId?: number;
14
+ /** Lifetime in milliseconds, at most one hour (default ten minutes). */
15
+ ttlMs?: number;
16
+ };
17
+ export type UploadGrant = {
18
+ token: string;
19
+ expiresAt: string;
20
+ };
21
+ export type RedeemedUploadGrant = {
22
+ id: number;
23
+ resource: string;
24
+ recordId: number;
25
+ field: string;
26
+ orgUnitId: number | null;
27
+ };
28
+ /**
29
+ * Lets one user upload a file for one attachment field of one existing record, when module
30
+ * code has authorized that user itself (for example the inspector of a visit) and the role
31
+ * rules grant no generic right. Only the token hash is stored. The grant replaces the
32
+ * upload's authorization (the role rule, the field's permission and hidden levels and form
33
+ * membership); type, size, the pending-upload limit, ownership and binding through the
34
+ * record write stay as for any upload, and the upload can bind to this record only. Issuing it is recorded in the record's activity log. Call it from module code after
35
+ * the module's own authorization, never on a user's word alone.
36
+ */
37
+ export declare function grantUpload(db: Knex, registry: Pick<ResourceRegistry, 'all' | 'get'>, input: UploadGrantInput): Promise<UploadGrant>;
38
+ /**
39
+ * The grant a token names, while it is unexpired, held by this user, and its record is still
40
+ * live and, for documents, a draft; otherwise null. The unit is the record's current unit.
41
+ */
42
+ export declare function redeemUploadGrant(db: Knex, registry: Pick<ResourceRegistry, 'all' | 'get'>, token: unknown, userId: number): Promise<RedeemedUploadGrant | null>;
43
+ /** Removes expired grants; the upload pruning schedule may call it. */
44
+ export declare function pruneUploadGrants(db: Knex): Promise<number>;
@@ -0,0 +1,105 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { KitError } from '../admin/errors.js';
3
+ import { logActivity } from '../core/activity.js';
4
+ import { isAttachmentId } from './attachment_service.js';
5
+ /** Longest lifetime of an upload grant; module pages ask for one right before the upload. */
6
+ export const UPLOAD_GRANT_MAX_TTL_MS = 60 * 60 * 1000;
7
+ const DEFAULT_TTL_MS = 10 * 60 * 1000;
8
+ const hash = (token) => createHash('sha256').update(token).digest('hex');
9
+ /**
10
+ * Lets one user upload a file for one attachment field of one existing record, when module
11
+ * code has authorized that user itself (for example the inspector of a visit) and the role
12
+ * rules grant no generic right. Only the token hash is stored. The grant replaces the
13
+ * upload's authorization (the role rule, the field's permission and hidden levels and form
14
+ * membership); type, size, the pending-upload limit, ownership and binding through the
15
+ * record write stay as for any upload, and the upload can bind to this record only. Issuing it is recorded in the record's activity log. Call it from module code after
16
+ * the module's own authorization, never on a user's word alone.
17
+ */
18
+ export async function grantUpload(db, registry, input) {
19
+ if (typeof input.resource !== 'string' ||
20
+ !registry.all().some((entry) => entry.name === input.resource))
21
+ throw new KitError(404, 'E_NOT_FOUND', 'الكيان غير موجود');
22
+ const resource = registry.get(input.resource);
23
+ const field = Object.hasOwn(resource.fields, input.field) ? resource.fields[input.field] : null;
24
+ if (!field || field.type !== 'attachment')
25
+ throw new KitError(422, 'E_FIELD_INVALID', 'الحقل ليس حقل مرفقات');
26
+ if (!isAttachmentId(input.recordId) || !isAttachmentId(input.userId))
27
+ throw new KitError(422, 'E_UPLOAD_GRANT', 'Invalid record or user');
28
+ if (input.actorId !== undefined && !isAttachmentId(input.actorId))
29
+ throw new KitError(422, 'E_UPLOAD_GRANT', 'Invalid actor');
30
+ const ttl = input.ttlMs ?? DEFAULT_TTL_MS;
31
+ if (!Number.isSafeInteger(ttl) || ttl < 1000 || ttl > UPLOAD_GRANT_MAX_TTL_MS)
32
+ throw new KitError(422, 'E_UPLOAD_GRANT', 'Grant lifetime must be between 1 second and 1 hour');
33
+ const record = await db(resource.name)
34
+ .where('id', input.recordId)
35
+ .whereNull('deleted_at')
36
+ .first([
37
+ 'id',
38
+ ...(resource.scoped ? ['org_unit_id'] : []),
39
+ ...(resource.submittable ? ['doc_status'] : []),
40
+ ]);
41
+ if (!record)
42
+ throw new KitError(404, 'E_NOT_FOUND', 'السجل غير موجود');
43
+ // A submitted or cancelled document is locked: no new evidence can be bound to it.
44
+ if (resource.submittable && Number(record.doc_status) !== 0)
45
+ throw new KitError(409, 'E_DOCUMENT_LOCKED', 'المستند معتمد أو ملغى ولا يقبل مرفقات جديدة');
46
+ // A disabled user cannot sign in, so cannot redeem the grant either.
47
+ if (!(await db('users').where('id', input.userId).first('id')))
48
+ throw new KitError(422, 'E_UPLOAD_GRANT', 'Unknown user');
49
+ const token = randomBytes(32).toString('base64url');
50
+ const expiresAt = new Date(Date.now() + ttl);
51
+ await db('upload_grants').insert({
52
+ token_hash: hash(token),
53
+ user_id: input.userId,
54
+ resource: resource.name,
55
+ record_id: input.recordId,
56
+ field: input.field,
57
+ org_unit_id: resource.scoped ? record.org_unit_id : null,
58
+ expires_at: expiresAt,
59
+ created_by: input.actorId ?? input.userId,
60
+ });
61
+ await logActivity(db, {
62
+ resource: resource.name,
63
+ recordId: input.recordId,
64
+ actorId: input.actorId ?? input.userId,
65
+ action: 'upload_granted',
66
+ changes: { field: input.field, userId: input.userId, expiresAt: expiresAt.toISOString() },
67
+ });
68
+ return { token, expiresAt: expiresAt.toISOString() };
69
+ }
70
+ /**
71
+ * The grant a token names, while it is unexpired, held by this user, and its record is still
72
+ * live and, for documents, a draft; otherwise null. The unit is the record's current unit.
73
+ */
74
+ export async function redeemUploadGrant(db, registry, token, userId) {
75
+ if (typeof token !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(token))
76
+ return null;
77
+ const row = await db('upload_grants')
78
+ .where({ token_hash: hash(token), user_id: userId })
79
+ .where('expires_at', '>', db.fn.now())
80
+ .first();
81
+ if (!row || !registry.all().some((entry) => entry.name === row.resource))
82
+ return null;
83
+ const resource = registry.get(String(row.resource));
84
+ const record = await db(resource.name)
85
+ .where('id', row.record_id)
86
+ .whereNull('deleted_at')
87
+ .first([
88
+ 'id',
89
+ ...(resource.scoped ? ['org_unit_id'] : []),
90
+ ...(resource.submittable ? ['doc_status'] : []),
91
+ ]);
92
+ if (!record || (resource.submittable && Number(record.doc_status) !== 0))
93
+ return null;
94
+ return {
95
+ id: Number(row.id),
96
+ resource: resource.name,
97
+ recordId: Number(row.record_id),
98
+ field: String(row.field),
99
+ orgUnitId: resource.scoped && record.org_unit_id !== null ? Number(record.org_unit_id) : null,
100
+ };
101
+ }
102
+ /** Removes expired grants; the upload pruning schedule may call it. */
103
+ export async function pruneUploadGrants(db) {
104
+ return db('upload_grants').where('expires_at', '<=', db.fn.now()).delete();
105
+ }
@@ -17,7 +17,7 @@ export type Actor = {
17
17
  impersonatorId?: number;
18
18
  };
19
19
  export type KitAbility = Ability<[string, any], Conditions>;
20
- export declare const abilitySchemas: WeakMap<KitAbility, readonly Pick<Resource, "fields" | "name">[]>;
20
+ export declare const abilitySchemas: WeakMap<KitAbility, readonly Pick<Resource, "name" | "fields">[]>;
21
21
  export declare function buildAbility(rules: readonly Rule[], schemas?: readonly Pick<Resource, 'name' | 'fields'>[]): KitAbility;
22
22
  export declare function inOrgScope(path: unknown, paths: readonly string[]): boolean;
23
23
  export declare function canRecord(ability: KitAbility, actor: Actor, resource: Resource, action: string, record: RecordData, field?: string): boolean;
@@ -1,9 +1,12 @@
1
1
  import { Ability, subject, fieldPatternMatcher } from '@casl/ability';
2
- import { conditionsMatcher, predicates } from './conditions.js';
2
+ import { conditionsMatcher, predicates, usesActor } from './conditions.js';
3
3
  export const abilitySchemas = new WeakMap();
4
4
  export function buildAbility(rules, schemas = []) {
5
5
  for (const rule of rules) {
6
6
  predicates(rule.conditions);
7
+ // An unresolved placeholder would compare as text: `$ne` and inverted rules would fail open.
8
+ if (usesActor(rule.conditions))
9
+ throw new Error('Rule conditions use $actor.id; resolve them with resolveActorConditions');
7
10
  if (rule.fields?.length === 0)
8
11
  throw new Error('An empty fields rule is invalid');
9
12
  }
@@ -1,3 +1,4 @@
1
+ import { actorConditionField, resolveActorConditions } from './conditions.js';
1
2
  export class ActorStore {
2
3
  db;
3
4
  registry;
@@ -30,10 +31,14 @@ export class ActorStore {
30
31
  // Privileged field access only comes from deployment-wide roles.
31
32
  if (!row.role_path)
32
33
  permissionLevel = Math.max(permissionLevel, row.permission_level);
34
+ const subject = this.registry.all().find((resource) => resource.name === row.subject);
33
35
  const rule = {
34
36
  subject: row.subject,
35
37
  action: row.action,
36
- conditions: row.conditions ?? undefined,
38
+ // "$actor.id" becomes this user's id, so CASL and SQL compare a bound integer.
39
+ // On another field of a registered resource it stays, and buildAbility refuses the
40
+ // rule (fail closed). A store built without that resource cannot judge its fields.
41
+ conditions: resolveActorConditions(row.conditions ?? undefined, id, (field) => subject ? actorConditionField(subject, field) : true),
37
42
  fields: row.fields ?? undefined,
38
43
  inverted: row.inverted,
39
44
  };
@@ -6,6 +6,21 @@ export type Predicate = {
6
6
  operator: Operator;
7
7
  value: Scalar | Scalar[];
8
8
  };
9
+ /**
10
+ * Role rule operand replaced by the signed-in user's id when the actor is loaded
11
+ * (for example `{ createdBy: '$actor.id' }`). It is bound as a query parameter.
12
+ */
13
+ export declare const ACTOR_ID = "$actor.id";
14
+ /**
15
+ * Replaces ACTOR_ID operands with the actor's id; other values are returned unchanged.
16
+ * With `accepts`, only the fields it accepts are resolved: a placeholder left on another
17
+ * field makes buildAbility refuse the rule instead of comparing an id with text.
18
+ */
19
+ export declare function resolveActorConditions(conditions: Conditions | undefined, actorId: number, accepts?: (field: string) => boolean): Conditions | undefined;
20
+ /** Fields whose conditions may name the current user: user fields, createdBy and updatedBy. */
21
+ export declare function actorConditionField(resource: Pick<Resource, 'fields'> | undefined, field: string): boolean;
22
+ /** Whether a condition object uses the ACTOR_ID placeholder as an operand. */
23
+ export declare function usesActor(conditions: Conditions | undefined | null): boolean;
9
24
  export declare function predicates(conditions?: Conditions, fields?: Set<string>): Predicate[];
10
25
  export declare function likePattern(value: string): RegExp;
11
26
  export declare function conditionsMatcher(conditions: Conditions, schemas?: readonly Pick<Resource, 'name' | 'fields'>[]): (record: RecordData) => boolean;
@@ -1,4 +1,40 @@
1
1
  import { columnName } from '../resource/define_resource.js';
2
+ /**
3
+ * Role rule operand replaced by the signed-in user's id when the actor is loaded
4
+ * (for example `{ createdBy: '$actor.id' }`). It is bound as a query parameter.
5
+ */
6
+ export const ACTOR_ID = '$actor.id';
7
+ /**
8
+ * Replaces ACTOR_ID operands with the actor's id; other values are returned unchanged.
9
+ * With `accepts`, only the fields it accepts are resolved: a placeholder left on another
10
+ * field makes buildAbility refuse the rule instead of comparing an id with text.
11
+ */
12
+ export function resolveActorConditions(conditions, actorId, accepts) {
13
+ if (!conditions || typeof conditions !== 'object' || Array.isArray(conditions))
14
+ return conditions;
15
+ const resolve = (value) => value === ACTOR_ID ? actorId : Array.isArray(value) ? value.map(resolve) : value;
16
+ return Object.fromEntries(Object.entries(conditions).map(([field, condition]) => [
17
+ field,
18
+ accepts && !accepts(field)
19
+ ? condition
20
+ : condition !== null && typeof condition === 'object' && !Array.isArray(condition)
21
+ ? Object.fromEntries(Object.entries(condition).map(([operator, value]) => [operator, resolve(value)]))
22
+ : resolve(condition),
23
+ ]));
24
+ }
25
+ /** Fields whose conditions may name the current user: user fields, createdBy and updatedBy. */
26
+ export function actorConditionField(resource, field) {
27
+ return (resource !== undefined &&
28
+ (field === 'createdBy' || field === 'updatedBy' || resource.fields[field]?.type === 'user'));
29
+ }
30
+ /** Whether a condition object uses the ACTOR_ID placeholder as an operand. */
31
+ export function usesActor(conditions) {
32
+ const found = (value) => value === ACTOR_ID ||
33
+ (Array.isArray(value)
34
+ ? value.some(found)
35
+ : value !== null && typeof value === 'object' && Object.values(value).some(found));
36
+ return found(conditions ?? {});
37
+ }
2
38
  const operators = new Set(['$eq', '$ne', '$in', '$lt', '$gt', '$like']);
3
39
  const scalar = (v) => v === null ||
4
40
  ['string', 'boolean'].includes(typeof v) ||
@@ -1,5 +1,5 @@
1
1
  import { abilitySchemas } from './ability.js';
2
- import { predicates } from './conditions.js';
2
+ import { predicates, usesActor } from './conditions.js';
3
3
  import { columnName } from '../resource/define_resource.js';
4
4
  import { canonicalDate, canonicalMoney } from '../resource/values.js';
5
5
  export function conditionSql(conditions, resource) {
@@ -14,6 +14,9 @@ export function conditionSql(conditions, resource) {
14
14
  'orgPath',
15
15
  ]);
16
16
  const parts = predicates(conditions, allowed);
17
+ // The current user is bound as an integer; an unresolved placeholder must not reach SQL.
18
+ if (usesActor(conditions))
19
+ throw new Error('Rule conditions use $actor.id; resolve them with resolveActorConditions');
17
20
  const bindings = [];
18
21
  const text = parts
19
22
  .map(({ field, operator, value }) => {
@@ -25,7 +28,8 @@ export function conditionSql(conditions, resource) {
25
28
  throw new Error(`Conditions on ${type} fields require a typed adapter`);
26
29
  const expected = ['id', 'orgUnitId', 'createdBy', 'updatedBy', 'version', 'docStatus'].includes(field) ||
27
30
  type === 'integer' ||
28
- type === 'belongsTo'
31
+ type === 'belongsTo' ||
32
+ type === 'user'
29
33
  ? 'number'
30
34
  : type === 'boolean'
31
35
  ? 'boolean'
@@ -8,6 +8,8 @@ export type Assignment = {
8
8
  resource: string;
9
9
  resourceLabel: string;
10
10
  recordId: number;
11
+ /** The record's title under the viewer's field access; null shows the id instead (#32). */
12
+ recordTitle: string | null;
11
13
  assigneeId: number;
12
14
  assigneeName: string | null;
13
15
  assignedBy: number | null;
@@ -22,13 +24,33 @@ export type Assignment = {
22
24
  completedAt: string | null;
23
25
  /** Set for approval steps; completion goes through the workflow engine instead. */
24
26
  workflowRunId: string | null;
27
+ /** Opened and closed by module code with the record's state; a manual close needs a note. */
28
+ managed: boolean;
29
+ /** Whether closing this task by hand needs a note. */
30
+ closeNote: CloseNotePolicy;
31
+ /** The note written when the task was closed, by hand or by module code. */
32
+ closeReason: string | null;
25
33
  canComplete: boolean;
26
34
  canCancel: boolean;
35
+ /** An open approval step waiting for this user's decision (WorkflowEngine.decide). 1.2; optional for compatibility. */
36
+ canDecide?: boolean;
27
37
  };
28
38
  export type AssignmentPage = {
29
39
  data: Assignment[];
30
40
  nextCursor: string | null;
41
+ /** Open items of every kind. */
31
42
  open: number;
43
+ /** Open approval steps among them. 1.2; optional for compatibility. */
44
+ approvals?: number;
45
+ };
46
+ /**
47
+ * Whether closing a task by hand needs a note. Each application chooses: `optional`
48
+ * (the default) shows the note field but accepts it empty; `required` refuses an empty
49
+ * note. Managed tasks always need a note when closed by hand.
50
+ */
51
+ export type CloseNotePolicy = 'optional' | 'required';
52
+ export type AssignmentOptions = {
53
+ closeNote?: CloseNotePolicy;
32
54
  };
33
55
  /**
34
56
  * Assignments put a record on someone's "my tasks" list. Assigning needs update
@@ -39,7 +61,8 @@ export declare class Assignments {
39
61
  private db;
40
62
  private resources;
41
63
  private actors;
42
- constructor(db: Knex, resources: ResourceService, actors: ActorLoader);
64
+ private options;
65
+ constructor(db: Knex, resources: ResourceService, actors: ActorLoader, options?: AssignmentOptions);
43
66
  forRecord(name: string, id: number, actor: Actor): Promise<Assignment[]>;
44
67
  assign(name: string, id: number, actor: Actor, input: {
45
68
  assigneeId: unknown;
@@ -62,17 +85,45 @@ export declare class Assignments {
62
85
  kind?: string;
63
86
  workflowRunId?: string;
64
87
  workflowStep?: string;
88
+ /**
89
+ * The task represents open work on the record, such as a ticket to resolve. The
90
+ * assignee cannot mark it done; module code closes it with close() (#51).
91
+ */
92
+ managed?: boolean;
65
93
  }): Promise<any>;
66
94
  /** The signed-in user's own tasks, open first; records they can no longer read are hidden. */
67
95
  mine(actor: Actor, options?: {
68
96
  status?: string;
97
+ /** approval: workflow decisions only; task: manual assignments only. */
98
+ kind?: string;
69
99
  cursor?: string;
70
100
  limit?: number;
71
101
  }): 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>;
102
+ /**
103
+ * The assignee marks the task done, or the assigner cancels it, with a closing note. The
104
+ * note is required when the application's policy says so and for managed tasks.
105
+ */
106
+ complete(assignmentId: unknown, actor: Actor, outcome?: 'done' | 'cancelled', input?: {
107
+ note?: unknown;
108
+ }): Promise<void>;
109
+ /**
110
+ * Closes the open managed tasks of a record when module code decides that its work is
111
+ * finished (or no longer needed), for example from a listener on the record's final
112
+ * state. Recorded in the record's activity log with the reason; the assigner (done) or
113
+ * the assignee (cancelled) is notified as for a manual close. Returns the closed count.
114
+ */
115
+ close(resource: string, recordId: number, options: {
116
+ /** The user recorded as closing the tasks, usually the author of the final change. */
117
+ actorId: number;
118
+ outcome?: 'done' | 'cancelled';
119
+ reason?: string;
120
+ trx?: Knex.Transaction;
121
+ }): Promise<number>;
74
122
  private query;
75
123
  private present;
124
+ /** Adds record titles in one read per resource, under the viewer's field access. */
125
+ private titled;
126
+ private closeNote;
76
127
  private label;
77
128
  private canView;
78
129
  }