/** * Notification Router * * Handles in-app notification CRUD operations for users. */ import { z } from 'zod' import { router, protectedProcedure, adminProcedure } from '../trpc' import { markNotificationAsRead, markAllNotificationsAsRead, getUnreadCount, deleteExpiredNotifications, deleteOldNotifications, NotificationIcons, NotificationPriorities, } from '../services/in-app-notification' import { sendStyledNotificationEmail, NOTIFICATION_EMAIL_TEMPLATES } from '@/lib/email' export const notificationRouter = router({ /** * List notifications for the current user */ list: protectedProcedure .input( z.object({ unreadOnly: z.boolean().default(false), limit: z.number().int().min(1).max(100).default(50), cursor: z.string().optional(), // For infinite scroll pagination }) ) .query(async ({ ctx, input }) => { const { unreadOnly, limit, cursor } = input const userId = ctx.user.id const where = { userId, ...(unreadOnly && { isRead: false }), // Don't show expired notifications OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }], } const notifications = await ctx.prisma.inAppNotification.findMany({ where, take: limit + 1, // Fetch one extra to check if there are more orderBy: { createdAt: 'desc' }, ...(cursor && { cursor: { id: cursor }, skip: 1, // Skip the cursor item }), }) let nextCursor: string | undefined if (notifications.length > limit) { const nextItem = notifications.pop() nextCursor = nextItem?.id } return { notifications, nextCursor, } }), /** * Get unread notification count for the current user */ getUnreadCount: protectedProcedure.query(async ({ ctx }) => { return getUnreadCount(ctx.user.id) }), /** * Check if there are any urgent unread notifications */ hasUrgent: protectedProcedure.query(async ({ ctx }) => { const count = await ctx.prisma.inAppNotification.count({ where: { userId: ctx.user.id, isRead: false, priority: 'urgent', OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }], }, }) return count > 0 }), /** * Mark a single notification as read */ markAsRead: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { await markNotificationAsRead(input.id, ctx.user.id) return { success: true } }), /** * Mark all notifications as read for the current user */ markAllAsRead: protectedProcedure.mutation(async ({ ctx }) => { await markAllNotificationsAsRead(ctx.user.id) return { success: true } }), /** * Delete a notification (user can only delete their own) */ delete: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { await ctx.prisma.inAppNotification.deleteMany({ where: { id: input.id, userId: ctx.user.id, // Ensure user can only delete their own }, }) return { success: true } }), /** * Get notification email settings (admin only) */ getEmailSettings: adminProcedure.query(async ({ ctx }) => { return ctx.prisma.notificationEmailSetting.findMany({ orderBy: [{ category: 'asc' }, { label: 'asc' }], include: { updatedBy: { select: { name: true, email: true } }, }, }) }), /** * Update a notification email setting (admin only) */ updateEmailSetting: adminProcedure .input( z.object({ notificationType: z.string(), sendEmail: z.boolean(), emailSubject: z.string().optional(), emailTemplate: z.string().optional(), }) ) .mutation(async ({ ctx, input }) => { const { notificationType, sendEmail, emailSubject, emailTemplate } = input return ctx.prisma.notificationEmailSetting.upsert({ where: { notificationType }, update: { sendEmail, emailSubject, emailTemplate, updatedById: ctx.user.id, }, create: { notificationType, category: 'custom', label: notificationType, sendEmail, emailSubject, emailTemplate, updatedById: ctx.user.id, }, }) }), /** * Delete expired notifications (admin cleanup) */ deleteExpired: adminProcedure.mutation(async () => { const count = await deleteExpiredNotifications() return { deletedCount: count } }), /** * Delete old read notifications (admin cleanup) */ deleteOld: adminProcedure .input(z.object({ olderThanDays: z.number().int().min(1).max(365).default(30) })) .mutation(async ({ input }) => { const count = await deleteOldNotifications(input.olderThanDays) return { deletedCount: count } }), /** * Get notification icon and priority mappings (for UI) */ getMappings: protectedProcedure.query(() => { return { icons: NotificationIcons, priorities: NotificationPriorities, } }), /** * Admin: Get notification statistics */ getStats: adminProcedure.query(async ({ ctx }) => { const [total, unread, byType, byPriority] = await Promise.all([ ctx.prisma.inAppNotification.count(), ctx.prisma.inAppNotification.count({ where: { isRead: false } }), ctx.prisma.inAppNotification.groupBy({ by: ['type'], _count: true, orderBy: { _count: { type: 'desc' } }, take: 10, }), ctx.prisma.inAppNotification.groupBy({ by: ['priority'], _count: true, }), ]) return { total, unread, readRate: total > 0 ? ((total - unread) / total) * 100 : 0, byType: byType.map((t) => ({ type: t.type, count: t._count })), byPriority: byPriority.map((p) => ({ priority: p.priority, count: p._count })), } }), /** * Send a test notification email to the current admin */ sendTestEmail: adminProcedure .input(z.object({ notificationType: z.string() })) .mutation(async ({ ctx, input }) => { const { notificationType } = input // Check if this notification type has a styled template const hasStyledTemplate = notificationType in NOTIFICATION_EMAIL_TEMPLATES // Get setting for label const setting = await ctx.prisma.notificationEmailSetting.findUnique({ 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 label = setting?.label || notificationType try { await sendStyledNotificationEmail( ctx.user.email, ctx.user.name || 'Admin', notificationType, { title: `[TEST] ${label}`, message: `This is a test email for the "${label}" notification type.`, linkUrl: `${process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'}/admin/settings`, linkLabel: 'Back to Settings', metadata, } ) return { success: true, message: `Test email sent to ${ctx.user.email}`, hasStyledTemplate, } } catch (error) { return { success: false, message: error instanceof Error ? error.message : 'Failed to send test email', hasStyledTemplate, } } }), })