MOPC-App/docs/architecture/api.md

29 KiB

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

// 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

// 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

// 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

// 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

// 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

// 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

// 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

// 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

// 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

// 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

// 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

// 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

// 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

// 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

{
  error: {
    message: "Voting window has closed",
    code: "BAD_REQUEST",
    data: {
      zodError: null, // Present if validation error
      path: "evaluation.submit",
    }
  }
}

Client Usage

// 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
    },
  })

  // ...
}