feat: add Translation Management with approval workflow
All checks were successful
Deploy / deploy (push) Successful in 12s
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:
@@ -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}`,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
423
server/server.js
423
server/server.js
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user