feat: add Translation Management with approval workflow
All checks were successful
Deploy / deploy (push) Successful in 12s

- New Translations + TranslationTexts NocoDB tables (auto-created on restart)
- Full CRUD: list, create, update, delete, bulk-delete translations
- Translation texts per language (add/edit/delete inline)
- Review flow: submit-review generates public token link
- Public review page: shows source + all translations, approve/reject/revision
- Email notifications to approvers (registered users)
- Sidebar nav under Marketing category
- Bilingual i18n (80+ keys in en.json and ar.json)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
fahed
2026-03-11 14:49:04 +03:00
parent 14751c42e4
commit b17108b321
9 changed files with 1962 additions and 11 deletions

View File

@@ -85,8 +85,10 @@ const t = {
// Types
post: { en: 'post', ar: 'منشور' },
artefact: { en: 'artefact', ar: 'قطعة إبداعية' },
Post: { en: 'Post', ar: 'المنشور' },
Artefact: { en: 'Artefact', ar: 'القطعة الإبداعية' },
Post: { en: 'Post', ar: 'المنشور' },
Artefact: { en: 'Artefact', ar: 'القطعة الإبداعية' },
translation: { en: 'translation', ar: 'ترجمة' },
Translation: { en: 'Translation', ar: 'الترجمة' },
// Generic
view: { en: 'View', ar: 'عرض' },
@@ -192,14 +194,14 @@ function notifyApproved({ type, record, approverName }) {
getUser(creatorId).then(user => {
if (!user) return;
const l = user.lang;
const typeLabel = tr(type === 'post' ? 'Post' : 'Artefact', l);
const typeLabel = tr(type === 'post' ? 'Post' : type === 'translation' ? 'Translation' : 'Artefact', l);
send({
to: user.email, lang: l,
subject: `${tr('approved', l)}: ${title}`,
heading: tr('approvedHeading', l)(typeLabel),
bodyHtml: `<p>${tr('approvedBody', l)(title, approverName || (l === 'ar' ? 'مراجع' : 'a reviewer'))}</p>`,
ctaText: `${tr('view', l)} ${typeLabel}`,
ctaUrl: `${APP_URL}/${type === 'post' ? 'posts' : 'artefacts'}`,
ctaUrl: `${APP_URL}/${type === 'post' ? 'posts' : type === 'translation' ? 'translations' : 'artefacts'}`,
});
});
}
@@ -213,7 +215,7 @@ function notifyRejected({ type, record, approverName, feedback }) {
getUser(creatorId).then(user => {
if (!user) return;
const l = user.lang;
const typeLabel = tr(type === 'post' ? 'Post' : 'Artefact', l);
const typeLabel = tr(type === 'post' ? 'Post' : type === 'translation' ? 'Translation' : 'Artefact', l);
send({
to: user.email, lang: l,
subject: `${tr('needsChanges', l)}: ${title}`,
@@ -222,16 +224,18 @@ function notifyRejected({ type, record, approverName, feedback }) {
<p>${tr('rejectedBody', l)(title, approverName || (l === 'ar' ? 'مراجع' : 'a reviewer'))}</p>
${feedback ? `<blockquote ${BLOCKQUOTE}>${feedback}</blockquote>` : ''}`,
ctaText: `${tr('view', l)} ${typeLabel}`,
ctaUrl: `${APP_URL}/${type === 'post' ? 'posts' : 'artefacts'}`,
ctaUrl: `${APP_URL}/${type === 'post' ? 'posts' : type === 'translation' ? 'translations' : 'artefacts'}`,
});
});
}
// 4. Revision requested (artefact) → notify creator
function notifyRevisionRequested({ record, approverName, feedback }) {
function notifyRevisionRequested({ type, record, approverName, feedback }) {
const creatorId = record.created_by_user_id;
if (!creatorId) return;
const title = record.title || 'Untitled';
const entityType = type === 'translation' ? 'Translation' : 'Artefact';
const entityPath = type === 'translation' ? 'translations' : 'artefacts';
getUser(creatorId).then(user => {
if (!user) return;
@@ -243,8 +247,8 @@ function notifyRevisionRequested({ record, approverName, feedback }) {
bodyHtml: `
<p>${tr('revisionRequestedBody', l)(title, approverName || (l === 'ar' ? 'مراجع' : 'a reviewer'))}</p>
${feedback ? `<blockquote ${BLOCKQUOTE}>${feedback}</blockquote>` : ''}`,
ctaText: `${tr('view', l)} ${tr('Artefact', l)}`,
ctaUrl: `${APP_URL}/artefacts`,
ctaText: `${tr('view', l)} ${tr(entityType, l)}`,
ctaUrl: `${APP_URL}/${entityPath}`,
});
});
}

View File

@@ -158,6 +158,8 @@ const FK_COLUMNS = {
PostVersionTexts: ['version_id'],
Issues: ['brand_id', 'assigned_to_id', 'team_id'],
Users: ['role_id'],
Translations: ['brand_id', 'created_by_user_id'],
TranslationTexts: ['translation_id'],
};
// Maps link column names to FK field names for migration
@@ -421,6 +423,27 @@ const REQUIRED_TABLES = {
{ title: 'name', uidt: 'SingleLineText' },
{ title: 'color', uidt: 'SingleLineText' },
],
Translations: [
{ title: 'title', uidt: 'SingleLineText' },
{ title: 'description', uidt: 'LongText' },
{ title: 'source_language', uidt: 'SingleLineText' },
{ title: 'source_content', uidt: 'LongText' },
{ title: 'status', uidt: 'SingleSelect', dtxp: "'draft','pending_review','approved','rejected','revision_requested'" },
{ title: 'brand_id', uidt: 'Number' },
{ title: 'approver_ids', uidt: 'SingleLineText' },
{ title: 'approval_token', uidt: 'SingleLineText' },
{ title: 'token_expires_at', uidt: 'DateTime' },
{ title: 'approved_by_name', uidt: 'SingleLineText' },
{ title: 'approved_at', uidt: 'DateTime' },
{ title: 'feedback', uidt: 'LongText' },
{ title: 'created_by_user_id', uidt: 'Number' },
],
TranslationTexts: [
{ title: 'translation_id', uidt: 'Number' },
{ title: 'language_code', uidt: 'SingleLineText' },
{ title: 'language_label', uidt: 'SingleLineText' },
{ title: 'content', uidt: 'LongText' },
],
};
async function ensureRequiredTables() {
@@ -4299,6 +4322,406 @@ app.post('/api/public/review/:token/comment', async (req, res) => {
}
});
// ─── TRANSLATION MANAGEMENT API ──────────────────────────────────
// List translations
app.get('/api/translations', requireAuth, async (req, res) => {
try {
const { brand, status } = req.query;
const whereParts = [];
if (brand) whereParts.push(`(brand_id,eq,${sanitizeWhereValue(brand)})`);
if (status) whereParts.push(`(status,eq,${sanitizeWhereValue(status)})`);
const where = whereParts.length > 0 ? whereParts.join('~and') : undefined;
let translations = await nocodb.list('Translations', { where, sort: '-UpdatedAt', limit: QUERY_LIMITS.medium });
// Visibility filtering
const userId = req.session.userId;
if (req.session.userRole === 'contributor') {
translations = translations.filter(t => t.created_by_user_id === userId);
}
// Enrich with names
const brandIds = new Set(), userIds = new Set();
for (const t of translations) {
if (t.brand_id) brandIds.add(t.brand_id);
if (t.created_by_user_id) userIds.add(t.created_by_user_id);
if (t.approver_ids) {
for (const id of t.approver_ids.split(',').map(s => s.trim()).filter(Boolean)) {
userIds.add(Number(id));
}
}
}
const names = await batchResolveNames({
brand: { table: 'Brands', ids: [...brandIds] },
user: { table: 'Users', ids: [...userIds] },
});
// Count translation texts per record
const textCounts = {};
try {
const allTexts = await nocodb.list('TranslationTexts', { limit: QUERY_LIMITS.large });
for (const tt of allTexts) {
textCounts[tt.translation_id] = (textCounts[tt.translation_id] || 0) + 1;
}
} catch (e) { /* table may not exist yet */ }
res.json(translations.map(t => {
const approverIdList = t.approver_ids ? t.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [];
return {
...t,
brand_name: names[`brand:${t.brand_id}`] || null,
creator_name: names[`user:${t.created_by_user_id}`] || null,
approvers: approverIdList.map(id => ({ id: Number(id), name: names[`user:${Number(id)}`] || null })),
translation_count: textCounts[t.Id] || 0,
};
}));
} catch (err) {
console.error('GET /translations error:', err);
res.status(500).json({ error: 'Failed to load translations' });
}
});
// Create translation
app.post('/api/translations', requireAuth, async (req, res) => {
const { title, description, source_language, source_content, brand_id, approver_ids } = req.body;
if (!title) return res.status(400).json({ error: 'Title is required' });
if (!source_language) return res.status(400).json({ error: 'Source language is required' });
if (!source_content) return res.status(400).json({ error: 'Source content is required' });
try {
const created = await nocodb.create('Translations', {
title,
description: description || null,
source_language,
source_content,
status: 'draft',
brand_id: brand_id ? Number(brand_id) : null,
approver_ids: approver_ids || null,
created_by_user_id: req.session.userId,
});
const record = await nocodb.get('Translations', created.Id);
const approverIdList = record.approver_ids ? record.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [];
const approvers = [];
for (const id of approverIdList) {
approvers.push({ id: Number(id), name: await getRecordName('Users', Number(id)) });
}
res.status(201).json({
...record,
brand_name: await getRecordName('Brands', record.brand_id),
creator_name: await getRecordName('Users', record.created_by_user_id),
approvers,
translation_count: 0,
});
} catch (err) {
console.error('Create translation error:', err);
res.status(500).json({ error: 'Failed to create translation' });
}
});
// Bulk delete translations (BEFORE /:id)
app.post('/api/translations/bulk-delete', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => {
try {
const { ids } = req.body;
if (!Array.isArray(ids) || ids.length === 0) return res.status(400).json({ error: 'ids array required' });
for (const id of ids) {
const texts = await nocodb.list('TranslationTexts', { where: `(translation_id,eq,${sanitizeWhereValue(id)})`, limit: QUERY_LIMITS.large });
if (texts.length > 0) await nocodb.bulkDelete('TranslationTexts', texts.map(t => ({ Id: t.Id })));
}
await nocodb.bulkDelete('Translations', ids.map(id => ({ Id: id })));
res.json({ deleted: ids.length });
} catch (err) {
console.error('Bulk delete translations error:', err);
res.status(500).json({ error: 'Failed to bulk delete translations' });
}
});
// Update translation
app.patch('/api/translations/:id', requireAuth, async (req, res) => {
try {
const existing = await nocodb.get('Translations', req.params.id);
if (!existing) return res.status(404).json({ error: 'Translation not found' });
if (req.session.userRole === 'contributor' && existing.created_by_user_id !== req.session.userId) {
return res.status(403).json({ error: 'You can only modify your own translations' });
}
const data = {};
for (const f of ['title', 'description', 'source_language', 'source_content', 'status', 'feedback']) {
if (req.body[f] !== undefined) data[f] = req.body[f];
}
if (req.body.brand_id !== undefined) data.brand_id = req.body.brand_id ? Number(req.body.brand_id) : null;
if (req.body.approver_ids !== undefined) data.approver_ids = req.body.approver_ids || null;
if (Object.keys(data).length === 0) return res.status(400).json({ error: 'No fields to update' });
await nocodb.update('Translations', req.params.id, data);
const record = await nocodb.get('Translations', req.params.id);
const approverIdList = record.approver_ids ? record.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [];
const approvers = [];
for (const id of approverIdList) {
approvers.push({ id: Number(id), name: await getRecordName('Users', Number(id)) });
}
res.json({
...record,
brand_name: await getRecordName('Brands', record.brand_id),
creator_name: await getRecordName('Users', record.created_by_user_id),
approvers,
});
} catch (err) {
console.error('Update translation error:', err);
res.status(500).json({ error: 'Failed to update translation' });
}
});
// Delete translation
app.delete('/api/translations/:id', requireAuth, async (req, res) => {
try {
const existing = await nocodb.get('Translations', req.params.id);
if (!existing) return res.status(404).json({ error: 'Translation not found' });
if (req.session.userRole === 'contributor' && existing.created_by_user_id !== req.session.userId) {
return res.status(403).json({ error: 'You can only delete your own translations' });
}
// Cascade delete translation texts
const texts = await nocodb.list('TranslationTexts', { where: `(translation_id,eq,${sanitizeWhereValue(req.params.id)})`, limit: QUERY_LIMITS.large });
if (texts.length > 0) await nocodb.bulkDelete('TranslationTexts', texts.map(t => ({ Id: t.Id })));
await nocodb.delete('Translations', req.params.id);
res.json({ success: true });
} catch (err) {
console.error('Delete translation error:', err);
res.status(500).json({ error: 'Failed to delete translation' });
}
});
// List translation texts
app.get('/api/translations/:id/texts', requireAuth, async (req, res) => {
try {
const texts = await nocodb.list('TranslationTexts', {
where: `(translation_id,eq,${sanitizeWhereValue(req.params.id)})`,
limit: QUERY_LIMITS.large,
});
res.json(texts);
} catch (err) {
console.error('GET translation texts error:', err);
res.status(500).json({ error: 'Failed to load translation texts' });
}
});
// Add/update translation text
app.post('/api/translations/:id/texts', requireAuth, async (req, res) => {
const { language_code, language_label, content } = req.body;
if (!language_code || !content) return res.status(400).json({ error: 'Language code and content are required' });
try {
const translation = await nocodb.get('Translations', req.params.id);
if (!translation) return res.status(404).json({ error: 'Translation not found' });
// Check if text for this language already exists (upsert)
const existing = await nocodb.list('TranslationTexts', {
where: `(translation_id,eq,${sanitizeWhereValue(req.params.id)})~and(language_code,eq,${sanitizeWhereValue(language_code)})`,
limit: 1,
});
let result;
if (existing.length > 0) {
await nocodb.update('TranslationTexts', existing[0].Id, { content, language_label: language_label || language_code });
result = await nocodb.get('TranslationTexts', existing[0].Id);
} else {
result = await nocodb.create('TranslationTexts', {
translation_id: Number(req.params.id),
language_code,
language_label: language_label || language_code,
content,
});
}
res.json(result);
} catch (err) {
console.error('Add/update translation text error:', err);
res.status(500).json({ error: 'Failed to save translation text' });
}
});
// Delete translation text
app.delete('/api/translations/:id/texts/:textId', requireAuth, async (req, res) => {
try {
await nocodb.delete('TranslationTexts', req.params.textId);
res.json({ success: true });
} catch (err) {
console.error('Delete translation text error:', err);
res.status(500).json({ error: 'Failed to delete translation text' });
}
});
// Submit translation for review
app.post('/api/translations/:id/submit-review', requireAuth, async (req, res) => {
try {
const existing = await nocodb.get('Translations', req.params.id);
if (!existing) return res.status(404).json({ error: 'Translation not found' });
if (req.session.userRole === 'contributor' && existing.created_by_user_id !== req.session.userId) {
return res.status(403).json({ error: 'You can only submit your own translations' });
}
const token = require('crypto').randomUUID();
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + DEFAULTS.tokenExpiryDays);
await nocodb.update('Translations', req.params.id, {
status: 'pending_review',
approval_token: token,
token_expires_at: expiresAt.toISOString(),
});
const reviewUrl = `${req.protocol}://${req.get('host')}/review-translation/${token}`;
res.json({ success: true, token, reviewUrl, expiresAt: expiresAt.toISOString() });
notify.notifyReviewSubmitted({ type: 'translation', record: existing, reviewUrl });
} catch (err) {
console.error('Submit translation review error:', err);
res.status(500).json({ error: 'Failed to submit for review' });
}
});
// Public: Get translation for review
app.get('/api/public/review-translation/:token', async (req, res) => {
try {
const translations = await nocodb.list('Translations', {
where: `(approval_token,eq,${sanitizeWhereValue(req.params.token)})`,
limit: 1,
});
if (translations.length === 0) {
return res.status(404).json({ error: 'Review link not found or expired' });
}
const translation = translations[0];
if (translation.token_expires_at) {
const expiresAt = new Date(translation.token_expires_at);
if (expiresAt < new Date()) {
return res.status(410).json({ error: 'Review link has expired' });
}
}
// Get all translation texts
const texts = await nocodb.list('TranslationTexts', {
where: `(translation_id,eq,${translation.Id})`,
limit: QUERY_LIMITS.large,
});
// Resolve approver names
const approvers = [];
if (translation.approver_ids) {
for (const id of translation.approver_ids.split(',').filter(Boolean)) {
approvers.push({ id: Number(id), name: await getRecordName('Users', Number(id)) });
}
}
res.json({
...translation,
brand_name: await getRecordName('Brands', translation.brand_id),
creator_name: await getRecordName('Users', translation.created_by_user_id),
approvers,
texts,
});
} catch (err) {
console.error('Public translation review fetch error:', err);
res.status(500).json({ error: 'Failed to load translation for review' });
}
});
// Public: Approve translation
app.post('/api/public/review-translation/:token/approve', async (req, res) => {
const { approved_by_name } = req.body;
try {
const translations = await nocodb.list('Translations', {
where: `(approval_token,eq,${sanitizeWhereValue(req.params.token)})`,
limit: 1,
});
if (translations.length === 0) return res.status(404).json({ error: 'Review link not found' });
const translation = translations[0];
if (translation.token_expires_at && new Date(translation.token_expires_at) < new Date()) {
return res.status(410).json({ error: 'Review link has expired' });
}
await nocodb.update('Translations', translation.Id, {
status: 'approved',
approved_by_name: approved_by_name || 'Anonymous',
approved_at: new Date().toISOString(),
});
res.json({ success: true, message: 'Translation approved successfully' });
notify.notifyApproved({ type: 'translation', record: translation, approverName: approved_by_name });
} catch (err) {
console.error('Approve translation error:', err);
res.status(500).json({ error: 'Failed to approve translation' });
}
});
// Public: Reject translation
app.post('/api/public/review-translation/:token/reject', async (req, res) => {
const { approved_by_name, feedback } = req.body;
try {
const translations = await nocodb.list('Translations', {
where: `(approval_token,eq,${sanitizeWhereValue(req.params.token)})`,
limit: 1,
});
if (translations.length === 0) return res.status(404).json({ error: 'Review link not found' });
const translation = translations[0];
if (translation.token_expires_at && new Date(translation.token_expires_at) < new Date()) {
return res.status(410).json({ error: 'Review link has expired' });
}
await nocodb.update('Translations', translation.Id, {
status: 'rejected',
approved_by_name: approved_by_name || 'Anonymous',
feedback: feedback || '',
});
res.json({ success: true, message: 'Translation rejected' });
notify.notifyRejected({ type: 'translation', record: translation, approverName: approved_by_name, feedback });
} catch (err) {
console.error('Reject translation error:', err);
res.status(500).json({ error: 'Failed to reject translation' });
}
});
// Public: Request revision on translation
app.post('/api/public/review-translation/:token/revision', async (req, res) => {
const { feedback, approved_by_name } = req.body;
try {
const translations = await nocodb.list('Translations', {
where: `(approval_token,eq,${sanitizeWhereValue(req.params.token)})`,
limit: 1,
});
if (translations.length === 0) return res.status(404).json({ error: 'Review link not found' });
const translation = translations[0];
if (translation.token_expires_at && new Date(translation.token_expires_at) < new Date()) {
return res.status(410).json({ error: 'Review link has expired' });
}
await nocodb.update('Translations', translation.Id, {
status: 'revision_requested',
approved_by_name: approved_by_name || '',
feedback: feedback || '',
});
res.json({ success: true, message: 'Revision requested' });
notify.notifyRevisionRequested({ record: translation, approverName: approved_by_name, feedback });
} catch (err) {
console.error('Translation revision request error:', err);
res.status(500).json({ error: 'Failed to request revision' });
}
});
// ─── ISSUE TRACKER API ──────────────────────────────────────────
// Internal: List issues with filters