diff --git a/client/src/i18n/LanguageContext.jsx b/client/src/i18n/LanguageContext.jsx index ea29c43..a54b376 100644 --- a/client/src/i18n/LanguageContext.jsx +++ b/client/src/i18n/LanguageContext.jsx @@ -1,4 +1,5 @@ import { createContext, useContext, useState, useEffect } from 'react' +import api from '../utils/api' import en from './en.json' import ar from './ar.json' @@ -33,6 +34,7 @@ export function LanguageProvider({ children }) { if (newLang !== 'en' && newLang !== 'ar') return setLangState(newLang) localStorage.setItem('digitalhub-lang', newLang) + api.patch('/api/users/me/language', { language: newLang }).catch(() => {}) } const setCurrency = (code) => { diff --git a/server/notifications.js b/server/notifications.js new file mode 100644 index 0000000..05ccef9 --- /dev/null +++ b/server/notifications.js @@ -0,0 +1,397 @@ +// server/notifications.js — Bilingual email notification system +const { sendMail } = require('./mail'); +const nocodb = require('./nocodb'); +const { parseApproverIds } = require('./helpers'); + +const APP_URL = process.env.APP_URL || process.env.CORS_ORIGIN || 'http://localhost:3001'; +const APP_NAME_EN = "Samaya's Digital Hub"; +const APP_NAME_AR = 'المركز الرقمي لسمايا'; + +// ─── TRANSLATIONS ─────────────────────────────────────────────── + +const t = { + appName: { en: APP_NAME_EN, ar: APP_NAME_AR }, + automatedNotice: { en: `This is an automated notification from ${APP_NAME_EN}`, ar: `هذا إشعار تلقائي من ${APP_NAME_AR}` }, + + // Review + reviewRequested: { en: 'Review Requested', ar: 'طلب مراجعة' }, + reviewRequestedBody: { en: (type, title) => `You've been asked to review the ${type} "${title}".`, + ar: (type, title) => `تمت دعوتك لمراجعة ${type} "${title}".` }, + reviewFeedbackPrompt: { en: 'Please click the button below to review and provide your feedback.', + ar: 'يرجى الضغط على الزر أدناه للمراجعة وتقديم ملاحظاتك.' }, + reviewNow: { en: 'Review Now', ar: 'مراجعة الآن' }, + + // Approved + approved: { en: 'Approved', ar: 'تمت الموافقة' }, + approvedHeading: { en: (type) => `Your ${type} Has Been Approved`, + ar: (type) => `تمت الموافقة على ${type}` }, + approvedBody: { en: (title, name) => `Great news! "${title}" has been approved by ${name}.`, + ar: (title, name) => `أخبار رائعة! تمت الموافقة على "${title}" من قبل ${name}.` }, + + // Rejected + needsChanges: { en: 'Needs Changes', ar: 'يحتاج تعديلات' }, + rejectedHeading: { en: (type) => `Your ${type} Needs Changes`, + ar: (type) => `${type} يحتاج تعديلات` }, + rejectedBody: { en: (title, name) => `"${title}" was reviewed by ${name} and requires changes.`, + ar: (title, name) => `تمت مراجعة "${title}" من قبل ${name} ويحتاج إلى تعديلات.` }, + + // Revision + revisionRequested: { en: 'Revision Requested', ar: 'طلب تعديل' }, + revisionRequestedBody: { en: (title, name) => `"${title}" was reviewed by ${name} and a revision has been requested.`, + ar: (title, name) => `تمت مراجعة "${title}" من قبل ${name} وتم طلب تعديل.` }, + + // Task + taskAssigned: { en: 'Task Assigned', ar: 'تم تعيين مهمة' }, + taskAssignedHeading: { en: 'New Task Assigned to You', ar: 'مهمة جديدة مُسندة إليك' }, + taskAssignedBody: { en: (name) => `${name} assigned you a task:`, + ar: (name) => `قام ${name} بتعيين مهمة لك:` }, + taskCompleted: { en: 'Task Completed', ar: 'تم إنجاز المهمة' }, + taskCompletedHeading: { en: 'A Task You Created Has Been Completed', ar: 'تم إنجاز مهمة قمت بإنشائها' }, + taskCompletedBody: { en: (title) => `The task "${title}" has been marked as done.`, + ar: (title) => `تم وضع علامة "منجز" على المهمة "${title}".` }, + priority: { en: 'Priority', ar: 'الأولوية' }, + dueDate: { en: 'Due', ar: 'الاستحقاق' }, + category: { en: 'Category', ar: 'الفئة' }, + + // Issue + issueAssigned: { en: 'Issue Assigned', ar: 'تم تعيين مشكلة' }, + issueAssignedHeading: { en: 'Issue Assigned to You', ar: 'مشكلة مُسندة إليك' }, + issueAssignedBody: { en: (name) => `${name} assigned you an issue:`, + ar: (name) => `قام ${name} بتعيين مشكلة لك:` }, + issueUpdate: { en: 'Issue Update', ar: 'تحديث المشكلة' }, + issueUpdateHeading: { en: 'Your Issue Has Been Updated', ar: 'تم تحديث مشكلتك' }, + issueUpdateBody: { en: (title) => `The status of your issue "${title}" has changed:`, + ar: (title) => `تم تغيير حالة مشكلتك "${title}":` }, + resolution: { en: 'Resolution', ar: 'الحل' }, + trackIssue: { en: 'Track Your Issue', ar: 'تتبع مشكلتك' }, + + // Campaign + newCampaign: { en: 'New Campaign', ar: 'حملة جديدة' }, + campaignHeading: { en: 'A New Campaign Has Been Created', ar: 'تم إنشاء حملة جديدة' }, + campaignBody: { en: (name) => `A new campaign "${name}" has been created in your team.`, + ar: (name) => `تم إنشاء حملة جديدة "${name}" في فريقك.` }, + viewCampaign: { en: 'View Campaign', ar: 'عرض الحملة' }, + + // Welcome + welcome: { en: (name) => `Welcome, ${name}!`, ar: (name) => `!أهلاً بك، ${name}` }, + welcomeBody: { en: (inviter) => `${inviter} has invited you to ${APP_NAME_EN}.`, + ar: (inviter) => `قام ${inviter} بدعوتك إلى ${APP_NAME_AR}.` }, + emailLabel: { en: 'Email', ar: 'البريد الإلكتروني' }, + passwordLabel: { en: 'Password', ar: 'كلمة المرور' }, + changePassword: { en: 'Please change your password after your first login.', + ar: 'يرجى تغيير كلمة المرور بعد أول تسجيل دخول.' }, + signIn: { en: 'Sign In', ar: 'تسجيل الدخول' }, + + // Types + post: { en: 'post', ar: 'منشور' }, + artefact: { en: 'artefact', ar: 'قطعة إبداعية' }, + Post: { en: 'Post', ar: 'المنشور' }, + Artefact: { en: 'Artefact', ar: 'القطعة الإبداعية' }, + + // Generic + view: { en: 'View', ar: 'عرض' }, + viewTask: { en: 'View Task', ar: 'عرض المهمة' }, + viewIssue:{ en: 'View Issue', ar: 'عرض المشكلة' }, +}; + +function tr(key, lang) { return t[key]?.[lang] || t[key]?.en || key; } + +// ─── HTML EMAIL TEMPLATE ──────────────────────────────────────── + +function renderEmail({ heading, bodyHtml, ctaText, ctaUrl, lang = 'en' }) { + const isRtl = lang === 'ar'; + const dir = isRtl ? 'rtl' : 'ltr'; + const align = isRtl ? 'right' : 'left'; + const appName = isRtl ? APP_NAME_AR : APP_NAME_EN; + + const html = ` + + + +
+
+ ${appName} +
+
+

${heading}

+
+ ${bodyHtml} +
+ ${ctaText && ctaUrl ? ` +
+ ${ctaText} +
` : ''} +
+
+ ${tr('automatedNotice', lang)} +
+
+`; + + const text = `${heading}\n\n${bodyHtml.replace(/]*>(.*?)<\/blockquote>/gs, '> $1').replace(/<[^>]+>/g, '').replace(/ /g, ' ').trim()}${ctaUrl ? `\n\n${ctaText}: ${ctaUrl}` : ''}`; + + return { html, text }; +} + +// ─── HELPERS ──────────────────────────────────────────────────── + +async function getUser(userId) { + if (!userId) return null; + try { + const user = await nocodb.get('Users', userId); + return user?.email ? { email: user.email, name: user.name || 'User', lang: user.preferred_language || 'en' } : null; + } catch { return null; } +} + +async function getMultipleUsers(userIds) { + const results = await Promise.all(userIds.map(id => getUser(id))); + return results.filter(Boolean); +} + +function send({ to, subject, heading, bodyHtml, ctaText, ctaUrl, lang }) { + const { html, text } = renderEmail({ heading, bodyHtml, ctaText, ctaUrl, lang }); + sendMail({ to, subject, html, text }) + .then(() => console.log(`[notifications] Sent "${subject}" to ${to}`)) + .catch(err => console.error(`[notifications] FAILED "${subject}" to ${to}:`, err.message)); +} + +const BLOCKQUOTE = 'style="margin:16px 0;padding:12px 16px;background:#fef3c7;border-left:4px solid #f59e0b;border-radius:4px;color:#92400e"'; + +// ─── NOTIFICATION EVENTS ──────────────────────────────────────── + +// 1. Review submitted → notify approvers +function notifyReviewSubmitted({ type, record, reviewUrl }) { + const ids = parseApproverIds(record.approver_ids); + if (ids.length === 0) return; + const title = record.title || 'Untitled'; + + getMultipleUsers(ids).then(users => { + for (const user of users) { + const l = user.lang; + const typeLabel = tr(type, l); + send({ + to: user.email, lang: l, + subject: `${tr('reviewRequested', l)}: ${title}`, + heading: tr('reviewRequested', l), + bodyHtml: ` +

