Staff accounts use admin/curator roles and fine-grained flags; transitions connect source-to-target with color gradients and stream cutout masks so overlaps stay seamless. Co-authored-by: Cursor <cursoragent@cursor.com>
222 lines
7.4 KiB
JavaScript
222 lines
7.4 KiB
JavaScript
const express = require('express');
|
|
const pool = require('../db');
|
|
const { requirePermission } = require('../middleware/auth');
|
|
const { logCuratorAction } = require('../audit-log');
|
|
const {
|
|
TRANSLATABLE_FIELDS,
|
|
getEntityCanonical,
|
|
listTranslations,
|
|
upsertTranslation,
|
|
getTranslationCoverage,
|
|
} = require('../translation-service');
|
|
|
|
const router = express.Router();
|
|
|
|
const VALID_ENTITY_TYPES = new Set(Object.keys(TRANSLATABLE_FIELDS));
|
|
|
|
router.get('/worklist/:entityType', requirePermission('translations'), async (req, res) => {
|
|
try {
|
|
const entityType = req.params.entityType;
|
|
const locale = typeof req.query.locale === 'string' ? req.query.locale : 'ru';
|
|
if (!VALID_ENTITY_TYPES.has(entityType)) {
|
|
return res.status(400).json({ error: 'Invalid entity type' });
|
|
}
|
|
|
|
const tables = {
|
|
artist: { table: 'artists', label: 'name', idCol: 'id' },
|
|
painting: { table: 'paintings', label: 'title', idCol: 'id' },
|
|
movement: { table: 'art_movements', label: 'name', idCol: 'id' },
|
|
};
|
|
const spec = tables[entityType];
|
|
if (!spec) {
|
|
return res.json({ items: [] });
|
|
}
|
|
|
|
const { rows } = await pool.query(
|
|
`SELECT id AS entity_id, ${spec.label} AS label FROM ${spec.table} ORDER BY ${spec.label} LIMIT 1000`,
|
|
);
|
|
|
|
const ids = rows.map((r) => r.entity_id);
|
|
const { rows: transRows } = ids.length
|
|
? await pool.query(
|
|
`SELECT entity_id, field_name, status
|
|
FROM entity_translations
|
|
WHERE entity_type = $1 AND entity_id = ANY($2::int[]) AND locale = $3`,
|
|
[entityType, ids, locale],
|
|
)
|
|
: { rows: [] };
|
|
|
|
const byEntity = new Map();
|
|
for (const tr of transRows) {
|
|
if (!byEntity.has(tr.entity_id)) byEntity.set(tr.entity_id, []);
|
|
byEntity.get(tr.entity_id).push(tr);
|
|
}
|
|
|
|
const fields = TRANSLATABLE_FIELDS[entityType] || [];
|
|
const items = rows.map((row) => {
|
|
const existing = byEntity.get(row.entity_id) || [];
|
|
const publishedCount = existing.filter((t) => t.status === 'published').length;
|
|
const draftCount = existing.filter((t) => t.status !== 'published').length;
|
|
const haveFields = new Set(existing.map((t) => t.field_name));
|
|
const missingFields = fields.filter((f) => !haveFields.has(f));
|
|
return {
|
|
entityId: row.entity_id,
|
|
label: row.label,
|
|
publishedCount,
|
|
draftCount,
|
|
missingFields,
|
|
};
|
|
});
|
|
|
|
res.json({ items });
|
|
} catch (err) {
|
|
console.error('Translation worklist error:', err.message);
|
|
res.status(500).json({ error: 'Failed to load worklist' });
|
|
}
|
|
});
|
|
|
|
router.get('/coverage', requirePermission('translations'), async (req, res) => {
|
|
try {
|
|
const locale = typeof req.query.locale === 'string' ? req.query.locale : 'ru';
|
|
const coverage = await getTranslationCoverage(locale);
|
|
res.json({ locale, coverage });
|
|
} catch (err) {
|
|
console.error('Translation coverage error:', err.message);
|
|
res.status(500).json({ error: 'Failed to load coverage' });
|
|
}
|
|
});
|
|
|
|
router.get('/', requirePermission('translations'), async (req, res) => {
|
|
try {
|
|
const entityType = typeof req.query.entityType === 'string' ? req.query.entityType : undefined;
|
|
const locale = typeof req.query.locale === 'string' ? req.query.locale : 'ru';
|
|
const status = typeof req.query.status === 'string' ? req.query.status : undefined;
|
|
const rows = await listTranslations({ entityType, locale, status });
|
|
res.json({ translations: rows });
|
|
} catch (err) {
|
|
console.error('List translations error:', err.message);
|
|
res.status(500).json({ error: 'Failed to list translations' });
|
|
}
|
|
});
|
|
|
|
router.get('/:entityType/:id', requirePermission('translations'), async (req, res) => {
|
|
try {
|
|
const entityType = req.params.entityType;
|
|
const entityId = parseInt(req.params.id, 10);
|
|
if (!VALID_ENTITY_TYPES.has(entityType) || !Number.isFinite(entityId)) {
|
|
return res.status(400).json({ error: 'Invalid entity type or id' });
|
|
}
|
|
|
|
const canonical = await getEntityCanonical(entityType, entityId);
|
|
if (!canonical) {
|
|
return res.status(404).json({ error: 'Entity not found' });
|
|
}
|
|
|
|
const { rows } = await pool.query(
|
|
`SELECT locale, field_name, value, status, source, updated_at
|
|
FROM entity_translations
|
|
WHERE entity_type = $1 AND entity_id = $2
|
|
ORDER BY locale, field_name`,
|
|
[entityType, entityId],
|
|
);
|
|
|
|
res.json({
|
|
entityType,
|
|
entityId,
|
|
canonical,
|
|
translatableFields: TRANSLATABLE_FIELDS[entityType],
|
|
translations: rows,
|
|
});
|
|
} catch (err) {
|
|
console.error('Get translation error:', err.message);
|
|
res.status(500).json({ error: 'Failed to load translation' });
|
|
}
|
|
});
|
|
|
|
router.put('/:entityType/:id', requirePermission('translations'), async (req, res) => {
|
|
try {
|
|
const entityType = req.params.entityType;
|
|
const entityId = parseInt(req.params.id, 10);
|
|
const locale = typeof req.body?.locale === 'string' ? req.body.locale : 'ru';
|
|
const fields = req.body?.fields;
|
|
const status = typeof req.body?.status === 'string' ? req.body.status : 'draft';
|
|
|
|
if (!VALID_ENTITY_TYPES.has(entityType) || !Number.isFinite(entityId)) {
|
|
return res.status(400).json({ error: 'Invalid entity type or id' });
|
|
}
|
|
if (!fields || typeof fields !== 'object') {
|
|
return res.status(400).json({ error: 'fields object required' });
|
|
}
|
|
|
|
const canonical = await getEntityCanonical(entityType, entityId);
|
|
if (!canonical) {
|
|
return res.status(404).json({ error: 'Entity not found' });
|
|
}
|
|
|
|
const allowed = new Set(TRANSLATABLE_FIELDS[entityType]);
|
|
const saved = [];
|
|
for (const [fieldName, value] of Object.entries(fields)) {
|
|
if (!allowed.has(fieldName) || typeof value !== 'string') continue;
|
|
const row = await upsertTranslation({
|
|
entityType,
|
|
entityId,
|
|
locale,
|
|
fieldName,
|
|
value,
|
|
status,
|
|
source: 'manual',
|
|
});
|
|
saved.push(row);
|
|
}
|
|
|
|
await logCuratorAction({
|
|
userId: req.curatorUser.id,
|
|
action: 'translation.upsert',
|
|
resourceType: entityType,
|
|
resourceId: entityId,
|
|
details: { locale, fieldCount: saved.length, status },
|
|
req,
|
|
});
|
|
|
|
res.json({ saved });
|
|
} catch (err) {
|
|
console.error('Upsert translation error:', err.message);
|
|
res.status(500).json({ error: 'Failed to save translation' });
|
|
}
|
|
});
|
|
|
|
router.post('/:entityType/:id/publish', requirePermission('translations'), async (req, res) => {
|
|
try {
|
|
const entityType = req.params.entityType;
|
|
const entityId = parseInt(req.params.id, 10);
|
|
const locale = typeof req.body?.locale === 'string' ? req.body.locale : 'ru';
|
|
|
|
if (!VALID_ENTITY_TYPES.has(entityType) || !Number.isFinite(entityId)) {
|
|
return res.status(400).json({ error: 'Invalid entity type or id' });
|
|
}
|
|
|
|
const { rowCount } = await pool.query(
|
|
`UPDATE entity_translations
|
|
SET status = 'published', updated_at = now()
|
|
WHERE entity_type = $1 AND entity_id = $2 AND locale = $3 AND status IN ('draft', 'reviewed')`,
|
|
[entityType, entityId, locale],
|
|
);
|
|
|
|
await logCuratorAction({
|
|
userId: req.curatorUser.id,
|
|
action: 'translation.publish',
|
|
resourceType: entityType,
|
|
resourceId: entityId,
|
|
details: { locale, updated: rowCount },
|
|
req,
|
|
});
|
|
|
|
res.json({ published: rowCount });
|
|
} catch (err) {
|
|
console.error('Publish translation error:', err.message);
|
|
res.status(500).json({ error: 'Failed to publish translations' });
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|