MOPC-App/docs/architecture/api.md

1139 lines
29 KiB
Markdown

# 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<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<typeof createContext>
```
### 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