Add notification bell system and MOPC onboarding form

Notification System:
- Add InAppNotification and NotificationEmailSetting database models
- Create notification service with 60+ notification types for all user roles
- Add notification router with CRUD endpoints
- Build NotificationBell UI component with dropdown and unread count
- Integrate bell into admin, jury, mentor, and observer navs
- Add notification email settings admin UI in Settings > Notifications
- Add notification triggers to filtering router (complete/failed)
- Add sendNotificationEmail function to email library
- Add formatRelativeTime utility function

MOPC Onboarding Form:
- Create /apply landing page with auto-redirect for single form
- Create seed script for MOPC 2026 application form (6 steps)
- Create seed script for default notification email settings

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-03 21:30:25 +01:00
parent e1968d45df
commit 0277768ed7
18 changed files with 2344 additions and 13 deletions

View File

@@ -30,6 +30,7 @@ import { applicationRouter } from './application'
import { mentorRouter } from './mentor'
import { filteringRouter } from './filtering'
import { specialAwardRouter } from './specialAward'
import { notificationRouter } from './notification'
/**
* Root tRPC router that combines all domain routers
@@ -66,6 +67,7 @@ export const appRouter = router({
mentor: mentorRouter,
filtering: filteringRouter,
specialAward: specialAwardRouter,
notification: notificationRouter,
})
export type AppRouter = typeof appRouter

View File

@@ -6,6 +6,10 @@ import { executeFilteringRules, type ProgressCallback } from '../services/ai-fil
import { logAudit } from '../utils/audit'
import { isOpenAIConfigured, testOpenAIConnection } from '@/lib/openai'
import { prisma } from '@/lib/prisma'
import {
notifyAdmins,
NotificationTypes,
} from '../services/in-app-notification'
// Background job execution function
async function runFilteringJob(jobId: string, roundId: string, userId: string) {
@@ -123,6 +127,30 @@ async function runFilteringJob(jobId: string, roundId: string, userId: string) {
flagged: flaggedCount,
},
})
// Get round name for notification
const round = await prisma.round.findUnique({
where: { id: roundId },
select: { name: true },
})
// Notify admins that filtering is complete
await notifyAdmins({
type: NotificationTypes.FILTERING_COMPLETE,
title: 'AI Filtering Complete',
message: `Filtering complete for ${round?.name || 'round'}: ${passedCount} passed, ${flaggedCount} flagged, ${filteredCount} filtered out`,
linkUrl: `/admin/rounds/${roundId}/filtering/results`,
linkLabel: 'View Results',
priority: 'high',
metadata: {
roundId,
jobId,
projectCount: projects.length,
passedCount,
filteredCount,
flaggedCount,
},
})
} catch (error) {
console.error('[Filtering Job] Error:', error)
await prisma.filteringJob.update({
@@ -133,6 +161,17 @@ async function runFilteringJob(jobId: string, roundId: string, userId: string) {
errorMessage: error instanceof Error ? error.message : 'Unknown error',
},
})
// Notify admins of failure
await notifyAdmins({
type: NotificationTypes.FILTERING_FAILED,
title: 'AI Filtering Failed',
message: `Filtering job failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
linkUrl: `/admin/rounds/${roundId}/filtering`,
linkLabel: 'View Details',
priority: 'urgent',
metadata: { roundId, jobId, error: error instanceof Error ? error.message : 'Unknown error' },
})
}
}

View File

@@ -0,0 +1,221 @@
/**
* 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'
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 })),
}
}),
})