@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
@@ -1,12 +1,110 @@
1
+ export const KIT_ISSUES_URL = 'https://github.com/adulash/adula-kit/issues';
2
+ /**
3
+ * KIT_GAPS.md entry fields, in the order of the kit's gap issue form
4
+ * (.github/ISSUE_TEMPLATE/gap.yml). `id` is the form field id used to prefill it.
5
+ */
6
+ export const GAP_FIELDS = [
7
+ { label: 'Package', id: 'package', required: true },
8
+ { label: 'Needed by', id: 'needed', required: true },
9
+ { label: 'Tried', id: 'tried', required: true },
10
+ { label: 'Blocked because', id: 'blocked', required: true },
11
+ { label: 'Proposed kit change', id: 'proposal', required: true },
12
+ { label: 'Reproduction', id: 'reproduction', required: true },
13
+ { label: 'Acceptance', id: 'acceptance', required: true },
14
+ { label: 'Workaround', id: 'workaround', required: false },
15
+ ];
16
+ export const GAPS_TEMPLATE = `# Kit gaps
17
+
18
+ Record each limitation of the kit as one entry. Describe the kit capability, not this
19
+ project: reproduce it on a new application from create-app. Run \`node ace adula:gaps report\`
20
+ to open the kit's gap form prefilled, then write the issue number in \`Issue:\`.
21
+
22
+ <!--
23
+ ## GAP-001 — Short title naming the kit capability
24
+ Package: @adula/kit 1.0.0
25
+ Needed by: the kit capability that is missing, stated without project details
26
+ Tried: the kit extension points tried
27
+ Blocked because: why none of them works
28
+ Proposed kit change: the API, option or fix the kit should offer
29
+ Reproduction: steps on a new application from create-app
30
+ Acceptance: the test that proves the gap is closed
31
+ Workaround: none, or what the project does meanwhile
32
+ Issue:
33
+ -->
34
+ `;
35
+ /** Longest value put in one prefilled field; GitHub rejects very long URLs. */
36
+ const FIELD_LIMIT = 1500;
37
+ const labels = [...GAP_FIELDS.map((field) => field.label), 'Issue'];
38
+ const fieldLine = new RegExp(`^(${labels.join('|')}):\\s*(.*)$`);
39
+ function parseGaps(masked) {
40
+ const gaps = [];
41
+ let current;
42
+ let field;
43
+ let comment = false;
44
+ for (const line of masked.split(/\r?\n/)) {
45
+ if (line.trim().startsWith('<!--'))
46
+ comment = true;
47
+ if (comment) {
48
+ if (line.includes('-->'))
49
+ comment = false;
50
+ continue;
51
+ }
52
+ const heading = line.match(/^## (GAP-\d+.*)$/);
53
+ if (heading) {
54
+ current = { title: heading[1].trim(), fields: {}, issue: '', missing: [] };
55
+ gaps.push(current);
56
+ field = undefined;
57
+ continue;
58
+ }
59
+ if (!current)
60
+ continue;
61
+ if (line.startsWith('#')) {
62
+ current = undefined;
63
+ continue;
64
+ }
65
+ const match = line.match(fieldLine);
66
+ if (match) {
67
+ field = match[1];
68
+ if (field === 'Issue')
69
+ current.issue = match[2].trim();
70
+ else
71
+ current.fields[field] = match[2].trim();
72
+ }
73
+ else if (field && field !== 'Issue') {
74
+ current.fields[field] = `${current.fields[field]}\n${line}`.trim();
75
+ }
76
+ }
77
+ return gaps;
78
+ }
79
+ function clip(value) {
80
+ return value.length > FIELD_LIMIT
81
+ ? `${value.slice(0, FIELD_LIMIT)}\n… (shortened; copy the rest from the masked report)`
82
+ : value;
83
+ }
1
84
  /**
2
85
  * Prepares KIT_GAPS.md for sharing with the kit maintainers: e-mails, URLs and
3
- * IPv4 addresses are masked, and gap titles are listed for a quick review.
86
+ * IPv4 addresses are masked, gap titles are listed for a quick review, and each
87
+ * unreported gap gets a link that opens the kit's gap form prefilled. Nothing is sent.
4
88
  */
5
- export function gapReport(content) {
89
+ export function gapReport(content, issuesUrl = KIT_ISSUES_URL) {
6
90
  const masked = content
7
91
  .replace(/[\w.+-]+@[\w-]+(\.[\w-]+)+/g, '<email>')
8
92
  .replace(/https?:\/\/[^\s)]+/g, '<url>')
9
93
  .replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, '<ip>');
10
- const titles = (masked.match(/^## GAP-\d+.*$/gm) ?? []).map((title) => title.replace(/^## /, ''));
11
- return { masked, titles };
94
+ const gaps = parseGaps(masked);
95
+ for (const gap of gaps) {
96
+ gap.missing = GAP_FIELDS.filter((field) => field.required && !gap.fields[field.label]).map((field) => field.label);
97
+ if (gap.issue)
98
+ continue;
99
+ const url = new URL(`${issuesUrl}/new`);
100
+ url.searchParams.set('template', 'gap.yml');
101
+ url.searchParams.set('title', gap.title.replace(/^GAP-\d+\s*[—–-]?\s*/, ''));
102
+ for (const field of GAP_FIELDS) {
103
+ const value = gap.fields[field.label];
104
+ if (value)
105
+ url.searchParams.set(field.id, clip(value));
106
+ }
107
+ gap.url = url.toString();
108
+ }
109
+ return { masked, titles: gaps.map((gap) => gap.title), gaps };
12
110
  }
@@ -1,7 +1,7 @@
1
1
  import { mkdir, readFile, writeFile, access, glob } from 'node:fs/promises';
2
2
  import { dirname, join, resolve } from 'node:path';
3
3
  import { identifier } from '../resource/define_resource.js';
4
- import { appendMarkedItem } from './source_markers.js';
4
+ import { appendMarkedItem, moduleSource } from './source_markers.js';
5
5
  export async function generateResource(root, name, module) {
6
6
  identifier(name);
7
7
  identifier(module);
@@ -10,7 +10,7 @@ export async function generateResource(root, name, module) {
10
10
  const base = resolve(root, 'app/modules', module);
11
11
  const title = name.replace(/_/g, ' ');
12
12
  const relative = `app/modules/${module}`;
13
- const resourceSource = `import { defineResource } from '@adula/kit'\nimport Model from '#modules/${module}/models/${name}'\nimport { validator } from '#modules/${module}/validators/${name}'\n\nexport default defineResource({\n name: '${name}', label: { ar: '${title}', en: '${title}' }, model: Model, scoped: true,\n fields: { title: { type: 'string', label: { ar: 'العنوان', en: 'Title' }, required: true, searchable: true } },\n list: ['title'], form: ['title'], show: ['title'],\n actions: ['view', 'create', 'update', 'delete'], validator,\n})\n`;
13
+ const resourceSource = `import { defineResource } from '@adula/kit'\nimport Model from '#modules/${module}/models/${name}'\nimport { validator } from '#modules/${module}/validators/${name}'\n\nexport default defineResource({\n name: '${name}', label: { ar: '${title}', en: '${title}' },\n recordLabel: { ar: '${title}', en: '${title}' }, model: Model, scoped: true,\n fields: { title: { type: 'string', label: { ar: 'العنوان', en: 'Title' }, required: true, searchable: true } },\n list: ['title'], form: ['title'], show: ['title'],\n actions: ['view', 'create', 'update', 'delete'], validator,\n})\n`;
14
14
  const migration = {
15
15
  name,
16
16
  scoped: true,
@@ -39,7 +39,7 @@ export async function generateResource(root, name, module) {
39
39
  catch (error) {
40
40
  if (error.code !== 'ENOENT')
41
41
  throw error;
42
- current = `// adula:imports\nexport default { name: '${module}', label: { ar: '${module}', en: '${module}' }, dependsOn: [], resources: [/* adula:resources */] }\n`;
42
+ current = moduleSource(module);
43
43
  }
44
44
  if (!current.includes('// adula:imports') || !current.includes('/* adula:resources */'))
45
45
  throw new Error('Module registration markers are missing; no files were changed');
@@ -0,0 +1,28 @@
1
+ import type { Resource } from '../resource/types.js';
2
+ /**
3
+ * The part of a resource definition createResourceTable reads, as a generated
4
+ * create-migration embeds it. Labels are kept so the migration stays readable.
5
+ */
6
+ export declare function resourceSnapshot(resource: Pick<Resource, 'name' | 'scoped' | 'version' | 'submittable' | 'customFields' | 'fields'>): {
7
+ fields: {
8
+ [k: string]: {
9
+ sequence?: string | undefined;
10
+ searchable?: boolean | undefined;
11
+ unique?: boolean | undefined;
12
+ required?: boolean | undefined;
13
+ column?: string | undefined;
14
+ resource?: string | undefined;
15
+ type: "string" | "boolean" | "text" | "integer" | "money" | "date" | "datetime" | "json" | "attachment" | "belongsTo" | "user" | "hasMany" | "lookup";
16
+ label: import("../resource/types.js").Label;
17
+ };
18
+ };
19
+ customFields?: boolean | undefined;
20
+ submittable?: boolean | undefined;
21
+ version?: boolean | undefined;
22
+ name: string;
23
+ scoped: boolean;
24
+ };
25
+ /** The resource name embedded in a generated create-migration, if the source is one. */
26
+ export declare function embeddedResource(source: string): string | undefined;
27
+ /** Replaces the embedded definition of a generated create-migration (#20). */
28
+ export declare function rewriteResourceSnapshot(source: string, snapshot: ReturnType<typeof resourceSnapshot>): string;
@@ -0,0 +1,48 @@
1
+ const EMBEDDED = /(createResourceTable\([^,]+,\s*)(\{[\s\S]*\})(\s*\)\s*\})/;
2
+ /**
3
+ * The part of a resource definition createResourceTable reads, as a generated
4
+ * create-migration embeds it. Labels are kept so the migration stays readable.
5
+ */
6
+ export function resourceSnapshot(resource) {
7
+ return {
8
+ name: resource.name,
9
+ scoped: resource.scoped,
10
+ ...(resource.version ? { version: true } : {}),
11
+ ...(resource.submittable ? { submittable: true } : {}),
12
+ ...(resource.customFields ? { customFields: true } : {}),
13
+ fields: Object.fromEntries(Object.entries(resource.fields)
14
+ .filter(([, field]) => field.type !== 'hasMany')
15
+ .map(([key, field]) => [
16
+ key,
17
+ {
18
+ type: field.type,
19
+ label: field.label,
20
+ ...(field.type === 'belongsTo' ? { resource: field.resource } : {}),
21
+ ...(field.column ? { column: field.column } : {}),
22
+ ...(field.required ? { required: true } : {}),
23
+ ...(field.unique ? { unique: true } : {}),
24
+ ...(field.searchable ? { searchable: true } : {}),
25
+ ...(field.sequence ? { sequence: field.sequence } : {}),
26
+ },
27
+ ])),
28
+ };
29
+ }
30
+ /** The resource name embedded in a generated create-migration, if the source is one. */
31
+ export function embeddedResource(source) {
32
+ const embedded = EMBEDDED.exec(source)?.[2];
33
+ if (!embedded)
34
+ return undefined;
35
+ try {
36
+ const parsed = JSON.parse(embedded);
37
+ return typeof parsed?.name === 'string' ? parsed.name : undefined;
38
+ }
39
+ catch {
40
+ return undefined;
41
+ }
42
+ }
43
+ /** Replaces the embedded definition of a generated create-migration (#20). */
44
+ export function rewriteResourceSnapshot(source, snapshot) {
45
+ if (embeddedResource(source) !== snapshot.name)
46
+ throw new Error(`The migration does not embed the ${snapshot.name} definition`);
47
+ return source.replace(EMBEDDED, (_, before, _old, after) => `${before}${JSON.stringify(snapshot, null, 2)}${after}`);
48
+ }
@@ -1,2 +1,11 @@
1
- /** Append to a marked literal array, including when the last item has no trailing comma. */
1
+ /**
2
+ * Append to a marked literal array and rewrite it in the multi-line form Prettier keeps stable:
3
+ * one item per line with a trailing comma and the marker last. Single-line arrays written by
4
+ * earlier generators (`[a, /* marker *\/]`, `[a /* marker *\/]`) are normalized the same way.
5
+ */
2
6
  export declare function appendMarkedItem(source: string, marker: string, expression: string): string;
7
+ /** A new module definition, already in the project's Prettier layout. */
8
+ export declare function moduleSource(name: string, options?: {
9
+ reference?: boolean;
10
+ typed?: boolean;
11
+ }): string;
@@ -1,9 +1,41 @@
1
- /** Append to a marked literal array, including when the last item has no trailing comma. */
1
+ /**
2
+ * Append to a marked literal array and rewrite it in the multi-line form Prettier keeps stable:
3
+ * one item per line with a trailing comma and the marker last. Single-line arrays written by
4
+ * earlier generators (`[a, /* marker *\/]`, `[a /* marker *\/]`) are normalized the same way.
5
+ */
2
6
  export function appendMarkedItem(source, marker, expression) {
3
7
  const offset = source.indexOf(marker);
4
8
  if (offset < 0 || source.indexOf(marker, offset + marker.length) >= 0)
5
9
  throw new Error(`Expected one registration marker: ${marker}`);
6
- const before = source.slice(0, offset).trimEnd();
7
- const separator = /[\[,]$/.test(before) ? '' : ', ';
8
- return source.replace(marker, `${separator}${expression}, ${marker}`);
10
+ const open = source.lastIndexOf('[', offset);
11
+ const close = source.indexOf(']', offset + marker.length);
12
+ if (open < 0 || close < 0)
13
+ throw new Error(`Registration marker ${marker} must be inside an array`);
14
+ const listed = source.slice(open + 1, offset);
15
+ if (/[()[\]{}'"`]|\/\/|\/\*/.test(listed) || source.slice(offset + marker.length, close).trim())
16
+ throw new Error(`Registration list around ${marker} is not a plain list; add ${expression} by hand`);
17
+ const items = listed
18
+ .split(',')
19
+ .map((item) => item.trim())
20
+ .filter(Boolean);
21
+ const lineStart = source.lastIndexOf('\n', open) + 1;
22
+ const indent = /^[ \t]*/.exec(source.slice(lineStart, open))[0];
23
+ const inner = `${indent} `;
24
+ const lines = [...items, expression].map((item) => `${inner}${item},\n`).join('');
25
+ return `${source.slice(0, open)}[\n${lines}${inner}${marker}\n${indent}]${source.slice(close + 1)}`;
26
+ }
27
+ /** A new module definition, already in the project's Prettier layout. */
28
+ export function moduleSource(name, options = {}) {
29
+ return [
30
+ ...(options.typed ? ["import type { Module } from '@adula/kit'"] : []),
31
+ '// adula:imports',
32
+ 'export default {',
33
+ ` name: '${name}',`,
34
+ ` label: { ar: '${name}', en: '${name}' },`,
35
+ ...(options.reference === undefined ? [] : [` reference: ${options.reference},`]),
36
+ ' dependsOn: [],',
37
+ ' resources: [/* adula:resources */],',
38
+ `}${options.typed ? ' satisfies Module' : ''}`,
39
+ '',
40
+ ].join('\n');
9
41
  }
@@ -1,4 +1,5 @@
1
1
  import { buildAbility } from '../auth/ability.js';
2
+ import { resolveActorConditions } from '../auth/conditions.js';
2
3
  import { KitError } from '../admin/errors.js';
3
4
  /** Same CASL decision as the admin middleware, with fresh rows inside the transaction. */
4
5
  async function administrators(db) {
@@ -16,7 +17,8 @@ async function administrators(db) {
16
17
  subject: row.subject,
17
18
  action: row.action,
18
19
  inverted: row.inverted,
19
- conditions: row.conditions ?? undefined,
20
+ // Only subject 'all' is read here, so no field accepts the placeholder.
21
+ conditions: resolveActorConditions(row.conditions ?? undefined, Number(row.user_id), () => false),
20
22
  fields: row.fields ?? undefined,
21
23
  });
22
24
  users.set(row.user_id, rules);
@@ -1,4 +1,5 @@
1
1
  import type { Knex } from 'knex';
2
+ import { type NotificationTarget } from '../services/settings.js';
2
3
  export type TemplateDefinition = {
3
4
  label: string;
4
5
  subject: string;
@@ -51,12 +52,14 @@ export declare class MessageTemplates {
51
52
  * Inserts a templated notification in the caller's transaction. Templates with
52
53
  * mail enabled mark the row for the mail delivery worker (deliverNotificationMail).
53
54
  */
54
- export declare function notifyWithTemplate(db: Knex, userId: number, key: string, variables: Record<string, unknown>, templates?: MessageTemplates): Promise<void>;
55
+ export declare function notifyWithTemplate(db: Knex, userId: number, key: string, variables: Record<string, unknown>, templates?: MessageTemplates, target?: NotificationTarget | null): Promise<void>;
55
56
  export type MailSender = (message: {
56
57
  to: string;
57
58
  name: string | null;
58
59
  subject: string;
59
60
  text: string;
61
+ /** The record the notification is about; build an absolute link from the application URL. */
62
+ target?: NotificationTarget | null;
60
63
  }) => Promise<void>;
61
64
  /**
62
65
  * Delivers pending notification e-mails. Rows are claimed with SKIP LOCKED so
@@ -1,4 +1,5 @@
1
1
  import { KitError } from '../admin/errors.js';
2
+ import { targetColumns } from '../services/settings.js';
2
3
  /**
3
4
  * Package-owned defaults. Projects edit wording in the message_templates table;
4
5
  * an upgrade can change a default without overwriting a project's edit.
@@ -166,7 +167,7 @@ export class MessageTemplates {
166
167
  * Inserts a templated notification in the caller's transaction. Templates with
167
168
  * mail enabled mark the row for the mail delivery worker (deliverNotificationMail).
168
169
  */
169
- export async function notifyWithTemplate(db, userId, key, variables, templates = new MessageTemplates(db)) {
170
+ export async function notifyWithTemplate(db, userId, key, variables, templates = new MessageTemplates(db), target) {
170
171
  const message = await templates.render(key, variables, db);
171
172
  await db('notifications').insert({
172
173
  user_id: userId,
@@ -174,6 +175,7 @@ export async function notifyWithTemplate(db, userId, key, variables, templates =
174
175
  body: message.body,
175
176
  template_key: key,
176
177
  mail_state: message.mail ? 'pending' : null,
178
+ ...targetColumns(target),
177
179
  });
178
180
  }
179
181
  /**
@@ -191,7 +193,7 @@ export async function deliverNotificationMail(db, send, limit = 50) {
191
193
  .limit(limit)
192
194
  .forUpdate('n')
193
195
  .skipLocked()
194
- .select('n.id', 'n.title', 'n.body', 'n.mail_attempts', 'u.email', 'u.full_name', 'u.disabled_at');
196
+ .select('n.id', 'n.title', 'n.body', 'n.mail_attempts', 'n.resource', 'n.record_id', 'u.email', 'u.full_name', 'u.disabled_at');
195
197
  for (const row of rows) {
196
198
  if (row.disabled_at) {
197
199
  await trx('notifications').where('id', row.id).update({ mail_state: 'skipped' });
@@ -203,6 +205,9 @@ export async function deliverNotificationMail(db, send, limit = 50) {
203
205
  name: row.full_name ? String(row.full_name) : null,
204
206
  subject: String(row.title),
205
207
  text: String(row.body),
208
+ target: row.resource && row.record_id !== null
209
+ ? { resource: String(row.resource), recordId: Number(row.record_id) }
210
+ : null,
206
211
  });
207
212
  await trx('notifications')
208
213
  .where('id', row.id)
@@ -0,0 +1,15 @@
1
+ import type { Knex } from 'knex';
2
+ import type { ResourceRegistry } from '../resource/registry.js';
3
+ export type ModuleSeedResult = {
4
+ /** Lookup rows inserted now; rows that already existed are left as they are. */
5
+ lookups: number;
6
+ created: string[];
7
+ adopted: string[];
8
+ kept: string[];
9
+ };
10
+ /**
11
+ * Applies the lookups and default roles declared by registered modules. It is
12
+ * idempotent and additive: existing lookup rows and roles are never changed, so
13
+ * administrator edits survive every later installation (adula:install runs it).
14
+ */
15
+ export declare function seedModules(db: Knex, registry: ResourceRegistry, actorId: number): Promise<ModuleSeedResult>;
@@ -0,0 +1,31 @@
1
+ import { RolesAdmin } from './roles.js';
2
+ /**
3
+ * Applies the lookups and default roles declared by registered modules. It is
4
+ * idempotent and additive: existing lookup rows and roles are never changed, so
5
+ * administrator edits survive every later installation (adula:install runs it).
6
+ */
7
+ export async function seedModules(db, registry, actorId) {
8
+ return db.transaction(async (trx) => {
9
+ await trx.raw('SELECT pg_advisory_xact_lock(717012)');
10
+ let lookups = 0;
11
+ for (const module of registry.modules())
12
+ for (const [group, rows] of Object.entries(module.lookups ?? {}))
13
+ for (const [index, row] of rows.entries()) {
14
+ const inserted = await trx('lookups')
15
+ .insert({
16
+ group,
17
+ key: row.key,
18
+ label_ar: row.label.ar,
19
+ label_en: row.label.en,
20
+ sort: row.sort ?? index,
21
+ active: true,
22
+ })
23
+ .onConflict(['group', 'key'])
24
+ .ignore()
25
+ .returning('id');
26
+ lookups += inserted.length;
27
+ }
28
+ const roles = await new RolesAdmin(trx, registry).ensureDefaults(actorId, registry.modules().flatMap((module) => [...(module.defaultRoles ?? [])]));
29
+ return { lookups, ...roles };
30
+ });
31
+ }
@@ -1,10 +1,20 @@
1
1
  import type { Knex } from 'knex';
2
+ import type { ResourceRegistry } from '../resource/registry.js';
2
3
  export type Notification = {
3
4
  id: number;
4
5
  title: string;
5
6
  body: string;
6
7
  readAt: string | null;
7
8
  createdAt: string;
9
+ /**
10
+ * The record the notification is about, while its resource is still registered.
11
+ * The record page authorizes the reader when the link is opened.
12
+ */
13
+ target: {
14
+ resource: string;
15
+ recordId: number;
16
+ href: string;
17
+ } | null;
8
18
  };
9
19
  export type NotificationPage = {
10
20
  data: Notification[];
@@ -13,13 +23,20 @@ export type NotificationPage = {
13
23
  };
14
24
  export declare class NotificationsAdmin {
15
25
  private db;
16
- constructor(db: Knex);
26
+ /** Without a registry, stored targets are returned as they are. */
27
+ private registry?;
28
+ constructor(db: Knex,
29
+ /** Without a registry, stored targets are returned as they are. */
30
+ registry?: Pick<ResourceRegistry, "has"> | undefined);
31
+ private target;
17
32
  /** Unread first, newest first; the cursor remembers which of the two segments it is in. */
18
33
  list(userId: number, options?: {
19
34
  cursor?: string;
20
35
  limit?: number;
21
36
  }): Promise<NotificationPage>;
22
37
  markRead(userId: number, id: number): Promise<void>;
38
+ /** Marks the notification read and returns where it points (null without a target). */
39
+ open(userId: number, id: number): Promise<string | null>;
23
40
  markAllRead(userId: number): Promise<number>;
24
41
  unreadCount(userId: number): Promise<number>;
25
42
  }
@@ -2,8 +2,21 @@ import { KitError } from '../admin/errors.js';
2
2
  import { pageLimit } from './activity.js';
3
3
  export class NotificationsAdmin {
4
4
  db;
5
- constructor(db) {
5
+ registry;
6
+ constructor(db,
7
+ /** Without a registry, stored targets are returned as they are. */
8
+ registry) {
6
9
  this.db = db;
10
+ this.registry = registry;
11
+ }
12
+ target(row) {
13
+ if (!row.resource || row.record_id === null || row.record_id === undefined)
14
+ return null;
15
+ const resource = String(row.resource);
16
+ if (this.registry && !this.registry.has(resource))
17
+ return null;
18
+ const recordId = Number(row.record_id);
19
+ return { resource, recordId, href: `/resources/${resource}/${recordId}` };
7
20
  }
8
21
  /** Unread first, newest first; the cursor remembers which of the two segments it is in. */
9
22
  async list(userId, options = {}) {
@@ -34,6 +47,7 @@ export class NotificationsAdmin {
34
47
  body: row.body,
35
48
  readAt: row.read_at ? new Date(row.read_at).toISOString() : null,
36
49
  createdAt: new Date(row.created_at).toISOString(),
50
+ target: this.target(row),
37
51
  })),
38
52
  nextCursor: rows.length > limit ? `${last.read_at ? 'r' : 'u'}:${last.id}` : null,
39
53
  unread: await this.unreadCount(userId),
@@ -48,6 +62,18 @@ export class NotificationsAdmin {
48
62
  .whereNull('read_at')
49
63
  .update({ read_at: this.db.fn.now() });
50
64
  }
65
+ /** Marks the notification read and returns where it points (null without a target). */
66
+ async open(userId, id) {
67
+ const row = await this.db('notifications').where({ id, user_id: userId }).first();
68
+ if (!row)
69
+ throw new KitError(404, 'E_NOTIFICATION_NOT_FOUND', 'الإشعار غير موجود');
70
+ if (!row.read_at)
71
+ await this.db('notifications')
72
+ .where({ id, user_id: userId })
73
+ .whereNull('read_at')
74
+ .update({ read_at: this.db.fn.now() });
75
+ return this.target(row)?.href ?? null;
76
+ }
51
77
  async markAllRead(userId) {
52
78
  return this.db('notifications')
53
79
  .where('user_id', userId)
@@ -1,9 +1,14 @@
1
1
  import type { Knex } from 'knex';
2
2
  import type { ResourceRegistry } from '../resource/registry.js';
3
- import type { Label } from '../resource/types.js';
3
+ import type { Label, ModuleRole } from '../resource/types.js';
4
4
  import type { Conditions } from '../auth/conditions.js';
5
5
  export type RoleSummary = {
6
6
  id: number;
7
+ /**
8
+ * Stable identifier addressed by workflows and module defaults. The display name
9
+ * can change freely; the key is set once.
10
+ */
11
+ key: string | null;
7
12
  name: string;
8
13
  permissionLevel: number;
9
14
  rules: number;
@@ -32,6 +37,8 @@ export type MatrixField = {
32
37
  label: Label;
33
38
  type: string;
34
39
  conditionable: boolean;
40
+ /** Accepts the current user ("$actor.id") as a condition value: user fields, createdBy, updatedBy. */
41
+ actor?: boolean;
35
42
  };
36
43
  export type MatrixSubject = {
37
44
  name: string;
@@ -46,6 +53,8 @@ export type RoleMatrix = {
46
53
  subjects: MatrixSubject[];
47
54
  };
48
55
  export declare const ALL_SUBJECT_LABEL: Label;
56
+ /** A role key: lower-case letters, digits and underscores, starting with a letter. */
57
+ export declare function roleKey(value: unknown): string;
49
58
  export declare class RolesAdmin {
50
59
  private db;
51
60
  private registry;
@@ -54,12 +63,19 @@ export declare class RolesAdmin {
54
63
  get(id: number): Promise<RoleDetail>;
55
64
  create(actorId: number, input: {
56
65
  name: string;
66
+ key?: string | null;
57
67
  permissionLevel?: number;
58
68
  }): Promise<{
59
69
  id: number;
70
+ key: string | null;
60
71
  name: string;
61
72
  permissionLevel: number;
62
73
  }>;
74
+ /**
75
+ * Gives a role its stable key. A key is set once: workflows and module defaults
76
+ * depend on it, so changing it would silently detach them.
77
+ */
78
+ setKey(actorId: number, id: number, value: string): Promise<void>;
63
79
  rename(actorId: number, id: number, value: string): Promise<void>;
64
80
  setPermissionLevel(actorId: number, id: number, value: number): Promise<void>;
65
81
  delete(actorId: number, id: number): Promise<void>;
@@ -67,6 +83,16 @@ export declare class RolesAdmin {
67
83
  matrix(): RoleMatrix;
68
84
  setRule(actorId: number, roleId: number, input: RuleInput): Promise<RoleRule>;
69
85
  removeRule(actorId: number, roleId: number, ruleId: number): Promise<void>;
86
+ /**
87
+ * Creates module default roles that no role holds the key of yet, with their rules
88
+ * validated exactly like setRule. Existing roles are never modified. A keyless role
89
+ * with the same display name is adopted by receiving the key, rules untouched.
90
+ */
91
+ ensureDefaults(actorId: number, roles: readonly ModuleRole[]): Promise<{
92
+ created: string[];
93
+ adopted: string[];
94
+ kept: string[];
95
+ }>;
70
96
  private validateRule;
71
97
  private find;
72
98
  }