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 })),
|
||||
}
|
||||
}),
|
||||
})
|
||||
473
src/server/services/in-app-notification.ts
Normal file
473
src/server/services/in-app-notification.ts
Normal file
@@ -0,0 +1,473 @@
|
||||
/**
|
||||
* In-App Notification Service
|
||||
*
|
||||
* Creates and manages in-app notifications for users.
|
||||
* Optionally sends email notifications based on admin settings.
|
||||
*/
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { sendNotificationEmail } from '@/lib/email'
|
||||
|
||||
// Notification priority levels
|
||||
export type NotificationPriority = 'low' | 'normal' | 'high' | 'urgent'
|
||||
|
||||
// Notification type constants
|
||||
export const NotificationTypes = {
|
||||
// Admin notifications
|
||||
FILTERING_COMPLETE: 'FILTERING_COMPLETE',
|
||||
FILTERING_FAILED: 'FILTERING_FAILED',
|
||||
NEW_APPLICATION: 'NEW_APPLICATION',
|
||||
BULK_APPLICATIONS: 'BULK_APPLICATIONS',
|
||||
DOCUMENTS_UPLOADED: 'DOCUMENTS_UPLOADED',
|
||||
EVALUATION_MILESTONE: 'EVALUATION_MILESTONE',
|
||||
ALL_EVALUATIONS_DONE: 'ALL_EVALUATIONS_DONE',
|
||||
JURY_INACTIVE: 'JURY_INACTIVE',
|
||||
DEADLINE_24H: 'DEADLINE_24H',
|
||||
DEADLINE_1H: 'DEADLINE_1H',
|
||||
ROUND_AUTO_CLOSED: 'ROUND_AUTO_CLOSED',
|
||||
EXPORT_READY: 'EXPORT_READY',
|
||||
SYSTEM_ERROR: 'SYSTEM_ERROR',
|
||||
|
||||
// Jury notifications
|
||||
ASSIGNED_TO_PROJECT: 'ASSIGNED_TO_PROJECT',
|
||||
BATCH_ASSIGNED: 'BATCH_ASSIGNED',
|
||||
PROJECT_UPDATED: 'PROJECT_UPDATED',
|
||||
ROUND_NOW_OPEN: 'ROUND_NOW_OPEN',
|
||||
REMINDER_3_DAYS: 'REMINDER_3_DAYS',
|
||||
REMINDER_24H: 'REMINDER_24H',
|
||||
REMINDER_1H: 'REMINDER_1H',
|
||||
ROUND_EXTENDED: 'ROUND_EXTENDED',
|
||||
ROUND_CLOSED: 'ROUND_CLOSED',
|
||||
THANK_YOU: 'THANK_YOU',
|
||||
RESULTS_AVAILABLE: 'RESULTS_AVAILABLE',
|
||||
|
||||
// Jury - Award specific
|
||||
AWARD_JURY_SELECTED: 'AWARD_JURY_SELECTED',
|
||||
AWARD_VOTING_OPEN: 'AWARD_VOTING_OPEN',
|
||||
AWARD_REMINDER: 'AWARD_REMINDER',
|
||||
AWARD_RESULTS: 'AWARD_RESULTS',
|
||||
|
||||
// Mentor notifications
|
||||
MENTEE_ASSIGNED: 'MENTEE_ASSIGNED',
|
||||
MENTEE_BATCH_ASSIGNED: 'MENTEE_BATCH_ASSIGNED',
|
||||
MENTEE_INTRO: 'MENTEE_INTRO',
|
||||
MENTEE_UPLOADED_DOCS: 'MENTEE_UPLOADED_DOCS',
|
||||
MENTEE_UPDATED_PROJECT: 'MENTEE_UPDATED_PROJECT',
|
||||
MENTEE_ADVANCED: 'MENTEE_ADVANCED',
|
||||
MENTEE_FINALIST: 'MENTEE_FINALIST',
|
||||
MENTEE_WON: 'MENTEE_WON',
|
||||
MENTEE_ELIMINATED: 'MENTEE_ELIMINATED',
|
||||
MENTORSHIP_TIP: 'MENTORSHIP_TIP',
|
||||
NEW_RESOURCE: 'NEW_RESOURCE',
|
||||
|
||||
// Team/Applicant notifications
|
||||
APPLICATION_SUBMITTED: 'APPLICATION_SUBMITTED',
|
||||
APPLICATION_INCOMPLETE: 'APPLICATION_INCOMPLETE',
|
||||
TEAM_INVITE_RECEIVED: 'TEAM_INVITE_RECEIVED',
|
||||
TEAM_MEMBER_JOINED: 'TEAM_MEMBER_JOINED',
|
||||
TEAM_MEMBER_LEFT: 'TEAM_MEMBER_LEFT',
|
||||
DOCUMENTS_RECEIVED: 'DOCUMENTS_RECEIVED',
|
||||
REVIEW_IN_PROGRESS: 'REVIEW_IN_PROGRESS',
|
||||
ADVANCED_SEMIFINAL: 'ADVANCED_SEMIFINAL',
|
||||
ADVANCED_FINAL: 'ADVANCED_FINAL',
|
||||
MENTOR_ASSIGNED: 'MENTOR_ASSIGNED',
|
||||
MENTOR_MESSAGE: 'MENTOR_MESSAGE',
|
||||
NOT_SELECTED: 'NOT_SELECTED',
|
||||
FEEDBACK_AVAILABLE: 'FEEDBACK_AVAILABLE',
|
||||
EVENT_INVITATION: 'EVENT_INVITATION',
|
||||
WINNER_ANNOUNCEMENT: 'WINNER_ANNOUNCEMENT',
|
||||
CERTIFICATE_READY: 'CERTIFICATE_READY',
|
||||
PROGRAM_NEWSLETTER: 'PROGRAM_NEWSLETTER',
|
||||
|
||||
// Observer notifications
|
||||
ROUND_STARTED: 'ROUND_STARTED',
|
||||
ROUND_PROGRESS: 'ROUND_PROGRESS',
|
||||
ROUND_COMPLETED: 'ROUND_COMPLETED',
|
||||
FINALISTS_ANNOUNCED: 'FINALISTS_ANNOUNCED',
|
||||
WINNERS_ANNOUNCED: 'WINNERS_ANNOUNCED',
|
||||
REPORT_AVAILABLE: 'REPORT_AVAILABLE',
|
||||
} as const
|
||||
|
||||
export type NotificationType = (typeof NotificationTypes)[keyof typeof NotificationTypes]
|
||||
|
||||
// Notification icons by type
|
||||
export const NotificationIcons: Record<string, string> = {
|
||||
[NotificationTypes.FILTERING_COMPLETE]: 'Brain',
|
||||
[NotificationTypes.FILTERING_FAILED]: 'AlertTriangle',
|
||||
[NotificationTypes.NEW_APPLICATION]: 'FileText',
|
||||
[NotificationTypes.BULK_APPLICATIONS]: 'Files',
|
||||
[NotificationTypes.DOCUMENTS_UPLOADED]: 'Upload',
|
||||
[NotificationTypes.ASSIGNED_TO_PROJECT]: 'ClipboardList',
|
||||
[NotificationTypes.ROUND_NOW_OPEN]: 'PlayCircle',
|
||||
[NotificationTypes.REMINDER_24H]: 'Clock',
|
||||
[NotificationTypes.REMINDER_1H]: 'AlertCircle',
|
||||
[NotificationTypes.ROUND_CLOSED]: 'Lock',
|
||||
[NotificationTypes.MENTEE_ASSIGNED]: 'Users',
|
||||
[NotificationTypes.MENTEE_ADVANCED]: 'TrendingUp',
|
||||
[NotificationTypes.MENTEE_WON]: 'Trophy',
|
||||
[NotificationTypes.APPLICATION_SUBMITTED]: 'CheckCircle',
|
||||
[NotificationTypes.ADVANCED_SEMIFINAL]: 'TrendingUp',
|
||||
[NotificationTypes.ADVANCED_FINAL]: 'Star',
|
||||
[NotificationTypes.MENTOR_ASSIGNED]: 'GraduationCap',
|
||||
[NotificationTypes.WINNER_ANNOUNCEMENT]: 'Trophy',
|
||||
[NotificationTypes.AWARD_VOTING_OPEN]: 'Vote',
|
||||
[NotificationTypes.AWARD_RESULTS]: 'Trophy',
|
||||
}
|
||||
|
||||
// Priority by notification type
|
||||
export const NotificationPriorities: Record<string, NotificationPriority> = {
|
||||
[NotificationTypes.FILTERING_COMPLETE]: 'high',
|
||||
[NotificationTypes.FILTERING_FAILED]: 'urgent',
|
||||
[NotificationTypes.DEADLINE_1H]: 'urgent',
|
||||
[NotificationTypes.REMINDER_1H]: 'urgent',
|
||||
[NotificationTypes.SYSTEM_ERROR]: 'urgent',
|
||||
[NotificationTypes.ASSIGNED_TO_PROJECT]: 'high',
|
||||
[NotificationTypes.ROUND_NOW_OPEN]: 'high',
|
||||
[NotificationTypes.DEADLINE_24H]: 'high',
|
||||
[NotificationTypes.REMINDER_24H]: 'high',
|
||||
[NotificationTypes.MENTEE_ASSIGNED]: 'high',
|
||||
[NotificationTypes.APPLICATION_SUBMITTED]: 'high',
|
||||
[NotificationTypes.ADVANCED_SEMIFINAL]: 'high',
|
||||
[NotificationTypes.ADVANCED_FINAL]: 'high',
|
||||
[NotificationTypes.WINNER_ANNOUNCEMENT]: 'high',
|
||||
[NotificationTypes.AWARD_VOTING_OPEN]: 'high',
|
||||
}
|
||||
|
||||
interface CreateNotificationParams {
|
||||
userId: string
|
||||
type: string
|
||||
title: string
|
||||
message: string
|
||||
linkUrl?: string
|
||||
linkLabel?: string
|
||||
icon?: string
|
||||
priority?: NotificationPriority
|
||||
metadata?: Record<string, unknown>
|
||||
groupKey?: string
|
||||
expiresAt?: Date
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a single in-app notification
|
||||
*/
|
||||
export async function createNotification(
|
||||
params: CreateNotificationParams
|
||||
): Promise<void> {
|
||||
const {
|
||||
userId,
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
linkUrl,
|
||||
linkLabel,
|
||||
icon,
|
||||
priority,
|
||||
metadata,
|
||||
groupKey,
|
||||
expiresAt,
|
||||
} = params
|
||||
|
||||
// Determine icon and priority if not provided
|
||||
const finalIcon = icon || NotificationIcons[type] || 'Bell'
|
||||
const finalPriority = priority || NotificationPriorities[type] || 'normal'
|
||||
|
||||
// Check for existing notification with same groupKey (for batching)
|
||||
if (groupKey) {
|
||||
const existingNotification = await prisma.inAppNotification.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
groupKey,
|
||||
isRead: false,
|
||||
createdAt: {
|
||||
gte: new Date(Date.now() - 60 * 60 * 1000), // Within last hour
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (existingNotification) {
|
||||
// Update existing notification instead of creating new one
|
||||
const existingMeta = existingNotification.metadata as Record<string, unknown> || {}
|
||||
const currentCount = (existingMeta.count as number) || 1
|
||||
await prisma.inAppNotification.update({
|
||||
where: { id: existingNotification.id },
|
||||
data: {
|
||||
message,
|
||||
metadata: { ...existingMeta, ...metadata, count: currentCount + 1 },
|
||||
createdAt: new Date(), // Bump to top
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Create the in-app notification
|
||||
await prisma.inAppNotification.create({
|
||||
data: {
|
||||
userId,
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
linkUrl,
|
||||
linkLabel,
|
||||
icon: finalIcon,
|
||||
priority: finalPriority,
|
||||
metadata: metadata as object | undefined,
|
||||
groupKey,
|
||||
expiresAt,
|
||||
},
|
||||
})
|
||||
|
||||
// Check if we should also send an email
|
||||
await maybeSendEmail(userId, type, title, message, linkUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create notifications for multiple users
|
||||
*/
|
||||
export async function createBulkNotifications(params: {
|
||||
userIds: string[]
|
||||
type: string
|
||||
title: string
|
||||
message: string
|
||||
linkUrl?: string
|
||||
linkLabel?: string
|
||||
icon?: string
|
||||
priority?: NotificationPriority
|
||||
metadata?: Record<string, unknown>
|
||||
}): Promise<void> {
|
||||
const {
|
||||
userIds,
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
linkUrl,
|
||||
linkLabel,
|
||||
icon,
|
||||
priority,
|
||||
metadata,
|
||||
} = params
|
||||
|
||||
const finalIcon = icon || NotificationIcons[type] || 'Bell'
|
||||
const finalPriority = priority || NotificationPriorities[type] || 'normal'
|
||||
|
||||
// Create notifications in bulk
|
||||
await prisma.inAppNotification.createMany({
|
||||
data: userIds.map((userId) => ({
|
||||
userId,
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
linkUrl,
|
||||
linkLabel,
|
||||
icon: finalIcon,
|
||||
priority: finalPriority,
|
||||
metadata: metadata as object | undefined,
|
||||
})),
|
||||
})
|
||||
|
||||
// Check email settings and send emails
|
||||
for (const userId of userIds) {
|
||||
await maybeSendEmail(userId, type, title, message, linkUrl)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify all admin users
|
||||
*/
|
||||
export async function notifyAdmins(params: {
|
||||
type: string
|
||||
title: string
|
||||
message: string
|
||||
linkUrl?: string
|
||||
linkLabel?: string
|
||||
icon?: string
|
||||
priority?: NotificationPriority
|
||||
metadata?: Record<string, unknown>
|
||||
}): Promise<void> {
|
||||
const admins = await prisma.user.findMany({
|
||||
where: {
|
||||
role: { in: ['SUPER_ADMIN', 'PROGRAM_ADMIN'] },
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
if (admins.length === 0) return
|
||||
|
||||
await createBulkNotifications({
|
||||
...params,
|
||||
userIds: admins.map((a) => a.id),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify all jury members for a specific round
|
||||
*/
|
||||
export async function notifyRoundJury(
|
||||
roundId: string,
|
||||
params: Omit<CreateNotificationParams, 'userId'>
|
||||
): Promise<void> {
|
||||
const assignments = await prisma.assignment.findMany({
|
||||
where: { roundId },
|
||||
select: { userId: true },
|
||||
distinct: ['userId'],
|
||||
})
|
||||
|
||||
if (assignments.length === 0) return
|
||||
|
||||
await createBulkNotifications({
|
||||
...params,
|
||||
userIds: assignments.map((a) => a.userId),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify team members of a project
|
||||
*/
|
||||
export async function notifyProjectTeam(
|
||||
projectId: string,
|
||||
params: Omit<CreateNotificationParams, 'userId'>
|
||||
): Promise<void> {
|
||||
const teamMembers = await prisma.teamMember.findMany({
|
||||
where: { projectId },
|
||||
include: { user: { select: { id: true } } },
|
||||
})
|
||||
|
||||
const userIds = teamMembers
|
||||
.filter((tm) => tm.user)
|
||||
.map((tm) => tm.user!.id)
|
||||
|
||||
if (userIds.length === 0) return
|
||||
|
||||
await createBulkNotifications({
|
||||
...params,
|
||||
userIds,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify assigned mentors of a project
|
||||
*/
|
||||
export async function notifyProjectMentors(
|
||||
projectId: string,
|
||||
params: Omit<CreateNotificationParams, 'userId'>
|
||||
): Promise<void> {
|
||||
const mentorAssignments = await prisma.mentorAssignment.findMany({
|
||||
where: { projectId },
|
||||
select: { mentorId: true },
|
||||
})
|
||||
|
||||
if (mentorAssignments.length === 0) return
|
||||
|
||||
await createBulkNotifications({
|
||||
...params,
|
||||
userIds: mentorAssignments.map((ma) => ma.mentorId),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Check email settings and send email if enabled
|
||||
*/
|
||||
async function maybeSendEmail(
|
||||
userId: string,
|
||||
type: string,
|
||||
title: string,
|
||||
message: string,
|
||||
linkUrl?: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Check if email is enabled for this notification type
|
||||
const emailSetting = await prisma.notificationEmailSetting.findUnique({
|
||||
where: { notificationType: type },
|
||||
})
|
||||
|
||||
// If no setting exists, don't send email by default
|
||||
if (!emailSetting || !emailSetting.sendEmail) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check user's notification preference
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { email: true, name: true, notificationPreference: true },
|
||||
})
|
||||
|
||||
if (!user || user.notificationPreference === 'NONE') {
|
||||
return
|
||||
}
|
||||
|
||||
// Send the email
|
||||
const subject = emailSetting.emailSubject || title
|
||||
const body = emailSetting.emailTemplate
|
||||
? emailSetting.emailTemplate
|
||||
.replace('{title}', title)
|
||||
.replace('{message}', message)
|
||||
.replace('{link}', linkUrl || '')
|
||||
: message
|
||||
|
||||
await sendNotificationEmail(user.email, user.name || 'User', subject, body, linkUrl)
|
||||
} catch (error) {
|
||||
// Log but don't fail the notification creation
|
||||
console.error('[Notification] Failed to send email:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a notification as read
|
||||
*/
|
||||
export async function markNotificationAsRead(
|
||||
notificationId: string,
|
||||
userId: string
|
||||
): Promise<void> {
|
||||
await prisma.inAppNotification.updateMany({
|
||||
where: { id: notificationId, userId },
|
||||
data: { isRead: true, readAt: new Date() },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all notifications as read for a user
|
||||
*/
|
||||
export async function markAllNotificationsAsRead(userId: string): Promise<void> {
|
||||
await prisma.inAppNotification.updateMany({
|
||||
where: { userId, isRead: false },
|
||||
data: { isRead: true, readAt: new Date() },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unread notification count for a user
|
||||
*/
|
||||
export async function getUnreadCount(userId: string): Promise<number> {
|
||||
return prisma.inAppNotification.count({
|
||||
where: { userId, isRead: false },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete expired notifications
|
||||
*/
|
||||
export async function deleteExpiredNotifications(): Promise<number> {
|
||||
const result = await prisma.inAppNotification.deleteMany({
|
||||
where: {
|
||||
expiresAt: { lt: new Date() },
|
||||
},
|
||||
})
|
||||
return result.count
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete old read notifications (cleanup job)
|
||||
*/
|
||||
export async function deleteOldNotifications(olderThanDays: number): Promise<number> {
|
||||
const cutoffDate = new Date()
|
||||
cutoffDate.setDate(cutoffDate.getDate() - olderThanDays)
|
||||
|
||||
const result = await prisma.inAppNotification.deleteMany({
|
||||
where: {
|
||||
isRead: true,
|
||||
createdAt: { lt: cutoffDate },
|
||||
},
|
||||
})
|
||||
return result.count
|
||||
}
|
||||
Reference in New Issue
Block a user