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
|
// 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 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
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