553 lines
23 KiB
TypeScript
553 lines
23 KiB
TypeScript
/**
|
|
* 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;
|