// server/notifications.js — Bilingual email notification system const { sendMail } = require('./mail'); const nocodb = require('./nocodb'); const { parseApproverIds } = require('./helpers'); function escapeHtml(str) { if (!str) return ''; return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } const APP_URL = process.env.APP_URL || process.env.CORS_ORIGIN || 'http://localhost:3001'; const APP_NAME_EN = 'Rawaj'; 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: 'القطعة الإبداعية' }, translation: { en: 'translation', ar: 'ترجمة' }, Translation: { en: 'Translation', ar: 'الترجمة' }, // Generic view: { en: 'View', ar: 'عرض' }, viewTask: { en: 'View Task', ar: 'عرض المهمة' }, viewIssue:{ en: 'View Issue', ar: 'عرض المشكلة' }, // Budget budgetRequest: { en: 'Budget Request', ar: 'طلب ميزانية' }, budgetRequestHeading: { en: 'Budget Request', ar: 'طلب ميزانية' }, budgetRequestBody: { en: (name, amount) => `${name} is requesting ${amount}.`, ar: (name, amount) => `يطلب ${name} مبلغ ${amount}.` }, budgetJustification: { en: 'Justification', ar: 'المبرر' }, budgetEarmarkedFor: { en: 'Earmarked for', ar: 'مخصص لـ' }, reviewRequest: { en: 'Review Request', ar: 'مراجعة الطلب' }, budgetApproved: { en: 'Budget Request Approved', ar: 'تمت الموافقة على طلب الميزانية' }, budgetApprovedBody: { en: (amount) => `Your budget request for ${amount} has been approved. Funds are now available.`, ar: (amount) => `تمت الموافقة على طلب الميزانية بمبلغ ${amount}. الأموال متاحة الآن.` }, budgetRejected: { en: 'Budget Request Rejected', ar: 'تم رفض طلب الميزانية' }, budgetRejectedBody: { en: (amount) => `Your budget request for ${amount} has been rejected.`, ar: (amount) => `تم رفض طلب الميزانية بمبلغ ${amount}.` }, }; 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 = `
]*>(.*?)<\/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 appName = lang === 'ar' ? APP_NAME_AR : APP_NAME_EN; const fullSubject = `${appName} — ${subject}`; const { html, text } = renderEmail({ heading, bodyHtml, ctaText, ctaUrl, lang }); sendMail({ to, subject: fullSubject, 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' : type === 'translation' ? 'Translation' : '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' : type === 'translation' ? 'translations' : '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' : type === 'translation' ? 'Translation' : '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 ? `${escapeHtml(feedback)}` : ''}`, ctaText: `${tr('view', l)} ${typeLabel}`, ctaUrl: `${APP_URL}/${type === 'post' ? 'posts' : type === 'translation' ? 'translations' : 'artefacts'}`, }); }); } // 4. Revision requested (artefact) → notify creator 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; 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 ? `${escapeHtml(feedback)}` : ''}`, ctaText: `${tr('view', l)} ${tr(entityType, l)}`, ctaUrl: `${APP_URL}/${entityPath}`, }); }); } // 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 ? `${escapeHtml(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')}: ${escapeHtml(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`, }); } // 11. Budget request → email CEO function notifyBudgetRequest({ ceoEmail, amount, requesterName, justification, earmarkedFor, approvalUrl }) { const earmarkHtml = earmarkedFor ? `${tr('budgetEarmarkedFor', 'en')}: ${earmarkedFor}
` : ''; send({ to: ceoEmail, lang: 'en', subject: `${tr('budgetRequest', 'en')}: ${amount}`, heading: tr('budgetRequestHeading', 'en'), bodyHtml: `${tr('budgetRequestBody', 'en')(requesterName, amount)}
${tr('budgetJustification', 'en')}: ${escapeHtml(justification)}
${earmarkHtml}`, ctaText: tr('reviewRequest', 'en'), ctaUrl: approvalUrl, }); } // 12. Budget approved → notify requester function notifyBudgetApproved({ request, requesterEmail, requesterLang }) { const l = requesterLang || 'en'; send({ to: requesterEmail, lang: l, subject: `${tr('budgetApproved', l)}: ${request.amount}`, heading: tr('budgetApproved', l), bodyHtml: `${tr('budgetApprovedBody', l)(String(request.amount))}
${request.response_note ? `${escapeHtml(request.response_note)}` : ''}`, ctaText: null, ctaUrl: null, }); } // 13. Budget rejected → notify requester function notifyBudgetRejected({ request, requesterEmail, requesterLang }) { const l = requesterLang || 'en'; send({ to: requesterEmail, lang: l, subject: `${tr('budgetRejected', l)}: ${request.amount}`, heading: tr('budgetRejected', l), bodyHtml: `${tr('budgetRejectedBody', l)(String(request.amount))}
${request.response_note ? `${escapeHtml(request.response_note)}` : ''}`, ctaText: null, ctaUrl: null, }); } module.exports = { renderEmail, notifyReviewSubmitted, notifyApproved, notifyRejected, notifyRevisionRequested, notifyTaskAssigned, notifyTaskCompleted, notifyIssueAssigned, notifyIssueStatusUpdate, notifyCampaignCreated, notifyUserInvited, notifyBudgetRequest, notifyBudgetApproved, notifyBudgetRejected, };