${tr('reviewRequestedBody', l)(typeLabel, title)}

+

${tr('reviewFeedbackPrompt', l)}

`, + ctaText: tr('reviewNow', l), + ctaUrl: reviewUrl, + }); + } + }); +} + +// 2. Post/Artefact approved → notify creator +function notifyApproved({ type, record, approverName }) { + const creatorId = record.created_by_user_id; + if (!creatorId) return; + const title = record.title || 'Untitled'; + + getUser(creatorId).then(user => { + if (!user) return; + const l = user.lang; + const typeLabel = tr(type === 'post' ? 'Post' : 'Artefact', l); + send({ + to: user.email, lang: l, + subject: `${tr('approved', l)}: ${title}`, + heading: tr('approvedHeading', l)(typeLabel), + bodyHtml: `

${tr('approvedBody', l)(title, approverName || (l === 'ar' ? 'مراجع' : 'a reviewer'))}

`, + ctaText: `${tr('view', l)} ${typeLabel}`, + ctaUrl: `${APP_URL}/${type === 'post' ? 'posts' : 'artefacts'}`, + }); + }); +} + +// 3. Post/Artefact rejected → notify creator +function notifyRejected({ type, record, approverName, feedback }) { + const creatorId = record.created_by_user_id; + if (!creatorId) return; + const title = record.title || 'Untitled'; + + getUser(creatorId).then(user => { + if (!user) return; + const l = user.lang; + const typeLabel = tr(type === 'post' ? 'Post' : 'Artefact', l); + send({ + to: user.email, lang: l, + subject: `${tr('needsChanges', l)}: ${title}`, + heading: tr('rejectedHeading', l)(typeLabel), + bodyHtml: ` +

