@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
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ResourceRegistry } from '../resource/registry.js';
|
|
2
|
+
/** Field kinds accepted by defineResource, with their storage. */
|
|
3
|
+
export declare const FIELD_TYPES: Record<string, string>;
|
|
4
|
+
export declare const FIELD_OPTIONS: string[];
|
|
5
|
+
export declare const RESOURCE_OPTIONS: string[];
|
|
6
|
+
export declare const CONDITION_OPERATORS: string[];
|
|
7
|
+
export declare const WORKFLOW_STEPS: string[];
|
|
8
|
+
/**
|
|
9
|
+
* Kit services by concern. Every name is a public export of @adula/kit; a test
|
|
10
|
+
* checks this list against the package entry so the catalog cannot drift.
|
|
11
|
+
*/
|
|
12
|
+
export declare const SERVICES: Record<string, string[]>;
|
|
13
|
+
export declare const EXTENSION_POINTS: string[];
|
|
14
|
+
export declare const OUTSIDE_THE_KIT: string[];
|
|
15
|
+
/** The capability catalog read by idea-review: generated, never hand-edited. */
|
|
16
|
+
export declare function capabilityCatalog(options?: {
|
|
17
|
+
registry?: ResourceRegistry;
|
|
18
|
+
commands?: string[];
|
|
19
|
+
}): string;
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { KIT_VERSION } from '../version.js';
|
|
2
|
+
/** Field kinds accepted by defineResource, with their storage. */
|
|
3
|
+
export const FIELD_TYPES = {
|
|
4
|
+
string: 'varchar',
|
|
5
|
+
text: 'text',
|
|
6
|
+
integer: 'integer',
|
|
7
|
+
money: 'bigint minor units, decimal string in JSON',
|
|
8
|
+
boolean: 'boolean',
|
|
9
|
+
date: 'date (YYYY-MM-DD)',
|
|
10
|
+
datetime: 'timestamptz',
|
|
11
|
+
json: 'jsonb',
|
|
12
|
+
attachment: 'attachments row id; per-field accept/maxSize',
|
|
13
|
+
belongsTo: 'foreign key, preloaded, restrict on delete',
|
|
14
|
+
hasMany: 'child resource; inline rows saved with the parent',
|
|
15
|
+
lookup: 'lookups group key',
|
|
16
|
+
};
|
|
17
|
+
export const FIELD_OPTIONS = [
|
|
18
|
+
'required',
|
|
19
|
+
'unique (partial, active rows)',
|
|
20
|
+
'sortable',
|
|
21
|
+
'searchable (generated tsvector)',
|
|
22
|
+
'filterable',
|
|
23
|
+
'permissionLevel',
|
|
24
|
+
'sequence',
|
|
25
|
+
'column',
|
|
26
|
+
];
|
|
27
|
+
export const RESOURCE_OPTIONS = [
|
|
28
|
+
'scoped (required)',
|
|
29
|
+
'submittable (docStatus, submit/cancel/amend-by-copy)',
|
|
30
|
+
'version (optimistic locking, default with submittable)',
|
|
31
|
+
'customFields',
|
|
32
|
+
'list / form / show / serialize / hidden',
|
|
33
|
+
'actions',
|
|
34
|
+
'hooks.beforeSave / hooks.afterSave',
|
|
35
|
+
];
|
|
36
|
+
export const CONDITION_OPERATORS = ['$eq', '$ne', '$in', '$lt', '$gt', '$like'];
|
|
37
|
+
export const WORKFLOW_STEPS = ['condition', 'update', 'notify', 'approval', 'delay', 'http', 'end'];
|
|
38
|
+
/**
|
|
39
|
+
* Kit services by concern. Every name is a public export of @adula/kit; a test
|
|
40
|
+
* checks this list against the package entry so the catalog cannot drift.
|
|
41
|
+
*/
|
|
42
|
+
export const SERVICES = {
|
|
43
|
+
'Resources and authorization': [
|
|
44
|
+
'defineResource',
|
|
45
|
+
'ResourceRegistry',
|
|
46
|
+
'ResourceService',
|
|
47
|
+
'createResourceController',
|
|
48
|
+
'buildAbility',
|
|
49
|
+
'accessibleBy',
|
|
50
|
+
'ActorStore',
|
|
51
|
+
'packedResourceRules',
|
|
52
|
+
],
|
|
53
|
+
'Records and collaboration': [
|
|
54
|
+
'RecordCollaboration',
|
|
55
|
+
'followerListeners',
|
|
56
|
+
'Assignments',
|
|
57
|
+
'SavedViews',
|
|
58
|
+
'logActivity',
|
|
59
|
+
],
|
|
60
|
+
'Documents and workflows': ['defineWorkflow', 'WorkflowEngine', 'workflowListeners'],
|
|
61
|
+
'Notifications and messages': [
|
|
62
|
+
'notify',
|
|
63
|
+
'notifyWithTemplate',
|
|
64
|
+
'MessageTemplates',
|
|
65
|
+
'deliverNotificationMail',
|
|
66
|
+
'listenForNotifications',
|
|
67
|
+
'NotificationsAdmin',
|
|
68
|
+
],
|
|
69
|
+
'Integration': [
|
|
70
|
+
'Webhooks',
|
|
71
|
+
'signWebhook',
|
|
72
|
+
'openApiDocument',
|
|
73
|
+
'ImportBatches',
|
|
74
|
+
'renderPrintHtml',
|
|
75
|
+
'htmlToPdf',
|
|
76
|
+
],
|
|
77
|
+
'Security': ['UserInvitations', 'UsersAdmin', 'RolesAdmin'],
|
|
78
|
+
'Data and operations': [
|
|
79
|
+
'sequence',
|
|
80
|
+
'Settings',
|
|
81
|
+
'SettingsAdmin',
|
|
82
|
+
'publishOutbox',
|
|
83
|
+
'consumeEvent',
|
|
84
|
+
'recordMutation',
|
|
85
|
+
'moveOrgUnit',
|
|
86
|
+
'migrateStorage',
|
|
87
|
+
'verifyBackup',
|
|
88
|
+
'runtimeHealth',
|
|
89
|
+
],
|
|
90
|
+
};
|
|
91
|
+
export const EXTENSION_POINTS = [
|
|
92
|
+
'Resource hooks (beforeSave, afterSave) inside the save transaction',
|
|
93
|
+
'Page override: inertia/pages/<resource>/{index,form,show}.tsx replaces the generated page',
|
|
94
|
+
'Domain events <module>.<resource>.{created,updated,deleted,submitted,cancelled,amended} with idempotent listeners',
|
|
95
|
+
'Module workflows (Module.workflows) with versioned definitions',
|
|
96
|
+
'Message templates edited per deployment',
|
|
97
|
+
'Outgoing webhooks and the bearer-token /api/v1 API',
|
|
98
|
+
];
|
|
99
|
+
export const OUTSIDE_THE_KIT = [
|
|
100
|
+
'Dynamic fields or a field editor screen',
|
|
101
|
+
'Runtime plugins',
|
|
102
|
+
'A visual workflow editor',
|
|
103
|
+
'Multi-tenant SaaS in one database (one deployment per organization)',
|
|
104
|
+
'XLSX import until a maintained parser passes the dependency rule (GAP-006)',
|
|
105
|
+
];
|
|
106
|
+
/** The capability catalog read by idea-review: generated, never hand-edited. */
|
|
107
|
+
export function capabilityCatalog(options = {}) {
|
|
108
|
+
const lines = [
|
|
109
|
+
`# adula-kit capabilities (${KIT_VERSION})`,
|
|
110
|
+
'',
|
|
111
|
+
'Generated by `node ace adula:capabilities`. Read this before proposing a module; anything not listed here is not provided by the kit.',
|
|
112
|
+
'',
|
|
113
|
+
'## Resource definition',
|
|
114
|
+
'',
|
|
115
|
+
'| Field type | Storage |',
|
|
116
|
+
'|---|---|',
|
|
117
|
+
...Object.entries(FIELD_TYPES).map(([type, storage]) => `| ${type} | ${storage} |`),
|
|
118
|
+
'',
|
|
119
|
+
`Field options: ${FIELD_OPTIONS.join(', ')}.`,
|
|
120
|
+
'',
|
|
121
|
+
`Resource options: ${RESOURCE_OPTIONS.join(', ')}.`,
|
|
122
|
+
'',
|
|
123
|
+
`Role rule conditions: ${CONDITION_OPERATORS.join(', ')} on scalar fields; unsupported conditions are refused. Organization scope is always added with AND.`,
|
|
124
|
+
'',
|
|
125
|
+
`Workflow steps: ${WORKFLOW_STEPS.join(', ')}.`,
|
|
126
|
+
'',
|
|
127
|
+
'## Services',
|
|
128
|
+
'',
|
|
129
|
+
...Object.entries(SERVICES).map(([area, names]) => `- **${area}:** ${names.map((name) => `\`${name}\``).join(', ')}`),
|
|
130
|
+
'',
|
|
131
|
+
'## Extension points',
|
|
132
|
+
'',
|
|
133
|
+
...EXTENSION_POINTS.map((point) => `- ${point}`),
|
|
134
|
+
'',
|
|
135
|
+
'## Outside the kit',
|
|
136
|
+
'',
|
|
137
|
+
...OUTSIDE_THE_KIT.map((point) => `- ${point}`),
|
|
138
|
+
];
|
|
139
|
+
if (options.commands?.length)
|
|
140
|
+
lines.push('', '## Commands', '', ...options.commands.map((command) => `- \`${command}\``));
|
|
141
|
+
if (options.registry) {
|
|
142
|
+
lines.push('', '## This project', '');
|
|
143
|
+
const modules = options.registry.modules();
|
|
144
|
+
if (!modules.length)
|
|
145
|
+
lines.push('No modules are registered yet.');
|
|
146
|
+
for (const module of modules) {
|
|
147
|
+
lines.push(`### ${module.name} — ${module.label.ar}`, '');
|
|
148
|
+
if (module.dependsOn.length)
|
|
149
|
+
lines.push(`Depends on: ${module.dependsOn.join(', ')}`, '');
|
|
150
|
+
for (const resource of module.resources)
|
|
151
|
+
lines.push(`- \`${resource.name}\` (${resource.label.ar}; ${resource.scoped ? 'scoped' : 'central'}${resource.submittable ? ', submittable' : ''}): ${Object.entries(resource.fields)
|
|
152
|
+
.map(([key, field]) => `${key}:${field.type}${'resource' in field ? `→${field.resource}` : ''}`)
|
|
153
|
+
.join(', ')}`);
|
|
154
|
+
for (const workflow of module.workflows ?? [])
|
|
155
|
+
lines.push(`- workflow \`${workflow.name}@${workflow.version}\` on ${workflow.resource}: ${Object.keys(workflow.steps).join(' → ')}`);
|
|
156
|
+
lines.push('');
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return `${lines.join('\n').trimEnd()}\n`;
|
|
160
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 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.
|
|
4
|
+
*/
|
|
5
|
+
export function gapReport(content) {
|
|
6
|
+
const masked = content
|
|
7
|
+
.replace(/[\w.+-]+@[\w-]+(\.[\w-]+)+/g, '<email>')
|
|
8
|
+
.replace(/https?:\/\/[^\s)]+/g, '<url>')
|
|
9
|
+
.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 };
|
|
12
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { Knex } from 'knex';
|
|
2
|
+
export type TemplateDefinition = {
|
|
3
|
+
label: string;
|
|
4
|
+
subject: string;
|
|
5
|
+
body: string;
|
|
6
|
+
/** Variables available as {{name}}; unknown placeholders are refused on edit. */
|
|
7
|
+
variables: string[];
|
|
8
|
+
/** Whether notifications from this template are also delivered by e-mail. */
|
|
9
|
+
mail: boolean;
|
|
10
|
+
};
|
|
11
|
+
export type MessageTemplate = TemplateDefinition & {
|
|
12
|
+
key: string;
|
|
13
|
+
customized: boolean;
|
|
14
|
+
updatedAt: string | null;
|
|
15
|
+
};
|
|
16
|
+
export type RenderedMessage = {
|
|
17
|
+
subject: string;
|
|
18
|
+
body: string;
|
|
19
|
+
mail: boolean;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Package-owned defaults. Projects edit wording in the message_templates table;
|
|
23
|
+
* an upgrade can change a default without overwriting a project's edit.
|
|
24
|
+
*/
|
|
25
|
+
export declare const DEFAULT_TEMPLATES: Record<string, TemplateDefinition>;
|
|
26
|
+
export declare function renderTemplate(text: string, variables: Record<string, unknown>): string;
|
|
27
|
+
/** Message templates: package defaults plus per-deployment overrides edited by administrators. */
|
|
28
|
+
export declare class MessageTemplates {
|
|
29
|
+
private db;
|
|
30
|
+
private definitions;
|
|
31
|
+
constructor(db: Knex, definitions?: Record<string, TemplateDefinition>);
|
|
32
|
+
list(): Promise<MessageTemplate[]>;
|
|
33
|
+
render(key: string, variables: Record<string, unknown>, db?: Knex): Promise<RenderedMessage>;
|
|
34
|
+
update(key: string, input: {
|
|
35
|
+
subject: unknown;
|
|
36
|
+
body: unknown;
|
|
37
|
+
mail?: unknown;
|
|
38
|
+
}, actorId: number): Promise<void>;
|
|
39
|
+
reset(key: string): Promise<void>;
|
|
40
|
+
/** Example output with sample values, for the editor preview. */
|
|
41
|
+
preview(key: string, input: {
|
|
42
|
+
subject: string;
|
|
43
|
+
body: string;
|
|
44
|
+
}): {
|
|
45
|
+
subject: string;
|
|
46
|
+
body: string;
|
|
47
|
+
};
|
|
48
|
+
private definition;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Inserts a templated notification in the caller's transaction. Templates with
|
|
52
|
+
* mail enabled mark the row for the mail delivery worker (deliverNotificationMail).
|
|
53
|
+
*/
|
|
54
|
+
export declare function notifyWithTemplate(db: Knex, userId: number, key: string, variables: Record<string, unknown>, templates?: MessageTemplates): Promise<void>;
|
|
55
|
+
export type MailSender = (message: {
|
|
56
|
+
to: string;
|
|
57
|
+
name: string | null;
|
|
58
|
+
subject: string;
|
|
59
|
+
text: string;
|
|
60
|
+
}) => Promise<void>;
|
|
61
|
+
/**
|
|
62
|
+
* Delivers pending notification e-mails. Rows are claimed with SKIP LOCKED so
|
|
63
|
+
* several workers never send the same message; failures are retried three times.
|
|
64
|
+
*/
|
|
65
|
+
export declare function deliverNotificationMail(db: Knex, send: MailSender, limit?: number): Promise<{
|
|
66
|
+
sent: number;
|
|
67
|
+
failed: number;
|
|
68
|
+
}>;
|
|
69
|
+
export type NotificationSignal = {
|
|
70
|
+
userId: number;
|
|
71
|
+
id: number;
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* Holds one database connection that LISTENs for committed notifications and
|
|
75
|
+
* calls onSignal for each. Reconnects after connection loss. Returns a stop function.
|
|
76
|
+
*/
|
|
77
|
+
export declare function listenForNotifications(db: Knex, onSignal: (signal: NotificationSignal) => void, options?: {
|
|
78
|
+
retryMs?: number;
|
|
79
|
+
onError?: (error: unknown) => void;
|
|
80
|
+
}): () => Promise<void>;
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { KitError } from '../admin/errors.js';
|
|
2
|
+
/**
|
|
3
|
+
* Package-owned defaults. Projects edit wording in the message_templates table;
|
|
4
|
+
* an upgrade can change a default without overwriting a project's edit.
|
|
5
|
+
*/
|
|
6
|
+
export const DEFAULT_TEMPLATES = {
|
|
7
|
+
'comment.mentioned': {
|
|
8
|
+
label: 'إشارة في تعليق',
|
|
9
|
+
subject: 'أشار إليك {{author}}',
|
|
10
|
+
body: 'في {{resource}} #{{id}}: {{excerpt}}',
|
|
11
|
+
variables: ['author', 'resource', 'id', 'excerpt'],
|
|
12
|
+
mail: true,
|
|
13
|
+
},
|
|
14
|
+
'comment.created': {
|
|
15
|
+
label: 'تعليق على سجل تتابعه',
|
|
16
|
+
subject: 'تعليق جديد على {{resource}} #{{id}}',
|
|
17
|
+
body: '{{author}}: {{excerpt}}',
|
|
18
|
+
variables: ['author', 'resource', 'id', 'excerpt'],
|
|
19
|
+
mail: false,
|
|
20
|
+
},
|
|
21
|
+
'record.changed': {
|
|
22
|
+
label: 'تغيير سجل تتابعه',
|
|
23
|
+
subject: '{{change}} {{resource}} #{{id}}',
|
|
24
|
+
body: 'سجل تتابعه تغيّر.',
|
|
25
|
+
variables: ['change', 'resource', 'id'],
|
|
26
|
+
mail: false,
|
|
27
|
+
},
|
|
28
|
+
'assignment.created': {
|
|
29
|
+
label: 'مهمة جديدة',
|
|
30
|
+
subject: 'مهمة جديدة مسندة إليك',
|
|
31
|
+
body: '{{title}} — {{resource}} #{{id}}',
|
|
32
|
+
variables: ['title', 'resource', 'id', 'due'],
|
|
33
|
+
mail: true,
|
|
34
|
+
},
|
|
35
|
+
'assignment.approval': {
|
|
36
|
+
label: 'طلب موافقة',
|
|
37
|
+
subject: 'موافقة مطلوبة منك',
|
|
38
|
+
body: '{{title}} — {{resource}} #{{id}}',
|
|
39
|
+
variables: ['title', 'resource', 'id', 'due'],
|
|
40
|
+
mail: true,
|
|
41
|
+
},
|
|
42
|
+
'assignment.done': {
|
|
43
|
+
label: 'إنجاز مهمة أسندتها',
|
|
44
|
+
subject: 'أُنجزت مهمة أسندتها',
|
|
45
|
+
body: '{{title}} — {{resource}} #{{id}}',
|
|
46
|
+
variables: ['title', 'resource', 'id'],
|
|
47
|
+
mail: false,
|
|
48
|
+
},
|
|
49
|
+
'assignment.cancelled': {
|
|
50
|
+
label: 'إلغاء مهمة مسندة إليك',
|
|
51
|
+
subject: 'أُلغيت مهمة مسندة إليك',
|
|
52
|
+
body: '{{title}} — {{resource}} #{{id}}',
|
|
53
|
+
variables: ['title', 'resource', 'id'],
|
|
54
|
+
mail: false,
|
|
55
|
+
},
|
|
56
|
+
'workflow.decided': {
|
|
57
|
+
label: 'نتيجة تدفق عمل',
|
|
58
|
+
subject: '{{outcome}}: {{resource}} #{{id}}',
|
|
59
|
+
body: '{{workflow}} — {{step}}',
|
|
60
|
+
variables: ['outcome', 'resource', 'id', 'workflow', 'step'],
|
|
61
|
+
mail: true,
|
|
62
|
+
},
|
|
63
|
+
'workflow.failed': {
|
|
64
|
+
label: 'فشل خطوة تدفق',
|
|
65
|
+
subject: 'فشلت خطوة في تدفق {{workflow}}',
|
|
66
|
+
body: '{{resource}} #{{id}} — {{error}}',
|
|
67
|
+
variables: ['workflow', 'resource', 'id', 'error'],
|
|
68
|
+
mail: true,
|
|
69
|
+
},
|
|
70
|
+
'webhook.failed': {
|
|
71
|
+
label: 'تعطل Webhook',
|
|
72
|
+
subject: 'تعذّر تسليم Webhook: {{name}}',
|
|
73
|
+
body: '{{event}} — {{error}}',
|
|
74
|
+
variables: ['name', 'event', 'error'],
|
|
75
|
+
mail: true,
|
|
76
|
+
},
|
|
77
|
+
'import.finished': {
|
|
78
|
+
label: 'اكتمال استيراد',
|
|
79
|
+
subject: 'اكتمل استيراد {{resource}}',
|
|
80
|
+
body: 'نجح {{created}} صفاً، وفشل {{failed}}.',
|
|
81
|
+
variables: ['resource', 'created', 'failed'],
|
|
82
|
+
mail: false,
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
const PLACEHOLDER = /\{\{\s*([a-zA-Z][a-zA-Z0-9_]*)\s*\}\}/g;
|
|
86
|
+
const SUBJECT_LIMIT = 200;
|
|
87
|
+
const BODY_LIMIT = 4000;
|
|
88
|
+
export function renderTemplate(text, variables) {
|
|
89
|
+
return text.replace(PLACEHOLDER, (_, name) => {
|
|
90
|
+
const value = variables[name];
|
|
91
|
+
return value === null || value === undefined ? '' : String(value);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
/** Message templates: package defaults plus per-deployment overrides edited by administrators. */
|
|
95
|
+
export class MessageTemplates {
|
|
96
|
+
db;
|
|
97
|
+
definitions;
|
|
98
|
+
constructor(db, definitions = DEFAULT_TEMPLATES) {
|
|
99
|
+
this.db = db;
|
|
100
|
+
this.definitions = definitions;
|
|
101
|
+
}
|
|
102
|
+
async list() {
|
|
103
|
+
const rows = await this.db('message_templates').select('*');
|
|
104
|
+
const overrides = new Map(rows.map((row) => [String(row.key), row]));
|
|
105
|
+
return Object.entries(this.definitions).map(([key, definition]) => {
|
|
106
|
+
const row = overrides.get(key);
|
|
107
|
+
return {
|
|
108
|
+
key,
|
|
109
|
+
...definition,
|
|
110
|
+
subject: row ? String(row.subject) : definition.subject,
|
|
111
|
+
body: row ? String(row.body) : definition.body,
|
|
112
|
+
mail: row ? Boolean(row.mail) : definition.mail,
|
|
113
|
+
customized: Boolean(row),
|
|
114
|
+
updatedAt: row ? new Date(row.updated_at).toISOString() : null,
|
|
115
|
+
};
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
async render(key, variables, db = this.db) {
|
|
119
|
+
const definition = this.definition(key);
|
|
120
|
+
const row = await db('message_templates').where('key', key).first();
|
|
121
|
+
return {
|
|
122
|
+
subject: renderTemplate(row ? String(row.subject) : definition.subject, variables).slice(0, 255),
|
|
123
|
+
body: renderTemplate(row ? String(row.body) : definition.body, variables),
|
|
124
|
+
mail: row ? Boolean(row.mail) : definition.mail,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
async update(key, input, actorId) {
|
|
128
|
+
const definition = this.definition(key);
|
|
129
|
+
const subject = typeof input.subject === 'string' ? input.subject.trim() : '';
|
|
130
|
+
const body = typeof input.body === 'string' ? input.body.trim() : '';
|
|
131
|
+
if (!subject || subject.length > SUBJECT_LIMIT)
|
|
132
|
+
throw new KitError(422, 'E_TEMPLATE_SUBJECT', 'العنوان مطلوب ولا يتجاوز 200 حرف');
|
|
133
|
+
if (!body || body.length > BODY_LIMIT)
|
|
134
|
+
throw new KitError(422, 'E_TEMPLATE_BODY', 'نص الرسالة مطلوب ولا يتجاوز 4000 حرف');
|
|
135
|
+
for (const text of [subject, body])
|
|
136
|
+
for (const [, name] of text.matchAll(PLACEHOLDER))
|
|
137
|
+
if (!definition.variables.includes(name))
|
|
138
|
+
throw new KitError(422, 'E_TEMPLATE_VARIABLE', `متغير غير معروف في القالب: ${name}`);
|
|
139
|
+
const mail = input.mail === undefined ? definition.mail : input.mail === true;
|
|
140
|
+
await this.db('message_templates')
|
|
141
|
+
.insert({ key, subject, body, mail, updated_by: actorId, updated_at: this.db.fn.now() })
|
|
142
|
+
.onConflict('key')
|
|
143
|
+
.merge(['subject', 'body', 'mail', 'updated_by', 'updated_at']);
|
|
144
|
+
}
|
|
145
|
+
async reset(key) {
|
|
146
|
+
this.definition(key);
|
|
147
|
+
await this.db('message_templates').where('key', key).del();
|
|
148
|
+
}
|
|
149
|
+
/** Example output with sample values, for the editor preview. */
|
|
150
|
+
preview(key, input) {
|
|
151
|
+
const definition = this.definition(key);
|
|
152
|
+
const sample = Object.fromEntries(definition.variables.map((name) => [name, `‹${name}›`]));
|
|
153
|
+
return {
|
|
154
|
+
subject: renderTemplate(input.subject, sample),
|
|
155
|
+
body: renderTemplate(input.body, sample),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
definition(key) {
|
|
159
|
+
const definition = Object.hasOwn(this.definitions, key) ? this.definitions[key] : undefined;
|
|
160
|
+
if (!definition)
|
|
161
|
+
throw new KitError(404, 'E_TEMPLATE_NOT_FOUND', 'القالب غير موجود');
|
|
162
|
+
return definition;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Inserts a templated notification in the caller's transaction. Templates with
|
|
167
|
+
* mail enabled mark the row for the mail delivery worker (deliverNotificationMail).
|
|
168
|
+
*/
|
|
169
|
+
export async function notifyWithTemplate(db, userId, key, variables, templates = new MessageTemplates(db)) {
|
|
170
|
+
const message = await templates.render(key, variables, db);
|
|
171
|
+
await db('notifications').insert({
|
|
172
|
+
user_id: userId,
|
|
173
|
+
title: message.subject,
|
|
174
|
+
body: message.body,
|
|
175
|
+
template_key: key,
|
|
176
|
+
mail_state: message.mail ? 'pending' : null,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Delivers pending notification e-mails. Rows are claimed with SKIP LOCKED so
|
|
181
|
+
* several workers never send the same message; failures are retried three times.
|
|
182
|
+
*/
|
|
183
|
+
export async function deliverNotificationMail(db, send, limit = 50) {
|
|
184
|
+
let sent = 0;
|
|
185
|
+
let failed = 0;
|
|
186
|
+
await db.transaction(async (trx) => {
|
|
187
|
+
const rows = await trx('notifications as n')
|
|
188
|
+
.join('users as u', 'u.id', 'n.user_id')
|
|
189
|
+
.where('n.mail_state', 'pending')
|
|
190
|
+
.orderBy('n.id')
|
|
191
|
+
.limit(limit)
|
|
192
|
+
.forUpdate('n')
|
|
193
|
+
.skipLocked()
|
|
194
|
+
.select('n.id', 'n.title', 'n.body', 'n.mail_attempts', 'u.email', 'u.full_name', 'u.disabled_at');
|
|
195
|
+
for (const row of rows) {
|
|
196
|
+
if (row.disabled_at) {
|
|
197
|
+
await trx('notifications').where('id', row.id).update({ mail_state: 'skipped' });
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
try {
|
|
201
|
+
await send({
|
|
202
|
+
to: String(row.email),
|
|
203
|
+
name: row.full_name ? String(row.full_name) : null,
|
|
204
|
+
subject: String(row.title),
|
|
205
|
+
text: String(row.body),
|
|
206
|
+
});
|
|
207
|
+
await trx('notifications')
|
|
208
|
+
.where('id', row.id)
|
|
209
|
+
.update({ mail_state: 'sent', mailed_at: trx.fn.now() });
|
|
210
|
+
sent++;
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
const attempts = Number(row.mail_attempts ?? 0) + 1;
|
|
214
|
+
await trx('notifications')
|
|
215
|
+
.where('id', row.id)
|
|
216
|
+
.update({
|
|
217
|
+
mail_state: attempts >= 3 ? 'failed' : 'pending',
|
|
218
|
+
mail_attempts: attempts,
|
|
219
|
+
mail_error: String(error.message ?? error).slice(0, 500),
|
|
220
|
+
});
|
|
221
|
+
failed++;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
return { sent, failed };
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Holds one database connection that LISTENs for committed notifications and
|
|
229
|
+
* calls onSignal for each. Reconnects after connection loss. Returns a stop function.
|
|
230
|
+
*/
|
|
231
|
+
export function listenForNotifications(db, onSignal, options = {}) {
|
|
232
|
+
let stopped = false;
|
|
233
|
+
let connection;
|
|
234
|
+
let timer;
|
|
235
|
+
const connect = async () => {
|
|
236
|
+
try {
|
|
237
|
+
connection = await db.client.acquireConnection();
|
|
238
|
+
connection.on('notification', (message) => {
|
|
239
|
+
if (message.channel !== 'kit_notifications' || !message.payload)
|
|
240
|
+
return;
|
|
241
|
+
try {
|
|
242
|
+
const parsed = JSON.parse(message.payload);
|
|
243
|
+
if (Number.isSafeInteger(parsed.userId) && Number.isSafeInteger(parsed.id))
|
|
244
|
+
onSignal({ userId: parsed.userId, id: parsed.id });
|
|
245
|
+
}
|
|
246
|
+
catch (error) {
|
|
247
|
+
options.onError?.(error);
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
connection.once('error', (error) => {
|
|
251
|
+
options.onError?.(error);
|
|
252
|
+
void reconnect();
|
|
253
|
+
});
|
|
254
|
+
await connection.query('LISTEN kit_notifications');
|
|
255
|
+
}
|
|
256
|
+
catch (error) {
|
|
257
|
+
options.onError?.(error);
|
|
258
|
+
void reconnect();
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
const release = async () => {
|
|
262
|
+
const current = connection;
|
|
263
|
+
connection = undefined;
|
|
264
|
+
if (!current)
|
|
265
|
+
return;
|
|
266
|
+
current.removeAllListeners('notification');
|
|
267
|
+
try {
|
|
268
|
+
await current.query('UNLISTEN *');
|
|
269
|
+
}
|
|
270
|
+
catch { }
|
|
271
|
+
// A broken connection is destroyed rather than returned to the pool.
|
|
272
|
+
await db.client.releaseConnection(current).catch(() => { });
|
|
273
|
+
};
|
|
274
|
+
const reconnect = async () => {
|
|
275
|
+
await release();
|
|
276
|
+
if (stopped)
|
|
277
|
+
return;
|
|
278
|
+
timer = setTimeout(() => void connect(), options.retryMs ?? 2000);
|
|
279
|
+
timer.unref?.();
|
|
280
|
+
};
|
|
281
|
+
void connect();
|
|
282
|
+
return async () => {
|
|
283
|
+
stopped = true;
|
|
284
|
+
if (timer)
|
|
285
|
+
clearTimeout(timer);
|
|
286
|
+
await release();
|
|
287
|
+
};
|
|
288
|
+
}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { buildAbility } from '../auth/ability.js';
|
|
2
2
|
import { KitError } from '../admin/errors.js';
|
|
3
|
+
import { canQueryField } from '../admin/resource_service.js';
|
|
3
4
|
const NAME_LIMIT = 60;
|
|
4
5
|
const VIEW_LIMIT = 50;
|
|
6
|
+
/** Shared views are visible to every viewer of a resource; cap them per resource. */
|
|
7
|
+
const SHARED_LIMIT = 100;
|
|
5
8
|
/**
|
|
6
9
|
* A saved view is stored user input that later drives a list query, so every key
|
|
7
10
|
* is validated against the resource definition before it is written and again
|
|
@@ -16,12 +19,14 @@ export class SavedViews {
|
|
|
16
19
|
this.registry = registry;
|
|
17
20
|
}
|
|
18
21
|
async list(name, actor) {
|
|
19
|
-
const resource = this.authorize(name, actor);
|
|
22
|
+
const { resource } = this.authorize(name, actor);
|
|
23
|
+
// Own views first, so colleagues' shared views can never crowd them out.
|
|
20
24
|
const rows = await this.db('saved_views')
|
|
21
25
|
.where('resource', resource.name)
|
|
22
26
|
.where((where) => where.where('user_id', actor.id).orWhere('shared', true))
|
|
27
|
+
.orderByRaw('(user_id = ?) DESC', [actor.id])
|
|
23
28
|
.orderBy('name')
|
|
24
|
-
.limit(VIEW_LIMIT
|
|
29
|
+
.limit(VIEW_LIMIT + SHARED_LIMIT);
|
|
25
30
|
return rows.flatMap((row) => {
|
|
26
31
|
const query = this.sanitize(resource, row.query);
|
|
27
32
|
return query
|
|
@@ -38,15 +43,35 @@ export class SavedViews {
|
|
|
38
43
|
});
|
|
39
44
|
}
|
|
40
45
|
async save(name, actor, input) {
|
|
41
|
-
const resource = this.authorize(name, actor);
|
|
46
|
+
const { resource, ability } = this.authorize(name, actor);
|
|
42
47
|
const label = typeof input.name === 'string' ? input.name.trim() : '';
|
|
43
48
|
if (!label || label.length > NAME_LIMIT)
|
|
44
49
|
throw new KitError(422, 'E_VIEW_NAME', 'اسم العرض مطلوب ولا يتجاوز 60 حرفاً');
|
|
45
50
|
const query = this.sanitize(resource, input.query, true);
|
|
51
|
+
// A view may only query what its author may query; stored filters must not probe hidden fields.
|
|
52
|
+
const queried = [
|
|
53
|
+
...(query.sort ? [query.sort] : []),
|
|
54
|
+
...Object.keys(query.filters ?? {}),
|
|
55
|
+
...(query.search
|
|
56
|
+
? Object.entries(resource.fields)
|
|
57
|
+
.filter(([, field]) => field.searchable)
|
|
58
|
+
.map(([key]) => key)
|
|
59
|
+
: []),
|
|
60
|
+
];
|
|
61
|
+
for (const key of queried)
|
|
62
|
+
if (!canQueryField(resource, actor, ability, key))
|
|
63
|
+
throw new KitError(403, 'E_FIELD_FORBIDDEN', `لا يمكنك الاستعلام بهذا الحقل: ${key}`);
|
|
46
64
|
const shared = input.shared === true;
|
|
47
65
|
const existing = await this.db('saved_views')
|
|
48
66
|
.where({ user_id: actor.id, resource: resource.name, name: label })
|
|
49
|
-
.first('id');
|
|
67
|
+
.first('id', 'shared');
|
|
68
|
+
if (shared && !existing?.shared) {
|
|
69
|
+
const [{ count }] = await this.db('saved_views')
|
|
70
|
+
.where({ resource: resource.name, shared: true })
|
|
71
|
+
.count('* as count');
|
|
72
|
+
if (Number(count) >= SHARED_LIMIT)
|
|
73
|
+
throw new KitError(422, 'E_VIEW_LIMIT', 'بلغ هذا الكيان الحد الأقصى للعروض المشتركة');
|
|
74
|
+
}
|
|
50
75
|
if (!existing) {
|
|
51
76
|
const [{ count }] = await this.db('saved_views')
|
|
52
77
|
.where({ user_id: actor.id, resource: resource.name })
|
|
@@ -68,7 +93,7 @@ export class SavedViews {
|
|
|
68
93
|
return { id: Number(row.id), name: label, query, shared, own: true };
|
|
69
94
|
}
|
|
70
95
|
async remove(name, actor, id) {
|
|
71
|
-
const resource = this.authorize(name, actor);
|
|
96
|
+
const { resource } = this.authorize(name, actor);
|
|
72
97
|
const removed = await this.db('saved_views')
|
|
73
98
|
.where({ id, resource: resource.name, user_id: actor.id })
|
|
74
99
|
.del();
|
|
@@ -80,7 +105,7 @@ export class SavedViews {
|
|
|
80
105
|
const ability = buildAbility(actor.rules, this.registry.all());
|
|
81
106
|
if (!resource.actions.includes('view') || !ability.can('view', resource.name))
|
|
82
107
|
throw new KitError(403, 'E_FORBIDDEN', 'ليس لديك صلاحية لهذا الكيان');
|
|
83
|
-
return resource;
|
|
108
|
+
return { resource, ability };
|
|
84
109
|
}
|
|
85
110
|
/** Returns null for a stored view the resource no longer supports; throws on user input. */
|
|
86
111
|
sanitize(resource, value, strict = false) {
|
|
@@ -104,8 +104,8 @@ export class UserInvitations {
|
|
|
104
104
|
async accept(token, password, hashPassword) {
|
|
105
105
|
if (!(await this.valid(token)))
|
|
106
106
|
throw invalid();
|
|
107
|
-
if (typeof password !== 'string' || password.length < 8 || password.length >
|
|
108
|
-
throw new KitError(422, 'E_PASSWORD', 'كلمة المرور من 8 إلى
|
|
107
|
+
if (typeof password !== 'string' || password.length < 8 || password.length > 64)
|
|
108
|
+
throw new KitError(422, 'E_PASSWORD', 'كلمة المرور من 8 إلى 64 حرفًا');
|
|
109
109
|
const passwordHash = await hashPassword(password);
|
|
110
110
|
return this.db.transaction(async (trx) => {
|
|
111
111
|
const pending = await trx('user_invitations').where('token_hash', digest(token)).first();
|