Files
Art-gallery/server/routes/translations.js
T
Danila KhodjaefandCursor ca58c43648 Add Russian i18n with DB translations, locale API, and curator review UI.
UI chrome via react-i18next, catalog text in entity_translations with ru.wikipedia seeding, locale-aware search, and Translations page for publish workflow.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 11:51:46 +03:00

222 lines
7.3 KiB
JavaScript

const express = require('express');
const pool = require('../db');
const { requireCurator } = 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', requireCurator, 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', requireCurator, 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('/', requireCurator, 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', requireCurator, 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', requireCurator, 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', requireCurator, 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;