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:
@@ -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' });
|
||||
|
||||
Reference in New Issue
Block a user