import { z } from 'zod' import { TRPCError } from '@trpc/server' import { Prisma } from '@prisma/client' import { router, protectedProcedure, adminProcedure } from '../trpc' export const projectRouter = router({ /** * List projects with filtering and pagination * Admin sees all, jury sees only assigned projects */ list: protectedProcedure .input( z.object({ roundId: z.string(), status: z .enum([ 'SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST', 'REJECTED', ]) .optional(), search: z.string().optional(), tags: z.array(z.string()).optional(), page: z.number().int().min(1).default(1), perPage: z.number().int().min(1).max(100).default(20), }) ) .query(async ({ ctx, input }) => { const { roundId, status, search, tags, page, perPage } = input const skip = (page - 1) * perPage // Build where clause const where: Record = { roundId } if (status) where.status = status if (tags && tags.length > 0) { where.tags = { hasSome: tags } } if (search) { where.OR = [ { title: { contains: search, mode: 'insensitive' } }, { teamName: { contains: search, mode: 'insensitive' } }, { description: { contains: search, mode: 'insensitive' } }, ] } // Jury members can only see assigned projects if (ctx.user.role === 'JURY_MEMBER') { where.assignments = { some: { userId: ctx.user.id }, } } const [projects, total] = await Promise.all([ ctx.prisma.project.findMany({ where, skip, take: perPage, orderBy: { createdAt: 'desc' }, include: { files: true, _count: { select: { assignments: true } }, }, }), ctx.prisma.project.count({ where }), ]) return { projects, total, page, perPage, totalPages: Math.ceil(total / perPage), } }), /** * Get a single project with details */ get: protectedProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const project = await ctx.prisma.project.findUniqueOrThrow({ where: { id: input.id }, include: { files: true, round: true, teamMembers: { include: { user: { select: { id: true, name: true, email: true }, }, }, orderBy: { joinedAt: 'asc' }, }, mentorAssignment: { include: { mentor: { select: { id: true, name: true, email: true, expertiseTags: true }, }, }, }, }, }) // Check access for jury members if (ctx.user.role === 'JURY_MEMBER') { const assignment = await ctx.prisma.assignment.findFirst({ where: { projectId: input.id, userId: ctx.user.id, }, }) if (!assignment) { throw new TRPCError({ code: 'FORBIDDEN', message: 'You are not assigned to this project', }) } } return project }), /** * Create a single project (admin only) */ create: adminProcedure .input( z.object({ roundId: z.string(), title: z.string().min(1).max(500), teamName: z.string().optional(), description: z.string().optional(), tags: z.array(z.string()).optional(), metadataJson: z.record(z.unknown()).optional(), }) ) .mutation(async ({ ctx, input }) => { const { metadataJson, ...rest } = input const project = await ctx.prisma.project.create({ data: { ...rest, metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined, }, }) // Audit log await ctx.prisma.auditLog.create({ data: { userId: ctx.user.id, action: 'CREATE', entityType: 'Project', entityId: project.id, detailsJson: { title: input.title, roundId: input.roundId }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }, }) return project }), /** * Update a project (admin only) */ update: adminProcedure .input( z.object({ id: z.string(), title: z.string().min(1).max(500).optional(), teamName: z.string().optional().nullable(), description: z.string().optional().nullable(), status: z .enum([ 'SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST', 'REJECTED', ]) .optional(), tags: z.array(z.string()).optional(), metadataJson: z.record(z.unknown()).optional(), }) ) .mutation(async ({ ctx, input }) => { const { id, metadataJson, ...data } = input const project = await ctx.prisma.project.update({ where: { id }, data: { ...data, metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined, }, }) // Audit log await ctx.prisma.auditLog.create({ data: { userId: ctx.user.id, action: 'UPDATE', entityType: 'Project', entityId: id, detailsJson: { ...data, metadataJson } as Prisma.InputJsonValue, ipAddress: ctx.ip, userAgent: ctx.userAgent, }, }) return project }), /** * Delete a project (admin only) */ delete: adminProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const project = await ctx.prisma.project.delete({ where: { id: input.id }, }) // Audit log await ctx.prisma.auditLog.create({ data: { userId: ctx.user.id, action: 'DELETE', entityType: 'Project', entityId: input.id, detailsJson: { title: project.title }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }, }) return project }), /** * Import projects from CSV data (admin only) */ importCSV: adminProcedure .input( z.object({ roundId: z.string(), projects: z.array( z.object({ title: z.string().min(1), teamName: z.string().optional(), description: z.string().optional(), tags: z.array(z.string()).optional(), metadataJson: z.record(z.unknown()).optional(), }) ), }) ) .mutation(async ({ ctx, input }) => { // Verify round exists await ctx.prisma.round.findUniqueOrThrow({ where: { id: input.roundId }, }) const created = await ctx.prisma.project.createMany({ data: input.projects.map((p) => { const { metadataJson, ...rest } = p return { ...rest, roundId: input.roundId, metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined, } }), }) // Audit log await ctx.prisma.auditLog.create({ data: { userId: ctx.user.id, action: 'IMPORT', entityType: 'Project', detailsJson: { roundId: input.roundId, count: created.count }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }, }) return { imported: created.count } }), /** * Get all unique tags used in projects */ getTags: protectedProcedure .input(z.object({ roundId: z.string().optional() })) .query(async ({ ctx, input }) => { const projects = await ctx.prisma.project.findMany({ where: input.roundId ? { roundId: input.roundId } : undefined, select: { tags: true }, }) const allTags = projects.flatMap((p) => p.tags) const uniqueTags = [...new Set(allTags)].sort() return uniqueTags }), /** * Update project status in bulk (admin only) */ bulkUpdateStatus: adminProcedure .input( z.object({ ids: z.array(z.string()), status: z.enum([ 'SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST', 'REJECTED', ]), }) ) .mutation(async ({ ctx, input }) => { const updated = await ctx.prisma.project.updateMany({ where: { id: { in: input.ids } }, data: { status: input.status }, }) // Audit log await ctx.prisma.auditLog.create({ data: { userId: ctx.user.id, action: 'BULK_UPDATE_STATUS', entityType: 'Project', detailsJson: { ids: input.ids, status: input.status }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }, }) return { updated: updated.count } }), })