diff --git a/src/lib/email.ts b/src/lib/email.ts index eb2148a..343ded6 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -336,7 +336,7 @@ function statCard(label: string, value: string | number): string { // Email Templates // ============================================================================= -interface EmailTemplate { +export interface EmailTemplate { subject: string html: string text: string @@ -2689,6 +2689,23 @@ export const NOTIFICATION_EMAIL_TEMPLATES: Record = { ), } +/** + * Resolve the email template for a notification type without sending. + * Exported for use by preview endpoints. + */ +export function renderNotificationEmail( + name: string, + type: string, + context: NotificationEmailContext, + subjectOverride?: string, +): EmailTemplate { + const generator = NOTIFICATION_EMAIL_TEMPLATES[type] + const template = generator + ? generator({ ...context, linkUrl: ensureAbsoluteUrl(context.linkUrl), name }) + : getNotificationEmailTemplate(name, subjectOverride || context.title, context.message, ensureAbsoluteUrl(context.linkUrl)) + return subjectOverride ? { ...template, subject: subjectOverride } : template +} + /** * Send styled notification email using the appropriate template */ @@ -2699,32 +2716,7 @@ export async function sendStyledNotificationEmail( context: NotificationEmailContext, subjectOverride?: string ): Promise { - // Safety net: always ensure linkUrl is absolute before passing to templates - const safeContext = { - ...context, - linkUrl: ensureAbsoluteUrl(context.linkUrl), - } - const templateGenerator = NOTIFICATION_EMAIL_TEMPLATES[type] - - let template: EmailTemplate - - if (templateGenerator) { - // Use styled template - template = templateGenerator({ ...safeContext, name }) - // Apply subject override if provided - if (subjectOverride) { - template.subject = subjectOverride - } - } else { - // Fall back to generic template - template = getNotificationEmailTemplate( - name, - subjectOverride || safeContext.title, - safeContext.message, - safeContext.linkUrl - ) - } - + const template = renderNotificationEmail(name, type, context, subjectOverride) await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html }) } diff --git a/src/server/routers/notification.ts b/src/server/routers/notification.ts index 48c70ec..f5988e7 100644 --- a/src/server/routers/notification.ts +++ b/src/server/routers/notification.ts @@ -15,7 +15,123 @@ import { NotificationIcons, NotificationPriorities, } from '../services/in-app-notification' -import { sendStyledNotificationEmail, NOTIFICATION_EMAIL_TEMPLATES } from '@/lib/email' +import { sendStyledNotificationEmail, renderNotificationEmail, NOTIFICATION_EMAIL_TEMPLATES } from '@/lib/email' + +/** + * Sample data used for test emails and previews, keyed by notification type. + */ +const NOTIFICATION_SAMPLE_DATA: Record> = { + // Team notifications + ADVANCED_SEMIFINAL: { + projectName: 'Ocean Cleanup Initiative', + programName: 'Monaco Ocean Protection Challenge 2026', + nextSteps: 'Prepare your presentation for the semi-final round.', + }, + ADVANCED_FINAL: { + projectName: 'Ocean Cleanup Initiative', + programName: 'Monaco Ocean Protection Challenge 2026', + nextSteps: 'Get ready for the final presentation in Monaco.', + }, + MENTOR_ASSIGNED: { + projectName: 'Ocean Cleanup Initiative', + mentorName: 'Dr. Marine Expert', + mentorBio: 'Expert in marine conservation with 20 years of experience.', + }, + NOT_SELECTED: { + projectName: 'Ocean Cleanup Initiative', + roundName: 'Semi-Final Round', + }, + WINNER_ANNOUNCEMENT: { + projectName: 'Ocean Cleanup Initiative', + awardName: 'Grand Prize', + prizeDetails: '€50,000 and mentorship program', + }, + + // Jury notifications + ASSIGNED_TO_PROJECT: { + projectName: 'Ocean Cleanup Initiative', + roundName: 'Semi-Final Round', + deadline: 'Friday, March 15, 2026', + }, + BATCH_ASSIGNED: { + projectCount: 5, + roundName: 'Semi-Final Round', + deadline: 'Friday, March 15, 2026', + }, + ROUND_NOW_OPEN: { + roundName: 'Semi-Final Round', + projectCount: 12, + deadline: 'Friday, March 15, 2026', + }, + REMINDER_24H: { + pendingCount: 3, + roundName: 'Semi-Final Round', + deadline: 'Tomorrow at 5:00 PM', + }, + REMINDER_1H: { + pendingCount: 2, + roundName: 'Semi-Final Round', + deadline: 'Today at 5:00 PM', + }, + AWARD_VOTING_OPEN: { + awardName: 'Innovation Award', + finalistCount: 6, + deadline: 'Friday, March 15, 2026', + }, + + // Mentor notifications + MENTEE_ASSIGNED: { + projectName: 'Ocean Cleanup Initiative', + teamLeadName: 'John Smith', + teamLeadEmail: 'john@example.com', + }, + MENTEE_ADVANCED: { + projectName: 'Ocean Cleanup Initiative', + roundName: 'Semi-Final Round', + nextRoundName: 'Final Round', + }, + MENTEE_WON: { + projectName: 'Ocean Cleanup Initiative', + awardName: 'Innovation Award', + }, + + // Admin notifications + NEW_APPLICATION: { + projectName: 'New Ocean Project', + applicantName: 'Jane Doe', + applicantEmail: 'jane@example.com', + programName: 'Monaco Ocean Protection Challenge 2026', + }, + FILTERING_COMPLETE: { + roundName: 'Initial Review', + passedCount: 45, + flaggedCount: 12, + filteredCount: 8, + }, + FILTERING_FAILED: { + roundName: 'Initial Review', + error: 'Connection timeout', + }, + + // Logistics notifications + FINALIST_CONFIRMED: { projectTitle: 'Ocean Cleanup Initiative', category: 'STARTUP' }, + FINALIST_DECLINED: { projectTitle: 'Ocean Cleanup Initiative', category: 'STARTUP' }, + FINALIST_EXPIRED: { projectTitle: 'Ocean Cleanup Initiative', category: 'STARTUP' }, + FINALIST_WAITLIST_PROMOTED: { projectTitle: 'Reef Guardians', category: 'STARTUP' }, + FINALIST_REMINDER: { projectTitle: 'Ocean Cleanup Initiative', deadline: new Date(Date.now() + 86_400_000).toISOString() }, + FINALIST_WITHDRAWN: { projectTitle: 'Ocean Cleanup Initiative', reason: 'Schedule conflict' }, + TRAVEL_CONFIRMED: { + projectTitle: 'Ocean Cleanup Initiative', + arrivalAt: new Date(Date.now() + 5 * 86_400_000).toISOString(), + arrivalFlightNumber: 'AF1234', + arrivalAirport: 'NCE', + departureAt: new Date(Date.now() + 7 * 86_400_000).toISOString(), + departureFlightNumber: 'AF1235', + departureAirport: 'NCE', + hotel: { name: 'Hotel de Paris', address: 'Place du Casino, Monaco', link: 'https://example.com' }, + }, + VISA_STATUS_UPDATE: { projectTitle: 'Ocean Cleanup Initiative', status: 'GRANTED' }, +} export const notificationRouter = router({ /** @@ -253,102 +369,7 @@ export const notificationRouter = router({ where: { notificationType }, }) - // Sample data for test emails based on category - const sampleData: Record> = { - // Team notifications - ADVANCED_SEMIFINAL: { - projectName: 'Ocean Cleanup Initiative', - programName: 'Monaco Ocean Protection Challenge 2026', - nextSteps: 'Prepare your presentation for the semi-final round.', - }, - ADVANCED_FINAL: { - projectName: 'Ocean Cleanup Initiative', - programName: 'Monaco Ocean Protection Challenge 2026', - nextSteps: 'Get ready for the final presentation in Monaco.', - }, - MENTOR_ASSIGNED: { - projectName: 'Ocean Cleanup Initiative', - mentorName: 'Dr. Marine Expert', - mentorBio: 'Expert in marine conservation with 20 years of experience.', - }, - NOT_SELECTED: { - projectName: 'Ocean Cleanup Initiative', - roundName: 'Semi-Final Round', - }, - WINNER_ANNOUNCEMENT: { - projectName: 'Ocean Cleanup Initiative', - awardName: 'Grand Prize', - prizeDetails: '€50,000 and mentorship program', - }, - - // Jury notifications - ASSIGNED_TO_PROJECT: { - projectName: 'Ocean Cleanup Initiative', - roundName: 'Semi-Final Round', - deadline: 'Friday, March 15, 2026', - }, - BATCH_ASSIGNED: { - projectCount: 5, - roundName: 'Semi-Final Round', - deadline: 'Friday, March 15, 2026', - }, - ROUND_NOW_OPEN: { - roundName: 'Semi-Final Round', - projectCount: 12, - deadline: 'Friday, March 15, 2026', - }, - REMINDER_24H: { - pendingCount: 3, - roundName: 'Semi-Final Round', - deadline: 'Tomorrow at 5:00 PM', - }, - REMINDER_1H: { - pendingCount: 2, - roundName: 'Semi-Final Round', - deadline: 'Today at 5:00 PM', - }, - AWARD_VOTING_OPEN: { - awardName: 'Innovation Award', - finalistCount: 6, - deadline: 'Friday, March 15, 2026', - }, - - // Mentor notifications - MENTEE_ASSIGNED: { - projectName: 'Ocean Cleanup Initiative', - teamLeadName: 'John Smith', - teamLeadEmail: 'john@example.com', - }, - MENTEE_ADVANCED: { - projectName: 'Ocean Cleanup Initiative', - roundName: 'Semi-Final Round', - nextRoundName: 'Final Round', - }, - MENTEE_WON: { - projectName: 'Ocean Cleanup Initiative', - awardName: 'Innovation Award', - }, - - // Admin notifications - NEW_APPLICATION: { - projectName: 'New Ocean Project', - applicantName: 'Jane Doe', - applicantEmail: 'jane@example.com', - programName: 'Monaco Ocean Protection Challenge 2026', - }, - FILTERING_COMPLETE: { - roundName: 'Initial Review', - passedCount: 45, - flaggedCount: 12, - filteredCount: 8, - }, - FILTERING_FAILED: { - roundName: 'Initial Review', - error: 'Connection timeout', - }, - } - - const metadata = sampleData[notificationType] || {} + const metadata = NOTIFICATION_SAMPLE_DATA[notificationType] || {} const label = setting?.label || notificationType try { @@ -378,4 +399,35 @@ export const notificationRouter = router({ } } }), + + /** + * Preview a notification email template without sending it. + * Returns the rendered HTML and subject so admins can see the email before sending. + */ + previewEmailTemplate: adminProcedure + .input(z.object({ notificationType: z.string() })) + .query(async ({ ctx, input }) => { + const setting = await ctx.prisma.notificationEmailSetting.findUnique({ + where: { notificationType: input.notificationType }, + }) + const label = setting?.label || input.notificationType + const metadata = NOTIFICATION_SAMPLE_DATA[input.notificationType] || {} + const rendered = renderNotificationEmail( + ctx.user.name || 'Admin', + input.notificationType, + { + title: label, + message: `Preview of the "${label}" email.`, + linkUrl: `${process.env.NEXTAUTH_URL || ''}/applicant`, + linkLabel: 'Open', + metadata, + }, + setting?.emailSubject || undefined, + ) + return { + subject: rendered.subject, + html: rendered.html, + hasStyledTemplate: input.notificationType in NOTIFICATION_EMAIL_TEMPLATES, + } + }), }) diff --git a/tests/unit/notification-preview.test.ts b/tests/unit/notification-preview.test.ts new file mode 100644 index 0000000..b3097f0 --- /dev/null +++ b/tests/unit/notification-preview.test.ts @@ -0,0 +1,62 @@ +/** + * Task 1: renderNotificationEmail + previewEmailTemplate + * + * Tests that: + * 1. previewEmailTemplate returns rendered HTML and a non-empty subject for a + * type that has a styled template (VISA_STATUS_UPDATE). + * 2. previewEmailTemplate returns non-empty HTML for a fallback type + * (FINALIST_EXPIRED — no registered template, uses generic fallback). + */ +import { afterAll, describe, expect, it } from 'vitest' +import { createCaller, prisma } from '../setup' +import { createTestUser, cleanupTestData, uid } from '../helpers' +import { notificationRouter } from '../../src/server/routers/notification' + +describe('notification.previewEmailTemplate', () => { + let adminId: string + let adminEmail: string + + afterAll(async () => { + if (adminId) { + await prisma.user.deleteMany({ where: { id: adminId } }) + } + }) + + async function getAdminCaller() { + if (!adminId) { + const admin = await createTestUser('SUPER_ADMIN', { + name: 'Preview Admin', + email: `preview-admin-${uid()}@test.local`, + }) + adminId = admin.id + adminEmail = admin.email + } + return createCaller(notificationRouter, { + id: adminId, + email: adminEmail, + name: 'Preview Admin', + role: 'SUPER_ADMIN', + }) + } + + it('returns HTML containing a visa-related string and a non-empty subject for VISA_STATUS_UPDATE', async () => { + const caller = await getAdminCaller() + const result = await caller.previewEmailTemplate({ notificationType: 'VISA_STATUS_UPDATE' }) + + expect(result.subject).toBeTruthy() + expect(result.html).toBeTruthy() + // The visa template includes 'visa' or 'Grand' in its content + const lower = result.html.toLowerCase() + expect(lower.includes('visa') || lower.includes('grand')).toBe(true) + expect(result.hasStyledTemplate).toBe(true) + }) + + it('returns non-empty HTML for FINALIST_EXPIRED (fallback — no styled template)', async () => { + const caller = await getAdminCaller() + const result = await caller.previewEmailTemplate({ notificationType: 'FINALIST_EXPIRED' }) + + expect(result.html).toBeTruthy() + expect(result.subject).toBeTruthy() + expect(result.hasStyledTemplate).toBe(false) + }) +})