refactor: tech debt batch 3 — type safety + assignment router split
All checks were successful
Build and Push Docker Image / build (push) Successful in 13m4s

#5 — Replaced 55x PrismaClient | any with proper Prisma types across 8 files
- Service files: PrismaClient | any → PrismaClient, tx: any → Prisma.TransactionClient
- Fixed 4 real bugs uncovered by typing:
  - mentor-workspace.ts: wrong FK fields (mentorAssignmentId → workspaceId, role → senderRole)
  - ai-shortlist.ts: untyped string passed to CompetitionCategory enum filter
  - result-lock.ts: unknown passed where Prisma.InputJsonValue required

#9 — Split assignment.ts (2,775 lines) into 6 focused files:
  - shared.ts (93 lines) — MOVABLE_EVAL_STATUSES, buildBatchNotifications, getCandidateJurors
  - assignment-crud.ts (473 lines) — 8 core CRUD procedures
  - assignment-suggestions.ts (880 lines) — AI suggestions + job runner
  - assignment-notifications.ts (138 lines) — 2 notification procedures
  - assignment-redistribution.ts (1,162 lines) — 8 reassign/transfer procedures
  - index.ts (15 lines) — barrel export with router merge, zero frontend changes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 12:47:06 +01:00
parent 1c78ecf21d
commit 6b40fe7726
16 changed files with 2836 additions and 2851 deletions

View File

@@ -0,0 +1,138 @@
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 }
}),
})