1139 lines
29 KiB
Markdown
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
|