@adula/kit 0.2.0-alpha.1
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/LICENSE.md +10 -0
- package/README.md +31 -0
- package/build/agent/AGENTS.template.md +28 -0
- package/build/agent/capabilities.md +13 -0
- package/build/agent/skills/adula-frontend-design/SKILL.md +59 -0
- package/build/agent/skills/idea-review/SKILL.md +6 -0
- package/build/commands/capabilities.d.ts +6 -0
- package/build/commands/capabilities.js +9 -0
- package/build/commands/doctor.d.ts +9 -0
- package/build/commands/doctor.js +32 -0
- package/build/commands/gaps.d.ts +12 -0
- package/build/commands/gaps.js +46 -0
- package/build/commands/install.d.ts +9 -0
- package/build/commands/install.js +91 -0
- package/build/commands/main.d.ts +6 -0
- package/build/commands/main.js +27 -0
- package/build/commands/module_add.d.ts +8 -0
- package/build/commands/module_add.js +57 -0
- package/build/commands/module_remove.d.ts +10 -0
- package/build/commands/module_remove.js +94 -0
- package/build/commands/resource.d.ts +8 -0
- package/build/commands/resource.js +29 -0
- package/build/commands/storage_migrate.d.ts +12 -0
- package/build/commands/storage_migrate.js +39 -0
- package/build/commands/ui.d.ts +11 -0
- package/build/commands/ui.js +210 -0
- package/build/configure.d.ts +2 -0
- package/build/configure.js +79 -0
- package/build/database/migrations/1770000000000_kit_core.d.ts +5 -0
- package/build/database/migrations/1770000000000_kit_core.js +10 -0
- package/build/database/migrations/1770000000001_kit_attachments.d.ts +5 -0
- package/build/database/migrations/1770000000001_kit_attachments.js +10 -0
- package/build/database/migrations/1770000000002_kit_saved_views.d.ts +5 -0
- package/build/database/migrations/1770000000002_kit_saved_views.js +10 -0
- package/build/database/migrations/1770000000003_kit_user_invitations.d.ts +5 -0
- package/build/database/migrations/1770000000003_kit_user_invitations.js +18 -0
- package/build/index.d.ts +51 -0
- package/build/index.js +34 -0
- package/build/providers/kit_provider.d.ts +6 -0
- package/build/providers/kit_provider.js +10 -0
- package/build/src/admin/contracts.d.ts +12 -0
- package/build/src/admin/contracts.js +93 -0
- package/build/src/admin/controller.d.ts +30 -0
- package/build/src/admin/controller.js +116 -0
- package/build/src/admin/errors.d.ts +5 -0
- package/build/src/admin/errors.js +9 -0
- package/build/src/admin/presentation.d.ts +22 -0
- package/build/src/admin/presentation.js +1 -0
- package/build/src/admin/resource_service.d.ts +170 -0
- package/build/src/admin/resource_service.js +763 -0
- package/build/src/attachments/attachment_service.d.ts +71 -0
- package/build/src/attachments/attachment_service.js +182 -0
- package/build/src/attachments/doctor.d.ts +4 -0
- package/build/src/attachments/doctor.js +28 -0
- package/build/src/attachments/storage_migrate.d.ts +39 -0
- package/build/src/attachments/storage_migrate.js +64 -0
- package/build/src/auth/ability.d.ts +22 -0
- package/build/src/auth/ability.js +31 -0
- package/build/src/auth/actor_store.d.ts +14 -0
- package/build/src/auth/actor_store.js +65 -0
- package/build/src/auth/conditions.d.ts +11 -0
- package/build/src/auth/conditions.js +81 -0
- package/build/src/auth/packed_rules.d.ts +4 -0
- package/build/src/auth/packed_rules.js +13 -0
- package/build/src/auth/sql.d.ts +11 -0
- package/build/src/auth/sql.js +114 -0
- package/build/src/commands/agent_assets.d.ts +7 -0
- package/build/src/commands/agent_assets.js +54 -0
- package/build/src/commands/doctor.d.ts +11 -0
- package/build/src/commands/doctor.js +261 -0
- package/build/src/commands/generator.d.ts +1 -0
- package/build/src/commands/generator.js +70 -0
- package/build/src/commands/source_markers.d.ts +2 -0
- package/build/src/commands/source_markers.js +9 -0
- package/build/src/core/activity.d.ts +43 -0
- package/build/src/core/activity.js +77 -0
- package/build/src/core/administration_guard.d.ts +3 -0
- package/build/src/core/administration_guard.js +37 -0
- package/build/src/core/health.d.ts +42 -0
- package/build/src/core/health.js +44 -0
- package/build/src/core/mail_test.d.ts +18 -0
- package/build/src/core/mail_test.js +88 -0
- package/build/src/core/notifications.d.ts +25 -0
- package/build/src/core/notifications.js +65 -0
- package/build/src/core/org_units.d.ts +30 -0
- package/build/src/core/org_units.js +123 -0
- package/build/src/core/roles.d.ts +72 -0
- package/build/src/core/roles.js +319 -0
- package/build/src/core/saved_views.d.ts +37 -0
- package/build/src/core/saved_views.js +139 -0
- package/build/src/core/settings.d.ts +29 -0
- package/build/src/core/settings.js +113 -0
- package/build/src/core/setup.d.ts +14 -0
- package/build/src/core/setup.js +89 -0
- package/build/src/core/ui_preferences.d.ts +12 -0
- package/build/src/core/ui_preferences.js +30 -0
- package/build/src/core/user_invitations.d.ts +23 -0
- package/build/src/core/user_invitations.js +144 -0
- package/build/src/core/users.d.ts +44 -0
- package/build/src/core/users.js +182 -0
- package/build/src/database/configure.d.ts +3 -0
- package/build/src/database/configure.js +41 -0
- package/build/src/database/schema.d.ts +7 -0
- package/build/src/database/schema.js +248 -0
- package/build/src/eslint/index.d.ts +3 -0
- package/build/src/eslint/index.js +64 -0
- package/build/src/events/adonis_jobs.d.ts +9 -0
- package/build/src/events/adonis_jobs.js +13 -0
- package/build/src/events/outbox.d.ts +18 -0
- package/build/src/events/outbox.js +33 -0
- package/build/src/events/record_mutation.d.ts +11 -0
- package/build/src/events/record_mutation.js +45 -0
- package/build/src/mcp/tools.d.ts +8 -0
- package/build/src/mcp/tools.js +156 -0
- package/build/src/org/org_service.d.ts +2 -0
- package/build/src/org/org_service.js +16 -0
- package/build/src/resource/define_resource.d.ts +4 -0
- package/build/src/resource/define_resource.js +48 -0
- package/build/src/resource/registry.d.ts +9 -0
- package/build/src/resource/registry.js +59 -0
- package/build/src/resource/types.d.ts +80 -0
- package/build/src/resource/types.js +1 -0
- package/build/src/resource/values.d.ts +5 -0
- package/build/src/resource/values.js +87 -0
- package/build/src/services/backup.d.ts +14 -0
- package/build/src/services/backup.js +59 -0
- package/build/src/services/settings.d.ts +9 -0
- package/build/src/services/settings.js +23 -0
- package/build/src/version.d.ts +1 -0
- package/build/src/version.js +2 -0
- package/build/stubs/README.md +6 -0
- package/build/stubs/controller.stub +8 -0
- package/build/stubs/main.d.ts +5 -0
- package/build/stubs/main.js +5 -0
- package/build/stubs/main.ts +5 -0
- package/build/stubs/modules.stub +6 -0
- package/build/stubs/resource_contract.stub +1 -0
- package/build/stubs/resource_contract.txt +390 -0
- package/build/stubs/routes.stub +16 -0
- package/build/stubs/service.stub +9 -0
- package/package.json +104 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { buildAbility } from '../auth/ability.js';
|
|
2
|
+
import { KitError } from '../admin/errors.js';
|
|
3
|
+
/** Same CASL decision as the admin middleware, with fresh rows inside the transaction. */
|
|
4
|
+
async function administrators(db) {
|
|
5
|
+
const rows = await db('user_roles as ur')
|
|
6
|
+
.join('users as u', 'u.id', 'ur.user_id')
|
|
7
|
+
.join('role_rules as rr', 'rr.role_id', 'ur.role_id')
|
|
8
|
+
.whereNull('u.disabled_at')
|
|
9
|
+
.whereNull('ur.org_unit_id')
|
|
10
|
+
.where('rr.subject', 'all')
|
|
11
|
+
.select('ur.user_id', 'rr.subject', 'rr.action', 'rr.inverted', 'rr.conditions', 'rr.fields');
|
|
12
|
+
const users = new Map();
|
|
13
|
+
for (const row of rows) {
|
|
14
|
+
const rules = users.get(row.user_id) ?? [];
|
|
15
|
+
rules.push({
|
|
16
|
+
subject: row.subject,
|
|
17
|
+
action: row.action,
|
|
18
|
+
inverted: row.inverted,
|
|
19
|
+
conditions: row.conditions ?? undefined,
|
|
20
|
+
fields: row.fields ?? undefined,
|
|
21
|
+
});
|
|
22
|
+
users.set(row.user_id, rules);
|
|
23
|
+
}
|
|
24
|
+
return new Set([...users].filter(([, rules]) => buildAbility(rules).can('manage', 'all')).map(([id]) => id));
|
|
25
|
+
}
|
|
26
|
+
/** Serialize access-changing mutations, including simultaneous requests by different admins. */
|
|
27
|
+
export async function administrationGuard(trx, actorId) {
|
|
28
|
+
await trx.raw('SELECT pg_advisory_xact_lock(717011)');
|
|
29
|
+
const before = await administrators(trx);
|
|
30
|
+
return async () => {
|
|
31
|
+
const after = await administrators(trx);
|
|
32
|
+
if (before.size && !after.size)
|
|
33
|
+
throw new KitError(409, 'E_LAST_ADMIN', 'لا يمكن تنفيذ هذا التغيير لأنه سيزيل آخر مدير نشط للنظام. عيّن مديرًا آخر أولًا.');
|
|
34
|
+
if (before.has(actorId) && !after.has(actorId))
|
|
35
|
+
throw new KitError(409, 'E_SELF_ADMIN_ACCESS', 'لا يمكنك إزالة صلاحية إدارة النظام من حسابك الحالي. اطلب من مدير آخر تعديلها.');
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Knex } from 'knex';
|
|
2
|
+
export type HeartbeatStatus = {
|
|
3
|
+
at: string | null;
|
|
4
|
+
ageMs: number | null;
|
|
5
|
+
healthy: boolean;
|
|
6
|
+
};
|
|
7
|
+
export type FailedJob = {
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
attemptsMade: number;
|
|
11
|
+
failedReason: string;
|
|
12
|
+
failedAt: string | null;
|
|
13
|
+
};
|
|
14
|
+
export type QueueCounts = Record<'waiting' | 'active' | 'delayed' | 'failed' | 'completed', number>;
|
|
15
|
+
/** Filled by the host from its queue backend; the kit never talks to BullMQ. */
|
|
16
|
+
export type QueueSnapshot = {
|
|
17
|
+
name: string;
|
|
18
|
+
counts: QueueCounts;
|
|
19
|
+
failed: FailedJob[];
|
|
20
|
+
};
|
|
21
|
+
export type RuntimeHealth = {
|
|
22
|
+
outbox: {
|
|
23
|
+
backlog: number;
|
|
24
|
+
oldestAgeMs: number | null;
|
|
25
|
+
};
|
|
26
|
+
processedEvents: number;
|
|
27
|
+
heartbeats: {
|
|
28
|
+
scheduler: HeartbeatStatus;
|
|
29
|
+
worker: HeartbeatStatus;
|
|
30
|
+
};
|
|
31
|
+
backup: {
|
|
32
|
+
lastOffsite: string | null;
|
|
33
|
+
lastRestoreTest: string | null;
|
|
34
|
+
stale: boolean;
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
export declare const HEARTBEAT_MAX_AGE_MS = 60000;
|
|
38
|
+
export declare const BACKUP_MAX_AGE_MS: number;
|
|
39
|
+
export declare function heartbeat(value: unknown, now?: number): HeartbeatStatus;
|
|
40
|
+
export declare function backupStale(lastOffsite: unknown, now?: number): boolean;
|
|
41
|
+
export declare function isBackupStale(db: Knex, now?: number): Promise<boolean>;
|
|
42
|
+
export declare function runtimeHealth(db: Knex, now?: number): Promise<RuntimeHealth>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Settings } from '../services/settings.js';
|
|
2
|
+
export const HEARTBEAT_MAX_AGE_MS = 60_000;
|
|
3
|
+
export const BACKUP_MAX_AGE_MS = 48 * 3_600_000;
|
|
4
|
+
function age(value, now) {
|
|
5
|
+
const at = typeof value === 'string' ? Date.parse(value) : Number.NaN;
|
|
6
|
+
return Number.isFinite(at) ? now - at : null;
|
|
7
|
+
}
|
|
8
|
+
export function heartbeat(value, now = Date.now()) {
|
|
9
|
+
const ageMs = age(value, now);
|
|
10
|
+
return {
|
|
11
|
+
at: ageMs === null ? null : String(value),
|
|
12
|
+
ageMs,
|
|
13
|
+
healthy: ageMs !== null && ageMs >= 0 && ageMs < HEARTBEAT_MAX_AGE_MS,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export function backupStale(lastOffsite, now = Date.now()) {
|
|
17
|
+
const ageMs = age(lastOffsite, now);
|
|
18
|
+
return ageMs === null || ageMs < 0 || ageMs >= BACKUP_MAX_AGE_MS;
|
|
19
|
+
}
|
|
20
|
+
export async function isBackupStale(db, now = Date.now()) {
|
|
21
|
+
return backupStale(await new Settings(db).get('backup.lastOffsite'), now);
|
|
22
|
+
}
|
|
23
|
+
export async function runtimeHealth(db, now = Date.now()) {
|
|
24
|
+
const settings = new Settings(db);
|
|
25
|
+
const outbox = await db('outbox')
|
|
26
|
+
.whereNull('published_at')
|
|
27
|
+
.select(db.raw('count(*) AS backlog'), db.raw('min(created_at) AS oldest'))
|
|
28
|
+
.first();
|
|
29
|
+
const processed = await db('processed_events').count('event_id as count').first();
|
|
30
|
+
const lastOffsite = (await settings.get('backup.lastOffsite')) ?? null;
|
|
31
|
+
const lastRestoreTest = (await settings.get('backup.lastRestoreTest')) ?? null;
|
|
32
|
+
return {
|
|
33
|
+
outbox: {
|
|
34
|
+
backlog: Number(outbox?.backlog ?? 0),
|
|
35
|
+
oldestAgeMs: outbox?.oldest ? Math.max(0, now - new Date(outbox.oldest).getTime()) : null,
|
|
36
|
+
},
|
|
37
|
+
processedEvents: Number(processed?.count ?? 0),
|
|
38
|
+
heartbeats: {
|
|
39
|
+
scheduler: heartbeat(await settings.get('scheduler.heartbeat'), now),
|
|
40
|
+
worker: heartbeat(await settings.get('worker.heartbeat'), now),
|
|
41
|
+
},
|
|
42
|
+
backup: { lastOffsite, lastRestoreTest, stale: backupStale(lastOffsite, now) },
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Knex } from 'knex';
|
|
2
|
+
export declare const MAIL_TEST_KEY = "mail.delivery_test";
|
|
3
|
+
export type MailTestState = {
|
|
4
|
+
id: string;
|
|
5
|
+
recipient: string;
|
|
6
|
+
fingerprint: string;
|
|
7
|
+
status: 'sending' | 'pending' | 'confirmed' | 'not_received' | 'failed';
|
|
8
|
+
requestedAt: string;
|
|
9
|
+
answeredAt?: string;
|
|
10
|
+
};
|
|
11
|
+
/** Receipt is an administrator attestation, never inferred from SMTP acceptance. */
|
|
12
|
+
export declare class MailDeliveryTest {
|
|
13
|
+
private db;
|
|
14
|
+
constructor(db: Knex);
|
|
15
|
+
current(actorId: number, recipient: string, fingerprint: string): Promise<MailTestState | null>;
|
|
16
|
+
send(actorId: number, recipient: string, fingerprint: string, deliver: (id: string) => Promise<void>): Promise<void>;
|
|
17
|
+
answer(actorId: number, recipient: string, fingerprint: string, id: unknown, received: unknown): Promise<void>;
|
|
18
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { KitError } from '../admin/errors.js';
|
|
3
|
+
import { Settings } from '../services/settings.js';
|
|
4
|
+
import { logActivity } from './activity.js';
|
|
5
|
+
export const MAIL_TEST_KEY = 'mail.delivery_test';
|
|
6
|
+
/** Receipt is an administrator attestation, never inferred from SMTP acceptance. */
|
|
7
|
+
export class MailDeliveryTest {
|
|
8
|
+
db;
|
|
9
|
+
constructor(db) {
|
|
10
|
+
this.db = db;
|
|
11
|
+
}
|
|
12
|
+
async current(actorId, recipient, fingerprint) {
|
|
13
|
+
const state = await new Settings(this.db).get(MAIL_TEST_KEY, 'user', String(actorId));
|
|
14
|
+
return state?.recipient === recipient && state.fingerprint === fingerprint ? state : null;
|
|
15
|
+
}
|
|
16
|
+
async send(actorId, recipient, fingerprint, deliver) {
|
|
17
|
+
const state = {
|
|
18
|
+
id: randomUUID(),
|
|
19
|
+
recipient,
|
|
20
|
+
fingerprint,
|
|
21
|
+
status: 'sending',
|
|
22
|
+
requestedAt: new Date().toISOString(),
|
|
23
|
+
};
|
|
24
|
+
await this.db.transaction(async (trx) => {
|
|
25
|
+
await trx.raw('SELECT pg_advisory_xact_lock(?, ?)', [717012, actorId]);
|
|
26
|
+
const previous = await new Settings(trx).get(MAIL_TEST_KEY, 'user', String(actorId));
|
|
27
|
+
const cooldown = previous?.status === 'sending' ? 300_000 : 60_000;
|
|
28
|
+
if (previous && Date.now() - Date.parse(previous.requestedAt) < cooldown)
|
|
29
|
+
throw new KitError(429, 'E_MAIL_TEST_COOLDOWN', 'انتظر قليلًا قبل إعادة إرسال البريد التجريبي');
|
|
30
|
+
await new Settings(trx).set(MAIL_TEST_KEY, state, 'user', String(actorId));
|
|
31
|
+
await logActivity(trx, {
|
|
32
|
+
resource: 'core.mail',
|
|
33
|
+
recordId: actorId,
|
|
34
|
+
actorId,
|
|
35
|
+
action: 'test_requested',
|
|
36
|
+
changes: { testId: state.id },
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
let failed = false;
|
|
40
|
+
try {
|
|
41
|
+
await deliver(state.id);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
failed = true;
|
|
45
|
+
}
|
|
46
|
+
// Match the attempt so a delayed response cannot overwrite a newer test.
|
|
47
|
+
await this.db.transaction(async (trx) => {
|
|
48
|
+
await trx('settings')
|
|
49
|
+
.where({ key: MAIL_TEST_KEY, scope: 'user', scope_id: String(actorId) })
|
|
50
|
+
.whereRaw("value->>'id' = ?", [state.id])
|
|
51
|
+
.update({ value: JSON.stringify({ ...state, status: failed ? 'failed' : 'pending' }) });
|
|
52
|
+
await logActivity(trx, {
|
|
53
|
+
resource: 'core.mail',
|
|
54
|
+
recordId: actorId,
|
|
55
|
+
actorId,
|
|
56
|
+
action: failed ? 'test_send_failed' : 'test_send_accepted',
|
|
57
|
+
changes: { testId: state.id },
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
if (failed)
|
|
61
|
+
throw new KitError(502, 'E_MAIL_TEST_SEND', 'تعذّر إرسال البريد التجريبي. راجع إعدادات البريد ثم أعد المحاولة.');
|
|
62
|
+
}
|
|
63
|
+
async answer(actorId, recipient, fingerprint, id, received) {
|
|
64
|
+
if (typeof received !== 'boolean' || typeof id !== 'string')
|
|
65
|
+
throw new KitError(422, 'E_MAIL_TEST_ANSWER', 'اختر هل وصلت الرسالة أم لم تصل');
|
|
66
|
+
await this.db.transaction(async (trx) => {
|
|
67
|
+
await trx.raw('SELECT pg_advisory_xact_lock(?, ?)', [717012, actorId]);
|
|
68
|
+
const state = await new MailDeliveryTest(trx).current(actorId, recipient, fingerprint);
|
|
69
|
+
if (!state ||
|
|
70
|
+
state.id !== id ||
|
|
71
|
+
state.status !== 'pending' ||
|
|
72
|
+
Date.now() - Date.parse(state.requestedAt) > 86_400_000)
|
|
73
|
+
throw new KitError(409, 'E_MAIL_TEST_STALE', 'هذه التجربة لم تعد قابلة للتأكيد. أرسل رسالة تجريبية جديدة.');
|
|
74
|
+
await new Settings(trx).set(MAIL_TEST_KEY, {
|
|
75
|
+
...state,
|
|
76
|
+
status: received ? 'confirmed' : 'not_received',
|
|
77
|
+
answeredAt: new Date().toISOString(),
|
|
78
|
+
}, 'user', String(actorId));
|
|
79
|
+
await logActivity(trx, {
|
|
80
|
+
resource: 'core.mail',
|
|
81
|
+
recordId: actorId,
|
|
82
|
+
actorId,
|
|
83
|
+
action: received ? 'receipt_confirmed' : 'receipt_missing',
|
|
84
|
+
changes: { testId: state.id },
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Knex } from 'knex';
|
|
2
|
+
export type Notification = {
|
|
3
|
+
id: number;
|
|
4
|
+
title: string;
|
|
5
|
+
body: string;
|
|
6
|
+
readAt: string | null;
|
|
7
|
+
createdAt: string;
|
|
8
|
+
};
|
|
9
|
+
export type NotificationPage = {
|
|
10
|
+
data: Notification[];
|
|
11
|
+
nextCursor: string | null;
|
|
12
|
+
unread: number;
|
|
13
|
+
};
|
|
14
|
+
export declare class NotificationsAdmin {
|
|
15
|
+
private db;
|
|
16
|
+
constructor(db: Knex);
|
|
17
|
+
/** Unread first, newest first; the cursor remembers which of the two segments it is in. */
|
|
18
|
+
list(userId: number, options?: {
|
|
19
|
+
cursor?: string;
|
|
20
|
+
limit?: number;
|
|
21
|
+
}): Promise<NotificationPage>;
|
|
22
|
+
markRead(userId: number, id: number): Promise<void>;
|
|
23
|
+
markAllRead(userId: number): Promise<number>;
|
|
24
|
+
unreadCount(userId: number): Promise<number>;
|
|
25
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { KitError } from '../admin/errors.js';
|
|
2
|
+
import { pageLimit } from './activity.js';
|
|
3
|
+
export class NotificationsAdmin {
|
|
4
|
+
db;
|
|
5
|
+
constructor(db) {
|
|
6
|
+
this.db = db;
|
|
7
|
+
}
|
|
8
|
+
/** Unread first, newest first; the cursor remembers which of the two segments it is in. */
|
|
9
|
+
async list(userId, options = {}) {
|
|
10
|
+
const limit = pageLimit(options.limit);
|
|
11
|
+
const query = this.db('notifications')
|
|
12
|
+
.where('user_id', userId)
|
|
13
|
+
.orderByRaw('(read_at IS NULL) DESC, id DESC')
|
|
14
|
+
.limit(limit + 1);
|
|
15
|
+
if (options.cursor) {
|
|
16
|
+
const match = /^([ur]):(\d{1,18})$/.exec(options.cursor);
|
|
17
|
+
if (!match)
|
|
18
|
+
throw new KitError(422, 'E_NOTIFICATION_CURSOR', 'مؤشر الصفحة غير صالح');
|
|
19
|
+
const id = Number(match[2]);
|
|
20
|
+
if (match[1] === 'u')
|
|
21
|
+
query.where((where) => where
|
|
22
|
+
.where((unread) => unread.whereNull('read_at').where('id', '<', id))
|
|
23
|
+
.orWhereNotNull('read_at'));
|
|
24
|
+
else
|
|
25
|
+
query.whereNotNull('read_at').where('id', '<', id);
|
|
26
|
+
}
|
|
27
|
+
const rows = await query;
|
|
28
|
+
const page = rows.slice(0, limit);
|
|
29
|
+
const last = page[page.length - 1];
|
|
30
|
+
return {
|
|
31
|
+
data: page.map((row) => ({
|
|
32
|
+
id: row.id,
|
|
33
|
+
title: row.title,
|
|
34
|
+
body: row.body,
|
|
35
|
+
readAt: row.read_at ? new Date(row.read_at).toISOString() : null,
|
|
36
|
+
createdAt: new Date(row.created_at).toISOString(),
|
|
37
|
+
})),
|
|
38
|
+
nextCursor: rows.length > limit ? `${last.read_at ? 'r' : 'u'}:${last.id}` : null,
|
|
39
|
+
unread: await this.unreadCount(userId),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
async markRead(userId, id) {
|
|
43
|
+
const owned = await this.db('notifications').where({ id, user_id: userId }).first('id');
|
|
44
|
+
if (!owned)
|
|
45
|
+
throw new KitError(404, 'E_NOTIFICATION_NOT_FOUND', 'الإشعار غير موجود');
|
|
46
|
+
await this.db('notifications')
|
|
47
|
+
.where({ id, user_id: userId })
|
|
48
|
+
.whereNull('read_at')
|
|
49
|
+
.update({ read_at: this.db.fn.now() });
|
|
50
|
+
}
|
|
51
|
+
async markAllRead(userId) {
|
|
52
|
+
return this.db('notifications')
|
|
53
|
+
.where('user_id', userId)
|
|
54
|
+
.whereNull('read_at')
|
|
55
|
+
.update({ read_at: this.db.fn.now() });
|
|
56
|
+
}
|
|
57
|
+
async unreadCount(userId) {
|
|
58
|
+
const row = await this.db('notifications')
|
|
59
|
+
.where('user_id', userId)
|
|
60
|
+
.whereNull('read_at')
|
|
61
|
+
.count('id as count')
|
|
62
|
+
.first();
|
|
63
|
+
return Number(row?.count ?? 0);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Knex } from 'knex';
|
|
2
|
+
import type { ResourceRegistry } from '../resource/registry.js';
|
|
3
|
+
export type OrgUnitNode = {
|
|
4
|
+
id: number;
|
|
5
|
+
parentId: number | null;
|
|
6
|
+
name: string;
|
|
7
|
+
type: string;
|
|
8
|
+
path: string;
|
|
9
|
+
depth: number;
|
|
10
|
+
members: number;
|
|
11
|
+
};
|
|
12
|
+
export declare class OrgUnitsAdmin {
|
|
13
|
+
private db;
|
|
14
|
+
private registry;
|
|
15
|
+
constructor(db: Knex, registry: ResourceRegistry);
|
|
16
|
+
tree(): Promise<OrgUnitNode[]>;
|
|
17
|
+
create(actorId: number, input: {
|
|
18
|
+
parentId: number | null;
|
|
19
|
+
name: string;
|
|
20
|
+
type: string;
|
|
21
|
+
}): Promise<{
|
|
22
|
+
id: number;
|
|
23
|
+
path: string;
|
|
24
|
+
}>;
|
|
25
|
+
rename(actorId: number, id: number, value: string): Promise<void>;
|
|
26
|
+
move(actorId: number, id: number, parentId: number | null): Promise<void>;
|
|
27
|
+
/** A unit leaves only when nothing points at it: children, memberships or scoped records. */
|
|
28
|
+
delete(actorId: number, id: number): Promise<void>;
|
|
29
|
+
private find;
|
|
30
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { identifier } from '../resource/define_resource.js';
|
|
3
|
+
import { KitError } from '../admin/errors.js';
|
|
4
|
+
import { moveOrgUnit } from '../org/org_service.js';
|
|
5
|
+
import { logActivity } from './activity.js';
|
|
6
|
+
const RESOURCE = 'core.org_units';
|
|
7
|
+
// Shared with moveOrgUnit so every tree edit serializes on the same lock.
|
|
8
|
+
const TREE_LOCK = 717010;
|
|
9
|
+
function text(value, field) {
|
|
10
|
+
const trimmed = typeof value === 'string' ? value.trim() : '';
|
|
11
|
+
if (!trimmed || trimmed.length > 100)
|
|
12
|
+
throw new KitError(422, 'E_ORG_INPUT', `${field} مطلوب (حتى 100 حرف)`);
|
|
13
|
+
return trimmed;
|
|
14
|
+
}
|
|
15
|
+
export class OrgUnitsAdmin {
|
|
16
|
+
db;
|
|
17
|
+
registry;
|
|
18
|
+
constructor(db, registry) {
|
|
19
|
+
this.db = db;
|
|
20
|
+
this.registry = registry;
|
|
21
|
+
}
|
|
22
|
+
async tree() {
|
|
23
|
+
const rows = await this.db('org_units as o')
|
|
24
|
+
.select('o.id', 'o.parent_id', 'o.name', 'o.type', this.db.raw('o.path::text AS path'), this.db.raw('nlevel(o.path) AS depth'), this.db.raw('(SELECT count(*) FROM user_org_units m WHERE m.org_unit_id = o.id) AS members'))
|
|
25
|
+
.orderBy('o.path');
|
|
26
|
+
return rows.map((row) => ({
|
|
27
|
+
id: row.id,
|
|
28
|
+
parentId: row.parent_id ?? null,
|
|
29
|
+
name: row.name,
|
|
30
|
+
type: row.type,
|
|
31
|
+
path: row.path,
|
|
32
|
+
depth: Number(row.depth),
|
|
33
|
+
members: Number(row.members),
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
36
|
+
async create(actorId, input) {
|
|
37
|
+
const name = text(input.name, 'الاسم');
|
|
38
|
+
const type = text(input.type, 'النوع');
|
|
39
|
+
return this.db.transaction(async (trx) => {
|
|
40
|
+
await trx.raw('SELECT pg_advisory_xact_lock(?)', [TREE_LOCK]);
|
|
41
|
+
const parent = input.parentId === null ? null : await trx('org_units').where('id', input.parentId).first();
|
|
42
|
+
if (input.parentId !== null && !parent)
|
|
43
|
+
throw new KitError(404, 'E_ORG_NOT_FOUND', 'الوحدة الأم غير موجودة');
|
|
44
|
+
const [unit] = await trx('org_units')
|
|
45
|
+
.insert({
|
|
46
|
+
parent_id: input.parentId,
|
|
47
|
+
name,
|
|
48
|
+
type,
|
|
49
|
+
path: `tmp_${randomBytes(8).toString('hex')}`,
|
|
50
|
+
})
|
|
51
|
+
.returning('id');
|
|
52
|
+
const path = parent ? `${parent.path}.${unit.id}` : `${unit.id}`;
|
|
53
|
+
await trx('org_units').where('id', unit.id).update({ path });
|
|
54
|
+
await logActivity(trx, {
|
|
55
|
+
resource: RESOURCE,
|
|
56
|
+
recordId: unit.id,
|
|
57
|
+
actorId,
|
|
58
|
+
action: 'create',
|
|
59
|
+
changes: { name, type, parentId: input.parentId, path },
|
|
60
|
+
});
|
|
61
|
+
return { id: unit.id, path };
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
async rename(actorId, id, value) {
|
|
65
|
+
const name = text(value, 'الاسم');
|
|
66
|
+
return this.db.transaction(async (trx) => {
|
|
67
|
+
const unit = await this.find(trx, id);
|
|
68
|
+
await trx('org_units').where('id', id).update({ name });
|
|
69
|
+
await logActivity(trx, {
|
|
70
|
+
resource: RESOURCE,
|
|
71
|
+
recordId: id,
|
|
72
|
+
actorId,
|
|
73
|
+
action: 'rename',
|
|
74
|
+
changes: { from: unit.name, to: name },
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
async move(actorId, id, parentId) {
|
|
79
|
+
return this.db.transaction(async (trx) => {
|
|
80
|
+
const unit = await this.find(trx, id);
|
|
81
|
+
await moveOrgUnit(trx, id, parentId);
|
|
82
|
+
const moved = await trx('org_units').where('id', id).first();
|
|
83
|
+
await logActivity(trx, {
|
|
84
|
+
resource: RESOURCE,
|
|
85
|
+
recordId: id,
|
|
86
|
+
actorId,
|
|
87
|
+
action: 'move',
|
|
88
|
+
changes: { from: unit.parent_id ?? null, to: parentId, path: String(moved.path) },
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
/** A unit leaves only when nothing points at it: children, memberships or scoped records. */
|
|
93
|
+
async delete(actorId, id) {
|
|
94
|
+
return this.db.transaction(async (trx) => {
|
|
95
|
+
await trx.raw('SELECT pg_advisory_xact_lock(?)', [TREE_LOCK]);
|
|
96
|
+
const unit = await this.find(trx, id);
|
|
97
|
+
if (await trx('org_units').where('parent_id', id).first())
|
|
98
|
+
throw new KitError(409, 'E_ORG_HAS_CHILDREN', 'انقل الوحدات الفرعية أولاً');
|
|
99
|
+
if ((await trx('user_org_units').where('org_unit_id', id).first()) ||
|
|
100
|
+
(await trx('user_roles').where('org_unit_id', id).first()))
|
|
101
|
+
throw new KitError(409, 'E_ORG_HAS_MEMBERS', 'أزل أعضاء الوحدة وأدوارها المقيدة أولاً');
|
|
102
|
+
for (const resource of this.registry.all().filter((r) => r.scoped)) {
|
|
103
|
+
const record = await trx(identifier(resource.name)).where('org_unit_id', id).first('id');
|
|
104
|
+
if (record)
|
|
105
|
+
throw new KitError(409, 'E_ORG_HAS_RECORDS', `الوحدة تحتوي سجلات في ${resource.label.ar}؛ انقلها أولاً`);
|
|
106
|
+
}
|
|
107
|
+
await trx('org_units').where('id', id).delete();
|
|
108
|
+
await logActivity(trx, {
|
|
109
|
+
resource: RESOURCE,
|
|
110
|
+
recordId: id,
|
|
111
|
+
actorId,
|
|
112
|
+
action: 'delete',
|
|
113
|
+
changes: { name: unit.name, path: String(unit.path) },
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
async find(db, id) {
|
|
118
|
+
const unit = await db('org_units').where('id', id).first();
|
|
119
|
+
if (!unit)
|
|
120
|
+
throw new KitError(404, 'E_ORG_NOT_FOUND', 'الوحدة التنظيمية غير موجودة');
|
|
121
|
+
return unit;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { Knex } from 'knex';
|
|
2
|
+
import type { ResourceRegistry } from '../resource/registry.js';
|
|
3
|
+
import type { Label } from '../resource/types.js';
|
|
4
|
+
import type { Conditions } from '../auth/conditions.js';
|
|
5
|
+
export type RoleSummary = {
|
|
6
|
+
id: number;
|
|
7
|
+
name: string;
|
|
8
|
+
permissionLevel: number;
|
|
9
|
+
rules: number;
|
|
10
|
+
users: number;
|
|
11
|
+
};
|
|
12
|
+
export type RoleRule = {
|
|
13
|
+
id: number;
|
|
14
|
+
subject: string;
|
|
15
|
+
action: string;
|
|
16
|
+
inverted: boolean;
|
|
17
|
+
conditions: Conditions | null;
|
|
18
|
+
fields: string[] | null;
|
|
19
|
+
};
|
|
20
|
+
export type RoleDetail = Omit<RoleSummary, 'rules'> & {
|
|
21
|
+
rules: RoleRule[];
|
|
22
|
+
};
|
|
23
|
+
export type RuleInput = {
|
|
24
|
+
subject: string;
|
|
25
|
+
action: string;
|
|
26
|
+
inverted?: boolean;
|
|
27
|
+
conditions?: Conditions | null;
|
|
28
|
+
fields?: string[] | null;
|
|
29
|
+
};
|
|
30
|
+
export type MatrixField = {
|
|
31
|
+
key: string;
|
|
32
|
+
label: Label;
|
|
33
|
+
type: string;
|
|
34
|
+
conditionable: boolean;
|
|
35
|
+
};
|
|
36
|
+
export type MatrixSubject = {
|
|
37
|
+
name: string;
|
|
38
|
+
label: Label;
|
|
39
|
+
actions: string[];
|
|
40
|
+
fields: MatrixField[];
|
|
41
|
+
/** Standard columns the resource actually has plus its conditionable fields. */
|
|
42
|
+
conditionFields: MatrixField[];
|
|
43
|
+
};
|
|
44
|
+
export type RoleMatrix = {
|
|
45
|
+
actions: string[];
|
|
46
|
+
subjects: MatrixSubject[];
|
|
47
|
+
};
|
|
48
|
+
export declare const ALL_SUBJECT_LABEL: Label;
|
|
49
|
+
export declare class RolesAdmin {
|
|
50
|
+
private db;
|
|
51
|
+
private registry;
|
|
52
|
+
constructor(db: Knex, registry: ResourceRegistry);
|
|
53
|
+
list(): Promise<RoleSummary[]>;
|
|
54
|
+
get(id: number): Promise<RoleDetail>;
|
|
55
|
+
create(actorId: number, input: {
|
|
56
|
+
name: string;
|
|
57
|
+
permissionLevel?: number;
|
|
58
|
+
}): Promise<{
|
|
59
|
+
id: number;
|
|
60
|
+
name: string;
|
|
61
|
+
permissionLevel: number;
|
|
62
|
+
}>;
|
|
63
|
+
rename(actorId: number, id: number, value: string): Promise<void>;
|
|
64
|
+
setPermissionLevel(actorId: number, id: number, value: number): Promise<void>;
|
|
65
|
+
delete(actorId: number, id: number): Promise<void>;
|
|
66
|
+
/** Subjects × actions derived from the registry; the UI never hard-codes resources. */
|
|
67
|
+
matrix(): RoleMatrix;
|
|
68
|
+
setRule(actorId: number, roleId: number, input: RuleInput): Promise<RoleRule>;
|
|
69
|
+
removeRule(actorId: number, roleId: number, ruleId: number): Promise<void>;
|
|
70
|
+
private validateRule;
|
|
71
|
+
private find;
|
|
72
|
+
}
|