feat: add bilingual email notification system for key events
Some checks failed
Deploy / deploy (push) Failing after 8s
Some checks failed
Deploy / deploy (push) Failing after 8s
Notifications (fire-and-forget, non-blocking) for: review submitted, approved/rejected/revision requested, task assigned/completed, issue assigned/status changed, campaign created, user invited. Emails render in user's preferred language (EN/AR) with RTL support. Adds preferred_language to Users, syncs from frontend language toggle. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import { createContext, useContext, useState, useEffect } from 'react'
|
import { createContext, useContext, useState, useEffect } from 'react'
|
||||||
|
import api from '../utils/api'
|
||||||
import en from './en.json'
|
import en from './en.json'
|
||||||
import ar from './ar.json'
|
import ar from './ar.json'
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ export function LanguageProvider({ children }) {
|
|||||||
if (newLang !== 'en' && newLang !== 'ar') return
|
if (newLang !== 'en' && newLang !== 'ar') return
|
||||||
setLangState(newLang)
|
setLangState(newLang)
|
||||||
localStorage.setItem('digitalhub-lang', newLang)
|
localStorage.setItem('digitalhub-lang', newLang)
|
||||||
|
api.patch('/api/users/me/language', { language: newLang }).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
const setCurrency = (code) => {
|
const setCurrency = (code) => {
|
||||||
|
|||||||
397
server/notifications.js
Normal file
397
server/notifications.js
Normal file
@@ -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} <strong>"${title}"</strong>.`,
|
||||||
|
ar: (type, title) => `تمت دعوتك لمراجعة ${type} <strong>"${title}"</strong>.` },
|
||||||
|
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! <strong>"${title}"</strong> has been approved by <strong>${name}</strong>.`,
|
||||||
|
ar: (title, name) => `أخبار رائعة! تمت الموافقة على <strong>"${title}"</strong> من قبل <strong>${name}</strong>.` },
|
||||||
|
|
||||||
|
// Rejected
|
||||||
|
needsChanges: { en: 'Needs Changes', ar: 'يحتاج تعديلات' },
|
||||||
|
rejectedHeading: { en: (type) => `Your ${type} Needs Changes`,
|
||||||
|
ar: (type) => `${type} يحتاج تعديلات` },
|
||||||
|
rejectedBody: { en: (title, name) => `<strong>"${title}"</strong> was reviewed by <strong>${name}</strong> and requires changes.`,
|
||||||
|
ar: (title, name) => `تمت مراجعة <strong>"${title}"</strong> من قبل <strong>${name}</strong> ويحتاج إلى تعديلات.` },
|
||||||
|
|
||||||
|
// Revision
|
||||||
|
revisionRequested: { en: 'Revision Requested', ar: 'طلب تعديل' },
|
||||||
|
revisionRequestedBody: { en: (title, name) => `<strong>"${title}"</strong> was reviewed by <strong>${name}</strong> and a revision has been requested.`,
|
||||||
|
ar: (title, name) => `تمت مراجعة <strong>"${title}"</strong> من قبل <strong>${name}</strong> وتم طلب تعديل.` },
|
||||||
|
|
||||||
|
// Task
|
||||||
|
taskAssigned: { en: 'Task Assigned', ar: 'تم تعيين مهمة' },
|
||||||
|
taskAssignedHeading: { en: 'New Task Assigned to You', ar: 'مهمة جديدة مُسندة إليك' },
|
||||||
|
taskAssignedBody: { en: (name) => `<strong>${name}</strong> assigned you a task:`,
|
||||||
|
ar: (name) => `قام <strong>${name}</strong> بتعيين مهمة لك:` },
|
||||||
|
taskCompleted: { en: 'Task Completed', ar: 'تم إنجاز المهمة' },
|
||||||
|
taskCompletedHeading: { en: 'A Task You Created Has Been Completed', ar: 'تم إنجاز مهمة قمت بإنشائها' },
|
||||||
|
taskCompletedBody: { en: (title) => `The task <strong>"${title}"</strong> has been marked as done.`,
|
||||||
|
ar: (title) => `تم وضع علامة "منجز" على المهمة <strong>"${title}"</strong>.` },
|
||||||
|
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) => `<strong>${name}</strong> assigned you an issue:`,
|
||||||
|
ar: (name) => `قام <strong>${name}</strong> بتعيين مشكلة لك:` },
|
||||||
|
issueUpdate: { en: 'Issue Update', ar: 'تحديث المشكلة' },
|
||||||
|
issueUpdateHeading: { en: 'Your Issue Has Been Updated', ar: 'تم تحديث مشكلتك' },
|
||||||
|
issueUpdateBody: { en: (title) => `The status of your issue <strong>"${title}"</strong> has changed:`,
|
||||||
|
ar: (title) => `تم تغيير حالة مشكلتك <strong>"${title}"</strong>:` },
|
||||||
|
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 <strong>"${name}"</strong> has been created in your team.`,
|
||||||
|
ar: (name) => `تم إنشاء حملة جديدة <strong>"${name}"</strong> في فريقك.` },
|
||||||
|
viewCampaign: { en: 'View Campaign', ar: 'عرض الحملة' },
|
||||||
|
|
||||||
|
// Welcome
|
||||||
|
welcome: { en: (name) => `Welcome, ${name}!`, ar: (name) => `!أهلاً بك، ${name}` },
|
||||||
|
welcomeBody: { en: (inviter) => `<strong>${inviter}</strong> has invited you to ${APP_NAME_EN}.`,
|
||||||
|
ar: (inviter) => `قام <strong>${inviter}</strong> بدعوتك إلى ${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 = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html dir="${dir}" lang="${lang}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
|
||||||
|
<body style="margin:0;padding:0;background:#f4f5f7;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif">
|
||||||
|
<div style="max-width:600px;margin:0 auto;padding:20px;direction:${dir};text-align:${align}">
|
||||||
|
<div style="background:#1e293b;color:#fff;padding:16px 24px;border-radius:12px 12px 0 0;font-size:16px;font-weight:600;text-align:${align}">
|
||||||
|
${appName}
|
||||||
|
</div>
|
||||||
|
<div style="background:#fff;padding:32px 24px;border-radius:0 0 12px 12px;border:1px solid #e2e8f0;border-top:none">
|
||||||
|
<h2 style="margin:0 0 16px;color:#1e293b;font-size:20px">${heading}</h2>
|
||||||
|
<div style="color:#475569;font-size:15px;line-height:1.6">
|
||||||
|
${bodyHtml}
|
||||||
|
</div>
|
||||||
|
${ctaText && ctaUrl ? `
|
||||||
|
<div style="margin:24px 0 8px">
|
||||||
|
<a href="${ctaUrl}" style="display:inline-block;background:#3b82f6;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:600;font-size:14px">${ctaText}</a>
|
||||||
|
</div>` : ''}
|
||||||
|
</div>
|
||||||
|
<div style="text-align:center;padding:16px;color:#94a3b8;font-size:12px">
|
||||||
|
${tr('automatedNotice', lang)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body></html>`;
|
||||||
|
|
||||||
|
const text = `${heading}\n\n${bodyHtml.replace(/<blockquote[^>]*>(.*?)<\/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: `
|
||||||
|
<p>${tr('reviewRequestedBody', l)(typeLabel, title)}</p>
|
||||||
|
<p>${tr('reviewFeedbackPrompt', l)}</p>`,
|
||||||
|
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: `<p>${tr('approvedBody', l)(title, approverName || (l === 'ar' ? 'مراجع' : 'a reviewer'))}</p>`,
|
||||||
|
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: `
|
||||||
|
<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'}`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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: `
|
||||||
|
<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`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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: `
|
||||||
|
<p>${tr('taskAssignedBody', l)(assignerName || (l === 'ar' ? 'أحدهم' : 'Someone'))}</p>
|
||||||
|
<p style="font-size:16px;font-weight:600;color:#1e293b">${title}</p>
|
||||||
|
${task.description ? `<p style="color:#64748b">${task.description.substring(0, 200)}</p>` : ''}
|
||||||
|
${task.priority ? `<p>${tr('priority', l)}: <strong>${task.priority}</strong></p>` : ''}
|
||||||
|
${task.due_date ? `<p>${tr('dueDate', l)}: <strong>${task.due_date}</strong></p>` : ''}`,
|
||||||
|
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: `<p>${tr('taskCompletedBody', l)(title)}</p>`,
|
||||||
|
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: `
|
||||||
|
<p>${tr('issueAssignedBody', l)(assignerName || (l === 'ar' ? 'أحدهم' : 'Someone'))}</p>
|
||||||
|
<p style="font-size:16px;font-weight:600;color:#1e293b">${title}</p>
|
||||||
|
${issue.priority ? `<p>${tr('priority', l)}: <strong>${issue.priority}</strong></p>` : ''}
|
||||||
|
${issue.category ? `<p>${tr('category', l)}: <strong>${issue.category}</strong></p>` : ''}`,
|
||||||
|
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: `
|
||||||
|
<p>${tr('issueUpdateBody', 'en')(title)}</p>
|
||||||
|
<p><span style="color:#94a3b8">${oldStatus || 'new'}</span> → <strong style="color:#3b82f6">${newStatus}</strong></p>
|
||||||
|
${issue.resolution_summary ? `<p style="margin-top:12px"><strong>${tr('resolution', 'en')}:</strong> ${issue.resolution_summary}</p>` : ''}`,
|
||||||
|
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: `
|
||||||
|
<p>${tr('campaignBody', l)(name)}</p>
|
||||||
|
${campaign.start_date && campaign.end_date ? `<p>${campaign.start_date} — ${campaign.end_date}</p>` : ''}`,
|
||||||
|
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: `
|
||||||
|
<p>${tr('welcomeBody', l)(inviterName || (l === 'ar' ? 'فريقك' : 'Your team'))}</p>
|
||||||
|
<div style="margin:16px 0;padding:16px;background:#f1f5f9;border-radius:8px">
|
||||||
|
<p style="margin:0 0 8px"><strong>${tr('emailLabel', l)}:</strong> ${email}</p>
|
||||||
|
<p style="margin:0"><strong>${tr('passwordLabel', l)}:</strong> ${password}</p>
|
||||||
|
</div>
|
||||||
|
<p style="color:#64748b;font-size:13px">${tr('changePassword', l)}</p>`,
|
||||||
|
ctaText: tr('signIn', l),
|
||||||
|
ctaUrl: `${APP_URL}/login`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
notifyReviewSubmitted,
|
||||||
|
notifyApproved,
|
||||||
|
notifyRejected,
|
||||||
|
notifyRevisionRequested,
|
||||||
|
notifyTaskAssigned,
|
||||||
|
notifyTaskCompleted,
|
||||||
|
notifyIssueAssigned,
|
||||||
|
notifyIssueStatusUpdate,
|
||||||
|
notifyCampaignCreated,
|
||||||
|
notifyUserInvited,
|
||||||
|
};
|
||||||
@@ -12,6 +12,7 @@ const nocodb = require('./nocodb');
|
|||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const { PORT, UPLOADS_DIR, SETTINGS_PATH, DEFAULTS, QUERY_LIMITS, ALL_MODULES, TABLE_NAME_MAP, COMMENT_ENTITY_TYPES } = require('./config');
|
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 { getRecordName, batchResolveNames, parseApproverIds, safeJsonParse, pickBodyFields, sanitizeWhereValue, getUserModules, stripSensitiveFields, getUserTeamIds, getUserVisibilityContext } = require('./helpers');
|
||||||
|
const notify = require('./notifications');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
@@ -457,6 +458,7 @@ const TEXT_COLUMNS = {
|
|||||||
{ name: 'password_hash', uidt: 'SingleLineText' },
|
{ name: 'password_hash', uidt: 'SingleLineText' },
|
||||||
{ name: 'reset_token', uidt: 'SingleLineText' },
|
{ name: 'reset_token', uidt: 'SingleLineText' },
|
||||||
{ name: 'reset_token_expires', 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' }],
|
BudgetEntries: [{ name: 'project_id', uidt: 'Number' }, { name: 'destination', uidt: 'SingleLineText' }, { name: 'type', uidt: 'SingleLineText' }],
|
||||||
Comments: [{ name: 'version_number', uidt: 'Number' }],
|
Comments: [{ name: 'version_number', uidt: 'Number' }],
|
||||||
@@ -662,6 +664,7 @@ app.post('/api/auth/login', async (req, res) => {
|
|||||||
avatar: user.avatar,
|
avatar: user.avatar,
|
||||||
team_role: user.team_role,
|
team_role: user.team_role,
|
||||||
tutorial_completed: user.tutorial_completed,
|
tutorial_completed: user.tutorial_completed,
|
||||||
|
preferred_language: user.preferred_language || 'en',
|
||||||
profileComplete: !!user.name,
|
profileComplete: !!user.name,
|
||||||
modules,
|
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,
|
role: user.role, avatar: user.avatar, team_role: user.team_role,
|
||||||
brands: user.brands, phone: user.phone,
|
brands: user.brands, phone: user.phone,
|
||||||
tutorial_completed: user.tutorial_completed,
|
tutorial_completed: user.tutorial_completed,
|
||||||
|
preferred_language: user.preferred_language || 'en',
|
||||||
CreatedAt: user.CreatedAt, created_at: user.CreatedAt,
|
CreatedAt: user.CreatedAt, created_at: user.CreatedAt,
|
||||||
profileComplete: !!user.name,
|
profileComplete: !!user.name,
|
||||||
modules,
|
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 ────────────────────────────────────────────
|
// ─── USER MANAGEMENT ────────────────────────────────────────────
|
||||||
|
|
||||||
app.get('/api/users', requireAuth, async (req, res) => {
|
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);
|
const user = await nocodb.get('Users', created.Id);
|
||||||
res.status(201).json(stripSensitiveFields({ ...user, id: user.Id, _id: user.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) {
|
} catch (err) {
|
||||||
console.error('Create team member error:', err);
|
console.error('Create team member error:', err);
|
||||||
res.status(500).json({ error: 'Failed to create team member' });
|
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}`;
|
const reviewUrl = `${req.protocol}://${req.get('host')}/review-post/${token}`;
|
||||||
res.json({ success: true, token, reviewUrl, expiresAt: expiresAt.toISOString() });
|
res.json({ success: true, token, reviewUrl, expiresAt: expiresAt.toISOString() });
|
||||||
|
notify.notifyReviewSubmitted({ type: 'post', record: { ...existing, ...updateData }, reviewUrl });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Submit post review error:', err);
|
console.error('Submit post review error:', err);
|
||||||
res.status(500).json({ error: 'Failed to submit for review' });
|
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' });
|
res.json({ success: true, message: 'Post approved successfully' });
|
||||||
|
notify.notifyApproved({ type: 'post', record: post, approverName: approved_by_name });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Post approve error:', err);
|
console.error('Post approve error:', err);
|
||||||
res.status(500).json({ error: 'Failed to approve post' });
|
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' });
|
res.json({ success: true, message: 'Post rejected' });
|
||||||
|
notify.notifyRejected({ type: 'post', record: post, approverName: approved_by_name, feedback });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Post reject error:', err);
|
console.error('Post reject error:', err);
|
||||||
res.status(500).json({ error: 'Failed to reject post' });
|
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),
|
brand_name: await getRecordName('Brands', campaign.brand_id),
|
||||||
team_name: await getRecordName('Teams', campaign.team_id),
|
team_name: await getRecordName('Teams', campaign.team_id),
|
||||||
});
|
});
|
||||||
|
notify.notifyCampaignCreated({ campaign, creatorUserId: req.session.userId });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Create campaign error:', err);
|
console.error('Create campaign error:', err);
|
||||||
res.status(500).json({ error: 'Failed to create campaign' });
|
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),
|
assigned_name: await getRecordName('Users', task.assigned_to_id),
|
||||||
creator_user_name: await getRecordName('Users', task.created_by_user_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) {
|
} catch (err) {
|
||||||
console.error('Create task error:', err);
|
console.error('Create task error:', err);
|
||||||
res.status(500).json({ error: 'Failed to create task' });
|
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),
|
assigned_name: await getRecordName('Users', task.assigned_to_id),
|
||||||
creator_user_name: await getRecordName('Users', task.created_by_user_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) {
|
} catch (err) {
|
||||||
console.error('Update task error:', err);
|
console.error('Update task error:', err);
|
||||||
res.status(500).json({ error: 'Failed to update task' });
|
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}`;
|
const reviewUrl = `${req.protocol}://${req.get('host')}/review/${token}`;
|
||||||
res.json({ success: true, token, reviewUrl, expiresAt: expiresAt.toISOString() });
|
res.json({ success: true, token, reviewUrl, expiresAt: expiresAt.toISOString() });
|
||||||
|
notify.notifyReviewSubmitted({ type: 'artefact', record: existing, reviewUrl });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Submit review error:', err);
|
console.error('Submit review error:', err);
|
||||||
res.status(500).json({ error: 'Failed to submit for review' });
|
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' });
|
res.json({ success: true, message: 'Artefact approved successfully' });
|
||||||
|
notify.notifyApproved({ type: 'artefact', record: artefact, approverName: approved_by_name });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Approve error:', err);
|
console.error('Approve error:', err);
|
||||||
res.status(500).json({ error: 'Failed to approve artefact' });
|
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' });
|
res.json({ success: true, message: 'Artefact rejected' });
|
||||||
|
notify.notifyRejected({ type: 'artefact', record: artefact, approverName: approved_by_name, feedback });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Reject error:', err);
|
console.error('Reject error:', err);
|
||||||
res.status(500).json({ error: 'Failed to reject artefact' });
|
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' });
|
res.json({ success: true, message: 'Revision requested' });
|
||||||
|
notify.notifyRevisionRequested({ record: artefact, approverName: approved_by_name, feedback });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Revision request error:', err);
|
console.error('Revision request error:', err);
|
||||||
res.status(500).json({ error: 'Failed to request revision' });
|
res.status(500).json({ error: 'Failed to request revision' });
|
||||||
@@ -4180,6 +4213,9 @@ app.post('/api/issues', requireAuth, async (req, res) => {
|
|||||||
// Internal: Update issue
|
// Internal: Update issue
|
||||||
app.patch('/api/issues/:id', requireAuth, async (req, res) => {
|
app.patch('/api/issues/:id', requireAuth, async (req, res) => {
|
||||||
try {
|
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 { status, assigned_to_id, internal_notes, resolution_summary, priority, category, brand_id, team_id } = req.body;
|
||||||
const updates = { updated_at: new Date().toISOString() };
|
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);
|
await nocodb.update('Issues', req.params.id, updates);
|
||||||
res.json({ success: true });
|
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) {
|
} catch (err) {
|
||||||
console.error('Update issue error:', err);
|
console.error('Update issue error:', err);
|
||||||
res.status(500).json({ error: 'Failed to update issue' });
|
res.status(500).json({ error: 'Failed to update issue' });
|
||||||
|
|||||||
Reference in New Issue
Block a user