@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
@@ -4,7 +4,13 @@ export function createResourceController(resolveRuntime, renderList, renderForm,
4
4
  return class ResourcesController {
5
5
  async create(ctx) {
6
6
  return this.#execute(ctx, async ({ resources, actor }, resource) => {
7
- const editor = await resources.editor(resource.name, actor);
7
+ // `?defaults[field]=value` pre-fills the form; the service authorizes each value.
8
+ const defaults = ctx.request.qs().defaults;
9
+ const editor = await resources.editor(resource.name, actor, undefined, {
10
+ defaults: defaults && typeof defaults === 'object' && !Array.isArray(defaults)
11
+ ? defaults
12
+ : undefined,
13
+ });
8
14
  return renderForm && ctx.request.accepts(['html', 'json']) === 'html'
9
15
  ? renderForm(ctx, resource, editor)
10
16
  : editor;
@@ -50,8 +56,47 @@ export function createResourceController(resolveRuntime, renderList, renderForm,
50
56
  : this.#positiveId(ctx.request.input('id')),
51
57
  search: ctx.request.input('search'),
52
58
  cursor: ctx.request.input('cursor'),
59
+ orgUnitId: ctx.request.input('orgUnitId') === undefined || ctx.request.input('orgUnitId') === ''
60
+ ? undefined
61
+ : this.#positiveId(ctx.request.input('orgUnitId')),
62
+ purpose: ctx.request.input('purpose') === 'filter' ? 'filter' : 'form',
53
63
  }));
54
64
  }