${tr('rejectedBody', l)(title, approverName || (l === 'ar' ? 'مراجع' : 'a reviewer'))}

+ ${feedback ? `
${feedback}
` : ''}`, + ctaText: `${tr('view', l)} ${typeLabel}`, + ctaUrl: `${APP_URL}/${type === 'post' ? 'posts' : 'artefacts'}`, + }); + }); +} + +// 4. Revision requested (artefact) → notify creator +function notifyRevisionRequested({ record, approverName, feedback }) { + const creatorId = record.created_by_user_id; + if (!creatorId) return; + const title = record.title || 'Untitled'; + + getUser(creatorId).then(user => { + if (!user) return; + const l = user.lang; + send({ + to: user.email, lang: l, + subject: `${tr('revisionRequested', l)}: ${title}`, + heading: tr('revisionRequested', l), + bodyHtml: ` +

${tr('revisionRequestedBody', l)(title, approverName || (l === 'ar' ? 'مراجع' : 'a reviewer'))}

+ ${feedback ? `
${feedback}
` : ''}`, + ctaText: `${tr('view', l)} ${tr('Artefact', l)}`, + ctaUrl: `${APP_URL}/artefacts`, + }); + }); +} + +// 5. Task assigned → notify assignee +function notifyTaskAssigned({ task, assignerName }) { + const assigneeId = task.assigned_to_id; + if (!assigneeId) return; + const title = task.title || 'Untitled'; + + getUser(assigneeId).then(user => { + if (!user) return; + const l = user.lang; + send({ + to: user.email, lang: l, + subject: `${tr('taskAssigned', l)}: ${title}`, + heading: tr('taskAssignedHeading', l), + bodyHtml: ` +

