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

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