MOPC-App/src/server/routers/notification.ts

365 lines
10 KiB
TypeScript

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