livrare lot 2

This commit is contained in:
EVOTECH IT SRL 2026-07-10 03:39:53 -07:00
commit 8ecc78e729
763 changed files with 164593 additions and 0 deletions

View file

@ -0,0 +1,635 @@
/**
* Admin route helpers Keycloak operations + audit log.
*
* Extracted from the original 1734-line admin.ts so individual route modules
* (users, plans, docker, roles-groups) can import what they need without each
* duplicating Keycloak admin token handling, role/group caches, and the
* audit-log writer.
*
* Cache strategy: 10-min in-memory cache per realm role/group + per-user roles
* + per-user groups. `invalidateUserCache(keycloakId)` clears the per-user
* entries after a write (call from setUserRoles / setUserGroup).
*/
import type { Request } from 'express';
import { query } from '../../config/database';
import { requireEnv } from '../../config/env';
import { getKeycloakAdminToken } from '../../config/keycloak-admin';
import { log } from '../../config/logger';
// Shared response shapes (used by users.ts + plans.ts).
export interface UserListItem {
id: number;
email: string;
firstName: string;
lastName: string;
phone: string;
creditsRemained: number;
creditsSpent: number;
subscriptionPlanId: number;
subscriptionPlanName: string;
subscriptionStatus: number;
isActive: boolean;
keycloakId: string;
createdAt: string;
emailVerified?: boolean;
syncStatus?: 'synced' | 'keycloak_only';
// Phase U additions
storageUsedBytes?: number;
storageLimitBytes?: number;
storagePct?: number; // 0..1
roles?: string[];
groups?: string[];
}
export interface SubscriptionPlan {
id: number;
name: string;
creditsIncluded: number;
price: number;
}
export interface KeycloakUser {
id: string;
email: string;
emailVerified: boolean;
enabled: boolean;
}
// Helper: Get all Keycloak users
export async function getKeycloakUsers(): Promise<Map<string, KeycloakUser>> {
try {
const token = await getKeycloakAdminToken();
const keycloakUrl = requireEnv('KEYCLOAK_URL');
const response = await fetch(`${keycloakUrl}/admin/realms/didi-clients/users?max=1000`, {
headers: { 'Authorization': `Bearer ${token}` }
});
const users = await response.json() as KeycloakUser[];
const map = new Map<string, KeycloakUser>();
for (const user of users) {
map.set(user.id, user);
}
return map;
} catch (error) {
log.error('Failed to fetch Keycloak users:', error);
return new Map();
}
}
// Helper: Update Keycloak user emailVerified
export async function updateKeycloakEmailVerified(keycloakId: string, emailVerified: boolean): Promise<boolean> {
try {
const token = await getKeycloakAdminToken();
const keycloakUrl = requireEnv('KEYCLOAK_URL');
const response = await fetch(`${keycloakUrl}/admin/realms/didi-clients/users/${keycloakId}`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ emailVerified })
});
return response.status === 204;
} catch (error) {
log.error('Failed to update Keycloak emailVerified:', error);
return false;
}
}
// ============================================================================
// Phase U — Keycloak helpers for roles, groups, password reset (D2 user mgmt)
// ============================================================================
export interface KeycloakRoleRef {
id: string;
name: string;
description?: string;
composite?: boolean;
clientRole?: boolean;
}
export interface KeycloakGroup {
id: string;
name: string;
path: string;
attributes?: Record<string, string[]>;
realmRoles?: string[];
}
export const KC_REALM = 'didi-clients';
export function kcUrl(): string {
return requireEnv('KEYCLOAK_URL');
}
// In-memory cache (10 min) for relatively-static realm data so the admin
// list endpoint doesn't hammer Keycloak with N+1 calls.
let _rolesCache: { ts: number; roles: KeycloakRoleRef[] } | null = null;
let _groupsCache: { ts: number; groups: KeycloakGroup[] } | null = null;
let _userRolesCache: Map<string, { ts: number; names: string[] }> = new Map();
let _userGroupsCache: Map<string, { ts: number; names: string[] }> = new Map();
export const CACHE_TTL_MS = 10 * 60 * 1000;
export function invalidateUserCache(keycloakId: string): void {
_userRolesCache.delete(keycloakId);
_userGroupsCache.delete(keycloakId);
}
// List all realm roles (excluding default keycloak built-ins like uma_authorization).
export async function listRealmRoles(): Promise<KeycloakRoleRef[]> {
if (_rolesCache && Date.now() - _rolesCache.ts < CACHE_TTL_MS) {
return _rolesCache.roles;
}
try {
const token = await getKeycloakAdminToken();
const r = await fetch(`${kcUrl()}/admin/realms/${KC_REALM}/roles?briefRepresentation=false&max=200`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!r.ok) {
log.error('listRealmRoles HTTP', r.status);
return _rolesCache?.roles ?? [];
}
const all = (await r.json()) as KeycloakRoleRef[];
// Filter out keycloak's built-in roles which admins shouldn't manage.
const filtered = all.filter(
(x) =>
!x.clientRole &&
!['offline_access', 'uma_authorization', 'default-roles-didi-clients'].includes(x.name),
);
_rolesCache = { ts: Date.now(), roles: filtered };
return filtered;
} catch (e) {
log.error('listRealmRoles failed:', e);
return _rolesCache?.roles ?? [];
}
}
// Get realm roles assigned to one user.
export async function getUserRoles(keycloakId: string, force = false): Promise<string[]> {
const cached = _userRolesCache.get(keycloakId);
if (!force && cached && Date.now() - cached.ts < CACHE_TTL_MS) {
return cached.names;
}
try {
const token = await getKeycloakAdminToken();
const r = await fetch(
`${kcUrl()}/admin/realms/${KC_REALM}/users/${keycloakId}/role-mappings/realm`,
{ headers: { Authorization: `Bearer ${token}` } },
);
if (!r.ok) return [];
const roles = (await r.json()) as KeycloakRoleRef[];
const names = roles.map((x) => x.name);
_userRolesCache.set(keycloakId, { ts: Date.now(), names });
return names;
} catch (e) {
log.error('getUserRoles failed:', e);
return [];
}
}
// Set the user's realm roles to exactly the desired set (diff add/remove).
// Returns { added, removed, errors } for the audit log.
export async function setUserRoles(
keycloakId: string,
desiredNames: string[],
): Promise<{ added: string[]; removed: string[]; errors: string[] }> {
const errors: string[] = [];
try {
const token = await getKeycloakAdminToken();
const allRealmRoles = await listRealmRoles();
const byName = new Map(allRealmRoles.map((r) => [r.name, r]));
const current = await getUserRoles(keycloakId, true);
const desired = new Set(desiredNames);
const have = new Set(current);
const toAdd = [...desired].filter((n) => !have.has(n) && byName.has(n));
const toRemove = [...have].filter((n) => !desired.has(n) && byName.has(n));
const headers = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
const url = `${kcUrl()}/admin/realms/${KC_REALM}/users/${keycloakId}/role-mappings/realm`;
if (toAdd.length > 0) {
const body = JSON.stringify(toAdd.map((n) => byName.get(n)!));
const r = await fetch(url, { method: 'POST', headers, body });
if (!r.ok) errors.push(`add: HTTP ${r.status}`);
}
if (toRemove.length > 0) {
const body = JSON.stringify(toRemove.map((n) => byName.get(n)!));
const r = await fetch(url, { method: 'DELETE', headers, body });
if (!r.ok) errors.push(`remove: HTTP ${r.status}`);
}
invalidateUserCache(keycloakId);
return { added: toAdd, removed: toRemove, errors };
} catch (e) {
return { added: [], removed: [], errors: [(e as Error).message] };
}
}
// List realm top-level groups (we don't currently use sub-groups).
export async function listKeycloakGroups(): Promise<KeycloakGroup[]> {
if (_groupsCache && Date.now() - _groupsCache.ts < CACHE_TTL_MS) {
return _groupsCache.groups;
}
try {
const token = await getKeycloakAdminToken();
const r = await fetch(`${kcUrl()}/admin/realms/${KC_REALM}/groups?max=100`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!r.ok) return _groupsCache?.groups ?? [];
const groups = (await r.json()) as KeycloakGroup[];
_groupsCache = { ts: Date.now(), groups };
return groups;
} catch (e) {
log.error('listKeycloakGroups failed:', e);
return _groupsCache?.groups ?? [];
}
}
// Get groups for one user (by name only — that's what the UI needs).
export async function getUserGroups(keycloakId: string, force = false): Promise<string[]> {
const cached = _userGroupsCache.get(keycloakId);
if (!force && cached && Date.now() - cached.ts < CACHE_TTL_MS) {
return cached.names;
}
try {
const token = await getKeycloakAdminToken();
const r = await fetch(
`${kcUrl()}/admin/realms/${KC_REALM}/users/${keycloakId}/groups?max=20`,
{ headers: { Authorization: `Bearer ${token}` } },
);
if (!r.ok) return [];
const groups = (await r.json()) as KeycloakGroup[];
const names = groups.map((g) => g.name);
_userGroupsCache.set(keycloakId, { ts: Date.now(), names });
return names;
} catch (e) {
log.error('getUserGroups failed:', e);
return [];
}
}
// Replace user's groups with EXACTLY the given group names (typically one).
// Removes all current groups not in the target set, adds ones missing.
export async function setUserGroup(
keycloakId: string,
desiredGroupNames: string[],
): Promise<{ added: string[]; removed: string[]; errors: string[] }> {
const errors: string[] = [];
try {
const token = await getKeycloakAdminToken();
const allGroups = await listKeycloakGroups();
const byName = new Map(allGroups.map((g) => [g.name, g]));
const current = await getUserGroups(keycloakId, true);
const desired = new Set(desiredGroupNames);
const have = new Set(current);
const toAdd = [...desired].filter((n) => !have.has(n) && byName.has(n));
const toRemove = [...have].filter((n) => !desired.has(n) && byName.has(n));
const headers = { Authorization: `Bearer ${token}` };
for (const name of toRemove) {
const g = byName.get(name)!;
const r = await fetch(
`${kcUrl()}/admin/realms/${KC_REALM}/users/${keycloakId}/groups/${g.id}`,
{ method: 'DELETE', headers },
);
if (!r.ok) errors.push(`remove ${name}: HTTP ${r.status}`);
}
for (const name of toAdd) {
const g = byName.get(name)!;
const r = await fetch(
`${kcUrl()}/admin/realms/${KC_REALM}/users/${keycloakId}/groups/${g.id}`,
{ method: 'PUT', headers },
);
if (!r.ok) errors.push(`add ${name}: HTTP ${r.status}`);
}
invalidateUserCache(keycloakId);
return { added: toAdd, removed: toRemove, errors };
} catch (e) {
return { added: [], removed: [], errors: [(e as Error).message] };
}
}
// Trigger Keycloak's built-in "execute actions" email — pre-set with
// UPDATE_PASSWORD action so the user gets a 1-click reset link.
export async function sendResetPasswordEmail(
keycloakId: string,
options: { lifespanSeconds?: number; redirectUri?: string } = {},
): Promise<{ ok: boolean; status: number; detail?: string }> {
try {
const token = await getKeycloakAdminToken();
const params = new URLSearchParams();
if (options.lifespanSeconds) params.set('lifespan', String(options.lifespanSeconds));
if (options.redirectUri) params.set('redirect_uri', options.redirectUri);
const qs = params.toString() ? `?${params.toString()}` : '';
const r = await fetch(
`${kcUrl()}/admin/realms/${KC_REALM}/users/${keycloakId}/execute-actions-email${qs}`,
{
method: 'PUT',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify(['UPDATE_PASSWORD']),
},
);
if (r.ok) return { ok: true, status: r.status };
const detail = await r.text();
return { ok: false, status: r.status, detail };
} catch (e) {
return { ok: false, status: 0, detail: (e as Error).message };
}
}
// ============================================================================
// Create user in Keycloak (admin-initiated, two realms supported)
// ============================================================================
export type KeycloakRealmCode = 'didi-clients' | 'didi-admins';
export interface CreateKeycloakUserOptions {
realm: KeycloakRealmCode;
email: string;
firstName: string;
lastName: string;
password: string; // temporary password
passwordTemporary?: boolean; // default true — user must change at next login
requiredActions?: string[]; // default ['UPDATE_PASSWORD']
emailVerified?: boolean; // default true (admin-created → assumed verified)
enabled?: boolean; // default true
roles?: string[]; // realm role names to assign after create
groupName?: string | null; // single group to attach (Keycloak groups are singular here)
}
export interface CreateKeycloakUserResult {
ok: boolean;
keycloakId?: string;
realm: KeycloakRealmCode;
rolesAssigned: string[];
rolesFailed: string[];
groupAssigned: string | null;
warnings: string[];
error?: string;
status?: number;
}
/**
* Creates a user in the given Keycloak realm with a temporary password and
* required actions. After create, assigns realm roles and optional group.
*
* Used by admin "Create user" flow. The PG side (person/internet_user/etc)
* is handled by the caller after this returns successfully.
*/
export async function createKeycloakUser(
opts: CreateKeycloakUserOptions,
): Promise<CreateKeycloakUserResult> {
const warnings: string[] = [];
const rolesAssigned: string[] = [];
const rolesFailed: string[] = [];
let groupAssigned: string | null = null;
try {
const token = await getKeycloakAdminToken();
const base = kcUrl();
const headers = {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
};
// 1. Check email is not already taken in the target realm.
const existsRes = await fetch(
`${base}/admin/realms/${opts.realm}/users?email=${encodeURIComponent(opts.email)}&exact=true`,
{ headers: { Authorization: `Bearer ${token}` } },
);
if (!existsRes.ok) {
return {
ok: false,
realm: opts.realm,
rolesAssigned: [],
rolesFailed: [],
groupAssigned: null,
warnings,
error: `Keycloak lookup failed: ${existsRes.status}`,
status: existsRes.status,
};
}
const existing = (await existsRes.json()) as Array<{ id: string }>;
if (existing.length > 0) {
return {
ok: false,
realm: opts.realm,
rolesAssigned: [],
rolesFailed: [],
groupAssigned: null,
warnings,
error: `Email already exists in realm ${opts.realm}`,
status: 409,
};
}
// 2. Create the user with credentials inline.
const createBody = {
username: opts.email,
email: opts.email,
firstName: opts.firstName,
lastName: opts.lastName,
enabled: opts.enabled ?? true,
emailVerified: opts.emailVerified ?? true,
requiredActions: opts.requiredActions ?? ['UPDATE_PASSWORD'],
credentials: [
{
type: 'password',
value: opts.password,
temporary: opts.passwordTemporary ?? true,
},
],
};
const createRes = await fetch(`${base}/admin/realms/${opts.realm}/users`, {
method: 'POST',
headers,
body: JSON.stringify(createBody),
});
if (createRes.status !== 201) {
const detail = await createRes.text().catch(() => '');
return {
ok: false,
realm: opts.realm,
rolesAssigned: [],
rolesFailed: [],
groupAssigned: null,
warnings,
error: `Keycloak create failed (${createRes.status}): ${detail.slice(0, 200)}`,
status: createRes.status,
};
}
// Keycloak returns the new user ID in the Location header.
const location = createRes.headers.get('location') || '';
const keycloakId = location.split('/').pop() || '';
if (!keycloakId) {
return {
ok: false,
realm: opts.realm,
rolesAssigned: [],
rolesFailed: [],
groupAssigned: null,
warnings,
error: 'Keycloak create returned no Location header',
status: 500,
};
}
// 3. Keycloak may add CONFIGURE_TOTP from realm policy regardless of what
// we asked for. Re-PUT requiredActions to enforce exactly what was requested.
if (opts.requiredActions !== undefined) {
const putRes = await fetch(
`${base}/admin/realms/${opts.realm}/users/${keycloakId}`,
{
method: 'PUT',
headers,
body: JSON.stringify({ requiredActions: opts.requiredActions }),
},
);
if (!putRes.ok) {
warnings.push(`requiredActions override failed: ${putRes.status}`);
}
}
// 4. Assign realm roles.
if (opts.roles && opts.roles.length > 0) {
// Need to fetch each role definition by name (Keycloak requires the
// full role-representation in the POST body).
const roleDefs: Array<{ id: string; name: string }> = [];
for (const name of opts.roles) {
const r = await fetch(
`${base}/admin/realms/${opts.realm}/roles/${encodeURIComponent(name)}`,
{ headers: { Authorization: `Bearer ${token}` } },
);
if (r.ok) {
const def = (await r.json()) as { id: string; name: string };
roleDefs.push({ id: def.id, name: def.name });
} else {
rolesFailed.push(`${name} (lookup ${r.status})`);
}
}
if (roleDefs.length > 0) {
const assignRes = await fetch(
`${base}/admin/realms/${opts.realm}/users/${keycloakId}/role-mappings/realm`,
{ method: 'POST', headers, body: JSON.stringify(roleDefs) },
);
if (assignRes.ok || assignRes.status === 204) {
for (const r of roleDefs) rolesAssigned.push(r.name);
} else {
for (const r of roleDefs) rolesFailed.push(`${r.name} (assign ${assignRes.status})`);
}
}
}
// 5. Optional group assignment (didi-clients realm has free-users / paid-users / etc).
if (opts.groupName) {
try {
const allGroupsRes = await fetch(
`${base}/admin/realms/${opts.realm}/groups`,
{ headers: { Authorization: `Bearer ${token}` } },
);
if (allGroupsRes.ok) {
const allGroups = (await allGroupsRes.json()) as Array<{ id: string; name: string }>;
const target = allGroups.find((g) => g.name === opts.groupName);
if (target) {
const g = await fetch(
`${base}/admin/realms/${opts.realm}/users/${keycloakId}/groups/${target.id}`,
{ method: 'PUT', headers: { Authorization: `Bearer ${token}` } },
);
if (g.ok) {
groupAssigned = target.name;
} else {
warnings.push(`group ${opts.groupName} assign failed: ${g.status}`);
}
} else {
warnings.push(`group ${opts.groupName} not found in realm`);
}
}
} catch (e) {
warnings.push(`group assign error: ${(e as Error).message}`);
}
}
return {
ok: true,
keycloakId,
realm: opts.realm,
rolesAssigned,
rolesFailed,
groupAssigned,
warnings,
};
} catch (e) {
return {
ok: false,
realm: opts.realm,
rolesAssigned: [],
rolesFailed: [],
groupAssigned: null,
warnings: [],
error: (e as Error).message,
status: 500,
};
}
}
// ============================================================================
// User audit log helper
// ============================================================================
export interface AuditContext {
internetUserId?: number | null;
targetEmail?: string | null;
targetKeycloakId?: string | null;
action: string;
payload?: Record<string, unknown>;
}
// Pull actor info from JWT in Authorization header. Best-effort; works in
// staging where there's no JWT. Returns { keycloakId, email } or nulls.
export function getActor(req: Request): { keycloakId: string | null; email: string | null } {
try {
const auth = req.headers.authorization;
if (!auth || !auth.startsWith('Bearer ')) return { keycloakId: null, email: null };
const token = auth.slice(7);
const payload = token.split('.')[1];
if (!payload) return { keycloakId: null, email: null };
const decoded = JSON.parse(Buffer.from(payload, 'base64').toString('utf-8'));
return {
keycloakId: decoded.sub ?? null,
email: decoded.email ?? decoded.preferred_username ?? null,
};
} catch {
return { keycloakId: null, email: null };
}
}
export async function logUserAudit(req: Request, ctx: AuditContext): Promise<void> {
try {
const actor = getActor(req);
await query(
`INSERT INTO bos_sysadmin.user_audit_log
(internet_user_id, target_email, target_keycloak_id,
actor_keycloak_id, actor_email,
action, payload, request_ip, user_agent)
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9)`,
[
ctx.internetUserId ?? null,
ctx.targetEmail ?? null,
ctx.targetKeycloakId ?? null,
actor.keycloakId,
actor.email,
ctx.action,
JSON.stringify(ctx.payload ?? {}),
(req.ip ?? req.socket?.remoteAddress ?? null) as string | null,
(req.headers['user-agent'] ?? null) as string | null,
],
);
} catch (e) {
// Audit log MUST NOT break the operation — log + swallow.
log.error('[audit] logUserAudit failed:', e);
}
}

View file

@ -0,0 +1,65 @@
/**
* Admin route middleware JWT decode + role enforcement.
*
* Extracted from admin.ts. Kong validates the JWT signature upstream; here we
* decode the payload and enforce realm + role membership ("defense-in-depth").
*
* Set ADMIN_AUTH_BYPASS=true ONLY for local dev (logs a warning per request).
*/
import type { Request, Response, NextFunction } from 'express';
import { log } from '../../config/logger';
const ADMIN_REALM = process.env.ADMIN_REALM || 'didi-admins';
const ADMIN_ROLES = (process.env.ADMIN_ROLES || 'admin,super-admin')
.split(',')
.map(r => r.trim())
.filter(Boolean);
interface AdminJWTPayload {
sub?: string;
iss?: string;
email?: string;
preferred_username?: string;
realm_access?: { roles?: string[] };
}
function decodeJWTPayload(authHeader: string | undefined): AdminJWTPayload | null {
if (!authHeader || !authHeader.startsWith('Bearer ')) return null;
const parts = authHeader.slice(7).split('.');
if (parts.length !== 3) return null;
try {
return JSON.parse(Buffer.from(parts[1], 'base64').toString('utf-8')) as AdminJWTPayload;
} catch {
return null;
}
}
export function requireAdmin(req: Request, res: Response, next: NextFunction): void {
// Bypass is dev-only: hard-refused under NODE_ENV=production so a leftover
// env var can't disable admin auth on a deployed instance.
if (process.env.ADMIN_AUTH_BYPASS === 'true' && process.env.NODE_ENV !== 'production') {
log.warn('[admin] ADMIN_AUTH_BYPASS=true — auth disabled. NEVER set this in production.');
return next();
}
const payload = decodeJWTPayload(req.headers.authorization);
if (!payload || !payload.sub) {
res.status(401).json({ success: false, error: 'Missing or invalid Authorization header' });
return;
}
// Issuer must be from the admin realm (defense-in-depth — Kong should also enforce).
if (!payload.iss || !payload.iss.includes(`/realms/${ADMIN_REALM}`)) {
res.status(403).json({ success: false, error: 'Token not from admin realm' });
return;
}
const roles = payload.realm_access?.roles ?? [];
const hasAdmin = roles.some(r => ADMIN_ROLES.includes(r));
if (!hasAdmin) {
res.status(403).json({ success: false, error: 'Admin role required' });
return;
}
next();
}

View file

@ -0,0 +1,117 @@
/**
* Admin DOCKER routes container introspection.
*
* Endpoints:
* GET /logs/:container last N lines from a whitelisted container
* GET /containers list whitelisted containers + state
*
* Reads /var/run/docker.sock through the Docker HTTP API.
*/
import { Router } from 'express';
import { internalError } from '../../config/error-response';
import { log } from '../../config/logger';
const router = Router();
// ============================================================================
// GET /api/admin/logs/:container - Docker container logs
// ============================================================================
const CONTAINER_WHITELIST = new Set([
'didi-agent-v3', 'didi-framework', 'didi-admin', 'didi-cache',
'kong', 'keycloak',
'staging-dataLayer-minio', 'staging-dataLayer-postgres',
'staging-dataLayer-redis-commander',
'66e3748c7d9f_staging-dataLayer-rabbitmq',
'agent-v3-worker-techniques-1', 'agent-v3-worker-techniques-2',
'agent-v3-worker-ai-tampered-1', 'agent-v3-worker-ai-tampered-2',
'agent-v3-worker-claims-1', 'agent-v3-worker-claims-2', 'agent-v3-worker-claims-3',
'agent-v3-worker-domain-1', 'agent-v3-worker-domain-2',
'agent-v3-worker-media-preprocess-1', 'agent-v3-worker-media-preprocess-2',
'agent-v3-verdict-aggregator-1', 'agent-v3-verdict-aggregator-2',
]);
router.get('/logs/:container', async (req: any, res: any) => {
try {
const container = req.params.container;
if (!CONTAINER_WHITELIST.has(container)) {
return res.status(400).json({ success: false, error: `Unknown container: ${container}` });
}
const lines = parseInt(req.query.lines || '200', 10);
const since = req.query.since || '';
const tail = Math.min(Math.max(lines, 10), 2000);
// Build Docker Engine API URL
let url = `http://unix:/var/run/docker.sock:/containers/${container}/logs?stdout=true&stderr=true&tail=${tail}&timestamps=true`;
if (since) url += `&since=${since}`;
const http = require('http');
const dockerReq = http.request({ socketPath: '/var/run/docker.sock', path: `/containers/${container}/logs?stdout=true&stderr=true&tail=${tail}&timestamps=true${since ? '&since=' + since : ''}`, method: 'GET' }, (dockerRes: any) => {
if (dockerRes.statusCode === 404) {
res.status(404).json({ success: false, error: `Container ${container} not found` });
return;
}
const chunks: Buffer[] = [];
dockerRes.on('data', (chunk: Buffer) => chunks.push(chunk));
dockerRes.on('end', () => {
const raw = Buffer.concat(chunks);
// Docker multiplexed stream: 8-byte header per frame
// [stream_type(1), 0, 0, 0, size(4 BE)] + payload
const logLines: string[] = [];
let offset = 0;
while (offset < raw.length) {
if (offset + 8 > raw.length) break;
const size = raw.readUInt32BE(offset + 4);
if (offset + 8 + size > raw.length) break;
const line = raw.subarray(offset + 8, offset + 8 + size).toString('utf8').trimEnd();
if (line) logLines.push(line);
offset += 8 + size;
}
// If parsing failed (non-multiplexed), fall back to raw text split
if (logLines.length === 0 && raw.length > 0) {
logLines.push(...raw.toString('utf8').split('\n').filter(Boolean));
}
res.json({ success: true, data: { container, lines: logLines.length, logs: logLines } });
});
});
dockerReq.on('error', (err: any) => {
internalError(res, err, 'docker_api');
});
dockerReq.end();
} catch (error: any) {
internalError(res, error);
}
});
// GET /api/admin/containers - List running containers
router.get('/containers', async (_req: any, res: any) => {
try {
const http = require('http');
const dockerReq = http.request({ socketPath: '/var/run/docker.sock', path: '/containers/json', method: 'GET' }, (dockerRes: any) => {
const chunks: Buffer[] = [];
dockerRes.on('data', (chunk: Buffer) => chunks.push(chunk));
dockerRes.on('end', () => {
const containers = JSON.parse(Buffer.concat(chunks).toString());
const mapped = containers.map((c: any) => ({
name: (c.Names?.[0] || '').replace(/^\//, ''),
image: c.Image,
status: c.Status,
state: c.State,
}));
res.json({ success: true, data: mapped });
});
});
dockerReq.on('error', (err: any) => {
internalError(res, err);
});
dockerReq.end();
} catch (error: any) {
internalError(res, error);
}
});
export default router;

View file

@ -0,0 +1,34 @@
/**
* Admin route barrel.
*
* The original 1734-line admin.ts was split (this PR) into:
* _keycloak-helpers.ts Keycloak + audit helpers (cached)
* _middleware.ts JWT decode + role check
* users.ts user CRUD, sync, subscription, email-verified
* plans.ts plan CRUD
* docker.ts log + container introspection
* roles-groups.ts realm-roles, user roles/groups, password reset,
* usage history, audit log
*
* The auth middleware is mounted ONCE here, before any sub-router. Sub-routers
* mount with no prefix because all admin endpoints already include `/users`,
* `/plans`, etc. in their own paths (preserves the original public API).
*/
import { Router } from 'express';
import { requireAdmin } from './_middleware';
import usersRouter from './users';
import plansRouter from './plans';
import dockerRouter from './docker';
import rolesGroupsRouter from './roles-groups';
import socialRouter from './social';
const router = Router();
router.use(requireAdmin);
router.use(usersRouter);
router.use(plansRouter);
router.use(dockerRouter);
router.use(rolesGroupsRouter);
router.use(socialRouter);
export default router;

View file

@ -0,0 +1,170 @@
/**
* Admin PLANS routes subscription plan CRUD.
*
* Endpoints:
* GET /plans
* GET /plans/:id
* PUT /plans/:id
*/
import { Router, Request, Response } from 'express';
import { query, queryOne } from '../../config/database';
import { log } from '../../config/logger';
import { internalError } from '../../config/error-response';
const router = Router();
// GET /api/admin/plans - List all subscription plans
router.get('/plans', async (req: Request, res: Response) => {
try {
const plans = await query<any>(`
SELECT
subscription_plan_id as id,
plan_name as name,
plan_type as "planType",
billing_period as "billingPeriod",
price_amount as price,
credits_per_cycle as "creditsIncluded",
storage_limit_gb as "storageLimitGb",
max_images as "maxImages",
max_video_minutes as "maxVideoMinutes",
cost_text as "costText",
cost_url as "costUrl",
cost_image as "costImage",
cost_audio as "costAudio",
cost_video as "costVideo",
subscription_status as status
FROM bos_sysadmin.subscription_plan
ORDER BY subscription_plan_id
`);
res.json({ success: true, data: plans, count: plans.length });
} catch (error: any) {
log.error('Error fetching plans:', error);
internalError(res, error);
}
});
// GET /api/admin/plans/:id - Get single plan
router.get('/plans/:id', async (req: Request, res: Response) => {
try {
const planId = parseInt(req.params.id);
if (isNaN(planId)) {
return res.status(400).json({ success: false, error: 'Invalid plan ID' });
}
const plans = await query<any>(`
SELECT
subscription_plan_id as id,
plan_name as name,
plan_type as "planType",
billing_period as "billingPeriod",
price_amount as price,
credits_per_cycle as "creditsIncluded",
storage_limit_gb as "storageLimitGb",
max_images as "maxImages",
max_video_minutes as "maxVideoMinutes",
cost_text as "costText",
cost_url as "costUrl",
cost_image as "costImage",
cost_audio as "costAudio",
cost_video as "costVideo",
subscription_status as status
FROM bos_sysadmin.subscription_plan
WHERE subscription_plan_id = $1
`, [planId]);
if (plans.length === 0) {
return res.status(404).json({ success: false, error: 'Plan not found' });
}
res.json({ success: true, data: plans[0] });
} catch (error: any) {
log.error('Error fetching plan:', error);
internalError(res, error);
}
});
// PUT /api/admin/plans/:id - Update subscription plan
router.put('/plans/:id', async (req: Request, res: Response) => {
try {
const planId = parseInt(req.params.id);
if (isNaN(planId)) {
return res.status(400).json({ success: false, error: 'Invalid plan ID' });
}
const {
name, price, creditsIncluded, storageLimitGb,
maxImages, maxVideoMinutes,
costText, costUrl, costImage, costAudio, costVideo,
status,
} = req.body;
// Build dynamic SET clause from provided fields
const updates: string[] = [];
const values: any[] = [];
let paramIndex = 1;
const addField = (col: string, val: any) => {
if (val !== undefined) {
updates.push(`${col} = $${paramIndex++}`);
values.push(val);
}
};
addField('plan_name', name);
addField('price_amount', price);
addField('credits_per_cycle', creditsIncluded);
addField('storage_limit_gb', storageLimitGb);
addField('max_images', maxImages);
addField('max_video_minutes', maxVideoMinutes);
addField('cost_text', costText);
addField('cost_url', costUrl);
addField('cost_image', costImage);
addField('cost_audio', costAudio);
addField('cost_video', costVideo);
addField('subscription_status', status);
if (updates.length === 0) {
return res.status(400).json({ success: false, error: 'No fields to update' });
}
updates.push(`updated_time = CURRENT_DATE`);
values.push(planId);
const sql = `
UPDATE bos_sysadmin.subscription_plan
SET ${updates.join(', ')}
WHERE subscription_plan_id = $${paramIndex}
RETURNING
subscription_plan_id as id,
plan_name as name,
plan_type as "planType",
price_amount as price,
credits_per_cycle as "creditsIncluded",
storage_limit_gb as "storageLimitGb",
max_images as "maxImages",
max_video_minutes as "maxVideoMinutes",
cost_text as "costText",
cost_url as "costUrl",
cost_image as "costImage",
cost_audio as "costAudio",
cost_video as "costVideo",
subscription_status as status
`;
const result = await query<any>(sql, values);
if (result.length === 0) {
return res.status(404).json({ success: false, error: 'Plan not found' });
}
log.info(`[Admin] Updated plan ${planId}:`, req.body);
res.json({ success: true, data: result[0] });
} catch (error: any) {
log.error('Error updating plan:', error);
internalError(res, error);
}
});
export default router;

View file

@ -0,0 +1,393 @@
/**
* Admin ROLES + GROUPS + PASSWORD + USAGE + AUDIT routes.
*
* Endpoints:
* GET /realm-roles
* GET /users/:id/roles
* PUT /users/:id/roles
* GET /groups
* GET /users/:id/group
* PUT /users/:id/group
* POST /users/:id/reset-password
* GET /users/:id/usage-history
* GET /audit-log
*/
import { Router, Request, Response } from 'express';
import { query, queryOne } from '../../config/database';
import { log } from '../../config/logger';
import { internalError } from '../../config/error-response';
import {
listRealmRoles,
getUserRoles,
setUserRoles,
listKeycloakGroups,
getUserGroups,
setUserGroup,
sendResetPasswordEmail,
invalidateUserCache,
logUserAudit,
} from './_keycloak-helpers';
const router = Router();
// ============================================================================
// Phase U — User management endpoints (roles, groups, reset password,
// usage history, audit log).
// ============================================================================
// Resolve internet_user_id → { keycloak_id, email } for endpoints that take
// an internet_user_id but need to talk to Keycloak.
async function resolveTargetUser(internetUserId: number): Promise<
{ keycloakId: string | null; email: string | null } | null
> {
const row = await queryOne<{ keycloakId: string | null; email: string | null }>(
`SELECT keycloak_id as "keycloakId", email
FROM bos_sysadmin.user_credential
WHERE internet_user_id = $1`,
[internetUserId],
);
return row ?? null;
}
// ─── Realm roles + per-user roles ────────────────────────────────────────────
router.get('/realm-roles', async (_req: Request, res: Response) => {
try {
const roles = await listRealmRoles();
res.json({
success: true,
data: roles.map((r) => ({
name: r.name,
description: r.description ?? '',
})),
count: roles.length,
});
} catch (error: any) {
internalError(res, error);
}
});
router.get('/users/:id/roles', async (req: Request, res: Response) => {
try {
const userId = parseInt(req.params.id, 10);
if (isNaN(userId)) {
res.status(400).json({ success: false, error: 'Invalid user ID' });
return;
}
const target = await resolveTargetUser(userId);
if (!target || !target.keycloakId) {
res.status(404).json({ success: false, error: 'User has no keycloak_id' });
return;
}
const roles = await getUserRoles(target.keycloakId, true);
res.json({ success: true, data: { roles } });
} catch (error: any) {
internalError(res, error);
}
});
router.put('/users/:id/roles', async (req: Request, res: Response) => {
try {
const userId = parseInt(req.params.id, 10);
if (isNaN(userId)) {
res.status(400).json({ success: false, error: 'Invalid user ID' });
return;
}
const { roles } = (req.body ?? {}) as { roles?: unknown };
if (!Array.isArray(roles) || !roles.every((r) => typeof r === 'string')) {
res.status(400).json({
success: false,
error: 'Body must be {"roles": ["role_name", ...]}',
});
return;
}
const target = await resolveTargetUser(userId);
if (!target || !target.keycloakId) {
res.status(404).json({ success: false, error: 'User has no keycloak_id' });
return;
}
const result = await setUserRoles(target.keycloakId, roles as string[]);
await logUserAudit(req, {
internetUserId: userId,
targetEmail: target.email,
targetKeycloakId: target.keycloakId,
action: 'user.roles',
payload: {
added: result.added,
removed: result.removed,
errors: result.errors,
desired: roles,
},
});
if (result.errors.length > 0) {
res.status(207).json({ success: false, ...result });
return;
}
res.json({ success: true, ...result });
} catch (error: any) {
internalError(res, error);
}
});
// ─── Groups (realm groups + per-user single group) ──────────────────────────
router.get('/groups', async (_req: Request, res: Response) => {
try {
const groups = await listKeycloakGroups();
res.json({
success: true,
data: groups.map((g) => ({
id: g.id,
name: g.name,
path: g.path,
attributes: g.attributes ?? {},
})),
count: groups.length,
});
} catch (error: any) {
internalError(res, error);
}
});
router.get('/users/:id/group', async (req: Request, res: Response) => {
try {
const userId = parseInt(req.params.id, 10);
if (isNaN(userId)) {
res.status(400).json({ success: false, error: 'Invalid user ID' });
return;
}
const target = await resolveTargetUser(userId);
if (!target || !target.keycloakId) {
res.status(404).json({ success: false, error: 'User has no keycloak_id' });
return;
}
const groups = await getUserGroups(target.keycloakId, true);
res.json({ success: true, data: { groups } });
} catch (error: any) {
internalError(res, error);
}
});
router.put('/users/:id/group', async (req: Request, res: Response) => {
try {
const userId = parseInt(req.params.id, 10);
if (isNaN(userId)) {
res.status(400).json({ success: false, error: 'Invalid user ID' });
return;
}
const { group } = (req.body ?? {}) as { group?: unknown };
if (group !== null && typeof group !== 'string') {
res.status(400).json({
success: false,
error: 'Body must be {"group": "group_name"} or {"group": null}',
});
return;
}
const target = await resolveTargetUser(userId);
if (!target || !target.keycloakId) {
res.status(404).json({ success: false, error: 'User has no keycloak_id' });
return;
}
const desired = group ? [group as string] : [];
const result = await setUserGroup(target.keycloakId, desired);
await logUserAudit(req, {
internetUserId: userId,
targetEmail: target.email,
targetKeycloakId: target.keycloakId,
action: 'user.group',
payload: {
added: result.added,
removed: result.removed,
errors: result.errors,
desired,
},
});
if (result.errors.length > 0) {
res.status(207).json({ success: false, ...result });
return;
}
res.json({ success: true, ...result });
} catch (error: any) {
internalError(res, error);
}
});
// ─── Reset password (Keycloak email with UPDATE_PASSWORD action) ────────────
router.post('/users/:id/reset-password', async (req: Request, res: Response) => {
try {
const userId = parseInt(req.params.id, 10);
if (isNaN(userId)) {
res.status(400).json({ success: false, error: 'Invalid user ID' });
return;
}
const target = await resolveTargetUser(userId);
if (!target || !target.keycloakId) {
res.status(404).json({ success: false, error: 'User has no keycloak_id' });
return;
}
const { lifespanSeconds, redirectUri } = (req.body ?? {}) as {
lifespanSeconds?: number;
redirectUri?: string;
};
const result = await sendResetPasswordEmail(target.keycloakId, {
lifespanSeconds,
redirectUri,
});
await logUserAudit(req, {
internetUserId: userId,
targetEmail: target.email,
targetKeycloakId: target.keycloakId,
action: 'user.reset_password',
payload: { ok: result.ok, status: result.status, detail: result.detail ?? null },
});
if (!result.ok) {
res.status(502).json({
success: false,
error: `Keycloak rejected reset request (HTTP ${result.status})`,
detail: result.detail,
});
return;
}
res.json({
success: true,
message: `Reset-password email queued for ${target.email}`,
});
} catch (error: any) {
internalError(res, error);
}
});
// ─── Usage history (read from bos_sysadmin.ai_credit_usage) ─────────────────
router.get('/users/:id/usage-history', async (req: Request, res: Response) => {
try {
const userId = parseInt(req.params.id, 10);
if (isNaN(userId)) {
res.status(400).json({ success: false, error: 'Invalid user ID' });
return;
}
const limit = Math.min(parseInt((req.query.limit as string) || '50', 10), 200);
// Read columns flexibly — table shape may differ in older deployments.
// We surface what's available and gracefully fall back if columns don't exist.
let rows: any[] = [];
try {
rows = await query(
`SELECT *
FROM bos_sysadmin.ai_credit_usage
WHERE internet_user_id = $1
ORDER BY 1 DESC
LIMIT $2`,
[userId, limit],
);
} catch (e: any) {
log.error('[usage-history] read failed:', e.message);
rows = [];
}
// Total credits used (sum if such a column exists).
let totalCredits = 0;
for (const r of rows) {
const v =
r.credits_used ??
r.credits ??
r.amount ??
r.cost ??
0;
const n = Number(v);
if (Number.isFinite(n)) totalCredits += n;
}
res.json({
success: true,
data: rows,
stats: {
rows: rows.length,
total_credits: totalCredits,
},
});
} catch (error: any) {
internalError(res, error);
}
});
// ─── Audit log browser ──────────────────────────────────────────────────────
router.get('/audit-log', async (req: Request, res: Response) => {
try {
const action = (req.query.action as string | undefined)?.trim();
const actor = (req.query.actor as string | undefined)?.trim();
const internetUserId = req.query.internet_user_id
? parseInt(req.query.internet_user_id as string, 10)
: null;
const since = (req.query.since as string | undefined)?.trim();
const limit = Math.min(parseInt((req.query.limit as string) || '100', 10), 500);
const offset = parseInt((req.query.offset as string) || '0', 10);
const where: string[] = ['1=1'];
const params: any[] = [];
if (action) {
params.push(`${action}%`);
where.push(`action ILIKE $${params.length}`);
}
if (actor) {
params.push(`%${actor}%`);
where.push(`actor_email ILIKE $${params.length}`);
}
if (Number.isFinite(internetUserId) && internetUserId !== null) {
params.push(internetUserId);
where.push(`internet_user_id = $${params.length}`);
}
if (since) {
params.push(since);
where.push(`created_at >= $${params.length}`);
}
const whereSql = where.join(' AND ');
const totalRow = await queryOne<{ c: string }>(
`SELECT COUNT(*)::text AS c FROM bos_sysadmin.user_audit_log WHERE ${whereSql}`,
params,
);
const total = parseInt(totalRow?.c ?? '0', 10);
params.push(limit);
params.push(offset);
const rows = await query(
`SELECT audit_id as "auditId",
internet_user_id as "internetUserId",
target_email as "targetEmail",
target_keycloak_id as "targetKeycloakId",
actor_keycloak_id as "actorKeycloakId",
actor_email as "actorEmail",
action,
payload,
request_ip as "requestIp",
user_agent as "userAgent",
created_at as "createdAt"
FROM bos_sysadmin.user_audit_log
WHERE ${whereSql}
ORDER BY created_at DESC
LIMIT $${params.length - 1} OFFSET $${params.length}`,
params,
);
res.json({
success: true,
data: rows,
total,
limit,
offset,
});
} catch (error: any) {
internalError(res, error);
}
});
export default router;

View file

@ -0,0 +1,396 @@
/**
* Admin SOCIAL POSTS routes postare automată pe Facebook (DESI 6).
*
* Endpoints (toate sub /api/admin/social/):
* POST /social/draft creează draft din session_id sau content manual
* GET /social/drafts listă drafts (status=draft)
* POST /social/publish/:post_id publică acum SAU schedule (scheduled_at în body)
* GET /social/history lista posturi anterioare paginata
* GET /social/:post_id detalii post (cu engagement live)
* DELETE /social/:post_id șterge de pe FB + soft delete în DB
* POST /social/generate-from-session/:session_id generează draft auto din analiză
* GET /social/health verifică Facebook token valid
*
* Folosit de admin-dashboard pagina "Social Media" + buton "Post to Social" în
* Analysis History. Toate apelurile sunt audit-logged via _keycloak-helpers.
*/
import { Router, Request, Response } from 'express';
import { query, queryOne } from '../../config/database';
import { log } from '../../config/logger';
import { internalError } from '../../config/error-response';
import {
postToFacebookPage,
deleteFacebookPost,
getFacebookPostEngagement,
debugFacebookToken,
generateDraftFromAnalysisSession,
} from '../../services/facebook';
const router = Router();
interface SocialPostRow {
post_id: string;
session_id: string | null;
platform: string;
content: string;
image_url: string | null;
link_url: string | null;
status: string;
scheduled_at: string | null;
published_at: string | null;
external_post_id: string | null;
external_url: string | null;
external_response: unknown;
error_message: string | null;
engagement: unknown;
engagement_updated_at: string | null;
created_by: string;
created_at: string;
updated_at: string;
}
// ─────────────────────────────────────────────────────────────────────────
// GET /api/admin/social/health — verifică Facebook token + page access
// ─────────────────────────────────────────────────────────────────────────
router.get('/social/health', async (_req: Request, res: Response) => {
try {
const debug = await debugFacebookToken();
res.json({
success: true,
data: {
facebook: {
configured: !!process.env.FACEBOOK_PAGE_ACCESS_TOKEN && !!process.env.FACEBOOK_PAGE_ID,
page_id: process.env.FACEBOOK_PAGE_ID || null,
token_valid: debug.is_valid,
token_expires_at: debug.expires_at,
token_permanent: debug.expires_at === 0,
scopes: debug.scopes,
error: debug.error,
},
},
});
} catch (e) {
internalError(res, e, 'social_health');
}
});
// ─────────────────────────────────────────────────────────────────────────
// POST /api/admin/social/draft — create new draft
// Body: { session_id?, content, image_url?, link_url?, platform? }
// ─────────────────────────────────────────────────────────────────────────
router.post('/social/draft', async (req: Request, res: Response) => {
try {
const { session_id, content, image_url, link_url, platform } = req.body || {};
if (!content || typeof content !== 'string' || !content.trim()) {
return res.status(400).json({ success: false, error: 'content is required' });
}
if (content.length > 60000) {
return res.status(400).json({ success: false, error: 'content too long (max 60k chars)' });
}
const createdBy = (req as Request & { adminUser?: { sub?: string; email?: string } })
.adminUser?.email
|| (req as Request & { adminUser?: { sub?: string; email?: string } }).adminUser?.sub
|| 'unknown';
const row = await queryOne<SocialPostRow>(`
INSERT INTO bos_sysadmin.social_post
(session_id, platform, content, image_url, link_url, status, created_by)
VALUES ($1, $2, $3, $4, $5, 'draft', $6)
RETURNING post_id, session_id, platform, content, image_url, link_url,
status, scheduled_at, published_at, created_by, created_at, updated_at
`, [session_id || null, platform || 'facebook', content.trim(),
image_url || null, link_url || null, createdBy]);
res.status(201).json({ success: true, data: row });
} catch (e) {
internalError(res, e, 'social_draft_create');
}
});
// ─────────────────────────────────────────────────────────────────────────
// GET /api/admin/social/drafts — list active drafts
// ─────────────────────────────────────────────────────────────────────────
router.get('/social/drafts', async (req: Request, res: Response) => {
try {
const limit = Math.min(parseInt(String(req.query.limit || '50'), 10) || 50, 100);
const rows = await query<SocialPostRow>(`
SELECT *
FROM bos_sysadmin.social_post
WHERE status IN ('draft', 'scheduled', 'failed')
ORDER BY created_at DESC
LIMIT $1
`, [limit]);
res.json({ success: true, data: rows });
} catch (e) {
internalError(res, e, 'social_drafts_list');
}
});
// ─────────────────────────────────────────────────────────────────────────
// POST /api/admin/social/publish/:post_id
// Body: { scheduled_at? } — dacă scheduled_at în viitor → schedule, altfel publish acum
// ─────────────────────────────────────────────────────────────────────────
router.post('/social/publish/:post_id', async (req: Request, res: Response) => {
try {
const postId = req.params.post_id;
const draft = await queryOne<SocialPostRow>(
`SELECT * FROM bos_sysadmin.social_post WHERE post_id = $1`,
[postId],
);
if (!draft) {
return res.status(404).json({ success: false, error: 'Draft not found' });
}
if (draft.status === 'published') {
return res.status(409).json({ success: false, error: 'Already published' });
}
const scheduledAtIso = req.body?.scheduled_at as string | undefined;
let scheduledUnix: number | undefined;
if (scheduledAtIso) {
const ts = Math.floor(new Date(scheduledAtIso).getTime() / 1000);
if (isNaN(ts) || ts * 1000 < Date.now() + 9 * 60 * 1000) {
return res.status(400).json({
success: false,
error: 'scheduled_at must be at least 10 minutes in the future',
});
}
scheduledUnix = ts;
}
// Mark as publishing (prevent double-publish)
await query(
`UPDATE bos_sysadmin.social_post SET status='publishing', updated_at=now() WHERE post_id=$1`,
[postId],
);
try {
const fbResult = await postToFacebookPage({
message: draft.content,
link: draft.link_url || undefined,
imageUrl: draft.image_url || undefined,
scheduledPublishTime: scheduledUnix,
});
const finalStatus = scheduledUnix ? 'scheduled' : 'published';
const updated = await queryOne<SocialPostRow>(`
UPDATE bos_sysadmin.social_post
SET status = $1,
external_post_id = $2,
external_url = $3,
external_response = $4,
published_at = $5,
scheduled_at = $6,
error_message = NULL,
updated_at = now()
WHERE post_id = $7
RETURNING *
`, [
finalStatus,
fbResult.id,
fbResult.external_url,
JSON.stringify(fbResult),
scheduledUnix ? null : new Date().toISOString(),
scheduledUnix ? new Date(scheduledUnix * 1000).toISOString() : null,
postId,
]);
log.info(`[social] Post ${postId} → FB ${fbResult.id} (${finalStatus})`);
res.json({ success: true, data: updated });
} catch (fbErr) {
const errMsg = (fbErr as Error).message;
await query(`
UPDATE bos_sysadmin.social_post
SET status = 'failed', error_message = $1, updated_at = now()
WHERE post_id = $2
`, [errMsg, postId]);
log.error(`[social] FB publish failed for ${postId}: ${errMsg}`);
res.status(502).json({ success: false, error: `Facebook publish failed: ${errMsg}` });
}
} catch (e) {
internalError(res, e, 'social_publish');
}
});
// ─────────────────────────────────────────────────────────────────────────
// GET /api/admin/social/history — past posts (with filters)
// Query: ?status=published&platform=facebook&limit=50&offset=0
// ─────────────────────────────────────────────────────────────────────────
router.get('/social/history', async (req: Request, res: Response) => {
try {
const limit = Math.min(parseInt(String(req.query.limit || '50'), 10) || 50, 100);
const offset = Math.max(parseInt(String(req.query.offset || '0'), 10) || 0, 0);
const status = req.query.status as string | undefined;
const platform = req.query.platform as string | undefined;
const conditions: string[] = [];
const params: unknown[] = [];
if (status) {
params.push(status);
conditions.push(`status = $${params.length}`);
}
if (platform) {
params.push(platform);
conditions.push(`platform = $${params.length}`);
}
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
params.push(limit, offset);
const rows = await query<SocialPostRow>(`
SELECT * FROM bos_sysadmin.social_post
${where}
ORDER BY created_at DESC
LIMIT $${params.length - 1} OFFSET $${params.length}
`, params);
const countRow = await queryOne<{ total: string }>(`
SELECT COUNT(*)::text as total FROM bos_sysadmin.social_post ${where}
`, params.slice(0, -2));
res.json({
success: true,
data: { items: rows, total: parseInt(countRow?.total || '0', 10), limit, offset },
});
} catch (e) {
internalError(res, e, 'social_history');
}
});
// ─────────────────────────────────────────────────────────────────────────
// GET /api/admin/social/:post_id — fetch fresh engagement + return
// ─────────────────────────────────────────────────────────────────────────
router.get('/social/:post_id', async (req: Request, res: Response) => {
try {
const row = await queryOne<SocialPostRow>(
`SELECT * FROM bos_sysadmin.social_post WHERE post_id = $1`,
[req.params.post_id],
);
if (!row) {
return res.status(404).json({ success: false, error: 'Not found' });
}
// Refresh engagement dacă published și mai vechi de 5 min
if (row.status === 'published' && row.external_post_id) {
const lastUpdate = row.engagement_updated_at ? new Date(row.engagement_updated_at).getTime() : 0;
if (Date.now() - lastUpdate > 5 * 60 * 1000) {
try {
const eng = await getFacebookPostEngagement(row.external_post_id);
await query(`
UPDATE bos_sysadmin.social_post
SET engagement = $1, engagement_updated_at = now()
WHERE post_id = $2
`, [JSON.stringify(eng), req.params.post_id]);
row.engagement = eng;
row.engagement_updated_at = new Date().toISOString();
} catch (engErr) {
log.warn(`[social] engagement refresh failed: ${(engErr as Error).message}`);
}
}
}
res.json({ success: true, data: row });
} catch (e) {
internalError(res, e, 'social_get');
}
});
// ─────────────────────────────────────────────────────────────────────────
// DELETE /api/admin/social/:post_id — delete from FB + soft delete DB
// ─────────────────────────────────────────────────────────────────────────
router.delete('/social/:post_id', async (req: Request, res: Response) => {
try {
const row = await queryOne<SocialPostRow>(
`SELECT * FROM bos_sysadmin.social_post WHERE post_id = $1`,
[req.params.post_id],
);
if (!row) {
return res.status(404).json({ success: false, error: 'Not found' });
}
// Delete from FB only if published
if (row.external_post_id && row.status === 'published') {
try {
await deleteFacebookPost(row.external_post_id);
} catch (fbErr) {
// Continue with DB delete even if FB delete fails (post might be already gone)
log.warn(`[social] FB delete failed: ${(fbErr as Error).message}`);
}
}
// Soft delete în DB (preserve audit trail)
await query(`
UPDATE bos_sysadmin.social_post
SET status = 'deleted', updated_at = now()
WHERE post_id = $1
`, [req.params.post_id]);
res.json({ success: true });
} catch (e) {
internalError(res, e, 'social_delete');
}
});
// ─────────────────────────────────────────────────────────────────────────
// POST /api/admin/social/generate-from-session/:session_id
// Generate auto-draft din rezultatele unei analize
// ─────────────────────────────────────────────────────────────────────────
router.post('/social/generate-from-session/:session_id', async (req: Request, res: Response) => {
try {
const sessionId = req.params.session_id;
// Fetch session + verdict
const session = await queryOne<{
session_id: string;
input_text: string | null;
input_url: string | null;
input_type: string;
risk_score: number | null;
risk_category: string | null;
verdict_explanation_ro: string | null;
verdict_explanation_en: string | null;
}>(`
SELECT s.session_id, s.input_text, s.input_url, s.input_type,
s.risk_score, s.risk_category,
v.explanation_ro as verdict_explanation_ro,
v.explanation_en as verdict_explanation_en
FROM bos_analysis.analysis_session s
LEFT JOIN bos_analysis.analysis_verdict v ON v.session_id = s.session_id
WHERE s.session_id = $1
`, [sessionId]);
if (!session) {
return res.status(404).json({ success: false, error: 'Session not found' });
}
const content = generateDraftFromAnalysisSession({
input_text: session.input_text,
input_url: session.input_url,
input_type: session.input_type,
risk_score: session.risk_score,
risk_category: session.risk_category,
verdict: {
explanation_ro: session.verdict_explanation_ro || undefined,
explanation_en: session.verdict_explanation_en || undefined,
},
});
const createdBy = (req as Request & { adminUser?: { sub?: string; email?: string } })
.adminUser?.email
|| (req as Request & { adminUser?: { sub?: string; email?: string } }).adminUser?.sub
|| 'unknown';
// Salvează draft în DB
const row = await queryOne<SocialPostRow>(`
INSERT INTO bos_sysadmin.social_post
(session_id, platform, content, status, created_by)
VALUES ($1, 'facebook', $2, 'draft', $3)
RETURNING *
`, [sessionId, content, createdBy]);
res.status(201).json({ success: true, data: row });
} catch (e) {
internalError(res, e, 'social_generate_from_session');
}
});
export default router;

View file

@ -0,0 +1,988 @@
/**
* Admin USERS routes user CRUD + Keycloak sync + subscription + email-verified.
*
* Extracted from the original 1734-line admin.ts.
*
* Endpoints (mounted under /api/admin):
* GET /users
* POST /users/sync
* GET /users/:id
* PUT /users/:id
* DELETE /users/:id
* PUT /users/:id/subscription
* PUT /users/:id/email-verified
*/
import { Router, Request, Response } from 'express';
import { query, queryOne, transaction } from '../../config/database';
import { getMinioClient, updateUserBucketMetadata, createUserBucket } from '../../config/minio';
import { requireEnv } from '../../config/env';
import { getKeycloakAdminToken } from '../../config/keycloak-admin';
import { log } from '../../config/logger';
import { internalError } from '../../config/error-response';
import {
type KeycloakUser,
type SubscriptionPlan,
type UserListItem,
type KeycloakRealmCode,
getKeycloakUsers,
updateKeycloakEmailVerified,
getUserRoles,
getUserGroups,
logUserAudit,
createKeycloakUser,
} from './_keycloak-helpers';
const router = Router();
// GET /api/admin/users - List all users (from Keycloak + PostgreSQL merged)
router.get('/users', async (req: Request, res: Response) => {
try {
const search = (req.query.search as string) || '';
// Phase U — filter by sync state (default 'all').
const syncFilter = ((req.query.sync_status as string) || 'all').toLowerCase();
// include_kc_meta=true → fan-out to Keycloak for roles+groups per user.
// Default true; pass ?include_kc_meta=false to skip for faster pages
// when the admin only needs basic columns.
const includeKcMeta = ((req.query.include_kc_meta as string) ?? 'true') !== 'false';
// 1. Get ALL users from Keycloak
const keycloakUsers = await getKeycloakUsers();
// 2. Get users from PostgreSQL — extended with storage_used / limit
const dbUsers = await query<UserListItem & { keycloakId: string }>(`
SELECT
iu.internet_user_id as "id",
uc.email,
pf.prenume as "firstName",
pf.nume as "lastName",
uc.cellular_phone_no as "phone",
iu.credits_remained as "creditsRemained",
iu.credits_spent as "creditsSpent",
iu.storage_used_bytes as "storageUsedBytes",
iu.storage_limit_bytes as "storageLimitBytes",
s.subscription_plan_id as "subscriptionPlanId",
sp.plan_name as "subscriptionPlanName",
uc.subscription_status as "subscriptionStatus",
COALESCE(s.is_active, true) as "isActive",
uc.keycloak_id as "keycloakId",
s.created_time as "createdAt"
FROM bos_sysadmin.user_credential uc
LEFT JOIN bos_sysadmin.internet_user iu ON uc.internet_user_id = iu.internet_user_id
LEFT JOIN bos_subscriber.persoana_fizica pf ON iu.person_id = pf.individual_id
LEFT JOIN bos_sysadmin.subscription s ON iu.internet_user_id = s.internet_user_id
LEFT JOIN bos_sysadmin.subscription_plan sp ON s.subscription_plan_id = sp.subscription_plan_id
ORDER BY iu.internet_user_id DESC
`);
// 3. Create set of keycloak IDs that exist in PostgreSQL
const dbKeycloakIds = new Set(dbUsers.map(u => u.keycloakId).filter(Boolean));
// Helper — derive storagePct and normalize numeric storage fields.
const withStorage = (u: UserListItem): UserListItem => {
const used = Number(u.storageUsedBytes ?? 0);
const limit = Number(u.storageLimitBytes ?? 0);
const pct = limit > 0 ? Math.min(1, used / limit) : 0;
return { ...u, storageUsedBytes: used, storageLimitBytes: limit, storagePct: pct };
};
// 4. Merge: PostgreSQL users with Keycloak data
const mergedUsers: UserListItem[] = dbUsers.map(user => withStorage({
...user,
emailVerified: user.keycloakId ? keycloakUsers.get(user.keycloakId)?.emailVerified ?? false : false,
syncStatus: 'synced' as 'synced' | 'keycloak_only'
}));
// 5. Add Keycloak-only users (not in PostgreSQL)
for (const [keycloakId, kcUser] of keycloakUsers) {
if (!dbKeycloakIds.has(keycloakId)) {
mergedUsers.push({
id: 0,
email: kcUser.email,
firstName: (kcUser as any).firstName || '',
lastName: (kcUser as any).lastName || '',
phone: '',
creditsRemained: 0,
creditsSpent: 0,
storageUsedBytes: 0,
storageLimitBytes: 0,
storagePct: 0,
subscriptionPlanId: 0,
subscriptionPlanName: 'Not synced',
subscriptionStatus: 0,
isActive: kcUser.enabled,
keycloakId: keycloakId,
createdAt: '',
emailVerified: kcUser.emailVerified,
syncStatus: 'keycloak_only' as 'synced' | 'keycloak_only'
});
}
}
// 6. Filter by search if provided
let filteredUsers = mergedUsers;
if (search) {
const searchLower = search.toLowerCase();
filteredUsers = mergedUsers.filter(u =>
u.email?.toLowerCase().includes(searchLower) ||
u.firstName?.toLowerCase().includes(searchLower) ||
u.lastName?.toLowerCase().includes(searchLower)
);
}
// 6b. Apply sync_status filter (Phase U)
if (syncFilter === 'synced') {
filteredUsers = filteredUsers.filter((u) => u.syncStatus === 'synced');
} else if (syncFilter === 'keycloak_only') {
filteredUsers = filteredUsers.filter((u) => u.syncStatus === 'keycloak_only');
}
// 6c. Batch-enrich with Keycloak roles + groups (Phase U).
// Sequential rather than parallel to avoid Keycloak rate-limit on tokens.
if (includeKcMeta) {
for (const u of filteredUsers) {
if (!u.keycloakId) continue;
try {
const [roles, groups] = await Promise.all([
getUserRoles(u.keycloakId),
getUserGroups(u.keycloakId),
]);
u.roles = roles;
u.groups = groups;
} catch {
u.roles = [];
u.groups = [];
}
}
}
// Sort: keycloak_only first (need attention), then by id desc
filteredUsers.sort((a, b) => {
if (a.syncStatus === 'keycloak_only' && b.syncStatus !== 'keycloak_only') return -1;
if (a.syncStatus !== 'keycloak_only' && b.syncStatus === 'keycloak_only') return 1;
return b.id - a.id;
});
const syncedCount = filteredUsers.filter(u => u.syncStatus === 'synced').length;
const keycloakOnlyCount = filteredUsers.filter(u => u.syncStatus === 'keycloak_only').length;
res.json({
success: true,
data: filteredUsers,
stats: {
total: filteredUsers.length,
synced: syncedCount,
keycloakOnly: keycloakOnlyCount
}
});
} catch (error: any) {
log.error('Error fetching users:', error);
internalError(res, error);
}
});
// POST /api/admin/users - Create a new user in Keycloak + PostgreSQL (admin-initiated)
//
// Body:
// {
// realm: 'didi-clients' | 'didi-admins',
// email: string,
// firstName: string,
// lastName: string,
// password: string, // temporary password
// planId?: number, // default 1 (Free); only used for didi-clients
// roles?: string[], // realm role names to assign (after create)
// groupName?: string | null, // optional group (didi-clients only)
// requiredActions?: string[], // default ['UPDATE_PASSWORD']
// emailVerified?: boolean // default true
// }
//
// Flow:
// 1. Validate input
// 2. Create user in Keycloak target realm + assign roles + group
// 3. For didi-clients: also create person/internet_user/user_credential/subscription
// in PG (with the requested plan) + create MinIO user prefix
// 4. For didi-admins: only Keycloak side (operator account, no PG profile)
// 5. Audit log
router.post('/users', async (req: Request, res: Response) => {
try {
const body = req.body || {};
const realm: KeycloakRealmCode = body.realm === 'didi-admins' ? 'didi-admins' : 'didi-clients';
const email = String(body.email || '').trim().toLowerCase();
const firstName = String(body.firstName || '').trim();
const lastName = String(body.lastName || '').trim();
const password = String(body.password || '');
const planId = Number.isFinite(body.planId) ? Number(body.planId) : 1;
const roles: string[] = Array.isArray(body.roles) ? body.roles : [];
const groupName: string | null = body.groupName ?? null;
const requiredActions: string[] = Array.isArray(body.requiredActions)
? body.requiredActions
: ['UPDATE_PASSWORD'];
const emailVerified: boolean = body.emailVerified !== false; // default true
// Validation
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
res.status(400).json({ success: false, error: 'Invalid email' });
return;
}
if (!firstName || !lastName) {
res.status(400).json({ success: false, error: 'firstName and lastName are required' });
return;
}
if (!password || password.length < 8) {
res.status(400).json({ success: false, error: 'Password must be at least 8 characters' });
return;
}
// For didi-clients we also need to make sure the email is not already in PG.
if (realm === 'didi-clients') {
const existing = await queryOne<{ id: number }>(
`SELECT internet_user_id as id FROM bos_sysadmin.user_credential WHERE email = $1`,
[email],
);
if (existing) {
res.status(409).json({
success: false,
error: 'Email already exists in PostgreSQL',
internetUserId: existing.id,
});
return;
}
}
// Step 1: Create in Keycloak.
const kcResult = await createKeycloakUser({
realm,
email,
firstName,
lastName,
password,
passwordTemporary: true,
requiredActions,
emailVerified,
enabled: true,
roles,
groupName,
});
if (!kcResult.ok || !kcResult.keycloakId) {
res.status(kcResult.status || 500).json({
success: false,
error: kcResult.error || 'Keycloak user creation failed',
warnings: kcResult.warnings,
});
return;
}
const keycloakId = kcResult.keycloakId;
// Step 2: Optional PG side (only for didi-clients realm — end-user accounts).
let internetUserId: number | null = null;
let bucketCreated = false;
let pgError: string | null = null;
if (realm === 'didi-clients') {
try {
// Look up plan defaults (credits + storage).
const planRow = await queryOne<{
credits_per_cycle: number;
storage_limit_gb: number;
plan_name: string;
}>(
`SELECT credits_per_cycle, storage_limit_gb, plan_name
FROM bos_sysadmin.subscription_plan WHERE subscription_plan_id = $1`,
[planId],
);
const credits = planRow?.credits_per_cycle ?? 10;
const storageGb = planRow?.storage_limit_gb ?? 1;
const planName = planRow?.plan_name ?? 'Free';
await transaction(async (client) => {
const personIdRes = await client.query(
"SELECT COALESCE(MAX(person_id), 0) + 1 as next_id FROM bos_subscriber.person",
);
const personId = personIdRes.rows[0].next_id;
const addressIdRes = await client.query(
"SELECT COALESCE(MAX(address_id), 0) + 1 as next_id FROM bos_subscriber.address",
);
const addressId = addressIdRes.rows[0].next_id;
const iuIdRes = await client.query(
"SELECT COALESCE(MAX(internet_user_id), 0) + 1 as next_id FROM bos_sysadmin.internet_user",
);
internetUserId = iuIdRes.rows[0].next_id;
const subIdRes = await client.query(
"SELECT COALESCE(MAX(subscription_id), 0) + 1 as next_id FROM bos_sysadmin.subscription",
);
const subscriptionId = subIdRes.rows[0].next_id;
await client.query(
"INSERT INTO bos_subscriber.person (person_id, person_type, status) VALUES ($1, 0, 1)",
[personId],
);
await client.query(
"INSERT INTO bos_subscriber.address (address_id, address_type) VALUES ($1, 0)",
[addressId],
);
await client.query(
`INSERT INTO bos_subscriber.persoana_fizica
(individual_id, tip_persoana, nume, prenume, ro_official_address_id)
VALUES ($1, 2, $2, $3, $4)`,
[personId, lastName, firstName, addressId],
);
await client.query(
`INSERT INTO bos_sysadmin.internet_user
(internet_user_id, person_id, credits_remained, credits_spent, storage_limit_bytes)
VALUES ($1, $2, $3, 0, $4)`,
[internetUserId, personId, credits, storageGb * 1073741824],
);
await client.query(
`INSERT INTO bos_sysadmin.user_credential
(internet_user_id, email, keycloak_id, enrollment_type,
cellular_phone_no, no_attempts_failed, subscription_status, activation_date)
VALUES ($1, $2, $3, 1, '', 0, 1, CURRENT_DATE)`,
[internetUserId, email, keycloakId],
);
await client.query(
`INSERT INTO bos_sysadmin.subscription
(subscription_id, internet_user_id, subscription_plan_id,
subscription_status, is_active, activation_date, deactivation_date,
created_time, updated_time)
VALUES ($1, $2, $3, 1, true, CURRENT_DATE, '2099-12-31',
CURRENT_DATE, CURRENT_DATE)`,
[subscriptionId, internetUserId, planId],
);
await client.query(
`INSERT INTO bos_subscriber.contact (person_id, contact_type_id, contact_info)
VALUES ($1, 2, $2)`,
[personId, email],
);
});
// Create MinIO user namespace (no-op on single-bucket mode, but logs).
try {
const bucketResult = await createUserBucket(
internetUserId!,
email,
planId,
planName,
storageGb,
);
bucketCreated = !!bucketResult.created;
} catch (bucketErr: any) {
log.error(`[ADMIN] MinIO bucket create failed for new user ${internetUserId}:`, bucketErr.message);
}
} catch (e: any) {
// PG failed AFTER Keycloak create — orphan in Keycloak. Log it; the
// sync endpoint can recover later.
pgError = e.message || String(e);
log.error(
`[ADMIN] PG insert FAILED for new user ${email} (kc=${keycloakId}). ` +
`User exists in Keycloak but not in PG. Use POST /users/sync to recover. Error:`,
pgError,
);
}
}
// Step 3: Audit log.
await logUserAudit(req, {
internetUserId,
targetEmail: email,
targetKeycloakId: keycloakId,
action: 'user.create',
payload: {
realm,
roles: kcResult.rolesAssigned,
rolesFailed: kcResult.rolesFailed,
groupAssigned: kcResult.groupAssigned,
planId: realm === 'didi-clients' ? planId : null,
bucketCreated,
pgError,
warnings: kcResult.warnings,
},
});
res.status(pgError ? 207 : 201).json({
success: !pgError,
message: pgError
? `User created in Keycloak but PG insert failed — recoverable via /users/sync`
: `User ${email} created${realm === 'didi-clients' ? ' (Keycloak + PG)' : ' (Keycloak admin realm)'}`,
data: {
realm,
keycloakId,
email,
firstName,
lastName,
internetUserId,
rolesAssigned: kcResult.rolesAssigned,
rolesFailed: kcResult.rolesFailed,
groupAssigned: kcResult.groupAssigned,
planId: realm === 'didi-clients' ? planId : null,
bucketCreated,
warnings: kcResult.warnings,
pgError,
},
});
} catch (error: any) {
log.error('Error creating user:', error);
internalError(res, error);
}
});
// POST /api/admin/users/sync - Sync a Keycloak user to PostgreSQL
router.post('/users/sync', async (req: Request, res: Response) => {
try {
const { keycloakId } = req.body;
if (!keycloakId) {
res.status(400).json({ success: false, error: 'keycloakId is required' });
return;
}
// Check if already synced
const existing = await queryOne<{ id: number }>(`
SELECT internet_user_id as id FROM bos_sysadmin.user_credential WHERE keycloak_id = $1
`, [keycloakId]);
if (existing) {
res.status(409).json({ success: false, error: 'User already synced to PostgreSQL' });
return;
}
// Get user details from Keycloak
const keycloakUsers = await getKeycloakUsers();
const kcUser = keycloakUsers.get(keycloakId) as any;
if (!kcUser) {
res.status(404).json({ success: false, error: 'User not found in Keycloak' });
return;
}
// Seed users exist in PG with an email but a STALE keycloak_id (Keycloak was re-imported
// with different UUIDs). Link by email — update the keycloak_id — instead of inserting a
// duplicate (which hits the email unique constraint user_credential_ak2o → 500).
const byEmail = await queryOne<{ id: number }>(`
SELECT internet_user_id as id FROM bos_sysadmin.user_credential WHERE email = $1
`, [kcUser.email]);
if (byEmail) {
await query(
`UPDATE bos_sysadmin.user_credential SET keycloak_id = $1 WHERE internet_user_id = $2`,
[keycloakId, byEmail.id]
);
await logUserAudit(req, {
internetUserId: byEmail.id,
targetEmail: kcUser.email,
targetKeycloakId: keycloakId,
action: 'user.sync',
payload: { linked: true },
});
res.json({
success: true,
data: { internetUserId: byEmail.id, linked: true },
message: 'Utilizator PostgreSQL existent legat la Keycloak (keycloak_id actualizat)',
});
return;
}
// Create user in PostgreSQL
let newInternetUserId: number = 0;
await transaction(async (client) => {
// 1. Get next IDs
const personIdRes = await client.query("SELECT COALESCE(MAX(person_id), 0) + 1 as next_id FROM bos_subscriber.person");
const personId = personIdRes.rows[0].next_id;
const addressIdRes = await client.query("SELECT COALESCE(MAX(address_id), 0) + 1 as next_id FROM bos_subscriber.address");
const addressId = addressIdRes.rows[0].next_id;
const internetUserIdRes = await client.query("SELECT COALESCE(MAX(internet_user_id), 0) + 1 as next_id FROM bos_sysadmin.internet_user");
const internetUserId = internetUserIdRes.rows[0].next_id;
newInternetUserId = internetUserId;
const subscriptionIdRes = await client.query("SELECT COALESCE(MAX(subscription_id), 0) + 1 as next_id FROM bos_sysadmin.subscription");
const subscriptionId = subscriptionIdRes.rows[0].next_id;
// 2. Create person
await client.query("INSERT INTO bos_subscriber.person (person_id, person_type, status) VALUES ($1, 0, 1)", [personId]);
// 3. Create address
await client.query("INSERT INTO bos_subscriber.address (address_id, address_type) VALUES ($1, 0)", [addressId]);
// 4. Create persoana_fizica
await client.query(`
INSERT INTO bos_subscriber.persoana_fizica (individual_id, tip_persoana, nume, prenume, ro_official_address_id)
VALUES ($1, 2, $2, $3, $4)
`, [personId, kcUser.lastName || '', kcUser.firstName || '', addressId]);
// 5. Create internet_user with 100 credits
await client.query(`
INSERT INTO bos_sysadmin.internet_user (internet_user_id, person_id, credits_remained, credits_spent)
VALUES ($1, $2, 100, 0)
`, [internetUserId, personId]);
// 6. Create user_credential
await client.query(`
INSERT INTO bos_sysadmin.user_credential
(internet_user_id, email, keycloak_id, enrollment_type, cellular_phone_no, no_attempts_failed, subscription_status, activation_date)
VALUES ($1, $2, $3, 1, '', 0, 1, CURRENT_DATE)
`, [internetUserId, kcUser.email, keycloakId]);
// 7. Create subscription with Free plan
await client.query(`
INSERT INTO bos_sysadmin.subscription
(subscription_id, internet_user_id, subscription_plan_id, subscription_status, is_active, activation_date, deactivation_date, created_time, updated_time)
VALUES ($1, $2, 1, 1, true, CURRENT_DATE, '2099-12-31', CURRENT_DATE, CURRENT_DATE)
`, [subscriptionId, internetUserId]);
});
// 8. Create MinIO bucket for user
let bucketCreated = false;
try {
const bucketResult = await createUserBucket(newInternetUserId, kcUser.email, 1, 'Free', 1);
bucketCreated = bucketResult.created;
} catch (bucketError: any) {
log.error(`[ADMIN] Failed to create MinIO bucket for synced user ${newInternetUserId}:`, bucketError.message);
}
await logUserAudit(req, {
internetUserId: newInternetUserId,
targetEmail: kcUser.email,
targetKeycloakId: keycloakId,
action: 'user.sync',
payload: { bucketCreated, plan: 'Free', creditsRemained: 100 },
});
res.json({
success: true,
message: `User ${kcUser.email} synced to PostgreSQL with Free plan and 100 credits`,
bucketCreated
});
} catch (error: any) {
log.error('Error syncing user:', error);
internalError(res, error);
}
});
// GET /api/admin/users/:id - Get single user details
router.get('/users/:id', async (req: Request, res: Response) => {
try {
const userId = parseInt(req.params.id);
if (isNaN(userId)) {
res.status(400).json({ success: false, error: 'Invalid user ID' });
return;
}
const user = await queryOne<UserListItem>(`
SELECT
iu.internet_user_id as "id",
uc.email,
pf.prenume as "firstName",
pf.nume as "lastName",
uc.cellular_phone_no as "phone",
iu.credits_remained as "creditsRemained",
iu.credits_spent as "creditsSpent",
s.subscription_plan_id as "subscriptionPlanId",
sp.plan_name as "subscriptionPlanName",
uc.subscription_status as "subscriptionStatus",
COALESCE(s.is_active, true) as "isActive",
uc.keycloak_id as "keycloakId",
s.created_time as "createdAt"
FROM bos_sysadmin.internet_user iu
LEFT JOIN bos_sysadmin.user_credential uc ON uc.internet_user_id = iu.internet_user_id
LEFT JOIN bos_subscriber.persoana_fizica pf ON iu.person_id = pf.individual_id
LEFT JOIN bos_sysadmin.subscription s ON iu.internet_user_id = s.internet_user_id
LEFT JOIN bos_sysadmin.subscription_plan sp ON s.subscription_plan_id = sp.subscription_plan_id
WHERE iu.internet_user_id = $1
`, [userId]);
if (!user) {
res.status(404).json({ success: false, error: 'User not found' });
return;
}
res.json({ success: true, data: user });
} catch (error: any) {
log.error('Error fetching user:', error);
internalError(res, error);
}
});
// PUT /api/admin/users/:id - Update user profile
router.put('/users/:id', async (req: Request, res: Response) => {
try {
const userId = parseInt(req.params.id);
const { firstName, lastName, phone, isActive, creditsRemained } = req.body;
if (isNaN(userId)) {
res.status(400).json({ success: false, error: 'Invalid user ID' });
return;
}
const existingUser = await queryOne<{ personId: number }>(`
SELECT iu.person_id as "personId"
FROM bos_sysadmin.internet_user iu
WHERE iu.internet_user_id = $1
`, [userId]);
if (!existingUser) {
res.status(404).json({ success: false, error: 'User not found' });
return;
}
await transaction(async (client) => {
// Update persoana_fizica (first/last name)
if (firstName !== undefined || lastName !== undefined) {
const updateFields: string[] = [];
const updateValues: any[] = [];
let paramIndex = 1;
if (firstName !== undefined) {
updateFields.push(`prenume = $${paramIndex}`);
updateValues.push(firstName);
paramIndex++;
}
if (lastName !== undefined) {
updateFields.push(`nume = $${paramIndex}`);
updateValues.push(lastName);
paramIndex++;
}
if (updateFields.length > 0) {
updateValues.push(existingUser.personId);
await client.query(`
UPDATE bos_subscriber.persoana_fizica
SET ${updateFields.join(', ')}
WHERE individual_id = $${paramIndex}
`, updateValues);
}
}
// Update user_credential (phone)
if (phone !== undefined) {
await client.query(`
UPDATE bos_sysadmin.user_credential
SET cellular_phone_no = $1
WHERE internet_user_id = $2
`, [phone, userId]);
}
// Update subscription (is_active)
if (isActive !== undefined) {
await client.query(`
UPDATE bos_sysadmin.subscription
SET is_active = $1
WHERE internet_user_id = $2
`, [isActive, userId]);
}
// Update internet_user (credits)
if (creditsRemained !== undefined) {
await client.query(`
UPDATE bos_sysadmin.internet_user
SET credits_remained = $1
WHERE internet_user_id = $2
`, [creditsRemained, userId]);
}
});
// Phase U — audit the update with the diff that was applied.
const targetMeta = await queryOne<{ keycloakId: string | null; email: string | null }>(
`SELECT keycloak_id as "keycloakId", email
FROM bos_sysadmin.user_credential WHERE internet_user_id = $1`,
[userId],
);
await logUserAudit(req, {
internetUserId: userId,
targetEmail: targetMeta?.email ?? null,
targetKeycloakId: targetMeta?.keycloakId ?? null,
action: 'user.update',
payload: {
changes: {
firstName,
lastName,
phone,
isActive,
creditsRemained,
},
},
});
res.json({ success: true, message: 'User updated successfully' });
} catch (error: any) {
log.error('Error updating user:', error);
internalError(res, error);
}
});
// Helper: Delete user from Keycloak
async function deleteKeycloakUser(keycloakId: string): Promise<boolean> {
try {
const token = await getKeycloakAdminToken();
const keycloakUrl = requireEnv('KEYCLOAK_URL');
const response = await fetch(`${keycloakUrl}/admin/realms/didi-clients/users/${keycloakId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
return response.status === 204;
} catch (error) {
log.error('Failed to delete user from Keycloak:', error);
return false;
}
}
// DELETE /api/admin/users/:id - Hard delete from PostgreSQL and Keycloak
router.delete('/users/:id', async (req: Request, res: Response) => {
try {
const userId = parseInt(req.params.id);
if (isNaN(userId)) {
res.status(400).json({ success: false, error: 'Invalid user ID' });
return;
}
// Get user with keycloak_id
const existingUser = await queryOne<{ id: number; keycloakId: string; personId: number }>(`
SELECT
iu.internet_user_id as id,
uc.keycloak_id as "keycloakId",
iu.person_id as "personId"
FROM bos_sysadmin.internet_user iu
LEFT JOIN bos_sysadmin.user_credential uc ON uc.internet_user_id = iu.internet_user_id
WHERE iu.internet_user_id = $1
`, [userId]);
if (!existingUser) {
res.status(404).json({ success: false, error: 'User not found' });
return;
}
// Delete from Keycloak first
let keycloakDeleted = false;
if (existingUser.keycloakId) {
keycloakDeleted = await deleteKeycloakUser(existingUser.keycloakId);
}
// Delete from PostgreSQL (in correct order due to foreign keys)
await transaction(async (client) => {
// 1. Delete subscription
await client.query('DELETE FROM bos_sysadmin.subscription WHERE internet_user_id = $1', [userId]);
// 2. Delete user_credential
await client.query('DELETE FROM bos_sysadmin.user_credential WHERE internet_user_id = $1', [userId]);
// 3. Delete internet_user
await client.query('DELETE FROM bos_sysadmin.internet_user WHERE internet_user_id = $1', [userId]);
// 4. Delete persoana_fizica (if exists and not shared)
if (existingUser.personId) {
await client.query('DELETE FROM bos_subscriber.persoana_fizica WHERE individual_id = $1', [existingUser.personId]);
}
});
// Delete MinIO bucket for user
let bucketDeleted = false;
try {
const bucketName = `user-${userId}`;
const minioClient = getMinioClient();
const bucketExists = await minioClient.bucketExists(bucketName);
if (bucketExists) {
// First, delete all objects in the bucket
const objectsList: string[] = [];
const objectsStream = minioClient.listObjects(bucketName, '', true);
await new Promise<void>((resolve, reject) => {
objectsStream.on('data', (obj) => {
if (obj.name) objectsList.push(obj.name);
});
objectsStream.on('error', reject);
objectsStream.on('end', resolve);
});
if (objectsList.length > 0) {
await minioClient.removeObjects(bucketName, objectsList);
}
// Then delete the bucket
await minioClient.removeBucket(bucketName);
bucketDeleted = true;
log.info(`[ADMIN] Deleted MinIO bucket: ${bucketName}`);
}
} catch (bucketError: any) {
log.error(`[ADMIN] Failed to delete MinIO bucket for user ${userId}:`, bucketError.message);
// Don't fail the delete operation if bucket deletion fails
}
// Phase U — audit the deletion. We log here even if some sub-steps
// (Keycloak / bucket) failed; the audit row reflects what we attempted.
await logUserAudit(req, {
internetUserId: userId,
targetEmail: null,
targetKeycloakId: existingUser.keycloakId ?? null,
action: 'user.delete',
payload: { keycloakDeleted, bucketDeleted },
});
res.json({
success: true,
message: 'User deleted successfully',
keycloakDeleted,
bucketDeleted
});
} catch (error: any) {
log.error('Error deleting user:', error);
internalError(res, error);
}
});
// PUT /api/admin/users/:id/subscription - Change user subscription plan
router.put('/users/:id/subscription', async (req: Request, res: Response) => {
try {
const userId = parseInt(req.params.id);
const { planId, creditsRemained } = req.body;
if (isNaN(userId)) {
res.status(400).json({ success: false, error: 'Invalid user ID' });
return;
}
if (!planId) {
res.status(400).json({ success: false, error: 'Plan ID is required' });
return;
}
const existingUser = await queryOne<{ id: number }>(`
SELECT internet_user_id as id FROM bos_sysadmin.internet_user WHERE internet_user_id = $1
`, [userId]);
if (!existingUser) {
res.status(404).json({ success: false, error: 'User not found' });
return;
}
const plan = await queryOne<SubscriptionPlan & { storageLimitGb: number }>(`
SELECT subscription_plan_id as id, plan_name as name, credits_per_cycle as "creditsIncluded", storage_limit_gb as "storageLimitGb"
FROM bos_sysadmin.subscription_plan
WHERE subscription_plan_id = $1
`, [planId]);
if (!plan) {
res.status(404).json({ success: false, error: 'Subscription plan not found' });
return;
}
await transaction(async (client) => {
await client.query(`
UPDATE bos_sysadmin.subscription
SET subscription_plan_id = $1, updated_time = CURRENT_DATE
WHERE internet_user_id = $2
`, [planId, userId]);
const newCredits = creditsRemained !== undefined ? creditsRemained : plan.creditsIncluded;
await client.query(`
UPDATE bos_sysadmin.internet_user
SET credits_remained = $1
WHERE internet_user_id = $2
`, [newCredits, userId]);
});
// Update MinIO bucket metadata with new plan info
let bucketUpdated = false;
try {
bucketUpdated = await updateUserBucketMetadata(
userId,
planId,
plan.name,
plan.storageLimitGb
);
if (bucketUpdated) {
log.info(`[ADMIN] Updated MinIO bucket metadata for user ${userId} to plan ${plan.name}`);
}
} catch (bucketError: any) {
log.error(`[ADMIN] Failed to update MinIO bucket metadata for user ${userId}:`, bucketError.message);
// Don't fail the subscription change if bucket update fails
}
const targetMeta = await queryOne<{ keycloakId: string | null; email: string | null }>(
`SELECT keycloak_id as "keycloakId", email
FROM bos_sysadmin.user_credential WHERE internet_user_id = $1`,
[userId],
);
await logUserAudit(req, {
internetUserId: userId,
targetEmail: targetMeta?.email ?? null,
targetKeycloakId: targetMeta?.keycloakId ?? null,
action: 'user.subscription',
payload: {
planId,
planName: plan.name,
creditsRemained,
bucketUpdated,
},
});
res.json({
success: true,
message: `Subscription changed to ${plan.name}`,
bucketUpdated
});
} catch (error: any) {
log.error('Error updating subscription:', error);
internalError(res, error);
}
});
// GET /api/admin/plans - List all subscription plans
// PUT /api/admin/users/:id/email-verified - Update email verification status in Keycloak
router.put('/users/:id/email-verified', async (req: Request, res: Response) => {
try {
const userId = parseInt(req.params.id);
const { emailVerified } = req.body;
if (isNaN(userId)) {
res.status(400).json({ success: false, error: 'Invalid user ID' });
return;
}
if (typeof emailVerified !== 'boolean') {
res.status(400).json({ success: false, error: 'emailVerified must be a boolean' });
return;
}
// Get keycloak_id for this user
const user = await queryOne<{ keycloakId: string }>(`
SELECT uc.keycloak_id as "keycloakId"
FROM bos_sysadmin.user_credential uc
JOIN bos_sysadmin.internet_user iu ON uc.internet_user_id = iu.internet_user_id
WHERE iu.internet_user_id = $1
`, [userId]);
if (!user || !user.keycloakId) {
res.status(404).json({ success: false, error: 'User not found or no Keycloak ID' });
return;
}
const success = await updateKeycloakEmailVerified(user.keycloakId, emailVerified);
await logUserAudit(req, {
internetUserId: userId,
targetEmail: null,
targetKeycloakId: user.keycloakId,
action: 'user.email_verified',
payload: { emailVerified, success },
});
if (success) {
res.json({ success: true, message: `Email verification set to ${emailVerified}` });
} else {
res.status(500).json({ success: false, error: 'Failed to update Keycloak' });
}
} catch (error: any) {
log.error('Error updating email verification:', error);
internalError(res, error);
}
});
export default router;

View file

@ -0,0 +1,154 @@
/**
* Auth route helpers JWT decode + Keycloak token + credit cost lookup.
*
* Extracted from the original 1298-line auth.ts so individual route modules
* (me-profile, registration, credits, email-verify) can import the bits they
* need without each redeclaring constants and helpers.
*
* Note: extractJWTPayload does NOT verify the signature Kong is expected to
* verify upstream and pass through the token. We just decode the payload to
* read identity fields.
*/
import dotenv from 'dotenv';
import type { PoolClient } from 'pg';
import { requireEnv, optionalEnv } from '../../config/env';
import { getKeycloakAdminToken as fetchKeycloakAdminToken } from '../../config/keycloak-admin';
import { log } from '../../config/logger';
dotenv.config();
// Keycloak Admin API config — read at module load (fail-fast on missing).
export const KEYCLOAK_URL = requireEnv('KEYCLOAK_URL');
export const KEYCLOAK_REALM = optionalEnv('KEYCLOAK_REALM', 'didi-clients');
export const MOBILE_APP_SCHEME = optionalEnv('MOBILE_APP_SCHEME', 'didi://');
export const WEB_APP_URL = optionalEnv('WEB_APP_URL', 'https://didi365.eu');
// ============================================================================
// INTERFACES
// ============================================================================
export interface JWTPayload {
sub: string; // keycloak_id
email: string;
given_name?: string;
family_name?: string;
email_verified?: boolean;
preferred_username?: string;
}
export interface RegisterData {
firstName: string;
lastName: string;
phone?: string;
city?: string;
county?: string;
}
export interface UserProfile {
personId: number;
internetUserId: number;
email: string;
firstName: string;
lastName: string;
phone: string;
creditsRemained: number;
creditsSpent: number;
creditsTotal: number;
creditsPerCycle: number;
subscriptionPlanId: number;
subscriptionPlanName: string;
subscriptionStatus: number;
isActive: boolean;
keycloakId: string;
}
// ============================================================================
// HELPERS
// ============================================================================
/** Extract JWT payload from Authorization header (basic decode, not verify). */
export function extractJWTPayload(authHeader: string | undefined): JWTPayload | null {
if (!authHeader || !authHeader.startsWith('Bearer ')) return null;
const token = authHeader.substring(7);
const parts = token.split('.');
if (parts.length !== 3) return null;
try {
return JSON.parse(Buffer.from(parts[1], 'base64').toString('utf8')) as JWTPayload;
} catch {
return null;
}
}
/** Manual sequence: SELECT MAX + 1. Used for tables without auto-increment. */
export async function getNextId(
client: PoolClient,
table: string,
idColumn: string,
schema: string = 'bos_sysadmin',
): Promise<number> {
const result = await client.query(
`SELECT COALESCE(MAX(${idColumn}), 0) + 1 as next_id FROM ${schema}.${table}`,
);
return result.rows[0].next_id;
}
/**
* Wraps the shared Keycloak admin token helper to preserve the legacy nullable
* return call sites here treat null as "skip Keycloak step".
*/
export async function getKeycloakAdminToken(): Promise<string | null> {
try {
return await fetchKeycloakAdminToken();
} catch (error) {
log.error('[AUTH] Error getting Keycloak admin token:', error);
return null;
}
}
/** Decode a Keycloak action token (JWT) without verification. */
export function decodeActionToken(
token: string,
): { sub?: string; typ?: string; azp?: string; email?: string } | null {
try {
const parts = token.split('.');
if (parts.length !== 3) return null;
return JSON.parse(Buffer.from(parts[1], 'base64').toString('utf8'));
} catch (e) {
log.error('[AUTH] Failed to decode action token:', e);
return null;
}
}
/** Detect if the request comes from a mobile browser (used for redirect target). */
export function isMobileRequest(userAgent: string | undefined): boolean {
if (!userAgent) return false;
return /android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test(userAgent.toLowerCase());
}
// ============================================================================
// CREDIT COST LOOKUP (used by /credits + /use-credit + internal endpoints)
// ============================================================================
const DEFAULT_CREDIT_COSTS: Record<string, number> = {
text: 1, url: 1, image: 2, audio: 3, video: 5,
};
interface PlanWithCosts {
cost_text?: number;
cost_url?: number;
cost_image?: number;
cost_audio?: number;
cost_video?: number;
}
/** Read credit cost for media_type from plan columns, fallback to defaults. */
export function getCreditCost(plan: PlanWithCosts | null | undefined, mediaType: string): number {
if (!plan) return DEFAULT_CREDIT_COSTS[mediaType] || 1;
const colMap: Record<string, keyof PlanWithCosts> = {
text: 'cost_text', url: 'cost_url', image: 'cost_image', audio: 'cost_audio', video: 'cost_video',
};
const col = colMap[mediaType];
return col && plan[col] != null ? plan[col] as number : DEFAULT_CREDIT_COSTS[mediaType] || 1;
}

View file

@ -0,0 +1,335 @@
/**
* Auth: credit endpoints (user-facing + internal).
* GET /credits, POST /use-credit
* POST /internal/check-credits, /internal/deduct-credits, /internal/get-bucket-info
*/
import { Router, Request, Response } from 'express';
import { log } from '../../config/logger';
import { internalError } from '../../config/error-response';
import pool from '../../config/database';
import {
extractJWTPayload,
getCreditCost,
} from './_helpers';
const router = Router();
/**
* GET /api/auth/credits
* Returns just the credits info for the current user
*/
router.get('/credits', async (req: Request, res: Response) => {
try {
const jwtPayload = extractJWTPayload(req.headers.authorization);
if (!jwtPayload || !jwtPayload.sub) {
return res.status(401).json({
success: false,
error: 'Missing or invalid Authorization header'
});
}
const result = await pool.query(`
SELECT
iu.credits_remained,
iu.credits_spent,
sp.plan_name,
sp.credits_per_cycle
FROM bos_sysadmin.user_credential uc
JOIN bos_sysadmin.internet_user iu ON uc.internet_user_id = iu.internet_user_id
LEFT JOIN bos_sysadmin.subscription s ON iu.internet_user_id = s.internet_user_id AND s.is_active = true
LEFT JOIN bos_sysadmin.subscription_plan sp ON s.subscription_plan_id = sp.subscription_plan_id
WHERE uc.keycloak_id = $1
`, [jwtPayload.sub]);
if (result.rows.length === 0) {
return res.status(404).json({ success: false, error: 'User not found' });
}
const user = result.rows[0];
res.json({
success: true,
data: {
creditsRemained: user.credits_remained,
creditsSpent: user.credits_spent,
planName: user.plan_name || 'Free',
creditsPerCycle: user.credits_per_cycle || 100
}
});
} catch (error: any) {
log.error('Error in /auth/credits:', error);
internalError(res, error);
}
});
/**
* POST /api/auth/use-credit
* Decrements user credits by specified amount (default 1)
*/
router.post('/use-credit', async (req: Request, res: Response) => {
try {
const jwtPayload = extractJWTPayload(req.headers.authorization);
if (!jwtPayload || !jwtPayload.sub) {
return res.status(401).json({
success: false,
error: 'Missing or invalid Authorization header'
});
}
const amount = req.body.amount || 1;
// First check if user has enough credits
const checkResult = await pool.query(`
SELECT iu.internet_user_id, iu.credits_remained
FROM bos_sysadmin.user_credential uc
JOIN bos_sysadmin.internet_user iu ON uc.internet_user_id = iu.internet_user_id
WHERE uc.keycloak_id = $1
`, [jwtPayload.sub]);
if (checkResult.rows.length === 0) {
return res.status(404).json({ success: false, error: 'User not found' });
}
const user = checkResult.rows[0];
if (user.credits_remained < amount) {
return res.status(402).json({
success: false,
error: 'Insufficient credits',
creditsRemained: user.credits_remained,
creditsRequired: amount
});
}
// Decrement credits
const updateResult = await pool.query(`
UPDATE bos_sysadmin.internet_user
SET credits_remained = credits_remained - $1,
credits_spent = credits_spent + $1
WHERE internet_user_id = $2
RETURNING credits_remained, credits_spent
`, [amount, user.internet_user_id]);
res.json({
success: true,
data: {
creditsUsed: amount,
creditsRemained: updateResult.rows[0].credits_remained,
creditsSpent: updateResult.rows[0].credits_spent
}
});
} catch (error: any) {
log.error('Error in /auth/use-credit:', error);
internalError(res, error);
}
});
// (Internal endpoints below — for agent-v3 service-to-service calls)
// (DEFAULT_CREDIT_COSTS + getCreditCost moved to ./_helpers in this PR)
/**
* POST /api/auth/internal/check-credits
* Checks if user has enough credits for analysis (service-to-service)
* Body: { keycloak_id, media_type }
*/
router.post('/internal/check-credits', async (req: Request, res: Response) => {
try {
const { keycloak_id, media_type } = req.body;
if (!keycloak_id) {
return res.status(400).json({ success: false, error: 'keycloak_id is required' });
}
const result = await pool.query(`
SELECT
iu.internet_user_id,
iu.credits_remained,
sp.plan_name,
sp.plan_type,
sp.max_images,
sp.max_video_minutes,
sp.cost_text, sp.cost_url, sp.cost_image, sp.cost_audio, sp.cost_video
FROM bos_sysadmin.user_credential uc
JOIN bos_sysadmin.internet_user iu ON uc.internet_user_id = iu.internet_user_id
LEFT JOIN bos_sysadmin.subscription s ON iu.internet_user_id = s.internet_user_id AND s.is_active = true
LEFT JOIN bos_sysadmin.subscription_plan sp ON s.subscription_plan_id = sp.subscription_plan_id
WHERE uc.keycloak_id = $1
`, [keycloak_id]);
if (result.rows.length === 0) {
return res.status(404).json({ success: false, error: 'User not found' });
}
const user = result.rows[0];
const creditCost = getCreditCost(user, media_type);
const hasEnough = user.credits_remained >= creditCost;
res.json({
success: true,
data: {
hasEnoughCredits: hasEnough,
creditsRemained: user.credits_remained,
creditCost: creditCost,
planName: user.plan_name || 'Free',
planType: user.plan_type || 1,
mediaType: media_type
}
});
} catch (error: any) {
log.error('Error in /auth/internal/check-credits:', error);
internalError(res, error);
}
});
/**
* POST /api/auth/internal/deduct-credits
* Deducts credits after successful analysis (service-to-service)
* Body: { keycloak_id, media_type, session_id }
*/
router.post('/internal/deduct-credits', async (req: Request, res: Response) => {
try {
const { keycloak_id, media_type, session_id } = req.body;
if (!keycloak_id) {
return res.status(400).json({ success: false, error: 'keycloak_id is required' });
}
// Get user + plan costs in one query
const checkResult = await pool.query(`
SELECT iu.internet_user_id, iu.credits_remained,
sp.cost_text, sp.cost_url, sp.cost_image, sp.cost_audio, sp.cost_video
FROM bos_sysadmin.user_credential uc
JOIN bos_sysadmin.internet_user iu ON uc.internet_user_id = iu.internet_user_id
LEFT JOIN bos_sysadmin.subscription s ON iu.internet_user_id = s.internet_user_id AND s.is_active = true
LEFT JOIN bos_sysadmin.subscription_plan sp ON s.subscription_plan_id = sp.subscription_plan_id
WHERE uc.keycloak_id = $1
`, [keycloak_id]);
if (checkResult.rows.length === 0) {
return res.status(404).json({ success: false, error: 'User not found' });
}
const user = checkResult.rows[0];
const creditCost = getCreditCost(user, media_type);
if (user.credits_remained < creditCost) {
return res.status(402).json({
success: false,
error: 'Insufficient credits',
creditsRemained: user.credits_remained,
creditCost: creditCost
});
}
// Deduct credits
const updateResult = await pool.query(`
UPDATE bos_sysadmin.internet_user
SET credits_remained = credits_remained - $1,
credits_spent = credits_spent + $1
WHERE internet_user_id = $2
RETURNING credits_remained, credits_spent
`, [creditCost, user.internet_user_id]);
// Log usage in ai_credit_usage table
try {
await pool.query(`
INSERT INTO bos_sysadmin.ai_credit_usage
(internet_user_id, session_id, media_type, credits_used, created_at)
VALUES ($1, $2, $3, $4, NOW())
`, [user.internet_user_id, session_id || null, media_type, creditCost]);
} catch (usageError) {
log.warn('Could not log to ai_credit_usage:', usageError);
}
log.info(`[Credits] Deducted ${creditCost} credits for ${media_type} from user ${keycloak_id}`);
res.json({
success: true,
data: {
creditsDeducted: creditCost,
creditsRemained: updateResult.rows[0].credits_remained,
creditsSpent: updateResult.rows[0].credits_spent,
mediaType: media_type,
sessionId: session_id
}
});
} catch (error: any) {
log.error('Error in /auth/internal/deduct-credits:', error);
internalError(res, error);
}
});
/**
* POST /api/auth/internal/get-bucket-info
* Returns user's MinIO bucket info for uploads (service-to-service)
* Body: { keycloak_id, mime_type }
*/
router.post('/internal/get-bucket-info', async (req: Request, res: Response) => {
try {
const { keycloak_id, mime_type } = req.body;
log.info(`[get-bucket-info] keycloak_id=${keycloak_id}, mime_type=${mime_type}`);
if (!keycloak_id) {
return res.status(400).json({ success: false, error: 'keycloak_id is required' });
}
// Get internet_user_id from keycloak_id
const result = await pool.query(`
SELECT iu.internet_user_id
FROM bos_sysadmin.user_credential uc
JOIN bos_sysadmin.internet_user iu ON uc.internet_user_id = iu.internet_user_id
WHERE uc.keycloak_id = $1
`, [keycloak_id]);
if (result.rows.length === 0) {
return res.status(404).json({ success: false, error: 'User not found' });
}
const internetUserId = result.rows[0].internet_user_id;
// Single-bucket architecture (post 2026-04-25 migration):
// bucketName = 'didi-prod' (the only bucket we have on cluster)
// folder = 'users/{id}/{mimeFolder}' (everything is a prefix inside bucketName)
// fullPath = bucketName + '/' + folder
// This shape stays backward compatible with media-service.ts callers — they
// build the object key as `${folder}/${filename}` regardless of whether
// `folder` contains slashes.
const bucketName = process.env.MINIO_BUCKET || 'didi-prod';
// Determine MIME-specific subfolder
let mimeFolder = 'text-files';
if (mime_type) {
if (mime_type.startsWith('image/')) mimeFolder = 'images';
else if (mime_type.startsWith('video/')) mimeFolder = 'videos';
else if (mime_type.startsWith('audio/')) mimeFolder = 'audio-files';
else if (mime_type.startsWith('text/')) mimeFolder = 'text-files';
else if (mime_type.includes('pdf') || mime_type.includes('document')) mimeFolder = 'text-files';
}
const folder = `users/${internetUserId}/${mimeFolder}`;
res.json({
success: true,
data: {
internetUserId,
bucketName,
folder,
fullPath: `${bucketName}/${folder}`,
}
});
} catch (error: any) {
log.error('Error in /auth/internal/get-bucket-info:', error);
internalError(res, error);
}
});
export default router;

View file

@ -0,0 +1,415 @@
/**
* Auth: /verify-email (GET + POST) custom email verification endpoint that
* bypasses Keycloak's session requirement (Keycloak's built-in verify-email
* page requires an active session, which doesn't exist for first-time login
* via the action token sent in email).
*
* GET /verify-email landing page reached from email link
* POST /verify-email submission from the landing page form
*/
import { Router, Request, Response } from 'express';
import { log } from '../../config/logger';
import {
KEYCLOAK_URL,
KEYCLOAK_REALM,
MOBILE_APP_SCHEME,
WEB_APP_URL,
decodeActionToken,
getKeycloakAdminToken,
isMobileRequest,
} from './_helpers';
const router = Router();
// ============================================================================
// EMAIL VERIFICATION (Custom endpoint to bypass Keycloak session requirement)
// ============================================================================
/**
* GET /api/auth/verify-email
* Shows a confirmation page - does NOT verify automatically!
* This prevents WhatsApp/Telegram preview bots from verifying the email.
*
* Query params:
* - key: The Keycloak action token from the email link
*/
router.get('/verify-email', async (req: Request, res: Response) => {
const token = req.query.key as string;
log.info('[AUTH] verify-email GET (show confirmation page):', token ? `${token.substring(0, 50)}...` : 'none');
if (!token) {
return res.status(400).send(`
<!DOCTYPE html>
<html>
<head><title>Eroare verificare</title></head>
<body style="font-family: Arial; text-align: center; padding: 50px;">
<h1 style="color: #e53935;"> Link invalid</h1>
<p>Link-ul de verificare este invalid sau expirat.</p>
<p><a href="${WEB_APP_URL}">Înapoi la aplicație</a></p>
</body>
</html>
`);
}
// Decode token to get email for display (but don't verify yet!)
const tokenPayload = decodeActionToken(token);
const userEmail = tokenPayload?.email || '';
// Show confirmation page with a button - verification happens on POST
return res.send(`
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Verificare Email - DIDI</title>
<style>
body {
font-family: 'Segoe UI', Arial, sans-serif;
background: #050510;
min-height: 100vh;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
}
.container {
background: #0f0f1a;
border-radius: 16px;
padding: 50px;
text-align: center;
max-width: 420px;
box-shadow: 0 8px 32px rgba(124, 58, 237, 0.3);
border: 1px solid rgba(124, 58, 237, 0.2);
}
.logo {
font-size: 36px;
font-weight: 800;
color: #ffffff;
letter-spacing: 0.05em;
margin-bottom: 30px;
}
.email-icon {
width: 80px;
height: 80px;
margin: 0 auto 24px;
background: linear-gradient(135deg, #7c3aed 0%, #5b21b6 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8px 24px rgba(124, 58, 237, 0.4);
}
.email-icon svg {
width: 40px;
height: 40px;
fill: white;
}
h1 {
color: #7c3aed;
margin: 0 0 12px;
font-size: 28px;
font-weight: 700;
}
.subtitle {
color: #E8E8E8;
margin: 0 0 30px;
font-size: 16px;
}
.email-display {
color: #7c3aed;
font-weight: 600;
background: rgba(124, 58, 237, 0.1);
padding: 8px 16px;
border-radius: 8px;
display: inline-block;
margin-bottom: 30px;
}
.verify-btn {
background: linear-gradient(135deg, #7c3aed 0%, #5b21b6 100%);
color: white;
border: none;
padding: 16px 48px;
font-size: 18px;
font-weight: 600;
border-radius: 12px;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
width: 100%;
max-width: 300px;
}
.verify-btn:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(124, 58, 237, 0.4);
}
.verify-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.loader {
display: inline-block;
width: 20px;
height: 20px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-right: 8px;
vertical-align: middle;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.error-msg {
color: #e53935;
margin-top: 20px;
display: none;
}
</style>
</head>
<body>
<div class="container">
<div class="logo">didi</div>
<div class="email-icon">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/>
</svg>
</div>
<h1>Verificare Email</h1>
<p class="subtitle">Apasă butonul pentru a-ți confirma adresa de email</p>
${userEmail ? `<div class="email-display">${userEmail}</div>` : ''}
<form id="verifyForm" method="POST" action="/api/auth/verify-email">
<input type="hidden" name="key" value="${token}">
<button type="submit" class="verify-btn" id="verifyBtn">
Verifică Email
</button>
</form>
<p class="error-msg" id="errorMsg"></p>
</div>
<script>
document.getElementById('verifyForm').addEventListener('submit', function(e) {
var btn = document.getElementById('verifyBtn');
btn.disabled = true;
btn.innerHTML = '<span class="loader"></span> Se verifică...';
});
</script>
</body>
</html>
`);
});
/**
* POST /api/auth/verify-email
* Actually verifies the email - called when user clicks the button
*/
router.post('/verify-email', async (req: Request, res: Response) => {
const token = req.body.key as string;
log.info('[AUTH] verify-email POST (actual verification):', token ? `${token.substring(0, 50)}...` : 'none');
if (!token) {
return res.status(400).send(`
<!DOCTYPE html>
<html>
<head><title>Eroare verificare</title></head>
<body style="font-family: Arial; text-align: center; padding: 50px;">
<h1 style="color: #e53935;"> Link invalid</h1>
<p>Link-ul de verificare este invalid sau expirat.</p>
<p><a href="${WEB_APP_URL}">Înapoi la aplicație</a></p>
</body>
</html>
`);
}
try {
// 1. Decode the action token to get user info
const tokenPayload = decodeActionToken(token);
if (!tokenPayload || !tokenPayload.sub) {
log.error('[AUTH] Invalid token payload:', tokenPayload);
return res.status(400).send(`
<!DOCTYPE html>
<html>
<head><title>Eroare verificare</title></head>
<body style="font-family: Arial; text-align: center; padding: 50px;">
<h1 style="color: #e53935;"> Token invalid</h1>
<p>Token-ul de verificare nu poate fi procesat.</p>
<p><a href="${WEB_APP_URL}">Înapoi la aplicație</a></p>
</body>
</html>
`);
}
const userId = tokenPayload.sub;
const clientId = tokenPayload.azp; // Client that initiated the action (didi-mobile-app, didi-web-app)
log.info(`[AUTH] Verifying email for user: ${userId}, client: ${clientId}`);
// 2. Get Keycloak admin token
const adminToken = await getKeycloakAdminToken();
if (!adminToken) {
log.error('[AUTH] Failed to get admin token');
return res.status(500).send(`
<!DOCTYPE html>
<html>
<head><title>Eroare server</title></head>
<body style="font-family: Arial; text-align: center; padding: 50px;">
<h1 style="color: #e53935;"> Eroare internă</h1>
<p>Nu s-a putut conecta la serverul de autentificare.</p>
<p>Te rugăm încerci din nou mai târziu.</p>
</body>
</html>
`);
}
// 3. Verify email via Keycloak Admin API
const verifyResponse = await fetch(
`${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/users/${userId}`,
{
method: 'PUT',
headers: {
'Authorization': `Bearer ${adminToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
emailVerified: true,
requiredActions: [] // Clear VERIFY_EMAIL required action
})
}
);
if (!verifyResponse.ok) {
const errorText = await verifyResponse.text();
log.error('[AUTH] Failed to verify email:', verifyResponse.status, errorText);
return res.status(500).send(`
<!DOCTYPE html>
<html>
<head><title>Eroare verificare</title></head>
<body style="font-family: Arial; text-align: center; padding: 50px;">
<h1 style="color: #e53935;"> Verificare eșuată</h1>
<p>Nu s-a putut verifica adresa de email.</p>
<p>Eroare: ${verifyResponse.status}</p>
<p><a href="${WEB_APP_URL}">Înapoi la aplicație</a></p>
</body>
</html>
`);
}
log.info(`[AUTH] Email verified successfully for user: ${userId}`);
// 4. Determine redirect based on client or user-agent
const userAgent = req.headers['user-agent'];
const isMobile = isMobileRequest(userAgent) || clientId === 'didi-mobile-app';
// Show success page with redirect options
const mobileLink = `${MOBILE_APP_SCHEME}email-verified`;
const webLink = `${WEB_APP_URL}/?verified=true`;
// Auto-redirect based on platform
const autoRedirectUrl = isMobile ? mobileLink : webLink;
return res.send(`
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Email verificat!</title>
<style>
body {
font-family: 'Segoe UI', Arial, sans-serif;
background: #050510;
min-height: 100vh;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
}
.container {
background: #0f0f1a;
border-radius: 16px;
padding: 50px;
text-align: center;
max-width: 420px;
box-shadow: 0 8px 32px rgba(124, 58, 237, 0.3);
border: 1px solid rgba(124, 58, 237, 0.2);
}
.success-icon {
font-size: 72px;
margin-bottom: 24px;
color: #10b981;
}
h1 {
color: #7c3aed;
margin: 0 0 12px;
font-size: 28px;
font-weight: 700;
}
.subtitle {
color: #E8E8E8;
margin: 0 0 30px;
font-size: 16px;
}
.redirect-note {
color: #9CA3AF;
font-size: 14px;
margin-top: 20px;
}
.loader {
display: inline-block;
width: 20px;
height: 20px;
border: 2px solid rgba(124, 58, 237, 0.3);
border-top-color: #7c3aed;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-left: 8px;
vertical-align: middle;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
</head>
<body>
<div class="container">
<div class="success-icon"></div>
<h1>Email verificat!</h1>
<p class="subtitle">Contul tău DIDI a fost activat cu succes.</p>
<p class="redirect-note">Vei fi redirecționat în aplicația DIDI<span class="loader"></span></p>
</div>
<script>
setTimeout(function() {
window.location.href = "${autoRedirectUrl}";
}, 2000);
${isMobile ? `
setTimeout(function() {
window.location.href = "${mobileLink}";
}, 500);
` : ''}
</script>
</body>
</html>
`);
} catch (error: any) {
log.error('[AUTH] Error in verify-email:', error);
return res.status(500).send(`
<!DOCTYPE html>
<html>
<head><title>Eroare</title></head>
<body style="font-family: Arial; text-align: center; padding: 50px;">
<h1 style="color: #e53935;"> Eroare neașteptată</h1>
<p>${error.message || 'A apărut o eroare la verificarea email-ului.'}</p>
<p><a href="${WEB_APP_URL}">Înapoi la aplicație</a></p>
</body>
</html>
`);
}
});
export default router;

View file

@ -0,0 +1,23 @@
/**
* Auth route barrel.
*
* Original 1298-line auth.ts split (this PR) into:
* _helpers.ts JWT decode, Keycloak token, credit cost lookup, types
* me-profile.ts GET /me + PUT /profile
* registration.ts POST /register
* credits.ts /credits, /use-credit, /internal/* (agent-v3 calls)
* email-verify.ts GET + POST /verify-email
*/
import { Router } from 'express';
import meProfileRouter from './me-profile';
import registrationRouter from './registration';
import creditsRouter from './credits';
import emailVerifyRouter from './email-verify';
const router = Router();
router.use(meProfileRouter);
router.use(registrationRouter);
router.use(creditsRouter);
router.use(emailVerifyRouter);
export default router;

View file

@ -0,0 +1,327 @@
/**
* Auth: /me + /profile read + update user profile.
*/
import { Router, Request, Response } from 'express';
import { createUserBucket } from '../../config/minio';
import { log } from '../../config/logger';
import { internalError } from '../../config/error-response';
import pool from '../../config/database';
import {
type UserProfile,
extractJWTPayload,
getNextId,
} from './_helpers';
const router = Router();
/**
* GET /api/auth/me
* Returns user profile from bos_* tables based on keycloak_id in JWT
* AUTO-CREATES user in PostgreSQL if they exist in Keycloak but not in DB (Free tier, 100 credits)
*/
router.get('/me', async (req: Request, res: Response) => {
log.info('[AUTH] /me called');
const client = await pool.connect();
try {
const jwtPayload = extractJWTPayload(req.headers.authorization);
log.info('[AUTH] JWT payload:', jwtPayload?.email, jwtPayload?.sub);
if (!jwtPayload || !jwtPayload.sub) {
return res.status(401).json({
success: false,
error: 'Missing or invalid Authorization header'
});
}
const keycloakId = jwtPayload.sub;
const email = jwtPayload.email;
// Query user profile
let result = await client.query(`
SELECT
uc.internet_user_id,
uc.email,
uc.keycloak_id,
uc.cellular_phone_no as phone,
uc.subscription_status,
uc.activation_date,
iu.person_id,
iu.credits_remained,
iu.credits_spent,
pf.nume as last_name,
pf.prenume as first_name,
s.subscription_plan_id,
s.is_active,
sp.plan_name,
sp.credits_per_cycle
FROM bos_sysadmin.user_credential uc
JOIN bos_sysadmin.internet_user iu ON uc.internet_user_id = iu.internet_user_id
LEFT JOIN bos_subscriber.persoana_fizica pf ON iu.person_id = pf.individual_id
LEFT JOIN bos_sysadmin.subscription s ON iu.internet_user_id = s.internet_user_id AND s.is_active = true
LEFT JOIN bos_sysadmin.subscription_plan sp ON s.subscription_plan_id = sp.subscription_plan_id
WHERE uc.keycloak_id = $1
`, [keycloakId]);
// AUTO-RECONNECT: User exists in PG under a different keycloak_id (e.g. Keycloak
// realm was reset or user was deleted + recreated). Same email, new Keycloak ID.
// Update the keycloak_id in place — keeps credits, subscription, bucket, history.
if (result.rows.length === 0 && email) {
const emailMatch = await client.query(
`SELECT internet_user_id, keycloak_id FROM bos_sysadmin.user_credential
WHERE email = $1 AND "next$internet_user_id" IS NULL LIMIT 1`,
[email]
);
if (emailMatch.rows.length > 0) {
const oldKeycloakId = emailMatch.rows[0].keycloak_id;
log.info(`[AUTH] Reconnecting existing user ${email}: keycloak_id ${oldKeycloakId}${keycloakId}`);
await client.query(
`UPDATE bos_sysadmin.user_credential SET keycloak_id = $1
WHERE email = $2 AND "next$internet_user_id" IS NULL`,
[keycloakId, email]
);
// Re-run the main query — now finds the user
result = await client.query(`
SELECT
uc.internet_user_id, uc.email, uc.keycloak_id,
uc.cellular_phone_no as phone, uc.subscription_status, uc.activation_date,
iu.person_id, iu.credits_remained, iu.credits_spent,
pf.nume as last_name, pf.prenume as first_name,
s.subscription_plan_id, s.is_active,
sp.plan_name, sp.credits_per_cycle
FROM bos_sysadmin.user_credential uc
JOIN bos_sysadmin.internet_user iu ON uc.internet_user_id = iu.internet_user_id
LEFT JOIN bos_subscriber.persoana_fizica pf ON iu.person_id = pf.individual_id
LEFT JOIN bos_sysadmin.subscription s ON iu.internet_user_id = s.internet_user_id AND s.is_active = true
LEFT JOIN bos_sysadmin.subscription_plan sp ON s.subscription_plan_id = sp.subscription_plan_id
WHERE uc.keycloak_id = $1
`, [keycloakId]);
}
}
// AUTO-REGISTER: If user not in PostgreSQL but authenticated via Keycloak, create them
if (result.rows.length === 0) {
log.info('[AUTH] User not found in PostgreSQL, auto-registering:', email);
log.info(`[AUTH] Auto-registering Keycloak user: ${email} (${keycloakId})`);
const firstName = jwtPayload.given_name || '';
const lastName = jwtPayload.family_name || '';
await client.query('BEGIN');
try {
// 1. Create person
const personId = await getNextId(client, 'person', 'person_id', 'bos_subscriber');
await client.query(`
INSERT INTO bos_subscriber.person (person_id, person_type, status)
VALUES ($1, 0, 1)
`, [personId]);
// 2. Create address
const addressId = await getNextId(client, 'address', 'address_id', 'bos_subscriber');
await client.query(`
INSERT INTO bos_subscriber.address (address_id, address_type)
VALUES ($1, 0)
`, [addressId]);
// 3. Create persoana_fizica
await client.query(`
INSERT INTO bos_subscriber.persoana_fizica (individual_id, tip_persoana, nume, prenume, ro_official_address_id)
VALUES ($1, 2, $2, $3, $4)
`, [personId, lastName, firstName, addressId]);
// 4. Create internet_user with credits from Free plan (DB-driven)
const planRow = await client.query(
`SELECT credits_per_cycle, storage_limit_gb FROM bos_sysadmin.subscription_plan WHERE subscription_plan_id = 1`
);
const freeCredits = planRow.rows[0]?.credits_per_cycle ?? 5;
const freeStorageGb = planRow.rows[0]?.storage_limit_gb ?? 1;
const internetUserId = await getNextId(client, 'internet_user', 'internet_user_id', 'bos_sysadmin');
await client.query(`
INSERT INTO bos_sysadmin.internet_user (internet_user_id, person_id, credits_remained, credits_spent, storage_limit_bytes)
VALUES ($1, $2, $3, 0, $4)
`, [internetUserId, personId, freeCredits, freeStorageGb * 1073741824]);
// 5. Create user_credential
await client.query(`
INSERT INTO bos_sysadmin.user_credential
(internet_user_id, email, keycloak_id, enrollment_type, cellular_phone_no, no_attempts_failed, subscription_status, activation_date)
VALUES ($1, $2, $3, 1, '', 0, 1, CURRENT_DATE)
`, [internetUserId, email, keycloakId]);
// 6. Create subscription with Free plan
const subscriptionId = await getNextId(client, 'subscription', 'subscription_id', 'bos_sysadmin');
await client.query(`
INSERT INTO bos_sysadmin.subscription
(subscription_id, internet_user_id, subscription_plan_id, subscription_status, is_active, activation_date, deactivation_date, created_time, updated_time)
VALUES ($1, $2, 1, 1, true, CURRENT_DATE, '2099-12-31', CURRENT_DATE, CURRENT_DATE)
`, [subscriptionId, internetUserId]);
// 7. Create contact entry for email
await client.query(`
INSERT INTO bos_subscriber.contact (person_id, contact_type_id, contact_info)
VALUES ($1, 2, $2)
`, [personId, email]);
await client.query('COMMIT');
log.info(`[AUTH] Auto-registered user ${email} with ID ${internetUserId}`);
// 8. Create MinIO bucket for user with subscription metadata
try {
// Free plan: ID=1, storage_limit_gb=1
await createUserBucket(internetUserId, email, 1, 'Free', 1);
} catch (bucketError: any) {
log.error(`[AUTH] Failed to create MinIO bucket for user ${internetUserId}:`, bucketError.message);
// Don't fail registration if bucket creation fails
}
// Re-query to get full profile
result = await client.query(`
SELECT
uc.internet_user_id,
uc.email,
uc.keycloak_id,
uc.cellular_phone_no as phone,
uc.subscription_status,
uc.activation_date,
iu.person_id,
iu.credits_remained,
iu.credits_spent,
pf.nume as last_name,
pf.prenume as first_name,
s.subscription_plan_id,
s.is_active,
sp.plan_name,
sp.credits_per_cycle
FROM bos_sysadmin.user_credential uc
JOIN bos_sysadmin.internet_user iu ON uc.internet_user_id = iu.internet_user_id
LEFT JOIN bos_subscriber.persoana_fizica pf ON iu.person_id = pf.individual_id
LEFT JOIN bos_sysadmin.subscription s ON iu.internet_user_id = s.internet_user_id AND s.is_active = true
LEFT JOIN bos_sysadmin.subscription_plan sp ON s.subscription_plan_id = sp.subscription_plan_id
WHERE uc.keycloak_id = $1
`, [keycloakId]);
} catch (regError: any) {
await client.query('ROLLBACK');
log.error('[AUTH] Auto-register failed:', regError);
return res.status(500).json({
success: false,
error: 'Failed to auto-register user: ' + regError.message
});
}
}
const user = result.rows[0];
const profile: UserProfile = {
personId: user.person_id,
internetUserId: user.internet_user_id,
email: user.email,
firstName: user.first_name || jwtPayload.given_name || '',
lastName: user.last_name || jwtPayload.family_name || '',
phone: user.phone || '',
creditsRemained: user.credits_remained,
creditsSpent: user.credits_spent,
creditsTotal: (user.credits_remained || 0) + (user.credits_spent || 0),
creditsPerCycle: user.credits_per_cycle || 5,
subscriptionPlanId: user.subscription_plan_id || 1,
subscriptionPlanName: user.plan_name || 'Free',
subscriptionStatus: user.subscription_status,
isActive: user.is_active ?? true,
keycloakId: user.keycloak_id
};
res.json({ success: true, data: profile });
} catch (error: any) {
log.error('Error in /auth/me:', error);
internalError(res, error);
} finally {
client.release();
}
});
/**
* PUT /api/auth/profile
* Updates user profile (name, phone)
*/
router.put('/profile', async (req: Request, res: Response) => {
const client = await pool.connect();
try {
const jwtPayload = extractJWTPayload(req.headers.authorization);
if (!jwtPayload || !jwtPayload.sub) {
return res.status(401).json({
success: false,
error: 'Missing or invalid Authorization header'
});
}
const { firstName, lastName, phone } = req.body;
// Get user IDs
const userResult = await client.query(`
SELECT uc.internet_user_id, iu.person_id
FROM bos_sysadmin.user_credential uc
JOIN bos_sysadmin.internet_user iu ON uc.internet_user_id = iu.internet_user_id
WHERE uc.keycloak_id = $1
`, [jwtPayload.sub]);
if (userResult.rows.length === 0) {
return res.status(404).json({ success: false, error: 'User not found' });
}
const { internet_user_id, person_id } = userResult.rows[0];
await client.query('BEGIN');
// Update persoana_fizica if name provided
if (firstName || lastName) {
const updates: string[] = [];
const values: any[] = [];
let paramIndex = 1;
if (firstName) {
updates.push(`prenume = $${paramIndex++}`);
values.push(firstName);
}
if (lastName) {
updates.push(`nume = $${paramIndex++}`);
values.push(lastName);
}
values.push(person_id);
await client.query(`
UPDATE bos_subscriber.persoana_fizica
SET ${updates.join(', ')}
WHERE individual_id = $${paramIndex}
`, values);
}
// Update phone if provided
if (phone) {
await client.query(`
UPDATE bos_sysadmin.user_credential
SET cellular_phone_no = $1
WHERE internet_user_id = $2
`, [phone, internet_user_id]);
}
await client.query('COMMIT');
res.json({ success: true, message: 'Profile updated successfully' });
} catch (error: any) {
await client.query('ROLLBACK');
log.error('Error in /auth/profile:', error);
internalError(res, error);
} finally {
client.release();
}
});
export default router;

View file

@ -0,0 +1,164 @@
/**
* Auth: POST /register Creates user in bos_* tables after Keycloak signup.
*/
import { Router, Request, Response } from 'express';
import { createUserBucket } from '../../config/minio';
import { log } from '../../config/logger';
import { internalError } from '../../config/error-response';
import pool from '../../config/database';
import {
type RegisterData,
extractJWTPayload,
getNextId,
} from './_helpers';
const router = Router();
/**
* POST /api/auth/register
* Creates user in bos_* tables after Keycloak registration
* Automatically assigns Free tier with 100 credits
*/
router.post('/register', async (req: Request, res: Response) => {
const client = await pool.connect();
try {
const jwtPayload = extractJWTPayload(req.headers.authorization);
if (!jwtPayload || !jwtPayload.sub) {
return res.status(401).json({
success: false,
error: 'Missing or invalid Authorization header'
});
}
const keycloakId = jwtPayload.sub;
const email = jwtPayload.email;
// Check if user already exists
const existingUser = await client.query(
'SELECT internet_user_id FROM bos_sysadmin.user_credential WHERE keycloak_id = $1',
[keycloakId]
);
if (existingUser.rows.length > 0) {
return res.status(409).json({
success: false,
error: 'User already registered',
internetUserId: existingUser.rows[0].internet_user_id
});
}
const data: RegisterData = req.body;
const firstName = data.firstName || jwtPayload.given_name || '';
const lastName = data.lastName || jwtPayload.family_name || '';
const phone = data.phone || '';
await client.query('BEGIN');
// 1. Create person
const personId = await getNextId(client, 'person', 'person_id', 'bos_subscriber');
await client.query(`
INSERT INTO bos_subscriber.person (person_id, person_type, status)
VALUES ($1, 0, 1)
`, [personId]);
// 2. Create address entry for this person
const addressId = await getNextId(client, 'address', 'address_id', 'bos_subscriber');
await client.query(`
INSERT INTO bos_subscriber.address (address_id, address_type)
VALUES ($1, 0)
`, [addressId]);
// 3. Create persoana_fizica linked to the address
await client.query(`
INSERT INTO bos_subscriber.persoana_fizica
(individual_id, tip_persoana, nume, prenume, ro_official_address_id)
VALUES ($1, 2, $2, $3, $4)
`, [personId, lastName, firstName, addressId]);
// 4. Create internet_user with credits_per_cycle from Free plan (DB-driven)
const planRow = await client.query(
`SELECT credits_per_cycle, storage_limit_gb FROM bos_sysadmin.subscription_plan WHERE subscription_plan_id = 1`
);
const freeCredits = planRow.rows[0]?.credits_per_cycle ?? 10;
const freeStorageGb = planRow.rows[0]?.storage_limit_gb ?? 1;
const internetUserId = await getNextId(client, 'internet_user', 'internet_user_id', 'bos_sysadmin');
await client.query(`
INSERT INTO bos_sysadmin.internet_user
(internet_user_id, person_id, credits_remained, credits_spent, storage_limit_bytes)
VALUES ($1, $2, $3, 0, $4)
`, [internetUserId, personId, freeCredits, freeStorageGb * 1073741824]);
// 5. Create user_credential with keycloak_id
await client.query(`
INSERT INTO bos_sysadmin.user_credential
(internet_user_id, email, keycloak_id, enrollment_type,
cellular_phone_no, no_attempts_failed, subscription_status, activation_date)
VALUES ($1, $2, $3, 1, $4, 0, 1, CURRENT_DATE)
`, [internetUserId, email, keycloakId, phone]);
// 6. Create subscription with Free plan (plan_id = 1)
const subscriptionId = await getNextId(client, 'subscription', 'subscription_id', 'bos_sysadmin');
await client.query(`
INSERT INTO bos_sysadmin.subscription
(subscription_id, internet_user_id, subscription_plan_id,
subscription_status, is_active, activation_date, deactivation_date,
created_time, updated_time)
VALUES ($1, $2, 1, 1, true, CURRENT_DATE, '2099-12-31', CURRENT_DATE, CURRENT_DATE)
`, [subscriptionId, internetUserId]);
// 7. Create contact entries (email + phone)
// contact table has composite PK: (contact_type_id, person_id)
await client.query(`
INSERT INTO bos_subscriber.contact (person_id, contact_type_id, contact_info)
VALUES ($1, 2, $2)
`, [personId, email]);
if (phone) {
await client.query(`
INSERT INTO bos_subscriber.contact (person_id, contact_type_id, contact_info)
VALUES ($1, 5, $2)
`, [personId, phone]);
}
await client.query('COMMIT');
// 8. Create MinIO bucket for user with subscription metadata
let bucketCreated = false;
try {
const bucketResult = await createUserBucket(internetUserId, email, 1, 'Free', freeStorageGb);
bucketCreated = bucketResult.created;
} catch (bucketError: any) {
log.error(`[AUTH] Failed to create MinIO bucket for user ${internetUserId}:`, bucketError.message);
// Don't fail registration if bucket creation fails
}
res.status(201).json({
success: true,
message: 'User registered successfully',
data: {
personId,
internetUserId,
subscriptionId,
email,
firstName,
lastName,
credits: freeCredits,
plan: 'Free',
storageBucket: `user-${internetUserId}`,
storageBucketCreated: bucketCreated
}
});
} catch (error: any) {
await client.query('ROLLBACK');
log.error('Error in /auth/register:', error);
internalError(res, error);
} finally {
client.release();
}
});
export default router;

View file

@ -0,0 +1,480 @@
/**
* Claims Routes - FULL CRUD
*
* All claim-related tables are leaf nodes (no children), can be deleted directly:
* - Claim Status (VT, LT, UV, LF, VF, OP, NV)
* - Claim Type (EF, VF, RE, SC, QA, CC, PC, OF, VC)
* - Confidence levels
* - Interpretation (source concordance)
*/
import { Router, Request, Response } from 'express';
import { query, queryOne, transaction } from '../config/database';
import { ApiResponse } from '../types';
import { PoolClient } from 'pg';
import { internalError } from '../config/error-response';
const router = Router();
// ============================================================================
// HELPER FUNCTIONS
// ============================================================================
const createParameter = async (client: PoolClient, parameterType: number): Promise<number> => {
const maxResult = await client.query('SELECT COALESCE(MAX(parameter_id), 0) + 1 as next_id FROM parameter');
const nextParamId = maxResult.rows[0].next_id;
await client.query(`
INSERT INTO parameter (parameter_id, parameter_type, valid_from, valid_to, created_date, updated_date)
VALUES ($1, $2, '2026-01-01', '2999-01-01', CURRENT_DATE, CURRENT_DATE)
`, [nextParamId, parameterType]);
return nextParamId;
};
const getNextId = async (client: PoolClient, table: string, idColumn: string): Promise<number> => {
const result = await client.query(`SELECT COALESCE(MAX(${idColumn}), 0) + 1 as next_id FROM ${table}`);
return result.rows[0].next_id;
};
// ============================================================================
// CLAIM STATUS (Verification Outcome)
// Leaf node - no children
// ============================================================================
interface ClaimStatus {
claim_id: number;
claim_code: string;
claim_name: string;
claim_color: string;
start_range: number | null;
end_range: number | null;
parameter_id: number;
}
router.get('/status', async (req: Request, res: Response) => {
try {
const data = await query<ClaimStatus>(
'SELECT claim_id, claim_code, claim_name, claim_color, start_range, end_range, parameter_id FROM claim ORDER BY claim_id'
);
res.json({ success: true, data, count: data.length });
} catch (error) {
internalError(res, error);
}
});
router.get('/status/:id', async (req: Request, res: Response) => {
try {
const data = await queryOne<ClaimStatus>(
'SELECT claim_id, claim_code, claim_name, claim_color, start_range, end_range, parameter_id FROM claim WHERE claim_id = $1',
[req.params.id]
);
if (!data) {
return res.status(404).json({ success: false, error: 'Claim status nu a fost găsit' });
}
res.json({ success: true, data });
} catch (error) {
internalError(res, error);
}
});
router.post('/status', async (req: Request, res: Response) => {
try {
const { claim_code, claim_name, claim_color, start_range, end_range } = req.body;
if (!claim_code || !claim_name) {
return res.status(400).json({ success: false, error: 'Câmpuri obligatorii: claim_code, claim_name' });
}
const result = await transaction(async (client) => {
const parameterId = await createParameter(client, 10); // Parameter type for claims
const id = await getNextId(client, 'claim', 'claim_id');
const insertResult = await client.query(`
INSERT INTO claim (claim_id, claim_code, claim_name, claim_color, start_range, end_range, parameter_id)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING claim_id, claim_code, claim_name, claim_color, start_range, end_range, parameter_id
`, [id, claim_code, claim_name, claim_color || '#808080', start_range, end_range, parameterId]);
return insertResult.rows[0];
});
res.status(201).json({ success: true, data: result, message: 'Claim status creat cu succes' });
} catch (error) {
internalError(res, error);
}
});
router.put('/status/:id', async (req: Request, res: Response) => {
try {
const { claim_code, claim_name, claim_color, start_range, end_range } = req.body;
const result = await queryOne<ClaimStatus>(`
UPDATE claim
SET claim_code = COALESCE($1, claim_code),
claim_name = COALESCE($2, claim_name),
claim_color = COALESCE($3, claim_color),
start_range = COALESCE($4, start_range),
end_range = COALESCE($5, end_range)
WHERE claim_id = $6
RETURNING claim_id, claim_code, claim_name, claim_color, start_range, end_range, parameter_id
`, [claim_code, claim_name, claim_color, start_range, end_range, req.params.id]);
if (!result) {
return res.status(404).json({ success: false, error: 'Claim status nu a fost găsit' });
}
res.json({ success: true, data: result, message: 'Claim status actualizat cu succes' });
} catch (error) {
internalError(res, error);
}
});
router.delete('/status/:id', async (req: Request, res: Response) => {
try {
const result = await query('DELETE FROM claim WHERE claim_id = $1 RETURNING *', [req.params.id]);
if (result.length === 0) {
return res.status(404).json({ success: false, error: 'Claim status nu a fost găsit' });
}
res.json({ success: true, message: 'Claim status șters cu succes', deleted: true });
} catch (error) {
internalError(res, error);
}
});
// ============================================================================
// CLAIM TYPE (Type of Claim with Base Weight)
// Leaf node - no children
// ============================================================================
interface ClaimType {
claim_type_id: number;
claim_type_code: string;
claim_type_name: string;
base_weight: number;
description: string;
verification_method: string;
parameter_id: number;
}
router.get('/types', async (req: Request, res: Response) => {
try {
const data = await query<ClaimType>(
'SELECT claim_type_id, claim_type_code, claim_type_name, base_weight, description, verification_method, parameter_id FROM claim_type ORDER BY base_weight DESC'
);
res.json({ success: true, data, count: data.length });
} catch (error) {
internalError(res, error);
}
});
router.get('/types/:id', async (req: Request, res: Response) => {
try {
const data = await queryOne<ClaimType>(
'SELECT * FROM claim_type WHERE claim_type_id = $1',
[req.params.id]
);
if (!data) {
return res.status(404).json({ success: false, error: 'Claim type nu a fost găsit' });
}
res.json({ success: true, data });
} catch (error) {
internalError(res, error);
}
});
router.post('/types', async (req: Request, res: Response) => {
try {
const { claim_type_code, claim_type_name, base_weight, description, verification_method } = req.body;
if (!claim_type_code || !claim_type_name) {
return res.status(400).json({ success: false, error: 'Câmpuri obligatorii: claim_type_code, claim_type_name' });
}
const result = await transaction(async (client) => {
const parameterId = await createParameter(client, 11); // Parameter type for claim types
const id = await getNextId(client, 'claim_type', 'claim_type_id');
const insertResult = await client.query(`
INSERT INTO claim_type (claim_type_id, claim_type_code, claim_type_name, base_weight, description, verification_method, parameter_id)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *
`, [id, claim_type_code, claim_type_name, base_weight || 1, description || '', verification_method || '', parameterId]);
return insertResult.rows[0];
});
res.status(201).json({ success: true, data: result, message: 'Claim type creat cu succes' });
} catch (error) {
internalError(res, error);
}
});
router.put('/types/:id', async (req: Request, res: Response) => {
try {
const { claim_type_code, claim_type_name, base_weight, description, verification_method } = req.body;
const result = await queryOne<ClaimType>(`
UPDATE claim_type
SET claim_type_code = COALESCE($1, claim_type_code),
claim_type_name = COALESCE($2, claim_type_name),
base_weight = COALESCE($3, base_weight),
description = COALESCE($4, description),
verification_method = COALESCE($5, verification_method)
WHERE claim_type_id = $6
RETURNING *
`, [claim_type_code, claim_type_name, base_weight, description, verification_method, req.params.id]);
if (!result) {
return res.status(404).json({ success: false, error: 'Claim type nu a fost găsit' });
}
res.json({ success: true, data: result, message: 'Claim type actualizat cu succes' });
} catch (error) {
internalError(res, error);
}
});
router.delete('/types/:id', async (req: Request, res: Response) => {
try {
const result = await query('DELETE FROM claim_type WHERE claim_type_id = $1 RETURNING *', [req.params.id]);
if (result.length === 0) {
return res.status(404).json({ success: false, error: 'Claim type nu a fost găsit' });
}
res.json({ success: true, message: 'Claim type șters cu succes', deleted: true });
} catch (error) {
internalError(res, error);
}
});
// ============================================================================
// CONFIDENCE LEVELS
// Leaf node - no children
// ============================================================================
interface ConfidenceLevel {
confidence_id: number;
confidence_name: string;
confidence_level: number;
confidence_color: string;
action: string;
start_range: number;
end_range: number;
parameter_id: number;
}
router.get('/confidence', async (req: Request, res: Response) => {
try {
const data = await query<ConfidenceLevel>(
'SELECT * FROM confidence ORDER BY confidence_level'
);
res.json({ success: true, data, count: data.length });
} catch (error) {
internalError(res, error);
}
});
router.get('/confidence/:id', async (req: Request, res: Response) => {
try {
const data = await queryOne<ConfidenceLevel>(
'SELECT * FROM confidence WHERE confidence_id = $1',
[req.params.id]
);
if (!data) {
return res.status(404).json({ success: false, error: 'Confidence level nu a fost găsit' });
}
res.json({ success: true, data });
} catch (error) {
internalError(res, error);
}
});
router.post('/confidence', async (req: Request, res: Response) => {
try {
const { confidence_name, confidence_level, confidence_color, action, start_range, end_range } = req.body;
if (!confidence_name || confidence_level === undefined) {
return res.status(400).json({ success: false, error: 'Câmpuri obligatorii: confidence_name, confidence_level' });
}
const result = await transaction(async (client) => {
const parameterId = await createParameter(client, 12); // Parameter type for confidence
const id = await getNextId(client, 'confidence', 'confidence_id');
const insertResult = await client.query(`
INSERT INTO confidence (confidence_id, confidence_name, confidence_level, confidence_color, action, start_range, end_range, parameter_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *
`, [id, confidence_name, confidence_level, confidence_color || '#808080', action || '', start_range || 0, end_range || 100, parameterId]);
return insertResult.rows[0];
});
res.status(201).json({ success: true, data: result, message: 'Confidence level creat cu succes' });
} catch (error) {
internalError(res, error);
}
});
router.put('/confidence/:id', async (req: Request, res: Response) => {
try {
const { confidence_name, confidence_level, confidence_color, action, start_range, end_range } = req.body;
const result = await queryOne<ConfidenceLevel>(`
UPDATE confidence
SET confidence_name = COALESCE($1, confidence_name),
confidence_level = COALESCE($2, confidence_level),
confidence_color = COALESCE($3, confidence_color),
action = COALESCE($4, action),
start_range = COALESCE($5, start_range),
end_range = COALESCE($6, end_range)
WHERE confidence_id = $7
RETURNING *
`, [confidence_name, confidence_level, confidence_color, action, start_range, end_range, req.params.id]);
if (!result) {
return res.status(404).json({ success: false, error: 'Confidence level nu a fost găsit' });
}
res.json({ success: true, data: result, message: 'Confidence level actualizat cu succes' });
} catch (error) {
internalError(res, error);
}
});
router.delete('/confidence/:id', async (req: Request, res: Response) => {
try {
const result = await query('DELETE FROM confidence WHERE confidence_id = $1 RETURNING *', [req.params.id]);
if (result.length === 0) {
return res.status(404).json({ success: false, error: 'Confidence level nu a fost găsit' });
}
res.json({ success: true, message: 'Confidence level șters cu succes', deleted: true });
} catch (error) {
internalError(res, error);
}
});
// ============================================================================
// INTERPRETATION (Source Concordance)
// Leaf node - no children
// ============================================================================
interface Interpretation {
interpretation_id: number;
interpretation: string;
start_range: number;
end_range: number;
parameter_id: number;
}
router.get('/interpretation', async (req: Request, res: Response) => {
try {
const data = await query<Interpretation>(
'SELECT interpretation_id, interpretation, start_range, end_range, parameter_id FROM interpretation ORDER BY interpretation_id'
);
res.json({ success: true, data, count: data.length });
} catch (error) {
internalError(res, error);
}
});
router.get('/interpretation/:id', async (req: Request, res: Response) => {
try {
const data = await queryOne<Interpretation>(
'SELECT * FROM interpretation WHERE interpretation_id = $1',
[req.params.id]
);
if (!data) {
return res.status(404).json({ success: false, error: 'Interpretation nu a fost găsit' });
}
res.json({ success: true, data });
} catch (error) {
internalError(res, error);
}
});
router.post('/interpretation', async (req: Request, res: Response) => {
try {
const { interpretation, start_range, end_range } = req.body;
if (!interpretation) {
return res.status(400).json({ success: false, error: 'Câmp obligatoriu: interpretation' });
}
const result = await transaction(async (client) => {
const parameterId = await createParameter(client, 13); // Parameter type for interpretation
const id = await getNextId(client, 'interpretation', 'interpretation_id');
const insertResult = await client.query(`
INSERT INTO interpretation (interpretation_id, interpretation, start_range, end_range, parameter_id)
VALUES ($1, $2, $3, $4, $5)
RETURNING *
`, [id, interpretation, start_range || 0, end_range || 100, parameterId]);
return insertResult.rows[0];
});
res.status(201).json({ success: true, data: result, message: 'Interpretation creat cu succes' });
} catch (error) {
internalError(res, error);
}
});
router.put('/interpretation/:id', async (req: Request, res: Response) => {
try {
const { interpretation, start_range, end_range } = req.body;
const result = await queryOne<Interpretation>(`
UPDATE interpretation
SET interpretation = COALESCE($1, interpretation),
start_range = COALESCE($2, start_range),
end_range = COALESCE($3, end_range)
WHERE interpretation_id = $4
RETURNING *
`, [interpretation, start_range, end_range, req.params.id]);
if (!result) {
return res.status(404).json({ success: false, error: 'Interpretation nu a fost găsit' });
}
res.json({ success: true, data: result, message: 'Interpretation actualizat cu succes' });
} catch (error) {
internalError(res, error);
}
});
router.delete('/interpretation/:id', async (req: Request, res: Response) => {
try {
const result = await query('DELETE FROM interpretation WHERE interpretation_id = $1 RETURNING *', [req.params.id]);
if (result.length === 0) {
return res.status(404).json({ success: false, error: 'Interpretation nu a fost găsit' });
}
res.json({ success: true, message: 'Interpretation șters cu succes', deleted: true });
} catch (error) {
internalError(res, error);
}
});
// ============================================================================
// COMBINED: GET ALL CLAIMS DATA
// ============================================================================
router.get('/all', async (req: Request, res: Response) => {
try {
const [
claimStatus,
claimTypes,
confidence,
interpretation
] = await Promise.all([
query<ClaimStatus>('SELECT claim_id, claim_code, claim_name, claim_color, start_range, end_range FROM claim ORDER BY claim_id'),
query<ClaimType>('SELECT claim_type_id, claim_type_code, claim_type_name, base_weight, description, verification_method FROM claim_type ORDER BY base_weight DESC'),
query<ConfidenceLevel>('SELECT * FROM confidence ORDER BY confidence_level'),
query<Interpretation>('SELECT interpretation_id, interpretation, start_range, end_range FROM interpretation ORDER BY interpretation_id')
]);
res.json({
success: true,
data: {
claimStatus,
claimTypes,
confidence,
interpretation
},
counts: {
claimStatus: claimStatus.length,
claimTypes: claimTypes.length,
confidence: confidence.length,
interpretation: interpretation.length,
total: claimStatus.length + claimTypes.length + confidence.length + interpretation.length
}
});
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
} as ApiResponse<never>);
}
});
export default router;

View file

@ -0,0 +1,295 @@
/**
* Dimensions Routes - FULL CRUD with Safety
*
* Dimensions are the top-level categories in the analysis hierarchy:
* dimension -> subdimension -> technique -> indicator/validation_rule
*
* DELETE is protected - cannot delete dimension with subdimensions
*/
import { Router, Request, Response } from 'express';
import { query, queryOne, transaction } from '../config/database';
import { checkDependencies, safeDelete } from '../utils/dependency-checker';
import { Dimension, ApiResponse } from '../types';
import { PoolClient } from 'pg';
import { internalError } from '../config/error-response';
const router = Router();
// Parameter type for dimensions
const PARAMETER_TYPE_DIMENSION = 1;
// Helper: Create parameter entry
const createParameter = async (client: PoolClient): Promise<number> => {
const maxResult = await client.query('SELECT COALESCE(MAX(parameter_id), 0) + 1 as next_id FROM parameter');
const nextParamId = maxResult.rows[0].next_id;
await client.query(`
INSERT INTO parameter (parameter_id, parameter_type, valid_from, valid_to, created_date, updated_date)
VALUES ($1, $2, '2026-01-01', '2999-01-01', CURRENT_DATE, CURRENT_DATE)
`, [nextParamId, PARAMETER_TYPE_DIMENSION]);
return nextParamId;
};
// Helper: Get next dimension_id
const getNextDimensionId = async (client: PoolClient): Promise<number> => {
const result = await client.query('SELECT COALESCE(MAX(dimension_id), 0) + 1 as next_id FROM dimension');
return result.rows[0].next_id;
};
// GET all dimensions
router.get('/', async (req: Request, res: Response) => {
try {
const dimensions = await query<Dimension>(
'SELECT * FROM dimension ORDER BY dimension_id'
);
res.json({
success: true,
data: dimensions,
count: dimensions.length
} as ApiResponse<Dimension[]>);
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
} as ApiResponse<never>);
}
});
// GET all dimensions with subdimension counts
router.get('/with-counts', async (req: Request, res: Response) => {
try {
const dimensions = await query<Dimension & { subdimension_count: number; technique_count: number }>(`
SELECT d.*,
(SELECT COUNT(*) FROM subdimension s WHERE s.dimension_id = d.dimension_id) as subdimension_count,
(SELECT COUNT(*) FROM technique t
JOIN subdimension s ON t.subdimension_id = s.subdimension_id
WHERE s.dimension_id = d.dimension_id) as technique_count
FROM dimension d
ORDER BY d.dimension_id
`);
res.json({
success: true,
data: dimensions,
count: dimensions.length
});
} catch (error) {
internalError(res, error);
}
});
// GET dimension by ID
router.get('/:id', async (req: Request, res: Response) => {
try {
const dimension = await queryOne<Dimension>(
'SELECT * FROM dimension WHERE dimension_id = $1',
[req.params.id]
);
if (!dimension) {
return res.status(404).json({
success: false,
error: 'Dimensiunea nu a fost găsită'
} as ApiResponse<never>);
}
res.json({
success: true,
data: dimension
} as ApiResponse<Dimension>);
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
} as ApiResponse<never>);
}
});
// GET dependency check before delete
router.get('/:id/dependencies', async (req: Request, res: Response) => {
try {
const id = req.params.id;
// Check if dimension exists
const dimension = await queryOne<Dimension>(
'SELECT * FROM dimension WHERE dimension_id = $1',
[id]
);
if (!dimension) {
return res.status(404).json({
success: false,
error: 'Dimensiunea nu a fost găsită'
});
}
// Check dependencies
const depCheck = await checkDependencies('dimension', 'dimension_id', id);
// Get detailed subdimension info if there are children
let subdimensions: any[] = [];
if (depCheck.hasChildren) {
subdimensions = await query(`
SELECT s.subdimension_id, s.subdmiension_name as subdimension_name, s.subdimension_code,
(SELECT COUNT(*) FROM technique t WHERE t.subdimension_id = s.subdimension_id) as technique_count
FROM subdimension s
WHERE s.dimension_id = $1
ORDER BY s.subdimension_id
`, [id]);
}
res.json({
success: true,
data: {
dimension,
...depCheck,
childDetails: subdimensions
}
});
} catch (error) {
internalError(res, error);
}
});
// POST create dimension
router.post('/', async (req: Request, res: Response) => {
try {
const { dimension_code, dimension_name, description, weight } = req.body;
// Validation
if (!dimension_code || !dimension_name) {
return res.status(400).json({
success: false,
error: 'Câmpuri obligatorii: dimension_code, dimension_name'
});
}
const result = await transaction(async (client) => {
// Create parameter entry
const parameterId = await createParameter(client);
// Get next dimension_id
const dimensionId = await getNextDimensionId(client);
// Insert dimension
const insertResult = await client.query(`
INSERT INTO dimension (dimension_id, dimension_code, dimension_name, description, weight, parameter_id,
dimension_name_ro, dimension_name_en, description_ro, description_en)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *
`, [dimensionId, dimension_code, dimension_name, description || '', weight || 0, parameterId,
req.body.dimension_name_ro || null, req.body.dimension_name_en || dimension_name,
req.body.description_ro || null, req.body.description_en || description || '']);
return insertResult.rows[0];
});
res.status(201).json({
success: true,
data: result,
message: 'Dimensiunea a fost creată cu succes'
});
} catch (error) {
internalError(res, error);
}
});
// PUT update dimension
router.put('/:id', async (req: Request, res: Response) => {
try {
const { dimension_code, dimension_name, description, weight,
dimension_name_ro, dimension_name_en, description_ro, description_en } = req.body;
// Check if dimension exists
const existing = await queryOne<Dimension>(
'SELECT * FROM dimension WHERE dimension_id = $1',
[req.params.id]
);
if (!existing) {
return res.status(404).json({
success: false,
error: 'Dimensiunea nu a fost găsită'
});
}
const dimension = await queryOne<Dimension>(
`UPDATE dimension
SET dimension_code = COALESCE($1, dimension_code),
dimension_name = COALESCE($2, dimension_name),
description = COALESCE($3, description),
weight = COALESCE($4, weight),
dimension_name_ro = COALESCE($6, dimension_name_ro),
dimension_name_en = COALESCE($7, dimension_name_en),
description_ro = COALESCE($8, description_ro),
description_en = COALESCE($9, description_en),
updated_date = CURRENT_DATE
WHERE dimension_id = $5
RETURNING *`,
[dimension_code, dimension_name, description, weight, req.params.id,
dimension_name_ro, dimension_name_en, description_ro, description_en]
);
res.json({
success: true,
data: dimension,
message: 'Dimensiunea a fost actualizată cu succes'
} as ApiResponse<Dimension>);
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
} as ApiResponse<never>);
}
});
// DELETE dimension (with safety check)
router.delete('/:id', async (req: Request, res: Response) => {
try {
const id = req.params.id;
const force = req.query.force === 'true';
// Check if dimension exists
const existing = await queryOne<Dimension>(
'SELECT * FROM dimension WHERE dimension_id = $1',
[id]
);
if (!existing) {
return res.status(404).json({
success: false,
error: 'Dimensiunea nu a fost găsită'
});
}
// Use safe delete
const deleteResult = await safeDelete('dimension', 'dimension_id', id, force);
if (!deleteResult.success) {
return res.status(409).json({
success: false,
error: deleteResult.message,
canDelete: false,
dependencies: deleteResult.dependencyDetails?.dependencies || [],
hint: 'Ștergeți mai întâi toate subdimensiunile asociate acestei dimensiuni'
});
}
res.json({
success: true,
message: 'Dimensiunea a fost ștearsă cu succes',
deleted: true
});
} catch (error: any) {
if (error.code === '23503') {
return res.status(409).json({
success: false,
error: 'Nu se poate șterge: există subdimensiuni asociate',
canDelete: false
});
}
internalError(res, error);
}
});
export default router;

View file

@ -0,0 +1,14 @@
/**
* Shared infra for extension-keys routes:
* - Redis cache prefix + TTL for the api_key user mapping (read fast-path
* used by /validate and write-through by create/update/delete).
* - Lazy Redis connection helper (one connection per request disconnected
* in caller's finally block).
*/
import type Redis from 'ioredis';
import { createRedisConnection } from '../../config/redis';
export const CACHE_PREFIX = 'didi:extension:key:';
export const CACHE_TTL = 3600; // 1 hour
export const getRedis = (): Redis => createRedisConnection({ label: 'extension-keys' });

View file

@ -0,0 +1,80 @@
/**
* POST / Create a new browser-extension API key.
*
* Generates `didi_ext_<48 hex>` (24 random bytes), stores in PostgreSQL,
* and write-through caches in Redis so /validate hits the fast-path on first
* use without a DB round-trip.
*/
import { Router, Request, Response } from 'express';
import crypto from 'crypto';
import pool from '../../config/database';
import { internalError } from '../../config/error-response';
import { CACHE_PREFIX, CACHE_TTL, getRedis } from './_shared';
const router = Router();
router.post('/', async (req: Request, res: Response) => {
const { user_id, user_email, name } = req.body;
if (!user_id || !name) {
return res.status(400).json({
success: false,
error: 'user_id and name are required',
});
}
const client = await pool.connect();
const redis = getRedis();
try {
const apiKey = `didi_ext_${crypto.randomBytes(24).toString('hex')}`;
const keyPrefix = apiKey.substring(0, 16);
const result = await client.query(`
INSERT INTO bos_parammgmt.extension_api_key
(api_key, key_prefix, user_id, user_email, name)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, api_key, key_prefix, user_id, user_email, name, is_active, created_at
`, [apiKey, keyPrefix, user_id, user_email || null, name]);
const key = result.rows[0];
await redis.setex(
`${CACHE_PREFIX}${apiKey}`,
CACHE_TTL,
JSON.stringify({
id: key.id,
user_id: key.user_id,
user_email: key.user_email,
name: key.name,
is_active: key.is_active,
})
);
redis.quit();
res.status(201).json({
success: true,
data: {
id: key.id,
api_key: key.api_key,
key_prefix: key.key_prefix,
user_id: key.user_id,
user_email: key.user_email,
name: key.name,
created_at: key.created_at,
usage: {
header: 'X-API-Key',
example: `curl -H "X-API-Key: ${apiKey}" ...`,
},
},
});
} catch (error) {
redis.quit();
internalError(res, error, 'extension_keys_create');
} finally {
client.release();
}
});
export default router;

View file

@ -0,0 +1,56 @@
/**
* DELETE /:id Delete a key from PG + remove its Redis cache entry.
*
* Loads the api_key plaintext first (we only have id) so we know which Redis
* key to evict. Hard delete no soft-delete column on this table.
*/
import { Router, Request, Response } from 'express';
import pool from '../../config/database';
import { internalError } from '../../config/error-response';
import { CACHE_PREFIX, getRedis } from './_shared';
const router = Router();
router.delete('/:id', async (req: Request, res: Response) => {
const { id } = req.params;
const client = await pool.connect();
const redis = getRedis();
try {
const keyResult = await client.query(
'SELECT api_key FROM bos_parammgmt.extension_api_key WHERE id = $1',
[id]
);
if (keyResult.rows.length === 0) {
redis.quit();
return res.status(404).json({
success: false,
error: 'Key not found',
});
}
const apiKey = keyResult.rows[0].api_key;
await client.query(
'DELETE FROM bos_parammgmt.extension_api_key WHERE id = $1',
[id]
);
await redis.del(`${CACHE_PREFIX}${apiKey}`);
redis.quit();
res.json({
success: true,
message: 'API key deleted successfully',
});
} catch (error) {
redis.quit();
internalError(res, error, 'extension_keys_delete');
} finally {
client.release();
}
});
export default router;

View file

@ -0,0 +1,36 @@
/**
* EXTENSION-KEYS barrel router.
*
* Original 530-line extension-keys.ts split into:
* _shared.ts CACHE_PREFIX, CACHE_TTL, getRedis()
* create.ts POST /
* list.ts GET /, GET /user/:userId
* validate.ts GET /validate (cache-first hot-path)
* update.ts PUT /:id
* delete.ts DELETE /:id
* usage.ts POST /:id/usage, POST /usage-by-key
*
* Mount-order matters: validate must be registered BEFORE update/delete so the
* literal path `/validate` is matched before the `/:id` placeholder.
*
* Mounted at /api/extension-keys in src/server.ts.
*/
import { Router } from 'express';
import createRouter from './create';
import listRouter from './list';
import validateRouter from './validate';
import updateRouter from './update';
import deleteRouter from './delete';
import usageRouter from './usage';
const router = Router();
// /validate before /:id-style routes (literal vs placeholder collision)
router.use(validateRouter);
router.use(createRouter);
router.use(listRouter);
router.use(usageRouter);
router.use(updateRouter);
router.use(deleteRouter);
export default router;

View file

@ -0,0 +1,85 @@
/**
* GET / admin list with optional ?user_id=&active_only=true filters
* GET /user/:userId list all keys for a specific user
*
* Neither endpoint returns the `api_key` plaintext only `key_prefix` (first 16 chars).
*/
import { Router, Request, Response } from 'express';
import pool from '../../config/database';
import { internalError } from '../../config/error-response';
const router = Router();
router.get('/', async (req: Request, res: Response) => {
const { user_id, active_only } = req.query;
const client = await pool.connect();
try {
let query = `
SELECT id, key_prefix, user_id, user_email, name, is_active,
created_at, last_used_at, usage_count
FROM bos_parammgmt.extension_api_key
`;
const params: any[] = [];
const conditions: string[] = [];
if (user_id) {
conditions.push(`user_id = $${params.length + 1}`);
params.push(user_id);
}
if (active_only === 'true') {
conditions.push('is_active = true');
}
if (conditions.length > 0) {
query += ' WHERE ' + conditions.join(' AND ');
}
query += ' ORDER BY created_at DESC';
const result = await client.query(query, params);
res.json({
success: true,
data: {
total: result.rows.length,
keys: result.rows,
},
});
} catch (error) {
internalError(res, error, 'extension_keys_list');
} finally {
client.release();
}
});
router.get('/user/:userId', async (req: Request, res: Response) => {
const { userId } = req.params;
const client = await pool.connect();
try {
const result = await client.query(`
SELECT id, key_prefix, user_id, user_email, name, is_active,
created_at, last_used_at, usage_count
FROM bos_parammgmt.extension_api_key
WHERE user_id = $1
ORDER BY created_at DESC
`, [userId]);
res.json({
success: true,
data: {
user_id: userId,
total: result.rows.length,
keys: result.rows,
},
});
} catch (error) {
internalError(res, error, 'extension_keys_list_for_user');
} finally {
client.release();
}
});
export default router;

View file

@ -0,0 +1,104 @@
/**
* PUT /:id Update is_active / name / user_email on an existing key.
*
* Builds a dynamic SET clause from whichever fields are present in the body.
* Refreshes the Redis cache with the new is_active state so /validate doesn't
* keep returning the stale value for up to 1h.
*/
import { Router, Request, Response } from 'express';
import pool from '../../config/database';
import { internalError } from '../../config/error-response';
import { CACHE_PREFIX, CACHE_TTL, getRedis } from './_shared';
const router = Router();
router.put('/:id', async (req: Request, res: Response) => {
const { id } = req.params;
const { is_active, name, user_email } = req.body;
const client = await pool.connect();
const redis = getRedis();
try {
const updates: string[] = [];
const params: any[] = [];
let paramIndex = 1;
if (is_active !== undefined) {
updates.push(`is_active = $${paramIndex}`);
params.push(is_active);
paramIndex++;
}
if (name !== undefined) {
updates.push(`name = $${paramIndex}`);
params.push(name);
paramIndex++;
}
if (user_email !== undefined) {
updates.push(`user_email = $${paramIndex}`);
params.push(user_email);
paramIndex++;
}
if (updates.length === 0) {
return res.status(400).json({
success: false,
error: 'No fields to update',
});
}
params.push(id);
const result = await client.query(`
UPDATE bos_parammgmt.extension_api_key
SET ${updates.join(', ')}
WHERE id = $${paramIndex}
RETURNING id, api_key, key_prefix, user_id, user_email, name, is_active
`, params);
if (result.rows.length === 0) {
redis.quit();
return res.status(404).json({
success: false,
error: 'Key not found',
});
}
const key = result.rows[0];
await redis.setex(
`${CACHE_PREFIX}${key.api_key}`,
CACHE_TTL,
JSON.stringify({
id: key.id,
user_id: key.user_id,
user_email: key.user_email,
name: key.name,
is_active: key.is_active,
})
);
redis.quit();
res.json({
success: true,
data: {
id: key.id,
key_prefix: key.key_prefix,
user_id: key.user_id,
user_email: key.user_email,
name: key.name,
is_active: key.is_active,
},
});
} catch (error) {
redis.quit();
internalError(res, error, 'extension_keys_update');
} finally {
client.release();
}
});
export default router;

View file

@ -0,0 +1,83 @@
/**
* Usage telemetry endpoints increment `usage_count` and bump `last_used_at`.
*
* POST /:id/usage by row id (admin / internal)
* POST /usage-by-key by X-API-Key header or body.api_key (extension hot-path)
*
* Both atomic via single SQL UPDATE; no Redis touch (cache only stores user info).
*/
import { Router, Request, Response } from 'express';
import pool from '../../config/database';
import { internalError } from '../../config/error-response';
const router = Router();
router.post('/:id/usage', async (req: Request, res: Response) => {
const { id } = req.params;
const client = await pool.connect();
try {
const result = await client.query(`
UPDATE bos_parammgmt.extension_api_key
SET usage_count = usage_count + 1, last_used_at = NOW()
WHERE id = $1
RETURNING id, usage_count, last_used_at
`, [id]);
if (result.rows.length === 0) {
return res.status(404).json({
success: false,
error: 'Key not found',
});
}
res.json({
success: true,
data: result.rows[0],
});
} catch (error) {
internalError(res, error, 'extension_keys_usage_by_id');
} finally {
client.release();
}
});
router.post('/usage-by-key', async (req: Request, res: Response) => {
const apiKey = req.headers['x-api-key'] as string || req.body.api_key;
if (!apiKey) {
return res.status(400).json({
success: false,
error: 'API key required',
});
}
const client = await pool.connect();
try {
const result = await client.query(`
UPDATE bos_parammgmt.extension_api_key
SET usage_count = usage_count + 1, last_used_at = NOW()
WHERE api_key = $1
RETURNING id, usage_count, last_used_at
`, [apiKey]);
if (result.rows.length === 0) {
return res.status(404).json({
success: false,
error: 'Key not found',
});
}
res.json({
success: true,
data: result.rows[0],
});
} catch (error) {
internalError(res, error, 'extension_keys_usage_by_key');
} finally {
client.release();
}
});
export default router;

View file

@ -0,0 +1,111 @@
/**
* GET /validate Cache-first validation of an X-API-Key header (or ?api_key=).
*
* Hot-path for the browser extension. Strategy:
* 1. Read Redis cache (`didi:extension:key:<apiKey>`) if found and active, return.
* 2. Otherwise fall through to PostgreSQL; on hit, write back to cache (TTL 1h).
* 3. Inactive keys 401 even if present in DB.
*
* The `source` field in the response tells the caller whether the answer came
* from cache or DB (useful for monitoring cache hit-rate).
*/
import { Router, Request, Response } from 'express';
import pool from '../../config/database';
import { internalError } from '../../config/error-response';
import { CACHE_PREFIX, CACHE_TTL, getRedis } from './_shared';
const router = Router();
router.get('/validate', async (req: Request, res: Response) => {
const apiKey = req.headers['x-api-key'] as string || req.query.api_key as string;
if (!apiKey) {
return res.status(400).json({
success: false,
error: 'API key required (X-API-Key header or api_key query param)',
});
}
const redis = getRedis();
try {
const cached = await redis.get(`${CACHE_PREFIX}${apiKey}`);
if (cached) {
const data = JSON.parse(cached);
if (data.is_active) {
redis.quit();
return res.json({
success: true,
data: {
valid: true,
user_id: data.user_id,
user_email: data.user_email,
name: data.name,
source: 'cache',
},
});
}
}
const client = await pool.connect();
try {
const result = await client.query(`
SELECT id, user_id, user_email, name, is_active
FROM bos_parammgmt.extension_api_key
WHERE api_key = $1
`, [apiKey]);
if (result.rows.length === 0) {
redis.quit();
return res.status(401).json({
success: false,
data: { valid: false },
error: 'Invalid API key',
});
}
const key = result.rows[0];
if (!key.is_active) {
redis.quit();
return res.status(401).json({
success: false,
data: { valid: false },
error: 'API key is deactivated',
});
}
await redis.setex(
`${CACHE_PREFIX}${apiKey}`,
CACHE_TTL,
JSON.stringify({
id: key.id,
user_id: key.user_id,
user_email: key.user_email,
name: key.name,
is_active: key.is_active,
})
);
redis.quit();
res.json({
success: true,
data: {
valid: true,
user_id: key.user_id,
user_email: key.user_email,
name: key.name,
source: 'database',
},
});
} finally {
client.release();
}
} catch (error) {
redis.quit();
internalError(res, error, 'extension_keys_validate');
}
});
export default router;

View file

@ -0,0 +1,302 @@
/**
* History Routes - Analysis history API
*
* Returns flat canonical types matching AnalysisSession format.
* Detail endpoint returns SAME shape as GET /api/v3/pipeline/:sessionId/result.
*
* Endpoints:
* - GET /api/history/admin - Admin paginated list with filters
* - GET /api/history - User paginated list
* - GET /api/history/:sessionId - Full analysis detail (flat canonical types)
* - DELETE /api/history/:sessionId - Delete analysis
*/
import { Router, Request, Response } from 'express';
import { log } from '../config/logger';
import { internalError } from '../config/error-response';
import pool from '../config/database';
const router = Router();
const S = 'bos_analysis';
// ============================================================================
// Shared: list query helper
// ============================================================================
const LIST_SELECT = `
s.session_id, s.user_id, s.user_email, s.input_type,
CASE WHEN s.input_text IS NULL THEN NULL
WHEN char_length(s.input_text) > 200
THEN left(convert_from(convert_to(s.input_text, 'UTF8'), 'UTF8'), 200) || '...'
ELSE convert_from(convert_to(s.input_text, 'UTF8'), 'UTF8')
END as input_preview,
s.input_url, s.status, s.started_at, s.completed_at, s.total_duration_ms, s.source_app,
v.risk_score, v.risk_category, v.risk_level, v.confidence, v.confidence_level,
v.explanation_ro, v.explanation_en,
t.manipulation_score as techniques_score, t.techniques_count,
ai.ai_probability, ai.verdict as ai_verdict,
c.total_claims, c.verified_true, c.verified_false, c.credibility_score as claims_score,
d.domain, d.verdict as domain_verdict, d.trust_score as domain_trust_score,
sa.trust_score as source_trust_score, sa.verdict as source_verdict,
sa.publication->>'name' as source_publication,
sa.author->>'name' as source_author,
sa.platform->>'name' as source_platform`;
const LIST_JOINS = `
FROM ${S}.analysis_session s
LEFT JOIN ${S}.analysis_verdict v ON s.session_id = v.session_id
LEFT JOIN ${S}.analysis_techniques t ON s.session_id = t.session_id
LEFT JOIN ${S}.analysis_ai_tampered ai ON s.session_id = ai.session_id
LEFT JOIN ${S}.analysis_claims c ON s.session_id = c.session_id
LEFT JOIN ${S}.analysis_domain d ON s.session_id = d.session_id
LEFT JOIN ${S}.analysis_source_assessment sa ON s.session_id = sa.session_id`;
interface ListParams {
user_id?: string;
search?: string;
status?: string;
risk_level?: string;
from_date?: string;
to_date?: string;
page: number;
limit: number;
}
function parseListParams(query: any, requireUserId: boolean): ListParams | null {
const page = Math.max(1, parseInt(query.page as string, 10) || 1);
const limit = Math.min(100, Math.max(1, parseInt(query.limit as string, 10) || 20));
if (requireUserId && !query.user_id) return null;
return { user_id: query.user_id, search: query.search, status: query.status, risk_level: query.risk_level, from_date: query.from_date, to_date: query.to_date, page, limit };
}
async function queryList(p: ListParams, isAdmin: boolean) {
const conds: string[] = [];
const vals: any[] = [];
let i = 1;
if (p.user_id) { conds.push(`s.user_id = $${i++}`); vals.push(p.user_id); }
if (isAdmin && p.search) { conds.push(`(s.user_email ILIKE $${i} OR s.user_id ILIKE $${i})`); vals.push(`%${p.search}%`); i++; }
if (p.status) { conds.push(`s.status = $${i++}`); vals.push(p.status); }
if (p.risk_level) { conds.push(`v.risk_level = $${i++}`); vals.push(p.risk_level); }
if (p.from_date) { conds.push(`s.created_at >= $${i++}`); vals.push(p.from_date); }
if (p.to_date) { conds.push(`s.created_at <= $${i++}`); vals.push(p.to_date); }
const where = conds.length ? `WHERE ${conds.join(' AND ')}` : '';
const offset = (p.page - 1) * p.limit;
const client = await pool.connect();
try {
const countRes = await client.query(`SELECT COUNT(*) as total FROM ${S}.analysis_session s LEFT JOIN ${S}.analysis_verdict v ON s.session_id = v.session_id ${where}`, vals);
const total = parseInt(countRes.rows[0].total, 10);
const dataRes = await client.query(`SELECT ${LIST_SELECT} ${LIST_JOINS} ${where} ORDER BY s.created_at DESC LIMIT $${i} OFFSET $${i + 1}`, [...vals, p.limit, offset]);
return {
items: dataRes.rows,
pagination: { page: p.page, limit: p.limit, total, total_pages: Math.ceil(total / p.limit), has_next: p.page * p.limit < total, has_prev: p.page > 1 },
};
} finally {
client.release();
}
}
// ============================================================================
// Flat canonical mappers (same output as PgAdapter in agent-v3)
// ============================================================================
export function mapTechniques(r: any) {
if (!r) return null;
return {
manipulation_score: Number(r.manipulation_score), total_severity: r.total_severity,
dimensions_affected: r.dimensions_affected || [], techniques_count: r.techniques_count,
techniques_detected: r.techniques_detected || [], coupling_context: r.coupling_context || {},
llm_screening: r.llm_screening, llm_deep: r.llm_deep,
screening_duration_ms: r.screening_duration_ms, deep_analysis_duration_ms: r.deep_analysis_duration_ms,
total_duration_ms: r.total_duration_ms,
fallbacks_screening: r.fallbacks_screening ?? 0, fallbacks_deep: r.fallbacks_deep ?? 0,
};
}
export function mapAiTampered(r: any) {
if (!r) return null;
return {
ai_probability: Number(r.ai_probability), verdict: r.verdict, risk_score: Number(r.risk_score),
categories_affected: r.categories_affected || [], indicators_count: r.indicators_count,
disclosure_detected: r.disclosure_detected ?? false, disclosure_explicit: r.disclosure_explicit ?? false,
disclosure_text: r.disclosure_text,
indicators_detected: r.indicators_detected || [], coupling_context: r.coupling_context || {},
llm_screening: r.llm_screening, llm_deep: r.llm_deep,
screening_duration_ms: r.screening_duration_ms, deep_analysis_duration_ms: r.deep_analysis_duration_ms,
total_duration_ms: r.total_duration_ms,
fallbacks_screening: r.fallbacks_screening ?? 0, fallbacks_deep: r.fallbacks_deep ?? 0,
content_type: r.content_type || 'text', image_analysis: r.image_analysis ?? null,
};
}
export function mapClaims(r: any) {
if (!r) return null;
return {
total_claims: r.total_claims, verified_true: r.verified_true, verified_false: r.verified_false,
unverified: r.unverified, opinions: r.opinions,
credibility_score: r.credibility_score != null ? Number(r.credibility_score) : null,
interpretation: r.interpretation,
claims_by_status: r.claims_by_status || {}, claims_by_type: r.claims_by_type || {},
claims_verified: r.claims_verified || [],
llm_extraction: r.llm_extraction, llm_verification: r.llm_verification,
extraction_duration_ms: r.extraction_duration_ms, verification_duration_ms: r.verification_duration_ms,
total_duration_ms: r.total_duration_ms, web_searches_made: r.web_searches_made ?? 0,
};
}
export function mapDomain(r: any) {
if (!r) return null;
return {
domain: r.domain, verdict: r.verdict, trust_score: r.trust_score, risk_level: r.risk_level,
age_days: r.age_days, age_category: r.age_category, domain_created_at: r.domain_created_at ?? null,
is_blacklisted: r.is_blacklisted ?? false, reputation_score: r.reputation_score,
has_ssl: r.has_ssl, ssl_valid: r.ssl_valid, ssl_issuer: r.ssl_issuer,
registrar: r.registrar, organization: r.organization, country: r.country,
red_flags: r.red_flags || [], warnings: r.warnings || [], duration_ms: r.duration_ms,
};
}
export function mapSourceAssessment(r: any) {
if (!r) return null;
return {
trust_score: Number(r.trust_score), verdict: r.verdict, risk_level: r.risk_level,
publication: r.publication || { name: null, source_type: 'Anonymous source', source_type_id: 11, score: 20, confirmed: false },
author: r.author || { name: null, classification: 'Anonymous', classification_code: 'AUTH_ANON', score: 60, confirmed: false, credibility_indicators: [] },
platform: r.platform || { code: 'PLAT_UNKNOWN', name: 'Unknown/Other', score: 30, modifiers: [] },
domain: r.domain || { name: null, age_days: null, risk_score: null, score: 50, has_ssl: null, is_blacklisted: false, registrar: null, organization: null, country: null, red_flags: [] },
formula: r.formula || { publication_weight: 0.35, domain_weight: 0.25, author_weight: 0.25, platform_weight: 0.15, breakdown: '' },
warnings: r.warnings || [], red_flags: r.red_flags || [],
search_queries_used: r.search_queries_used || [], search_results_count: r.search_results_count ?? 0,
duration_ms: r.duration_ms ?? 0, llm_model_used: r.llm_model_used,
};
}
export function mapVerdict(r: any) {
if (!r) return null;
return {
risk_score: r.risk_score, risk_category: r.risk_category, risk_category_color: r.risk_category_color,
risk_level: r.risk_level, risk_level_color: r.risk_level_color,
severity: r.severity, recommended_action: r.recommended_action,
confidence: r.confidence, confidence_level: r.confidence_level,
score_manipulation: r.score_manipulation != null ? Number(r.score_manipulation) : null,
score_claims: r.score_claims != null ? Number(r.score_claims) : null,
score_ai: r.score_ai != null ? Number(r.score_ai) : null,
score_source: r.score_source != null ? Number(r.score_source) : null,
score_context: r.score_context != null ? Number(r.score_context) : null,
applied_weights: r.applied_weights || {}, override_applied: r.override_applied ?? false,
override_type: r.override_type, override_reason: r.override_reason,
override_adjustment: r.override_adjustment,
context_summary: r.context_summary || {}, components_used: r.components_used || [],
weights_source: r.weights_source, duration_ms: r.duration_ms,
explanation_ro: r.explanation_ro ?? null, explanation_en: r.explanation_en ?? null,
};
}
// ============================================================================
// GET /api/history/admin - Admin paginated list with filters
// ============================================================================
router.get('/admin', async (req: Request, res: Response) => {
try {
const p = parseListParams(req.query, false);
if (!p) return res.status(400).json({ success: false, error: 'Invalid parameters' });
const result = await queryList(p, true);
res.json({ success: true, data: result });
} catch (error) {
log.error('[History Admin] Error:', error);
internalError(res, error);
}
});
// ============================================================================
// GET /api/history - User paginated list
// ============================================================================
router.get('/', async (req: Request, res: Response) => {
try {
const p = parseListParams(req.query, true);
if (!p) return res.status(400).json({ success: false, error: 'user_id is required' });
const result = await queryList(p, false);
res.json({ success: true, data: result });
} catch (error) {
log.error('[History] Error:', error);
internalError(res, error);
}
});
// ============================================================================
// GET /api/history/:sessionId - Full analysis detail (flat canonical types)
// ============================================================================
router.get('/:sessionId', async (req: Request, res: Response) => {
const { sessionId } = req.params;
const client = await pool.connect();
try {
const sRes = await client.query(`SELECT * FROM ${S}.analysis_session WHERE session_id = $1`, [sessionId]);
if (sRes.rows.length === 0) return res.status(404).json({ success: false, error: 'Session not found' });
const [tRes, aiRes, cRes, dRes, saRes, vRes] = await Promise.all([
client.query(`SELECT * FROM ${S}.analysis_techniques WHERE session_id = $1`, [sessionId]),
client.query(`SELECT * FROM ${S}.analysis_ai_tampered WHERE session_id = $1`, [sessionId]),
client.query(`SELECT * FROM ${S}.analysis_claims WHERE session_id = $1`, [sessionId]),
client.query(`SELECT * FROM ${S}.analysis_domain WHERE session_id = $1`, [sessionId]),
client.query(`SELECT * FROM ${S}.analysis_source_assessment WHERE session_id = $1`, [sessionId]),
client.query(`SELECT * FROM ${S}.analysis_verdict WHERE session_id = $1`, [sessionId]),
]);
const s = sRes.rows[0];
res.json({
success: true,
data: {
session_id: s.session_id, user_id: s.user_id, user_email: s.user_email,
input_type: s.input_type, input_text: s.input_text, input_url: s.input_url,
input_media_url: s.input_media_url, input_hash: s.input_hash,
status: s.status, components_run: s.components_run || [], components_skipped: s.components_skipped || [],
risk_score: s.risk_score, risk_category: s.risk_category, risk_level: s.risk_level,
confidence: s.confidence, confidence_level: s.confidence_level,
started_at: s.started_at, completed_at: s.completed_at, total_duration_ms: s.total_duration_ms,
scenario_applied: s.scenario_applied, topic_applied: s.topic_applied,
source_app: s.source_app || 'web', api_version: s.api_version || 'v3', created_at: s.created_at,
techniques: mapTechniques(tRes.rows[0]),
ai_tampered: mapAiTampered(aiRes.rows[0]),
claims: mapClaims(cRes.rows[0]),
domain: mapDomain(dRes.rows[0]),
source_assessment: saRes.rows[0] ? mapSourceAssessment(saRes.rows[0]) : null,
verdict: mapVerdict(vRes.rows[0]),
},
});
} catch (error) {
log.error('[History Detail] Error:', error);
internalError(res, error);
} finally {
client.release();
}
});
// ============================================================================
// DELETE /api/history/:sessionId - Delete analysis
// ============================================================================
router.delete('/:sessionId', async (req: Request, res: Response) => {
const { sessionId } = req.params;
const { user_id } = req.query;
if (!user_id) return res.status(400).json({ success: false, error: 'user_id is required' });
const client = await pool.connect();
try {
const check = await client.query(`SELECT user_id FROM ${S}.analysis_session WHERE session_id = $1`, [sessionId]);
if (check.rows.length === 0) return res.status(404).json({ success: false, error: 'Session not found' });
if (check.rows[0].user_id !== user_id) return res.status(403).json({ success: false, error: 'Not authorized to delete this session' });
await client.query(`DELETE FROM ${S}.analysis_session WHERE session_id = $1`, [sessionId]);
res.json({ success: true, message: 'Analysis deleted successfully' });
} catch (error) {
log.error('[History Delete] Error:', error);
internalError(res, error);
} finally {
client.release();
}
});
export default router;

View file

@ -0,0 +1,380 @@
import { Router, Request, Response } from 'express';
import { query, queryOne, transaction } from '../config/database';
import { TechniqueIndicator, ApiResponse } from '../types';
import { PoolClient } from 'pg';
import { internalError } from '../config/error-response';
const router = Router();
// Parameter type for indicators
const PARAMETER_TYPE_INDICATOR = 4;
// Helper: Create parameter entry and return parameter_id
const createParameter = async (client: PoolClient): Promise<number> => {
// Get next parameter_id
const maxResult = await client.query('SELECT COALESCE(MAX(parameter_id), 0) + 1 as next_id FROM parameter');
const nextParamId = maxResult.rows[0].next_id;
await client.query(`
INSERT INTO parameter (parameter_id, parameter_type, valid_from, valid_to, created_date, updated_date)
VALUES ($1, $2, '2026-01-01', '2999-01-01', CURRENT_DATE, CURRENT_DATE)
`, [nextParamId, PARAMETER_TYPE_INDICATOR]);
return nextParamId;
};
// Helper: Get next indicator_id for a technique
const getNextIndicatorId = async (client: PoolClient, techniqueId: number): Promise<number> => {
const result = await client.query(`
SELECT COALESCE(MAX(indicator_id), 0) + 1 as next_id
FROM technique_indicator
WHERE technique_id = $1
`, [techniqueId]);
return result.rows[0].next_id;
};
// GET all indicators
router.get('/', async (req: Request, res: Response) => {
try {
const indicators = await query<TechniqueIndicator>(
'SELECT * FROM technique_indicator ORDER BY technique_id, indicator_id'
);
res.json({
success: true,
data: indicators,
count: indicators.length
} as ApiResponse<TechniqueIndicator[]>);
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
} as ApiResponse<never>);
}
});
// GET indicators by technique_id
router.get('/by-technique/:techniqueId', async (req: Request, res: Response) => {
try {
const indicators = await query<TechniqueIndicator>(
'SELECT * FROM technique_indicator WHERE technique_id = $1 ORDER BY indicator_id',
[req.params.techniqueId]
);
res.json({
success: true,
data: indicators,
count: indicators.length
} as ApiResponse<TechniqueIndicator[]>);
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
} as ApiResponse<never>);
}
});
// GET techniques without indicators (for bulk creation planning)
router.get('/missing', async (req: Request, res: Response) => {
try {
const techniques = await query<any>(`
SELECT t.technique_id, t.technique_name, t.severity,
d.dimension_code, d.dimension_name,
s.subdimension_code, s.subdmiension_name as subdimension_name
FROM technique t
JOIN subdimension s ON t.subdimension_id = s.subdimension_id
JOIN dimension d ON s.dimension_id = d.dimension_id
WHERE t.technique_id NOT IN (
SELECT DISTINCT technique_id FROM technique_indicator
)
ORDER BY d.dimension_id, s.subdimension_id, t.technique_id
`);
res.json({
success: true,
data: techniques,
count: techniques.length
});
} catch (error) {
internalError(res, error);
}
});
// GET statistics
router.get('/stats', async (req: Request, res: Response) => {
try {
const stats = await query<any>(`
SELECT
(SELECT COUNT(*) FROM technique) as total_techniques,
(SELECT COUNT(DISTINCT technique_id) FROM technique_indicator) as techniques_with_indicators,
(SELECT COUNT(*) FROM technique_indicator) as total_indicators,
(SELECT COUNT(*) FROM technique WHERE technique_id NOT IN (SELECT DISTINCT technique_id FROM technique_indicator)) as techniques_missing_indicators
`);
res.json({
success: true,
data: stats[0]
});
} catch (error) {
internalError(res, error);
}
});
// POST - Create single indicator
router.post('/', async (req: Request, res: Response) => {
try {
const { technique_id, indicator_name, description, max_intensity } = req.body;
if (!technique_id || !indicator_name || !description || !max_intensity) {
return res.status(400).json({
success: false,
error: 'Missing required fields: technique_id, indicator_name, description, max_intensity'
});
}
const result = await transaction(async (client) => {
// Create parameter entry
const parameterId = await createParameter(client);
// Get next indicator_id for this technique
const indicatorId = await getNextIndicatorId(client, technique_id);
// Insert indicator
const insertResult = await client.query(`
INSERT INTO technique_indicator (technique_id, indicator_id, indicator_name, description, max_intensity, parameter_id)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *
`, [technique_id, indicatorId, indicator_name, description, max_intensity, parameterId]);
return insertResult.rows[0];
});
res.status(201).json({
success: true,
data: result
});
} catch (error) {
internalError(res, error);
}
});
// POST /bulk - Create multiple indicators for one or more techniques
router.post('/bulk', async (req: Request, res: Response) => {
try {
const { indicators } = req.body;
if (!indicators || !Array.isArray(indicators) || indicators.length === 0) {
return res.status(400).json({
success: false,
error: 'Missing required field: indicators (array of {technique_id, indicator_name, description, max_intensity})'
});
}
// Validate all indicators
for (const ind of indicators) {
if (!ind.technique_id || !ind.indicator_name || !ind.description || !ind.max_intensity) {
return res.status(400).json({
success: false,
error: `Invalid indicator: ${JSON.stringify(ind)}. Required: technique_id, indicator_name, description, max_intensity`
});
}
}
const result = await transaction(async (client) => {
const created: any[] = [];
// Group by technique_id to manage indicator_id sequences
const byTechnique: Record<number, typeof indicators> = {};
for (const ind of indicators) {
if (!byTechnique[ind.technique_id]) {
byTechnique[ind.technique_id] = [];
}
byTechnique[ind.technique_id].push(ind);
}
// Process each technique's indicators
for (const [techIdStr, techIndicators] of Object.entries(byTechnique)) {
const techId = parseInt(techIdStr);
let nextIndicatorId = await getNextIndicatorId(client, techId);
for (const ind of techIndicators) {
// Create parameter entry
const parameterId = await createParameter(client);
// Insert indicator
const insertResult = await client.query(`
INSERT INTO technique_indicator (technique_id, indicator_id, indicator_name, description, max_intensity, parameter_id)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *
`, [techId, nextIndicatorId, ind.indicator_name, ind.description, ind.max_intensity, parameterId]);
created.push(insertResult.rows[0]);
nextIndicatorId++;
}
}
return created;
});
res.status(201).json({
success: true,
data: result,
count: result.length
});
} catch (error) {
internalError(res, error);
}
});
// POST /bulk-for-technique - Create multiple indicators for a single technique (simpler format)
router.post('/bulk-for-technique/:techniqueId', async (req: Request, res: Response) => {
try {
const techniqueId = parseInt(req.params.techniqueId);
const { indicators } = req.body;
if (!indicators || !Array.isArray(indicators) || indicators.length === 0) {
return res.status(400).json({
success: false,
error: 'Missing required field: indicators (array of {indicator_name, description, max_intensity})'
});
}
const result = await transaction(async (client) => {
const created: any[] = [];
let nextIndicatorId = await getNextIndicatorId(client, techniqueId);
for (const ind of indicators) {
if (!ind.indicator_name || !ind.description || !ind.max_intensity) {
throw new Error(`Invalid indicator: ${JSON.stringify(ind)}`);
}
// Create parameter entry
const parameterId = await createParameter(client);
// Insert indicator
const insertResult = await client.query(`
INSERT INTO technique_indicator (technique_id, indicator_id, indicator_name, description, max_intensity, parameter_id)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *
`, [techniqueId, nextIndicatorId, ind.indicator_name, ind.description, ind.max_intensity, parameterId]);
created.push(insertResult.rows[0]);
nextIndicatorId++;
}
return created;
});
res.status(201).json({
success: true,
data: result,
count: result.length
});
} catch (error) {
internalError(res, error);
}
});
// PUT - Update indicator
router.put('/:techniqueId/:indicatorId', async (req: Request, res: Response) => {
try {
const { techniqueId, indicatorId } = req.params;
const { indicator_name, description, max_intensity } = req.body;
const updates: string[] = [];
const values: any[] = [];
let paramIndex = 1;
if (indicator_name) {
updates.push(`indicator_name = $${paramIndex++}`);
values.push(indicator_name);
}
if (description) {
updates.push(`description = $${paramIndex++}`);
values.push(description);
}
if (max_intensity) {
updates.push(`max_intensity = $${paramIndex++}`);
values.push(max_intensity);
}
if (updates.length === 0) {
return res.status(400).json({
success: false,
error: 'No fields to update'
});
}
values.push(techniqueId, indicatorId);
const result = await query<TechniqueIndicator>(`
UPDATE technique_indicator
SET ${updates.join(', ')}
WHERE technique_id = $${paramIndex++} AND indicator_id = $${paramIndex}
RETURNING *
`, values);
if (result.length === 0) {
return res.status(404).json({
success: false,
error: 'Indicator not found'
});
}
res.json({
success: true,
data: result[0]
});
} catch (error) {
internalError(res, error);
}
});
// DELETE all indicators for a technique — declarat înainte de '/:techniqueId/:indicatorId',
// altfel 'by-technique' e capturat ca :techniqueId și ruta devine inaccesibilă
router.delete('/by-technique/:techniqueId', async (req: Request, res: Response) => {
try {
const { techniqueId } = req.params;
const result = await query<TechniqueIndicator>(`
DELETE FROM technique_indicator
WHERE technique_id = $1
RETURNING *
`, [techniqueId]);
res.json({
success: true,
data: result,
count: result.length,
message: `Deleted ${result.length} indicators for technique ${techniqueId}`
});
} catch (error) {
internalError(res, error);
}
});
// DELETE - Delete indicator
router.delete('/:techniqueId/:indicatorId', async (req: Request, res: Response) => {
try {
const { techniqueId, indicatorId } = req.params;
const result = await query<TechniqueIndicator>(`
DELETE FROM technique_indicator
WHERE technique_id = $1 AND indicator_id = $2
RETURNING *
`, [techniqueId, indicatorId]);
if (result.length === 0) {
return res.status(404).json({
success: false,
error: 'Indicator not found'
});
}
res.json({
success: true,
data: result[0],
message: 'Indicator deleted'
});
} catch (error) {
internalError(res, error);
}
});
export default router;

View file

@ -0,0 +1,553 @@
/**
* INPUT PROFILES ROUTES pipeline definitions (CRUD + lifecycle).
*
* Un input profile este DEFINIȚIA DE PIPELINE a platformei: ce componente
* rulează, cu ce ponderi/roluri/praguri, per tip de input. Mounted at both
* /api/input-profiles and /api/pipelines (alias, see server.ts).
*
* GET / list all profiles with overrides
* GET /:code single profile with overrides
* PUT /:code update (snapshots previous state as a version)
* POST /:code/clone clone into a new (inactive) profile
* POST /:code/activate publish (is_active=true)
* POST /:code/deactivate unpublish (is_active=false)
* GET /:code/versions version history (snapshots)
* POST /:code/versions/:id/restore restore a snapshot (current state is versioned first)
* GET /:code/overrides override configs for profile
* PUT /:code/overrides update override configs
* GET/PUT /scoring-config/:component scoring config (component_config PG)
*/
import { Router, type Request, type Response } from 'express';
import { query } from '../config/database';
import { internalError } from '../config/error-response';
const router = Router();
// Identity for the version audit trail. The JWT signature was already
// verified by the global gate (config/jwt-verify.ts) — this only reads claims.
function changedBy(req: Request): string | null {
const auth = req.headers.authorization;
if (!auth?.startsWith('Bearer ')) return null;
const parts = auth.slice(7).split('.');
if (parts.length !== 3) return null;
try {
const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString('utf8'));
return payload.email || payload.preferred_username || payload.sub || null;
} catch {
return null;
}
}
/** Snapshot the CURRENT state of a profile (+overrides) into the version table. */
async function snapshotProfile(code: string, user: string | null, note: string): Promise<number | null> {
const rows = await query('SELECT * FROM input_type_profile WHERE profile_code = $1', [code]);
if (rows.length === 0) return null;
const overrides = await query(
'SELECT * FROM profile_override_config WHERE profile_code = $1 ORDER BY override_code',
[code],
);
const result = await query(
`INSERT INTO input_type_profile_version (profile_code, version_no, snapshot, changed_by, change_note)
VALUES ($1::varchar,
COALESCE((SELECT MAX(version_no) FROM input_type_profile_version WHERE profile_code = $1::varchar), 0) + 1,
$2, $3, $4)
RETURNING version_no`,
[code, JSON.stringify({ ...rows[0], overrides }), user, note],
);
return result[0].version_no;
}
// Fields copied verbatim when importing a pipeline JSON (whitelist — ignore
// profile_id/created_date and any unknown keys so an export round-trips safely).
const IMPORTABLE_FIELDS = [
'profile_name', 'description',
'weight_techniques', 'weight_claims', 'weight_ai_tampered', 'weight_source',
'role_techniques', 'role_claims', 'role_ai_tampered', 'role_source',
'min_components', 'primary_components', 'required_any',
'missing_techniques', 'missing_claims', 'missing_ai_tampered', 'missing_source',
'override_cap', 'confidence_config', 'ai_disclosure_multipliers',
] as const;
// ============================================================================
// POST /import — Create a pipeline from an exported JSON definition
// ============================================================================
router.post('/import', async (req: Request, res: Response) => {
try {
const body = req.body || {};
// Accept either a raw profile object or { profile, overrides } (export shape).
const profile = body.profile || body;
const overrides = Array.isArray(body.overrides) ? body.overrides
: Array.isArray(profile.overrides) ? profile.overrides : [];
const code = body.new_code || profile.profile_code;
if (!code || !/^[a-z0-9_-]{2,50}$/.test(code)) {
return res.status(400).json({ success: false, error: 'profile_code / new_code required (lowercase, [a-z0-9_-], 2-50 chars)' });
}
const exists = await query('SELECT 1 FROM input_type_profile WHERE profile_code = $1', [code]);
if (exists.length > 0) {
return res.status(409).json({ success: false, error: `Profile '${code}' already exists (delete or pick a new_code)` });
}
// Weight sum guard (same rule as PUT) when all four are present.
const w = ['weight_techniques', 'weight_claims', 'weight_ai_tampered', 'weight_source'];
if (w.every(k => profile[k] != null)) {
const total = w.reduce((s, k) => s + Number(profile[k]), 0);
if (total !== 100) return res.status(400).json({ success: false, error: `Weights must sum to 100 (got ${total})` });
}
const cols = ['profile_code'];
const vals: any[] = [code];
for (const f of IMPORTABLE_FIELDS) {
if (profile[f] === undefined) continue;
cols.push(f);
vals.push(f.includes('config') || f.includes('multipliers')
? (typeof profile[f] === 'string' ? profile[f] : JSON.stringify(profile[f]))
: profile[f]);
}
cols.push('is_active'); vals.push(false); // imports land INACTIVE — activate explicitly
cols.push('created_date'); vals.push(new Date().toISOString().slice(0, 10));
const placeholders = vals.map((_, i) => `$${i + 1}`).join(', ');
const created = await query(
`INSERT INTO input_type_profile (${cols.join(', ')}) VALUES (${placeholders}) RETURNING *`,
vals,
);
// Import overrides too (best-effort: only rows whose override_code exists in schema).
for (const ov of overrides) {
if (!ov?.override_code) continue;
await query(
`INSERT INTO profile_override_config (profile_code, override_code, enabled, threshold, bonus_per_unit, bonus_fixed, max_bonus)
VALUES ($1,$2,$3,$4,$5,$6,$7)
ON CONFLICT DO NOTHING`,
[code, ov.override_code, ov.enabled ?? true, ov.threshold ?? null, ov.bonus_per_unit ?? null, ov.bonus_fixed ?? null, ov.max_bonus ?? null],
);
}
await snapshotProfile(code, changedBy(req), 'imported from JSON');
res.status(201).json({ success: true, data: created[0], message: `Pipeline '${code}' imported (inactive — activate to publish)` });
} catch (error) {
internalError(res, error);
}
});
// ============================================================================
// GET / — List all profiles with override summary
// ============================================================================
router.get('/', async (_req: Request, res: Response) => {
try {
const profiles = await query(`
SELECT p.*,
(SELECT json_agg(json_build_object(
'override_code', o.override_code,
'enabled', o.enabled,
'threshold', o.threshold,
'bonus_per_unit', o.bonus_per_unit,
'bonus_fixed', o.bonus_fixed,
'max_bonus', o.max_bonus
) ORDER BY o.override_code)
FROM profile_override_config o WHERE o.profile_code = p.profile_code
) as overrides
FROM input_type_profile p
ORDER BY p.profile_id
`);
res.json({ success: true, data: profiles, count: profiles.length });
} catch (error) {
internalError(res, error);
}
});
// ============================================================================
// GET /:code — Single profile with overrides
// ============================================================================
router.get('/:code', async (req: Request, res: Response) => {
try {
const { code } = req.params;
const profiles = await query(
'SELECT * FROM input_type_profile WHERE profile_code = $1',
[code]
);
if (profiles.length === 0) {
return res.status(404).json({ success: false, error: `Profile '${code}' not found` });
}
const overrides = await query(
'SELECT * FROM profile_override_config WHERE profile_code = $1 ORDER BY override_code',
[code]
);
res.json({ success: true, data: { ...profiles[0], overrides } });
} catch (error) {
internalError(res, error);
}
});
// ============================================================================
// PUT /:code — Update profile weights, rules, confidence
// ============================================================================
router.put('/:code', async (req: Request, res: Response) => {
try {
const { code } = req.params;
const {
profile_name, description,
weight_techniques, weight_claims, weight_ai_tampered, weight_source,
role_techniques, role_claims, role_ai_tampered, role_source,
min_components, primary_components, required_any,
missing_techniques, missing_claims, missing_ai_tampered, missing_source,
override_cap, confidence_config, ai_disclosure_multipliers,
} = req.body;
// Validate weights sum to 100 if all provided
if (weight_techniques != null && weight_claims != null && weight_ai_tampered != null && weight_source != null) {
const total = weight_techniques + weight_claims + weight_ai_tampered + weight_source;
if (total !== 100) {
return res.status(400).json({ success: false, error: `Weights must sum to 100 (got ${total})` });
}
}
// Build SET clause dynamically (only update provided fields)
const updates: string[] = [];
const values: any[] = [];
let paramIdx = 1;
const addField = (field: string, value: any) => {
if (value !== undefined) {
updates.push(`${field} = $${paramIdx++}`);
values.push(field.includes('config') || field.includes('multipliers')
? (typeof value === 'string' ? value : JSON.stringify(value))
: value
);
}
};
addField('profile_name', profile_name);
addField('description', description);
addField('weight_techniques', weight_techniques);
addField('weight_claims', weight_claims);
addField('weight_ai_tampered', weight_ai_tampered);
addField('weight_source', weight_source);
addField('role_techniques', role_techniques);
addField('role_claims', role_claims);
addField('role_ai_tampered', role_ai_tampered);
addField('role_source', role_source);
addField('min_components', min_components);
addField('primary_components', primary_components);
addField('required_any', required_any);
addField('missing_techniques', missing_techniques);
addField('missing_claims', missing_claims);
addField('missing_ai_tampered', missing_ai_tampered);
addField('missing_source', missing_source);
addField('override_cap', override_cap);
addField('confidence_config', confidence_config);
addField('ai_disclosure_multipliers', ai_disclosure_multipliers);
if (updates.length === 0) {
return res.status(400).json({ success: false, error: 'No fields to update' });
}
updates.push(`updated_date = CURRENT_DATE`);
values.push(code);
// Version the previous state BEFORE mutating — GET /:code/versions shows
// the full change history; restore brings any snapshot back.
const versionNo = await snapshotProfile(code, changedBy(req), req.body.change_note || 'update');
if (versionNo === null) {
return res.status(404).json({ success: false, error: `Profile '${code}' not found` });
}
const result = await query(
`UPDATE input_type_profile SET ${updates.join(', ')} WHERE profile_code = $${paramIdx} RETURNING *`,
values
);
if (result.length === 0) {
return res.status(404).json({ success: false, error: `Profile '${code}' not found` });
}
res.json({ success: true, data: result[0], version_saved: versionNo, message: `Profile '${code}' updated` });
} catch (error) {
internalError(res, error);
}
});
// ============================================================================
// POST /:code/clone — Clone a profile into a new (inactive) pipeline definition
// ============================================================================
router.post('/:code/clone', async (req: Request, res: Response) => {
try {
const { code } = req.params;
const { new_code, new_name } = req.body;
if (!new_code || !/^[a-z0-9_-]{2,50}$/.test(new_code)) {
return res.status(400).json({ success: false, error: 'new_code is required (lowercase, [a-z0-9_-], 2-50 chars)' });
}
const source = await query('SELECT * FROM input_type_profile WHERE profile_code = $1', [code]);
if (source.length === 0) {
return res.status(404).json({ success: false, error: `Profile '${code}' not found` });
}
const existing = await query('SELECT 1 FROM input_type_profile WHERE profile_code = $1', [new_code]);
if (existing.length > 0) {
return res.status(409).json({ success: false, error: `Profile '${new_code}' already exists` });
}
// Clone the profile row — new clones start UNPUBLISHED (is_active=false);
// activation is an explicit lifecycle step.
const cloned = await query(
`INSERT INTO input_type_profile (
profile_code, profile_name, description,
weight_techniques, weight_claims, weight_ai_tampered, weight_source,
role_techniques, role_claims, role_ai_tampered, role_source,
min_components, primary_components, required_any,
missing_techniques, missing_claims, missing_ai_tampered, missing_source,
override_cap, confidence_config, ai_disclosure_multipliers,
is_active, created_date, updated_date)
SELECT $2, $3, description,
weight_techniques, weight_claims, weight_ai_tampered, weight_source,
role_techniques, role_claims, role_ai_tampered, role_source,
min_components, primary_components, required_any,
missing_techniques, missing_claims, missing_ai_tampered, missing_source,
override_cap, confidence_config, ai_disclosure_multipliers,
false, CURRENT_DATE, CURRENT_DATE
FROM input_type_profile WHERE profile_code = $1
RETURNING *`,
[code, new_code, new_name || `${source[0].profile_name} (clone)`],
);
// Clone the override configs too
await query(
`INSERT INTO profile_override_config (profile_code, override_code, enabled, threshold, bonus_per_unit, bonus_fixed, max_bonus)
SELECT $2, override_code, enabled, threshold, bonus_per_unit, bonus_fixed, max_bonus
FROM profile_override_config WHERE profile_code = $1`,
[code, new_code],
);
await snapshotProfile(new_code, changedBy(req), `cloned from '${code}'`);
res.status(201).json({
success: true,
data: cloned[0],
message: `Profile '${new_code}' cloned from '${code}' (inactive — activate to publish)`,
});
} catch (error) {
internalError(res, error);
}
});
// ============================================================================
// POST /:code/activate | /:code/deactivate — publish / unpublish
// ============================================================================
for (const action of ['activate', 'deactivate'] as const) {
router.post(`/:code/${action}`, async (req: Request, res: Response) => {
try {
const { code } = req.params;
const result = await query(
'UPDATE input_type_profile SET is_active = $1, updated_date = CURRENT_DATE WHERE profile_code = $2 RETURNING profile_code, profile_name, is_active',
[action === 'activate', code],
);
if (result.length === 0) {
return res.status(404).json({ success: false, error: `Profile '${code}' not found` });
}
await snapshotProfile(code, changedBy(req), action);
res.json({ success: true, data: result[0], message: `Profile '${code}' ${action}d` });
} catch (error) {
internalError(res, error);
}
});
}
// ============================================================================
// GET /:code/versions — version history (snapshots, newest first)
// ============================================================================
router.get('/:code/versions', async (req: Request, res: Response) => {
try {
const versions = await query(
`SELECT version_id, version_no, changed_at, changed_by, change_note, snapshot
FROM input_type_profile_version
WHERE profile_code = $1
ORDER BY version_no DESC`,
[req.params.code],
);
res.json({ success: true, data: versions, count: versions.length });
} catch (error) {
internalError(res, error);
}
});
// ============================================================================
// POST /:code/versions/:versionId/restore — roll back to a snapshot
// ============================================================================
router.post('/:code/versions/:versionId/restore', async (req: Request, res: Response) => {
try {
const { code, versionId } = req.params;
const versions = await query(
'SELECT snapshot, version_no FROM input_type_profile_version WHERE profile_code = $1 AND version_id = $2',
[code, versionId],
);
if (versions.length === 0) {
return res.status(404).json({ success: false, error: `Version ${versionId} not found for '${code}'` });
}
const snap = typeof versions[0].snapshot === 'string' ? JSON.parse(versions[0].snapshot) : versions[0].snapshot;
// Version current state first so restore itself is reversible.
await snapshotProfile(code, changedBy(req), `pre-restore of v${versions[0].version_no}`);
const result = await query(
`UPDATE input_type_profile SET
profile_name = $1, description = $2,
weight_techniques = $3, weight_claims = $4, weight_ai_tampered = $5, weight_source = $6,
role_techniques = $7, role_claims = $8, role_ai_tampered = $9, role_source = $10,
min_components = $11, primary_components = $12, required_any = $13,
missing_techniques = $14, missing_claims = $15, missing_ai_tampered = $16, missing_source = $17,
override_cap = $18, confidence_config = $19, ai_disclosure_multipliers = $20,
is_active = $21, updated_date = CURRENT_DATE
WHERE profile_code = $22 RETURNING *`,
[
snap.profile_name, snap.description,
snap.weight_techniques, snap.weight_claims, snap.weight_ai_tampered, snap.weight_source,
snap.role_techniques, snap.role_claims, snap.role_ai_tampered, snap.role_source,
snap.min_components, snap.primary_components, snap.required_any,
snap.missing_techniques, snap.missing_claims, snap.missing_ai_tampered, snap.missing_source,
snap.override_cap,
snap.confidence_config ? JSON.stringify(snap.confidence_config) : null,
snap.ai_disclosure_multipliers ? JSON.stringify(snap.ai_disclosure_multipliers) : null,
snap.is_active, code,
],
);
// Restore overrides from the snapshot as well
if (Array.isArray(snap.overrides)) {
for (const ov of snap.overrides) {
await query(
`UPDATE profile_override_config
SET enabled = $1, threshold = $2, bonus_per_unit = $3, bonus_fixed = $4, max_bonus = $5
WHERE profile_code = $6 AND override_code = $7`,
[ov.enabled, ov.threshold, ov.bonus_per_unit, ov.bonus_fixed, ov.max_bonus, code, ov.override_code],
);
}
}
res.json({
success: true,
data: result[0],
message: `Profile '${code}' restored to version ${versions[0].version_no}`,
});
} catch (error) {
internalError(res, error);
}
});
// ============================================================================
// GET /:code/overrides — Override configs for a profile
// ============================================================================
router.get('/:code/overrides', async (req: Request, res: Response) => {
try {
const overrides = await query(
'SELECT * FROM profile_override_config WHERE profile_code = $1 ORDER BY override_code',
[req.params.code]
);
res.json({ success: true, data: overrides, count: overrides.length });
} catch (error) {
internalError(res, error);
}
});
// ============================================================================
// PUT /:code/overrides — Update override configs for a profile
// ============================================================================
router.put('/:code/overrides', async (req: Request, res: Response) => {
try {
const { code } = req.params;
const { overrides } = req.body;
if (!Array.isArray(overrides)) {
return res.status(400).json({ success: false, error: 'overrides must be an array' });
}
const results: any[] = [];
for (const ov of overrides) {
const result = await query(
`UPDATE profile_override_config
SET enabled = COALESCE($1, enabled),
threshold = COALESCE($2, threshold),
bonus_per_unit = COALESCE($3, bonus_per_unit),
bonus_fixed = COALESCE($4, bonus_fixed),
max_bonus = COALESCE($5, max_bonus)
WHERE profile_code = $6 AND override_code = $7
RETURNING *`,
[ov.enabled, ov.threshold, ov.bonus_per_unit, ov.bonus_fixed, ov.max_bonus, code, ov.override_code]
);
if (result.length > 0) results.push(result[0]);
}
res.json({ success: true, data: results, message: `Updated ${results.length} overrides for '${code}'` });
} catch (error) {
internalError(res, error);
}
});
// ============================================================================
// GET /scoring-config/:component — Read scoring_config from component_config PG
// ============================================================================
router.get('/scoring-config/:component', async (req: Request, res: Response) => {
try {
const rows = await query(
'SELECT config_value FROM component_config WHERE component_code = $1 AND config_key = $2',
[req.params.component, 'scoring_config']
);
if (rows.length === 0) {
return res.status(404).json({ success: false, error: `No scoring_config for ${req.params.component}` });
}
const value = typeof rows[0].config_value === 'string'
? JSON.parse(rows[0].config_value)
: rows[0].config_value;
res.json({ success: true, data: value });
} catch (error) {
internalError(res, error);
}
});
// ============================================================================
// PUT /scoring-config/:component — Update scoring_config in component_config PG
// ============================================================================
router.put('/scoring-config/:component', async (req: Request, res: Response) => {
try {
const { component } = req.params;
const configValue = req.body;
if (!configValue || Object.keys(configValue).length === 0) {
return res.status(400).json({ success: false, error: 'Request body must contain the scoring config' });
}
const result = await query(
`UPDATE component_config SET config_value = $1 WHERE component_code = $2 AND config_key = 'scoring_config' RETURNING component_code, config_key`,
[JSON.stringify(configValue), component]
);
if (result.length === 0) {
return res.status(404).json({ success: false, error: `No scoring_config for ${component}` });
}
res.json({ success: true, message: `Scoring config updated for ${component}. Sync to Redis to apply.` });
} catch (error) {
internalError(res, error);
}
});
export default router;

View file

@ -0,0 +1,115 @@
/**
* MODERATION CONFIG ROUTES single-row settings for HIL triage + brain client
*
* GET /api/moderation-config read full config
* PUT /api/moderation-config update fields (any subset), trigger sync to Redis
*
* Stored in bos_parammgmt.moderation_config (single row, config_id=1).
* Synced to Redis as didi:config:moderation:v1:settings.
*
* No POST/DELETE config is single-row, fixed.
*/
import { Router, type Request, type Response } from 'express';
import { query } from '../config/database';
import { internalError } from '../config/error-response';
const router = Router();
// Whitelist of fields that can be updated (defense in depth — DB also has CHECKs)
const UPDATABLE_FIELDS = [
'triage_enabled',
'confidence_low',
'risk_grey_min',
'risk_grey_max',
'queue_relax_at',
'queue_strict_at',
'auto_tune_enabled',
'brain_enabled',
'brain_url',
'brain_lookup_timeout_ms',
'brain_write_timeout_ms',
'brain_confidence_min_silver',
'brain_semantic_threshold',
'brain_per_component',
] as const;
// ============================================================================
// GET / — read full config (single row)
// ============================================================================
router.get('/', async (_req: Request, res: Response) => {
try {
const rows = await query(
`SELECT * FROM bos_parammgmt.moderation_config WHERE config_id = 1`
);
if (rows.length === 0) {
return res.status(404).json({
success: false,
error: 'moderation_config row missing — did migration 011 run?',
});
}
res.json({ success: true, data: rows[0] });
} catch (error) {
internalError(res, error);
}
});
// ============================================================================
// PUT / — update any subset of allowed fields
// ============================================================================
router.put('/', async (req: Request, res: Response) => {
try {
const body = req.body ?? {};
if (typeof body !== 'object' || Array.isArray(body)) {
return res.status(400).json({ success: false, error: 'Body must be a JSON object' });
}
// Filter to whitelisted fields only — silently drop anything else
const updates: string[] = [];
const values: unknown[] = [];
let i = 1;
for (const field of UPDATABLE_FIELDS) {
if (Object.prototype.hasOwnProperty.call(body, field)) {
updates.push(`${field} = $${i}`);
values.push(field === 'brain_per_component' ? JSON.stringify(body[field]) : body[field]);
i++;
}
}
if (updates.length === 0) {
return res.status(400).json({
success: false,
error: `No updatable fields in body. Allowed: ${UPDATABLE_FIELDS.join(', ')}`,
});
}
// Audit
const updatedBy = (req.headers['x-user-id'] as string | undefined) ?? null;
updates.push(`updated_by = $${i}`);
values.push(updatedBy);
i++;
const sql = `
UPDATE bos_parammgmt.moderation_config
SET ${updates.join(', ')}
WHERE config_id = 1
RETURNING *
`;
const rows = await query(sql, values);
if (rows.length === 0) {
return res.status(404).json({ success: false, error: 'moderation_config row missing' });
}
res.json({
success: true,
data: rows[0],
message: 'Config updated. Sync to Redis to apply (POST /api/sync-redis).',
});
} catch (error) {
internalError(res, error);
}
});
export default router;

View file

@ -0,0 +1,95 @@
/**
* MODERATION ROLES ROUTES Keycloak role HIL permission mapping
*
* GET /api/moderation-roles list all roles
* PUT /api/moderation-roles/:code update permissions on a role
*
* Stored in bos_parammgmt.moderation_role.
* Synced to Redis as didi:config:moderation:v1:roles.
*
* No POST/DELETE roles are fixed (moderator, senior_moderator). Permissions only toggle.
*/
import { Router, type Request, type Response } from 'express';
import { query } from '../config/database';
import { internalError } from '../config/error-response';
const router = Router();
const TOGGLE_FIELDS = ['can_resolve', 'can_escalate', 'can_force_gold_brain', 'is_active'] as const;
const LABEL_FIELD = 'role_label';
// ============================================================================
// GET / — list all roles
// ============================================================================
router.get('/', async (_req: Request, res: Response) => {
try {
const rows = await query(
`SELECT role_code, role_label, can_resolve, can_escalate, can_force_gold_brain, is_active, created_at, updated_at
FROM bos_parammgmt.moderation_role ORDER BY role_code`
);
res.json({ success: true, data: rows, count: rows.length });
} catch (error) {
internalError(res, error);
}
});
// ============================================================================
// PUT /:code — update permissions (role_code immutable)
// ============================================================================
router.put('/:code', async (req: Request, res: Response) => {
try {
const { code } = req.params;
const body = req.body ?? {};
const updates: string[] = [];
const values: unknown[] = [];
let i = 1;
for (const field of TOGGLE_FIELDS) {
if (Object.prototype.hasOwnProperty.call(body, field)) {
if (typeof body[field] !== 'boolean') {
return res.status(400).json({ success: false, error: `${field} must be boolean` });
}
updates.push(`${field} = $${i}`);
values.push(body[field]);
i++;
}
}
if (Object.prototype.hasOwnProperty.call(body, LABEL_FIELD)) {
if (typeof body[LABEL_FIELD] !== 'string' || !body[LABEL_FIELD].trim()) {
return res.status(400).json({ success: false, error: 'role_label must be non-empty string' });
}
updates.push(`${LABEL_FIELD} = $${i}`);
values.push(body[LABEL_FIELD]);
i++;
}
if (updates.length === 0) {
return res.status(400).json({
success: false,
error: `Nothing to update. Allowed fields: ${[...TOGGLE_FIELDS, LABEL_FIELD].join(', ')}`,
});
}
values.push(code);
const rows = await query(
`UPDATE bos_parammgmt.moderation_role
SET ${updates.join(', ')}
WHERE role_code = $${i}
RETURNING role_code, role_label, can_resolve, can_escalate, can_force_gold_brain, is_active, updated_at`,
values
);
if (rows.length === 0) {
return res.status(404).json({ success: false, error: `Role '${code}' not found` });
}
res.json({ success: true, data: rows[0] });
} catch (error) {
internalError(res, error);
}
});
export default router;

View file

@ -0,0 +1,55 @@
/**
* Admin/test endpoints for the email notification system.
* GET /api/notifications/health verify SMTP connection (does not send)
* POST /api/notifications/test send a test email
* POST /api/notifications/credit-reset manually trigger Free-credit reset (debug)
*/
import { Router, Request, Response } from 'express';
import { log } from '../config/logger';
import { internalError } from '../config/error-response';
import { isEmailEnabled, sendEmail, verifyEmailConnection } from '../config/email';
import { resetFreeUserCredits } from '../services/credit-reset-cron';
const router = Router();
router.get('/health', async (req: Request, res: Response) => {
if (!isEmailEnabled()) {
return res.json({ success: false, configured: false, error: 'SMTP env vars not set' });
}
const result = await verifyEmailConnection();
res.json({ success: result.ok, configured: true, error: result.error });
});
router.post('/test', async (req: Request, res: Response) => {
try {
const to = (req.body?.to as string) || process.env.SMTP_FROM_EMAIL;
if (!to) {
return res.status(400).json({ success: false, error: 'Missing "to" email in body' });
}
const result = await sendEmail({
to,
subject: 'DIDI · SMTP test email',
html: `<!doctype html><html><body style="font-family:Inter,Arial,sans-serif;padding:24px">
<h2 style="color:#7c3aed">SMTP test successful</h2>
<p>Your DIDI notification stack can reach <strong>${process.env.SMTP_HOST}:${process.env.SMTP_PORT}</strong>.</p>
<p style="color:#6b7280">Sent at ${new Date().toISOString()}</p></body></html>`,
text: `SMTP test successful. DIDI notifications stack can reach ${process.env.SMTP_HOST}:${process.env.SMTP_PORT}. Sent at ${new Date().toISOString()}`,
});
res.json({ success: result.ok, ...result });
} catch (error: any) {
log.error('Error in /notifications/test:', error);
internalError(res, error);
}
});
router.post('/credit-reset', async (req: Request, res: Response) => {
try {
const result = await resetFreeUserCredits();
res.json({ success: true, data: result });
} catch (error: any) {
log.error('Error in /notifications/credit-reset:', error);
internalError(res, error);
}
});
export default router;

View file

@ -0,0 +1,87 @@
import { Router, Request, Response } from 'express';
import { query, checkHealth } from '../config/database';
import { ApiResponse } from '../types';
const router = Router();
// GET framework overview statistics
router.get('/stats', async (req: Request, res: Response) => {
try {
const [
dimensions,
techniques,
verdicts,
riskMappings,
sourceTypes,
platforms
] = await Promise.all([
query('SELECT COUNT(*) as count FROM dimension'),
query('SELECT COUNT(*) as count FROM technique'),
query('SELECT COUNT(*) as count FROM verdict_category'),
query('SELECT COUNT(*) as count FROM risk_mapping'),
query('SELECT COUNT(*) as count FROM source_type'),
query('SELECT COUNT(*) as count FROM platform')
]);
const stats = {
dimensions: parseInt(dimensions[0].count),
techniques: parseInt(techniques[0].count),
verdicts: parseInt(verdicts[0].count),
riskMappings: parseInt(riskMappings[0].count),
sourceTypes: parseInt(sourceTypes[0].count),
platforms: parseInt(platforms[0].count)
};
res.json({
success: true,
data: stats
} as ApiResponse<typeof stats>);
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
} as ApiResponse<never>);
}
});
// GET health check
router.get('/health', async (req: Request, res: Response) => {
const isHealthy = await checkHealth();
res.json({
success: isHealthy,
status: isHealthy ? 'healthy' : 'unhealthy',
service: 'didiFramework',
timestamp: new Date().toISOString()
});
});
// GET docker containers health status
router.get('/docker-health', async (req: Request, res: Response) => {
const { exec } = await import('child_process');
const { promisify } = await import('util');
const execAsync = promisify(exec);
try {
const { stdout } = await execAsync('docker ps --format "{{.Names}}|{{.Status}}"');
const services: Record<string, string> = {};
stdout.trim().split('\n').forEach(line => {
const [name, status] = line.split('|');
if (name && status) {
const isHealthy = status.includes('healthy') ||
(status.includes('Up') && !status.includes('unhealthy'));
services[name] = isHealthy ? 'healthy' :
status.includes('unhealthy') ? 'unhealthy' : 'unknown';
}
});
res.json({ success: true, services, timestamp: new Date().toISOString() });
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Docker not available'
});
}
});
export default router;

View file

@ -0,0 +1,131 @@
/**
* Platforms Routes - FULL CRUD
*
* Platforms are leaf nodes (no children), can be deleted directly.
* They reference platform_modifier which needs safety check.
*/
import { Router, Request, Response } from 'express';
import { query, queryOne, transaction } from '../config/database';
import { Platform, ApiResponse } from '../types';
import { PoolClient } from 'pg';
import { internalError } from '../config/error-response';
const router = Router();
// Helper functions
const getNextId = async (client: PoolClient, table: string, idColumn: string): Promise<number> => {
const result = await client.query(`SELECT COALESCE(MAX(${idColumn}), 0) + 1 as next_id FROM ${table}`);
return result.rows[0].next_id;
};
// GET all platforms with modifier info
router.get('/', async (req: Request, res: Response) => {
try {
const platforms = await query<Platform & { modifier_name: string }>(`
SELECT p.*, pm.platform_modifier as modifier_name
FROM platform p
LEFT JOIN platform_modifier pm ON p.platform_modifier_id = pm.platform_modifier_id
ORDER BY p.platform_id
`);
res.json({ success: true, data: platforms, count: platforms.length });
} catch (error) {
internalError(res, error);
}
});
// GET platform by ID
router.get('/:id', async (req: Request, res: Response) => {
try {
const platform = await queryOne<Platform>(
'SELECT * FROM platform WHERE platform_id = $1',
[req.params.id]
);
if (!platform) {
return res.status(404).json({ success: false, error: 'Platforma nu a fost găsită' });
}
res.json({ success: true, data: platform });
} catch (error) {
internalError(res, error);
}
});
// POST create platform
router.post('/', async (req: Request, res: Response) => {
try {
const { platform_code, platform_name, platform_modifier_id, platform_score, notes } = req.body;
if (!platform_code || !platform_name) {
return res.status(400).json({ success: false, error: 'Câmpuri obligatorii: platform_code, platform_name' });
}
// Verify platform_modifier exists if provided
if (platform_modifier_id) {
const modifier = await queryOne('SELECT platform_modifier_id FROM platform_modifier WHERE platform_modifier_id = $1', [platform_modifier_id]);
if (!modifier) {
return res.status(400).json({ success: false, error: 'Platform modifier specificat nu există' });
}
}
const result = await transaction(async (client) => {
const id = await getNextId(client, 'platform', 'platform_id');
const insertResult = await client.query(`
INSERT INTO platform (platform_id, platform_code, platform_name, platform_modifier_id, platform_score, notes)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *
`, [id, platform_code, platform_name, platform_modifier_id || 1, platform_score || 0, notes || '']);
return insertResult.rows[0];
});
res.status(201).json({ success: true, data: result, message: 'Platforma a fost creată cu succes' });
} catch (error) {
internalError(res, error);
}
});
// PUT update platform
router.put('/:id', async (req: Request, res: Response) => {
try {
const { platform_code, platform_name, platform_modifier_id, platform_score, notes } = req.body;
// Verify platform_modifier exists if provided
if (platform_modifier_id) {
const modifier = await queryOne('SELECT platform_modifier_id FROM platform_modifier WHERE platform_modifier_id = $1', [platform_modifier_id]);
if (!modifier) {
return res.status(400).json({ success: false, error: 'Platform modifier specificat nu există' });
}
}
const platform = await queryOne<Platform>(`
UPDATE platform
SET platform_code = COALESCE($1, platform_code),
platform_name = COALESCE($2, platform_name),
platform_modifier_id = COALESCE($3, platform_modifier_id),
platform_score = COALESCE($4, platform_score),
notes = COALESCE($5, notes)
WHERE platform_id = $6
RETURNING *
`, [platform_code, platform_name, platform_modifier_id, platform_score, notes, req.params.id]);
if (!platform) {
return res.status(404).json({ success: false, error: 'Platforma nu a fost găsită' });
}
res.json({ success: true, data: platform, message: 'Platforma a fost actualizată cu succes' });
} catch (error) {
internalError(res, error);
}
});
// DELETE platform
router.delete('/:id', async (req: Request, res: Response) => {
try {
const result = await query('DELETE FROM platform WHERE platform_id = $1 RETURNING *', [req.params.id]);
if (result.length === 0) {
return res.status(404).json({ success: false, error: 'Platforma nu a fost găsită' });
}
res.json({ success: true, message: 'Platforma a fost ștearsă cu succes', deleted: true });
} catch (error) {
internalError(res, error);
}
});
export default router;

View file

@ -0,0 +1,184 @@
import { Router, Request, Response } from 'express';
import * as fs from 'fs';
import * as path from 'path';
const router = Router();
// Calea către fișierele MD din agent
// DIDI pipeline: workspace/pipelines/didi/
// Legacy: missinfo_docs/ (pentru techniques, sources, claims, verdict)
const DIDI_PIPELINE_PATH = process.env.DIDI_PIPELINE_PATH || '/app/pipelines/didi';
const MISSINFO_DOCS_PATH = process.env.MISSINFO_DOCS_PATH || '/app/missinfo_docs';
// Mapare step -> {basePath, filename}
interface StepFile {
basePath: string;
filename: string;
}
const STEP_FILES: Record<string, StepFile> = {
'intake': { basePath: DIDI_PIPELINE_PATH, filename: 'intake_eligibility.md' },
'techniques': { basePath: MISSINFO_DOCS_PATH, filename: 'manipulation_techniques_v3.md' },
'sources': { basePath: MISSINFO_DOCS_PATH, filename: 'source_assessment.md' },
'claims': { basePath: MISSINFO_DOCS_PATH, filename: 'claims_analysis.md' },
'verdict': { basePath: MISSINFO_DOCS_PATH, filename: 'main_verdict.md' },
};
// GET /api/prompts - Lista toate fișierele disponibile
router.get('/', async (req: Request, res: Response) => {
try {
const files = Object.entries(STEP_FILES).map(([step, stepFile]) => {
const filePath = path.join(stepFile.basePath, stepFile.filename);
const exists = fs.existsSync(filePath);
let size = 0;
if (exists) {
const stats = fs.statSync(filePath);
size = stats.size;
}
return {
step,
filename: stepFile.filename,
exists,
size,
path: filePath,
basePath: stepFile.basePath,
};
});
res.json({
success: true,
data: files,
});
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to list prompts',
});
}
});
// GET /api/prompts/:step - Conținutul pentru un pas specific
router.get('/:step', async (req: Request, res: Response) => {
try {
const { step } = req.params;
if (!STEP_FILES[step]) {
return res.status(404).json({
success: false,
error: `Unknown step: ${step}. Valid steps: ${Object.keys(STEP_FILES).join(', ')}`,
});
}
const stepFile = STEP_FILES[step];
const filePath = path.join(stepFile.basePath, stepFile.filename);
if (!fs.existsSync(filePath)) {
return res.status(404).json({
success: false,
error: `File not found: ${stepFile.filename}`,
path: filePath,
});
}
const content = fs.readFileSync(filePath, 'utf-8');
res.json({
success: true,
data: {
step,
filename: stepFile.filename,
content,
charCount: content.length,
lineCount: content.split('\n').length,
basePath: stepFile.basePath,
},
});
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to read prompt file',
});
}
});
// GET /api/prompts/:step/sections - Extrage secțiuni specifice din fișier
router.get('/:step/sections', async (req: Request, res: Response) => {
try {
const { step } = req.params;
if (!STEP_FILES[step]) {
return res.status(404).json({
success: false,
error: `Unknown step: ${step}`,
});
}
const stepFile = STEP_FILES[step];
const filePath = path.join(stepFile.basePath, stepFile.filename);
if (!fs.existsSync(filePath)) {
return res.status(404).json({
success: false,
error: `File not found: ${stepFile.filename}`,
});
}
const content = fs.readFileSync(filePath, 'utf-8');
// Parsează secțiunile (bazat pe headings #)
const sections: Array<{ level: number; title: string; content: string }> = [];
const lines = content.split('\n');
let currentSection: { level: number; title: string; content: string[] } | null = null;
for (const line of lines) {
const headingMatch = line.match(/^(#{1,3})\s+(.+)$/);
if (headingMatch) {
// Save previous section
if (currentSection) {
sections.push({
level: currentSection.level,
title: currentSection.title,
content: currentSection.content.join('\n').trim(),
});
}
// Start new section
currentSection = {
level: headingMatch[1].length,
title: headingMatch[2],
content: [],
};
} else if (currentSection) {
currentSection.content.push(line);
}
}
// Don't forget last section
if (currentSection) {
sections.push({
level: currentSection.level,
title: currentSection.title,
content: currentSection.content.join('\n').trim(),
});
}
res.json({
success: true,
data: {
step,
filename: stepFile.filename,
sections,
sectionCount: sections.length,
basePath: stepFile.basePath,
},
});
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to parse sections',
});
}
});
export default router;

View file

@ -0,0 +1,6 @@
/**
* Re-export of the new providers barrel. Kept at this path so server.ts
* (which imports `./routes/providers`) continues to work unchanged after
* the 921-LOC 8-file split. See ./providers/index.ts for the routing map.
*/
export { default } from './providers/index';

View file

@ -0,0 +1,98 @@
/**
* Shared types + helpers for providers/ sub-routers (configs, models,
* assignments, keys, all, prompts, test).
*
* `createParameter` allocates a new row in the generic `parameter` table
* with parameter_type chosen by caller (40=provider, 41=model, 42=assignment,
* 43=key, 44=prompt). Used by every POST endpoint.
*
* `maskApiKey` is the canonical display format never return raw keys.
*/
import { PoolClient } from 'pg';
// ============================================================================
// SHARED TYPES
// ============================================================================
export interface LlmProvider {
provider_id: number;
provider_code: string;
provider_name: string;
base_url: string;
auth_type: string;
is_active: boolean;
priority: number;
rate_limit_rpm: number;
rate_limit_tpm: number;
description: string;
parameter_id: number;
}
export interface LlmModel {
model_id: number;
provider_id: number;
model_code: string;
model_name: string;
context_window: number;
max_output_tokens: number;
input_cost_per_1m: number;
output_cost_per_1m: number;
supports_streaming: boolean;
supports_tools: boolean;
supports_vision: boolean;
is_active: boolean;
description: string;
}
export interface ComponentAssignment {
assignment_id: number;
component_code: string;
component_name: string;
provider_id: number;
model_id: number;
fallback_provider_id: number;
fallback_model_id: number;
temperature: number;
max_tokens: number;
timeout_ms: number;
is_enabled: boolean;
description: string;
}
export interface ApiKey {
api_key_id: number;
provider_id: number;
key_name: string;
api_key_value: string;
key_prefix: string;
is_active: boolean;
usage_count: number;
last_used_at: string;
expires_at: string;
}
// ============================================================================
// HELPERS
// ============================================================================
/**
* Allocate a new parameter row. Used inside transactions when creating
* a new provider/model/assignment/key/prompt to satisfy the parameter FK.
*/
export const createParameter = async (client: PoolClient, parameterType: number): Promise<number> => {
const maxResult = await client.query('SELECT COALESCE(MAX(parameter_id), 0) + 1 as next_id FROM parameter');
const nextParamId = maxResult.rows[0].next_id;
await client.query(`
INSERT INTO parameter (parameter_id, parameter_type, valid_from, valid_to, created_date, updated_date)
VALUES ($1, $2, '2026-01-01', '2999-01-01', CURRENT_DATE, CURRENT_DATE)
`, [nextParamId, parameterType]);
return nextParamId;
};
/** Mask API key for display: keep first 7 + last 4 chars. */
export const maskApiKey = (key: string): string => {
if (!key || key.length < 8) return '***';
return key.substring(0, 7) + '...' + key.substring(key.length - 4);
};

View file

@ -0,0 +1,62 @@
/**
* Providers/all.ts combined endpoint that returns ALL provider data
* (providers + models + assignments + keys + prompts) in a single call.
* Used by the admin dashboard to populate the "Providers Management" page
* without making 5 separate fetch calls.
*
* GET /all
*/
import { Router, Request, Response } from 'express';
import { query } from '../../config/database';
import { internalError } from '../../config/error-response';
import { maskApiKey } from './_helpers';
const router = Router();
router.get('/all', async (req: Request, res: Response) => {
try {
const [providers, models, assignments, keys] = await Promise.all([
query('SELECT * FROM llm_provider ORDER BY priority'),
query(`
SELECT m.*, p.provider_code, p.provider_name
FROM llm_model m
JOIN llm_provider p ON m.provider_id = p.provider_id
ORDER BY p.priority, m.model_name
`),
query(`
SELECT csa.*, p.provider_code, p.provider_name, m.model_code, m.model_name
FROM component_stage_assignment csa
JOIN llm_provider p ON csa.provider_id = p.provider_id
JOIN llm_model m ON csa.model_id = m.model_id
ORDER BY csa.component_code, csa.stage_code, csa.fallback_order
`),
query(`
SELECT k.api_key_id, k.provider_id, k.key_name, k.key_prefix, k.is_active,
k.usage_count, k.last_used_at, k.expires_at, p.provider_code
FROM provider_api_key k
JOIN llm_provider p ON k.provider_id = p.provider_id
ORDER BY p.provider_name, k.key_name
`),
]);
res.json({
success: true,
data: {
providers,
models,
assignments,
keys,
},
counts: {
providers: providers.length,
models: models.length,
assignments: assignments.length,
keys: keys.length,
},
});
} catch (error) {
internalError(res, error);
}
});
export default router;

View file

@ -0,0 +1,182 @@
/**
* Providers/assignments.ts Component (provider, model) assignments CRUD.
* Each row says "for component X use this provider+model with these LLM params".
* Worker pipeline reads these to decide which model to call per component.
*
* GET /assignments list (joins provider + model + fallbacks)
* GET /assignments/:id single
* POST /assignments create (parameter type 42)
* PUT /assignments/:id partial update
* DELETE /assignments/:id
*/
import { Router, Request, Response } from 'express';
import { query, queryOne, transaction } from '../../config/database';
import { internalError } from '../../config/error-response';
import { createParameter, type ComponentAssignment } from './_helpers';
const router = Router();
router.get('/assignments', async (req: Request, res: Response) => {
try {
// Optional ?tier=free|premium filter
const tier = typeof req.query.tier === 'string' ? req.query.tier : null;
const params: any[] = [];
let tierFilter = '';
if (tier === 'free' || tier === 'premium') {
tierFilter = ' AND csa.tier = $1';
params.push(tier);
}
const assignments = await query(`
SELECT
csa.stage_id,
csa.component_code,
csa.stage_code,
csa.stage_name,
csa.fallback_order,
csa.tier,
csa.temperature,
csa.max_tokens,
csa.timeout_ms,
csa.is_enabled,
csa.description,
p.provider_id,
p.provider_code,
p.provider_name,
m.model_id,
m.model_code,
m.model_name,
m.context_window,
m.input_cost_per_1m,
m.output_cost_per_1m
FROM component_stage_assignment csa
JOIN llm_provider p ON csa.provider_id = p.provider_id
JOIN llm_model m ON csa.model_id = m.model_id
WHERE csa.is_enabled = true${tierFilter}
ORDER BY csa.component_code, csa.stage_code, csa.tier, csa.fallback_order
`, params);
res.json({ success: true, data: assignments, count: assignments.length });
} catch (error) {
internalError(res, error);
}
});
router.get('/assignments/:id', async (req: Request, res: Response) => {
try {
const assignment = await queryOne(`
SELECT
csa.stage_id,
csa.component_code,
csa.stage_code,
csa.stage_name,
csa.fallback_order,
csa.tier,
csa.temperature,
csa.max_tokens,
csa.timeout_ms,
csa.is_enabled,
csa.description,
p.provider_id,
p.provider_code,
p.provider_name,
m.model_id,
m.model_code,
m.model_name
FROM component_stage_assignment csa
JOIN llm_provider p ON csa.provider_id = p.provider_id
JOIN llm_model m ON csa.model_id = m.model_id
WHERE csa.stage_id = $1
`, [req.params.id]);
if (!assignment) {
return res.status(404).json({ success: false, error: 'Assignment not found' });
}
res.json({ success: true, data: assignment });
} catch (error) {
internalError(res, error);
}
});
router.post('/assignments', async (req: Request, res: Response) => {
try {
const {
component_code, stage_code, stage_name, fallback_order, tier,
provider_id, model_id, temperature, max_tokens,
timeout_ms, is_enabled, description
} = req.body;
if (!component_code || !stage_code || !provider_id || !model_id) {
return res.status(400).json({
success: false,
error: 'Required fields: component_code, stage_code, provider_id, model_id'
});
}
// Validate tier (defaults to 'free' if missing)
const resolvedTier = tier === 'premium' ? 'premium' : 'free';
const result = await query(`
INSERT INTO component_stage_assignment (
component_code, stage_code, stage_name, fallback_order, tier,
provider_id, model_id, temperature, max_tokens,
timeout_ms, is_enabled, description
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING *
`, [
component_code, stage_code, stage_name || stage_code,
fallback_order || 1, resolvedTier, provider_id, model_id,
temperature || 0, max_tokens || 4096, timeout_ms || 120000,
is_enabled !== false, description || 'primary'
]);
res.status(201).json({ success: true, data: result[0], message: 'Assignment created successfully' });
} catch (error) {
internalError(res, error);
}
});
router.put('/assignments/:id', async (req: Request, res: Response) => {
try {
const { provider_id, model_id, temperature, max_tokens, timeout_ms, is_enabled, description, tier } = req.body;
// Only accept valid tier values (free/premium) or null/undefined to preserve current value
const tierParam = (tier === 'free' || tier === 'premium') ? tier : null;
const assignment = await queryOne(`
UPDATE component_stage_assignment
SET provider_id = COALESCE($1, provider_id),
model_id = COALESCE($2, model_id),
temperature = COALESCE($3, temperature),
max_tokens = COALESCE($4, max_tokens),
timeout_ms = COALESCE($5, timeout_ms),
is_enabled = COALESCE($6, is_enabled),
description = COALESCE($7, description),
tier = COALESCE($8, tier),
updated_date = CURRENT_DATE
WHERE stage_id = $9
RETURNING *
`, [provider_id, model_id, temperature, max_tokens, timeout_ms, is_enabled, description, tierParam, req.params.id]);
if (!assignment) {
return res.status(404).json({ success: false, error: 'Assignment not found' });
}
res.json({ success: true, data: assignment, message: 'Assignment updated successfully' });
} catch (error) {
internalError(res, error);
}
});
router.delete('/assignments/:id', async (req: Request, res: Response) => {
try {
const result = await query('DELETE FROM component_stage_assignment WHERE stage_id = $1 RETURNING *', [req.params.id]);
if (result.length === 0) {
return res.status(404).json({ success: false, error: 'Assignment not found' });
}
res.json({ success: true, message: 'Assignment deleted successfully', deleted: true });
} catch (error) {
internalError(res, error);
}
});
export default router;

View file

@ -0,0 +1,126 @@
/**
* Providers/configs.ts LLM Providers CRUD.
* GET /configs list all
* GET /configs/:id single
* POST /configs create (allocates parameter row, type 40)
* PUT /configs/:id partial update
* DELETE /configs/:id refuses if any models or assignments depend on it
*/
import { Router, Request, Response } from 'express';
import { query, queryOne, transaction } from '../../config/database';
import { log } from '../../config/logger';
import { internalError } from '../../config/error-response';
import { createParameter, type LlmProvider } from './_helpers';
const router = Router();
router.get('/configs', async (req: Request, res: Response) => {
try {
const providers = await query<LlmProvider>(
'SELECT * FROM llm_provider ORDER BY priority, provider_id'
);
res.json({ success: true, data: providers, count: providers.length });
} catch (error) {
log.error('[providers] Error fetching providers:', error);
internalError(res, error);
}
});
router.get('/configs/:id', async (req: Request, res: Response) => {
try {
const provider = await queryOne<LlmProvider>(
'SELECT * FROM llm_provider WHERE provider_id = $1',
[req.params.id]
);
if (!provider) {
return res.status(404).json({ success: false, error: 'Provider not found' });
}
res.json({ success: true, data: provider });
} catch (error) {
internalError(res, error);
}
});
router.post('/configs', async (req: Request, res: Response) => {
try {
const { provider_code, provider_name, base_url, auth_type, is_active, priority, rate_limit_rpm, rate_limit_tpm, description } = req.body;
if (!provider_code || !provider_name) {
return res.status(400).json({ success: false, error: 'Required fields: provider_code, provider_name' });
}
const result = await transaction(async (client) => {
const parameterId = await createParameter(client, 40); // 40 = provider parameter type
const insertResult = await client.query(`
INSERT INTO llm_provider (provider_code, provider_name, base_url, auth_type, is_active, priority, rate_limit_rpm, rate_limit_tpm, description, parameter_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *
`, [provider_code, provider_name, base_url || '', auth_type || 'bearer', is_active !== false, priority || 100, rate_limit_rpm || 60, rate_limit_tpm || 100000, description || '', parameterId]);
return insertResult.rows[0];
});
res.status(201).json({ success: true, data: result, message: 'Provider created successfully' });
} catch (error) {
internalError(res, error);
}
});
router.put('/configs/:id', async (req: Request, res: Response) => {
try {
const { provider_code, provider_name, base_url, auth_type, is_active, priority, rate_limit_rpm, rate_limit_tpm, description } = req.body;
const provider = await queryOne<LlmProvider>(`
UPDATE llm_provider
SET provider_code = COALESCE($1, provider_code),
provider_name = COALESCE($2, provider_name),
base_url = COALESCE($3, base_url),
auth_type = COALESCE($4, auth_type),
is_active = COALESCE($5, is_active),
priority = COALESCE($6, priority),
rate_limit_rpm = COALESCE($7, rate_limit_rpm),
rate_limit_tpm = COALESCE($8, rate_limit_tpm),
description = COALESCE($9, description),
updated_date = CURRENT_DATE
WHERE provider_id = $10
RETURNING *
`, [provider_code, provider_name, base_url, auth_type, is_active, priority, rate_limit_rpm, rate_limit_tpm, description, req.params.id]);
if (!provider) {
return res.status(404).json({ success: false, error: 'Provider not found' });
}
res.json({ success: true, data: provider, message: 'Provider updated successfully' });
} catch (error) {
internalError(res, error);
}
});
router.delete('/configs/:id', async (req: Request, res: Response) => {
try {
// Check if provider has models or assignments
const [models, assignments] = await Promise.all([
query('SELECT model_id FROM llm_model WHERE provider_id = $1', [req.params.id]),
query('SELECT stage_id FROM component_stage_assignment WHERE provider_id = $1', [req.params.id]),
]);
if (models.length > 0 || assignments.length > 0) {
return res.status(409).json({
success: false,
error: 'Cannot delete provider with existing models or assignments',
dependencies: {
models: models.length,
assignments: assignments.length,
},
});
}
const result = await query('DELETE FROM llm_provider WHERE provider_id = $1 RETURNING *', [req.params.id]);
if (result.length === 0) {
return res.status(404).json({ success: false, error: 'Provider not found' });
}
res.json({ success: true, message: 'Provider deleted successfully', deleted: true });
} catch (error) {
internalError(res, error);
}
});
export default router;

View file

@ -0,0 +1,30 @@
/**
* didiFramework /api/providers barrel router.
*
* The original 921-line providers.ts was split into 7 sub-routers (configs,
* models, assignments, keys, all, prompts, test) + _helpers (shared types +
* createParameter + maskApiKey).
*
* server.ts mounts this at `/api/providers` so all the same paths
* (/api/providers/configs, /api/providers/models, etc.) work unchanged.
*/
import { Router } from 'express';
import configsRouter from './configs';
import modelsRouter from './models';
import assignmentsRouter from './assignments';
import keysRouter from './keys';
import allRouter from './all';
import promptsRouter from './prompts';
import testRouter from './test';
const router = Router();
router.use(configsRouter);
router.use(modelsRouter);
router.use(assignmentsRouter);
router.use(keysRouter);
router.use(allRouter);
router.use(promptsRouter);
router.use(testRouter);
export default router;

View file

@ -0,0 +1,147 @@
/**
* Providers/keys.ts API Keys CRUD.
* Keys are stored encrypted; GET endpoints return them masked via maskApiKey.
*
* GET /keys list (masked, JOIN provider)
* GET /keys/:id single (masked)
* POST /keys create (parameter type 43)
* PUT /keys/:id rotate / metadata update
* DELETE /keys/:id
*/
import { Router, Request, Response } from 'express';
import { query, queryOne, transaction } from '../../config/database';
import { internalError } from '../../config/error-response';
import { createParameter, maskApiKey, type ApiKey } from './_helpers';
const router = Router();
router.get('/keys', async (req: Request, res: Response) => {
try {
const { provider_id } = req.query;
let sql = `
SELECT k.api_key_id, k.provider_id, k.key_name, k.key_prefix, k.is_active,
k.usage_count, k.last_used_at, k.expires_at, k.created_by, k.created_date,
p.provider_code, p.provider_name
FROM provider_api_key k
JOIN llm_provider p ON k.provider_id = p.provider_id
`;
const params: any[] = [];
if (provider_id) {
sql += ' WHERE k.provider_id = $1';
params.push(provider_id);
}
sql += ' ORDER BY p.provider_name, k.key_name';
const keys = await query(sql, params);
// Don't expose actual API key values in list
res.json({ success: true, data: keys, count: keys.length });
} catch (error) {
internalError(res, error);
}
});
router.get('/keys/:id', async (req: Request, res: Response) => {
try {
const key = await queryOne<ApiKey & { provider_code: string }>(`
SELECT k.*, p.provider_code, p.provider_name
FROM provider_api_key k
JOIN llm_provider p ON k.provider_id = p.provider_id
WHERE k.api_key_id = $1
`, [req.params.id]);
if (!key) {
return res.status(404).json({ success: false, error: 'API key not found' });
}
// Return masked key
res.json({
success: true,
data: {
...key,
api_key_value: maskApiKey(key.api_key_value),
},
});
} catch (error) {
internalError(res, error);
}
});
router.post('/keys', async (req: Request, res: Response) => {
try {
const { provider_id, key_name, api_key_value, is_active, expires_at, created_by } = req.body;
if (!provider_id || !key_name || !api_key_value) {
return res.status(400).json({
success: false,
error: 'Required fields: provider_id, key_name, api_key_value'
});
}
// Extract key prefix (first 7 chars for display)
const key_prefix = api_key_value.substring(0, 7);
const result = await transaction(async (client) => {
const parameterId = await createParameter(client, 43); // 43 = api key parameter type
const insertResult = await client.query(`
INSERT INTO provider_api_key (provider_id, key_name, api_key_value, key_prefix, is_active, expires_at, created_by, parameter_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING api_key_id, provider_id, key_name, key_prefix, is_active, usage_count, expires_at, created_by, created_date
`, [provider_id, key_name, api_key_value, key_prefix, is_active !== false, expires_at || null, created_by || 'admin', parameterId]);
return insertResult.rows[0];
});
res.status(201).json({ success: true, data: result, message: 'API key created successfully' });
} catch (error) {
internalError(res, error);
}
});
router.put('/keys/:id', async (req: Request, res: Response) => {
try {
const { key_name, api_key_value, is_active, expires_at } = req.body;
let sql = `
UPDATE provider_api_key
SET key_name = COALESCE($1, key_name),
is_active = COALESCE($2, is_active),
expires_at = $3,
updated_date = CURRENT_DATE
`;
const params: any[] = [key_name, is_active, expires_at];
// Only update key value if provided
if (api_key_value) {
sql += `, api_key_value = $4, key_prefix = $5`;
params.push(api_key_value, api_key_value.substring(0, 7));
}
sql += ` WHERE api_key_id = $${params.length + 1} RETURNING api_key_id, provider_id, key_name, key_prefix, is_active, usage_count, expires_at, created_by, created_date`;
params.push(req.params.id);
const key = await queryOne(sql, params);
if (!key) {
return res.status(404).json({ success: false, error: 'API key not found' });
}
res.json({ success: true, data: key, message: 'API key updated successfully' });
} catch (error) {
internalError(res, error);
}
});
router.delete('/keys/:id', async (req: Request, res: Response) => {
try {
const result = await query('DELETE FROM provider_api_key WHERE api_key_id = $1 RETURNING api_key_id', [req.params.id]);
if (result.length === 0) {
return res.status(404).json({ success: false, error: 'API key not found' });
}
res.json({ success: true, message: 'API key deleted successfully', deleted: true });
} catch (error) {
internalError(res, error);
}
});
export default router;

View file

@ -0,0 +1,169 @@
/**
* Providers/models.ts LLM Models CRUD.
* GET /models list (optional ?provider_id filter), JOIN provider
* GET /models/:id single, JOIN provider
* POST /models create (parameter type 41)
* PUT /models/:id partial update
* DELETE /models/:id refuses if any assignments depend on it
*/
import { Router, Request, Response } from 'express';
import { query, queryOne, transaction } from '../../config/database';
import { internalError } from '../../config/error-response';
import { createParameter, type LlmModel } from './_helpers';
const router = Router();
router.get('/models', async (req: Request, res: Response) => {
try {
const { provider_id } = req.query;
let sql = `
SELECT m.*, p.provider_code, p.provider_name
FROM llm_model m
JOIN llm_provider p ON m.provider_id = p.provider_id
`;
const params: any[] = [];
if (provider_id) {
sql += ' WHERE m.provider_id = $1';
params.push(provider_id);
}
sql += ' ORDER BY p.priority, m.model_name';
const models = await query<LlmModel & { provider_code: string; provider_name: string }>(sql, params);
res.json({ success: true, data: models, count: models.length });
} catch (error) {
internalError(res, error);
}
});
router.get('/models/:id', async (req: Request, res: Response) => {
try {
const model = await queryOne<LlmModel & { provider_code: string; provider_name: string }>(`
SELECT m.*, p.provider_code, p.provider_name
FROM llm_model m
JOIN llm_provider p ON m.provider_id = p.provider_id
WHERE m.model_id = $1
`, [req.params.id]);
if (!model) {
return res.status(404).json({ success: false, error: 'Model not found' });
}
res.json({ success: true, data: model });
} catch (error) {
internalError(res, error);
}
});
router.post('/models', async (req: Request, res: Response) => {
try {
const {
provider_id, model_code, model_name, context_window, max_output_tokens,
input_cost_per_1m, output_cost_per_1m, supports_streaming, supports_tools,
supports_vision, is_active, description,
deployment, compute_target, quantization, capabilities
} = req.body;
if (!provider_id || !model_code || !model_name) {
return res.status(400).json({ success: false, error: 'Required fields: provider_id, model_code, model_name' });
}
const result = await transaction(async (client) => {
const parameterId = await createParameter(client, 41); // 41 = model parameter type
const insertResult = await client.query(`
INSERT INTO llm_model (
provider_id, model_code, model_name, context_window, max_output_tokens,
input_cost_per_1m, output_cost_per_1m, supports_streaming, supports_tools,
supports_vision, is_active, description, parameter_id,
deployment, compute_target, quantization, capabilities
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
RETURNING *
`, [
provider_id, model_code, model_name, context_window || 32000, max_output_tokens || 4096,
input_cost_per_1m || 0, output_cost_per_1m || 0, supports_streaming !== false,
supports_tools !== false, supports_vision === true, is_active !== false,
description || '', parameterId,
deployment || 'remote', compute_target || null, quantization || null,
JSON.stringify(Array.isArray(capabilities) ? capabilities : ['text'])
]);
return insertResult.rows[0];
});
res.status(201).json({ success: true, data: result, message: 'Model created successfully' });
} catch (error) {
internalError(res, error);
}
});
router.put('/models/:id', async (req: Request, res: Response) => {
try {
const {
provider_id, model_code, model_name, context_window, max_output_tokens,
input_cost_per_1m, output_cost_per_1m, supports_streaming, supports_tools,
supports_vision, is_active, description,
deployment, compute_target, quantization, capabilities
} = req.body;
const model = await queryOne<LlmModel>(`
UPDATE llm_model
SET provider_id = COALESCE($1, provider_id),
model_code = COALESCE($2, model_code),
model_name = COALESCE($3, model_name),
context_window = COALESCE($4, context_window),
max_output_tokens = COALESCE($5, max_output_tokens),
input_cost_per_1m = COALESCE($6, input_cost_per_1m),
output_cost_per_1m = COALESCE($7, output_cost_per_1m),
supports_streaming = COALESCE($8, supports_streaming),
supports_tools = COALESCE($9, supports_tools),
supports_vision = COALESCE($10, supports_vision),
is_active = COALESCE($11, is_active),
description = COALESCE($12, description),
deployment = COALESCE($13, deployment),
compute_target = COALESCE($14, compute_target),
quantization = COALESCE($15, quantization),
capabilities = COALESCE($16, capabilities),
updated_date = CURRENT_DATE
WHERE model_id = $17
RETURNING *
`, [provider_id, model_code, model_name, context_window, max_output_tokens, input_cost_per_1m, output_cost_per_1m, supports_streaming, supports_tools, supports_vision, is_active, description,
deployment, compute_target, quantization,
capabilities !== undefined ? JSON.stringify(capabilities) : null,
req.params.id]);
if (!model) {
return res.status(404).json({ success: false, error: 'Model not found' });
}
res.json({ success: true, data: model, message: 'Model updated successfully' });
} catch (error) {
internalError(res, error);
}
});
router.delete('/models/:id', async (req: Request, res: Response) => {
try {
// Check if model is used in assignments
const assignments = await query(
'SELECT stage_id FROM component_stage_assignment WHERE model_id = $1',
[req.params.id]
);
if (assignments.length > 0) {
return res.status(409).json({
success: false,
error: 'Cannot delete model used in component assignments',
dependencies: { assignments: assignments.length },
});
}
const result = await query('DELETE FROM llm_model WHERE model_id = $1 RETURNING *', [req.params.id]);
if (result.length === 0) {
return res.status(404).json({ success: false, error: 'Model not found' });
}
res.json({ success: true, message: 'Model deleted successfully', deleted: true });
} catch (error) {
internalError(res, error);
}
});
export default router;

View file

@ -0,0 +1,147 @@
/**
* Providers/prompts.ts Component prompts CRUD.
* System + user-template prompts per component (techniques screening,
* claims extraction, etc). Stored in DB, synced to Redis at /api/sync-redis.
*
* GET /prompts list all
* GET /prompts/:id single
* POST /prompts create (parameter type 44)
* PUT /prompts/:id partial update
* DELETE /prompts/:id
*/
import { Router, Request, Response } from 'express';
import { query, queryOne, transaction } from '../../config/database';
import { log } from '../../config/logger';
import { internalError } from '../../config/error-response';
import { createParameter } from './_helpers';
const router = Router();
router.get('/prompts', async (req: Request, res: Response) => {
try {
const { component, stage } = req.query;
let sql = 'SELECT * FROM bos_parammgmt.component_prompt';
const params: any[] = [];
const conditions: string[] = [];
if (component) {
params.push(component);
conditions.push(`component_code = $${params.length}`);
}
if (stage) {
params.push(stage);
conditions.push(`stage_code = $${params.length}`);
}
if (conditions.length > 0) {
sql += ' WHERE ' + conditions.join(' AND ');
}
sql += ' ORDER BY component_code, stage_code';
const prompts = await query(sql, params);
res.json({ success: true, data: prompts, count: prompts.length });
} catch (error) {
log.error('[providers] Error fetching prompts:', error);
internalError(res, error);
}
});
router.get('/prompts/:id', async (req: Request, res: Response) => {
try {
const prompt = await queryOne(
'SELECT * FROM bos_parammgmt.component_prompt WHERE prompt_id = $1',
[req.params.id]
);
if (!prompt) {
return res.status(404).json({ success: false, error: 'Prompt not found' });
}
res.json({ success: true, data: prompt });
} catch (error) {
internalError(res, error);
}
});
router.post('/prompts', async (req: Request, res: Response) => {
try {
const { component_code, stage_code, system_prompt, user_template, description } = req.body;
if (!component_code || !stage_code || !system_prompt || !user_template) {
return res.status(400).json({
success: false,
error: 'Required fields: component_code, stage_code, system_prompt, user_template',
});
}
// Check if prompt already exists for this component+stage
const existing = await queryOne(
'SELECT prompt_id FROM bos_parammgmt.component_prompt WHERE component_code = $1 AND stage_code = $2',
[component_code, stage_code]
);
if (existing) {
return res.status(409).json({
success: false,
error: `Prompt already exists for ${component_code}/${stage_code}. Use PUT to update.`,
});
}
const maxResult = await queryOne<{ next_id: number }>(
'SELECT COALESCE(MAX(prompt_id), 0) + 1 as next_id FROM bos_parammgmt.component_prompt'
);
const nextId = maxResult?.next_id || 1;
const result = await queryOne(`
INSERT INTO bos_parammgmt.component_prompt (prompt_id, component_code, stage_code, system_prompt, user_template, description, created_date, updated_date,
system_prompt_ro, user_template_ro)
VALUES ($1, $2, $3, $4, $5, $6, CURRENT_DATE, CURRENT_DATE, $7, $8)
RETURNING *
`, [nextId, component_code, stage_code, system_prompt, user_template, description || `${component_code} ${stage_code}`,
req.body.system_prompt_ro || null, req.body.user_template_ro || null]);
res.status(201).json({ success: true, data: result, message: 'Prompt created successfully' });
} catch (error) {
internalError(res, error);
}
});
router.put('/prompts/:id', async (req: Request, res: Response) => {
try {
const { system_prompt, user_template, description, system_prompt_ro, user_template_ro } = req.body;
const prompt = await queryOne(`
UPDATE bos_parammgmt.component_prompt
SET system_prompt = COALESCE($1, system_prompt),
user_template = COALESCE($2, user_template),
description = COALESCE($3, description),
system_prompt_ro = COALESCE($5, system_prompt_ro),
user_template_ro = COALESCE($6, user_template_ro),
updated_date = CURRENT_DATE
WHERE prompt_id = $4
RETURNING *
`, [system_prompt, user_template, description, req.params.id, system_prompt_ro, user_template_ro]);
if (!prompt) {
return res.status(404).json({ success: false, error: 'Prompt not found' });
}
res.json({ success: true, data: prompt, message: 'Prompt updated successfully' });
} catch (error) {
internalError(res, error);
}
});
router.delete('/prompts/:id', async (req: Request, res: Response) => {
try {
const result = await query(
'DELETE FROM bos_parammgmt.component_prompt WHERE prompt_id = $1 RETURNING *',
[req.params.id]
);
if (result.length === 0) {
return res.status(404).json({ success: false, error: 'Prompt not found' });
}
res.json({ success: true, message: 'Prompt deleted successfully', deleted: true });
} catch (error) {
internalError(res, error);
}
});
export default router;

View file

@ -0,0 +1,98 @@
/**
* Providers/test.ts utility endpoint to test a provider connection.
* POST /test/:providerId sends a small "hello" request to the provider's
* base_url with the active API key. Returns success/failure + latency.
* Used by the admin dashboard to verify a provider is reachable before
* relying on it in production analyses.
*/
import { Router, Request, Response } from 'express';
import { query, queryOne } from '../../config/database';
import { log } from '../../config/logger';
import { internalError } from '../../config/error-response';
import type { ApiKey, LlmProvider } from './_helpers';
const router = Router();
router.post('/test/:providerId', async (req: Request, res: Response) => {
try {
const provider = await queryOne<LlmProvider>(
'SELECT * FROM llm_provider WHERE provider_id = $1',
[req.params.providerId]
);
if (!provider) {
return res.status(404).json({ success: false, error: 'Provider not found' });
}
// Get active API key for this provider
const apiKey = await queryOne<ApiKey>(
'SELECT * FROM provider_api_key WHERE provider_id = $1 AND is_active = true ORDER BY api_key_id LIMIT 1',
[req.params.providerId]
);
if (!apiKey && provider.auth_type !== 'none') {
return res.status(400).json({
success: false,
error: 'No active API key configured for this provider'
});
}
// Basic connectivity test based on provider type
const startTime = Date.now();
let testResult: { success: boolean; latencyMs?: number; error?: string; details?: any } = { success: false };
try {
if (provider.provider_code === 'qwen' || provider.provider_code === 'm17') {
// Local provider - just check health endpoint
const response = await fetch(`${provider.base_url}/health`, {
method: 'GET',
signal: AbortSignal.timeout(5000),
});
testResult = {
success: response.ok,
latencyMs: Date.now() - startTime,
details: { status: response.status },
};
} else if (provider.provider_code === 'openrouter') {
// OpenRouter - check models endpoint
const response = await fetch('https://openrouter.ai/api/v1/models', {
method: 'GET',
headers: {
'Authorization': `Bearer ${apiKey?.api_key_value}`,
},
signal: AbortSignal.timeout(10000),
});
testResult = {
success: response.ok,
latencyMs: Date.now() - startTime,
details: { status: response.status, modelsAvailable: response.ok },
};
} else {
// Generic test - assume success if we have config
testResult = {
success: true,
latencyMs: Date.now() - startTime,
details: { message: 'Provider configured, direct test not implemented' },
};
}
} catch (error) {
testResult = {
success: false,
latencyMs: Date.now() - startTime,
error: error instanceof Error ? error.message : 'Connection failed',
};
}
res.json({
success: true,
data: {
provider: provider.provider_name,
...testResult,
},
});
} catch (error) {
internalError(res, error);
}
});
export default router;

View file

@ -0,0 +1,273 @@
/**
* SENSITIVE TOPICS ROUTES list of topics that trigger HIL review +
* volatility taxonomy that drives brain cache TTL and recency boost.
*
* GET /api/sensitive-topics list (filter ?active=true|false|all, default 'true')
* POST /api/sensitive-topics create new topic (volatility fields optional)
* PUT /api/sensitive-topics/:id update any of: label, active, volatility, ttl, recency, half_life
* DELETE /api/sensitive-topics/:id soft delete (set is_active=false)
*
* Stored in bos_parammgmt.sensitive_topic. Migration 011 created the table;
* migration 012 added (volatility, cache_ttl_hours, recency_window_days,
* half_life_days) for the brain cache freshness defense (Phase D1).
*
* Synced to Redis under TWO keys:
* - didi:config:moderation:v1:sensitive_topics used by HIL agent-v3
* (only topic_code + topic_label, unchanged shape, backwards compatible)
* - didi:config:topics:volatility used by brain cache freshness logic,
* includes volatility/ttl/recency_window/half_life per topic (D1 addition)
*/
import { Router, type Request, type Response } from 'express';
import { query } from '../config/database';
import { internalError } from '../config/error-response';
const router = Router();
const TOPIC_CODE_REGEX = /^[a-z0-9_]+$/;
const ALLOWED_VOLATILITY = new Set(['volatile', 'evolving', 'stable']);
// Atomic taxonomy paths look like "Topics/Health/" or "Topics/Politics/Elections".
// We keep validation light because atomic taxonomy is dynamic — operators may
// create new namespaces in atomic-server that we don't know about yet. We only
// reject obviously bogus shapes (whitespace, leading slash, length).
const ATOMIC_PATH_REGEX = /^[A-Za-z][A-Za-z0-9_/-]*\/?$/;
const ATOMIC_PATH_MAX_LEN = 200;
// ----------------------------------------------------------------------------
// Validation helpers — keep schema-side CHECK constraints aligned with API.
// ----------------------------------------------------------------------------
function validateVolatilityFields(body: Record<string, unknown>): string | null {
const { volatility, cache_ttl_hours, recency_window_days, half_life_days } = body;
if (volatility !== undefined && (typeof volatility !== 'string' || !ALLOWED_VOLATILITY.has(volatility))) {
return 'volatility must be one of: volatile, evolving, stable';
}
if (cache_ttl_hours !== undefined) {
const v = Number(cache_ttl_hours);
if (!Number.isFinite(v) || v < 1 || v > 26280) {
return 'cache_ttl_hours must be between 1 and 26280';
}
}
if (recency_window_days !== undefined) {
const v = Number(recency_window_days);
if (!Number.isFinite(v) || v < 1 || v > 365) {
return 'recency_window_days must be between 1 and 365';
}
}
if (half_life_days !== undefined) {
const v = Number(half_life_days);
if (!Number.isFinite(v) || v <= 0) {
return 'half_life_days must be greater than 0';
}
}
return null;
}
function validateAtomicPathPrefix(body: Record<string, unknown>): string | null {
const v = body.atomic_path_prefix;
if (v === undefined || v === null || v === '') return null; // optional, NULL allowed
if (typeof v !== 'string') return 'atomic_path_prefix must be a string or null';
if (v.length > ATOMIC_PATH_MAX_LEN) {
return `atomic_path_prefix too long (max ${ATOMIC_PATH_MAX_LEN} chars)`;
}
if (!ATOMIC_PATH_REGEX.test(v)) {
return 'atomic_path_prefix must look like "Topics/Health/" or "Topics/Politics/Elections" (no leading slash, no whitespace)';
}
return null;
}
// All columns that PUT may modify, in the order the SQL below references
// them via $1..$7. Keeping this list as the single source of truth lets the
// validation pass loop over it without drifting from the SQL.
const PUT_FIELDS = [
'topic_label',
'is_active',
'volatility',
'cache_ttl_hours',
'recency_window_days',
'half_life_days',
'atomic_path_prefix',
] as const;
// ============================================================================
// GET / — list topics
// ============================================================================
router.get('/', async (req: Request, res: Response) => {
try {
const filter = (req.query.active as string | undefined) ?? 'true';
let where = '';
if (filter === 'true') where = 'WHERE is_active = true';
else if (filter === 'false') where = 'WHERE is_active = false';
else if (filter !== 'all') {
return res.status(400).json({ success: false, error: 'Invalid ?active value (use true|false|all)' });
}
const rows = await query(
`SELECT topic_id, topic_code, topic_label, is_active,
volatility, cache_ttl_hours, recency_window_days, half_life_days,
atomic_path_prefix,
created_at, updated_at
FROM bos_parammgmt.sensitive_topic ${where}
ORDER BY topic_id`
);
res.json({ success: true, data: rows, count: rows.length });
} catch (error) {
internalError(res, error);
}
});
// ============================================================================
// POST / — create new topic (volatility fields optional, fall to defaults)
// ============================================================================
router.post('/', async (req: Request, res: Response) => {
try {
const body = (req.body ?? {}) as Record<string, unknown>;
const { topic_code, topic_label, is_active, volatility, cache_ttl_hours, recency_window_days, half_life_days, atomic_path_prefix } = body;
if (!topic_code || typeof topic_code !== 'string') {
return res.status(400).json({ success: false, error: 'topic_code (string) required' });
}
if (!TOPIC_CODE_REGEX.test(topic_code)) {
return res.status(400).json({ success: false, error: 'topic_code must match [a-z0-9_]+' });
}
if (!topic_label || typeof topic_label !== 'string') {
return res.status(400).json({ success: false, error: 'topic_label (string) required' });
}
const volErr = validateVolatilityFields(body);
if (volErr) return res.status(400).json({ success: false, error: volErr });
const pathErr = validateAtomicPathPrefix(body);
if (pathErr) return res.status(400).json({ success: false, error: pathErr });
const rows = await query(
`INSERT INTO bos_parammgmt.sensitive_topic
(topic_code, topic_label, is_active,
volatility, cache_ttl_hours, recency_window_days, half_life_days,
atomic_path_prefix)
VALUES ($1, $2, COALESCE($3, true),
COALESCE($4, 'evolving'), COALESCE($5, 720),
COALESCE($6, 30), COALESCE($7, 30.0),
$8)
RETURNING topic_id, topic_code, topic_label, is_active,
volatility, cache_ttl_hours, recency_window_days, half_life_days,
atomic_path_prefix,
created_at, updated_at`,
[topic_code, topic_label, is_active,
volatility, cache_ttl_hours, recency_window_days, half_life_days,
atomic_path_prefix ?? null]
);
res.status(201).json({ success: true, data: rows[0] });
} catch (error) {
const err = error as { code?: string; message: string };
if (err.code === '23505') {
return res.status(409).json({ success: false, error: 'topic_code already exists' });
}
if (err.code === '23514') {
return res.status(400).json({ success: false, error: `check constraint failed: ${err.message}` });
}
internalError(res, err);
}
});
// ============================================================================
// PUT /:id — update any of: label, active, volatility, ttl, recency, half_life
// (topic_code is immutable)
// ============================================================================
router.put('/:id', async (req: Request, res: Response) => {
try {
const id = parseInt(req.params.id, 10);
if (Number.isNaN(id)) {
return res.status(400).json({ success: false, error: 'Invalid id' });
}
const body = (req.body ?? {}) as Record<string, unknown>;
const provided = PUT_FIELDS.filter((k) => body[k] !== undefined);
if (provided.length === 0) {
return res.status(400).json({
success: false,
error: `Nothing to update (provide one of: ${PUT_FIELDS.join(', ')})`,
});
}
const volErr = validateVolatilityFields(body);
if (volErr) return res.status(400).json({ success: false, error: volErr });
const pathErr = validateAtomicPathPrefix(body);
if (pathErr) return res.status(400).json({ success: false, error: pathErr });
// Build params: first 6 PUT_FIELDS (topic_label..half_life_days), then
// conditionally the atomic_path_prefix, then id last. We explicitly omit
// $7 from params when clearAtomic is true so Postgres doesn't complain
// about an unused, untyped parameter.
const baseParams: unknown[] = PUT_FIELDS.slice(0, 6).map((k) =>
body[k] === undefined ? null : body[k],
);
const clearAtomic = body.atomic_path_prefix === null || body.atomic_path_prefix === '';
let atomicSql: string;
const params: unknown[] = [...baseParams];
if (clearAtomic) {
atomicSql = 'NULL';
params.push(id);
} else if (body.atomic_path_prefix === undefined) {
atomicSql = 'atomic_path_prefix'; // no-op: keep existing value
params.push(id);
} else {
atomicSql = '$7';
params.push(body.atomic_path_prefix, id);
}
const idIdx = params.length;
const rows = await query(
`UPDATE bos_parammgmt.sensitive_topic
SET topic_label = COALESCE($1, topic_label),
is_active = COALESCE($2, is_active),
volatility = COALESCE($3, volatility),
cache_ttl_hours = COALESCE($4, cache_ttl_hours),
recency_window_days = COALESCE($5, recency_window_days),
half_life_days = COALESCE($6, half_life_days),
atomic_path_prefix = ${atomicSql}
WHERE topic_id = $${idIdx}
RETURNING topic_id, topic_code, topic_label, is_active,
volatility, cache_ttl_hours, recency_window_days, half_life_days,
atomic_path_prefix,
created_at, updated_at`,
params
);
if (rows.length === 0) {
return res.status(404).json({ success: false, error: `Topic id=${id} not found` });
}
res.json({ success: true, data: rows[0] });
} catch (error) {
const err = error as { code?: string; message: string };
if (err.code === '23514') {
return res.status(400).json({ success: false, error: `check constraint failed: ${err.message}` });
}
internalError(res, err);
}
});
// ============================================================================
// DELETE /:id — soft delete (is_active=false)
// ============================================================================
router.delete('/:id', async (req: Request, res: Response) => {
try {
const id = parseInt(req.params.id, 10);
if (Number.isNaN(id)) {
return res.status(400).json({ success: false, error: 'Invalid id' });
}
const rows = await query(
`UPDATE bos_parammgmt.sensitive_topic SET is_active = false WHERE topic_id = $1
RETURNING topic_id, topic_code`,
[id]
);
if (rows.length === 0) {
return res.status(404).json({ success: false, error: `Topic id=${id} not found` });
}
res.json({ success: true, message: `Topic '${rows[0].topic_code}' deactivated` });
} catch (error) {
internalError(res, error);
}
});
export default router;

View file

@ -0,0 +1,99 @@
/**
* SKILLS CATALOG read-only registry of executable resources (Modul 1:
* catalog resurse AI: modele / skills / code-jobs").
*
* - analysis_components: nodurile pipeline-ului de analiză (agent-v3),
* configurabile prin didiFramework (prompturi/modele/ponderi).
* - extractor_skills: modulele platformei AI (host configurabil) fiecare un
* serviciu Python izolat în propriul container, apelabil prin API.
* Include un health probe live (fail-open).
* - code_jobs: execuția de cod = aceleași module Python containerizate
* (izolare la nivel de container); job-uri ad-hoc definite de utilizator
* rămân pe roadmap.
*
* Modelele LLM au catalogul lor CRUD la /api/providers/models (deployment,
* compute_target, quantization, capabilities vezi migrația 017).
*
* Env: AI_PLATFORM_HOST (host platformei AI), AI_PLATFORM_TOKEN (optional
* Bearer pentru gateway/catalog-api).
*/
import { Router, type Request, type Response } from 'express';
import { internalError } from '../config/error-response';
const router = Router();
const AI_HOST = process.env.AI_PLATFORM_HOST || 'localhost';
interface SkillDef {
code: string;
name: string;
runtime: string;
port: number;
health_path: string;
capabilities: string[];
description: string;
}
const ANALYSIS_COMPONENTS = [
{ code: 'media_preprocess', name: 'Media Pre-processing', queue: 'analysis.media_preprocess.{plan}', inputs: ['video', 'audio', 'image'], outputs: ['transcript', 'frames', 'ocr_text', 'buster_verdict', 'forensic_features', 'metadata', 'ner', 'sentiment'], configurable_via: ['component_stage_assignment', 'vision prompts'] },
{ code: 'techniques', name: 'Manipulation Techniques Detection', queue: 'analysis.techniques.{plan}', inputs: ['text'], outputs: ['techniques_score', 'detected_techniques'], configurable_via: ['component_prompt', 'component_stage_assignment', 'weights', 'dimensions/indicators CRUD'] },
{ code: 'ai_tampered', name: 'AI-Generated Content Detection', queue: 'analysis.ai_tampered.{plan}', inputs: ['text', 'image', 'video'], outputs: ['ai_probability', 'categories'], configurable_via: ['component_prompt', 'component_stage_assignment', 'scoring_config'] },
{ code: 'claims', name: 'Claim Extraction & Verification', queue: 'analysis.claims.{plan}', inputs: ['text'], outputs: ['claims', 'verification_status'], configurable_via: ['component_prompt', 'component_stage_assignment', 'claim types CRUD'] },
{ code: 'domain', name: 'Source Credibility Assessment', queue: 'analysis.domain.{plan}', inputs: ['url', 'text'], outputs: ['source_score', 'credibility'], configurable_via: ['component_prompt', 'sources CRUD'] },
{ code: 'verdict_aggregator', name: 'Verdict Aggregation', queue: 'analysis.results', inputs: ['component_results'], outputs: ['risk_score', 'risk_category', 'explanations RO+EN'], configurable_via: ['input_type_profile (pipeline definition)', 'weights', 'verdicts CRUD'] },
];
const EXTRACTOR_SKILLS: SkillDef[] = [
{ code: 'llm-inference', name: 'LLM Router (Qwen3.5 text+vision+OCR)', runtime: 'python-container', port: 14011, health_path: '/health', capabilities: ['text', 'vision', 'ocr', 'streaming'], description: 'Router LLM OpenAI-compatible; vLLM GPU / llama.cpp CPU / LiteLLM cloud' },
{ code: 'embeddings', name: 'Embeddings BGE-M3', runtime: 'python-container', port: 14100, health_path: '/health', capabilities: ['embeddings'], description: 'Vectori 1024-dim, max 8192 tokeni' },
{ code: 'rerank', name: 'Reranker BGE-v2-m3', runtime: 'python-container', port: 14200, health_path: '/health', capabilities: ['rerank'], description: 'Sortare documente după relevanță (Cohere/Jina-compatible)' },
{ code: 'audio', name: 'Speech-to-Text (Whisper large-v3-turbo)', runtime: 'python-container', port: 54300, health_path: '/health', capabilities: ['transcription'], description: '99+ limbi, VAD, faster-whisper' },
{ code: 'video-analysis', name: 'Video Analysis + Deepfake (BusterX++)', runtime: 'python-container', port: 54600, health_path: '/health', capabilities: ['deepfake', 'video-semantic'], description: 'Verdict REAL/FAKE/UNCERTAIN + analiză semantică pe chunk-uri' },
{ code: 'extractors', name: 'Multi-signal Extractors', runtime: 'python-container', port: 54400, health_path: '/health', capabilities: ['exif', 'ela', 'c2pa', 'ner', 'object-detection', 'ocr', 'sentiment'], description: 'EXIF/ELA/C2PA/SHA256, GLiNER NER, YOLOv8, OCR, sentiment' },
{ code: 'forensic-features', name: 'Forensic Features (m25m29)', runtime: 'python-container', port: 8085, health_path: '/health', capabilities: ['rppg', 'lip-sync', 'ai-detector', 'forgery-heatmap', 'lighting-3d'], description: 'Semnale forensice obiective pentru LLM (nu dă verdict)' },
{ code: 'web', name: 'Web Evidence / Fact-check Pipeline', runtime: 'python-container', port: 51100, health_path: '/health', capabilities: ['search', 'fetch', 'evidence-packing'], description: 'SearXNG multi-round + Playwright + Vision fallback, protecție SSRF' },
{ code: 'cloak', name: 'Stealth SERP Scraper', runtime: 'python-container', port: 8770, health_path: '/health', capabilities: ['serp'], description: 'Google/Bing/DDG via CloakBrowser (tier-3 fallback)' },
{ code: 'didi-brain', name: 'Knowledge Brain (RAG + verification cache)', runtime: 'python-container', port: 8090, health_path: '/health', capabilities: ['rag', 'verification-cache', 'fact-status'], description: 'pgvector, analysis atoms gold/silver/bronze, invalidare TTL' },
];
async function probeHealth(skill: SkillDef): Promise<string> {
try {
const headers: Record<string, string> = {};
if (process.env.AI_PLATFORM_TOKEN) headers.Authorization = `Bearer ${process.env.AI_PLATFORM_TOKEN}`;
const resp = await fetch(`http://${AI_HOST}:${skill.port}${skill.health_path}`, {
headers, signal: AbortSignal.timeout(1500),
});
return resp.ok ? 'healthy' : `http_${resp.status}`;
} catch {
return 'unreachable';
}
}
// GET /api/skills — full catalog (health probe optional via ?health=true)
router.get('/', async (req: Request, res: Response) => {
try {
const withHealth = req.query.health === 'true';
const extractors = withHealth
? await Promise.all(EXTRACTOR_SKILLS.map(async s => ({ ...s, host: AI_HOST, health: await probeHealth(s) })))
: EXTRACTOR_SKILLS.map(s => ({ ...s, host: AI_HOST }));
res.json({
success: true,
data: {
analysis_components: ANALYSIS_COMPONENTS,
extractor_skills: extractors,
code_jobs: {
status: 'container-isolated',
note: 'Execuția de cod rulează ca module Python izolate per container (extractor_skills de mai sus). Job-uri ad-hoc definite de utilizator: roadmap.',
},
models_catalog: '/api/providers/models',
pipelines_catalog: '/api/pipelines',
},
counts: { analysis_components: ANALYSIS_COMPONENTS.length, extractor_skills: EXTRACTOR_SKILLS.length },
});
} catch (error) {
internalError(res, error);
}
});
export default router;

View file

@ -0,0 +1,86 @@
/**
* Shared infra for source-assessment routes:
* - Helpers: createParameter (parameter-row factory), getNextId (cheap MAX+1).
* - 7 row-type interfaces kept here because they're used by `overview.ts`
* which queries all 7 tables in parallel; collocating prevents cycles
* between sibling files.
*/
import type { PoolClient } from 'pg';
export const createParameter = async (client: PoolClient, parameterType: number): Promise<number> => {
const maxResult = await client.query('SELECT COALESCE(MAX(parameter_id), 0) + 1 as next_id FROM parameter');
const nextParamId = maxResult.rows[0].next_id;
await client.query(`
INSERT INTO parameter (parameter_id, parameter_type, valid_from, valid_to, created_date, updated_date)
VALUES ($1, $2, '2026-01-01', '2999-01-01', CURRENT_DATE, CURRENT_DATE)
`, [nextParamId, parameterType]);
return nextParamId;
};
export const getNextId = async (client: PoolClient, table: string, idColumn: string): Promise<number> => {
const result = await client.query(`SELECT COALESCE(MAX(${idColumn}), 0) + 1 as next_id FROM ${table}`);
return result.rows[0].next_id;
};
export interface PlatformModifier {
platform_modifier_id: number;
platform_modifier: string;
condition: string;
score: number;
}
export interface SourceCredibility {
source_credibility_id: number;
source_credibility: string;
factor: number;
condition: string;
}
export interface DomainAgeScore {
domain_age_score: number;
start_range: number;
end_range: number;
description: string;
score_impact: number;
}
export interface DomainRiskLevel {
domain_risk_level_id: number;
domain_risk_level: string;
start_range: number;
end_range: number;
interpretation: string;
score_impact: number;
}
export interface DomainRedFlag {
domain_red_flag_id: number;
domain_red_flag: string;
condition: string;
severity: number;
action: string;
}
export interface AuthorClassification {
author_classification_id: number;
author_classification_code: string;
author_classification_name: string;
score: number;
}
export interface AuthorCredibility {
author_credibility_id: number;
author_credibility: string;
impact: number;
}
export interface SourceAssessmentRange {
source_assessment_id: number;
source_assessment: string;
description: string;
calculation_start: number;
calcularion_end: number;
parameter_id: number;
}

View file

@ -0,0 +1,102 @@
/**
* Author classifications classification codes/names + base score.
* Has children: author.author_classification_id.
*/
import { Router, Request, Response } from 'express';
import { query, queryOne, transaction } from '../../config/database';
import { safeDelete } from '../../utils/dependency-checker';
import { internalError } from '../../config/error-response';
import { getNextId, type AuthorClassification } from './_shared';
const router = Router();
router.get('/author-classifications', async (_req: Request, res: Response) => {
try {
const data = await query<AuthorClassification & { usage_count: number }>(`
SELECT ac.*,
(SELECT COUNT(*) FROM author a WHERE a.author_classification_id = ac.author_classification_id) as usage_count
FROM author_classification ac
ORDER BY ac.author_classification_id
`);
res.json({ success: true, data, count: data.length });
} catch (error) {
internalError(res, error, 'sa_author_classifications_list');
}
});
router.get('/author-classifications/:id', async (req: Request, res: Response) => {
try {
const data = await queryOne<AuthorClassification>(
'SELECT * FROM author_classification WHERE author_classification_id = $1',
[req.params.id]
);
if (!data) {
return res.status(404).json({ success: false, error: 'Author classification nu a fost găsit' });
}
res.json({ success: true, data });
} catch (error) {
internalError(res, error, 'sa_author_classifications_get');
}
});
router.post('/author-classifications', async (req: Request, res: Response) => {
try {
const { author_classification_code, author_classification_name, score } = req.body;
if (!author_classification_code || !author_classification_name) {
return res.status(400).json({ success: false, error: 'Câmpuri obligatorii: author_classification_code, author_classification_name' });
}
const result = await transaction(async (client) => {
const id = await getNextId(client, 'author_classification', 'author_classification_id');
const insertResult = await client.query(`
INSERT INTO author_classification (author_classification_id, author_classification_code, author_classification_name, score)
VALUES ($1, $2, $3, $4)
RETURNING *
`, [id, author_classification_code, author_classification_name, score || 0]);
return insertResult.rows[0];
});
res.status(201).json({ success: true, data: result, message: 'Author classification creat cu succes' });
} catch (error) {
internalError(res, error, 'sa_author_classifications_create');
}
});
router.put('/author-classifications/:id', async (req: Request, res: Response) => {
try {
const { author_classification_code, author_classification_name, score } = req.body;
const result = await queryOne<AuthorClassification>(`
UPDATE author_classification
SET author_classification_code = COALESCE($1, author_classification_code),
author_classification_name = COALESCE($2, author_classification_name),
score = COALESCE($3, score)
WHERE author_classification_id = $4
RETURNING *
`, [author_classification_code, author_classification_name, score, req.params.id]);
if (!result) {
return res.status(404).json({ success: false, error: 'Author classification nu a fost găsit' });
}
res.json({ success: true, data: result, message: 'Author classification actualizat cu succes' });
} catch (error) {
internalError(res, error, 'sa_author_classifications_update');
}
});
router.delete('/author-classifications/:id', async (req: Request, res: Response) => {
try {
const deleteResult = await safeDelete('author_classification', 'author_classification_id', req.params.id);
if (!deleteResult.success) {
return res.status(409).json({
success: false,
error: deleteResult.message,
dependencies: deleteResult.dependencyDetails?.dependencies
});
}
res.json({ success: true, message: 'Author classification șters cu succes', deleted: true });
} catch (error) {
internalError(res, error, 'sa_author_classifications_delete');
}
});
export default router;

View file

@ -0,0 +1,101 @@
/**
* Author credibility credibility tier per author with score impact.
* Has children: author.author_credibility_id.
*/
import { Router, Request, Response } from 'express';
import { query, queryOne, transaction } from '../../config/database';
import { safeDelete } from '../../utils/dependency-checker';
import { internalError } from '../../config/error-response';
import { getNextId, type AuthorCredibility } from './_shared';
const router = Router();
router.get('/author-credibility', async (_req: Request, res: Response) => {
try {
const data = await query<AuthorCredibility & { usage_count: number }>(`
SELECT acr.*,
(SELECT COUNT(*) FROM author a WHERE a.author_credibility_id = acr.author_credibility_id) as usage_count
FROM author_credibility acr
ORDER BY acr.author_credibility_id
`);
res.json({ success: true, data, count: data.length });
} catch (error) {
internalError(res, error, 'sa_author_credibility_list');
}
});
router.get('/author-credibility/:id', async (req: Request, res: Response) => {
try {
const data = await queryOne<AuthorCredibility>(
'SELECT * FROM author_credibility WHERE author_credibility_id = $1',
[req.params.id]
);
if (!data) {
return res.status(404).json({ success: false, error: 'Author credibility nu a fost găsit' });
}
res.json({ success: true, data });
} catch (error) {
internalError(res, error, 'sa_author_credibility_get');
}
});
router.post('/author-credibility', async (req: Request, res: Response) => {
try {
const { author_credibility, impact } = req.body;
if (!author_credibility) {
return res.status(400).json({ success: false, error: 'Câmp obligatoriu: author_credibility' });
}
const result = await transaction(async (client) => {
const id = await getNextId(client, 'author_credibility', 'author_credibility_id');
const insertResult = await client.query(`
INSERT INTO author_credibility (author_credibility_id, author_credibility, impact)
VALUES ($1, $2, $3)
RETURNING *
`, [id, author_credibility, impact || 0]);
return insertResult.rows[0];
});
res.status(201).json({ success: true, data: result, message: 'Author credibility creat cu succes' });
} catch (error) {
internalError(res, error, 'sa_author_credibility_create');
}
});
router.put('/author-credibility/:id', async (req: Request, res: Response) => {
try {
const { author_credibility, impact } = req.body;
const result = await queryOne<AuthorCredibility>(`
UPDATE author_credibility
SET author_credibility = COALESCE($1, author_credibility),
impact = COALESCE($2, impact)
WHERE author_credibility_id = $3
RETURNING *
`, [author_credibility, impact, req.params.id]);
if (!result) {
return res.status(404).json({ success: false, error: 'Author credibility nu a fost găsit' });
}
res.json({ success: true, data: result, message: 'Author credibility actualizat cu succes' });
} catch (error) {
internalError(res, error, 'sa_author_credibility_update');
}
});
router.delete('/author-credibility/:id', async (req: Request, res: Response) => {
try {
const deleteResult = await safeDelete('author_credibility', 'author_credibility_id', req.params.id);
if (!deleteResult.success) {
return res.status(409).json({
success: false,
error: deleteResult.message,
dependencies: deleteResult.dependencyDetails?.dependencies
});
}
res.json({ success: true, message: 'Author credibility șters cu succes', deleted: true });
} catch (error) {
internalError(res, error, 'sa_author_credibility_delete');
}
});
export default router;

View file

@ -0,0 +1,102 @@
/**
* Domain age scores score buckets keyed by registered-domain age (years).
* Has children: domain_attribute.domain_age_score.
*
* Note: PK column is `domain_age_score` itself (not a generated id), so the
* insert path takes domain_age_score from the request body no MAX+1 helper.
*/
import { Router, Request, Response } from 'express';
import { query, queryOne } from '../../config/database';
import { safeDelete } from '../../utils/dependency-checker';
import { internalError } from '../../config/error-response';
import type { DomainAgeScore } from './_shared';
const router = Router();
router.get('/domain-age-scores', async (_req: Request, res: Response) => {
try {
const data = await query<DomainAgeScore & { usage_count: number }>(`
SELECT das.*,
(SELECT COUNT(*) FROM domain_attribute da WHERE da.domain_age_score = das.domain_age_score) as usage_count
FROM domain_age_score das
ORDER BY das.domain_age_score
`);
res.json({ success: true, data, count: data.length });
} catch (error) {
internalError(res, error, 'sa_domain_age_scores_list');
}
});
router.get('/domain-age-scores/:id', async (req: Request, res: Response) => {
try {
const data = await queryOne<DomainAgeScore>(
'SELECT * FROM domain_age_score WHERE domain_age_score = $1',
[req.params.id]
);
if (!data) {
return res.status(404).json({ success: false, error: 'Domain age score nu a fost găsit' });
}
res.json({ success: true, data });
} catch (error) {
internalError(res, error, 'sa_domain_age_scores_get');
}
});
router.post('/domain-age-scores', async (req: Request, res: Response) => {
try {
const { domain_age_score, start_range, end_range, description, score_impact } = req.body;
if (domain_age_score === undefined || start_range === undefined || end_range === undefined) {
return res.status(400).json({ success: false, error: 'Câmpuri obligatorii: domain_age_score, start_range, end_range' });
}
const result = await queryOne<DomainAgeScore>(`
INSERT INTO domain_age_score (domain_age_score, start_range, end_range, description, score_impact)
VALUES ($1, $2, $3, $4, $5)
RETURNING *
`, [domain_age_score, start_range, end_range, description || '', score_impact || 0]);
res.status(201).json({ success: true, data: result, message: 'Domain age score creat cu succes' });
} catch (error) {
internalError(res, error, 'sa_domain_age_scores_create');
}
});
router.put('/domain-age-scores/:id', async (req: Request, res: Response) => {
try {
const { start_range, end_range, description, score_impact } = req.body;
const result = await queryOne<DomainAgeScore>(`
UPDATE domain_age_score
SET start_range = COALESCE($1, start_range),
end_range = COALESCE($2, end_range),
description = COALESCE($3, description),
score_impact = COALESCE($4, score_impact)
WHERE domain_age_score = $5
RETURNING *
`, [start_range, end_range, description, score_impact, req.params.id]);
if (!result) {
return res.status(404).json({ success: false, error: 'Domain age score nu a fost găsit' });
}
res.json({ success: true, data: result, message: 'Domain age score actualizat cu succes' });
} catch (error) {
internalError(res, error, 'sa_domain_age_scores_update');
}
});
router.delete('/domain-age-scores/:id', async (req: Request, res: Response) => {
try {
const deleteResult = await safeDelete('domain_age_score', 'domain_age_score', req.params.id);
if (!deleteResult.success) {
return res.status(409).json({
success: false,
error: deleteResult.message,
dependencies: deleteResult.dependencyDetails?.dependencies
});
}
res.json({ success: true, message: 'Domain age score șters cu succes', deleted: true });
} catch (error) {
internalError(res, error, 'sa_domain_age_scores_delete');
}
});
export default router;

View file

@ -0,0 +1,103 @@
/**
* Domain red flags discrete red-flag rules with severity + recommended action.
* Has children: domain_attribute.domain_red_flag_id.
*/
import { Router, Request, Response } from 'express';
import { query, queryOne, transaction } from '../../config/database';
import { safeDelete } from '../../utils/dependency-checker';
import { internalError } from '../../config/error-response';
import { getNextId, type DomainRedFlag } from './_shared';
const router = Router();
router.get('/domain-red-flags', async (_req: Request, res: Response) => {
try {
const data = await query<DomainRedFlag & { usage_count: number }>(`
SELECT drf.*,
(SELECT COUNT(*) FROM domain_attribute da WHERE da.domain_red_flag_id = drf.domain_red_flag_id) as usage_count
FROM domain_red_flag drf
ORDER BY drf.domain_red_flag_id
`);
res.json({ success: true, data, count: data.length });
} catch (error) {
internalError(res, error, 'sa_domain_red_flags_list');
}
});
router.get('/domain-red-flags/:id', async (req: Request, res: Response) => {
try {
const data = await queryOne<DomainRedFlag>(
'SELECT * FROM domain_red_flag WHERE domain_red_flag_id = $1',
[req.params.id]
);
if (!data) {
return res.status(404).json({ success: false, error: 'Domain red flag nu a fost găsit' });
}
res.json({ success: true, data });
} catch (error) {
internalError(res, error, 'sa_domain_red_flags_get');
}
});
router.post('/domain-red-flags', async (req: Request, res: Response) => {
try {
const { domain_red_flag, condition, severity, action } = req.body;
if (!domain_red_flag) {
return res.status(400).json({ success: false, error: 'Câmp obligatoriu: domain_red_flag' });
}
const result = await transaction(async (client) => {
const id = await getNextId(client, 'domain_red_flag', 'domain_red_flag_id');
const insertResult = await client.query(`
INSERT INTO domain_red_flag (domain_red_flag_id, domain_red_flag, condition, severity, action)
VALUES ($1, $2, $3, $4, $5)
RETURNING *
`, [id, domain_red_flag, condition || '', severity || 0, action || '']);
return insertResult.rows[0];
});
res.status(201).json({ success: true, data: result, message: 'Domain red flag creat cu succes' });
} catch (error) {
internalError(res, error, 'sa_domain_red_flags_create');
}
});
router.put('/domain-red-flags/:id', async (req: Request, res: Response) => {
try {
const { domain_red_flag, condition, severity, action } = req.body;
const result = await queryOne<DomainRedFlag>(`
UPDATE domain_red_flag
SET domain_red_flag = COALESCE($1, domain_red_flag),
condition = COALESCE($2, condition),
severity = COALESCE($3, severity),
action = COALESCE($4, action)
WHERE domain_red_flag_id = $5
RETURNING *
`, [domain_red_flag, condition, severity, action, req.params.id]);
if (!result) {
return res.status(404).json({ success: false, error: 'Domain red flag nu a fost găsit' });
}
res.json({ success: true, data: result, message: 'Domain red flag actualizat cu succes' });
} catch (error) {
internalError(res, error, 'sa_domain_red_flags_update');
}
});
router.delete('/domain-red-flags/:id', async (req: Request, res: Response) => {
try {
const deleteResult = await safeDelete('domain_red_flag', 'domain_red_flag_id', req.params.id);
if (!deleteResult.success) {
return res.status(409).json({
success: false,
error: deleteResult.message,
dependencies: deleteResult.dependencyDetails?.dependencies
});
}
res.json({ success: true, message: 'Domain red flag șters cu succes', deleted: true });
} catch (error) {
internalError(res, error, 'sa_domain_red_flags_delete');
}
});
export default router;

View file

@ -0,0 +1,104 @@
/**
* Domain risk levels risk-tier ranges (start..end) with interpretation text.
* Has children: domain_attribute.domain_risk_level_id.
*/
import { Router, Request, Response } from 'express';
import { query, queryOne, transaction } from '../../config/database';
import { safeDelete } from '../../utils/dependency-checker';
import { internalError } from '../../config/error-response';
import { getNextId, type DomainRiskLevel } from './_shared';
const router = Router();
router.get('/domain-risk-levels', async (_req: Request, res: Response) => {
try {
const data = await query<DomainRiskLevel & { usage_count: number }>(`
SELECT drl.*,
(SELECT COUNT(*) FROM domain_attribute da WHERE da.domain_risk_level_id = drl.domain_risk_level_id) as usage_count
FROM domain_risk_level drl
ORDER BY drl.domain_risk_level_id
`);
res.json({ success: true, data, count: data.length });
} catch (error) {
internalError(res, error, 'sa_domain_risk_levels_list');
}
});
router.get('/domain-risk-levels/:id', async (req: Request, res: Response) => {
try {
const data = await queryOne<DomainRiskLevel>(
'SELECT * FROM domain_risk_level WHERE domain_risk_level_id = $1',
[req.params.id]
);
if (!data) {
return res.status(404).json({ success: false, error: 'Domain risk level nu a fost găsit' });
}
res.json({ success: true, data });
} catch (error) {
internalError(res, error, 'sa_domain_risk_levels_get');
}
});
router.post('/domain-risk-levels', async (req: Request, res: Response) => {
try {
const { domain_risk_level, start_range, end_range, interpretation, score_impact } = req.body;
if (!domain_risk_level) {
return res.status(400).json({ success: false, error: 'Câmp obligatoriu: domain_risk_level' });
}
const result = await transaction(async (client) => {
const id = await getNextId(client, 'domain_risk_level', 'domain_risk_level_id');
const insertResult = await client.query(`
INSERT INTO domain_risk_level (domain_risk_level_id, domain_risk_level, start_range, end_range, interpretation, score_impact)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *
`, [id, domain_risk_level, start_range || 0, end_range || 0, interpretation || '', score_impact || 0]);
return insertResult.rows[0];
});
res.status(201).json({ success: true, data: result, message: 'Domain risk level creat cu succes' });
} catch (error) {
internalError(res, error, 'sa_domain_risk_levels_create');
}
});
router.put('/domain-risk-levels/:id', async (req: Request, res: Response) => {
try {
const { domain_risk_level, start_range, end_range, interpretation, score_impact } = req.body;
const result = await queryOne<DomainRiskLevel>(`
UPDATE domain_risk_level
SET domain_risk_level = COALESCE($1, domain_risk_level),
start_range = COALESCE($2, start_range),
end_range = COALESCE($3, end_range),
interpretation = COALESCE($4, interpretation),
score_impact = COALESCE($5, score_impact)
WHERE domain_risk_level_id = $6
RETURNING *
`, [domain_risk_level, start_range, end_range, interpretation, score_impact, req.params.id]);
if (!result) {
return res.status(404).json({ success: false, error: 'Domain risk level nu a fost găsit' });
}
res.json({ success: true, data: result, message: 'Domain risk level actualizat cu succes' });
} catch (error) {
internalError(res, error, 'sa_domain_risk_levels_update');
}
});
router.delete('/domain-risk-levels/:id', async (req: Request, res: Response) => {
try {
const deleteResult = await safeDelete('domain_risk_level', 'domain_risk_level_id', req.params.id);
if (!deleteResult.success) {
return res.status(409).json({
success: false,
error: deleteResult.message,
dependencies: deleteResult.dependencyDetails?.dependencies
});
}
res.json({ success: true, message: 'Domain risk level șters cu succes', deleted: true });
} catch (error) {
internalError(res, error, 'sa_domain_risk_levels_delete');
}
});
export default router;

View file

@ -0,0 +1,38 @@
/**
* SOURCE ASSESSMENT barrel router.
*
* Original 851-line source-assessment.ts split per entity:
* _shared.ts createParameter + getNextId helpers + 8 row types
* platform-modifiers.ts 5 CRUD + /:id/dependencies (only entity exposing deps)
* source-credibility.ts 5 CRUD
* domain-age-scores.ts 5 CRUD (PK = domain_age_score, no MAX+1 helper)
* domain-risk-levels.ts 5 CRUD
* domain-red-flags.ts 5 CRUD
* author-classifications.ts 5 CRUD
* author-credibility.ts 5 CRUD
* overview.ts GET /source-assessment-ranges + /source-assessment/all
*
* Mounted at /api/source-assessment in src/server.ts (router-level prefix).
*/
import { Router } from 'express';
import platformModifiersRouter from './platform-modifiers';
import sourceCredibilityRouter from './source-credibility';
import domainAgeScoresRouter from './domain-age-scores';
import domainRiskLevelsRouter from './domain-risk-levels';
import domainRedFlagsRouter from './domain-red-flags';
import authorClassificationsRouter from './author-classifications';
import authorCredibilityRouter from './author-credibility';
import overviewRouter from './overview';
const router = Router();
router.use(platformModifiersRouter);
router.use(sourceCredibilityRouter);
router.use(domainAgeScoresRouter);
router.use(domainRiskLevelsRouter);
router.use(domainRedFlagsRouter);
router.use(authorClassificationsRouter);
router.use(authorCredibilityRouter);
router.use(overviewRouter);
export default router;

View file

@ -0,0 +1,86 @@
/**
* Read-only overview endpoints:
*
* GET /source-assessment-ranges leaf-table lookup of score-band ranges.
* GET /source-assessment/all combined snapshot of all 7 entity tables
* in parallel (used by the admin dashboard's
* summary view to avoid 7 round-trips).
*/
import { Router, Request, Response } from 'express';
import { query } from '../../config/database';
import { internalError } from '../../config/error-response';
import type {
PlatformModifier,
SourceCredibility,
DomainAgeScore,
DomainRiskLevel,
DomainRedFlag,
AuthorClassification,
AuthorCredibility,
SourceAssessmentRange,
} from './_shared';
const router = Router();
router.get('/source-assessment-ranges', async (_req: Request, res: Response) => {
try {
const data = await query<SourceAssessmentRange>(
'SELECT * FROM source_assessment ORDER BY source_assessment_id'
);
res.json({ success: true, data, count: data.length });
} catch (error) {
internalError(res, error, 'sa_ranges_list');
}
});
router.get('/source-assessment/all', async (_req: Request, res: Response) => {
try {
const [
platformModifiers,
sourceCredibility,
domainAgeScores,
domainRiskLevels,
domainRedFlags,
authorClassifications,
authorCredibility
] = await Promise.all([
query<PlatformModifier>('SELECT * FROM platform_modifier ORDER BY platform_modifier_id'),
query<SourceCredibility>('SELECT * FROM source_credibility ORDER BY source_credibility_id'),
query<DomainAgeScore>('SELECT * FROM domain_age_score ORDER BY domain_age_score'),
query<DomainRiskLevel>('SELECT * FROM domain_risk_level ORDER BY domain_risk_level_id'),
query<DomainRedFlag>('SELECT * FROM domain_red_flag ORDER BY domain_red_flag_id'),
query<AuthorClassification>('SELECT * FROM author_classification ORDER BY author_classification_id'),
query<AuthorCredibility>('SELECT * FROM author_credibility ORDER BY author_credibility_id')
]);
res.json({
success: true,
data: {
platformModifiers,
sourceCredibility,
domainAgeScores,
domainRiskLevels,
domainRedFlags,
authorClassifications,
authorCredibility
},
counts: {
platformModifiers: platformModifiers.length,
sourceCredibility: sourceCredibility.length,
domainAgeScores: domainAgeScores.length,
domainRiskLevels: domainRiskLevels.length,
domainRedFlags: domainRedFlags.length,
authorClassifications: authorClassifications.length,
authorCredibility: authorCredibility.length,
total: platformModifiers.length + sourceCredibility.length +
domainAgeScores.length + domainRiskLevels.length +
domainRedFlags.length + authorClassifications.length +
authorCredibility.length
}
});
} catch (error) {
internalError(res, error, 'sa_all');
}
});
export default router;

View file

@ -0,0 +1,113 @@
/**
* Platform modifiers adjustments to scoring based on the publishing platform.
*
* Has children: platform.platform_modifier_id (FK).
* /:id/dependencies endpoint exposes the FK references for safe-delete preview.
*/
import { Router, Request, Response } from 'express';
import { query, queryOne, transaction } from '../../config/database';
import { checkDependencies, safeDelete } from '../../utils/dependency-checker';
import { internalError } from '../../config/error-response';
import { getNextId, type PlatformModifier } from './_shared';
const router = Router();
router.get('/platform-modifiers', async (_req: Request, res: Response) => {
try {
const data = await query<PlatformModifier & { platform_count: number }>(`
SELECT pm.*,
(SELECT COUNT(*) FROM platform p WHERE p.platform_modifier_id = pm.platform_modifier_id) as platform_count
FROM platform_modifier pm
ORDER BY pm.platform_modifier_id
`);
res.json({ success: true, data, count: data.length });
} catch (error) {
internalError(res, error, 'sa_platform_modifiers_list');
}
});
router.get('/platform-modifiers/:id', async (req: Request, res: Response) => {
try {
const data = await queryOne<PlatformModifier>(
'SELECT * FROM platform_modifier WHERE platform_modifier_id = $1',
[req.params.id]
);
if (!data) {
return res.status(404).json({ success: false, error: 'Platform modifier nu a fost găsit' });
}
res.json({ success: true, data });
} catch (error) {
internalError(res, error, 'sa_platform_modifiers_get');
}
});
router.get('/platform-modifiers/:id/dependencies', async (req: Request, res: Response) => {
try {
const depCheck = await checkDependencies('platform_modifier', 'platform_modifier_id', req.params.id);
res.json({ success: true, data: depCheck });
} catch (error) {
internalError(res, error, 'sa_platform_modifiers_deps');
}
});
router.post('/platform-modifiers', async (req: Request, res: Response) => {
try {
const { platform_modifier, condition, score } = req.body;
if (!platform_modifier) {
return res.status(400).json({ success: false, error: 'Câmp obligatoriu: platform_modifier' });
}
const result = await transaction(async (client) => {
const id = await getNextId(client, 'platform_modifier', 'platform_modifier_id');
const insertResult = await client.query(`
INSERT INTO platform_modifier (platform_modifier_id, platform_modifier, condition, score)
VALUES ($1, $2, $3, $4)
RETURNING *
`, [id, platform_modifier, condition || '', score || 0]);
return insertResult.rows[0];
});
res.status(201).json({ success: true, data: result, message: 'Platform modifier creat cu succes' });
} catch (error) {
internalError(res, error, 'sa_platform_modifiers_create');
}
});
router.put('/platform-modifiers/:id', async (req: Request, res: Response) => {
try {
const { platform_modifier, condition, score } = req.body;
const result = await queryOne<PlatformModifier>(`
UPDATE platform_modifier
SET platform_modifier = COALESCE($1, platform_modifier),
condition = COALESCE($2, condition),
score = COALESCE($3, score)
WHERE platform_modifier_id = $4
RETURNING *
`, [platform_modifier, condition, score, req.params.id]);
if (!result) {
return res.status(404).json({ success: false, error: 'Platform modifier nu a fost găsit' });
}
res.json({ success: true, data: result, message: 'Platform modifier actualizat cu succes' });
} catch (error) {
internalError(res, error, 'sa_platform_modifiers_update');
}
});
router.delete('/platform-modifiers/:id', async (req: Request, res: Response) => {
try {
const deleteResult = await safeDelete('platform_modifier', 'platform_modifier_id', req.params.id);
if (!deleteResult.success) {
return res.status(409).json({
success: false,
error: deleteResult.message,
dependencies: deleteResult.dependencyDetails?.dependencies
});
}
res.json({ success: true, message: 'Platform modifier șters cu succes', deleted: true });
} catch (error) {
internalError(res, error, 'sa_platform_modifiers_delete');
}
});
export default router;

View file

@ -0,0 +1,102 @@
/**
* Source credibility tiers rate the credibility of the publishing source.
* Has children: domain_attribute.source_credibility_id.
*/
import { Router, Request, Response } from 'express';
import { query, queryOne, transaction } from '../../config/database';
import { safeDelete } from '../../utils/dependency-checker';
import { internalError } from '../../config/error-response';
import { getNextId, type SourceCredibility } from './_shared';
const router = Router();
router.get('/source-credibility', async (_req: Request, res: Response) => {
try {
const data = await query<SourceCredibility & { usage_count: number }>(`
SELECT sc.*,
(SELECT COUNT(*) FROM domain_attribute da WHERE da.source_credibility_id = sc.source_credibility_id) as usage_count
FROM source_credibility sc
ORDER BY sc.source_credibility_id
`);
res.json({ success: true, data, count: data.length });
} catch (error) {
internalError(res, error, 'sa_source_credibility_list');
}
});
router.get('/source-credibility/:id', async (req: Request, res: Response) => {
try {
const data = await queryOne<SourceCredibility>(
'SELECT * FROM source_credibility WHERE source_credibility_id = $1',
[req.params.id]
);
if (!data) {
return res.status(404).json({ success: false, error: 'Source credibility nu a fost găsit' });
}
res.json({ success: true, data });
} catch (error) {
internalError(res, error, 'sa_source_credibility_get');
}
});
router.post('/source-credibility', async (req: Request, res: Response) => {
try {
const { source_credibility, factor, condition } = req.body;
if (!source_credibility) {
return res.status(400).json({ success: false, error: 'Câmp obligatoriu: source_credibility' });
}
const result = await transaction(async (client) => {
const id = await getNextId(client, 'source_credibility', 'source_credibility_id');
const insertResult = await client.query(`
INSERT INTO source_credibility (source_credibility_id, source_credibility, factor, condition)
VALUES ($1, $2, $3, $4)
RETURNING *
`, [id, source_credibility, factor || 0, condition || '']);
return insertResult.rows[0];
});
res.status(201).json({ success: true, data: result, message: 'Source credibility creat cu succes' });
} catch (error) {
internalError(res, error, 'sa_source_credibility_create');
}
});
router.put('/source-credibility/:id', async (req: Request, res: Response) => {
try {
const { source_credibility, factor, condition } = req.body;
const result = await queryOne<SourceCredibility>(`
UPDATE source_credibility
SET source_credibility = COALESCE($1, source_credibility),
factor = COALESCE($2, factor),
condition = COALESCE($3, condition)
WHERE source_credibility_id = $4
RETURNING *
`, [source_credibility, factor, condition, req.params.id]);
if (!result) {
return res.status(404).json({ success: false, error: 'Source credibility nu a fost găsit' });
}
res.json({ success: true, data: result, message: 'Source credibility actualizat cu succes' });
} catch (error) {
internalError(res, error, 'sa_source_credibility_update');
}
});
router.delete('/source-credibility/:id', async (req: Request, res: Response) => {
try {
const deleteResult = await safeDelete('source_credibility', 'source_credibility_id', req.params.id);
if (!deleteResult.success) {
return res.status(409).json({
success: false,
error: deleteResult.message,
dependencies: deleteResult.dependencyDetails?.dependencies
});
}
res.json({ success: true, message: 'Source credibility șters cu succes', deleted: true });
} catch (error) {
internalError(res, error, 'sa_source_credibility_delete');
}
});
export default router;

View file

@ -0,0 +1,125 @@
/**
* Source Types Routes - FULL CRUD
*
* Source types have children in domain_attribute, need safety check before delete.
*/
import { Router, Request, Response } from 'express';
import { query, queryOne, transaction } from '../config/database';
import { checkDependencies, safeDelete } from '../utils/dependency-checker';
import { SourceType, ApiResponse } from '../types';
import { PoolClient } from 'pg';
import { internalError } from '../config/error-response';
const router = Router();
// Helper functions
const getNextId = async (client: PoolClient, table: string, idColumn: string): Promise<number> => {
const result = await client.query(`SELECT COALESCE(MAX(${idColumn}), 0) + 1 as next_id FROM ${table}`);
return result.rows[0].next_id;
};
// GET all source types with usage count
router.get('/', async (req: Request, res: Response) => {
try {
const sources = await query<SourceType & { usage_count: number }>(`
SELECT st.*,
(SELECT COUNT(*) FROM domain_attribute da WHERE da.source_type_id = st.source_type_id) as usage_count
FROM source_type st
ORDER BY st.source_type_id
`);
res.json({ success: true, data: sources, count: sources.length });
} catch (error) {
internalError(res, error);
}
});
// GET source type by ID
router.get('/:id', async (req: Request, res: Response) => {
try {
const source = await queryOne<SourceType>(
'SELECT * FROM source_type WHERE source_type_id = $1',
[req.params.id]
);
if (!source) {
return res.status(404).json({ success: false, error: 'Source type nu a fost găsit' });
}
res.json({ success: true, data: source });
} catch (error) {
internalError(res, error);
}
});
// GET dependency check
router.get('/:id/dependencies', async (req: Request, res: Response) => {
try {
const depCheck = await checkDependencies('source_type', 'source_type_id', req.params.id);
res.json({ success: true, data: depCheck });
} catch (error) {
internalError(res, error);
}
});
// POST create source type
router.post('/', async (req: Request, res: Response) => {
try {
const { source_type, base_score } = req.body;
if (!source_type) {
return res.status(400).json({ success: false, error: 'Câmp obligatoriu: source_type' });
}
const result = await transaction(async (client) => {
const id = await getNextId(client, 'source_type', 'source_type_id');
const insertResult = await client.query(`
INSERT INTO source_type (source_type_id, source_type, base_score)
VALUES ($1, $2, $3)
RETURNING *
`, [id, source_type, base_score || 0]);
return insertResult.rows[0];
});
res.status(201).json({ success: true, data: result, message: 'Source type creat cu succes' });
} catch (error) {
internalError(res, error);
}
});
// PUT update source type
router.put('/:id', async (req: Request, res: Response) => {
try {
const { source_type, base_score } = req.body;
const source = await queryOne<SourceType>(`
UPDATE source_type
SET source_type = COALESCE($1, source_type),
base_score = COALESCE($2, base_score)
WHERE source_type_id = $3
RETURNING *
`, [source_type, base_score, req.params.id]);
if (!source) {
return res.status(404).json({ success: false, error: 'Source type nu a fost găsit' });
}
res.json({ success: true, data: source, message: 'Source type actualizat cu succes' });
} catch (error) {
internalError(res, error);
}
});
// DELETE source type (with safety check)
router.delete('/:id', async (req: Request, res: Response) => {
try {
const deleteResult = await safeDelete('source_type', 'source_type_id', req.params.id);
if (!deleteResult.success) {
return res.status(409).json({
success: false,
error: deleteResult.message,
dependencies: deleteResult.dependencyDetails?.dependencies
});
}
res.json({ success: true, message: 'Source type șters cu succes', deleted: true });
} catch (error) {
internalError(res, error);
}
});
export default router;

View file

@ -0,0 +1,342 @@
/**
* Subdimensions Routes - FULL CRUD with Safety
*
* Subdimensions are children of dimensions and parents of techniques:
* dimension -> subdimension -> technique -> indicator/validation_rule
*
* DELETE is protected - cannot delete subdimension with techniques
*/
import { Router, Request, Response } from 'express';
import { query, queryOne, transaction } from '../config/database';
import { checkDependencies, safeDelete } from '../utils/dependency-checker';
import { Subdimension, ApiResponse } from '../types';
import { PoolClient } from 'pg';
import { internalError } from '../config/error-response';
const router = Router();
// Parameter type for subdimensions
const PARAMETER_TYPE_SUBDIMENSION = 2;
// Helper: Create parameter entry
const createParameter = async (client: PoolClient): Promise<number> => {
const maxResult = await client.query('SELECT COALESCE(MAX(parameter_id), 0) + 1 as next_id FROM parameter');
const nextParamId = maxResult.rows[0].next_id;
await client.query(`
INSERT INTO parameter (parameter_id, parameter_type, valid_from, valid_to, created_date, updated_date)
VALUES ($1, $2, '2026-01-01', '2999-01-01', CURRENT_DATE, CURRENT_DATE)
`, [nextParamId, PARAMETER_TYPE_SUBDIMENSION]);
return nextParamId;
};
// Helper: Get next subdimension_id
const getNextSubdimensionId = async (client: PoolClient): Promise<number> => {
const result = await client.query('SELECT COALESCE(MAX(subdimension_id), 0) + 1 as next_id FROM subdimension');
return result.rows[0].next_id;
};
// Fixed column name (database has typo: subdmiension_name)
const SELECT_COLUMNS = `subdimension_id, dimension_id, subdmiension_name as subdimension_name, subdimension_code, description`;
// GET all subdimensions
router.get('/', async (req: Request, res: Response) => {
try {
const subdimensions = await query<Subdimension>(
`SELECT ${SELECT_COLUMNS} FROM subdimension ORDER BY subdimension_id`
);
res.json({
success: true,
data: subdimensions,
count: subdimensions.length
} as ApiResponse<Subdimension[]>);
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
} as ApiResponse<never>);
}
});
// GET all subdimensions with counts
router.get('/with-counts', async (req: Request, res: Response) => {
try {
const subdimensions = await query<Subdimension & { technique_count: number; dimension_name: string }>(`
SELECT s.subdimension_id, s.dimension_id, s.subdmiension_name as subdimension_name,
s.subdimension_code, s.description,
d.dimension_name, d.dimension_code,
(SELECT COUNT(*) FROM technique t WHERE t.subdimension_id = s.subdimension_id) as technique_count
FROM subdimension s
JOIN dimension d ON s.dimension_id = d.dimension_id
ORDER BY d.dimension_id, s.subdimension_id
`);
res.json({
success: true,
data: subdimensions,
count: subdimensions.length
});
} catch (error) {
internalError(res, error);
}
});
// GET subdimensions by dimension_id
router.get('/by-dimension/:dimensionId', async (req: Request, res: Response) => {
try {
const subdimensions = await query<Subdimension>(
`SELECT ${SELECT_COLUMNS},
(SELECT COUNT(*) FROM technique t WHERE t.subdimension_id = s.subdimension_id) as technique_count
FROM subdimension s
WHERE s.dimension_id = $1
ORDER BY s.subdimension_id`,
[req.params.dimensionId]
);
res.json({
success: true,
data: subdimensions,
count: subdimensions.length
} as ApiResponse<Subdimension[]>);
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
} as ApiResponse<never>);
}
});
// GET subdimension by ID
router.get('/:id', async (req: Request, res: Response) => {
try {
const subdimension = await queryOne<Subdimension>(
`SELECT ${SELECT_COLUMNS} FROM subdimension WHERE subdimension_id = $1`,
[req.params.id]
);
if (!subdimension) {
return res.status(404).json({
success: false,
error: 'Subdimensiunea nu a fost găsită'
} as ApiResponse<never>);
}
res.json({
success: true,
data: subdimension
} as ApiResponse<Subdimension>);
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
} as ApiResponse<never>);
}
});
// GET dependency check before delete
router.get('/:id/dependencies', async (req: Request, res: Response) => {
try {
const id = req.params.id;
// Check if subdimension exists
const subdimension = await queryOne<Subdimension>(
`SELECT ${SELECT_COLUMNS} FROM subdimension WHERE subdimension_id = $1`,
[id]
);
if (!subdimension) {
return res.status(404).json({
success: false,
error: 'Subdimensiunea nu a fost găsită'
});
}
// Check dependencies
const depCheck = await checkDependencies('subdimension', 'subdimension_id', id);
// Get detailed technique info if there are children
let techniques: any[] = [];
if (depCheck.hasChildren) {
techniques = await query(`
SELECT t.technique_id, t.technique_name, t.severity,
(SELECT COUNT(*) FROM technique_indicator ti WHERE ti.technique_id = t.technique_id) as indicator_count,
(SELECT COUNT(*) FROM technique_validation_rule tr WHERE tr.technique_id = t.technique_id) as rule_count
FROM technique t
WHERE t.subdimension_id = $1
ORDER BY t.technique_id
`, [id]);
}
res.json({
success: true,
data: {
subdimension,
...depCheck,
childDetails: techniques
}
});
} catch (error) {
internalError(res, error);
}
});
// POST create subdimension
router.post('/', async (req: Request, res: Response) => {
try {
const { dimension_id, subdimension_name, subdimension_code, description } = req.body;
// Validation
if (!dimension_id || !subdimension_name || !subdimension_code) {
return res.status(400).json({
success: false,
error: 'Câmpuri obligatorii: dimension_id, subdimension_name, subdimension_code'
});
}
// Verify dimension exists
const dimension = await queryOne('SELECT dimension_id FROM dimension WHERE dimension_id = $1', [dimension_id]);
if (!dimension) {
return res.status(400).json({
success: false,
error: 'Dimensiunea specificată nu există'
});
}
const result = await transaction(async (client) => {
// Create parameter entry
const parameterId = await createParameter(client);
// Get next subdimension_id
const subdimensionId = await getNextSubdimensionId(client);
// Insert subdimension (note: column name is subdmiension_name with typo)
const insertResult = await client.query(`
INSERT INTO subdimension (subdimension_id, dimension_id, subdmiension_name, subdimension_code, description,
subdimension_name_ro, subdimension_name_en, description_ro, description_en)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING subdimension_id, dimension_id, subdmiension_name as subdimension_name, subdimension_code, description,
subdimension_name_ro, subdimension_name_en, description_ro, description_en
`, [subdimensionId, dimension_id, subdimension_name, subdimension_code, description || '',
req.body.subdimension_name_ro || null, req.body.subdimension_name_en || subdimension_name,
req.body.description_ro || null, req.body.description_en || description || '']);
return insertResult.rows[0];
});
res.status(201).json({
success: true,
data: result,
message: 'Subdimensiunea a fost creată cu succes'
});
} catch (error) {
internalError(res, error);
}
});
// PUT update subdimension
router.put('/:id', async (req: Request, res: Response) => {
try {
const { dimension_id, subdimension_name, subdimension_code, description,
subdimension_name_ro, subdimension_name_en, description_ro, description_en } = req.body;
// Check if subdimension exists
const existing = await queryOne<Subdimension>(
`SELECT ${SELECT_COLUMNS} FROM subdimension WHERE subdimension_id = $1`,
[req.params.id]
);
if (!existing) {
return res.status(404).json({
success: false,
error: 'Subdimensiunea nu a fost găsită'
});
}
// If changing dimension_id, verify it exists
if (dimension_id) {
const dimension = await queryOne('SELECT dimension_id FROM dimension WHERE dimension_id = $1', [dimension_id]);
if (!dimension) {
return res.status(400).json({
success: false,
error: 'Dimensiunea specificată nu există'
});
}
}
// Note: column name is subdmiension_name with typo in DB
const subdimension = await queryOne<Subdimension>(
`UPDATE subdimension
SET dimension_id = COALESCE($1, dimension_id),
subdmiension_name = COALESCE($2, subdmiension_name),
subdimension_code = COALESCE($3, subdimension_code),
description = COALESCE($4, description),
subdimension_name_ro = COALESCE($6, subdimension_name_ro),
subdimension_name_en = COALESCE($7, subdimension_name_en),
description_ro = COALESCE($8, description_ro),
description_en = COALESCE($9, description_en)
WHERE subdimension_id = $5
RETURNING subdimension_id, dimension_id, subdmiension_name as subdimension_name, subdimension_code, description,
subdimension_name_ro, subdimension_name_en, description_ro, description_en`,
[dimension_id, subdimension_name, subdimension_code, description, req.params.id,
subdimension_name_ro, subdimension_name_en, description_ro, description_en]
);
res.json({
success: true,
data: subdimension,
message: 'Subdimensiunea a fost actualizată cu succes'
});
} catch (error) {
internalError(res, error);
}
});
// DELETE subdimension (with safety check)
router.delete('/:id', async (req: Request, res: Response) => {
try {
const id = req.params.id;
const force = req.query.force === 'true';
// Check if subdimension exists
const existing = await queryOne<Subdimension>(
`SELECT ${SELECT_COLUMNS} FROM subdimension WHERE subdimension_id = $1`,
[id]
);
if (!existing) {
return res.status(404).json({
success: false,
error: 'Subdimensiunea nu a fost găsită'
});
}
// Use safe delete
const deleteResult = await safeDelete('subdimension', 'subdimension_id', id, force);
if (!deleteResult.success) {
return res.status(409).json({
success: false,
error: deleteResult.message,
canDelete: false,
dependencies: deleteResult.dependencyDetails?.dependencies || [],
hint: 'Ștergeți mai întâi toate tehnicile asociate acestei subdimensiuni'
});
}
res.json({
success: true,
message: 'Subdimensiunea a fost ștearsă cu succes',
deleted: true
});
} catch (error: any) {
if (error.code === '23503') {
return res.status(409).json({
success: false,
error: 'Nu se poate șterge: există tehnici asociate',
canDelete: false
});
}
internalError(res, error);
}
});
export default router;

View file

@ -0,0 +1,385 @@
/**
* Subscriptions Routes
*
* API for subscription and usage information
* Endpoints:
* - GET /api/subscriptions/usage - Get user's usage stats (credits, plan info)
* - GET /api/subscriptions/plans - List available plans
*/
import { Router, Request, Response } from 'express';
import { log } from '../config/logger';
import { internalError } from '../config/error-response';
import pool from '../config/database';
import { getStripe, isStripeEnabled } from '../config/stripe';
const router = Router();
// ============================================================================
// HELPERS
// ============================================================================
interface JWTPayload {
sub: string;
email: string;
preferred_username?: string;
given_name?: string;
family_name?: string;
}
function extractJWTPayload(authHeader: string | undefined): JWTPayload | null {
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return null;
}
const token = authHeader.substring(7);
const parts = token.split('.');
if (parts.length !== 3) {
return null;
}
try {
const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString('utf8'));
return payload as JWTPayload;
} catch (e) {
return null;
}
}
// ============================================================================
// ROUTES
// ============================================================================
/**
* GET /api/subscriptions/usage
* Returns user's current usage stats including credits and plan info
*/
router.get('/usage', async (req: Request, res: Response) => {
try {
const jwtPayload = extractJWTPayload(req.headers.authorization);
if (!jwtPayload || !jwtPayload.sub) {
return res.status(401).json({
success: false,
error: 'Missing or invalid Authorization header'
});
}
const keycloakId = jwtPayload.sub;
const result = await pool.query(`
SELECT
iu.internet_user_id,
iu.credits_remained,
iu.credits_spent,
sp.subscription_plan_id,
sp.plan_name,
sp.plan_type,
sp.credits_per_cycle,
sp.max_images,
sp.max_video_minutes,
sp.storage_limit_gb,
sp.price_amount,
s.is_active,
s.activation_date,
s.deactivation_date
FROM bos_sysadmin.user_credential uc
JOIN bos_sysadmin.internet_user iu ON uc.internet_user_id = iu.internet_user_id
LEFT JOIN bos_sysadmin.subscription s ON iu.internet_user_id = s.internet_user_id AND s.is_active = true
LEFT JOIN bos_sysadmin.subscription_plan sp ON s.subscription_plan_id = sp.subscription_plan_id
WHERE uc.keycloak_id = $1
`, [keycloakId]);
if (result.rows.length === 0) {
return res.status(404).json({
success: false,
error: 'User not found'
});
}
const user = result.rows[0];
// Calculate usage percentage
const creditsUsed = user.credits_spent || 0;
const creditsRemaining = user.credits_remained || 0;
const creditsTotal = creditsRemaining + creditsUsed;
const usagePercent = creditsTotal > 0 ? Math.round((creditsUsed / creditsTotal) * 100) : 0;
res.json({
success: true,
data: {
// Credits info
credits: {
remaining: creditsRemaining,
spent: creditsUsed,
total: creditsTotal,
usagePercent: usagePercent
},
// Plan info
plan: {
id: user.subscription_plan_id || 1,
name: user.plan_name || 'Free',
type: user.plan_type ?? 1,
creditsPerCycle: user.credits_per_cycle || 5,
maxImages: user.max_images || 20,
maxVideoMinutes: user.max_video_minutes || 0,
storageLimitGb: user.storage_limit_gb || 1,
priceAmount: user.price_amount || 0
},
// Subscription status
subscription: {
isActive: user.is_active ?? true,
activationDate: user.activation_date,
deactivationDate: user.deactivation_date
}
}
});
} catch (error: any) {
log.error('Error in /subscriptions/usage:', error);
internalError(res, error);
}
});
/**
* GET /api/subscriptions/plans
* Returns recurring subscription plans only (Free + 5 paid tiers).
* One-time micro-purchases (plan_type=7, e.g. "Techniques - Text") are excluded
* those are exposed via /api/subscriptions/one-time-products if needed.
*/
router.get('/plans', async (req: Request, res: Response) => {
try {
const result = await pool.query(`
SELECT
subscription_plan_id as id,
plan_name as name,
plan_type as type,
billing_period as "billingPeriod",
price_amount as price,
credits_per_cycle as "creditsPerCycle",
max_images as "maxImages",
max_video_minutes as "maxVideoMinutes",
storage_limit_gb as "storageLimitGb",
subscription_status as status,
stripe_price_id as "stripePriceIdMonthly",
stripe_price_id_yearly as "stripePriceIdYearly"
FROM bos_sysadmin.subscription_plan
WHERE subscription_status = 1
AND COALESCE(is_one_time, false) = false
AND plan_type BETWEEN 1 AND 6
ORDER BY plan_type
`);
const plans = result.rows.map(plan => ({
...plan,
priceUsd: plan.price / 100,
priceMonthly: plan.price / 100,
priceYearly: plan.price === 0 ? 0 : (plan.price * 10) / 100, // yearly = monthly × 10 (17% off)
}));
res.json({
success: true,
data: plans
});
} catch (error: any) {
log.error('Error in /subscriptions/plans:', error);
internalError(res, error);
}
});
/**
* GET /api/subscriptions/one-time-products
* Returns pay-per-use micro-purchases (plan_type=7).
* Separate from recurring plans to avoid mixing in upgrade UI.
*/
router.get('/one-time-products', async (req: Request, res: Response) => {
try {
const result = await pool.query(`
SELECT
subscription_plan_id as id,
plan_name as name,
price_amount as price,
credits_per_cycle as credits,
component_name as component
FROM bos_sysadmin.subscription_plan
WHERE subscription_status = 1
AND COALESCE(is_one_time, false) = true
ORDER BY price_amount, plan_name
`);
const products = result.rows.map(p => ({
...p,
priceUsd: p.price / 100,
}));
res.json({ success: true, data: products });
} catch (error: any) {
log.error('Error in /subscriptions/one-time-products:', error);
internalError(res, error);
}
});
/**
* POST /api/subscriptions/upgrade
* Body: { plan_id: number, interval?: 'month' | 'year' }
* Returns: { url: string } redirect URL to Stripe Checkout
*
* Flow:
* 1. Validate JWT, find user.
* 2. Ensure Stripe customer exists (create if missing, with keycloak_id metadata).
* 3. Resolve plan_id stripe_price_id (monthly or yearly).
* 4. Create Stripe Checkout Session.
* 5. Return URL frontend redirects user there.
* 6. On success/cancel, Stripe redirects user back to our app; webhook handles state sync.
*/
router.post('/upgrade', async (req: Request, res: Response) => {
try {
if (!isStripeEnabled()) {
return res.status(503).json({ success: false, error: 'Stripe not configured' });
}
const jwtPayload = extractJWTPayload(req.headers.authorization);
if (!jwtPayload || !jwtPayload.sub) {
return res.status(401).json({ success: false, error: 'Missing or invalid Authorization header' });
}
const { plan_id, interval } = req.body as { plan_id?: number; interval?: 'month' | 'year' };
if (!plan_id || typeof plan_id !== 'number') {
return res.status(400).json({ success: false, error: 'plan_id (number) is required in body' });
}
const billingInterval = interval === 'year' ? 'year' : 'month';
// Resolve user + plan
const userResult = await pool.query(
`SELECT iu.internet_user_id, iu.stripe_customer_id, uc.email
FROM bos_sysadmin.user_credential uc
JOIN bos_sysadmin.internet_user iu ON iu.internet_user_id = uc.internet_user_id
WHERE uc.keycloak_id = $1`,
[jwtPayload.sub]
);
if (userResult.rows.length === 0) {
return res.status(404).json({ success: false, error: 'User not found' });
}
const user = userResult.rows[0];
const planResult = await pool.query(
`SELECT subscription_plan_id, plan_name, stripe_product_id, stripe_price_id, stripe_price_id_yearly
FROM bos_sysadmin.subscription_plan
WHERE subscription_plan_id = $1`,
[plan_id]
);
if (planResult.rows.length === 0) {
return res.status(404).json({ success: false, error: `Plan ${plan_id} not found` });
}
const plan = planResult.rows[0];
const priceId = billingInterval === 'year' ? plan.stripe_price_id_yearly : plan.stripe_price_id;
if (!priceId) {
return res.status(400).json({
success: false,
error: `Plan "${plan.plan_name}" has no ${billingInterval}ly Stripe price configured`
});
}
const stripe = getStripe();
// Ensure customer exists
let customerId = user.stripe_customer_id;
if (!customerId) {
const fullName = [jwtPayload.given_name, jwtPayload.family_name].filter(Boolean).join(' ').trim();
const customer = await stripe.customers.create({
email: jwtPayload.email || user.email,
name: fullName || undefined,
metadata: {
keycloak_id: jwtPayload.sub,
internet_user_id: String(user.internet_user_id),
project: 'didi',
},
});
customerId = customer.id;
// Persist immediately (webhook customer.created may race; this guarantees our DB has it)
await pool.query(
`UPDATE bos_sysadmin.internet_user SET stripe_customer_id = $1 WHERE internet_user_id = $2`,
[customerId, user.internet_user_id]
);
log.info(`[subscriptions/upgrade] created Stripe customer ${customerId} for user ${user.internet_user_id}`);
}
// Build success/cancel URLs (origin from request — works for prod + dev)
const origin = req.headers.origin || `https://${req.headers.host}`;
const successUrl = `${origin}/dashboard?upgrade=success&session_id={CHECKOUT_SESSION_ID}`;
const cancelUrl = `${origin}/dashboard?upgrade=cancelled`;
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer: customerId,
line_items: [{ price: priceId, quantity: 1 }],
success_url: successUrl,
cancel_url: cancelUrl,
allow_promotion_codes: true,
metadata: {
plan_id: String(plan_id),
keycloak_id: jwtPayload.sub,
interval: billingInterval,
},
subscription_data: {
metadata: {
keycloak_id: jwtPayload.sub,
plan_id: String(plan_id),
project: 'didi',
},
},
});
log.info(`[subscriptions/upgrade] checkout session ${session.id} for user ${user.internet_user_id}${plan.plan_name} (${billingInterval})`);
res.json({ success: true, data: { url: session.url, sessionId: session.id } });
} catch (error: any) {
log.error('Error in /subscriptions/upgrade:', error);
internalError(res, error);
}
});
/**
* POST /api/subscriptions/portal
* Returns: { url: string } Stripe Customer Portal URL for self-service
* (cancel, update card, view invoices). Activate Portal in Stripe Dashboard first.
*/
router.post('/portal', async (req: Request, res: Response) => {
try {
if (!isStripeEnabled()) {
return res.status(503).json({ success: false, error: 'Stripe not configured' });
}
const jwtPayload = extractJWTPayload(req.headers.authorization);
if (!jwtPayload || !jwtPayload.sub) {
return res.status(401).json({ success: false, error: 'Missing or invalid Authorization header' });
}
const r = await pool.query(
`SELECT iu.stripe_customer_id
FROM bos_sysadmin.user_credential uc
JOIN bos_sysadmin.internet_user iu ON iu.internet_user_id = uc.internet_user_id
WHERE uc.keycloak_id = $1`,
[jwtPayload.sub]
);
const customerId = r.rows[0]?.stripe_customer_id;
if (!customerId) {
return res.status(400).json({ success: false, error: 'No Stripe customer linked to this user' });
}
const origin = req.headers.origin || `https://${req.headers.host}`;
const session = await getStripe().billingPortal.sessions.create({
customer: customerId,
return_url: `${origin}/dashboard`,
});
res.json({ success: true, data: { url: session.url } });
} catch (error: any) {
log.error('Error in /subscriptions/portal:', error);
internalError(res, error);
}
});
export default router;

View file

@ -0,0 +1,382 @@
/**
* Sync Analysis Route (LEGACY)
*
* Syncs analysis results from Redis PostgreSQL for the OLD code path
* (routes.ts saveAndSyncComponent). The NEW pipeline path uses PersistService
* which writes directly to PG, making this sync redundant for new analyses.
*
* History endpoints REMOVED (Task 8.2) - use /api/history (history.ts) instead.
*
* Endpoints:
* - POST /api/sync-analysis/:sessionId - Sync a session from Redis to PG
* - POST /api/sync-analysis/batch - Sync multiple sessions
* - GET /api/sync-analysis/pending - List completed sessions in Redis
* - GET /api/sync-analysis/stats - Analysis statistics from PG
*/
import { Router, Request, Response } from 'express';
import type Redis from 'ioredis';
import { createRedisConnection } from '../config/redis';
import * as crypto from 'crypto';
import { log } from '../config/logger';
import { internalError } from '../config/error-response';
import pool from '../config/database';
const router = Router();
const getRedisClient = (): Redis => {
return createRedisConnection({ label: 'sync-analysis' });
};
const REDIS_KEYS = {
sessionStatus: (sid: string) => `didi:pipeline:${sid}:status`,
sessionResult: (sid: string, comp: string) => `didi:pipeline:${sid}:${comp}`,
sessionVerdict: (sid: string) => `didi:pipeline:${sid}:verdict`,
historyEntry: (sid: string) => `didi:pipeline:history:entry:${sid}`,
historyUser: (uid: string) => `didi:pipeline:history:user:${uid}`,
};
// ============================================================================
// TYPES
// ============================================================================
interface PipelineStatus {
session_id: string;
status: 'running' | 'completed' | 'failed';
started_at: number;
completed_at?: number;
components: Record<string, { status: string; result?: any }>;
}
interface SyncResult {
session_id: string;
success: boolean;
tables_inserted: string[];
error?: string;
redis_keys_deleted?: number;
}
// ============================================================================
// HELPERS
// ============================================================================
function inputHash(text?: string, url?: string): string {
return crypto.createHash('sha256').update(text || url || '').digest('hex');
}
function componentsWithStatus(status: PipelineStatus, s: string): string[] {
return Object.entries(status.components).filter(([_, v]) => v.status === s).map(([k]) => k);
}
// ============================================================================
// SYNC SESSION: Redis → PostgreSQL
// All scores are already 0-100 (executors produce correct scale since Task 2.x).
// ============================================================================
async function syncSession(redis: Redis, sessionId: string, input?: any): Promise<SyncResult> {
const client = await pool.connect();
const tables: string[] = [];
try {
const [statusJson, techJson, aiJson, claimsJson, domainJson, verdictJson, historyJson] = await Promise.all([
redis.get(REDIS_KEYS.sessionStatus(sessionId)),
redis.get(REDIS_KEYS.sessionResult(sessionId, 'techniques')),
redis.get(REDIS_KEYS.sessionResult(sessionId, 'ai_tampered')),
redis.get(REDIS_KEYS.sessionResult(sessionId, 'claims')),
redis.get(REDIS_KEYS.sessionResult(sessionId, 'domain')),
redis.get(REDIS_KEYS.sessionVerdict(sessionId)),
redis.get(REDIS_KEYS.historyEntry(sessionId)),
]);
if (!statusJson) {
return { session_id: sessionId, success: false, tables_inserted: [], error: 'Session not found in Redis' };
}
const status: PipelineStatus = JSON.parse(statusJson);
const techniques = techJson ? JSON.parse(techJson) : null;
const aiTampered = aiJson ? JSON.parse(aiJson) : null;
const claims = claimsJson ? JSON.parse(claimsJson) : null;
const domain = domainJson ? JSON.parse(domainJson) : null;
const verdict = verdictJson ? JSON.parse(verdictJson) : null;
const history = historyJson ? JSON.parse(historyJson) : null;
const inp = input || history || {};
await client.query('BEGIN');
// analysis_session
await client.query(`
INSERT INTO bos_analysis.analysis_session (
session_id, user_id, user_email, input_type, input_text, input_url, input_media_url, input_hash,
status, components_run, components_skipped,
risk_score, risk_category, risk_level, confidence, confidence_level,
started_at, completed_at, total_duration_ms,
scenario_applied, topic_applied, source_app, api_version
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23)
ON CONFLICT (session_id) DO UPDATE SET
status = EXCLUDED.status, risk_score = EXCLUDED.risk_score,
risk_category = EXCLUDED.risk_category, completed_at = EXCLUDED.completed_at,
total_duration_ms = EXCLUDED.total_duration_ms
`, [
sessionId, inp.user_id || null, inp.user_email || null,
inp.input_type || history?.input_type || 'text',
inp.input_text || inp.input_preview || null,
inp.input_url || inp.url || null, inp.media_url || null,
inputHash(inp.input_text, inp.input_url),
status.status, componentsWithStatus(status, 'completed'), componentsWithStatus(status, 'skipped'),
verdict?.risk_score ?? history?.risk_score ?? null,
verdict?.risk_category ?? history?.risk_category ?? null, verdict?.risk_level ?? null,
verdict?.confidence ?? history?.confidence ?? null, verdict?.confidence_level ?? null,
new Date(status.started_at),
status.completed_at ? new Date(status.completed_at) : null,
status.completed_at ? status.completed_at - status.started_at : null,
inp.options?.scenario || null, inp.options?.topic || null, inp.source_app || 'web', 'v3',
]);
tables.push('analysis_session');
// analysis_techniques
if (techniques?.result || techniques?.techniques) {
const t = techniques.result || techniques;
await client.query(`
INSERT INTO bos_analysis.analysis_techniques (
session_id, manipulation_score, total_severity, dimensions_affected, techniques_count,
techniques_detected, coupling_context, llm_screening, llm_deep,
screening_duration_ms, deep_analysis_duration_ms, total_duration_ms,
fallbacks_screening, fallbacks_deep
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) ON CONFLICT DO NOTHING
`, [
sessionId, Math.round(t.manipulation_score || 0), t.total_severity || 0,
t.dimensions_affected || [], t.techniques?.length || 0,
JSON.stringify(t.techniques || []), JSON.stringify(t.coupling_context || {}),
t.metadata?.llm_screening || null, t.metadata?.llm_deep || null,
t.metadata?.screening_duration_ms || null, t.metadata?.deep_analysis_duration_ms || null,
t.metadata?.total_duration_ms || null,
t.metadata?.fallbacks_used?.screening || 0, t.metadata?.fallbacks_used?.deep || 0,
]);
tables.push('analysis_techniques');
}
// analysis_ai_tampered
if (aiTampered?.result || aiTampered?.ai_probability !== undefined) {
const a = aiTampered.result || aiTampered;
await client.query(`
INSERT INTO bos_analysis.analysis_ai_tampered (
session_id, ai_probability, verdict, risk_score, categories_affected, indicators_count,
disclosure_detected, disclosure_explicit, disclosure_text,
indicators_detected, coupling_context, llm_screening, llm_deep,
screening_duration_ms, deep_analysis_duration_ms, total_duration_ms,
fallbacks_screening, fallbacks_deep, content_type, image_analysis
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20) ON CONFLICT DO NOTHING
`, [
sessionId, a.ai_probability || 0, a.verdict || 'LIKELY_HUMAN', Math.round(a.risk_score || 0),
a.categories_affected || [], a.detected_indicators?.length || 0,
a.disclosure?.disclosed || false, a.disclosure?.type === 'explicit',
a.disclosure?.tool_mentioned || null,
JSON.stringify(a.detected_indicators || []), JSON.stringify(a.coupling_context || {}),
a.metadata?.llm_screening || null, a.metadata?.llm_deep || null,
a.metadata?.screening_duration_ms || null, a.metadata?.deep_analysis_duration_ms || null,
a.metadata?.total_duration_ms || null,
a.metadata?.fallbacks_used?.screening || 0, a.metadata?.fallbacks_used?.deep || 0,
a.metadata?.content_type || 'text',
a.image_analysis ? JSON.stringify(a.image_analysis) : null,
]);
tables.push('analysis_ai_tampered');
}
// analysis_claims
if (claims?.result || claims?.claims) {
const c = claims.result || claims;
const credScore = c.credibility_score != null && c.credibility_score >= 0 ? Math.round(c.credibility_score) : null;
await client.query(`
INSERT INTO bos_analysis.analysis_claims (
session_id, total_claims, verified_true, verified_false, unverified, opinions,
credibility_score, interpretation, claims_by_status, claims_by_type, claims_verified,
llm_extraction, llm_verification, extraction_duration_ms, verification_duration_ms,
total_duration_ms, web_searches_made
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17) ON CONFLICT DO NOTHING
`, [
sessionId, c.total_claims || 0, c.verified_true || 0, c.verified_false || 0,
c.unverified || 0, c.opinions || 0, credScore, c.interpretation || null,
JSON.stringify(c.claims_by_status || {}), JSON.stringify(c.claims_by_type || {}),
JSON.stringify(c.claims || []),
c.metadata?.llm_extraction || null, c.metadata?.llm_verification || null,
c.metadata?.extraction_duration_ms || null, c.metadata?.verification_duration_ms || null,
c.metadata?.total_duration_ms || null, c.metadata?.web_searches_made || 0,
]);
tables.push('analysis_claims');
}
// analysis_domain
if (domain?.result || domain?.domain) {
const d = domain.result || domain;
await client.query(`
INSERT INTO bos_analysis.analysis_domain (
session_id, domain, verdict, trust_score, risk_level,
age_days, age_category, domain_created_at, is_blacklisted, reputation_score,
has_ssl, ssl_valid, ssl_issuer, registrar, organization, country,
red_flags, warnings, duration_ms
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19) ON CONFLICT DO NOTHING
`, [
sessionId, d.domain || 'unknown', d.verdict || 'NEUTRAL', d.trust_score || 50,
d.risk_level || null,
d.age?.days || null, d.age?.category || null,
d.age?.created_at ? new Date(d.age.created_at) : null,
d.blacklist?.is_blacklisted || false, d.blacklist?.reputation_score || null,
d.ssl?.has_ssl || null, d.ssl?.is_valid || null, d.ssl?.issuer || null,
d.ownership?.registrar || null, d.ownership?.organization || null, d.ownership?.country || null,
d.red_flags || [], d.warnings || [], d.metadata?.duration_ms || null,
]);
tables.push('analysis_domain');
}
// analysis_verdict
if (verdict) {
await client.query(`
INSERT INTO bos_analysis.analysis_verdict (
session_id, risk_score, risk_category, risk_category_color, risk_level, risk_level_color,
severity, recommended_action, confidence, confidence_level,
score_manipulation, score_claims, score_ai, score_source, score_context,
applied_weights, override_applied, override_type, override_reason, override_adjustment,
context_summary, components_used, weights_source, duration_ms
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24)
ON CONFLICT DO NOTHING
`, [
sessionId, verdict.risk_score || 0, verdict.risk_category || 'UNKNOWN',
verdict.risk_category_color || null, verdict.risk_level || 'LOW',
verdict.risk_level_color || null, verdict.severity || null,
verdict.recommended_action || null, verdict.confidence || 0,
verdict.confidence_level || 'LOW',
verdict.component_scores?.manipulation >= 0 ? verdict.component_scores.manipulation : null,
verdict.component_scores?.claims >= 0 ? verdict.component_scores.claims : null,
verdict.component_scores?.ai >= 0 ? verdict.component_scores.ai : null,
verdict.component_scores?.source >= 0 ? verdict.component_scores.source : null,
verdict.component_scores?.context >= 0 ? verdict.component_scores.context : null,
JSON.stringify(verdict.applied_weights || {}),
verdict.override?.applied || false, verdict.override?.type || null,
verdict.override?.reason || null, verdict.override?.adjustment || null,
JSON.stringify(verdict.context_summary || {}),
verdict.metadata?.components_used || [], verdict.metadata?.weights_source || null,
verdict.metadata?.duration_ms || null,
]);
tables.push('analysis_verdict');
}
await client.query('COMMIT');
// Clean up Redis after successful PG insert
const deleted = await redis.del(
REDIS_KEYS.sessionStatus(sessionId),
REDIS_KEYS.sessionResult(sessionId, 'techniques'),
REDIS_KEYS.sessionResult(sessionId, 'ai_tampered'),
REDIS_KEYS.sessionResult(sessionId, 'claims'),
REDIS_KEYS.sessionResult(sessionId, 'domain'),
REDIS_KEYS.sessionVerdict(sessionId),
REDIS_KEYS.historyEntry(sessionId),
);
if (inp.user_id) await redis.zrem(REDIS_KEYS.historyUser(inp.user_id), sessionId);
return { session_id: sessionId, success: true, tables_inserted: tables, redis_keys_deleted: deleted };
} catch (error) {
await client.query('ROLLBACK');
log.error(`[Sync] Error syncing session ${sessionId}:`, error);
return { session_id: sessionId, success: false, tables_inserted: [], error: (error as Error).message };
} finally {
client.release();
}
}
// ============================================================================
// ROUTES
// ============================================================================
/** POST /api/sync-analysis/batch - Sync multiple sessions
* Declarat înainte de '/:sessionId', altfel 'batch' e capturat ca sessionId */
router.post('/batch', async (req: Request, res: Response) => {
const { session_ids } = req.body;
if (!session_ids?.length) return res.status(400).json({ success: false, error: 'session_ids array is required' });
const redis = getRedisClient();
try {
const results: SyncResult[] = [];
for (const sid of session_ids) results.push(await syncSession(redis, sid));
redis.quit();
const ok = results.filter(r => r.success).length;
res.json({ success: true, message: `Batch: ${ok}/${session_ids.length} synced`, data: { total: session_ids.length, successful: ok, failed: session_ids.length - ok, results } });
} catch (error) {
redis.quit();
internalError(res, error);
}
});
/** POST /api/sync-analysis/:sessionId - Sync single session from Redis to PG */
router.post('/:sessionId', async (req: Request, res: Response) => {
const { sessionId } = req.params;
if (!sessionId) return res.status(400).json({ success: false, error: 'sessionId is required' });
const redis = getRedisClient();
try {
const result = await syncSession(redis, sessionId, req.body);
redis.quit();
result.success
? res.json({ success: true, message: `Session ${sessionId} synced`, data: result })
: res.status(400).json({ success: false, error: result.error, session_id: sessionId });
} catch (error) {
redis.quit();
internalError(res, error);
}
});
/** GET /api/sync-analysis/pending - List completed sessions still in Redis */
router.get('/pending', async (_req: Request, res: Response) => {
const redis = getRedisClient();
try {
const keys: string[] = [];
let cursor = '0';
do {
const [next, found] = await redis.scan(cursor, 'MATCH', 'didi:pipeline:*:status', 'COUNT', 100);
cursor = next;
keys.push(...found);
} while (cursor !== '0');
const pending: { session_id: string; status: string; completed_at: string }[] = [];
for (const key of keys) {
const data = await redis.get(key);
if (data) {
const s = JSON.parse(data);
if (s.status === 'completed') {
pending.push({ session_id: key.replace('didi:pipeline:', '').replace(':status', ''), status: s.status, completed_at: s.completed_at ? new Date(s.completed_at).toISOString() : 'unknown' });
}
}
}
redis.quit();
res.json({ success: true, data: { count: pending.length, sessions: pending } });
} catch (error) {
redis.quit();
internalError(res, error);
}
});
/** GET /api/sync-analysis/stats - Analysis statistics */
router.get('/stats', async (_req: Request, res: Response) => {
const client = await pool.connect();
try {
const [total, today, byStatus] = await Promise.all([
client.query('SELECT COUNT(*) as total FROM bos_analysis.analysis_session'),
client.query('SELECT COUNT(*) as today FROM bos_analysis.analysis_session WHERE created_at >= CURRENT_DATE'),
client.query('SELECT status, COUNT(*) as count FROM bos_analysis.analysis_session GROUP BY status'),
]);
res.json({
success: true,
data: {
total_sessions: parseInt(total.rows[0].total),
sessions_today: parseInt(today.rows[0].today),
by_status: byStatus.rows.reduce((acc: Record<string, number>, r) => { acc[r.status] = parseInt(r.count); return acc; }, {}),
},
});
} catch (error) {
internalError(res, error);
} finally {
client.release();
}
});
export default router;

View file

@ -0,0 +1,6 @@
/**
* Re-export of the new sync-redis barrel. Kept at this path so server.ts
* (which imports `./routes/sync-redis`) continues to work unchanged after
* the 898-LOC 7-file split. See ./sync-redis/index.ts for the routing map.
*/
export { default } from './sync-redis/index';

View file

@ -0,0 +1,83 @@
/**
* Shared Redis client + framework key prefix + parameter type interfaces
* used across the sync-redis sub-routers.
*
* Each call to `getRedisClient()` opens a NEW connection this matches
* the original behavior (open per request, close at end of route). Callers
* must `quit()` after use.
*/
import type Redis from 'ioredis';
import { createRedisConnection } from '../../config/redis';
export const KEY_PREFIX = 'didi:framework';
export const getRedisClient = (): Redis => {
return createRedisConnection({
label: 'sync-redis',
overrides: { keyPrefix: '' },
});
};
// ============================================================================
// FRAMEWORK PARAMETER INTERFACES (used by fetch-data.ts)
// ============================================================================
export interface Dimension {
dimension_id: number;
dimension_code: string;
dimension_name: string;
description: string;
weight: number;
dimension_name_ro?: string;
dimension_name_en?: string;
description_ro?: string;
description_en?: string;
}
export interface Subdimension {
subdimension_id: number;
dimension_id: number;
subdimension_code: string;
subdimension_name: string;
description: string;
subdimension_name_ro?: string;
subdimension_name_en?: string;
description_ro?: string;
description_en?: string;
}
export interface Technique {
technique_id: number;
subdimension_id: number;
technique_key: number;
technique_name: string;
severity: number;
confidence: number;
detectability: number;
technique_name_ro?: string;
technique_name_en?: string;
}
export interface Indicator {
technique_id: number;
indicator_id: number;
indicator_name: string;
description: string;
max_intensity: number;
indicator_name_ro?: string;
indicator_name_en?: string;
description_ro?: string;
description_en?: string;
}
export interface ValidationRule {
technique_valid_rule_id: number;
technique_id: number;
rule_name: string;
rule_value: string;
description: string;
rule_name_ro?: string;
rule_name_en?: string;
description_ro?: string;
description_en?: string;
}

View file

@ -0,0 +1,53 @@
/**
* GET /api/sync-redis/data/:category
*
* Read a single framework key from Redis for debugging returns parsed
* JSON. Categories: manifest | techniques | sources | claims | verdicts |
* weights | providers.
*/
import { Router, Request, Response } from 'express';
import { internalError } from '../../config/error-response';
import { getRedisClient, KEY_PREFIX } from './_shared';
const router = Router();
const VALID_CATEGORIES = ['manifest', 'techniques', 'sources', 'claims', 'verdicts', 'weights', 'providers'];
router.get('/data/:category', async (req: Request, res: Response) => {
const redis = getRedisClient();
const { category } = req.params;
if (!VALID_CATEGORIES.includes(category)) {
await redis.quit();
return res.status(400).json({
success: false,
error: `Invalid category. Valid options: ${VALID_CATEGORIES.join(', ')}`,
});
}
try {
const data = await redis.get(`${KEY_PREFIX}:${category}`);
await redis.quit();
if (!data) {
return res.status(404).json({
success: false,
error: `No data found for category: ${category}`,
});
}
res.json({
success: true,
data: JSON.parse(data),
});
} catch (error) {
try {
await redis.quit();
} catch {
// ignore
}
internalError(res, error, 'sync_redis_data');
}
});
export default router;

View file

@ -0,0 +1,123 @@
/**
* Component config fetchers pull stage assignments / prompts / configs /
* input profiles from PG, return shapes that the agent reads from Redis at
* `didi:config:{component}:v1:*`.
*
* Each fetcher returns an empty object / null when its table doesn't exist
* (graceful degradation for fresh deploys).
*/
import { query } from '../../config/database';
import { log } from '../../config/logger';
/**
* Fetches stage assignments grouped by component stage tier models.
* Returns: { [component]: { [stage]: { [tier]: { stage, description, models[] } } } }
* Tiers: 'free' | 'premium'
*/
export async function fetchStageAssignments(): Promise<Record<string, Record<string, Record<string, any>>>> {
try {
const rows = await query(`
SELECT csa.component_code, csa.stage_code, csa.stage_name, csa.fallback_order, csa.tier,
p.provider_code, p.base_url, p.auth_type,
m.model_code, m.model_name, m.context_window, m.max_output_tokens,
csa.temperature, csa.max_tokens, csa.timeout_ms, csa.description as role
FROM bos_parammgmt.component_stage_assignment csa
JOIN bos_parammgmt.llm_provider p ON csa.provider_id = p.provider_id
JOIN bos_parammgmt.llm_model m ON csa.model_id = m.model_id
WHERE csa.is_enabled = true
ORDER BY csa.component_code, csa.stage_code, csa.tier, csa.fallback_order
`);
const result: Record<string, Record<string, Record<string, any>>> = {};
for (const r of rows) {
const tier = r.tier || 'free';
if (!result[r.component_code]) result[r.component_code] = {};
if (!result[r.component_code][r.stage_code]) result[r.component_code][r.stage_code] = {};
if (!result[r.component_code][r.stage_code][tier]) {
result[r.component_code][r.stage_code][tier] = {
stage: r.stage_code,
description: r.stage_name,
models: [],
};
}
result[r.component_code][r.stage_code][tier].models.push({
order: r.fallback_order,
role: r.role || (r.fallback_order === 1 ? 'primary' : `fallback_${r.fallback_order - 1}`),
model_key: `${r.provider_code}:${r.model_code}`,
provider: r.provider_code,
provider_config: { base_url: r.base_url, auth_type: r.auth_type },
model_code: r.model_code,
model_name: r.model_name,
context_window: r.context_window,
max_output_tokens: r.max_output_tokens,
temperature: parseFloat(r.temperature),
max_tokens: r.max_tokens,
timeout_ms: r.timeout_ms,
});
}
return result;
} catch (error) {
log.info('[sync-redis] component_stage_assignment table not found, skipping...');
return {};
}
}
export async function fetchPrompts(): Promise<Record<string, Record<string, any>>> {
try {
const rows = await query('SELECT component_code, stage_code, system_prompt, user_template, system_prompt_ro, user_template_ro FROM bos_parammgmt.component_prompt ORDER BY component_code, stage_code');
const result: Record<string, Record<string, any>> = {};
for (const r of rows) {
if (!result[r.component_code]) result[r.component_code] = {};
result[r.component_code][r.stage_code] = {
system: r.system_prompt,
user_template: r.user_template,
// Bilingual: RO variants (null if not yet translated)
system_ro: r.system_prompt_ro || null,
user_template_ro: r.user_template_ro || null,
};
}
return result;
} catch (error) {
log.info('[sync-redis] component_prompt table not found, skipping...');
return {};
}
}
export async function fetchComponentConfigs(): Promise<Record<string, Record<string, any>>> {
try {
const rows = await query('SELECT component_code, config_key, config_value FROM bos_parammgmt.component_config ORDER BY component_code, config_key');
const result: Record<string, Record<string, any>> = {};
for (const r of rows) {
if (!result[r.component_code]) result[r.component_code] = {};
result[r.component_code][r.config_key] = r.config_value;
}
return result;
} catch (error) {
log.info('[sync-redis] component_config table not found, skipping...');
return {};
}
}
export async function fetchInputProfiles(): Promise<any[] | null> {
try {
const profiles = await query(`
SELECT p.*,
(SELECT json_agg(json_build_object(
'override_code', o.override_code,
'enabled', o.enabled,
'threshold', o.threshold,
'bonus_per_unit', o.bonus_per_unit,
'bonus_fixed', o.bonus_fixed,
'max_bonus', o.max_bonus
) ORDER BY o.override_code)
FROM profile_override_config o WHERE o.profile_code = p.profile_code
) as overrides
FROM input_type_profile p
WHERE p.is_active = true
ORDER BY p.profile_id
`);
return profiles;
} catch (error) {
log.info('[sync-redis] input_type_profile table not found, skipping...');
return null;
}
}

View file

@ -0,0 +1,260 @@
/**
* Framework data fetchers pull each PG table into the shape that ends up in
* Redis under `didi:framework:{techniques,sources,claims,verdicts,weights,providers}`.
*
* Each function returns a JSON-ready object (or null if its tables don't exist
* yet gracefully skipped during sync so a fresh deploy doesn't crash).
*/
import { query } from '../../config/database';
import { log } from '../../config/logger';
import type { Dimension, Indicator, Subdimension, Technique, ValidationRule } from './_shared';
// Fetch techniques hierarchy (denormalized)
export async function fetchTechniquesHierarchy() {
// Get all data in parallel
const [dimensions, subdimensions, techniques, indicators, rules] = await Promise.all([
query<Dimension>('SELECT * FROM dimension ORDER BY dimension_id'),
query<Subdimension>(`SELECT subdimension_id, dimension_id, subdimension_code,
subdmiension_name as subdimension_name, description,
subdimension_name_ro, subdimension_name_en, description_ro, description_en
FROM subdimension ORDER BY subdimension_id`),
query<Technique>('SELECT * FROM technique ORDER BY technique_id'),
query<Indicator>('SELECT * FROM technique_indicator ORDER BY technique_id, indicator_id'),
query<ValidationRule>('SELECT * FROM technique_validation_rule ORDER BY technique_id'),
]);
// Build hierarchy
const techniquesMap = new Map<number, any>();
techniques.forEach(t => {
techniquesMap.set(t.technique_id, {
...t,
indicators: [],
validation_rules: [],
});
});
// Add indicators to techniques - EXACT DB structure
indicators.forEach(i => {
const technique = techniquesMap.get(i.technique_id);
if (technique) {
technique.indicators.push({ ...i });
}
});
// Add validation rules to techniques - EXACT DB structure
rules.forEach(r => {
const technique = techniquesMap.get(r.technique_id);
if (technique) {
technique.validation_rules.push({ ...r });
}
});
// Build subdimensions with techniques - EXACT DB structure
const subdimensionsMap = new Map<number, any>();
subdimensions.forEach(sd => {
subdimensionsMap.set(sd.subdimension_id, {
...sd,
techniques: [],
});
});
// Add techniques to subdimensions - EXACT DB structure
techniquesMap.forEach(t => {
const subdimension = subdimensionsMap.get(t.subdimension_id);
if (subdimension) {
subdimension.techniques.push(t);
}
});
// Build final hierarchy - EXACT DB structure
return dimensions.map(d => ({
...d,
subdimensions: Array.from(subdimensionsMap.values())
.filter(sd => sd.dimension_id === d.dimension_id),
}));
}
// Fetch source assessment data
export async function fetchSourceAssessment() {
const [
platforms,
platformModifiers,
sourceCredibility,
sourceTypes,
sourceAssessment,
domainAgeScores,
domainRiskLevels,
domainRedFlags,
authorClassifications,
authorCredibility,
] = await Promise.all([
query('SELECT * FROM platform ORDER BY platform_id'),
query('SELECT * FROM platform_modifier ORDER BY platform_modifier_id'),
query('SELECT * FROM source_credibility ORDER BY source_credibility_id'),
query('SELECT * FROM source_type ORDER BY source_type_id'),
query('SELECT * FROM source_assessment ORDER BY source_assessment_id'),
query('SELECT * FROM domain_age_score ORDER BY domain_age_score'),
query('SELECT * FROM domain_risk_level ORDER BY domain_risk_level_id'),
query('SELECT * FROM domain_red_flag ORDER BY domain_red_flag_id'),
query('SELECT * FROM author_classification ORDER BY author_classification_id'),
query('SELECT * FROM author_credibility ORDER BY author_credibility_id'),
]);
return {
platforms,
platform_modifiers: platformModifiers,
source_credibility: sourceCredibility,
source_types: sourceTypes,
source_assessment: sourceAssessment,
domain_age_scores: domainAgeScores,
domain_risk_levels: domainRiskLevels,
domain_red_flags: domainRedFlags,
author_classifications: authorClassifications,
author_credibility: authorCredibility,
};
}
// Fetch claims data
export async function fetchClaims() {
const [status, types, confidence, interpretation] = await Promise.all([
query('SELECT * FROM claim ORDER BY claim_id'),
query('SELECT * FROM claim_type ORDER BY claim_type_id'),
query('SELECT * FROM confidence ORDER BY confidence_id'),
query('SELECT * FROM interpretation ORDER BY interpretation_id'),
]);
return {
status,
types,
confidence,
interpretation,
};
}
// Fetch verdicts data
export async function fetchVerdicts() {
const [categories, risk, severity] = await Promise.all([
query('SELECT * FROM verdict_category ORDER BY verdict_category_id'),
query('SELECT * FROM risk_mapping ORDER BY risk_mapping_id'),
query('SELECT * FROM severity_assessment ORDER BY severity_id'),
]);
return {
categories,
risk_mappings: risk,
severity_assessments: severity,
};
}
// Fetch weights data
export async function fetchWeights() {
const [components, scenarios, multipliers] = await Promise.all([
query('SELECT * FROM component_weight ORDER BY component_weight_id'),
query('SELECT * FROM weight_scenario ORDER BY scenario_id'),
query('SELECT * FROM multiplier ORDER BY multiplier_id'),
]);
return {
components,
scenarios,
multipliers,
};
}
/**
* Build dimensions_compact from techniques hierarchy
* This generates the compact dimension list used by the screening stage
* to ensure consistency between screening and deep analysis
*/
export function buildDimensionsCompact(techniques: any[]): {
_description: string;
dimensions: { code: string; name: string; name_ro: string; short_description: string; short_description_ro: string }[];
} {
return {
_description: 'Compact dimension list for screening prompt - auto-generated from framework DB (bilingual EN/RO)',
dimensions: techniques.map(dim => {
// Get subdimension names for short_description (EN)
const subdimNames = dim.subdimensions
.slice(0, 4)
.map((sd: any) => sd.subdimension_name)
.join(', ');
// Get subdimension names for short_description (RO)
const subdimNamesRo = dim.subdimensions
.slice(0, 4)
.map((sd: any) => sd.subdimension_name_ro || sd.subdimension_name)
.join(', ');
// Capitalize dimension name properly (EN)
const formattedName = dim.dimension_name
.split('_')
.map((word: string) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
return {
code: dim.dimension_code,
name: formattedName,
name_ro: dim.dimension_name_ro || formattedName,
short_description: subdimNames || dim.description,
short_description_ro: subdimNamesRo || dim.description_ro || dim.description,
};
}),
};
}
// Fetch providers data (LLM configuration per component)
export async function fetchProviders() {
try {
const [providers, models, assignments, keys] = await Promise.all([
query('SELECT * FROM llm_provider WHERE is_active = true ORDER BY priority'),
query(`
SELECT m.*, p.provider_code, p.provider_name
FROM llm_model m
JOIN llm_provider p ON m.provider_id = p.provider_id
WHERE m.is_active = true AND p.is_active = true
ORDER BY p.priority, m.model_name
`),
query(`
SELECT
ca.component_code, ca.component_name, ca.temperature, ca.max_tokens, ca.timeout_ms, ca.is_enabled,
p.provider_code, p.provider_name, p.base_url, p.auth_type,
m.model_code, m.model_name, m.context_window, m.max_output_tokens,
fp.provider_code AS fallback_provider_code, fp.base_url AS fallback_base_url,
fm.model_code AS fallback_model_code
FROM component_provider_assignment ca
JOIN llm_provider p ON ca.provider_id = p.provider_id
JOIN llm_model m ON ca.model_id = m.model_id
LEFT JOIN llm_provider fp ON ca.fallback_provider_id = fp.provider_id
LEFT JOIN llm_model fm ON ca.fallback_model_id = fm.model_id
WHERE ca.is_enabled = true
ORDER BY ca.component_code
`),
query(`
SELECT k.provider_id, k.api_key_value, p.provider_code
FROM provider_api_key k
JOIN llm_provider p ON k.provider_id = p.provider_id
WHERE k.is_active = true
ORDER BY k.api_key_id
`),
]);
// Group API keys by provider
const keysByProvider: Record<string, string[]> = {};
keys.forEach((k: any) => {
if (!keysByProvider[k.provider_code]) {
keysByProvider[k.provider_code] = [];
}
keysByProvider[k.provider_code].push(k.api_key_value);
});
return {
providers,
models,
assignments,
keys: keysByProvider, // provider_code -> [keys]
};
} catch (error) {
// Tables might not exist yet
log.info('[sync-redis] Providers tables not found, skipping...');
return null;
}
}

View file

@ -0,0 +1,26 @@
/**
* didiFramework /api/sync-redis barrel router.
*
* The original 898-line sync-redis.ts was split into 6 files:
* _shared.ts Redis client + KEY_PREFIX + 5 PG row interfaces
* fetch-data.ts 7 framework data fetchers (techniques, sources, ...)
* fetch-config.ts 4 component config fetchers (stage assignments, prompts, ...)
* sync.ts POST / (master sync orchestrator, ~330 LOC)
* status.ts GET /status (read-only health check)
* data.ts GET /data/:category (debug read a single key)
*
* server.ts mounts this barrel at `/api/sync-redis` so all the same paths
* keep working unchanged.
*/
import { Router } from 'express';
import syncRouter from './sync';
import statusRouter from './status';
import dataRouter from './data';
const router = Router();
router.use(syncRouter);
router.use(statusRouter);
router.use(dataRouter);
export default router;

View file

@ -0,0 +1,73 @@
/**
* GET /api/sync-redis/status
*
* Read-only health check does Redis have the framework manifest? are all
* 6 framework keys present? Returns the manifest's `last_sync` timestamp so
* the admin dashboard can show "synced 2h ago" badges.
*/
import { Router, Request, Response } from 'express';
import { log } from '../../config/logger';
import { internalError } from '../../config/error-response';
import { getRedisClient, KEY_PREFIX } from './_shared';
const router = Router();
router.get('/status', async (_req: Request, res: Response) => {
const redis = getRedisClient();
try {
const manifestRaw = await redis.get(`${KEY_PREFIX}:manifest`);
if (!manifestRaw) {
await redis.quit();
return res.json({
success: true,
data: {
synced: false,
message: 'No framework data in Redis. Click "Sync to Redis" to load.',
},
});
}
const manifest = JSON.parse(manifestRaw);
const keysExist = await Promise.all([
redis.exists(`${KEY_PREFIX}:techniques`),
redis.exists(`${KEY_PREFIX}:sources`),
redis.exists(`${KEY_PREFIX}:claims`),
redis.exists(`${KEY_PREFIX}:verdicts`),
redis.exists(`${KEY_PREFIX}:weights`),
redis.exists(`${KEY_PREFIX}:providers`),
]);
const allKeysExist = keysExist.every(v => v === 1);
await redis.quit();
res.json({
success: true,
data: {
synced: true,
complete: allKeysExist,
last_sync: manifest.last_sync,
version: manifest.version,
categories: Object.keys(manifest.categories).map(key => ({
name: key,
key: manifest.categories[key].key,
exists: keysExist[Object.keys(manifest.categories).indexOf(key)] === 1,
counts: manifest.categories[key].counts,
})),
},
});
} catch (error) {
log.error('[sync-redis] Status check error:', error);
try {
await redis.quit();
} catch {
// ignore quit errors
}
internalError(res, error, 'sync_redis_status');
}
});
export default router;

View file

@ -0,0 +1,328 @@
/**
* POST /api/sync-redis
*
* Master sync endpoint: fetches all framework + component config from PG and
* writes it to Redis under `didi:framework:*` and `didi:config:*` in a single
* pipeline (atomic). Called by:
* - Admin dashboard "Sync to Redis" button.
* - Bootstrap on container start.
* - Hooks after framework edits (some POST/PUT routes call this internally).
*
* Skips silently when newer tables (HIL moderation, topic volatility) don't
* exist yet fresh deploys can still sync the core framework.
*/
import { Router, Request, Response } from 'express';
import { query } from '../../config/database';
import { log } from '../../config/logger';
import { internalError } from '../../config/error-response';
import { getRedisClient, KEY_PREFIX } from './_shared';
import {
fetchClaims, fetchProviders, fetchSourceAssessment, fetchTechniquesHierarchy,
fetchVerdicts, fetchWeights, buildDimensionsCompact,
} from './fetch-data';
import {
fetchComponentConfigs, fetchInputProfiles, fetchPrompts, fetchStageAssignments,
} from './fetch-config';
const router = Router();
router.post('/', async (req: Request, res: Response) => {
const redis = getRedisClient();
const startTime = Date.now();
try {
log.info('[sync-redis] Starting full framework sync to Redis...');
// Fetch all data in parallel
const [techniques, sources, claims, verdicts, weights, providers, stageAssignments, prompts, componentConfigs, inputProfiles] = await Promise.all([
fetchTechniquesHierarchy(),
fetchSourceAssessment(),
fetchClaims(),
fetchVerdicts(),
fetchWeights(),
fetchProviders(),
fetchStageAssignments(),
fetchPrompts(),
fetchComponentConfigs(),
fetchInputProfiles(),
]);
// Calculate counts
const counts = {
techniques: {
dimensions: techniques.length,
subdimensions: techniques.reduce((acc, d) => acc + d.subdimensions.length, 0),
techniques: techniques.reduce((acc, d) => acc + d.subdimensions.reduce((a, sd) => a + sd.techniques.length, 0), 0),
indicators: techniques.reduce((acc, d) => acc + d.subdimensions.reduce((a: number, sd: any) => a + sd.techniques.reduce((t: number, tech: any) => t + tech.indicators.length, 0), 0), 0),
validation_rules: techniques.reduce((acc, d) => acc + d.subdimensions.reduce((a: number, sd: any) => a + sd.techniques.reduce((t: number, tech: any) => t + tech.validation_rules.length, 0), 0), 0),
},
sources: {
platforms: sources.platforms.length,
platform_modifiers: sources.platform_modifiers.length,
source_credibility: sources.source_credibility.length,
source_types: sources.source_types.length,
source_assessment: sources.source_assessment.length,
domain_age_scores: sources.domain_age_scores.length,
domain_risk_levels: sources.domain_risk_levels.length,
domain_red_flags: sources.domain_red_flags.length,
author_classifications: sources.author_classifications.length,
author_credibility: sources.author_credibility.length,
},
claims: {
status: claims.status.length,
types: claims.types.length,
confidence: claims.confidence.length,
interpretation: claims.interpretation.length,
},
verdicts: {
categories: verdicts.categories.length,
risk_mappings: verdicts.risk_mappings.length,
severity_assessments: verdicts.severity_assessments.length,
},
weights: {
components: weights.components.length,
scenarios: weights.scenarios.length,
multipliers: weights.multipliers.length,
},
providers: providers ? {
providers: providers.providers.length,
models: providers.models.length,
assignments: providers.assignments.length,
keys: Object.keys(providers.keys).length,
} : null,
};
// Build manifest
const manifest = {
version: '1.0.0',
last_sync: new Date().toISOString(),
synced_by: 'didiFramework',
categories: {
techniques: {
key: `${KEY_PREFIX}:techniques`,
description: 'Manipulation techniques hierarchy (dimensions → subdimensions → techniques with indicators and rules)',
use_when: 'analyzing content for manipulation patterns, identifying deceptive techniques',
counts: counts.techniques,
},
sources: {
key: `${KEY_PREFIX}:sources`,
description: 'Source credibility assessment parameters (platforms, modifiers, domain scoring, author credibility)',
use_when: 'evaluating source reliability, checking platform credibility, assessing author trustworthiness',
counts: counts.sources,
},
claims: {
key: `${KEY_PREFIX}:claims`,
description: 'Claim verification parameters (status codes, claim types, confidence levels, interpretations)',
use_when: 'verifying factual claims, determining claim veracity, assessing confidence',
counts: counts.claims,
},
verdicts: {
key: `${KEY_PREFIX}:verdicts`,
description: 'Final verdict and risk scoring (verdict categories, risk mappings, severity levels)',
use_when: 'generating final assessment, calculating risk scores, determining severity',
counts: counts.verdicts,
},
weights: {
key: `${KEY_PREFIX}:weights`,
description: 'Scoring weights and multipliers (component weights, scenarios, topic/temporal/reach multipliers)',
use_when: 'calculating final scores, applying scenario-specific weights, adjusting for context',
counts: counts.weights,
},
...(providers ? {
providers: {
key: `${KEY_PREFIX}:providers`,
description: 'LLM provider configurations per analysis component (provider/model assignments, API keys)',
use_when: 'selecting which LLM provider/model to use for each analysis step',
counts: counts.providers,
},
} : {}),
},
};
// Use pipeline for atomic writes
const pipeline = redis.pipeline();
// Build dimensions_compact from techniques hierarchy (ensures consistency)
const dimensionsCompact = buildDimensionsCompact(techniques);
// Write all data
pipeline.set(`${KEY_PREFIX}:manifest`, JSON.stringify(manifest));
pipeline.set(`${KEY_PREFIX}:techniques`, JSON.stringify({ dimensions: techniques }));
pipeline.set(`${KEY_PREFIX}:sources`, JSON.stringify(sources));
pipeline.set(`${KEY_PREFIX}:claims`, JSON.stringify(claims));
pipeline.set(`${KEY_PREFIX}:verdicts`, JSON.stringify(verdicts));
pipeline.set(`${KEY_PREFIX}:weights`, JSON.stringify(weights));
if (providers) {
pipeline.set(`${KEY_PREFIX}:providers`, JSON.stringify(providers));
}
// Write dimensions_compact (canonical framework location)
pipeline.set(`${KEY_PREFIX}:dimensions_compact`, JSON.stringify(dimensionsCompact));
// ================================================================
// Write component configs to didi:config:* (unified config prefix)
// ================================================================
const CONFIG_PREFIX = 'didi:config';
const VERSION_MAP: Record<string, string> = { 'techniques': 'v3', 'ai-tampered': 'v1', 'claims': 'v1', 'pipeline': 'v1', 'vision': 'v1', 'source-assessment': 'v1', 'verdict': 'v1' };
let configKeysWritten = 0;
// Stage assignments (tier-nested structure) + available_models per component
// Structure: { [stage]: { free: {models:[...]}, premium: {models:[...]} } }
for (const [comp, stages] of Object.entries(stageAssignments)) {
const prefix = `${CONFIG_PREFIX}:${comp}:${VERSION_MAP[comp] || 'v1'}`;
pipeline.set(`${prefix}:stage_assignments`, JSON.stringify(stages));
configKeysWritten++;
// Build available_models from all unique models across all stages + tiers.
// tierConfig has shape { models: Array<{ model_key: string, ... }> }.
interface TierConfig { models: Array<{ model_key: string; [k: string]: unknown }> }
const allModels = new Map<string, unknown>();
for (const stage of Object.values(stages)) {
for (const tierConfig of Object.values(stage as Record<string, TierConfig>)) {
for (const m of tierConfig.models) {
allModels.set(m.model_key, m);
}
}
}
pipeline.set(`${prefix}:available_models`, JSON.stringify({ models: Array.from(allModels.values()) }));
configKeysWritten++;
}
// Prompts per stage
for (const [comp, stagePrompts] of Object.entries(prompts)) {
const prefix = `${CONFIG_PREFIX}:${comp}:${VERSION_MAP[comp] || 'v1'}`;
for (const [stageCode, prompt] of Object.entries(stagePrompts)) {
const shortStage = stageCode.replace(`${comp.replace('-', '_')}_`, '');
pipeline.set(`${prefix}:prompts:${shortStage}`, JSON.stringify(prompt));
configKeysWritten++;
}
}
// JSONB configs
for (const [comp, configs] of Object.entries(componentConfigs)) {
const prefix = `${CONFIG_PREFIX}:${comp}:${VERSION_MAP[comp] || 'v1'}`;
for (const [key, value] of Object.entries(configs)) {
pipeline.set(`${prefix}:${key}`, JSON.stringify(value));
configKeysWritten++;
}
}
// Input profiles (verdict per input type)
if (inputProfiles && inputProfiles.length > 0) {
pipeline.set(`${CONFIG_PREFIX}:pipeline:v1:input_profiles`, JSON.stringify({ profiles: inputProfiles }));
configKeysWritten++;
log.info(`[sync-redis] Input profiles: ${inputProfiles.length} profiles synced`);
}
// ================================================================
// HIL Moderation config (triage + brain client + sensitive topics + roles)
// Migration 011 introduces these tables. Skip silently if missing.
// ================================================================
try {
const modConfig = await query<Record<string, unknown>>(
'SELECT * FROM bos_parammgmt.moderation_config WHERE config_id = 1'
);
if (modConfig.length > 0) {
pipeline.set(`${CONFIG_PREFIX}:moderation:v1:settings`, JSON.stringify(modConfig[0]));
configKeysWritten++;
log.info('[sync-redis] Moderation config: 1 row synced');
}
// Legacy HIL key — exact same shape as before so agent-v3 triage
// (which reads this key on every analysis) stays bit-identical.
const topics = await query<{ topic_code: string; topic_label: string }>(
'SELECT topic_code, topic_label FROM bos_parammgmt.sensitive_topic WHERE is_active = true ORDER BY topic_id'
);
pipeline.set(`${CONFIG_PREFIX}:moderation:v1:sensitive_topics`, JSON.stringify({ topics }));
configKeysWritten++;
log.info(`[sync-redis] Sensitive topics: ${topics.length} active topics synced`);
// D1 — full volatility taxonomy for brain cache freshness.
// Brain reads this key with cache 60s (services/topic_volatility.py).
// Agent-v3 does NOT read this key — it's brain-internal.
// Wrapped in its own try so missing migration 012 columns degrade
// gracefully (brain falls back to its hardcoded defaults).
try {
const topicVolatility = await query<{
topic_code: string;
topic_label: string;
volatility: string;
cache_ttl_hours: number;
recency_window_days: number;
half_life_days: number;
atomic_path_prefix: string | null;
}>(
`SELECT topic_code, topic_label, volatility,
cache_ttl_hours, recency_window_days, half_life_days,
atomic_path_prefix
FROM bos_parammgmt.sensitive_topic
WHERE is_active = true
ORDER BY topic_id`
);
pipeline.set(
`${CONFIG_PREFIX}:topics:volatility`,
JSON.stringify({
topics: topicVolatility,
synced_at: new Date().toISOString(),
})
);
configKeysWritten++;
log.info(`[sync-redis] Topic volatility: ${topicVolatility.length} topics synced`);
} catch (innerErr) {
log.info(
'[sync-redis] Topic volatility columns not found, skipping ' +
'(migration 012 may not have run; brain falls back to defaults)'
);
}
const roles = await query<Record<string, unknown>>(
'SELECT role_code, role_label, can_resolve, can_escalate, can_force_gold_brain, is_active FROM bos_parammgmt.moderation_role ORDER BY role_code'
);
pipeline.set(`${CONFIG_PREFIX}:moderation:v1:roles`, JSON.stringify({ roles }));
configKeysWritten++;
log.info(`[sync-redis] Moderation roles: ${roles.length} roles synced`);
} catch (e) {
log.info('[sync-redis] Moderation tables not found, skipping (migration 011 may not have run)');
}
await pipeline.exec();
const duration = Date.now() - startTime;
const frameworkKeysWritten = providers ? 8 : 7; // manifest + techniques + sources + claims + verdicts + weights + dimensions_compact + providers
const totalKeysWritten = frameworkKeysWritten + configKeysWritten;
log.info(`[sync-redis] Sync completed in ${duration}ms`);
log.info(`[sync-redis] Synced: ${counts.techniques.techniques} techniques, ${counts.sources.platforms} platforms, ${counts.claims.status} claim statuses, ${counts.verdicts.categories} verdict categories${providers ? `, ${counts.providers?.assignments} provider assignments` : ''}`);
log.info(`[sync-redis] Generated dimensions_compact: ${dimensionsCompact.dimensions.length} dimensions`);
log.info(`[sync-redis] Config keys written: ${configKeysWritten} (stage_assignments, available_models, prompts, configs)`);
await redis.quit();
res.json({
success: true,
message: 'Framework data synced to Redis successfully',
data: {
duration_ms: duration,
last_sync: manifest.last_sync,
keys_written: totalKeysWritten,
framework_keys: frameworkKeysWritten,
config_keys: configKeysWritten,
counts,
dimensions_compact: dimensionsCompact.dimensions.map(d => `${d.code}: ${d.name}`),
},
});
} catch (error) {
log.error('[sync-redis] Error:', error);
try {
await redis.quit();
} catch {
// ignore quit errors
}
internalError(res, error, 'sync_redis_main');
}
});
export default router;

View file

@ -0,0 +1,393 @@
/**
* Techniques Routes - FULL CRUD with Safety
*
* Techniques are children of subdimensions and parents of indicators/validation_rules:
* dimension -> subdimension -> technique -> indicator/validation_rule
*
* DELETE is protected - cannot delete technique with indicators or validation rules
*/
import { Router, Request, Response } from 'express';
import { query, queryOne, transaction } from '../config/database';
import { checkDependencies, safeDelete } from '../utils/dependency-checker';
import { Technique, ApiResponse } from '../types';
import { PoolClient } from 'pg';
import { internalError } from '../config/error-response';
const router = Router();
// Parameter type for techniques
const PARAMETER_TYPE_TECHNIQUE = 3;
// Helper: Create parameter entry
const createParameter = async (client: PoolClient): Promise<number> => {
const maxResult = await client.query('SELECT COALESCE(MAX(parameter_id), 0) + 1 as next_id FROM parameter');
const nextParamId = maxResult.rows[0].next_id;
await client.query(`
INSERT INTO parameter (parameter_id, parameter_type, valid_from, valid_to, created_date, updated_date)
VALUES ($1, $2, '2026-01-01', '2999-01-01', CURRENT_DATE, CURRENT_DATE)
`, [nextParamId, PARAMETER_TYPE_TECHNIQUE]);
return nextParamId;
};
// Helper: Get next technique_id
const getNextTechniqueId = async (client: PoolClient): Promise<number> => {
const result = await client.query('SELECT COALESCE(MAX(technique_id), 0) + 1 as next_id FROM technique');
return result.rows[0].next_id;
};
// GET all techniques
router.get('/', async (req: Request, res: Response) => {
try {
const techniques = await query<Technique>(
'SELECT * FROM technique ORDER BY technique_id'
);
res.json({
success: true,
data: techniques,
count: techniques.length
} as ApiResponse<Technique[]>);
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
} as ApiResponse<never>);
}
});
// GET all techniques with full hierarchy and counts
router.get('/with-hierarchy', async (req: Request, res: Response) => {
try {
const techniques = await query<Technique & {
dimension_id: number;
dimension_code: string;
dimension_name: string;
subdimension_name: string;
subdimension_code: string;
indicator_count: number;
rule_count: number;
}>(`
SELECT t.*,
s.subdmiension_name as subdimension_name, s.subdimension_code,
d.dimension_id, d.dimension_code, d.dimension_name,
(SELECT COUNT(*) FROM technique_indicator ti WHERE ti.technique_id = t.technique_id) as indicator_count,
(SELECT COUNT(*) FROM technique_validation_rule tr WHERE tr.technique_id = t.technique_id) as rule_count
FROM technique t
JOIN subdimension s ON t.subdimension_id = s.subdimension_id
JOIN dimension d ON s.dimension_id = d.dimension_id
ORDER BY d.dimension_id, s.subdimension_id, t.technique_id
`);
res.json({
success: true,
data: techniques,
count: techniques.length
});
} catch (error) {
internalError(res, error);
}
});
// GET techniques by subdimension_id
router.get('/by-subdimension/:subdimensionId', async (req: Request, res: Response) => {
try {
const techniques = await query<Technique & { indicator_count: number; rule_count: number }>(`
SELECT t.*,
(SELECT COUNT(*) FROM technique_indicator ti WHERE ti.technique_id = t.technique_id) as indicator_count,
(SELECT COUNT(*) FROM technique_validation_rule tr WHERE tr.technique_id = t.technique_id) as rule_count
FROM technique t
WHERE t.subdimension_id = $1
ORDER BY t.technique_id
`, [req.params.subdimensionId]);
res.json({
success: true,
data: techniques,
count: techniques.length
});
} catch (error) {
internalError(res, error);
}
});
// GET technique by ID
router.get('/:id', async (req: Request, res: Response) => {
try {
const technique = await queryOne<Technique>(
'SELECT * FROM technique WHERE technique_id = $1',
[req.params.id]
);
if (!technique) {
return res.status(404).json({
success: false,
error: 'Tehnica nu a fost găsită'
} as ApiResponse<never>);
}
res.json({
success: true,
data: technique
} as ApiResponse<Technique>);
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
} as ApiResponse<never>);
}
});
// GET dependency check before delete
router.get('/:id/dependencies', async (req: Request, res: Response) => {
try {
const id = req.params.id;
// Check if technique exists
const technique = await queryOne<Technique>(
'SELECT * FROM technique WHERE technique_id = $1',
[id]
);
if (!technique) {
return res.status(404).json({
success: false,
error: 'Tehnica nu a fost găsită'
});
}
// Check dependencies
const depCheck = await checkDependencies('technique', 'technique_id', id);
// Get detailed child info if there are children
let indicators: any[] = [];
let validationRules: any[] = [];
if (depCheck.hasChildren) {
[indicators, validationRules] = await Promise.all([
query(`
SELECT technique_indicator_id, indicator_name, max_intensity
FROM technique_indicator
WHERE technique_id = $1
ORDER BY indicator_id
`, [id]),
query(`
SELECT technique_valid_rule_id, rule_name, rule_value
FROM technique_validation_rule
WHERE technique_id = $1
ORDER BY technique_valid_rule_id
`, [id])
]);
}
res.json({
success: true,
data: {
technique,
...depCheck,
childDetails: {
indicators,
validationRules
}
}
});
} catch (error) {
internalError(res, error);
}
});
// POST create technique
router.post('/', async (req: Request, res: Response) => {
try {
const { subdimension_id, technique_key, technique_name, severity, confidence, detectability } = req.body;
// Validation
if (!subdimension_id || !technique_name) {
return res.status(400).json({
success: false,
error: 'Câmpuri obligatorii: subdimension_id, technique_name'
});
}
// Verify subdimension exists
const subdimension = await queryOne('SELECT subdimension_id FROM subdimension WHERE subdimension_id = $1', [subdimension_id]);
if (!subdimension) {
return res.status(400).json({
success: false,
error: 'Subdimensiunea specificată nu există'
});
}
const result = await transaction(async (client) => {
// Create parameter entry
const parameterId = await createParameter(client);
// Get next technique_id
const techniqueId = await getNextTechniqueId(client);
// Determine technique_key (auto-increment within subdimension if not provided)
let techKey = technique_key;
if (!techKey) {
const maxKeyResult = await client.query(
'SELECT COALESCE(MAX(technique_key), 0) + 1 as next_key FROM technique WHERE subdimension_id = $1',
[subdimension_id]
);
techKey = maxKeyResult.rows[0].next_key;
}
// Insert technique
const insertResult = await client.query(`
INSERT INTO technique (technique_id, subdimension_id, technique_key, technique_name, severity, confidence, detectability, parameter_id,
technique_name_ro, technique_name_en)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *
`, [techniqueId, subdimension_id, techKey, technique_name, severity || 5, confidence || 5, detectability || 5, parameterId,
req.body.technique_name_ro || null, req.body.technique_name_en || technique_name]);
return insertResult.rows[0];
});
res.status(201).json({
success: true,
data: result,
message: 'Tehnica a fost creată cu succes'
});
} catch (error) {
internalError(res, error);
}
});
// PUT update technique
router.put('/:id', async (req: Request, res: Response) => {
try {
const { subdimension_id, technique_key, technique_name, severity, confidence, detectability,
technique_name_ro, technique_name_en } = req.body;
// Check if technique exists
const existing = await queryOne<Technique>(
'SELECT * FROM technique WHERE technique_id = $1',
[req.params.id]
);
if (!existing) {
return res.status(404).json({
success: false,
error: 'Tehnica nu a fost găsită'
});
}
// If changing subdimension_id, verify it exists
if (subdimension_id) {
const subdimension = await queryOne('SELECT subdimension_id FROM subdimension WHERE subdimension_id = $1', [subdimension_id]);
if (!subdimension) {
return res.status(400).json({
success: false,
error: 'Subdimensiunea specificată nu există'
});
}
}
const technique = await queryOne<Technique>(
`UPDATE technique
SET subdimension_id = COALESCE($1, subdimension_id),
technique_key = COALESCE($2, technique_key),
technique_name = COALESCE($3, technique_name),
severity = COALESCE($4, severity),
confidence = COALESCE($5, confidence),
detectability = COALESCE($6, detectability),
technique_name_ro = COALESCE($8, technique_name_ro),
technique_name_en = COALESCE($9, technique_name_en),
updated_date = CURRENT_DATE
WHERE technique_id = $7
RETURNING *`,
[subdimension_id, technique_key, technique_name, severity, confidence, detectability, req.params.id,
technique_name_ro, technique_name_en]
);
res.json({
success: true,
data: technique,
message: 'Tehnica a fost actualizată cu succes'
} as ApiResponse<Technique>);
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
} as ApiResponse<never>);
}
});
// DELETE technique (with safety check)
router.delete('/:id', async (req: Request, res: Response) => {
try {
const id = req.params.id;
const force = req.query.force === 'true';
const cascade = req.query.cascade === 'true';
// Check if technique exists
const existing = await queryOne<Technique>(
'SELECT * FROM technique WHERE technique_id = $1',
[id]
);
if (!existing) {
return res.status(404).json({
success: false,
error: 'Tehnica nu a fost găsită'
});
}
// Check dependencies first
const depCheck = await checkDependencies('technique', 'technique_id', id);
// If has children and cascade is requested, delete children first
if (depCheck.hasChildren && cascade) {
await transaction(async (client) => {
// Delete indicators first
await client.query('DELETE FROM technique_indicator WHERE technique_id = $1', [id]);
// Delete validation rules
await client.query('DELETE FROM technique_validation_rule WHERE technique_id = $1', [id]);
// Delete technique
await client.query('DELETE FROM technique WHERE technique_id = $1', [id]);
});
return res.json({
success: true,
message: `Tehnica și toate dependențele (${depCheck.totalChildren} înregistrări) au fost șterse cu succes`,
deleted: true,
cascadeDeleted: depCheck.dependencies
});
}
// Use safe delete
const deleteResult = await safeDelete('technique', 'technique_id', id, force);
if (!deleteResult.success) {
return res.status(409).json({
success: false,
error: deleteResult.message,
canDelete: false,
dependencies: deleteResult.dependencyDetails?.dependencies || [],
hints: [
'Ștergeți mai întâi indicatorii și regulile de validare',
'Sau folosiți ?cascade=true pentru a șterge totul automat'
]
});
}
res.json({
success: true,
message: 'Tehnica a fost ștearsă cu succes',
deleted: true
});
} catch (error: any) {
if (error.code === '23503') {
return res.status(409).json({
success: false,
error: 'Nu se poate șterge: există indicatori sau reguli de validare asociate',
canDelete: false
});
}
internalError(res, error);
}
});
export default router;

View file

@ -0,0 +1,441 @@
/**
* Upload Routes
* File upload, download, and management with MinIO storage
*/
import { Router, Request, Response } from 'express';
import multer from 'multer';
import { v4 as uuidv4 } from 'uuid';
import path from 'path';
import { log } from '../config/logger';
import {
getMinioClient,
getBucketForMime,
getPresignedUrl,
uploadBuffer,
deleteObject,
getObjectInfo,
listObjects,
checkMinioHealth,
ALLOWED_MIME_TYPES,
getMaxSizeForMime,
BUCKETS,
} from '../config/minio';
const router = Router();
// Configure multer for memory storage (buffer)
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 500 * 1024 * 1024, // 500MB max (will be validated per type later)
},
fileFilter: (req, file, cb) => {
// Check if MIME type is allowed
if (ALLOWED_MIME_TYPES.has(file.mimetype)) {
cb(null, true);
} else {
cb(new Error(`File type not allowed: ${file.mimetype}`));
}
},
});
// In-memory metadata store (in production, use PostgreSQL)
interface FileMetadata {
id: string;
originalName: string;
mimeType: string;
size: number;
bucket: string;
objectName: string;
uploadedAt: string;
expiresAt?: string;
metadata?: Record<string, string>;
}
const fileMetadataStore = new Map<string, FileMetadata>();
/**
* GET /health
* Check MinIO connection health
*/
router.get('/health', async (req: Request, res: Response) => {
try {
const healthy = await checkMinioHealth();
res.json({
success: true,
minio: healthy ? 'connected' : 'disconnected',
timestamp: new Date().toISOString(),
});
} catch (error: any) {
res.status(500).json({
success: false,
error: error.message,
});
}
});
/**
* POST /
* Upload a file
* Returns: fileId, presignedUrl, contentType, size
*/
router.post('/', upload.single('file'), async (req: Request, res: Response) => {
try {
if (!req.file) {
return res.status(400).json({
success: false,
error: 'No file provided',
});
}
const file = req.file;
const fileId = uuidv4();
const bucket = getBucketForMime(file.mimetype);
const ext = path.extname(file.originalname) || '';
const objectName = `${fileId}${ext}`;
// Check file size against type-specific limit
const maxSize = getMaxSizeForMime(file.mimetype);
if (file.size > maxSize) {
return res.status(413).json({
success: false,
error: `File too large. Max size for ${file.mimetype}: ${Math.round(maxSize / 1024 / 1024)}MB`,
});
}
// Upload to MinIO
const uploadResult = await uploadBuffer(
bucket,
objectName,
file.buffer,
file.mimetype,
{
'X-Original-Name': encodeURIComponent(file.originalname),
'X-Upload-Id': fileId,
}
);
// Generate presigned URL for access (1 hour default)
const expirySeconds = parseInt(req.query.expiry as string) || 3600;
const presignedUrl = await getPresignedUrl(bucket, objectName, expirySeconds);
// Store metadata
const metadata: FileMetadata = {
id: fileId,
originalName: file.originalname,
mimeType: file.mimetype,
size: file.size,
bucket,
objectName,
uploadedAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + expirySeconds * 1000).toISOString(),
};
fileMetadataStore.set(fileId, metadata);
log.info(`[Upload] File uploaded: ${fileId} -> ${bucket}/${objectName} (${file.size} bytes)`);
res.json({
success: true,
data: {
fileId,
presignedUrl,
contentType: file.mimetype,
size: file.size,
bucket,
objectName,
originalName: file.originalname,
uploadedAt: metadata.uploadedAt,
expiresAt: metadata.expiresAt,
},
});
} catch (error: any) {
log.error('[Upload] Error:', error);
res.status(500).json({
success: false,
error: error.message,
});
}
});
/**
* POST /multipart
* Upload multiple files
*/
router.post('/multipart', upload.array('files', 10), async (req: Request, res: Response) => {
try {
const files = req.files as Express.Multer.File[];
if (!files || files.length === 0) {
return res.status(400).json({
success: false,
error: 'No files provided',
});
}
const results = await Promise.all(
files.map(async (file) => {
const fileId = uuidv4();
const bucket = getBucketForMime(file.mimetype);
const ext = path.extname(file.originalname) || '';
const objectName = `${fileId}${ext}`;
await uploadBuffer(bucket, objectName, file.buffer, file.mimetype, {
'X-Original-Name': encodeURIComponent(file.originalname),
'X-Upload-Id': fileId,
});
const presignedUrl = await getPresignedUrl(bucket, objectName, 3600);
const metadata: FileMetadata = {
id: fileId,
originalName: file.originalname,
mimeType: file.mimetype,
size: file.size,
bucket,
objectName,
uploadedAt: new Date().toISOString(),
};
fileMetadataStore.set(fileId, metadata);
return {
fileId,
presignedUrl,
contentType: file.mimetype,
size: file.size,
originalName: file.originalname,
};
})
);
log.info(`[Upload] ${results.length} files uploaded`);
res.json({
success: true,
data: {
count: results.length,
files: results,
},
});
} catch (error: any) {
log.error('[Upload] Multipart error:', error);
res.status(500).json({
success: false,
error: error.message,
});
}
});
/**
* GET /:fileId
* Get file info and fresh presigned URL
*/
router.get('/:fileId', async (req: Request, res: Response) => {
try {
const { fileId } = req.params;
const metadata = fileMetadataStore.get(fileId);
if (!metadata) {
return res.status(404).json({
success: false,
error: 'File not found',
});
}
// Check if file still exists in MinIO
const objectInfo = await getObjectInfo(metadata.bucket, metadata.objectName);
if (!objectInfo) {
fileMetadataStore.delete(fileId);
return res.status(404).json({
success: false,
error: 'File no longer exists in storage',
});
}
// Generate fresh presigned URL
const expirySeconds = parseInt(req.query.expiry as string) || 3600;
const presignedUrl = await getPresignedUrl(metadata.bucket, metadata.objectName, expirySeconds);
res.json({
success: true,
data: {
...metadata,
presignedUrl,
expiresAt: new Date(Date.now() + expirySeconds * 1000).toISOString(),
},
});
} catch (error: any) {
log.error('[Upload] Get error:', error);
res.status(500).json({
success: false,
error: error.message,
});
}
});
/**
* GET /:fileId/url
* Get only the presigned URL (for quick access)
*/
router.get('/:fileId/url', async (req: Request, res: Response) => {
try {
const { fileId } = req.params;
const metadata = fileMetadataStore.get(fileId);
if (!metadata) {
return res.status(404).json({
success: false,
error: 'File not found',
});
}
const expirySeconds = parseInt(req.query.expiry as string) || 3600;
const presignedUrl = await getPresignedUrl(metadata.bucket, metadata.objectName, expirySeconds);
res.json({
success: true,
data: {
fileId,
presignedUrl,
contentType: metadata.mimeType,
expiresAt: new Date(Date.now() + expirySeconds * 1000).toISOString(),
},
});
} catch (error: any) {
res.status(500).json({
success: false,
error: error.message,
});
}
});
/**
* DELETE /:fileId
* Delete a file
*/
router.delete('/:fileId', async (req: Request, res: Response) => {
try {
const { fileId } = req.params;
const metadata = fileMetadataStore.get(fileId);
if (!metadata) {
return res.status(404).json({
success: false,
error: 'File not found',
});
}
// Delete from MinIO
await deleteObject(metadata.bucket, metadata.objectName);
// Remove metadata
fileMetadataStore.delete(fileId);
log.info(`[Upload] File deleted: ${fileId}`);
res.json({
success: true,
message: 'File deleted successfully',
});
} catch (error: any) {
log.error('[Upload] Delete error:', error);
res.status(500).json({
success: false,
error: error.message,
});
}
});
/**
* GET /
* List all uploaded files (with pagination)
*/
router.get('/', async (req: Request, res: Response) => {
try {
const page = parseInt(req.query.page as string) || 1;
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
const type = req.query.type as string; // Filter by content type prefix
let files = Array.from(fileMetadataStore.values());
// Filter by type if specified
if (type) {
files = files.filter((f) => f.mimeType.startsWith(type));
}
// Sort by upload date (newest first)
files.sort((a, b) => new Date(b.uploadedAt).getTime() - new Date(a.uploadedAt).getTime());
// Paginate
const total = files.length;
const start = (page - 1) * limit;
const paginatedFiles = files.slice(start, start + limit);
res.json({
success: true,
data: {
files: paginatedFiles,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
},
});
} catch (error: any) {
res.status(500).json({
success: false,
error: error.message,
});
}
});
/**
* GET /buckets/stats
* Get statistics about buckets
*/
router.get('/buckets/stats', async (req: Request, res: Response) => {
try {
const client = getMinioClient();
const buckets = await client.listBuckets();
const stats = await Promise.all(
buckets.map(async (bucket) => {
try {
const objects = await listObjects(bucket.name, undefined, 1000);
const totalSize = objects.reduce((sum, obj) => sum + (obj.size || 0), 0);
return {
name: bucket.name,
createdAt: bucket.creationDate,
objectCount: objects.length,
totalSize,
totalSizeMB: Math.round(totalSize / 1024 / 1024 * 100) / 100,
};
} catch {
return {
name: bucket.name,
createdAt: bucket.creationDate,
objectCount: 0,
totalSize: 0,
totalSizeMB: 0,
};
}
})
);
res.json({
success: true,
data: {
buckets: stats,
total: stats.length,
},
});
} catch (error: any) {
res.status(500).json({
success: false,
error: error.message,
});
}
});
export default router;

View file

@ -0,0 +1,359 @@
/**
* Validation Rules Routes - FULL CRUD
*
* Validation rules are leaf nodes - no children, can be deleted directly
* technique -> technique_validation_rule
*/
import { Router, Request, Response } from 'express';
import { query, queryOne, transaction } from '../config/database';
import { TechniqueValidationRule, ApiResponse } from '../types';
import { PoolClient } from 'pg';
import { internalError } from '../config/error-response';
const router = Router();
// Parameter type for validation rules
const PARAMETER_TYPE_VALIDATION_RULE = 5;
// Helper: Create parameter entry
const createParameter = async (client: PoolClient): Promise<number> => {
const maxResult = await client.query('SELECT COALESCE(MAX(parameter_id), 0) + 1 as next_id FROM parameter');
const nextParamId = maxResult.rows[0].next_id;
await client.query(`
INSERT INTO parameter (parameter_id, parameter_type, valid_from, valid_to, created_date, updated_date)
VALUES ($1, $2, '2026-01-01', '2999-01-01', CURRENT_DATE, CURRENT_DATE)
`, [nextParamId, PARAMETER_TYPE_VALIDATION_RULE]);
return nextParamId;
};
// Helper: Get next rule_id
const getNextRuleId = async (client: PoolClient): Promise<number> => {
const result = await client.query('SELECT COALESCE(MAX(technique_valid_rule_id), 0) + 1 as next_id FROM technique_validation_rule');
return result.rows[0].next_id;
};
// GET all validation rules
router.get('/', async (req: Request, res: Response) => {
try {
const rules = await query<TechniqueValidationRule>(
'SELECT * FROM technique_validation_rule ORDER BY technique_id, technique_valid_rule_id'
);
res.json({
success: true,
data: rules,
count: rules.length
} as ApiResponse<TechniqueValidationRule[]>);
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
} as ApiResponse<never>);
}
});
// GET validation rules with technique info
router.get('/with-techniques', async (req: Request, res: Response) => {
try {
const rules = await query<TechniqueValidationRule & { technique_name: string }>(`
SELECT r.*, t.technique_name
FROM technique_validation_rule r
JOIN technique t ON r.technique_id = t.technique_id
ORDER BY r.technique_id, r.technique_valid_rule_id
`);
res.json({
success: true,
data: rules,
count: rules.length
});
} catch (error) {
internalError(res, error);
}
});
// GET validation rules by technique_id
router.get('/by-technique/:techniqueId', async (req: Request, res: Response) => {
try {
const rules = await query<TechniqueValidationRule>(
'SELECT * FROM technique_validation_rule WHERE technique_id = $1 ORDER BY technique_valid_rule_id',
[req.params.techniqueId]
);
res.json({
success: true,
data: rules,
count: rules.length
} as ApiResponse<TechniqueValidationRule[]>);
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
} as ApiResponse<never>);
}
});
// GET stats — declarat înainte de '/:id', altfel 'stats' e capturat ca :id
router.get('/stats', async (req: Request, res: Response) => {
try {
const stats = await query<any>(`
SELECT
(SELECT COUNT(*) FROM technique) as total_techniques,
(SELECT COUNT(DISTINCT technique_id) FROM technique_validation_rule) as techniques_with_rules,
(SELECT COUNT(*) FROM technique_validation_rule) as total_rules,
(SELECT COUNT(*) FROM technique WHERE technique_id NOT IN (SELECT DISTINCT technique_id FROM technique_validation_rule)) as techniques_missing_rules
`);
res.json({
success: true,
data: stats[0]
});
} catch (error) {
internalError(res, error);
}
});
// GET single validation rule by ID
router.get('/:id', async (req: Request, res: Response) => {
try {
const rule = await queryOne<TechniqueValidationRule>(
'SELECT * FROM technique_validation_rule WHERE technique_valid_rule_id = $1',
[req.params.id]
);
if (!rule) {
return res.status(404).json({
success: false,
error: 'Regula de validare nu a fost găsită'
});
}
res.json({
success: true,
data: rule
});
} catch (error) {
internalError(res, error);
}
});
// POST - Create single validation rule
router.post('/', async (req: Request, res: Response) => {
try {
const { technique_id, rule_name, rule_value, description } = req.body;
// Validation
if (!technique_id || !rule_name || !rule_value) {
return res.status(400).json({
success: false,
error: 'Câmpuri obligatorii: technique_id, rule_name, rule_value'
});
}
// Verify technique exists
const technique = await queryOne('SELECT technique_id FROM technique WHERE technique_id = $1', [technique_id]);
if (!technique) {
return res.status(400).json({
success: false,
error: 'Tehnica specificată nu există'
});
}
const result = await transaction(async (client) => {
// Create parameter entry
const parameterId = await createParameter(client);
// Get next rule ID
const ruleId = await getNextRuleId(client);
// Insert rule
const insertResult = await client.query(`
INSERT INTO technique_validation_rule (technique_valid_rule_id, technique_id, rule_name, rule_value, description, parameter_id)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *
`, [ruleId, technique_id, rule_name, rule_value, description || '', parameterId]);
return insertResult.rows[0];
});
res.status(201).json({
success: true,
data: result,
message: 'Regula de validare a fost creată cu succes'
});
} catch (error) {
internalError(res, error);
}
});
// POST /bulk-for-technique - Create multiple rules for a single technique
router.post('/bulk-for-technique/:techniqueId', async (req: Request, res: Response) => {
try {
const techniqueId = parseInt(req.params.techniqueId);
const { rules } = req.body;
if (!rules || !Array.isArray(rules) || rules.length === 0) {
return res.status(400).json({
success: false,
error: 'Câmp obligatoriu: rules (array de {rule_name, rule_value, description})'
});
}
// Verify technique exists
const technique = await queryOne('SELECT technique_id FROM technique WHERE technique_id = $1', [techniqueId]);
if (!technique) {
return res.status(400).json({
success: false,
error: 'Tehnica specificată nu există'
});
}
const result = await transaction(async (client) => {
const created: any[] = [];
for (const rule of rules) {
if (!rule.rule_name || !rule.rule_value) {
throw new Error(`Regulă invalidă: ${JSON.stringify(rule)}. Obligatoriu: rule_name, rule_value`);
}
// Create parameter entry
const parameterId = await createParameter(client);
// Get next rule ID
const ruleId = await getNextRuleId(client);
// Insert rule
const insertResult = await client.query(`
INSERT INTO technique_validation_rule (technique_valid_rule_id, technique_id, rule_name, rule_value, description, parameter_id)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *
`, [ruleId, techniqueId, rule.rule_name, rule.rule_value, rule.description || '', parameterId]);
created.push(insertResult.rows[0]);
}
return created;
});
res.status(201).json({
success: true,
data: result,
count: result.length,
message: `${result.length} reguli de validare au fost create cu succes`
});
} catch (error) {
internalError(res, error);
}
});
// PUT - Update validation rule
router.put('/:id', async (req: Request, res: Response) => {
try {
const id = req.params.id;
const { technique_id, rule_name, rule_value, description } = req.body;
// Check if rule exists
const existing = await queryOne<TechniqueValidationRule>(
'SELECT * FROM technique_validation_rule WHERE technique_valid_rule_id = $1',
[id]
);
if (!existing) {
return res.status(404).json({
success: false,
error: 'Regula de validare nu a fost găsită'
});
}
// If changing technique_id, verify it exists
if (technique_id) {
const technique = await queryOne('SELECT technique_id FROM technique WHERE technique_id = $1', [technique_id]);
if (!technique) {
return res.status(400).json({
success: false,
error: 'Tehnica specificată nu există'
});
}
}
const result = await queryOne<TechniqueValidationRule>(`
UPDATE technique_validation_rule
SET technique_id = COALESCE($1, technique_id),
rule_name = COALESCE($2, rule_name),
rule_value = COALESCE($3, rule_value),
description = COALESCE($4, description)
WHERE technique_valid_rule_id = $5
RETURNING *
`, [technique_id, rule_name, rule_value, description, id]);
res.json({
success: true,
data: result,
message: 'Regula de validare a fost actualizată cu succes'
});
} catch (error) {
internalError(res, error);
}
});
// DELETE - Delete single validation rule
router.delete('/:id', async (req: Request, res: Response) => {
try {
const id = req.params.id;
// Check if rule exists
const existing = await queryOne<TechniqueValidationRule>(
'SELECT * FROM technique_validation_rule WHERE technique_valid_rule_id = $1',
[id]
);
if (!existing) {
return res.status(404).json({
success: false,
error: 'Regula de validare nu a fost găsită'
});
}
// Delete (leaf node - no children to check)
await query('DELETE FROM technique_validation_rule WHERE technique_valid_rule_id = $1', [id]);
res.json({
success: true,
message: 'Regula de validare a fost ștearsă cu succes',
deleted: true
});
} catch (error) {
internalError(res, error);
}
});
// DELETE all validation rules for a technique
router.delete('/by-technique/:techniqueId', async (req: Request, res: Response) => {
try {
const techniqueId = req.params.techniqueId;
// Verify technique exists
const technique = await queryOne('SELECT technique_id FROM technique WHERE technique_id = $1', [techniqueId]);
if (!technique) {
return res.status(404).json({
success: false,
error: 'Tehnica specificată nu există'
});
}
const result = await query<TechniqueValidationRule>(
'DELETE FROM technique_validation_rule WHERE technique_id = $1 RETURNING *',
[techniqueId]
);
res.json({
success: true,
data: result,
count: result.length,
message: `${result.length} reguli de validare au fost șterse pentru tehnica ${techniqueId}`
});
} catch (error) {
internalError(res, error);
}
});
export default router;

View file

@ -0,0 +1,422 @@
/**
* Verdicts Routes - FULL CRUD
*
* All verdict-related tables are leaf nodes (no children):
* - Verdict Categories
* - Risk Mappings
* - Severity Assessments
*/
import { Router, Request, Response } from 'express';
import { query, queryOne, transaction } from '../config/database';
import { VerdictCategory, RiskMapping, SeverityAssessment, ApiResponse } from '../types';
import { PoolClient } from 'pg';
import { internalError } from '../config/error-response';
const router = Router();
// Helper functions
const createParameter = async (client: PoolClient, parameterType: number): Promise<number> => {
const maxResult = await client.query('SELECT COALESCE(MAX(parameter_id), 0) + 1 as next_id FROM parameter');
const nextParamId = maxResult.rows[0].next_id;
await client.query(`
INSERT INTO parameter (parameter_id, parameter_type, valid_from, valid_to, created_date, updated_date)
VALUES ($1, $2, '2026-01-01', '2999-01-01', CURRENT_DATE, CURRENT_DATE)
`, [nextParamId, parameterType]);
return nextParamId;
};
const getNextId = async (client: PoolClient, table: string, idColumn: string): Promise<number> => {
const result = await client.query(`SELECT COALESCE(MAX(${idColumn}), 0) + 1 as next_id FROM ${table}`);
return result.rows[0].next_id;
};
// ============================================================================
// VERDICT CATEGORIES
// ============================================================================
router.get('/categories', async (req: Request, res: Response) => {
try {
const categories = await query<VerdictCategory>(
'SELECT * FROM verdict_category ORDER BY start_range'
);
res.json({ success: true, data: categories, count: categories.length });
} catch (error) {
internalError(res, error);
}
});
router.get('/categories/:id', async (req: Request, res: Response) => {
try {
const category = await queryOne<VerdictCategory>(
'SELECT * FROM verdict_category WHERE verdict_category_id = $1',
[req.params.id]
);
if (!category) {
return res.status(404).json({ success: false, error: 'Verdict category nu a fost găsită' });
}
res.json({ success: true, data: category });
} catch (error) {
internalError(res, error);
}
});
router.post('/categories', async (req: Request, res: Response) => {
try {
const { verdict_category_code, description, start_range, end_range, verdict_category_color } = req.body;
if (!verdict_category_code) {
return res.status(400).json({ success: false, error: 'Câmp obligatoriu: verdict_category_code' });
}
const result = await transaction(async (client) => {
const parameterId = await createParameter(client, 20);
const id = await getNextId(client, 'verdict_category', 'verdict_category_id');
const insertResult = await client.query(`
INSERT INTO verdict_category (verdict_category_id, verdict_category_code, description, start_range, end_range, verdict_category_color, parameter_id)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *
`, [id, verdict_category_code, description || '', start_range || 0, end_range || 100, verdict_category_color || '#808080', parameterId]);
return insertResult.rows[0];
});
res.status(201).json({ success: true, data: result, message: 'Verdict category creată cu succes' });
} catch (error) {
internalError(res, error);
}
});
router.put('/categories/:id', async (req: Request, res: Response) => {
try {
const { verdict_category_code, description, start_range, end_range, verdict_category_color } = req.body;
const category = await queryOne<VerdictCategory>(`
UPDATE verdict_category
SET verdict_category_code = COALESCE($1, verdict_category_code),
description = COALESCE($2, description),
start_range = COALESCE($3, start_range),
end_range = COALESCE($4, end_range),
verdict_category_color = COALESCE($5, verdict_category_color)
WHERE verdict_category_id = $6
RETURNING *
`, [verdict_category_code, description, start_range, end_range, verdict_category_color, req.params.id]);
if (!category) {
return res.status(404).json({ success: false, error: 'Verdict category nu a fost găsită' });
}
res.json({ success: true, data: category, message: 'Verdict category actualizată cu succes' });
} catch (error) {
internalError(res, error);
}
});
router.delete('/categories/:id', async (req: Request, res: Response) => {
try {
const result = await query('DELETE FROM verdict_category WHERE verdict_category_id = $1 RETURNING *', [req.params.id]);
if (result.length === 0) {
return res.status(404).json({ success: false, error: 'Verdict category nu a fost găsită' });
}
res.json({ success: true, message: 'Verdict category ștearsă cu succes', deleted: true });
} catch (error) {
internalError(res, error);
}
});
// ============================================================================
// RISK MAPPINGS
// ============================================================================
router.get('/risk', async (req: Request, res: Response) => {
try {
const mappings = await query<RiskMapping>(
'SELECT * FROM risk_mapping ORDER BY start_range'
);
res.json({ success: true, data: mappings, count: mappings.length });
} catch (error) {
internalError(res, error);
}
});
router.get('/risk/:id', async (req: Request, res: Response) => {
try {
const mapping = await queryOne<RiskMapping>(
'SELECT * FROM risk_mapping WHERE risk_mapping_id = $1',
[req.params.id]
);
if (!mapping) {
return res.status(404).json({ success: false, error: 'Risk mapping nu a fost găsit' });
}
res.json({ success: true, data: mapping });
} catch (error) {
internalError(res, error);
}
});
router.post('/risk', async (req: Request, res: Response) => {
try {
const { risk_mapping, risk_level, start_range, end_range, risk_color } = req.body;
if (!risk_mapping) {
return res.status(400).json({ success: false, error: 'Câmp obligatoriu: risk_mapping' });
}
const result = await transaction(async (client) => {
const parameterId = await createParameter(client, 21);
const id = await getNextId(client, 'risk_mapping', 'risk_mapping_id');
const insertResult = await client.query(`
INSERT INTO risk_mapping (risk_mapping_id, risk_mapping, risk_level, start_range, end_range, risk_color, parameter_id)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *
`, [id, risk_mapping, risk_level || 0, start_range || 0, end_range || 100, risk_color || '#808080', parameterId]);
return insertResult.rows[0];
});
res.status(201).json({ success: true, data: result, message: 'Risk mapping creat cu succes' });
} catch (error) {
internalError(res, error);
}
});
router.put('/risk/:id', async (req: Request, res: Response) => {
try {
const { risk_mapping, risk_level, start_range, end_range, risk_color } = req.body;
const mapping = await queryOne<RiskMapping>(`
UPDATE risk_mapping
SET risk_mapping = COALESCE($1, risk_mapping),
risk_level = COALESCE($2, risk_level),
start_range = COALESCE($3, start_range),
end_range = COALESCE($4, end_range),
risk_color = COALESCE($5, risk_color)
WHERE risk_mapping_id = $6
RETURNING *
`, [risk_mapping, risk_level, start_range, end_range, risk_color, req.params.id]);
if (!mapping) {
return res.status(404).json({ success: false, error: 'Risk mapping nu a fost găsit' });
}
res.json({ success: true, data: mapping, message: 'Risk mapping actualizat cu succes' });
} catch (error) {
internalError(res, error);
}
});
router.delete('/risk/:id', async (req: Request, res: Response) => {
try {
const result = await query('DELETE FROM risk_mapping WHERE risk_mapping_id = $1 RETURNING *', [req.params.id]);
if (result.length === 0) {
return res.status(404).json({ success: false, error: 'Risk mapping nu a fost găsit' });
}
res.json({ success: true, message: 'Risk mapping șters cu succes', deleted: true });
} catch (error) {
internalError(res, error);
}
});
// ============================================================================
// SEVERITY ASSESSMENTS
// ============================================================================
router.get('/severity', async (req: Request, res: Response) => {
try {
const assessments = await query<SeverityAssessment>(
'SELECT severity_id, severity_category, start_range, end_range, recomended_action, parameter_id FROM severity_assessment ORDER BY start_range'
);
res.json({ success: true, data: assessments, count: assessments.length });
} catch (error) {
internalError(res, error);
}
});
router.get('/severity/:id', async (req: Request, res: Response) => {
try {
const assessment = await queryOne<SeverityAssessment>(
'SELECT * FROM severity_assessment WHERE severity_id = $1',
[req.params.id]
);
if (!assessment) {
return res.status(404).json({ success: false, error: 'Severity assessment nu a fost găsit' });
}
res.json({ success: true, data: assessment });
} catch (error) {
internalError(res, error);
}
});
router.post('/severity', async (req: Request, res: Response) => {
try {
const { severity_id, severity_category, start_range, end_range, recomended_action } = req.body;
if (!severity_id || !severity_category) {
return res.status(400).json({ success: false, error: 'Câmpuri obligatorii: severity_id, severity_category' });
}
const result = await transaction(async (client) => {
const parameterId = await createParameter(client, 22);
const insertResult = await client.query(`
INSERT INTO severity_assessment (severity_id, severity_category, start_range, end_range, recomended_action, parameter_id)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *
`, [severity_id, severity_category, start_range || 0, end_range || 100, recomended_action || '', parameterId]);
return insertResult.rows[0];
});
res.status(201).json({ success: true, data: result, message: 'Severity assessment creat cu succes' });
} catch (error) {
internalError(res, error);
}
});
router.put('/severity/:id', async (req: Request, res: Response) => {
try {
const { severity_category, start_range, end_range, recomended_action } = req.body;
const assessment = await queryOne<SeverityAssessment>(`
UPDATE severity_assessment
SET severity_category = COALESCE($1, severity_category),
start_range = COALESCE($2, start_range),
end_range = COALESCE($3, end_range),
recomended_action = COALESCE($4, recomended_action)
WHERE severity_id = $5
RETURNING *
`, [severity_category, start_range, end_range, recomended_action, req.params.id]);
if (!assessment) {
return res.status(404).json({ success: false, error: 'Severity assessment nu a fost găsit' });
}
res.json({ success: true, data: assessment, message: 'Severity assessment actualizat cu succes' });
} catch (error) {
internalError(res, error);
}
});
router.delete('/severity/:id', async (req: Request, res: Response) => {
try {
const result = await query('DELETE FROM severity_assessment WHERE severity_id = $1 RETURNING *', [req.params.id]);
if (result.length === 0) {
return res.status(404).json({ success: false, error: 'Severity assessment nu a fost găsit' });
}
res.json({ success: true, message: 'Severity assessment șters cu succes', deleted: true });
} catch (error) {
internalError(res, error);
}
});
// ============================================================================
// RUNTIME CONFIG: synergy + overrides + confidence + confidence_levels
// Stored in component_config (component_code='pipeline', config_key='verdict_config')
// Same key that is synced to Redis at didi:config:pipeline:v1:verdict_config
// ============================================================================
router.get('/runtime-config', async (_req: Request, res: Response) => {
try {
const rows = await query<{ config_value: unknown }>(
`SELECT config_value FROM component_config
WHERE component_code = 'pipeline' AND config_key = 'verdict_config'`
);
if (rows.length === 0) {
return res.status(404).json({ success: false, error: 'verdict_config row missing in component_config' });
}
const value = typeof rows[0].config_value === 'string'
? JSON.parse(rows[0].config_value as string)
: rows[0].config_value;
res.json({ success: true, data: value });
} catch (error) {
internalError(res, error);
}
});
router.put('/runtime-config', async (req: Request, res: Response) => {
try {
const body = req.body;
if (!body || typeof body !== 'object' || Array.isArray(body)) {
return res.status(400).json({ success: false, error: 'Body must be a JSON object' });
}
const required = ['synergy', 'overrides', 'confidence', 'confidence_levels'];
const missing = required.filter(k => !(k in body));
if (missing.length > 0) {
return res.status(400).json({ success: false, error: `Missing required keys: ${missing.join(', ')}` });
}
const result = await query(
`UPDATE component_config SET config_value = $1
WHERE component_code = 'pipeline' AND config_key = 'verdict_config'
RETURNING component_code, config_key`,
[JSON.stringify(body)]
);
if (result.length === 0) {
return res.status(404).json({ success: false, error: 'verdict_config row missing in component_config' });
}
res.json({ success: true, message: 'verdict_config updated. Sync to Redis to apply.' });
} catch (error) {
internalError(res, error);
}
});
// PATCH partial update — accept a single section (synergy, overrides, confidence, confidence_levels, or a nested override key)
router.patch('/runtime-config/:section', async (req: Request, res: Response) => {
try {
const { section } = req.params;
const allowedTop = ['synergy', 'overrides', 'confidence', 'confidence_levels'];
const allowedOverride = ['false_claims', 'severe_techniques', 'undisclosed_ai', 'untrusted_domain', 'domain_red_flags'];
const rows = await query<{ config_value: unknown }>(
`SELECT config_value FROM component_config
WHERE component_code = 'pipeline' AND config_key = 'verdict_config'`
);
if (rows.length === 0) {
return res.status(404).json({ success: false, error: 'verdict_config row missing in component_config' });
}
const current = (typeof rows[0].config_value === 'string'
? JSON.parse(rows[0].config_value as string)
: rows[0].config_value) as Record<string, unknown>;
if (allowedTop.includes(section)) {
current[section] = req.body;
} else if (allowedOverride.includes(section)) {
const overrides = (current.overrides as Record<string, unknown>) || {};
overrides[section] = req.body;
current.overrides = overrides;
} else {
return res.status(400).json({ success: false, error: `Unknown section: ${section}` });
}
await query(
`UPDATE component_config SET config_value = $1
WHERE component_code = 'pipeline' AND config_key = 'verdict_config'`,
[JSON.stringify(current)]
);
res.json({ success: true, data: current, message: `Section '${section}' updated. Sync to Redis to apply.` });
} catch (error) {
internalError(res, error);
}
});
// ============================================================================
// COMBINED: GET ALL VERDICT DATA
// ============================================================================
router.get('/all', async (req: Request, res: Response) => {
try {
const [categories, riskMappings, severity] = await Promise.all([
query<VerdictCategory>('SELECT * FROM verdict_category ORDER BY start_range'),
query<RiskMapping>('SELECT * FROM risk_mapping ORDER BY start_range'),
query<SeverityAssessment>('SELECT severity_id, severity_category, start_range, end_range, recomended_action, parameter_id FROM severity_assessment ORDER BY start_range'),
]);
res.json({
success: true,
data: {
verdictCategories: categories,
riskMappings: riskMappings,
severityAssessments: severity,
},
counts: {
verdictCategories: categories.length,
riskMappings: riskMappings.length,
severityAssessments: severity.length,
total: categories.length + riskMappings.length + severity.length,
}
});
} catch (error) {
internalError(res, error);
}
});
export default router;

View file

@ -0,0 +1,155 @@
import { Router, Request, Response } from 'express';
import { Pool } from 'pg';
import { requireEnv, optionalEnv } from '../config/env';
import { log } from '../config/logger';
import { internalError } from '../config/error-response';
const router = Router();
// Staging database connection (local waitlist-only DB, separate from main cluster).
// Lazy-init so the server can boot even when waitlist env vars aren't configured —
// the failure surfaces only on /api/waitlist requests.
let _stagingPool: Pool | null = null;
function getStagingPool(): Pool {
if (!_stagingPool) {
_stagingPool = new Pool({
host: requireEnv('STAGING_DB_HOST'),
port: parseInt(optionalEnv('STAGING_DB_PORT', '5432'), 10),
database: requireEnv('STAGING_DB_NAME'),
user: requireEnv('STAGING_DB_USER'),
password: requireEnv('STAGING_DB_PASSWORD'),
max: 5,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000,
});
}
return _stagingPool;
}
// POST /api/waitlist - Add to waitlist (public endpoint)
router.post('/', async (req: Request, res: Response) => {
try {
const { email, name } = req.body;
if (!email) {
res.status(400).json({
success: false,
error: 'Email is required'
});
return;
}
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
res.status(400).json({
success: false,
error: 'Invalid email format'
});
return;
}
// Insert into waitlist
const result = await getStagingPool().query(
`INSERT INTO public.waitlist (email, name)
VALUES ($1, $2)
ON CONFLICT (email) DO NOTHING
RETURNING id, email, name, created_at`,
[email.toLowerCase().trim(), name?.trim() || null]
);
if (result.rows.length === 0) {
// Email already exists
res.status(200).json({
success: true,
message: 'Already on the waitlist!',
alreadyExists: true
});
return;
}
res.status(201).json({
success: true,
message: 'Successfully added to waitlist!',
data: result.rows[0]
});
} catch (error: any) {
log.error('Error adding to waitlist:', error);
internalError(res, error);
}
});
// GET /api/waitlist - List all waitlist entries (for admin)
router.get('/', async (req: Request, res: Response) => {
try {
const result = await getStagingPool().query(
`SELECT id, email, name, created_at
FROM public.waitlist
ORDER BY created_at DESC`
);
res.json({
success: true,
data: result.rows,
count: result.rows.length
});
} catch (error: any) {
log.error('Error fetching waitlist:', error);
internalError(res, error);
}
});
// GET /api/waitlist/count - Get waitlist count (public)
router.get('/count', async (req: Request, res: Response) => {
try {
const result = await getStagingPool().query(
`SELECT COUNT(*) as count FROM public.waitlist`
);
res.json({
success: true,
count: parseInt(result.rows[0].count, 10)
});
} catch (error: any) {
log.error('Error fetching waitlist count:', error);
internalError(res, error);
}
});
// DELETE /api/waitlist/:id - Remove from waitlist (admin)
router.delete('/:id', async (req: Request, res: Response) => {
try {
const id = parseInt(req.params.id, 10);
if (isNaN(id)) {
res.status(400).json({
success: false,
error: 'Invalid ID'
});
return;
}
const result = await getStagingPool().query(
`DELETE FROM public.waitlist WHERE id = $1 RETURNING *`,
[id]
);
if (result.rows.length === 0) {
res.status(404).json({
success: false,
error: 'Entry not found'
});
return;
}
res.json({
success: true,
message: 'Removed from waitlist'
});
} catch (error: any) {
log.error('Error removing from waitlist:', error);
internalError(res, error);
}
});
export default router;

View file

@ -0,0 +1,457 @@
/**
* POST /webhooks/stripe
*
* Receives Stripe events. Verifies signature, dispatches per event type,
* and persists state changes to bos_sysadmin.
*
* Critical contract:
* - Body MUST be raw (Buffer) for signature verification.
* - We respond 200 fast even on processing errors (Stripe retries
* based on HTTP status; we log + retry internally if needed).
* - All handlers must be idempotent Stripe may deliver same event
* multiple times (network retry, manual replay).
*/
import { Router, Request, Response } from 'express';
import type Stripe from 'stripe';
import pool from '../../config/database';
import { log } from '../../config/logger';
import { getStripe, getWebhookSecret, isStripeEnabled } from '../../config/stripe';
import { sendEmail } from '../../config/email';
import {
subscriptionCreatedEmail,
subscriptionCancelledEmail,
paymentFailedEmail,
trialEndingEmail,
} from '../../services/email-templates';
const router = Router();
router.post('/', async (req: Request, res: Response) => {
if (!isStripeEnabled()) {
log.warn('[stripe-webhook] received event but STRIPE_* env not configured — 503');
return res.status(503).json({ error: 'Stripe not configured' });
}
const sig = req.headers['stripe-signature'];
if (!sig || typeof sig !== 'string') {
log.warn('[stripe-webhook] missing Stripe-Signature header');
return res.status(400).send('Missing signature');
}
// req.body is Buffer here (raw body parser configured in server.ts for this route)
const stripe = getStripe();
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, getWebhookSecret());
} catch (err: any) {
log.warn(`[stripe-webhook] signature verification failed: ${err.message}`);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// Idempotency: dedupe via event.id (Stripe guarantees unique IDs)
const seen = await alreadyProcessed(event.id);
if (seen) {
log.info(`[stripe-webhook] duplicate event ${event.id} (${event.type}) — ack`);
return res.json({ received: true, duplicate: true });
}
// Acknowledge fast; process async if needed.
// (For now, we process inline — events are small and DB calls are fast.)
try {
await dispatch(event);
await markProcessed(event);
log.info(`[stripe-webhook] handled ${event.type} (${event.id})`);
} catch (err: any) {
log.error(`[stripe-webhook] handler failed for ${event.type} (${event.id}): ${err.message}`);
await markFailed(event, err.message);
// Still 200 — Stripe would retry on 5xx, but we want to investigate via logs first.
// Switch to res.status(500) once handlers are stable to enable Stripe auto-retry.
}
res.json({ received: true });
});
// ──────────────────────────────────────────────────────────────────────────────
// Event dispatcher
// ──────────────────────────────────────────────────────────────────────────────
async function dispatch(event: Stripe.Event): Promise<void> {
switch (event.type) {
case 'customer.created':
return handleCustomerCreated(event.data.object as Stripe.Customer);
case 'checkout.session.completed':
return handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session);
case 'customer.subscription.created':
case 'customer.subscription.updated':
return handleSubscriptionUpsert(event.data.object as Stripe.Subscription);
case 'customer.subscription.deleted':
return handleSubscriptionDeleted(event.data.object as Stripe.Subscription);
case 'customer.subscription.trial_will_end':
return handleTrialWillEnd(event.data.object as Stripe.Subscription);
case 'invoice.payment_succeeded':
return handlePaymentSucceeded(event.data.object as Stripe.Invoice);
case 'invoice.payment_failed':
return handlePaymentFailed(event.data.object as Stripe.Invoice);
case 'invoice.upcoming':
return handleInvoiceUpcoming(event.data.object as Stripe.Invoice);
default:
log.info(`[stripe-webhook] unhandled event type ${event.type} — ignoring`);
}
}
// ──────────────────────────────────────────────────────────────────────────────
// Handlers (each is idempotent — UPSERT or no-op pattern)
// ──────────────────────────────────────────────────────────────────────────────
async function handleCustomerCreated(customer: Stripe.Customer): Promise<void> {
// Map Stripe customer back to internet_user via metadata.keycloak_id (set at customer create)
// or via email lookup as fallback.
const keycloakId = customer.metadata?.keycloak_id;
const email = customer.email;
let userId: number | null = null;
if (keycloakId) {
const r = await pool.query(
`SELECT internet_user_id FROM bos_sysadmin.user_credential WHERE keycloak_id = $1`,
[keycloakId]
);
userId = r.rows[0]?.internet_user_id ?? null;
}
if (!userId && email) {
const r = await pool.query(
`SELECT internet_user_id FROM bos_sysadmin.user_credential WHERE email = $1 AND "next$internet_user_id" IS NULL`,
[email]
);
userId = r.rows[0]?.internet_user_id ?? null;
}
if (!userId) {
log.warn(`[stripe-webhook] customer.created — no DIDI user found (kc=${keycloakId}, email=${email})`);
return;
}
await pool.query(
`UPDATE bos_sysadmin.internet_user SET stripe_customer_id = $1 WHERE internet_user_id = $2`,
[customer.id, userId]
);
log.info(`[stripe-webhook] mapped stripe_customer ${customer.id} → user ${userId}`);
}
async function handleCheckoutCompleted(session: Stripe.Checkout.Session): Promise<void> {
// Subscription mode → subscription details arrive separately via customer.subscription.created.
// We only log here for audit purposes; actual state mutation happens in the subscription handler.
log.info(`[stripe-webhook] checkout completed — customer=${session.customer}, sub=${session.subscription}, mode=${session.mode}`);
}
async function handleSubscriptionUpsert(sub: Stripe.Subscription): Promise<void> {
const customerId = typeof sub.customer === 'string' ? sub.customer : sub.customer.id;
const item = sub.items.data[0];
const priceId = item?.price.id;
if (!priceId) {
log.warn(`[stripe-webhook] subscription ${sub.id} has no price — skip`);
return;
}
// API 2025-09+ moved current_period_start/end from subscription → item; fall back gracefully.
const periodStart = (item as any)?.current_period_start ?? (sub as any).current_period_start ?? (sub as any).start_date;
const periodEnd = (item as any)?.current_period_end ?? (sub as any).current_period_end;
if (!periodStart || !periodEnd) {
log.warn(`[stripe-webhook] subscription ${sub.id} missing period dates — skip`);
return;
}
// Resolve internet_user_id from stripe_customer_id
const userR = await pool.query(
`SELECT internet_user_id FROM bos_sysadmin.internet_user WHERE stripe_customer_id = $1`,
[customerId]
);
const userId = userR.rows[0]?.internet_user_id;
if (!userId) {
log.warn(`[stripe-webhook] subscription ${sub.id} — no DIDI user for customer ${customerId}`);
return;
}
// Resolve subscription_plan_id from price_id (matches monthly OR yearly column)
const planR = await pool.query(
`SELECT subscription_plan_id, credits_per_cycle, storage_limit_gb
FROM bos_sysadmin.subscription_plan
WHERE stripe_price_id = $1 OR stripe_price_id_yearly = $1
LIMIT 1`,
[priceId]
);
const plan = planR.rows[0];
if (!plan) {
log.warn(`[stripe-webhook] subscription ${sub.id} — no DIDI plan for price ${priceId}`);
return;
}
const isActive = sub.status === 'active' || sub.status === 'trialing';
const startDate = new Date(periodStart * 1000).toISOString().slice(0, 10);
const endDate = new Date(periodEnd * 1000).toISOString().slice(0, 10);
// Deactivate prior active rows for same user (different stripe_sub_id)
await pool.query(
`UPDATE bos_sysadmin.subscription SET is_active = false, deactivation_date = CURRENT_DATE, updated_time = CURRENT_DATE
WHERE internet_user_id = $1 AND is_active = true AND stripe_subscription_id IS DISTINCT FROM $2`,
[userId, sub.id]
);
// SELECT-then-UPDATE-or-INSERT (avoids ON CONFLICT issues with partial unique indexes)
const existing = await pool.query(
`SELECT subscription_id FROM bos_sysadmin.subscription WHERE stripe_subscription_id = $1 LIMIT 1`,
[sub.id]
);
if (existing.rows.length > 0) {
await pool.query(
`UPDATE bos_sysadmin.subscription SET
subscription_plan_id = $2,
subscription_status = $3,
is_active = $4,
activation_date = $5,
deactivation_date = $6,
stripe_status = $7,
updated_time = CURRENT_DATE
WHERE subscription_id = $1`,
[existing.rows[0].subscription_id, plan.subscription_plan_id, isActive ? 1 : 4, isActive, startDate, endDate, sub.status]
);
} else {
await pool.query(
`INSERT INTO bos_sysadmin.subscription
(subscription_id, internet_user_id, subscription_plan_id, subscription_status, is_active,
activation_date, deactivation_date, created_time, updated_time,
stripe_subscription_id, stripe_status)
VALUES (
(SELECT COALESCE(MAX(subscription_id),0)+1 FROM bos_sysadmin.subscription),
$1, $2, $3, $4, $5, $6, CURRENT_DATE, CURRENT_DATE, $7, $8
)`,
[userId, plan.subscription_plan_id, isActive ? 1 : 4, isActive, startDate, endDate, sub.id, sub.status]
);
}
// Refill credits + apply storage limit on subscription create OR period renewal.
if (isActive) {
await pool.query(
`UPDATE bos_sysadmin.internet_user
SET credits_remained = $1,
storage_limit_bytes = $2
WHERE internet_user_id = $3`,
[
plan.credits_per_cycle,
plan.storage_limit_gb < 0 ? 1099511627776 : plan.storage_limit_gb * 1073741824,
userId,
]
);
log.info(`[stripe-webhook] credits refilled for user ${userId}: ${plan.credits_per_cycle}, storage=${plan.storage_limit_gb}GB`);
// Send welcome email only on initial create (not every period rollover)
if (existing.rows.length === 0) {
void sendSubscriptionCreatedEmail(userId, plan, sub, item).catch(err =>
log.error(`[stripe-webhook] failed to send welcome email: ${err.message}`)
);
}
}
log.info(`[stripe-webhook] subscription ${sub.id} synced: user=${userId}, plan=${plan.subscription_plan_id}, status=${sub.status}`);
}
async function sendSubscriptionCreatedEmail(
userId: number,
plan: any,
sub: Stripe.Subscription,
item: any
): Promise<void> {
const r = await pool.query(
`SELECT uc.email, p.prenume AS first_name
FROM bos_sysadmin.user_credential uc
JOIN bos_sysadmin.internet_user iu ON iu.internet_user_id = uc.internet_user_id
LEFT JOIN bos_subscriber.persoana_fizica p ON p.individual_id = iu.person_id
WHERE uc.internet_user_id = $1`,
[userId]
);
const u = r.rows[0];
if (!u?.email) return;
const price = item?.price;
const currency = (price?.currency || 'eur').toUpperCase();
const amount = (price?.unit_amount || 0) / 100;
const symbol = currency === 'EUR' ? '€' : currency === 'USD' ? '$' : currency + ' ';
const interval: 'month' | 'year' = price?.recurring?.interval === 'year' ? 'year' : 'month';
const periodEnd = item?.current_period_end ? new Date(item.current_period_end * 1000).toISOString().slice(0, 10) : undefined;
const tpl = subscriptionCreatedEmail({
userName: u.first_name || undefined,
planName: plan.plan_name,
billingInterval: interval,
priceFormatted: `${symbol}${amount.toFixed(2)}`,
nextRenewalDate: periodEnd,
creditsPerCycle: plan.credits_per_cycle,
storageGb: plan.storage_limit_gb,
});
await sendEmail({ to: u.email, ...tpl });
}
async function handleSubscriptionDeleted(sub: Stripe.Subscription): Promise<void> {
await pool.query(
`UPDATE bos_sysadmin.subscription
SET is_active = false, deactivation_date = CURRENT_DATE, stripe_status = $2, updated_time = CURRENT_DATE
WHERE stripe_subscription_id = $1`,
[sub.id, sub.status]
);
// Notify user
const r = await pool.query(
`SELECT uc.email, p.prenume AS first_name, sp.plan_name
FROM bos_sysadmin.subscription s
JOIN bos_sysadmin.user_credential uc ON uc.internet_user_id = s.internet_user_id
JOIN bos_sysadmin.internet_user iu ON iu.internet_user_id = uc.internet_user_id
LEFT JOIN bos_subscriber.persoana_fizica p ON p.individual_id = iu.person_id
LEFT JOIN bos_sysadmin.subscription_plan sp ON sp.subscription_plan_id = s.subscription_plan_id
WHERE s.stripe_subscription_id = $1
LIMIT 1`,
[sub.id]
);
const u = r.rows[0];
if (u?.email) {
const cancelAt = (sub as any).cancel_at;
const tpl = subscriptionCancelledEmail({
userName: u.first_name,
planName: u.plan_name || 'Premium',
activeUntil: cancelAt ? new Date(cancelAt * 1000).toISOString().slice(0, 10) : undefined,
});
void sendEmail({ to: u.email, ...tpl }).catch(err =>
log.error(`[stripe-webhook] failed to send cancel email: ${err.message}`)
);
}
log.info(`[stripe-webhook] subscription ${sub.id} cancelled`);
}
async function handleTrialWillEnd(sub: Stripe.Subscription): Promise<void> {
const trialEnd = (sub as any).trial_end;
if (!trialEnd) return;
const daysLeft = Math.max(0, Math.round((trialEnd * 1000 - Date.now()) / 86400000));
const customerId = typeof sub.customer === 'string' ? sub.customer : sub.customer.id;
const r = await pool.query(
`SELECT uc.email, p.prenume AS first_name, sp.plan_name
FROM bos_sysadmin.internet_user iu
JOIN bos_sysadmin.user_credential uc ON uc.internet_user_id = iu.internet_user_id
LEFT JOIN bos_subscriber.persoana_fizica p ON p.individual_id = iu.person_id
LEFT JOIN bos_sysadmin.subscription s ON s.internet_user_id = iu.internet_user_id AND s.is_active = true
LEFT JOIN bos_sysadmin.subscription_plan sp ON sp.subscription_plan_id = s.subscription_plan_id
WHERE iu.stripe_customer_id = $1
LIMIT 1`,
[customerId]
);
const u = r.rows[0];
if (u?.email) {
const tpl = trialEndingEmail({
userName: u.first_name,
planName: u.plan_name || 'Premium',
daysLeft,
});
void sendEmail({ to: u.email, ...tpl }).catch(err =>
log.error(`[stripe-webhook] failed to send trial-ending email: ${err.message}`)
);
}
log.info(`[stripe-webhook] trial ending in ${daysLeft}d for ${sub.id}`);
}
async function handlePaymentSucceeded(invoice: Stripe.Invoice): Promise<void> {
// Audit-only: refill happens in handleSubscriptionUpsert which is the
// single source of truth for plan/credit state. This avoids race conditions
// where invoice.payment_succeeded arrives before customer.subscription.created
// (Stripe doesn't guarantee event order).
const customerId = typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id;
const reason = (invoice as any).billing_reason;
log.info(`[stripe-webhook] invoice ${invoice.id} paid (customer=${customerId}, reason=${reason}, amount=${invoice.amount_paid / 100} ${invoice.currency})`);
}
async function handlePaymentFailed(invoice: Stripe.Invoice): Promise<void> {
log.warn(`[stripe-webhook] invoice ${invoice.id} payment failed (customer=${invoice.customer})`);
const customerId = typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id;
if (!customerId) return;
const r = await pool.query(
`SELECT uc.email, p.prenume AS first_name, sp.plan_name
FROM bos_sysadmin.internet_user iu
JOIN bos_sysadmin.user_credential uc ON uc.internet_user_id = iu.internet_user_id
LEFT JOIN bos_subscriber.persoana_fizica p ON p.individual_id = iu.person_id
LEFT JOIN bos_sysadmin.subscription s ON s.internet_user_id = iu.internet_user_id AND s.is_active = true
LEFT JOIN bos_sysadmin.subscription_plan sp ON sp.subscription_plan_id = s.subscription_plan_id
WHERE iu.stripe_customer_id = $1
LIMIT 1`,
[customerId]
);
const u = r.rows[0];
if (!u?.email) return;
// Build a portal session for the user to update their card
let portalUrl = `${process.env.PUBLIC_APP_URL || 'https://didi365.eu'}/dashboard?section=settings`;
try {
const portal = await getStripe().billingPortal.sessions.create({
customer: customerId,
return_url: `${process.env.PUBLIC_APP_URL || 'https://didi365.eu'}/dashboard`,
});
portalUrl = portal.url;
} catch (err: any) {
log.warn(`[stripe-webhook] portal session create failed (portal not activated yet?): ${err.message}`);
}
const currency = (invoice.currency || 'eur').toUpperCase();
const symbol = currency === 'EUR' ? '€' : currency === 'USD' ? '$' : currency + ' ';
const tpl = paymentFailedEmail({
userName: u.first_name,
planName: u.plan_name || 'Premium',
amountFormatted: `${symbol}${(invoice.amount_due / 100).toFixed(2)}`,
portalUrl,
});
void sendEmail({ to: u.email, ...tpl }).catch(err =>
log.error(`[stripe-webhook] failed to send payment-failed email: ${err.message}`)
);
}
async function handleInvoiceUpcoming(invoice: Stripe.Invoice): Promise<void> {
// Hook for "your subscription renews in 7 days" email notification.
log.info(`[stripe-webhook] upcoming invoice for ${invoice.customer}: ${invoice.amount_due / 100} ${invoice.currency}`);
}
// ──────────────────────────────────────────────────────────────────────────────
// Idempotency persistence
// ──────────────────────────────────────────────────────────────────────────────
async function alreadyProcessed(eventId: string): Promise<boolean> {
const r = await pool.query(
`SELECT 1 FROM bos_sysadmin.stripe_event_log WHERE event_id = $1 AND status = 'processed' LIMIT 1`,
[eventId]
);
return r.rowCount! > 0;
}
async function markProcessed(event: Stripe.Event): Promise<void> {
await pool.query(
`INSERT INTO bos_sysadmin.stripe_event_log (event_id, event_type, status, payload_excerpt, processed_at)
VALUES ($1, $2, 'processed', $3, NOW())
ON CONFLICT (event_id) DO UPDATE SET status = 'processed', processed_at = NOW()`,
[event.id, event.type, JSON.stringify(event.data.object).slice(0, 2000)]
);
}
async function markFailed(event: Stripe.Event, errorMsg: string): Promise<void> {
await pool.query(
`INSERT INTO bos_sysadmin.stripe_event_log (event_id, event_type, status, error_message, payload_excerpt, processed_at)
VALUES ($1, $2, 'failed', $3, $4, NOW())
ON CONFLICT (event_id) DO UPDATE SET status = 'failed', error_message = $3, processed_at = NOW()`,
[event.id, event.type, errorMsg.slice(0, 500), JSON.stringify(event.data.object).slice(0, 2000)]
);
}
export default router;

View file

@ -0,0 +1,352 @@
/**
* Weights Routes - FULL CRUD
*
* All weight-related tables are leaf nodes (no children):
* - Component Weights
* - Weight Scenarios
* - Multipliers (topic, temporal, reach)
*/
import { Router, Request, Response } from 'express';
import { query, queryOne, transaction } from '../config/database';
import { ComponentWeight, WeightScenario, Multiplier, ApiResponse } from '../types';
import { PoolClient } from 'pg';
import { internalError } from '../config/error-response';
const router = Router();
// Helper functions
const createParameter = async (client: PoolClient, parameterType: number): Promise<number> => {
const maxResult = await client.query('SELECT COALESCE(MAX(parameter_id), 0) + 1 as next_id FROM parameter');
const nextParamId = maxResult.rows[0].next_id;
await client.query(`
INSERT INTO parameter (parameter_id, parameter_type, valid_from, valid_to, created_date, updated_date)
VALUES ($1, $2, '2026-01-01', '2999-01-01', CURRENT_DATE, CURRENT_DATE)
`, [nextParamId, parameterType]);
return nextParamId;
};
const getNextId = async (client: PoolClient, table: string, idColumn: string): Promise<number> => {
const result = await client.query(`SELECT COALESCE(MAX(${idColumn}), 0) + 1 as next_id FROM ${table}`);
return result.rows[0].next_id;
};
// ============================================================================
// COMPONENT WEIGHTS
// ============================================================================
router.get('/components', async (req: Request, res: Response) => {
try {
const weights = await query<ComponentWeight>(
'SELECT * FROM component_weight ORDER BY component_weight_id'
);
res.json({ success: true, data: weights, count: weights.length });
} catch (error) {
internalError(res, error);
}
});
router.get('/components/:id', async (req: Request, res: Response) => {
try {
const weight = await queryOne<ComponentWeight>(
'SELECT * FROM component_weight WHERE component_weight_id = $1',
[req.params.id]
);
if (!weight) {
return res.status(404).json({ success: false, error: 'Component weight nu a fost găsit' });
}
res.json({ success: true, data: weight });
} catch (error) {
internalError(res, error);
}
});
router.post('/components', async (req: Request, res: Response) => {
try {
const { component_name, component_weight, description } = req.body;
if (!component_name) {
return res.status(400).json({ success: false, error: 'Câmp obligatoriu: component_name' });
}
const result = await transaction(async (client) => {
const parameterId = await createParameter(client, 30);
const id = await getNextId(client, 'component_weight', 'component_weight_id');
const insertResult = await client.query(`
INSERT INTO component_weight (component_weight_id, component_name, component_weight, description, parameter_id)
VALUES ($1, $2, $3, $4, $5)
RETURNING *
`, [id, component_name, component_weight || 0, description || '', parameterId]);
return insertResult.rows[0];
});
res.status(201).json({ success: true, data: result, message: 'Component weight creat cu succes' });
} catch (error) {
internalError(res, error);
}
});
router.put('/components/:id', async (req: Request, res: Response) => {
try {
const { component_name, component_weight, description } = req.body;
const weight = await queryOne<ComponentWeight>(`
UPDATE component_weight
SET component_name = COALESCE($1, component_name),
component_weight = COALESCE($2, component_weight),
description = COALESCE($3, description)
WHERE component_weight_id = $4
RETURNING *
`, [component_name, component_weight, description, req.params.id]);
if (!weight) {
return res.status(404).json({ success: false, error: 'Component weight nu a fost găsit' });
}
res.json({ success: true, data: weight, message: 'Component weight actualizat cu succes' });
} catch (error) {
internalError(res, error);
}
});
router.delete('/components/:id', async (req: Request, res: Response) => {
try {
const result = await query('DELETE FROM component_weight WHERE component_weight_id = $1 RETURNING *', [req.params.id]);
if (result.length === 0) {
return res.status(404).json({ success: false, error: 'Component weight nu a fost găsit' });
}
res.json({ success: true, message: 'Component weight șters cu succes', deleted: true });
} catch (error) {
internalError(res, error);
}
});
// ============================================================================
// WEIGHT SCENARIOS
// ============================================================================
router.get('/scenarios', async (req: Request, res: Response) => {
try {
const scenarios = await query<WeightScenario>(
'SELECT * FROM weight_scenario ORDER BY scenario_id'
);
res.json({ success: true, data: scenarios, count: scenarios.length });
} catch (error) {
internalError(res, error);
}
});
router.get('/scenarios/:id', async (req: Request, res: Response) => {
try {
const scenario = await queryOne<WeightScenario>(
'SELECT * FROM weight_scenario WHERE scenario_id = $1',
[req.params.id]
);
if (!scenario) {
return res.status(404).json({ success: false, error: 'Weight scenario nu a fost găsit' });
}
res.json({ success: true, data: scenario });
} catch (error) {
internalError(res, error);
}
});
router.post('/scenarios', async (req: Request, res: Response) => {
try {
const { scenario_name, manipulation, claims, source, ai, context, notes } = req.body;
if (!scenario_name) {
return res.status(400).json({ success: false, error: 'Câmp obligatoriu: scenario_name' });
}
const result = await transaction(async (client) => {
const parameterId = await createParameter(client, 31);
const id = await getNextId(client, 'weight_scenario', 'scenario_id');
const insertResult = await client.query(`
INSERT INTO weight_scenario (scenario_id, scenario_name, manipulation, claims, source, ai, context, notes, parameter_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING *
`, [id, scenario_name, manipulation || 0, claims || 0, source || 0, ai || 0, context || 0, notes || '', parameterId]);
return insertResult.rows[0];
});
res.status(201).json({ success: true, data: result, message: 'Weight scenario creat cu succes' });
} catch (error) {
internalError(res, error);
}
});
router.put('/scenarios/:id', async (req: Request, res: Response) => {
try {
const { scenario_name, manipulation, claims, source, ai, context, notes } = req.body;
const scenario = await queryOne<WeightScenario>(`
UPDATE weight_scenario
SET scenario_name = COALESCE($1, scenario_name),
manipulation = COALESCE($2, manipulation),
claims = COALESCE($3, claims),
source = COALESCE($4, source),
ai = COALESCE($5, ai),
context = COALESCE($6, context),
notes = COALESCE($7, notes)
WHERE scenario_id = $8
RETURNING *
`, [scenario_name, manipulation, claims, source, ai, context, notes, req.params.id]);
if (!scenario) {
return res.status(404).json({ success: false, error: 'Weight scenario nu a fost găsit' });
}
res.json({ success: true, data: scenario, message: 'Weight scenario actualizat cu succes' });
} catch (error) {
internalError(res, error);
}
});
router.delete('/scenarios/:id', async (req: Request, res: Response) => {
try {
const result = await query('DELETE FROM weight_scenario WHERE scenario_id = $1 RETURNING *', [req.params.id]);
if (result.length === 0) {
return res.status(404).json({ success: false, error: 'Weight scenario nu a fost găsit' });
}
res.json({ success: true, message: 'Weight scenario șters cu succes', deleted: true });
} catch (error) {
internalError(res, error);
}
});
// ============================================================================
// MULTIPLIERS
// ============================================================================
router.get('/multipliers', async (req: Request, res: Response) => {
try {
const multipliers = await query<Multiplier>(
'SELECT * FROM multiplier ORDER BY multiplier_type, multiplier_id'
);
res.json({ success: true, data: multipliers, count: multipliers.length });
} catch (error) {
internalError(res, error);
}
});
router.get('/multipliers/type/:type', async (req: Request, res: Response) => {
try {
// multiplier_type e integer în DB — validăm ca să răspundem 400, nu 500
if (!/^\d+$/.test(req.params.type)) {
return res.status(400).json({
success: false,
error: 'Parametrul type trebuie să fie numeric (multiplier_type: 1, 2, 3)'
});
}
const multipliers = await query<Multiplier>(
'SELECT * FROM multiplier WHERE multiplier_type = $1 ORDER BY multiplier_id',
[req.params.type]
);
res.json({ success: true, data: multipliers, count: multipliers.length });
} catch (error) {
internalError(res, error);
}
});
router.get('/multipliers/:id', async (req: Request, res: Response) => {
try {
const multiplier = await queryOne<Multiplier>(
'SELECT * FROM multiplier WHERE multiplier_id = $1',
[req.params.id]
);
if (!multiplier) {
return res.status(404).json({ success: false, error: 'Multiplier nu a fost găsit' });
}
res.json({ success: true, data: multiplier });
} catch (error) {
internalError(res, error);
}
});
router.post('/multipliers', async (req: Request, res: Response) => {
try {
const { multiplier_type, multiplier_name, description, multiplier } = req.body;
if (!multiplier_name || multiplier_type === undefined) {
return res.status(400).json({ success: false, error: 'Câmpuri obligatorii: multiplier_type, multiplier_name' });
}
const result = await transaction(async (client) => {
const parameterId = await createParameter(client, 32);
const id = await getNextId(client, 'multiplier', 'multiplier_id');
const insertResult = await client.query(`
INSERT INTO multiplier (multiplier_id, multiplier_type, multiplier_name, description, multiplier, parameter_id)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *
`, [id, multiplier_type, multiplier_name, description || '', multiplier || 1, parameterId]);
return insertResult.rows[0];
});
res.status(201).json({ success: true, data: result, message: 'Multiplier creat cu succes' });
} catch (error) {
internalError(res, error);
}
});
router.put('/multipliers/:id', async (req: Request, res: Response) => {
try {
const { multiplier_type, multiplier_name, description, multiplier } = req.body;
const result = await queryOne<Multiplier>(`
UPDATE multiplier
SET multiplier_type = COALESCE($1, multiplier_type),
multiplier_name = COALESCE($2, multiplier_name),
description = COALESCE($3, description),
multiplier = COALESCE($4, multiplier)
WHERE multiplier_id = $5
RETURNING *
`, [multiplier_type, multiplier_name, description, multiplier, req.params.id]);
if (!result) {
return res.status(404).json({ success: false, error: 'Multiplier nu a fost găsit' });
}
res.json({ success: true, data: result, message: 'Multiplier actualizat cu succes' });
} catch (error) {
internalError(res, error);
}
});
router.delete('/multipliers/:id', async (req: Request, res: Response) => {
try {
const result = await query('DELETE FROM multiplier WHERE multiplier_id = $1 RETURNING *', [req.params.id]);
if (result.length === 0) {
return res.status(404).json({ success: false, error: 'Multiplier nu a fost găsit' });
}
res.json({ success: true, message: 'Multiplier șters cu succes', deleted: true });
} catch (error) {
internalError(res, error);
}
});
// ============================================================================
// COMBINED: GET ALL WEIGHTS DATA
// ============================================================================
router.get('/all', async (req: Request, res: Response) => {
try {
const [components, scenarios, multipliers] = await Promise.all([
query<ComponentWeight>('SELECT * FROM component_weight ORDER BY component_weight_id'),
query<WeightScenario>('SELECT * FROM weight_scenario ORDER BY scenario_id'),
query<Multiplier>('SELECT * FROM multiplier ORDER BY multiplier_type, multiplier_id'),
]);
res.json({
success: true,
data: {
componentWeights: components,
weightScenarios: scenarios,
multipliers: multipliers,
},
counts: {
componentWeights: components.length,
weightScenarios: scenarios.length,
multipliers: multipliers.length,
total: components.length + scenarios.length + multipliers.length,
}
});
} catch (error) {
internalError(res, error);
}
});
export default router;