feat(notifications): renderNotificationEmail + previewEmailTemplate + logistics sample data
- Export `renderNotificationEmail` from email.ts (pure template resolver, no send) - Refactor `sendStyledNotificationEmail` to delegate to `renderNotificationEmail` - Hoist sampleData to module-level `NOTIFICATION_SAMPLE_DATA` in notification router - Add 8 logistics sample entries (FINALIST_*/TRAVEL_CONFIRMED/VISA_STATUS_UPDATE) - Add `notification.previewEmailTemplate` adminProcedure query (returns subject/html/hasStyledTemplate) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, TemplateGenerator> = {
|
||||
),
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
// 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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, Record<string, unknown>> = {
|
||||
// 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<string, Record<string, unknown>> = {
|
||||
// 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,
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
62
tests/unit/notification-preview.test.ts
Normal file
62
tests/unit/notification-preview.test.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user