${tr('taskAssignedBody', l)(assignerName || (l === 'ar' ? 'أحدهم' : 'Someone'))}

+

${title}

+ ${task.description ? `

${task.description.substring(0, 200)}

` : ''} + ${task.priority ? `

${tr('priority', l)}: ${task.priority}

` : ''} + ${task.due_date ? `

${tr('dueDate', l)}: ${task.due_date}

` : ''}`, + ctaText: tr('viewTask', l), + ctaUrl: `${APP_URL}/tasks`, + }); + }); +} + +// 6. Task completed → notify creator +function notifyTaskCompleted({ task }) { + const creatorId = task.created_by_user_id; + if (!creatorId || creatorId === task.assigned_to_id) return; + const title = task.title || 'Untitled'; + + getUser(creatorId).then(user => { + if (!user) return; + const l = user.lang; + send({ + to: user.email, lang: l, + subject: `${tr('taskCompleted', l)}: ${title}`, + heading: tr('taskCompletedHeading', l), + bodyHtml: `

${tr('taskCompletedBody', l)(title)}

`, + ctaText: tr('viewTask', l), + ctaUrl: `${APP_URL}/tasks`, + }); + }); +} + +// 7. Issue assigned → notify assignee +function notifyIssueAssigned({ issue, assignerName }) { + const assigneeId = issue.assigned_to_id; + if (!assigneeId) return; + const title = issue.title || 'Untitled'; + + getUser(assigneeId).then(user => { + if (!user) return; + const l = user.lang; + send({ + to: user.email, lang: l, + subject: `${tr('issueAssigned', l)}: ${title}`, + heading: tr('issueAssignedHeading', l), + bodyHtml: ` +

${tr('issueAssignedBody', l)(assignerName || (l === 'ar' ? 'أحدهم' : 'Someone'))}

+

${title}

+ ${issue.priority ? `

${tr('priority', l)}: ${issue.priority}

` : ''} + ${issue.category ? `

${tr('category', l)}: ${issue.category}

` : ''}`, + ctaText: tr('viewIssue', l), + ctaUrl: `${APP_URL}/issues`, + }); + }); +} + +// 8. Issue status update → notify submitter (external — always English, no user record) +function notifyIssueStatusUpdate({ issue, oldStatus, newStatus }) { + if (!issue.submitter_email || oldStatus === newStatus) return; + const title = issue.title || 'Untitled'; + + send({ + to: issue.submitter_email, lang: 'en', + subject: `${tr('issueUpdate', 'en')}: ${title}`, + heading: tr('issueUpdateHeading', 'en'), + bodyHtml: ` +

