/** * 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 { 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;