import { z } from 'zod' import { TRPCError } from '@trpc/server' import { Prisma } from '@prisma/client' import { router, protectedProcedure, adminProcedure } from '../trpc' import { getUserAvatarUrl } from '../utils/avatar-url' import { notifyProjectTeam, NotificationTypes, } from '../services/in-app-notification' export const projectRouter = router({ /** * List projects with filtering and pagination * Admin sees all, jury sees only assigned projects */ list: protectedProcedure .input( z.object({ programId: z.string().optional(), roundId: z.string().optional(), status: z .enum([ 'SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST', 'REJECTED', ]) .optional(), statuses: z.array( z.enum([ 'SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST', 'REJECTED', ]) ).optional(), notInRoundId: z.string().optional(), // Exclude projects already in this round unassignedOnly: z.boolean().optional(), // Projects not in any round search: z.string().optional(), tags: z.array(z.string()).optional(), competitionCategory: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(), oceanIssue: z.enum([ 'POLLUTION_REDUCTION', 'CLIMATE_MITIGATION', 'TECHNOLOGY_INNOVATION', 'SUSTAINABLE_SHIPPING', 'BLUE_CARBON', 'HABITAT_RESTORATION', 'COMMUNITY_CAPACITY', 'SUSTAINABLE_FISHING', 'CONSUMER_AWARENESS', 'OCEAN_ACIDIFICATION', 'OTHER', ]).optional(), country: z.string().optional(), wantsMentorship: z.boolean().optional(), hasFiles: z.boolean().optional(), hasAssignments: z.boolean().optional(), page: z.number().int().min(1).default(1), perPage: z.number().int().min(1).max(5000).default(20), }) ) .query(async ({ ctx, input }) => { const { programId, roundId, notInRoundId, status, statuses, unassignedOnly, search, tags, competitionCategory, oceanIssue, country, wantsMentorship, hasFiles, hasAssignments, page, perPage, } = input const skip = (page - 1) * perPage // Build where clause const where: Record = {} // Filter by program via round if (programId) where.round = { programId } // Filter by round if (roundId) { where.roundId = roundId } // Exclude projects in a specific round if (notInRoundId) { where.roundId = { not: notInRoundId } } // Filter by unassigned (no round) if (unassignedOnly) { where.roundId = null } // Status filter if (statuses?.length || status) { const statusValues = statuses?.length ? statuses : status ? [status] : [] if (statusValues.length > 0) { where.status = { in: statusValues } } } if (tags && tags.length > 0) { where.tags = { hasSome: tags } } if (competitionCategory) where.competitionCategory = competitionCategory if (oceanIssue) where.oceanIssue = oceanIssue if (country) where.country = country if (wantsMentorship !== undefined) where.wantsMentorship = wantsMentorship if (hasFiles === true) where.files = { some: {} } if (hasFiles === false) where.files = { none: {} } if (hasAssignments === true) where.assignments = { some: {} } if (hasAssignments === false) where.assignments = { none: {} } 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 = { ...((where.assignments as Record) || {}), 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, round: { select: { id: true, name: true, program: { select: { id: true, name: true, year: true } }, }, }, _count: { select: { assignments: true } }, }, }), ctx.prisma.project.count({ where }), ]) return { projects, total, page, perPage, totalPages: Math.ceil(total / perPage), } }), /** * Get filter options for the project list (distinct values) */ getFilterOptions: protectedProcedure .query(async ({ ctx }) => { const [rounds, countries, categories, issues] = await Promise.all([ ctx.prisma.round.findMany({ select: { id: true, name: true, program: { select: { name: true, year: true } } }, orderBy: [{ program: { year: 'desc' } }, { createdAt: 'asc' }], }), ctx.prisma.project.findMany({ where: { country: { not: null } }, select: { country: true }, distinct: ['country'], orderBy: { country: 'asc' }, }), ctx.prisma.project.groupBy({ by: ['competitionCategory'], where: { competitionCategory: { not: null } }, _count: true, }), ctx.prisma.project.groupBy({ by: ['oceanIssue'], where: { oceanIssue: { not: null } }, _count: true, }), ]) return { rounds, countries: countries.map((c) => c.country).filter(Boolean) as string[], categories: categories.map((c) => ({ value: c.competitionCategory!, count: c._count, })), issues: issues.map((i) => ({ value: i.oceanIssue!, count: i._count, })), } }), /** * 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, profileImageKey: true, profileImageProvider: true }, }, }, orderBy: { joinedAt: 'asc' }, }, mentorAssignment: { include: { mentor: { select: { id: true, name: true, email: true, expertiseTags: true, profileImageKey: true, profileImageProvider: 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', }) } } // Attach avatar URLs to team members and mentor const teamMembersWithAvatars = await Promise.all( project.teamMembers.map(async (member) => ({ ...member, user: { ...member.user, avatarUrl: await getUserAvatarUrl(member.user.profileImageKey, member.user.profileImageProvider), }, })) ) const mentorWithAvatar = project.mentorAssignment ? { ...project.mentorAssignment, mentor: { ...project.mentorAssignment.mentor, avatarUrl: await getUserAvatarUrl( project.mentorAssignment.mentor.profileImageKey, project.mentorAssignment.mentor.profileImageProvider ), }, } : null return { ...project, teamMembers: teamMembersWithAvatars, mentorAssignment: mentorWithAvatar, } }), /** * Create a single project (admin only) * Projects belong to a round. */ 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, status: 'SUBMITTED', }, }) // 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) * Status updates require a roundId context since status is per-round. */ 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 update requires roundId roundId: z.string().optional(), 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, status, roundId, ...data } = input const project = await ctx.prisma.project.update({ where: { id }, data: { ...data, ...(status && { status }), metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined, }, }) // Send notifications if status changed if (status) { // Get round details for notification const projectWithRound = await ctx.prisma.project.findUnique({ where: { id }, include: { round: { select: { name: true, entryNotificationType: true, program: { select: { name: true } } } } }, }) const round = projectWithRound?.round // Helper to get notification title based on type const getNotificationTitle = (type: string): string => { const titles: Record = { ADVANCED_SEMIFINAL: "Congratulations! You're a Semi-Finalist", ADVANCED_FINAL: "Amazing News! You're a Finalist", NOT_SELECTED: 'Application Status Update', WINNER_ANNOUNCEMENT: 'Congratulations! You Won!', } return titles[type] || 'Project Update' } // Helper to get notification message based on type const getNotificationMessage = (type: string, projectName: string): string => { const messages: Record string> = { ADVANCED_SEMIFINAL: (name) => `Your project "${name}" has advanced to the semi-finals!`, ADVANCED_FINAL: (name) => `Your project "${name}" has been selected as a finalist!`, NOT_SELECTED: (name) => `We regret to inform you that "${name}" was not selected for the next round.`, WINNER_ANNOUNCEMENT: (name) => `Your project "${name}" has been selected as a winner!`, } return messages[type]?.(projectName) || `Update regarding your project "${projectName}".` } // Use round's configured notification type, or fall back to status-based defaults if (round?.entryNotificationType) { await notifyProjectTeam(id, { type: round.entryNotificationType, title: getNotificationTitle(round.entryNotificationType), message: getNotificationMessage(round.entryNotificationType, project.title), linkUrl: `/team/projects/${id}`, linkLabel: 'View Project', priority: round.entryNotificationType === 'NOT_SELECTED' ? 'normal' : 'high', metadata: { projectName: project.title, roundName: round.name, programName: round.program?.name, }, }) } else if (round) { // Fall back to hardcoded status-based notifications const notificationConfig: Record< string, { type: string; title: string; message: string } > = { SEMIFINALIST: { type: NotificationTypes.ADVANCED_SEMIFINAL, title: "Congratulations! You're a Semi-Finalist", message: `Your project "${project.title}" has advanced to the semi-finals!`, }, FINALIST: { type: NotificationTypes.ADVANCED_FINAL, title: "Amazing News! You're a Finalist", message: `Your project "${project.title}" has been selected as a finalist!`, }, REJECTED: { type: NotificationTypes.NOT_SELECTED, title: 'Application Status Update', message: `We regret to inform you that "${project.title}" was not selected for the next round.`, }, } const config = notificationConfig[status] if (config) { await notifyProjectTeam(id, { type: config.type, title: config.title, message: config.message, linkUrl: `/team/projects/${id}`, linkLabel: 'View Project', priority: status === 'REJECTED' ? 'normal' : 'high', metadata: { projectName: project.title, roundName: round?.name, programName: round?.program?.name, }, }) } } } // Audit log await ctx.prisma.auditLog.create({ data: { userId: ctx.user.id, action: 'UPDATE', entityType: 'Project', entityId: id, detailsJson: { ...data, status, 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) * Projects belong to a program. Optionally assign to a round. */ importCSV: adminProcedure .input( z.object({ programId: z.string(), roundId: z.string().optional(), 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 program exists await ctx.prisma.program.findUniqueOrThrow({ where: { id: input.programId }, }) // Verify round exists and belongs to program if provided if (input.roundId) { const round = await ctx.prisma.round.findUniqueOrThrow({ where: { id: input.roundId }, }) if (round.programId !== input.programId) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Round does not belong to the selected program', }) } } // Create projects in a transaction const result = await ctx.prisma.$transaction(async (tx) => { // Create all projects with roundId const projectData = input.projects.map((p) => { const { metadataJson, ...rest } = p return { ...rest, roundId: input.roundId!, status: 'SUBMITTED' as const, metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined, } }) const created = await tx.project.createManyAndReturn({ data: projectData, select: { id: true }, }) return { imported: created.length } }) // Audit log await ctx.prisma.auditLog.create({ data: { userId: ctx.user.id, action: 'IMPORT', entityType: 'Project', detailsJson: { programId: input.programId, roundId: input.roundId, count: result.imported }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }, }) return result }), /** * Get all unique tags used in projects */ getTags: protectedProcedure .input(z.object({ roundId: z.string().optional(), programId: z.string().optional(), })) .query(async ({ ctx, input }) => { const where: Record = {} if (input.programId) where.round = { programId: input.programId } if (input.roundId) where.roundId = input.roundId const projects = await ctx.prisma.project.findMany({ where: Object.keys(where).length > 0 ? where : 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) * Status is per-round, so roundId is required. */ bulkUpdateStatus: adminProcedure .input( z.object({ ids: z.array(z.string()), roundId: 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 }, roundId: input.roundId, }, 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, roundId: input.roundId, status: input.status }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }, }) // Get round details including configured notification type const [projects, round] = await Promise.all([ input.ids.length > 0 ? ctx.prisma.project.findMany({ where: { id: { in: input.ids } }, select: { id: true, title: true }, }) : Promise.resolve([]), ctx.prisma.round.findUnique({ where: { id: input.roundId }, select: { name: true, entryNotificationType: true, program: { select: { name: true } } }, }), ]) // Helper to get notification title based on type const getNotificationTitle = (type: string): string => { const titles: Record = { ADVANCED_SEMIFINAL: "Congratulations! You're a Semi-Finalist", ADVANCED_FINAL: "Amazing News! You're a Finalist", NOT_SELECTED: 'Application Status Update', WINNER_ANNOUNCEMENT: 'Congratulations! You Won!', } return titles[type] || 'Project Update' } // Helper to get notification message based on type const getNotificationMessage = (type: string, projectName: string): string => { const messages: Record string> = { ADVANCED_SEMIFINAL: (name) => `Your project "${name}" has advanced to the semi-finals!`, ADVANCED_FINAL: (name) => `Your project "${name}" has been selected as a finalist!`, NOT_SELECTED: (name) => `We regret to inform you that "${name}" was not selected for the next round.`, WINNER_ANNOUNCEMENT: (name) => `Your project "${name}" has been selected as a winner!`, } return messages[type]?.(projectName) || `Update regarding your project "${projectName}".` } // Notify project teams based on round's configured notification or status-based fallback if (projects.length > 0) { if (round?.entryNotificationType) { // Use round's configured notification type for (const project of projects) { await notifyProjectTeam(project.id, { type: round.entryNotificationType, title: getNotificationTitle(round.entryNotificationType), message: getNotificationMessage(round.entryNotificationType, project.title), linkUrl: `/team/projects/${project.id}`, linkLabel: 'View Project', priority: round.entryNotificationType === 'NOT_SELECTED' ? 'normal' : 'high', metadata: { projectName: project.title, roundName: round.name, programName: round.program?.name, }, }) } } else { // Fall back to hardcoded status-based notifications const notificationConfig: Record< string, { type: string; titleFn: (name: string) => string; messageFn: (name: string) => string } > = { SEMIFINALIST: { type: NotificationTypes.ADVANCED_SEMIFINAL, titleFn: () => "Congratulations! You're a Semi-Finalist", messageFn: (name) => `Your project "${name}" has advanced to the semi-finals!`, }, FINALIST: { type: NotificationTypes.ADVANCED_FINAL, titleFn: () => "Amazing News! You're a Finalist", messageFn: (name) => `Your project "${name}" has been selected as a finalist!`, }, REJECTED: { type: NotificationTypes.NOT_SELECTED, titleFn: () => 'Application Status Update', messageFn: (name) => `We regret to inform you that "${name}" was not selected for the next round.`, }, } const config = notificationConfig[input.status] if (config) { for (const project of projects) { await notifyProjectTeam(project.id, { type: config.type, title: config.titleFn(project.title), message: config.messageFn(project.title), linkUrl: `/team/projects/${project.id}`, linkLabel: 'View Project', priority: input.status === 'REJECTED' ? 'normal' : 'high', metadata: { projectName: project.title, roundName: round?.name, programName: round?.program?.name, }, }) } } } } return { updated: updated.count } }), /** * List projects in a program's pool (not assigned to any round) */ listPool: adminProcedure .input( z.object({ programId: z.string(), search: z.string().optional(), page: z.number().int().min(1).default(1), perPage: z.number().int().min(1).max(100).default(50), }) ) .query(async ({ ctx, input }) => { const { programId, search, page, perPage } = input const skip = (page - 1) * perPage const where: Record = { round: { programId }, roundId: null, } if (search) { where.OR = [ { title: { contains: search, mode: 'insensitive' } }, { teamName: { contains: search, mode: 'insensitive' } }, ] } const [projects, total] = await Promise.all([ ctx.prisma.project.findMany({ where, skip, take: perPage, orderBy: { createdAt: 'desc' }, select: { id: true, title: true, teamName: true, country: true, competitionCategory: true, createdAt: true, }, }), ctx.prisma.project.count({ where }), ]) return { projects, total, page, perPage, totalPages: Math.ceil(total / perPage) } }), })