${tr('issueUpdateBody', 'en')(title)}

+

${oldStatus || 'new'}${newStatus}

+ ${issue.resolution_summary ? `

${tr('resolution', 'en')}: ${issue.resolution_summary}

` : ''}`, + ctaText: issue.tracking_token ? tr('trackIssue', 'en') : null, + ctaUrl: issue.tracking_token ? `${APP_URL}/track/${issue.tracking_token}` : null, + }); +} + +// 9. Campaign created → notify team members +function notifyCampaignCreated({ campaign, creatorUserId }) { + if (!campaign.team_id) return; + const name = campaign.name || 'Untitled'; + + nocodb.list('TeamMembers', { where: `(team_id,eq,${campaign.team_id})`, limit: 200 }) + .then(members => { + const memberIds = members.map(m => m.user_id).filter(id => id !== creatorUserId); + if (memberIds.length === 0) return; + return getMultipleUsers(memberIds).then(users => { + for (const user of users) { + const l = user.lang; + send({ + to: user.email, lang: l, + subject: `${tr('newCampaign', l)}: ${name}`, + heading: tr('campaignHeading', l), + bodyHtml: ` +

${tr('campaignBody', l)(name)}

+ ${campaign.start_date && campaign.end_date ? `

${campaign.start_date} — ${campaign.end_date}

` : ''}`, + ctaText: tr('viewCampaign', l), + ctaUrl: `${APP_URL}/campaigns`, + }); + } + }); + }) + .catch(err => console.error('[notifications] Campaign team lookup failed:', err.message)); +} + +// 10. User invited → send welcome email (default English for new users) +function notifyUserInvited({ email, name, password, inviterName, lang = 'en' }) { + const l = lang; + send({ + to: email, lang: l, + subject: `${tr('welcome', l)(name)} — ${tr('appName', l)}`, + heading: tr('welcome', l)(name), + bodyHtml: ` +

${tr('welcomeBody', l)(inviterName || (l === 'ar' ? 'فريقك' : 'Your team'))}

+
+

${tr('emailLabel', l)}: ${email}

+

${tr('passwordLabel', l)}: ${password}

+
+

${tr('changePassword', l)}

