# MOPC Platform - API Design ## Overview The MOPC platform uses **tRPC** for all API communication. tRPC provides end-to-end type safety between the server and client without code generation or API schemas. ## Why tRPC? 1. **Type Safety**: Changes to the API are immediately reflected in the client 2. **No Code Generation**: Types flow directly from server to client 3. **Great DX**: Full autocomplete and type checking 4. **Performance**: Automatic batching, minimal overhead 5. **React Query Integration**: Built-in caching, refetching, optimistic updates ## tRPC Setup ### Server Configuration ```typescript // src/server/trpc.ts import { initTRPC, TRPCError } from '@trpc/server' import { type Context } from './context' import superjson from 'superjson' import { ZodError } from 'zod' const t = initTRPC.context().create({ transformer: superjson, errorFormatter({ shape, error }) { return { ...shape, data: { ...shape.data, zodError: error.cause instanceof ZodError ? error.cause.flatten() : null, }, } }, }) export const router = t.router export const publicProcedure = t.procedure export const middleware = t.middleware ``` ### Context Definition ```typescript // src/server/context.ts import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' import type { inferAsyncReturnType } from '@trpc/server' export async function createContext(opts: { req: Request }) { const session = await getServerSession(authOptions) return { session, prisma, ip: opts.req.headers.get('x-forwarded-for') ?? 'unknown', userAgent: opts.req.headers.get('user-agent') ?? 'unknown', } } export type Context = inferAsyncReturnType ``` ### Auth Middleware ```typescript // src/server/middleware/auth.ts import { TRPCError } from '@trpc/server' import { middleware } from '../trpc' import type { UserRole } from '@prisma/client' // Require authenticated user export const isAuthenticated = middleware(async ({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: 'UNAUTHORIZED', message: 'You must be logged in', }) } return next({ ctx: { ...ctx, user: ctx.session.user, }, }) }) // Require specific role(s) export const hasRole = (...roles: UserRole[]) => middleware(async ({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: 'UNAUTHORIZED' }) } if (!roles.includes(ctx.session.user.role)) { throw new TRPCError({ code: 'FORBIDDEN', message: 'Insufficient permissions', }) } return next({ ctx: { ...ctx, user: ctx.session.user, }, }) }) // Pre-built role procedures export const protectedProcedure = t.procedure.use(isAuthenticated) export const adminProcedure = t.procedure.use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN')) export const juryProcedure = t.procedure.use(hasRole('JURY_MEMBER')) ``` ## Router Structure ``` src/server/routers/ ├── _app.ts # Root router (combines all routers) ├── program.ts # Program management ├── round.ts # Round management ├── project.ts # Project management ├── user.ts # User management ├── assignment.ts # Assignment management (includes smart assignment) ├── evaluation.ts # Evaluation management ├── file.ts # File operations ├── export.ts # Export operations ├── audit.ts # Audit log access ├── settings.ts # Platform settings (admin) └── gracePeriod.ts # Grace period management ``` ### Root Router ```typescript // src/server/routers/_app.ts import { router } from '../trpc' import { programRouter } from './program' import { roundRouter } from './round' import { projectRouter } from './project' import { userRouter } from './user' import { assignmentRouter } from './assignment' import { evaluationRouter } from './evaluation' import { fileRouter } from './file' import { exportRouter } from './export' import { auditRouter } from './audit' export const appRouter = router({ program: programRouter, round: roundRouter, project: projectRouter, user: userRouter, assignment: assignmentRouter, evaluation: evaluationRouter, file: fileRouter, export: exportRouter, audit: auditRouter, settings: settingsRouter, gracePeriod: gracePeriodRouter, }) export type AppRouter = typeof appRouter ``` ## Router Specifications ### Program Router ```typescript // src/server/routers/program.ts import { z } from 'zod' import { router, adminProcedure, protectedProcedure } from '../trpc' export const programRouter = router({ // List all programs list: protectedProcedure.query(async ({ ctx }) => { return ctx.prisma.program.findMany({ orderBy: { year: 'desc' }, }) }), // Get single program with rounds get: protectedProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { return ctx.prisma.program.findUniqueOrThrow({ where: { id: input.id }, include: { rounds: true }, }) }), // Create program (admin only) create: adminProcedure .input(z.object({ name: z.string().min(1).max(255), year: z.number().int().min(2020).max(2100), description: z.string().optional(), })) .mutation(async ({ ctx, input }) => { const program = await ctx.prisma.program.create({ data: input, }) // Audit log await ctx.prisma.auditLog.create({ data: { userId: ctx.user.id, action: 'CREATE', entityType: 'Program', entityId: program.id, detailsJson: input, ipAddress: ctx.ip, }, }) return program }), // Update program (admin only) update: adminProcedure .input(z.object({ id: z.string(), name: z.string().min(1).max(255).optional(), status: z.enum(['DRAFT', 'ACTIVE', 'ARCHIVED']).optional(), description: z.string().optional(), })) .mutation(async ({ ctx, input }) => { const { id, ...data } = input const program = await ctx.prisma.program.update({ where: { id }, data, }) // Audit log await ctx.prisma.auditLog.create({ data: { userId: ctx.user.id, action: 'UPDATE', entityType: 'Program', entityId: id, detailsJson: data, ipAddress: ctx.ip, }, }) return program }), }) ``` ### Round Router ```typescript // src/server/routers/round.ts import { z } from 'zod' import { router, adminProcedure, protectedProcedure } from '../trpc' import { TRPCError } from '@trpc/server' export const roundRouter = router({ // List rounds for a program list: protectedProcedure .input(z.object({ programId: z.string() })) .query(async ({ ctx, input }) => { return ctx.prisma.round.findMany({ where: { programId: input.programId }, orderBy: { createdAt: 'asc' }, }) }), // Get round with stats get: protectedProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const round = await ctx.prisma.round.findUniqueOrThrow({ where: { id: input.id }, include: { program: true, _count: { select: { projects: true, assignments: true, }, }, }, }) return round }), // Create round (admin only) create: adminProcedure .input(z.object({ programId: z.string(), name: z.string().min(1).max(255), requiredReviews: z.number().int().min(1).max(10).default(3), votingStartAt: z.date().optional(), votingEndAt: z.date().optional(), })) .mutation(async ({ ctx, input }) => { // Validate dates if (input.votingStartAt && input.votingEndAt) { if (input.votingEndAt <= input.votingStartAt) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'End date must be after start date', }) } } return ctx.prisma.round.create({ data: input }) }), // Update round status (admin only) updateStatus: adminProcedure .input(z.object({ id: z.string(), status: z.enum(['DRAFT', 'ACTIVE', 'CLOSED', 'ARCHIVED']), })) .mutation(async ({ ctx, input }) => { return ctx.prisma.round.update({ where: { id: input.id }, data: { status: input.status }, }) }), // Check if voting is open isVotingOpen: protectedProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const round = await ctx.prisma.round.findUniqueOrThrow({ where: { id: input.id }, }) const now = new Date() return ( round.status === 'ACTIVE' && round.votingStartAt && round.votingEndAt && now >= round.votingStartAt && now <= round.votingEndAt ) }), }) ``` ### Project Router ```typescript // src/server/routers/project.ts import { z } from 'zod' import { router, adminProcedure, protectedProcedure, juryProcedure } from '../trpc' export const projectRouter = router({ // List projects (admin sees all, jury sees assigned) list: protectedProcedure .input(z.object({ roundId: z.string(), status: z.enum(['SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST', 'REJECTED']).optional(), search: 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, page, perPage } = input const skip = (page - 1) * perPage // Build where clause const where: any = { roundId } if (status) where.status = status if (search) { where.OR = [ { title: { contains: search, mode: 'insensitive' } }, { teamName: { 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 project 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, }, }) // 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 }), // Import projects from CSV (admin only) importCSV: adminProcedure .input(z.object({ roundId: z.string(), projects: z.array(z.object({ title: z.string(), teamName: z.string().optional(), description: z.string().optional(), tags: z.array(z.string()).optional(), })), })) .mutation(async ({ ctx, input }) => { const created = await ctx.prisma.project.createMany({ data: input.projects.map((p) => ({ ...p, roundId: input.roundId, })), }) // 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, }, }) return { imported: created.count } }), }) ``` ### Assignment Router ```typescript // src/server/routers/assignment.ts import { z } from 'zod' import { router, adminProcedure, protectedProcedure } from '../trpc' export const assignmentRouter = router({ // List assignments for a round listByRound: adminProcedure .input(z.object({ roundId: z.string() })) .query(async ({ ctx, input }) => { return ctx.prisma.assignment.findMany({ where: { roundId: input.roundId }, include: { user: { select: { id: true, name: true, email: true } }, project: { select: { id: true, title: true } }, evaluation: { select: { status: true } }, }, }) }), // Get my assignments (for jury) myAssignments: protectedProcedure .input(z.object({ roundId: z.string().optional() })) .query(async ({ ctx, input }) => { return ctx.prisma.assignment.findMany({ where: { userId: ctx.user.id, ...(input.roundId && { roundId: input.roundId }), round: { status: 'ACTIVE' }, }, include: { project: { include: { files: true }, }, round: true, evaluation: true, }, }) }), // Create single assignment (admin only) create: adminProcedure .input(z.object({ userId: z.string(), projectId: z.string(), roundId: z.string(), })) .mutation(async ({ ctx, input }) => { return ctx.prisma.assignment.create({ data: { ...input, method: 'MANUAL', createdBy: ctx.user.id, }, }) }), // Bulk assign (admin only) bulkCreate: adminProcedure .input(z.object({ assignments: z.array(z.object({ userId: z.string(), projectId: z.string(), roundId: z.string(), })), })) .mutation(async ({ ctx, input }) => { const result = await ctx.prisma.assignment.createMany({ data: input.assignments.map((a) => ({ ...a, method: 'BULK', createdBy: ctx.user.id, })), skipDuplicates: true, }) return { created: result.count } }), // Delete assignment (admin only) delete: adminProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { return ctx.prisma.assignment.delete({ where: { id: input.id }, }) }), }) ``` ### Evaluation Router ```typescript // src/server/routers/evaluation.ts import { z } from 'zod' import { router, protectedProcedure, adminProcedure } from '../trpc' import { TRPCError } from '@trpc/server' export const evaluationRouter = router({ // Get evaluation for assignment get: protectedProcedure .input(z.object({ assignmentId: z.string() })) .query(async ({ ctx, input }) => { // Verify ownership or admin const assignment = await ctx.prisma.assignment.findUniqueOrThrow({ where: { id: input.assignmentId }, include: { round: true }, }) if (ctx.user.role === 'JURY_MEMBER' && assignment.userId !== ctx.user.id) { throw new TRPCError({ code: 'FORBIDDEN' }) } return ctx.prisma.evaluation.findUnique({ where: { assignmentId: input.assignmentId }, }) }), // Start evaluation (creates draft) start: protectedProcedure .input(z.object({ assignmentId: z.string(), formId: z.string(), })) .mutation(async ({ ctx, input }) => { // Verify assignment ownership const assignment = await ctx.prisma.assignment.findUniqueOrThrow({ where: { id: input.assignmentId }, include: { round: true }, }) if (assignment.userId !== ctx.user.id) { throw new TRPCError({ code: 'FORBIDDEN' }) } // Check if evaluation exists const existing = await ctx.prisma.evaluation.findUnique({ where: { assignmentId: input.assignmentId }, }) if (existing) return existing return ctx.prisma.evaluation.create({ data: { assignmentId: input.assignmentId, formId: input.formId, status: 'DRAFT', }, }) }), // Autosave evaluation (debounced on client) autosave: protectedProcedure .input(z.object({ id: z.string(), criterionScoresJson: z.record(z.number()).optional(), globalScore: z.number().int().min(1).max(10).optional(), binaryDecision: z.boolean().optional(), feedbackText: z.string().optional(), })) .mutation(async ({ ctx, input }) => { const { id, ...data } = input // Verify ownership and status const evaluation = await ctx.prisma.evaluation.findUniqueOrThrow({ where: { id }, include: { assignment: true }, }) if (evaluation.assignment.userId !== ctx.user.id) { throw new TRPCError({ code: 'FORBIDDEN' }) } if (evaluation.status === 'SUBMITTED' || evaluation.status === 'LOCKED') { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Cannot edit submitted evaluation', }) } return ctx.prisma.evaluation.update({ where: { id }, data: { ...data, status: 'DRAFT', }, }) }), // Submit evaluation (final) submit: protectedProcedure .input(z.object({ id: z.string(), criterionScoresJson: z.record(z.number()), globalScore: z.number().int().min(1).max(10), binaryDecision: z.boolean(), feedbackText: z.string().min(1), })) .mutation(async ({ ctx, input }) => { const { id, ...data } = input // Verify ownership const evaluation = await ctx.prisma.evaluation.findUniqueOrThrow({ where: { id }, include: { assignment: { include: { round: true }, }, }, }) if (evaluation.assignment.userId !== ctx.user.id) { throw new TRPCError({ code: 'FORBIDDEN' }) } // Check voting window const round = evaluation.assignment.round const now = new Date() if (round.status !== 'ACTIVE') { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Round is not active', }) } if (round.votingStartAt && now < round.votingStartAt) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Voting has not started yet', }) } if (round.votingEndAt && now > round.votingEndAt) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Voting window has closed', }) } // Submit const updated = await ctx.prisma.evaluation.update({ where: { id }, data: { ...data, status: 'SUBMITTED', submittedAt: new Date(), }, }) // Mark assignment as completed await ctx.prisma.assignment.update({ where: { id: evaluation.assignmentId }, data: { isCompleted: true }, }) // Audit log await ctx.prisma.auditLog.create({ data: { userId: ctx.user.id, action: 'SUBMIT_EVALUATION', entityType: 'Evaluation', entityId: id, ipAddress: ctx.ip, }, }) return updated }), // Get aggregated stats for a project (admin only) getProjectStats: adminProcedure .input(z.object({ projectId: z.string() })) .query(async ({ ctx, input }) => { const evaluations = await ctx.prisma.evaluation.findMany({ where: { status: 'SUBMITTED', assignment: { projectId: input.projectId }, }, }) if (evaluations.length === 0) { return null } const globalScores = evaluations .map((e) => e.globalScore) .filter((s): s is number => s !== null) const yesVotes = evaluations.filter((e) => e.binaryDecision === true).length return { totalEvaluations: evaluations.length, averageGlobalScore: globalScores.reduce((a, b) => a + b, 0) / globalScores.length, minScore: Math.min(...globalScores), maxScore: Math.max(...globalScores), yesVotes, noVotes: evaluations.length - yesVotes, yesPercentage: (yesVotes / evaluations.length) * 100, } }), }) ``` ### File Router ```typescript // src/server/routers/file.ts import { z } from 'zod' import { router, adminProcedure, protectedProcedure } from '../trpc' import { getPresignedUrl, uploadFile } from '@/lib/minio' export const fileRouter = router({ // Get pre-signed download URL getDownloadUrl: protectedProcedure .input(z.object({ bucket: z.string(), objectKey: z.string(), })) .query(async ({ ctx, input }) => { const url = await getPresignedUrl(input.bucket, input.objectKey, 'GET', 900) // 15 min return { url } }), // Get pre-signed upload URL (admin only) getUploadUrl: adminProcedure .input(z.object({ projectId: z.string(), fileName: z.string(), fileType: z.enum(['EXEC_SUMMARY', 'PRESENTATION', 'VIDEO', 'OTHER']), mimeType: z.string(), size: z.number().int().positive(), })) .mutation(async ({ ctx, input }) => { const bucket = 'mopc-files' const objectKey = `projects/${input.projectId}/${Date.now()}-${input.fileName}` const uploadUrl = await getPresignedUrl(bucket, objectKey, 'PUT', 3600) // 1 hour // Create file record const file = await ctx.prisma.projectFile.create({ data: { projectId: input.projectId, fileType: input.fileType, fileName: input.fileName, mimeType: input.mimeType, size: input.size, bucket, objectKey, }, }) return { uploadUrl, file, } }), // Delete file (admin only) delete: adminProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const file = await ctx.prisma.projectFile.delete({ where: { id: input.id }, }) // Note: Actual MinIO deletion could be done here or via background job return file }), }) ``` ### Settings Router ```typescript // src/server/routers/settings.ts import { z } from 'zod' import { router, adminProcedure, superAdminProcedure } from '../trpc' export const settingsRouter = router({ // Get all settings by category getByCategory: adminProcedure .input(z.object({ category: z.string() })) .query(async ({ ctx, input }) => { return ctx.prisma.systemSettings.findMany({ where: { category: input.category }, orderBy: { key: 'asc' }, }) }), // Update a setting (super admin only for sensitive settings) update: superAdminProcedure .input(z.object({ key: z.string(), value: z.string(), })) .mutation(async ({ ctx, input }) => { const setting = await ctx.prisma.systemSettings.update({ where: { key: input.key }, data: { value: input.value, updatedAt: new Date(), updatedBy: ctx.user.id, }, }) // Audit log for sensitive settings changes await ctx.prisma.auditLog.create({ data: { userId: ctx.user.id, action: 'UPDATE_SETTING', entityType: 'SystemSettings', entityId: setting.id, detailsJson: { key: input.key }, ipAddress: ctx.ip, }, }) return setting }), // Test AI connection testAIConnection: superAdminProcedure .mutation(async ({ ctx }) => { // Test OpenAI API connectivity // Returns success/failure }), // Test email connection testEmailConnection: superAdminProcedure .mutation(async ({ ctx }) => { // Send test email // Returns success/failure }), }) ``` ### Smart Assignment Endpoints ```typescript // Added to src/server/routers/assignment.ts // Get AI-suggested assignments suggestAssignments: adminProcedure .input(z.object({ roundId: z.string(), mode: z.enum(['ai', 'algorithm']).default('algorithm'), })) .mutation(async ({ ctx, input }) => { // Returns suggested assignments with reasoning // AI mode: Uses GPT with anonymized data // Algorithm mode: Uses rule-based scoring }), // Preview assignment before applying previewAssignment: adminProcedure .input(z.object({ roundId: z.string(), assignments: z.array(z.object({ userId: z.string(), projectId: z.string(), })), })) .query(async ({ ctx, input }) => { // Returns coverage stats, balance metrics }), // Apply suggested assignments applyAssignments: adminProcedure .input(z.object({ roundId: z.string(), assignments: z.array(z.object({ userId: z.string(), projectId: z.string(), reasoning: z.string().optional(), })), })) .mutation(async ({ ctx, input }) => { // Creates assignments in bulk // Logs AI suggestions that were accepted }), ``` ### Grace Period Router ```typescript // src/server/routers/gracePeriod.ts export const gracePeriodRouter = router({ // Grant grace period to a juror grant: adminProcedure .input(z.object({ roundId: z.string(), userId: z.string(), projectId: z.string().optional(), // Optional: specific project extendedUntil: z.date(), reason: z.string(), })) .mutation(async ({ ctx, input }) => { return ctx.prisma.gracePeriod.create({ data: { ...input, grantedBy: ctx.user.id, }, }) }), // List grace periods for a round listByRound: adminProcedure .input(z.object({ roundId: z.string() })) .query(async ({ ctx, input }) => { return ctx.prisma.gracePeriod.findMany({ where: { roundId: input.roundId }, include: { user: { select: { id: true, name: true, email: true } }, project: { select: { id: true, title: true } }, }, }) }), // Revoke grace period revoke: adminProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { return ctx.prisma.gracePeriod.delete({ where: { id: input.id }, }) }), }) ``` ## Authentication Flow ### Magic Link Implementation ```typescript // src/lib/auth.ts import NextAuth from 'next-auth' import EmailProvider from 'next-auth/providers/email' import { PrismaAdapter } from '@auth/prisma-adapter' import { prisma } from './prisma' import { sendMagicLinkEmail } from './email' export const authOptions = { adapter: PrismaAdapter(prisma), providers: [ EmailProvider({ server: { host: process.env.SMTP_HOST, port: Number(process.env.SMTP_PORT), auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS, }, }, from: process.env.EMAIL_FROM, sendVerificationRequest: async ({ identifier, url }) => { await sendMagicLinkEmail(identifier, url) }, }), ], callbacks: { session: async ({ session, user }) => { if (session.user) { // Add user id and role to session const dbUser = await prisma.user.findUnique({ where: { email: user.email! }, }) session.user.id = dbUser?.id ?? user.id session.user.role = dbUser?.role ?? 'JURY_MEMBER' } return session }, }, pages: { signIn: '/login', verifyRequest: '/verify-email', error: '/auth-error', }, } export const { handlers, auth, signIn, signOut } = NextAuth(authOptions) ``` ## Error Handling ### Standard Error Codes | Code | HTTP Status | Usage | |------|-------------|-------| | `BAD_REQUEST` | 400 | Invalid input, validation errors | | `UNAUTHORIZED` | 401 | Not logged in | | `FORBIDDEN` | 403 | Insufficient permissions | | `NOT_FOUND` | 404 | Resource doesn't exist | | `CONFLICT` | 409 | Duplicate entry, state conflict | | `INTERNAL_SERVER_ERROR` | 500 | Unexpected error | ### Error Response Format ```typescript { error: { message: "Voting window has closed", code: "BAD_REQUEST", data: { zodError: null, // Present if validation error path: "evaluation.submit", } } } ``` ## Client Usage ```typescript // In React component import { trpc } from '@/lib/trpc/client' function ProjectList() { const { data, isLoading, error } = trpc.project.list.useQuery({ roundId: 'round-123', page: 1, }) const submitMutation = trpc.evaluation.submit.useMutation({ onSuccess: () => { // Handle success }, onError: (error) => { // Handle error }, }) // ... } ``` ## Related Documentation - [Database Design](./database.md) - Schema that powers the API - [Infrastructure](./infrastructure.md) - How the API is deployed