65
+ /**
66
+ * GET …/aggregate?groupBy=status,priority&sum=total&filters[x]=…&where={json}
67
+ * Counts and totals over the records the actor may view (ResourceService.aggregate).
68
+ */
69
+ async aggregate(ctx) {
70
+ return this.#execute(ctx, async ({ resources, actor }, resource) => {
71
+ const list = (value) => value === undefined || value === ''
72
+ ? []
73
+ : Array.isArray(value)
74
+ ? value.map(String)
75
+ : String(value).split(',').filter(Boolean);
76
+ let where;
77
+ const raw = ctx.request.input('where');
78
+ if (typeof raw === 'string' && raw) {
79
+ try {
80
+ where = JSON.parse(raw);
81
+ }
82
+ catch {
83
+ throw new KitError(422, 'E_FILTER', 'where must be a JSON object');
84
+ }
85
+ }
86
+ else if (raw && typeof raw === 'object')
87
+ where = raw;
88
+ return {
89
+ data: await resources.aggregate(resource.name, actor, {
90
+ groupBy: list(ctx.request.input('groupBy')),
91
+ sum: list(ctx.request.input('sum')),
92
+ count: ctx.request.input('count') !== 'false',
93
+ filters: ctx.request.input('filters'),
94
+ where: where,
95
+ search: ctx.request.input('search'),
96
+ }),
97
+ };
98
+ });
99
+ }
55
100
  async store(ctx) {
56
101
  return this.#execute(ctx, async ({ resources, actor }, resource) => ctx.response.created({
57
102
  data: await resources.save(resource.name, actor, ctx.request.body()),
@@ -6,6 +6,10 @@ export type RecordPermissions = Partial<Record<Action, boolean>>;
6
6
  export type ResourceDescription = {
7
7
  name: string;
8
8
  label: string;
9
+ /** Singular record noun in Arabic, or null when the resource does not declare one. */
10
+ recordLabel: string | null;
11
+ /** Arabic create-button text, or null to derive «إضافة <recordLabel>» (or «إضافة سجل»). */
12
+ createLabel: string | null;
9
13
  fields: ResourceField[];
10
14
  list: string[];
11
15
  show: string[];
@@ -19,4 +23,6 @@ export type ResourceNavigation = {
19
23
  label: string;
20
24
  href: string;
21
25
  module: string;
26
+ /** Arabic module label, for grouping the navigation by module (1.2; optional for compatibility). */
27
+ moduleLabel?: string;
22
28
  }[];
@@ -0,0 +1,14 @@
1
+ import type { Resource, SerializedRecord } from '../resource/types.js';
2
+ /**
3
+ * The fields that name a record wherever it is referenced (#47, #32): the resource's
4
+ * `title`, or else its first sequence field and its first text field in `list`. Lookup
5
+ * keys are never shown raw; declared lookup fields are shown by their Arabic label.
6
+ */
7
+ export declare function titleFields(resource: Pick<Resource, 'fields' | 'list' | 'title'>): string[];
8
+ /** Definition check: title fields exist, are readable and have a displayable type. */
9
+ export declare function assertTitle(resource: Pick<Resource, 'name' | 'fields' | 'list' | 'show' | 'serialize' | 'title'>): void;
10
+ /**
11
+ * The title of one serialized record, read after field-level filtering so it never shows
12
+ * a value the viewer may not read. Returns null when no title field has a value.
13
+ */
14
+ export declare function formatTitle(resource: Pick<Resource, 'fields' | 'list' | 'title'>, record: SerializedRecord, lookupLabel: (group: string, key: string) => string | undefined): string | null;
@@ -0,0 +1,50 @@
1
+ const TITLE_TYPES = new Set(['string', 'text', 'integer', 'money', 'date', 'datetime', 'lookup']);
2
+ /**
3
+ * The fields that name a record wherever it is referenced (#47, #32): the resource's
4
+ * `title`, or else its first sequence field and its first text field in `list`. Lookup
5
+ * keys are never shown raw; declared lookup fields are shown by their Arabic label.
6
+ */
7
+ export function titleFields(resource) {
8
+ if (resource.title)
9
+ return [...resource.title];
10
+ const sequence = Object.keys(resource.fields).find((key) => resource.fields[key].sequence);
11
+ const text = resource.list.find((key) => {
12
+ const field = resource.fields[key];
13
+ return (field.type === 'string' || field.type === 'text') && !field.sequence;
14
+ });
15
+ return [sequence, text].filter((key) => key !== undefined);
16
+ }
17
+ /** Definition check: title fields exist, are readable and have a displayable type. */
18
+ export function assertTitle(resource) {
19
+ if (!resource.title)
20
+ return;
21
+ if (!resource.title.length)
22
+ throw new Error(`${resource.name}: title needs at least one field`);
23
+ const readable = new Set(resource.serialize ?? [...resource.list, ...resource.show]);
24
+ for (const key of resource.title) {
25
+ const field = resource.fields[key];
26
+ if (!field)
27
+ throw new Error(`Unknown field: ${key}`);
28
+ if (!TITLE_TYPES.has(field.type))
29
+ throw new Error(`${resource.name}: title field ${key} cannot be a ${field.type}`);
30
+ if (!readable.has(key))
31
+ throw new Error(`${resource.name}: title field ${key} must be serialized`);
32
+ }
33
+ }
34
+ /**
35
+ * The title of one serialized record, read after field-level filtering so it never shows
36
+ * a value the viewer may not read. Returns null when no title field has a value.
37
+ */
38
+ export function formatTitle(resource, record, lookupLabel) {
39
+ const parts = [];
40
+ for (const key of titleFields(resource)) {
41
+ const value = record[key];
42
+ if (value === null || value === undefined || value === '')
43
+ continue;
44
+ const field = resource.fields[key];
45
+ parts.push(field.type === 'lookup'
46
+ ? (lookupLabel(field.group, String(value)) ?? String(value))
47
+ : String(value));
48
+ }
49
+ return parts.length ? parts.join(' · ') : null;
50
+ }
@@ -1,8 +1,27 @@
1
1
  import type { Knex } from 'knex';
2
2
  import type { ResourceRegistry } from '../resource/registry.js';
3
- import type { Action, RecordData, Resource, SerializedRecord } from '../resource/types.js';
3
+ import type { Action, JsonValue, RecordData, Resource, SerializedRecord } from '../resource/types.js';
4
4
  import { type Actor, type KitAbility } from '../auth/ability.js';
5
+ import { type Conditions } from '../auth/conditions.js';
5
6
  import type { ResourceDescription, ResourceNavigation, ResourceField } from './presentation.js';
7
+ export type AggregateOptions = {
8
+ /** Up to three queryable fields (docStatus and orgUnitId are also accepted). */
9
+ groupBy?: string[];
10
+ /** Count rows per group (default true). */
11
+ count?: boolean;
12
+ /** Integer or money fields to total per group; money totals are decimal strings. */
13
+ sum?: string[];
14
+ /** Equality filters, as in list(). */
15
+ filters?: Record<string, unknown>;
16
+ /** Role-rule style conditions ($eq, $ne, $in, $lt, $gt, $like, "$actor.id"). */
17
+ where?: Conditions;
18
+ search?: string;
19
+ };
20
+ export type AggregateRow = {
21
+ group: Record<string, JsonValue>;
22
+ count?: number;
23
+ sum?: Record<string, string | null>;
24
+ };
6
25
  export type ListOptions = {
7
26
  limit?: number;
8
27
  cursor?: string;
@@ -19,6 +38,24 @@ export type ListOptions = {
19
38
  * indirectly, so it needs unconditional view access at the field's level.
20
39
  */
21
40
  export declare function canQueryField(resource: Resource, actor: Actor, ability: KitAbility, key: string): boolean;
41
+ /** Options of ResourceService.systemSave. */
42
+ export type SystemSaveOptions = {
43
+ /** The user recorded as the author: created/updated by, activity and events. */
44
+ actorId: number;
45
+ /** Write inside the module's transaction. */
46
+ trx?: Knex.Transaction;
47
+ /** Why module code wrote the record; stored with the activity entry. */
48
+ reason?: string;
49
+ /** Optimistic lock: when given, it must match the stored version. */
50
+ version?: number;
51
+ /**
52
+ * The person who chose the values, when module code writes a choice made by a user,
53
+ * such as a supervisor reassigning a record. A new value of a user field must then be
54
+ * eligible for them as in a user save; without it, any active member qualifies on
55
+ * unscoped resources.
56
+ */
57
+ chooser?: Actor;
58
+ };
22
59
  export declare class ResourceService {
23
60
  private db;
24
61
  private registry;
@@ -37,6 +74,8 @@ export declare class ResourceService {
37
74
  private scopedQuery;
38
75
  private columns;
39
76
  private find;
77
+ /** A live record regardless of any actor's scope, for system writes. */
78
+ private findAny;
40
79
  private requireRecord;
41
80
  /**
42
81
  * Authorizes one record for collaboration features (comments, tags, assignments...).
@@ -62,7 +101,24 @@ export declare class ResourceService {
62
101
  related: Record<string, SerializedRecord[]>;
63
102
  }>;
64
103
  private canQueryField;
104
+ /**
105
+ * Counts and totals over the records the actor may view, grouped by queryable fields,
106
+ * for dashboards. Authorization is list()'s: the same rules, organization scope and
107
+ * soft-delete filter apply, and every grouped, totalled or filtered field must be one
108
+ * the actor may query (E_FIELD_FORBIDDEN otherwise), so no hidden value leaks.
109
+ */
110
+ aggregate(name: string, actor: Actor, options?: AggregateOptions): Promise<{
111
+ rows: AggregateRow[];
112
+ truncated: boolean;
113
+ }>;
65
114
  private preload;
115
+ /** Formats record titles of one resource, loading the labels of its title lookups once. */
116
+ private titler;
117
+ /**
118
+ * Titles of records the actor may view, read under the actor's field access (#32).
119
+ * Records the actor cannot view are left out; callers fall back to the record id.
120
+ */
121
+ titles(name: string, ids: readonly number[], actor: Actor): Promise<Map<number, string>>;
66
122
  show(name: string, id: number, actor: Actor): Promise<{
67
123
  data: SerializedRecord;
68
124
  permissions: Partial<Record<Action, boolean>>;
@@ -72,6 +128,10 @@ export declare class ResourceService {
72
128
  id?: number;
73
129
  search?: string;
74
130
  cursor?: string;
131
+ /** User fields: the organization unit chosen in the form. */
132
+ orgUnitId?: number;
133
+ /** User fields: choices for a list filter instead of a form. */
134
+ purpose?: 'form' | 'filter';
75
135
  }): Promise<{
76
136
  data: {
77
137
  value: string;
@@ -79,11 +139,25 @@ export declare class ResourceService {
79
139
  }[];
80
140
  nextCursor: string | null;
81
141
  }>;
142
+ /**
143
+ * Choices for a user field. A form lists active members of the record's unit or its
144
+ * ancestors (the users the record is visible to through membership); a filter lists
145
+ * active users who share organization scope with the actor. Only names are returned.
146
+ */
147
+ private userOptions;
148
+ /**
149
+ * Active users eligible for a user field: members of the unit at `path` or of one of
150
+ * its ancestors, or (without a path) users sharing organization scope with the actor.
151
+ * A system write on an unscoped resource passes no actor: any active member qualifies.
152
+ */
153
+ private eligibleUsers;
82
154
  /** Deferred relation reads re-authorize the parent and each child on every request. */
83
155
  children(name: string, id: number, actor: Actor): Promise<Record<string, {
84
156
  rows: SerializedRecord[];
85
157
  hasMore: boolean;
86
158
  }>>;
159
+ /** Inline children of an already authorized parent record. */
160
+ private childrenOf;
87
161
  /** Active lookup labels for the lookup fields the actor may read on this resource. */
88
162
  lookups(name: string, actor: Actor): Promise<Record<string, {
89
163
  value: string;
@@ -103,10 +177,66 @@ export declare class ResourceService {
103
177
  actorEmail: string | null;
104
178
  createdAt: string;
105
179
  }[]>;
106
- editor(name: string, actor: Actor, id?: number): Promise<{
180
+ /**
181
+ * Inline children and recent activity of one record, authorized with a single record
182
+ * read (for deferred page props that would otherwise read the record twice).
183
+ */
184
+ details(name: string, id: number, actor: Actor): Promise<{
185
+ children: Record<string, {
186
+ rows: SerializedRecord[];
187
+ hasMore: boolean;
188
+ }>;
189
+ activity: {
190
+ id: number;
191
+ action: Action;
192
+ fields: string[];
193
+ actorId: number;
194
+ actorName: string | null;
195
+ /**
196
+ * @deprecated Always null. Kept so project-owned copies of resource-show from
197
+ * earlier releases still compile; they fall back to the actor id.
198
+ */
199
+ actorEmail: string | null;
200
+ createdAt: string;
201
+ }[];
202
+ }>;
203
+ /** show(), children() and activity() of one record from a single record read. */
204
+ record(name: string, id: number, actor: Actor): Promise<{
205
+ data: SerializedRecord;
206
+ permissions: Partial<Record<Action, boolean>>;
207
+ related: Record<string, SerializedRecord[]>;
208
+ children: Record<string, {
209
+ rows: SerializedRecord[];
210
+ hasMore: boolean;
211
+ }>;
212
+ activity: {
213
+ id: number;
214
+ action: Action;
215
+ fields: string[];
216
+ actorId: number;
217
+ actorName: string | null;
218
+ /**
219
+ * @deprecated Always null. Kept so project-owned copies of resource-show from
220
+ * earlier releases still compile; they fall back to the actor id.
221
+ */
222
+ actorEmail: string | null;
223
+ createdAt: string;
224
+ }[];
225
+ }>;
226
+ private activityOf;
227
+ /**
228
+ * The form description for create (no id) or update. `defaults` pre-fills a create form
229
+ * (#48), for example `?defaults[violation]=13`: only visible form fields are used, a
230
+ * related record must be viewable by the actor and a lookup must be active. Anything else
231
+ * is dropped. Defaults are only initial values; saving validates as usual.
232
+ */
233
+ editor(name: string, actor: Actor, id?: number, request?: {
234
+ defaults?: Record<string, unknown>;
235
+ }): Promise<{
107
236
  mode: string;
108
237
  name: string;
109
238
  label: string;
239
+ recordLabel: string | null;
110
240
  fields: ({
111
241
  label: import("../resource/types.js").Label;
112
242
  column?: string;
@@ -146,6 +276,18 @@ export declare class ResourceService {
146
276
  type: "belongsTo";
147
277
  resource: string;
148
278
  key: string;
279
+ } | {
280
+ label: import("../resource/types.js").Label;
281
+ column?: string;
282
+ required?: boolean;
283
+ unique?: boolean;
284
+ sortable?: boolean;
285
+ searchable?: boolean;
286
+ filterable?: boolean;
287
+ permissionLevel?: number;
288
+ sequence?: string;
289
+ type: "user";
290
+ key: string;
149
291
  } | {
150
292
  label: import("../resource/types.js").Label;
151
293
  column?: string;
@@ -175,6 +317,8 @@ export declare class ResourceService {
175
317
  group: string;
176
318
  key: string;
177
319
  })[];
320
+ /** Initial values of a create form, already checked against the actor's access. */
321
+ defaults: SerializedRecord;
178
322
  inline: Record<string, {
179
323
  rows: SerializedRecord[];
180
324
  hasMore: boolean;
@@ -197,9 +341,26 @@ export declare class ResourceService {
197
341
  label: string;
198
342
  }[];
199
343
  scoped: boolean;
344
+ /** The belongsTo field whose record decides the unit; the form shows no unit picker. */
345
+ scopeFrom: string | null;
200
346
  record: SerializedRecord | null;
201
347
  }>;
202
348
  save(name: string, actor: Actor, input: RecordData, id?: number, transaction?: Knex.Transaction): Promise<SerializedRecord>;
349
+ /**
350
+ * A write decided by module code rather than by a user's role rules: state transitions,
351
+ * snapshots and listener updates. It runs the validator, hooks, lookup, relation and
352
+ * attachment checks, versioning and the audit trail exactly like save(), and skips only
353
+ * the actor's role rules and organization scope. Values may set any stored field except
354
+ * sequences and inline children. Updates merge the given values into the stored record.
355
+ * Returns the full stored record; module code must not send it to users unfiltered.
356
+ */
357
+ systemSave(name: string, values: RecordData, id: number | undefined, options: SystemSaveOptions): Promise<RecordData>;
358
+ /**
359
+ * Moves the records whose scope follows this parent (`scope: { from }`) to the parent's
360
+ * current organization unit, through systemSave. Call it from a listener on the parent's
361
+ * `updated` event, or right after moving the parent. Returns the number of moved records.
362
+ */
363
+ rehome(parentName: string, parentId: number, options: Omit<SystemSaveOptions, 'version'>): Promise<number>;
203
364
  private persist;
204
365
  private requireVersion;
205
366
  /** Inline children share the parent's draft state, organization and update authority. */