`, + ctaText: tr('signIn', l), + ctaUrl: `${APP_URL}/login`, + }); +} + +module.exports = { + notifyReviewSubmitted, + notifyApproved, + notifyRejected, + notifyRevisionRequested, + notifyTaskAssigned, + notifyTaskCompleted, + notifyIssueAssigned, + notifyIssueStatusUpdate, + notifyCampaignCreated, + notifyUserInvited, +}; diff --git a/server/server.js b/server/server.js index 6642c58..46d45f9 100644 --- a/server/server.js +++ b/server/server.js @@ -12,6 +12,7 @@ const nocodb = require('./nocodb'); const crypto = require('crypto'); const { PORT, UPLOADS_DIR, SETTINGS_PATH, DEFAULTS, QUERY_LIMITS, ALL_MODULES, TABLE_NAME_MAP, COMMENT_ENTITY_TYPES } = require('./config'); const { getRecordName, batchResolveNames, parseApproverIds, safeJsonParse, pickBodyFields, sanitizeWhereValue, getUserModules, stripSensitiveFields, getUserTeamIds, getUserVisibilityContext } = require('./helpers'); +const notify = require('./notifications'); const app = express(); @@ -457,6 +458,7 @@ const TEXT_COLUMNS = { { name: 'password_hash', uidt: 'SingleLineText' }, { name: 'reset_token', uidt: 'SingleLineText' }, { name: 'reset_token_expires', uidt: 'SingleLineText' }, + { name: 'preferred_language', uidt: 'SingleLineText' }, ], BudgetEntries: [{ name: 'project_id', uidt: 'Number' }, { name: 'destination', uidt: 'SingleLineText' }, { name: 'type', uidt: 'SingleLineText' }], Comments: [{ name: 'version_number', uidt: 'Number' }], @@ -662,6 +664,7 @@ app.post('/api/auth/login', async (req, res) => { avatar: user.avatar, team_role: user.team_role, tutorial_completed: user.tutorial_completed, + preferred_language: user.preferred_language || 'en', profileComplete: !!user.name, modules, }, @@ -755,6 +758,7 @@ app.get('/api/auth/me', requireAuth, async (req, res) => { role: user.role, avatar: user.avatar, team_role: user.team_role, brands: user.brands, phone: user.phone, tutorial_completed: user.tutorial_completed, + preferred_language: user.preferred_language || 'en', CreatedAt: user.CreatedAt, created_at: user.CreatedAt, profileComplete: !!user.name, modules, @@ -849,6 +853,17 @@ app.patch('/api/users/me/tutorial', requireAuth, async (req, res) => { } }); +app.patch('/api/users/me/language', requireAuth, async (req, res) => { + const { language } = req.body; + if (!language || !['en', 'ar'].includes(language)) return res.status(400).json({ error: 'Invalid language' }); + try { + await nocodb.update('Users', req.session.userId, { preferred_language: language }); + res.json({ success: true, preferred_language: language }); + } catch (err) { + res.status(500).json({ error: 'Failed to update language preference' }); + } +}); + // ─── USER MANAGEMENT ──────────────────────────────────────────── app.get('/api/users', requireAuth, async (req, res) => { @@ -961,6 +976,7 @@ app.post('/api/users/team', requireAuth, requireRole('superadmin', 'manager'), a const user = await nocodb.get('Users', created.Id); res.status(201).json(stripSensitiveFields({ ...user, id: user.Id, _id: user.Id })); + notify.notifyUserInvited({ email, name, password: defaultPassword, inviterName: req.session.userName }); } catch (err) { console.error('Create team member error:', err); res.status(500).json({ error: 'Failed to create team member' }); @@ -1495,6 +1511,7 @@ app.post('/api/posts/:id/submit-review', requireAuth, requireOwnerOrRole('posts' const reviewUrl = `${req.protocol}://${req.get('host')}/review-post/${token}`; res.json({ success: true, token, reviewUrl, expiresAt: expiresAt.toISOString() }); + notify.notifyReviewSubmitted({ type: 'post', record: { ...existing, ...updateData }, reviewUrl }); } catch (err) { console.error('Submit post review error:', err); res.status(500).json({ error: 'Failed to submit for review' }); @@ -1583,6 +1600,7 @@ app.post('/api/public/review-post/:token/approve', async (req, res) => { }); res.json({ success: true, message: 'Post approved successfully' }); + notify.notifyApproved({ type: 'post', record: post, approverName: approved_by_name }); } catch (err) { console.error('Post approve error:', err); res.status(500).json({ error: 'Failed to approve post' }); @@ -1614,6 +1632,7 @@ app.post('/api/public/review-post/:token/reject', async (req, res) => { }); res.json({ success: true, message: 'Post rejected' }); + notify.notifyRejected({ type: 'post', record: post, approverName: approved_by_name, feedback }); } catch (err) { console.error('Post reject error:', err); res.status(500).json({ error: 'Failed to reject post' }); @@ -1851,6 +1870,7 @@ app.post('/api/campaigns', requireAuth, requireRole('superadmin', 'manager'), as brand_name: await getRecordName('Brands', campaign.brand_id), team_name: await getRecordName('Teams', campaign.team_id), }); + notify.notifyCampaignCreated({ campaign, creatorUserId: req.session.userId }); } catch (err) { console.error('Create campaign error:', err); res.status(500).json({ error: 'Failed to create campaign' }); @@ -2637,6 +2657,7 @@ app.post('/api/tasks', requireAuth, async (req, res) => { assigned_name: await getRecordName('Users', task.assigned_to_id), creator_user_name: await getRecordName('Users', task.created_by_user_id), }); + if (task.assigned_to_id) notify.notifyTaskAssigned({ task, assignerName: req.session.userName }); } catch (err) { console.error('Create task error:', err); res.status(500).json({ error: 'Failed to create task' }); @@ -2694,6 +2715,14 @@ app.patch('/api/tasks/:id', requireAuth, requireOwnerOrRole('tasks', 'superadmin assigned_name: await getRecordName('Users', task.assigned_to_id), creator_user_name: await getRecordName('Users', task.created_by_user_id), }); + // Notify on assignment change + if (data.assigned_to_id && data.assigned_to_id !== existing.assigned_to_id) { + notify.notifyTaskAssigned({ task, assignerName: req.session.userName }); + } + // Notify on completion + if (req.body.status === 'done' && existing.status !== 'done') { + notify.notifyTaskCompleted({ task }); + } } catch (err) { console.error('Update task error:', err); res.status(500).json({ error: 'Failed to update task' }); @@ -3414,6 +3443,7 @@ app.post('/api/artefacts/:id/submit-review', requireAuth, async (req, res) => { const reviewUrl = `${req.protocol}://${req.get('host')}/review/${token}`; res.json({ success: true, token, reviewUrl, expiresAt: expiresAt.toISOString() }); + notify.notifyReviewSubmitted({ type: 'artefact', record: existing, reviewUrl }); } catch (err) { console.error('Submit review error:', err); res.status(500).json({ error: 'Failed to submit for review' }); @@ -3937,6 +3967,7 @@ app.post('/api/public/review/:token/approve', async (req, res) => { }); res.json({ success: true, message: 'Artefact approved successfully' }); + notify.notifyApproved({ type: 'artefact', record: artefact, approverName: approved_by_name }); } catch (err) { console.error('Approve error:', err); res.status(500).json({ error: 'Failed to approve artefact' }); @@ -3966,6 +3997,7 @@ app.post('/api/public/review/:token/reject', async (req, res) => { }); res.json({ success: true, message: 'Artefact rejected' }); + notify.notifyRejected({ type: 'artefact', record: artefact, approverName: approved_by_name, feedback }); } catch (err) { console.error('Reject error:', err); res.status(500).json({ error: 'Failed to reject artefact' }); @@ -3995,6 +4027,7 @@ app.post('/api/public/review/:token/revision', async (req, res) => { }); res.json({ success: true, message: 'Revision requested' }); + notify.notifyRevisionRequested({ record: artefact, approverName: approved_by_name, feedback }); } catch (err) { console.error('Revision request error:', err); res.status(500).json({ error: 'Failed to request revision' }); @@ -4180,6 +4213,9 @@ app.post('/api/issues', requireAuth, async (req, res) => { // Internal: Update issue app.patch('/api/issues/:id', requireAuth, async (req, res) => { try { + const existing = await nocodb.get('Issues', req.params.id); + if (!existing) return res.status(404).json({ error: 'Issue not found' }); + const { status, assigned_to_id, internal_notes, resolution_summary, priority, category, brand_id, team_id } = req.body; const updates = { updated_at: new Date().toISOString() }; @@ -4198,6 +4234,14 @@ app.patch('/api/issues/:id', requireAuth, async (req, res) => { await nocodb.update('Issues', req.params.id, updates); res.json({ success: true }); + // Notify on assignment change + if (assigned_to_id && assigned_to_id !== existing.assigned_to_id) { + notify.notifyIssueAssigned({ issue: { ...existing, ...updates }, assignerName: req.session.userName }); + } + // Notify submitter on status change + if (status && status !== existing.status) { + notify.notifyIssueStatusUpdate({ issue: { ...existing, ...updates }, oldStatus: existing.status, newStatus: status }); + } } catch (err) { console.error('Update issue error:', err); res.status(500).json({ error: 'Failed to update issue' });