2026-02-03 21:30:25 +01:00
|
|
|
/**
|
|
|
|
|
* 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'
|
2026-02-04 00:10:51 +01:00
|
|
|
import { sendStyledNotificationEmail, NOTIFICATION_EMAIL_TEMPLATES } from '@/lib/email'
|
2026-02-03 21:30:25 +01:00
|
|
|
|
|
|
|
|
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 })),
|
|
|
|
|
}
|
|
|
|
|
}),
|
2026-02-04 00:10:51 +01:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}),
|
2026-02-03 21:30:25 +01:00
|
|
|
})
|