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:
@@ -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
|
||||
|
||||
@@ -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' },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
221
src/server/routers/notification.ts
Normal file
221
src/server/routers/notification.ts
Normal 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 })),
|
||||
}
|
||||
}),
|
||||
})
|
||||
Reference in New Issue
Block a user