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:
Matt
2026-06-04 16:39:29 +02:00
parent 27bdf8cdef
commit e5788b3e9d
3 changed files with 230 additions and 124 deletions

View File

@@ -336,7 +336,7 @@ function statCard(label: string, value: string | number): string {
// Email Templates // Email Templates
// ============================================================================= // =============================================================================
interface EmailTemplate { export interface EmailTemplate {
subject: string subject: string
html: string html: string
text: 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 * Send styled notification email using the appropriate template
*/ */
@@ -2699,32 +2716,7 @@ export async function sendStyledNotificationEmail(
context: NotificationEmailContext, context: NotificationEmailContext,
subjectOverride?: string subjectOverride?: string
): Promise<void> { ): Promise<void> {
// Safety net: always ensure linkUrl is absolute before passing to templates const template = renderNotificationEmail(name, type, context, subjectOverride)
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
)
}
await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html }) await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html })
} }

View File

@@ -15,7 +15,123 @@ import {
NotificationIcons, NotificationIcons,
NotificationPriorities, NotificationPriorities,
} from '../services/in-app-notification' } 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({ export const notificationRouter = router({
/** /**
@@ -253,102 +369,7 @@ export const notificationRouter = router({
where: { notificationType }, where: { notificationType },
}) })
// Sample data for test emails based on category const metadata = NOTIFICATION_SAMPLE_DATA[notificationType] || {}
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 label = setting?.label || notificationType const label = setting?.label || notificationType
try { 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,
}
}),
}) })

View 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)
})
})