- Pin HS256 algorithm on all jwt.verify() calls (9 sites) and jwt.sign() calls (3 sites) — prevents algorithm confusion attacks - Add JWT_INVITE_SECRET env var; volunteer invite tokens now use a dedicated key separate from access/refresh secrets - Remove req.query.secret fallback from Listmonk webhook route — secrets must not appear in nginx access logs - Replace child_process.spawn in email template seed endpoint with direct function import; add require.main guard to seed script - Add sanitizeCsvField() to location CSV export to prevent formula injection in Excel/Sheets (=, +, -, @ prefix → apostrophe prefix) - Cap QR endpoint text input at 2000 chars to prevent DoS via large payloads - Fix pre-existing TS errors: type participantNeeds as UpsertNeedsInput in meeting-planner service; add sso field to UpdateResourcePayload Bunker Admin
350 lines
10 KiB
TypeScript
350 lines
10 KiB
TypeScript
import { Router } from 'express';
|
|
import { emailTemplatesService } from './email-templates.service';
|
|
import { emailService } from '../../services/email.service';
|
|
import { validate } from '../../middleware/validate';
|
|
import {
|
|
listEmailTemplatesSchema,
|
|
createEmailTemplateSchema,
|
|
updateEmailTemplateSchema,
|
|
rollbackToVersionSchema,
|
|
validateTemplateSchema,
|
|
sendTestEmailSchema,
|
|
} from './email-templates.schemas';
|
|
import { logger } from '../../utils/logger';
|
|
import { authenticate } from '../../middleware/auth.middleware';
|
|
import { requireRole } from '../../middleware/rbac.middleware';
|
|
import { UserRole } from '@prisma/client';
|
|
import { Request, Response } from 'express';
|
|
import { BROADCAST_ROLES } from '../../utils/roles';
|
|
import rateLimit from 'express-rate-limit';
|
|
import RedisStore from 'rate-limit-redis';
|
|
import { redis } from '../../config/redis';
|
|
import { seedEmailTemplates } from '../../scripts/seed-email-templates';
|
|
|
|
const router = Router();
|
|
|
|
// All email template routes require authentication
|
|
router.use(authenticate);
|
|
|
|
// All routes require broadcast admin role
|
|
const requireBroadcastRole = requireRole(...BROADCAST_ROLES);
|
|
|
|
/**
|
|
* List email templates
|
|
* GET /email-templates
|
|
*/
|
|
router.get(
|
|
'/',
|
|
requireBroadcastRole,
|
|
validate(listEmailTemplatesSchema, 'query'),
|
|
async (req: Request, res: Response): Promise<void> => {
|
|
try {
|
|
const result = await emailTemplatesService.list(req.query as any);
|
|
res.json(result);
|
|
} catch (error) {
|
|
logger.error('Error listing email templates:', error);
|
|
res.status(500).json({ error: 'Failed to list email templates' });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* Get single email template
|
|
* GET /email-templates/:id
|
|
*/
|
|
router.get(
|
|
'/:id',
|
|
requireBroadcastRole,
|
|
async (req: Request, res: Response): Promise<void> => {
|
|
try {
|
|
const template = await emailTemplatesService.getById(req.params.id as string);
|
|
res.json(template);
|
|
} catch (error) {
|
|
if (error instanceof Error && error.message === 'Template not found') {
|
|
res.status(404).json({ error: 'Template not found' });
|
|
return;
|
|
}
|
|
logger.error('Error getting email template:', error);
|
|
res.status(500).json({ error: 'Failed to get email template' });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* Create email template
|
|
* POST /email-templates
|
|
*/
|
|
router.post(
|
|
'/',
|
|
requireBroadcastRole,
|
|
validate(createEmailTemplateSchema),
|
|
async (req: Request, res: Response): Promise<void> => {
|
|
try {
|
|
const template = await emailTemplatesService.create(req.body, req.user!.id);
|
|
res.status(201).json(template);
|
|
} catch (error) {
|
|
if (error instanceof Error && error.message.includes('already exists')) {
|
|
res.status(409).json({ error: error.message });
|
|
return;
|
|
}
|
|
if (error instanceof Error && error.message.includes('validation failed')) {
|
|
res.status(400).json({ error: error.message });
|
|
return;
|
|
}
|
|
logger.error('Error creating email template:', error);
|
|
res.status(500).json({ error: 'Failed to create email template' });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* Update email template
|
|
* PUT /email-templates/:id
|
|
*/
|
|
router.put(
|
|
'/:id',
|
|
requireBroadcastRole,
|
|
validate(updateEmailTemplateSchema),
|
|
async (req: Request, res: Response): Promise<void> => {
|
|
try {
|
|
const template = await emailTemplatesService.update(req.params.id as string, req.body, req.user!.id);
|
|
|
|
// Clear cache so changes take effect immediately
|
|
emailService.clearDatabaseCache(template.key);
|
|
logger.info(`Cleared template cache for: ${template.key}`);
|
|
|
|
res.json(template);
|
|
} catch (error) {
|
|
if (error instanceof Error && error.message === 'Template not found') {
|
|
res.status(404).json({ error: 'Template not found' });
|
|
return;
|
|
}
|
|
if (error instanceof Error && error.message.includes('validation failed')) {
|
|
res.status(400).json({ error: error.message });
|
|
return;
|
|
}
|
|
logger.error('Error updating email template:', error);
|
|
res.status(500).json({ error: 'Failed to update email template' });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* Delete email template
|
|
* DELETE /email-templates/:id
|
|
*/
|
|
router.delete(
|
|
'/:id',
|
|
requireBroadcastRole,
|
|
async (req: Request, res: Response): Promise<void> => {
|
|
try {
|
|
// Fetch template before deleting to get the key
|
|
const template = await emailTemplatesService.getById(req.params.id as string);
|
|
|
|
await emailTemplatesService.delete(req.params.id as string);
|
|
|
|
// Clear cache for deleted template
|
|
emailService.clearDatabaseCache(template.key);
|
|
logger.info(`Cleared template cache for deleted template: ${template.key}`);
|
|
|
|
res.status(204).send();
|
|
} catch (error) {
|
|
if (error instanceof Error && error.message === 'Template not found') {
|
|
res.status(404).json({ error: 'Template not found' });
|
|
return;
|
|
}
|
|
if (error instanceof Error && error.message.includes('Cannot delete system templates')) {
|
|
res.status(403).json({ error: error.message });
|
|
return;
|
|
}
|
|
logger.error('Error deleting email template:', error);
|
|
res.status(500).json({ error: 'Failed to delete email template' });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* Get version history
|
|
* GET /email-templates/:id/versions
|
|
*/
|
|
router.get(
|
|
'/:id/versions',
|
|
requireBroadcastRole,
|
|
async (req: Request, res: Response): Promise<void> => {
|
|
try {
|
|
const versions = await emailTemplatesService.getVersions(req.params.id as string);
|
|
res.json(versions);
|
|
} catch (error) {
|
|
logger.error('Error getting template versions:', error);
|
|
res.status(500).json({ error: 'Failed to get template versions' });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* Get specific version
|
|
* GET /email-templates/:id/versions/:versionNumber
|
|
*/
|
|
router.get(
|
|
'/:id/versions/:versionNumber',
|
|
requireBroadcastRole,
|
|
async (req: Request, res: Response): Promise<void> => {
|
|
try {
|
|
const version = await emailTemplatesService.getVersion(
|
|
req.params.id as string,
|
|
parseInt(req.params.versionNumber as string, 10),
|
|
);
|
|
res.json(version);
|
|
} catch (error) {
|
|
if (error instanceof Error && error.message === 'Version not found') {
|
|
res.status(404).json({ error: 'Version not found' });
|
|
return;
|
|
}
|
|
logger.error('Error getting template version:', error);
|
|
res.status(500).json({ error: 'Failed to get template version' });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* Rollback to previous version
|
|
* POST /email-templates/:id/rollback
|
|
*/
|
|
router.post(
|
|
'/:id/rollback',
|
|
requireBroadcastRole,
|
|
validate(rollbackToVersionSchema),
|
|
async (req: Request, res: Response): Promise<void> => {
|
|
try {
|
|
const template = await emailTemplatesService.rollbackToVersion(
|
|
req.params.id as string,
|
|
req.body,
|
|
req.user!.id,
|
|
);
|
|
res.json(template);
|
|
} catch (error) {
|
|
if (error instanceof Error && (error.message === 'Template not found' || error.message === 'Version not found')) {
|
|
res.status(404).json({ error: error.message });
|
|
return;
|
|
}
|
|
logger.error('Error rolling back template:', error);
|
|
res.status(500).json({ error: 'Failed to rollback template' });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* Validate template syntax
|
|
* POST /email-templates/validate
|
|
*/
|
|
router.post(
|
|
'/validate',
|
|
requireBroadcastRole,
|
|
validate(validateTemplateSchema),
|
|
async (req: Request, res: Response): Promise<void> => {
|
|
try {
|
|
const result = emailTemplatesService.validateTemplate(req.body);
|
|
res.json(result);
|
|
} catch (error) {
|
|
logger.error('Error validating template:', error);
|
|
res.status(500).json({ error: 'Failed to validate template' });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* Send test email
|
|
* POST /email-templates/:id/test
|
|
* Rate limited to 10 per 15 minutes per user
|
|
*/
|
|
router.post(
|
|
'/:id/test',
|
|
requireBroadcastRole,
|
|
rateLimit({
|
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
max: 10,
|
|
standardHeaders: true,
|
|
legacyHeaders: false,
|
|
store: new RedisStore({
|
|
sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise<any>,
|
|
prefix: 'rl:email-template-test:',
|
|
}),
|
|
keyGenerator: (req) => req.user!.id,
|
|
}),
|
|
validate(sendTestEmailSchema),
|
|
async (req: Request, res: Response): Promise<void> => {
|
|
try {
|
|
const result = await emailTemplatesService.sendTestEmail(req.params.id as string, req.body, req.user!.id);
|
|
res.json(result);
|
|
} catch (error) {
|
|
if (error instanceof Error && error.message === 'Template not found') {
|
|
res.status(404).json({ error: 'Template not found' });
|
|
return;
|
|
}
|
|
logger.error('Error sending test email:', error);
|
|
res.status(500).json({ error: 'Failed to send test email' });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* Get test logs for template
|
|
* GET /email-templates/:id/test-logs
|
|
*/
|
|
router.get(
|
|
'/:id/test-logs',
|
|
requireBroadcastRole,
|
|
async (req: Request, res: Response): Promise<void> => {
|
|
try {
|
|
const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : 10;
|
|
const logs = await emailTemplatesService.getTestLogs(req.params.id as string, limit);
|
|
res.json(logs);
|
|
} catch (error) {
|
|
logger.error('Error getting test logs:', error);
|
|
res.status(500).json({ error: 'Failed to get test logs' });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* Seed templates from filesystem (SUPER_ADMIN only)
|
|
* POST /email-templates/seed
|
|
*/
|
|
router.post(
|
|
'/seed',
|
|
requireRole(UserRole.SUPER_ADMIN),
|
|
async (req: Request, res: Response): Promise<void> => {
|
|
try {
|
|
await seedEmailTemplates();
|
|
logger.info('Email templates seeded via API');
|
|
res.json({ success: true, message: 'Templates seeded successfully' });
|
|
} catch (error) {
|
|
logger.error('Error seeding templates:', error);
|
|
res.status(500).json({ error: 'Failed to seed templates' });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* Clear template cache (SUPER_ADMIN only)
|
|
* POST /email-templates/clear-cache
|
|
* Body: { key?: string } - Optional template key to clear. If not provided, clears all.
|
|
*/
|
|
router.post(
|
|
'/clear-cache',
|
|
requireRole(UserRole.SUPER_ADMIN),
|
|
async (req: Request, res: Response): Promise<void> => {
|
|
try {
|
|
const { key } = req.body;
|
|
emailService.clearDatabaseCache(key);
|
|
logger.info(`Template cache cleared${key ? ` for: ${key}` : ' (all)'}`);
|
|
res.json({ success: true, cleared: key || 'all' });
|
|
} catch (error) {
|
|
logger.error('Error clearing template cache:', error);
|
|
res.status(500).json({ error: 'Failed to clear template cache' });
|
|
}
|
|
},
|
|
);
|
|
|
|
export default router;
|