// 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: 'القطعة الإبداعية' }, translation: { en: 'translation', ar: 'ترجمة' }, Translation: { en: 'Translation', 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 = `
]*>(.*?)<\/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' : 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 ? `${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 ? `${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 ? `${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, };