139 lines
4.8 KiB
TypeScript
139 lines
4.8 KiB
TypeScript
|
|
import { z } from 'zod'
|
||
|
|
import { TRPCError } from '@trpc/server'
|
||
|
|
import { router, adminProcedure } from '../../trpc'
|
||
|
|
import { createBulkNotifications, NotificationTypes } from '../../services/in-app-notification'
|
||
|
|
import { logAudit } from '@/server/utils/audit'
|
||
|
|
|
||
|
|
export const assignmentNotificationsRouter = router({
|
||
|
|
/**
|
||
|
|
* Notify all jurors of their current assignments for a round (admin only).
|
||
|
|
* Sends in-app notifications (emails are handled by maybeSendEmail via createBulkNotifications).
|
||
|
|
*/
|
||
|
|
notifyJurorsOfAssignments: adminProcedure
|
||
|
|
.input(z.object({ roundId: z.string() }))
|
||
|
|
.mutation(async ({ ctx, input }) => {
|
||
|
|
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||
|
|
where: { id: input.roundId },
|
||
|
|
select: { name: true, windowCloseAt: true },
|
||
|
|
})
|
||
|
|
|
||
|
|
// Get all assignments grouped by user
|
||
|
|
const assignments = await ctx.prisma.assignment.findMany({
|
||
|
|
where: { roundId: input.roundId },
|
||
|
|
select: { userId: true },
|
||
|
|
})
|
||
|
|
|
||
|
|
if (assignments.length === 0) {
|
||
|
|
return { sent: 0, jurorCount: 0 }
|
||
|
|
}
|
||
|
|
|
||
|
|
// Count assignments per user
|
||
|
|
const userCounts: Record<string, number> = {}
|
||
|
|
for (const a of assignments) {
|
||
|
|
userCounts[a.userId] = (userCounts[a.userId] || 0) + 1
|
||
|
|
}
|
||
|
|
|
||
|
|
const deadline = round.windowCloseAt
|
||
|
|
? new Date(round.windowCloseAt).toLocaleDateString('en-US', {
|
||
|
|
weekday: 'long',
|
||
|
|
year: 'numeric',
|
||
|
|
month: 'long',
|
||
|
|
day: 'numeric',
|
||
|
|
})
|
||
|
|
: undefined
|
||
|
|
|
||
|
|
// Create in-app notifications grouped by project count
|
||
|
|
const usersByProjectCount = new Map<number, string[]>()
|
||
|
|
for (const [userId, projectCount] of Object.entries(userCounts)) {
|
||
|
|
const existing = usersByProjectCount.get(projectCount) || []
|
||
|
|
existing.push(userId)
|
||
|
|
usersByProjectCount.set(projectCount, existing)
|
||
|
|
}
|
||
|
|
|
||
|
|
let totalSent = 0
|
||
|
|
for (const [projectCount, userIds] of usersByProjectCount) {
|
||
|
|
if (userIds.length === 0) continue
|
||
|
|
await createBulkNotifications({
|
||
|
|
userIds,
|
||
|
|
type: NotificationTypes.BATCH_ASSIGNED,
|
||
|
|
title: `${projectCount} Projects Assigned`,
|
||
|
|
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${round.name || 'this round'}.`,
|
||
|
|
linkUrl: `/jury/competitions`,
|
||
|
|
linkLabel: 'View Assignments',
|
||
|
|
metadata: { projectCount, roundName: round.name, deadline },
|
||
|
|
})
|
||
|
|
totalSent += userIds.length
|
||
|
|
}
|
||
|
|
|
||
|
|
await logAudit({
|
||
|
|
prisma: ctx.prisma,
|
||
|
|
userId: ctx.user.id,
|
||
|
|
action: 'NOTIFY_JURORS_OF_ASSIGNMENTS',
|
||
|
|
entityType: 'Round',
|
||
|
|
entityId: input.roundId,
|
||
|
|
detailsJson: {
|
||
|
|
jurorCount: Object.keys(userCounts).length,
|
||
|
|
totalAssignments: assignments.length,
|
||
|
|
},
|
||
|
|
ipAddress: ctx.ip,
|
||
|
|
userAgent: ctx.userAgent,
|
||
|
|
})
|
||
|
|
|
||
|
|
return { sent: totalSent, jurorCount: Object.keys(userCounts).length }
|
||
|
|
}),
|
||
|
|
|
||
|
|
notifySingleJurorOfAssignments: adminProcedure
|
||
|
|
.input(z.object({ roundId: z.string(), userId: z.string() }))
|
||
|
|
.mutation(async ({ ctx, input }) => {
|
||
|
|
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||
|
|
where: { id: input.roundId },
|
||
|
|
select: { name: true, windowCloseAt: true },
|
||
|
|
})
|
||
|
|
|
||
|
|
const assignments = await ctx.prisma.assignment.findMany({
|
||
|
|
where: { roundId: input.roundId, userId: input.userId },
|
||
|
|
select: { id: true },
|
||
|
|
})
|
||
|
|
|
||
|
|
if (assignments.length === 0) {
|
||
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'No assignments found for this juror in this round' })
|
||
|
|
}
|
||
|
|
|
||
|
|
const projectCount = assignments.length
|
||
|
|
const deadline = round.windowCloseAt
|
||
|
|
? new Date(round.windowCloseAt).toLocaleDateString('en-US', {
|
||
|
|
weekday: 'long',
|
||
|
|
year: 'numeric',
|
||
|
|
month: 'long',
|
||
|
|
day: 'numeric',
|
||
|
|
})
|
||
|
|
: undefined
|
||
|
|
|
||
|
|
await createBulkNotifications({
|
||
|
|
userIds: [input.userId],
|
||
|
|
type: NotificationTypes.BATCH_ASSIGNED,
|
||
|
|
title: `${projectCount} Projects Assigned`,
|
||
|
|
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${round.name || 'this round'}.`,
|
||
|
|
linkUrl: `/jury/competitions`,
|
||
|
|
linkLabel: 'View Assignments',
|
||
|
|
metadata: { projectCount, roundName: round.name, deadline },
|
||
|
|
})
|
||
|
|
|
||
|
|
await logAudit({
|
||
|
|
prisma: ctx.prisma,
|
||
|
|
userId: ctx.user.id,
|
||
|
|
action: 'NOTIFY_SINGLE_JUROR_OF_ASSIGNMENTS',
|
||
|
|
entityType: 'Round',
|
||
|
|
entityId: input.roundId,
|
||
|
|
detailsJson: {
|
||
|
|
targetUserId: input.userId,
|
||
|
|
assignmentCount: projectCount,
|
||
|
|
},
|
||
|
|
ipAddress: ctx.ip,
|
||
|
|
userAgent: ctx.userAgent,
|
||
|
|
})
|
||
|
|
|
||
|
|
return { sent: 1, projectCount }
|
||
|
|
}),
|
||
|
|
})
|