livrare lot 2
This commit is contained in:
commit
8ecc78e729
763 changed files with 164593 additions and 0 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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}×tamps=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}×tamps=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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 să î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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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' });
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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';
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 (m25–m29)', 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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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';
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue