Initial commit: MOPC platform with Docker deployment setup

Full Next.js 15 platform with tRPC, Prisma, PostgreSQL, NextAuth.
Includes production Dockerfile (multi-stage, port 7600), docker-compose
with registry-based image pull, Gitea Actions CI workflow, nginx config
for portal.monaco-opc.com, deployment scripts, and DEPLOYMENT.md guide.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-30 13:41:32 +01:00
commit a606292aaa
290 changed files with 70691 additions and 0 deletions

24
src/server/context.ts Normal file
View File

@@ -0,0 +1,24 @@
import type { inferAsyncReturnType } from '@trpc/server'
import type { FetchCreateContextFnOptions } from '@trpc/server/adapters/fetch'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
/**
* Create tRPC context for each request
*/
export async function createContext(opts?: FetchCreateContextFnOptions) {
const session = await auth()
// Extract IP and user agent from headers
const ip = opts?.req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown'
const userAgent = opts?.req.headers.get('user-agent') ?? 'unknown'
return {
session,
prisma,
ip,
userAgent,
}
}
export type Context = inferAsyncReturnType<typeof createContext>

View File

@@ -0,0 +1,65 @@
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'
import { settingsRouter } from './settings'
import { gracePeriodRouter } from './gracePeriod'
// Phase 2 routers
import { learningResourceRouter } from './learningResource'
import { partnerRouter } from './partner'
import { notionImportRouter } from './notion-import'
import { typeformImportRouter } from './typeform-import'
import { applicationFormRouter } from './applicationForm'
// Phase 2B routers
import { tagRouter } from './tag'
import { applicantRouter } from './applicant'
import { liveVotingRouter } from './live-voting'
import { analyticsRouter } from './analytics'
// Storage routers
import { avatarRouter } from './avatar'
import { logoRouter } from './logo'
// Applicant system routers
import { applicationRouter } from './application'
import { mentorRouter } from './mentor'
/**
* Root tRPC router that combines all domain routers
*/
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,
// Phase 2 routers
learningResource: learningResourceRouter,
partner: partnerRouter,
notionImport: notionImportRouter,
typeformImport: typeformImportRouter,
applicationForm: applicationFormRouter,
// Phase 2B routers
tag: tagRouter,
applicant: applicantRouter,
liveVoting: liveVotingRouter,
analytics: analyticsRouter,
// Storage routers
avatar: avatarRouter,
logo: logoRouter,
// Applicant system routers
application: applicationRouter,
mentor: mentorRouter,
})
export type AppRouter = typeof appRouter

View File

@@ -0,0 +1,365 @@
import { z } from 'zod'
import { router, protectedProcedure, adminProcedure } from '../trpc'
export const analyticsRouter = router({
/**
* Get score distribution for a round (histogram data)
*/
getScoreDistribution: adminProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const evaluations = await ctx.prisma.evaluation.findMany({
where: {
assignment: { roundId: input.roundId },
status: 'SUBMITTED',
},
select: {
criterionScoresJson: true,
},
})
// Extract all scores and calculate distribution
const allScores: number[] = []
evaluations.forEach((evaluation) => {
const scores = evaluation.criterionScoresJson as Record<string, number> | null
if (scores) {
Object.values(scores).forEach((score) => {
if (typeof score === 'number') {
allScores.push(score)
}
})
}
})
// Count scores by bucket (1-10)
const distribution = Array.from({ length: 10 }, (_, i) => ({
score: i + 1,
count: allScores.filter((s) => Math.round(s) === i + 1).length,
}))
return {
distribution,
totalScores: allScores.length,
averageScore:
allScores.length > 0
? allScores.reduce((a, b) => a + b, 0) / allScores.length
: 0,
}
}),
/**
* Get evaluation completion over time (timeline data)
*/
getEvaluationTimeline: adminProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const evaluations = await ctx.prisma.evaluation.findMany({
where: {
assignment: { roundId: input.roundId },
status: 'SUBMITTED',
},
select: {
submittedAt: true,
},
orderBy: { submittedAt: 'asc' },
})
// Group by date
const byDate: Record<string, number> = {}
let cumulative = 0
evaluations.forEach((evaluation) => {
if (evaluation.submittedAt) {
const date = evaluation.submittedAt.toISOString().split('T')[0]
if (!byDate[date]) {
byDate[date] = 0
}
byDate[date]++
}
})
// Convert to cumulative timeline
const timeline = Object.entries(byDate)
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, count]) => {
cumulative += count
return {
date,
daily: count,
cumulative,
}
})
return timeline
}),
/**
* Get juror workload distribution
*/
getJurorWorkload: adminProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const assignments = await ctx.prisma.assignment.findMany({
where: { roundId: input.roundId },
include: {
user: { select: { name: true, email: true } },
evaluation: {
select: { id: true, status: true },
},
},
})
// Group by user
const byUser: Record<
string,
{ name: string; assigned: number; completed: number }
> = {}
assignments.forEach((assignment) => {
const userId = assignment.userId
if (!byUser[userId]) {
byUser[userId] = {
name: assignment.user.name || assignment.user.email || 'Unknown',
assigned: 0,
completed: 0,
}
}
byUser[userId].assigned++
if (assignment.evaluation?.status === 'SUBMITTED') {
byUser[userId].completed++
}
})
return Object.entries(byUser)
.map(([id, data]) => ({
id,
...data,
completionRate:
data.assigned > 0
? Math.round((data.completed / data.assigned) * 100)
: 0,
}))
.sort((a, b) => b.assigned - a.assigned)
}),
/**
* Get project rankings with average scores
*/
getProjectRankings: adminProcedure
.input(z.object({ roundId: z.string(), limit: z.number().optional() }))
.query(async ({ ctx, input }) => {
const projects = await ctx.prisma.project.findMany({
where: { roundId: input.roundId },
include: {
assignments: {
include: {
evaluation: {
select: { criterionScoresJson: true, status: true },
},
},
},
},
})
// Calculate average scores
const rankings = projects
.map((project) => {
const allScores: number[] = []
project.assignments.forEach((assignment) => {
const evaluation = assignment.evaluation
if (evaluation?.status === 'SUBMITTED') {
const scores = evaluation.criterionScoresJson as Record<
string,
number
> | null
if (scores) {
const scoreValues = Object.values(scores).filter(
(s): s is number => typeof s === 'number'
)
if (scoreValues.length > 0) {
const average =
scoreValues.reduce((a, b) => a + b, 0) / scoreValues.length
allScores.push(average)
}
}
}
})
const averageScore =
allScores.length > 0
? allScores.reduce((a, b) => a + b, 0) / allScores.length
: null
return {
id: project.id,
title: project.title,
teamName: project.teamName,
status: project.status,
averageScore,
evaluationCount: allScores.length,
}
})
.filter((p) => p.averageScore !== null)
.sort((a, b) => (b.averageScore || 0) - (a.averageScore || 0))
return input.limit ? rankings.slice(0, input.limit) : rankings
}),
/**
* Get status breakdown (pie chart data)
*/
getStatusBreakdown: adminProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const projects = await ctx.prisma.project.groupBy({
by: ['status'],
where: { roundId: input.roundId },
_count: true,
})
return projects.map((p) => ({
status: p.status,
count: p._count,
}))
}),
/**
* Get overview stats for dashboard
*/
getOverviewStats: adminProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const [
projectCount,
assignmentCount,
evaluationCount,
jurorCount,
statusCounts,
] = await Promise.all([
ctx.prisma.project.count({ where: { roundId: input.roundId } }),
ctx.prisma.assignment.count({ where: { roundId: input.roundId } }),
ctx.prisma.evaluation.count({
where: {
assignment: { roundId: input.roundId },
status: 'SUBMITTED',
},
}),
ctx.prisma.assignment.groupBy({
by: ['userId'],
where: { roundId: input.roundId },
}),
ctx.prisma.project.groupBy({
by: ['status'],
where: { roundId: input.roundId },
_count: true,
}),
])
const completionRate =
assignmentCount > 0
? Math.round((evaluationCount / assignmentCount) * 100)
: 0
return {
projectCount,
assignmentCount,
evaluationCount,
jurorCount: jurorCount.length,
completionRate,
statusBreakdown: statusCounts.map((s) => ({
status: s.status,
count: s._count,
})),
}
}),
/**
* Get criteria-level score distribution
*/
getCriteriaScores: adminProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
// Get active evaluation form for this round
const evaluationForm = await ctx.prisma.evaluationForm.findFirst({
where: { roundId: input.roundId, isActive: true },
})
if (!evaluationForm?.criteriaJson) {
return []
}
// Parse criteria from JSON
const criteria = evaluationForm.criteriaJson as Array<{
id: string
label: string
}>
if (!criteria || criteria.length === 0) {
return []
}
// Get all evaluations
const evaluations = await ctx.prisma.evaluation.findMany({
where: {
assignment: { roundId: input.roundId },
status: 'SUBMITTED',
},
select: { criterionScoresJson: true },
})
// Calculate average score per criterion
const criteriaScores = criteria.map((criterion) => {
const scores: number[] = []
evaluations.forEach((evaluation) => {
const criterionScoresJson = evaluation.criterionScoresJson as Record<
string,
number
> | null
if (criterionScoresJson && typeof criterionScoresJson[criterion.id] === 'number') {
scores.push(criterionScoresJson[criterion.id])
}
})
return {
id: criterion.id,
name: criterion.label,
averageScore:
scores.length > 0
? scores.reduce((a, b) => a + b, 0) / scores.length
: 0,
count: scores.length,
}
})
return criteriaScores
}),
/**
* Get geographic distribution of projects by country
*/
getGeographicDistribution: adminProcedure
.input(
z.object({
programId: z.string(),
roundId: z.string().optional(),
})
)
.query(async ({ ctx, input }) => {
const where = input.roundId
? { roundId: input.roundId }
: { round: { programId: input.programId } }
const distribution = await ctx.prisma.project.groupBy({
by: ['country'],
where,
_count: { id: true },
})
return distribution.map((d) => ({
countryCode: d.country || 'UNKNOWN',
count: d._count.id,
}))
}),
})

View File

@@ -0,0 +1,701 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, publicProcedure, protectedProcedure } from '../trpc'
import { getPresignedUrl } from '@/lib/minio'
// Bucket for applicant submissions
export const SUBMISSIONS_BUCKET = 'mopc-submissions'
export const applicantRouter = router({
/**
* Get submission info for an applicant (by round slug)
*/
getSubmissionBySlug: publicProcedure
.input(z.object({ slug: z.string() }))
.query(async ({ ctx, input }) => {
// Find the round by slug
const round = await ctx.prisma.round.findFirst({
where: { slug: input.slug },
include: {
program: { select: { id: true, name: true, year: true, description: true } },
},
})
if (!round) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Round not found',
})
}
// Check if submissions are open
const now = new Date()
const isOpen = round.submissionDeadline
? now < round.submissionDeadline
: round.status === 'ACTIVE'
return {
round: {
id: round.id,
name: round.name,
slug: round.slug,
submissionDeadline: round.submissionDeadline,
isOpen,
},
program: round.program,
}
}),
/**
* Get the current user's submission for a round (as submitter or team member)
*/
getMySubmission: protectedProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
// Only applicants can use this
if (ctx.user.role !== 'APPLICANT') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Only applicants can access submissions',
})
}
const project = await ctx.prisma.project.findFirst({
where: {
roundId: input.roundId,
OR: [
{ submittedByUserId: ctx.user.id },
{
teamMembers: {
some: { userId: ctx.user.id },
},
},
],
},
include: {
files: true,
round: {
include: {
program: { select: { name: true, year: true } },
},
},
teamMembers: {
include: {
user: {
select: { id: true, name: true, email: true },
},
},
},
},
})
if (project) {
const userMembership = project.teamMembers.find((tm) => tm.userId === ctx.user.id)
return {
...project,
userRole: userMembership?.role || (project.submittedByUserId === ctx.user.id ? 'LEAD' : null),
isTeamLead: project.submittedByUserId === ctx.user.id || userMembership?.role === 'LEAD',
}
}
return null
}),
/**
* Create or update a submission (draft or submitted)
*/
saveSubmission: protectedProcedure
.input(
z.object({
roundId: z.string(),
projectId: z.string().optional(), // If updating existing
title: z.string().min(1).max(500),
teamName: z.string().optional(),
description: z.string().optional(),
tags: z.array(z.string()).optional(),
metadataJson: z.record(z.unknown()).optional(),
submit: z.boolean().default(false), // Whether to submit or just save draft
})
)
.mutation(async ({ ctx, input }) => {
// Only applicants can use this
if (ctx.user.role !== 'APPLICANT') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Only applicants can submit projects',
})
}
// Check if the round is open for submissions
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
})
const now = new Date()
if (round.submissionDeadline && now > round.submissionDeadline) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Submission deadline has passed',
})
}
const { projectId, submit, roundId, metadataJson, ...data } = input
if (projectId) {
// Update existing
const existing = await ctx.prisma.project.findFirst({
where: {
id: projectId,
submittedByUserId: ctx.user.id,
},
})
if (!existing) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Project not found or you do not have access',
})
}
// Can't update if already submitted
if (existing.submittedAt && !submit) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Cannot modify a submitted project',
})
}
const project = await ctx.prisma.project.update({
where: { id: projectId },
data: {
...data,
metadataJson: metadataJson as unknown ?? undefined,
submittedAt: submit && !existing.submittedAt ? now : existing.submittedAt,
status: submit ? 'SUBMITTED' : existing.status,
},
})
return project
} else {
// Create new
const project = await ctx.prisma.project.create({
data: {
roundId,
...data,
metadataJson: metadataJson as unknown ?? undefined,
submittedByUserId: ctx.user.id,
submittedByEmail: ctx.user.email,
submissionSource: 'MANUAL',
status: 'SUBMITTED',
submittedAt: submit ? now : null,
},
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'CREATE',
entityType: 'Project',
entityId: project.id,
detailsJson: { title: input.title, source: 'applicant_portal' },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return project
}
}),
/**
* Get upload URL for a submission file
*/
getUploadUrl: protectedProcedure
.input(
z.object({
projectId: z.string(),
fileName: z.string(),
mimeType: z.string(),
fileType: z.enum(['EXEC_SUMMARY', 'BUSINESS_PLAN', 'VIDEO_PITCH', 'PRESENTATION', 'SUPPORTING_DOC', 'OTHER']),
})
)
.mutation(async ({ ctx, input }) => {
// Only applicants can use this
if (ctx.user.role !== 'APPLICANT') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Only applicants can upload files',
})
}
// Verify project ownership
const project = await ctx.prisma.project.findFirst({
where: {
id: input.projectId,
submittedByUserId: ctx.user.id,
},
})
if (!project) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Project not found or you do not have access',
})
}
// Can't upload if already submitted
if (project.submittedAt) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Cannot modify a submitted project',
})
}
const timestamp = Date.now()
const sanitizedName = input.fileName.replace(/[^a-zA-Z0-9.-]/g, '_')
const objectKey = `${project.id}/${input.fileType}/${timestamp}-${sanitizedName}`
const url = await getPresignedUrl(SUBMISSIONS_BUCKET, objectKey, 'PUT', 3600)
return {
url,
bucket: SUBMISSIONS_BUCKET,
objectKey,
}
}),
/**
* Save file metadata after upload
*/
saveFileMetadata: protectedProcedure
.input(
z.object({
projectId: z.string(),
fileName: z.string(),
mimeType: z.string(),
size: z.number().int(),
fileType: z.enum(['EXEC_SUMMARY', 'BUSINESS_PLAN', 'VIDEO_PITCH', 'PRESENTATION', 'SUPPORTING_DOC', 'OTHER']),
bucket: z.string(),
objectKey: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
// Only applicants can use this
if (ctx.user.role !== 'APPLICANT') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Only applicants can save files',
})
}
// Verify project ownership
const project = await ctx.prisma.project.findFirst({
where: {
id: input.projectId,
submittedByUserId: ctx.user.id,
},
})
if (!project) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Project not found or you do not have access',
})
}
const { projectId, ...fileData } = input
// Delete existing file of same type if exists
await ctx.prisma.projectFile.deleteMany({
where: {
projectId,
fileType: input.fileType,
},
})
// Create new file record
const file = await ctx.prisma.projectFile.create({
data: {
projectId,
...fileData,
},
})
return file
}),
/**
* Delete a file from submission
*/
deleteFile: protectedProcedure
.input(z.object({ fileId: z.string() }))
.mutation(async ({ ctx, input }) => {
// Only applicants can use this
if (ctx.user.role !== 'APPLICANT') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Only applicants can delete files',
})
}
const file = await ctx.prisma.projectFile.findUniqueOrThrow({
where: { id: input.fileId },
include: { project: true },
})
// Verify ownership
if (file.project.submittedByUserId !== ctx.user.id) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You do not have access to this file',
})
}
// Can't delete if project is submitted
if (file.project.submittedAt) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Cannot modify a submitted project',
})
}
await ctx.prisma.projectFile.delete({
where: { id: input.fileId },
})
return { success: true }
}),
/**
* Get submission status timeline
*/
getSubmissionStatus: protectedProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
const project = await ctx.prisma.project.findFirst({
where: {
id: input.projectId,
OR: [
{ submittedByUserId: ctx.user.id },
{
teamMembers: {
some: { userId: ctx.user.id },
},
},
],
},
include: {
round: {
include: {
program: { select: { name: true, year: true } },
},
},
files: true,
teamMembers: {
include: {
user: {
select: { id: true, name: true, email: true },
},
},
},
},
})
if (!project) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Project not found',
})
}
// Build timeline
const timeline = [
{
status: 'CREATED',
label: 'Application Started',
date: project.createdAt,
completed: true,
},
{
status: 'SUBMITTED',
label: 'Application Submitted',
date: project.submittedAt,
completed: !!project.submittedAt,
},
{
status: 'UNDER_REVIEW',
label: 'Under Review',
date: project.status === 'SUBMITTED' && project.submittedAt ? project.submittedAt : null,
completed: ['UNDER_REVIEW', 'SEMIFINALIST', 'FINALIST', 'WINNER'].includes(project.status),
},
{
status: 'SEMIFINALIST',
label: 'Semi-finalist',
date: null, // Would need status change tracking
completed: ['SEMIFINALIST', 'FINALIST', 'WINNER'].includes(project.status),
},
{
status: 'FINALIST',
label: 'Finalist',
date: null,
completed: ['FINALIST', 'WINNER'].includes(project.status),
},
]
return {
project,
timeline,
currentStatus: project.status,
}
}),
/**
* List all submissions for current user (including as team member)
*/
listMySubmissions: protectedProcedure.query(async ({ ctx }) => {
if (ctx.user.role !== 'APPLICANT') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Only applicants can access submissions',
})
}
// Find projects where user is either the submitter OR a team member
const projects = await ctx.prisma.project.findMany({
where: {
OR: [
{ submittedByUserId: ctx.user.id },
{
teamMembers: {
some: { userId: ctx.user.id },
},
},
],
},
include: {
round: {
include: {
program: { select: { name: true, year: true } },
},
},
files: true,
teamMembers: {
include: {
user: {
select: { id: true, name: true, email: true },
},
},
},
},
orderBy: { createdAt: 'desc' },
})
// Add user's role in each project
return projects.map((project) => {
const userMembership = project.teamMembers.find((tm) => tm.userId === ctx.user.id)
return {
...project,
userRole: userMembership?.role || (project.submittedByUserId === ctx.user.id ? 'LEAD' : null),
isTeamLead: project.submittedByUserId === ctx.user.id || userMembership?.role === 'LEAD',
}
})
}),
/**
* Get team members for a project
*/
getTeamMembers: protectedProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
// Verify user has access to this project
const project = await ctx.prisma.project.findFirst({
where: {
id: input.projectId,
OR: [
{ submittedByUserId: ctx.user.id },
{
teamMembers: {
some: { userId: ctx.user.id },
},
},
],
},
include: {
teamMembers: {
include: {
user: {
select: {
id: true,
name: true,
email: true,
status: true,
lastLoginAt: true,
},
},
},
orderBy: { joinedAt: 'asc' },
},
submittedBy: {
select: { id: true, name: true, email: true },
},
},
})
if (!project) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Project not found or you do not have access',
})
}
return {
teamMembers: project.teamMembers,
submittedBy: project.submittedBy,
}
}),
/**
* Invite a new team member
*/
inviteTeamMember: protectedProcedure
.input(
z.object({
projectId: z.string(),
email: z.string().email(),
name: z.string().min(1),
role: z.enum(['MEMBER', 'ADVISOR']),
title: z.string().optional(),
})
)
.mutation(async ({ ctx, input }) => {
// Verify user is team lead
const project = await ctx.prisma.project.findFirst({
where: {
id: input.projectId,
OR: [
{ submittedByUserId: ctx.user.id },
{
teamMembers: {
some: {
userId: ctx.user.id,
role: 'LEAD',
},
},
},
],
},
})
if (!project) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Only team leads can invite new members',
})
}
// Check if already a team member
const existingMember = await ctx.prisma.teamMember.findFirst({
where: {
projectId: input.projectId,
user: { email: input.email },
},
})
if (existingMember) {
throw new TRPCError({
code: 'CONFLICT',
message: 'This person is already a team member',
})
}
// Find or create user
let user = await ctx.prisma.user.findUnique({
where: { email: input.email },
})
if (!user) {
user = await ctx.prisma.user.create({
data: {
email: input.email,
name: input.name,
role: 'APPLICANT',
status: 'INVITED',
},
})
}
// Create team membership
const teamMember = await ctx.prisma.teamMember.create({
data: {
projectId: input.projectId,
userId: user.id,
role: input.role,
title: input.title,
},
include: {
user: {
select: { id: true, name: true, email: true, status: true },
},
},
})
// TODO: Send invitation email to the new team member
return teamMember
}),
/**
* Remove a team member
*/
removeTeamMember: protectedProcedure
.input(
z.object({
projectId: z.string(),
userId: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
// Verify user is team lead
const project = await ctx.prisma.project.findFirst({
where: {
id: input.projectId,
OR: [
{ submittedByUserId: ctx.user.id },
{
teamMembers: {
some: {
userId: ctx.user.id,
role: 'LEAD',
},
},
},
],
},
})
if (!project) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Only team leads can remove members',
})
}
// Can't remove the original submitter
if (project.submittedByUserId === input.userId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Cannot remove the original applicant from the team',
})
}
await ctx.prisma.teamMember.deleteMany({
where: {
projectId: input.projectId,
userId: input.userId,
},
})
return { success: true }
}),
})

View File

@@ -0,0 +1,335 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, publicProcedure } from '../trpc'
import { CompetitionCategory, OceanIssue, TeamMemberRole } from '@prisma/client'
// Zod schemas for the application form
const teamMemberSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email address'),
role: z.nativeEnum(TeamMemberRole).default('MEMBER'),
title: z.string().optional(),
})
const applicationSchema = z.object({
// Step 1: Category
competitionCategory: z.nativeEnum(CompetitionCategory),
// Step 2: Contact Info
contactName: z.string().min(2, 'Full name is required'),
contactEmail: z.string().email('Invalid email address'),
contactPhone: z.string().min(5, 'Phone number is required'),
country: z.string().min(2, 'Country is required'),
city: z.string().optional(),
// Step 3: Project Details
projectName: z.string().min(2, 'Project name is required').max(200),
teamName: z.string().optional(),
description: z.string().min(20, 'Description must be at least 20 characters'),
oceanIssue: z.nativeEnum(OceanIssue),
// Step 4: Team Members
teamMembers: z.array(teamMemberSchema).optional(),
// Step 5: Additional Info (conditional & optional)
institution: z.string().optional(), // Required if BUSINESS_CONCEPT
startupCreatedDate: z.string().optional(), // Required if STARTUP
wantsMentorship: z.boolean().default(false),
referralSource: z.string().optional(),
// Consent
gdprConsent: z.boolean().refine((val) => val === true, {
message: 'You must agree to the data processing terms',
}),
})
export type ApplicationFormData = z.infer<typeof applicationSchema>
export const applicationRouter = router({
/**
* Get application configuration for a round
*/
getConfig: publicProcedure
.input(z.object({ roundSlug: z.string() }))
.query(async ({ ctx, input }) => {
const round = await ctx.prisma.round.findFirst({
where: { slug: input.roundSlug },
include: {
program: {
select: {
id: true,
name: true,
year: true,
description: true,
},
},
},
})
if (!round) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Application round not found',
})
}
// Check if submissions are open
const now = new Date()
let isOpen = false
if (round.submissionStartDate && round.submissionEndDate) {
isOpen = now >= round.submissionStartDate && now <= round.submissionEndDate
} else if (round.submissionDeadline) {
isOpen = now <= round.submissionDeadline
} else {
isOpen = round.status === 'ACTIVE'
}
// Calculate grace period if applicable
let gracePeriodEnd: Date | null = null
if (round.lateSubmissionGrace && round.submissionEndDate) {
gracePeriodEnd = new Date(round.submissionEndDate.getTime() + round.lateSubmissionGrace * 60 * 60 * 1000)
if (now <= gracePeriodEnd) {
isOpen = true
}
}
return {
round: {
id: round.id,
name: round.name,
slug: round.slug,
submissionStartDate: round.submissionStartDate,
submissionEndDate: round.submissionEndDate,
submissionDeadline: round.submissionDeadline,
lateSubmissionGrace: round.lateSubmissionGrace,
gracePeriodEnd,
phase1Deadline: round.phase1Deadline,
phase2Deadline: round.phase2Deadline,
isOpen,
},
program: round.program,
oceanIssueOptions: [
{ value: 'POLLUTION_REDUCTION', label: 'Reduction of pollution (plastics, chemicals, noise, light,...)' },
{ value: 'CLIMATE_MITIGATION', label: 'Mitigation of climate change and sea-level rise' },
{ value: 'TECHNOLOGY_INNOVATION', label: 'Technology & innovations' },
{ value: 'SUSTAINABLE_SHIPPING', label: 'Sustainable shipping & yachting' },
{ value: 'BLUE_CARBON', label: 'Blue carbon' },
{ value: 'HABITAT_RESTORATION', label: 'Restoration of marine habitats & ecosystems' },
{ value: 'COMMUNITY_CAPACITY', label: 'Capacity building for coastal communities' },
{ value: 'SUSTAINABLE_FISHING', label: 'Sustainable fishing and aquaculture & blue food' },
{ value: 'CONSUMER_AWARENESS', label: 'Consumer awareness and education' },
{ value: 'OCEAN_ACIDIFICATION', label: 'Mitigation of ocean acidification' },
{ value: 'OTHER', label: 'Other' },
],
competitionCategories: [
{
value: 'BUSINESS_CONCEPT',
label: 'Business Concepts',
description: 'For students and recent graduates with innovative ocean-focused business ideas',
},
{
value: 'STARTUP',
label: 'Start-ups',
description: 'For established companies working on ocean protection solutions',
},
],
}
}),
/**
* Submit a new application
*/
submit: publicProcedure
.input(
z.object({
roundId: z.string(),
data: applicationSchema,
})
)
.mutation(async ({ ctx, input }) => {
const { roundId, data } = input
// Verify round exists and is open
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: roundId },
include: { program: true },
})
const now = new Date()
// Check submission window
let isOpen = false
if (round.submissionStartDate && round.submissionEndDate) {
isOpen = now >= round.submissionStartDate && now <= round.submissionEndDate
// Check grace period
if (!isOpen && round.lateSubmissionGrace) {
const gracePeriodEnd = new Date(
round.submissionEndDate.getTime() + round.lateSubmissionGrace * 60 * 60 * 1000
)
isOpen = now <= gracePeriodEnd
}
} else if (round.submissionDeadline) {
isOpen = now <= round.submissionDeadline
} else {
isOpen = round.status === 'ACTIVE'
}
if (!isOpen) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Applications are currently closed for this round',
})
}
// Check if email already submitted for this round
const existingProject = await ctx.prisma.project.findFirst({
where: {
roundId,
submittedByEmail: data.contactEmail,
},
})
if (existingProject) {
throw new TRPCError({
code: 'CONFLICT',
message: 'An application with this email already exists for this round',
})
}
// Check if user exists, or create a new applicant user
let user = await ctx.prisma.user.findUnique({
where: { email: data.contactEmail },
})
if (!user) {
user = await ctx.prisma.user.create({
data: {
email: data.contactEmail,
name: data.contactName,
role: 'APPLICANT',
status: 'ACTIVE',
phoneNumber: data.contactPhone,
},
})
}
// Create the project
const project = await ctx.prisma.project.create({
data: {
roundId,
title: data.projectName,
teamName: data.teamName,
description: data.description,
status: 'SUBMITTED',
competitionCategory: data.competitionCategory,
oceanIssue: data.oceanIssue,
country: data.country,
geographicZone: data.city ? `${data.city}, ${data.country}` : data.country,
institution: data.institution,
wantsMentorship: data.wantsMentorship,
referralSource: data.referralSource,
submissionSource: 'PUBLIC_FORM',
submittedByEmail: data.contactEmail,
submittedByUserId: user.id,
submittedAt: now,
metadataJson: {
contactPhone: data.contactPhone,
startupCreatedDate: data.startupCreatedDate,
gdprConsentAt: now.toISOString(),
},
},
})
// Create team lead membership
await ctx.prisma.teamMember.create({
data: {
projectId: project.id,
userId: user.id,
role: 'LEAD',
title: 'Team Lead',
},
})
// Create additional team members
if (data.teamMembers && data.teamMembers.length > 0) {
for (const member of data.teamMembers) {
// Find or create user for team member
let memberUser = await ctx.prisma.user.findUnique({
where: { email: member.email },
})
if (!memberUser) {
memberUser = await ctx.prisma.user.create({
data: {
email: member.email,
name: member.name,
role: 'APPLICANT',
status: 'INVITED',
},
})
}
// Create team membership
await ctx.prisma.teamMember.create({
data: {
projectId: project.id,
userId: memberUser.id,
role: member.role,
title: member.title,
},
})
}
}
// Create audit log
await ctx.prisma.auditLog.create({
data: {
userId: user.id,
action: 'CREATE',
entityType: 'Project',
entityId: project.id,
detailsJson: {
source: 'public_application_form',
title: data.projectName,
category: data.competitionCategory,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return {
success: true,
projectId: project.id,
message: `Thank you for applying to ${round.program.name} ${round.program.year}! We will review your application and contact you at ${data.contactEmail}.`,
}
}),
/**
* Check if email is already registered for a round
*/
checkEmailAvailability: publicProcedure
.input(
z.object({
roundId: z.string(),
email: z.string().email(),
})
)
.query(async ({ ctx, input }) => {
const existing = await ctx.prisma.project.findFirst({
where: {
roundId: input.roundId,
submittedByEmail: input.email,
},
})
return {
available: !existing,
message: existing
? 'An application with this email already exists for this round'
: null,
}
}),
})

View File

@@ -0,0 +1,777 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { Prisma } from '@prisma/client'
import { router, publicProcedure, adminProcedure } from '../trpc'
import { getPresignedUrl } from '@/lib/minio'
// Bucket for form submission files
export const SUBMISSIONS_BUCKET = 'mopc-submissions'
// Field type enum matching Prisma
const fieldTypeEnum = z.enum([
'TEXT',
'TEXTAREA',
'NUMBER',
'EMAIL',
'PHONE',
'URL',
'DATE',
'DATETIME',
'SELECT',
'MULTI_SELECT',
'RADIO',
'CHECKBOX',
'CHECKBOX_GROUP',
'FILE',
'FILE_MULTIPLE',
'SECTION',
'INSTRUCTIONS',
])
// Field input schema
const fieldInputSchema = z.object({
fieldType: fieldTypeEnum,
name: z.string().min(1).max(100),
label: z.string().min(1).max(255),
description: z.string().optional(),
placeholder: z.string().optional(),
required: z.boolean().default(false),
minLength: z.number().int().optional(),
maxLength: z.number().int().optional(),
minValue: z.number().optional(),
maxValue: z.number().optional(),
optionsJson: z
.array(z.object({ value: z.string(), label: z.string() }))
.optional(),
conditionJson: z
.object({
fieldId: z.string(),
operator: z.enum(['equals', 'not_equals', 'contains', 'not_empty']),
value: z.string().optional(),
})
.optional(),
sortOrder: z.number().int().default(0),
width: z.enum(['full', 'half']).default('full'),
})
export const applicationFormRouter = router({
/**
* List all forms (admin view)
*/
list: adminProcedure
.input(
z.object({
programId: z.string().optional(),
status: z.enum(['DRAFT', 'PUBLISHED', 'CLOSED']).optional(),
page: z.number().int().min(1).default(1),
perPage: z.number().int().min(1).max(100).default(20),
})
)
.query(async ({ ctx, input }) => {
const where: Record<string, unknown> = {}
if (input.programId !== undefined) {
where.programId = input.programId
}
if (input.status) {
where.status = input.status
}
const [data, total] = await Promise.all([
ctx.prisma.applicationForm.findMany({
where,
include: {
program: { select: { id: true, name: true, year: true } },
_count: { select: { submissions: true, fields: true } },
},
orderBy: { createdAt: 'desc' },
skip: (input.page - 1) * input.perPage,
take: input.perPage,
}),
ctx.prisma.applicationForm.count({ where }),
])
return {
data,
total,
page: input.page,
perPage: input.perPage,
totalPages: Math.ceil(total / input.perPage),
}
}),
/**
* Get a single form by ID (admin view with all fields)
*/
get: adminProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.applicationForm.findUniqueOrThrow({
where: { id: input.id },
include: {
program: { select: { id: true, name: true, year: true } },
fields: { orderBy: { sortOrder: 'asc' } },
_count: { select: { submissions: true } },
},
})
}),
/**
* Get a public form by slug (for form submission)
*/
getBySlug: publicProcedure
.input(z.object({ slug: z.string() }))
.query(async ({ ctx, input }) => {
const form = await ctx.prisma.applicationForm.findUnique({
where: { publicSlug: input.slug },
include: {
fields: { orderBy: { sortOrder: 'asc' } },
},
})
if (!form) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Form not found',
})
}
// Check if form is available
if (!form.isPublic || form.status !== 'PUBLISHED') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'This form is not currently accepting submissions',
})
}
// Check submission window
const now = new Date()
if (form.opensAt && now < form.opensAt) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'This form is not yet open for submissions',
})
}
if (form.closesAt && now > form.closesAt) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'This form has closed',
})
}
// Check submission limit
if (form.submissionLimit) {
const count = await ctx.prisma.applicationFormSubmission.count({
where: { formId: form.id },
})
if (count >= form.submissionLimit) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'This form has reached its submission limit',
})
}
}
return form
}),
/**
* Create a new form (admin only)
*/
create: adminProcedure
.input(
z.object({
programId: z.string().nullable(),
name: z.string().min(1).max(255),
description: z.string().optional(),
publicSlug: z.string().min(1).max(100).optional(),
submissionLimit: z.number().int().optional(),
opensAt: z.string().datetime().optional(),
closesAt: z.string().datetime().optional(),
confirmationMessage: z.string().optional(),
})
)
.mutation(async ({ ctx, input }) => {
// Check slug uniqueness
if (input.publicSlug) {
const existing = await ctx.prisma.applicationForm.findUnique({
where: { publicSlug: input.publicSlug },
})
if (existing) {
throw new TRPCError({
code: 'CONFLICT',
message: 'This URL slug is already in use',
})
}
}
const form = await ctx.prisma.applicationForm.create({
data: {
...input,
opensAt: input.opensAt ? new Date(input.opensAt) : null,
closesAt: input.closesAt ? new Date(input.closesAt) : null,
},
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'CREATE',
entityType: 'ApplicationForm',
entityId: form.id,
detailsJson: { name: input.name },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return form
}),
/**
* Update a form (admin only)
*/
update: adminProcedure
.input(
z.object({
id: z.string(),
name: z.string().min(1).max(255).optional(),
description: z.string().optional().nullable(),
status: z.enum(['DRAFT', 'PUBLISHED', 'CLOSED']).optional(),
isPublic: z.boolean().optional(),
publicSlug: z.string().min(1).max(100).optional().nullable(),
submissionLimit: z.number().int().optional().nullable(),
opensAt: z.string().datetime().optional().nullable(),
closesAt: z.string().datetime().optional().nullable(),
confirmationMessage: z.string().optional().nullable(),
})
)
.mutation(async ({ ctx, input }) => {
const { id, opensAt, closesAt, ...data } = input
// Check slug uniqueness if changing
if (data.publicSlug) {
const existing = await ctx.prisma.applicationForm.findFirst({
where: { publicSlug: data.publicSlug, NOT: { id } },
})
if (existing) {
throw new TRPCError({
code: 'CONFLICT',
message: 'This URL slug is already in use',
})
}
}
const form = await ctx.prisma.applicationForm.update({
where: { id },
data: {
...data,
opensAt: opensAt ? new Date(opensAt) : opensAt === null ? null : undefined,
closesAt: closesAt ? new Date(closesAt) : closesAt === null ? null : undefined,
},
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'ApplicationForm',
entityId: id,
detailsJson: data,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return form
}),
/**
* Delete a form (admin only)
*/
delete: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const form = await ctx.prisma.applicationForm.delete({
where: { id: input.id },
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'DELETE',
entityType: 'ApplicationForm',
entityId: input.id,
detailsJson: { name: form.name },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return form
}),
/**
* Add a field to a form
*/
addField: adminProcedure
.input(
z.object({
formId: z.string(),
field: fieldInputSchema,
})
)
.mutation(async ({ ctx, input }) => {
// Get max sort order
const maxOrder = await ctx.prisma.applicationFormField.aggregate({
where: { formId: input.formId },
_max: { sortOrder: true },
})
const field = await ctx.prisma.applicationFormField.create({
data: {
formId: input.formId,
...input.field,
sortOrder: input.field.sortOrder ?? (maxOrder._max.sortOrder ?? 0) + 1,
optionsJson: input.field.optionsJson ?? undefined,
conditionJson: input.field.conditionJson ?? undefined,
},
})
return field
}),
/**
* Update a field
*/
updateField: adminProcedure
.input(
z.object({
id: z.string(),
field: fieldInputSchema.partial(),
})
)
.mutation(async ({ ctx, input }) => {
const field = await ctx.prisma.applicationFormField.update({
where: { id: input.id },
data: {
...input.field,
optionsJson: input.field.optionsJson ?? undefined,
conditionJson: input.field.conditionJson ?? undefined,
},
})
return field
}),
/**
* Delete a field
*/
deleteField: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
return ctx.prisma.applicationFormField.delete({
where: { id: input.id },
})
}),
/**
* Reorder fields
*/
reorderFields: adminProcedure
.input(
z.object({
items: z.array(
z.object({
id: z.string(),
sortOrder: z.number().int(),
})
),
})
)
.mutation(async ({ ctx, input }) => {
await ctx.prisma.$transaction(
input.items.map((item) =>
ctx.prisma.applicationFormField.update({
where: { id: item.id },
data: { sortOrder: item.sortOrder },
})
)
)
return { success: true }
}),
/**
* Submit a form (public endpoint)
*/
submit: publicProcedure
.input(
z.object({
formId: z.string(),
data: z.record(z.unknown()),
email: z.string().email().optional(),
name: z.string().optional(),
})
)
.mutation(async ({ ctx, input }) => {
// Get form with fields
const form = await ctx.prisma.applicationForm.findUniqueOrThrow({
where: { id: input.formId },
include: { fields: true },
})
// Verify form is accepting submissions
if (!form.isPublic || form.status !== 'PUBLISHED') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'This form is not accepting submissions',
})
}
// Check submission window
const now = new Date()
if (form.opensAt && now < form.opensAt) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'This form is not yet open',
})
}
if (form.closesAt && now > form.closesAt) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'This form has closed',
})
}
// Check submission limit
if (form.submissionLimit) {
const count = await ctx.prisma.applicationFormSubmission.count({
where: { formId: form.id },
})
if (count >= form.submissionLimit) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'This form has reached its submission limit',
})
}
}
// Validate required fields
for (const field of form.fields) {
if (field.required && field.fieldType !== 'SECTION' && field.fieldType !== 'INSTRUCTIONS') {
const value = input.data[field.name]
if (value === undefined || value === null || value === '') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `${field.label} is required`,
})
}
}
}
// Create submission
const submission = await ctx.prisma.applicationFormSubmission.create({
data: {
formId: input.formId,
email: input.email,
name: input.name,
dataJson: input.data as Prisma.InputJsonValue,
},
})
return {
success: true,
submissionId: submission.id,
confirmationMessage: form.confirmationMessage,
}
}),
/**
* Get upload URL for a submission file
*/
getSubmissionUploadUrl: publicProcedure
.input(
z.object({
formId: z.string(),
fieldName: z.string(),
fileName: z.string(),
mimeType: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
// Verify form exists and is accepting submissions
const form = await ctx.prisma.applicationForm.findUniqueOrThrow({
where: { id: input.formId },
})
if (!form.isPublic || form.status !== 'PUBLISHED') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'This form is not accepting submissions',
})
}
const timestamp = Date.now()
const sanitizedName = input.fileName.replace(/[^a-zA-Z0-9.-]/g, '_')
const objectKey = `forms/${input.formId}/${timestamp}-${sanitizedName}`
const url = await getPresignedUrl(SUBMISSIONS_BUCKET, objectKey, 'PUT', 3600)
return {
url,
bucket: SUBMISSIONS_BUCKET,
objectKey,
}
}),
/**
* List submissions for a form (admin only)
*/
listSubmissions: adminProcedure
.input(
z.object({
formId: z.string(),
status: z.enum(['SUBMITTED', 'REVIEWED', 'APPROVED', '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 where: Record<string, unknown> = {
formId: input.formId,
}
if (input.status) {
where.status = input.status
}
if (input.search) {
where.OR = [
{ email: { contains: input.search, mode: 'insensitive' } },
{ name: { contains: input.search, mode: 'insensitive' } },
]
}
const [data, total] = await Promise.all([
ctx.prisma.applicationFormSubmission.findMany({
where,
include: {
files: true,
},
orderBy: { createdAt: 'desc' },
skip: (input.page - 1) * input.perPage,
take: input.perPage,
}),
ctx.prisma.applicationFormSubmission.count({ where }),
])
return {
data,
total,
page: input.page,
perPage: input.perPage,
totalPages: Math.ceil(total / input.perPage),
}
}),
/**
* Get a single submission
*/
getSubmission: adminProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.applicationFormSubmission.findUniqueOrThrow({
where: { id: input.id },
include: {
form: {
include: { fields: { orderBy: { sortOrder: 'asc' } } },
},
files: true,
},
})
}),
/**
* Update submission status
*/
updateSubmissionStatus: adminProcedure
.input(
z.object({
id: z.string(),
status: z.enum(['SUBMITTED', 'REVIEWED', 'APPROVED', 'REJECTED']),
})
)
.mutation(async ({ ctx, input }) => {
const submission = await ctx.prisma.applicationFormSubmission.update({
where: { id: input.id },
data: { status: input.status },
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'UPDATE_STATUS',
entityType: 'ApplicationFormSubmission',
entityId: input.id,
detailsJson: { status: input.status },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return submission
}),
/**
* Delete a submission
*/
deleteSubmission: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const submission = await ctx.prisma.applicationFormSubmission.delete({
where: { id: input.id },
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'DELETE',
entityType: 'ApplicationFormSubmission',
entityId: input.id,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return submission
}),
/**
* Get download URL for a submission file
*/
getSubmissionFileUrl: adminProcedure
.input(z.object({ fileId: z.string() }))
.query(async ({ ctx, input }) => {
const file = await ctx.prisma.submissionFile.findUniqueOrThrow({
where: { id: input.fileId },
})
const url = await getPresignedUrl(file.bucket, file.objectKey, 'GET', 900)
return { url, fileName: file.fileName }
}),
/**
* Duplicate a form
*/
duplicate: adminProcedure
.input(
z.object({
id: z.string(),
name: z.string().min(1).max(255),
})
)
.mutation(async ({ ctx, input }) => {
// Get original form with fields
const original = await ctx.prisma.applicationForm.findUniqueOrThrow({
where: { id: input.id },
include: { fields: true },
})
// Create new form
const newForm = await ctx.prisma.applicationForm.create({
data: {
programId: original.programId,
name: input.name,
description: original.description,
status: 'DRAFT',
isPublic: false,
confirmationMessage: original.confirmationMessage,
},
})
// Copy fields
await ctx.prisma.applicationFormField.createMany({
data: original.fields.map((field) => ({
formId: newForm.id,
fieldType: field.fieldType,
name: field.name,
label: field.label,
description: field.description,
placeholder: field.placeholder,
required: field.required,
minLength: field.minLength,
maxLength: field.maxLength,
minValue: field.minValue,
maxValue: field.maxValue,
optionsJson: field.optionsJson as Prisma.InputJsonValue ?? undefined,
conditionJson: field.conditionJson as Prisma.InputJsonValue ?? undefined,
sortOrder: field.sortOrder,
width: field.width,
})),
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'DUPLICATE',
entityType: 'ApplicationForm',
entityId: newForm.id,
detailsJson: { originalId: input.id, name: input.name },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return newForm
}),
/**
* Get form statistics
*/
getStats: adminProcedure
.input(z.object({ formId: z.string() }))
.query(async ({ ctx, input }) => {
const [total, byStatus, recentSubmissions] = await Promise.all([
ctx.prisma.applicationFormSubmission.count({
where: { formId: input.formId },
}),
ctx.prisma.applicationFormSubmission.groupBy({
by: ['status'],
where: { formId: input.formId },
_count: true,
}),
ctx.prisma.applicationFormSubmission.findMany({
where: { formId: input.formId },
select: { createdAt: true },
orderBy: { createdAt: 'desc' },
take: 30,
}),
])
// Calculate submissions per day for last 30 days
const thirtyDaysAgo = new Date()
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30)
const submissionsPerDay = new Map<string, number>()
for (const sub of recentSubmissions) {
const date = sub.createdAt.toISOString().split('T')[0]
submissionsPerDay.set(date, (submissionsPerDay.get(date) || 0) + 1)
}
return {
total,
byStatus: Object.fromEntries(
byStatus.map((r) => [r.status, r._count])
),
submissionsPerDay: Object.fromEntries(submissionsPerDay),
}
}),
})

View File

@@ -0,0 +1,628 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, protectedProcedure, adminProcedure } from '../trpc'
import {
generateAIAssignments,
generateFallbackAssignments,
} from '../services/ai-assignment'
import { isOpenAIConfigured } from '@/lib/openai'
export const assignmentRouter = router({
/**
* List assignments for a round (admin only)
*/
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, expertiseTags: true } },
project: { select: { id: true, title: true, tags: true } },
evaluation: { select: { status: true, submittedAt: true } },
},
orderBy: { createdAt: 'desc' },
})
}),
/**
* List assignments for a project (admin only)
*/
listByProject: adminProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.assignment.findMany({
where: { projectId: input.projectId },
include: {
user: { select: { id: true, name: true, email: true, expertiseTags: true } },
evaluation: { select: { status: true, submittedAt: true, globalScore: true, binaryDecision: true } },
},
orderBy: { createdAt: 'desc' },
})
}),
/**
* Get my assignments (for jury members)
*/
myAssignments: protectedProcedure
.input(
z.object({
roundId: z.string().optional(),
status: z.enum(['all', 'pending', 'completed']).default('all'),
})
)
.query(async ({ ctx, input }) => {
const where: Record<string, unknown> = {
userId: ctx.user.id,
round: { status: 'ACTIVE' },
}
if (input.roundId) {
where.roundId = input.roundId
}
if (input.status === 'pending') {
where.isCompleted = false
} else if (input.status === 'completed') {
where.isCompleted = true
}
return ctx.prisma.assignment.findMany({
where,
include: {
project: {
include: { files: true },
},
round: true,
evaluation: true,
},
orderBy: [{ isCompleted: 'asc' }, { createdAt: 'asc' }],
})
}),
/**
* Get assignment by ID
*/
get: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const assignment = await ctx.prisma.assignment.findUniqueOrThrow({
where: { id: input.id },
include: {
user: { select: { id: true, name: true, email: true } },
project: { include: { files: true } },
round: { include: { evaluationForms: { where: { isActive: true } } } },
evaluation: true,
},
})
// Verify access
if (
ctx.user.role === 'JURY_MEMBER' &&
assignment.userId !== ctx.user.id
) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You do not have access to this assignment',
})
}
return assignment
}),
/**
* Create a single assignment (admin only)
*/
create: adminProcedure
.input(
z.object({
userId: z.string(),
projectId: z.string(),
roundId: z.string(),
isRequired: z.boolean().default(true),
})
)
.mutation(async ({ ctx, input }) => {
// Check if assignment already exists
const existing = await ctx.prisma.assignment.findUnique({
where: {
userId_projectId_roundId: {
userId: input.userId,
projectId: input.projectId,
roundId: input.roundId,
},
},
})
if (existing) {
throw new TRPCError({
code: 'CONFLICT',
message: 'This assignment already exists',
})
}
// Check user's assignment limit
const user = await ctx.prisma.user.findUniqueOrThrow({
where: { id: input.userId },
select: { maxAssignments: true },
})
if (user.maxAssignments !== null) {
const currentCount = await ctx.prisma.assignment.count({
where: { userId: input.userId, roundId: input.roundId },
})
if (currentCount >= user.maxAssignments) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `User has reached their maximum assignment limit of ${user.maxAssignments}`,
})
}
}
const assignment = await ctx.prisma.assignment.create({
data: {
...input,
method: 'MANUAL',
createdBy: ctx.user.id,
},
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'CREATE',
entityType: 'Assignment',
entityId: assignment.id,
detailsJson: input,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return assignment
}),
/**
* Bulk create assignments (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,
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'BULK_CREATE',
entityType: 'Assignment',
detailsJson: { count: result.count },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return { created: result.count }
}),
/**
* Delete an assignment (admin only)
*/
delete: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const assignment = await ctx.prisma.assignment.delete({
where: { id: input.id },
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'DELETE',
entityType: 'Assignment',
entityId: input.id,
detailsJson: {
userId: assignment.userId,
projectId: assignment.projectId,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return assignment
}),
/**
* Get assignment statistics for a round
*/
getStats: adminProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const [
totalAssignments,
completedAssignments,
assignmentsByUser,
projectCoverage,
] = await Promise.all([
ctx.prisma.assignment.count({ where: { roundId: input.roundId } }),
ctx.prisma.assignment.count({
where: { roundId: input.roundId, isCompleted: true },
}),
ctx.prisma.assignment.groupBy({
by: ['userId'],
where: { roundId: input.roundId },
_count: true,
}),
ctx.prisma.project.findMany({
where: { roundId: input.roundId },
select: {
id: true,
title: true,
_count: { select: { assignments: true } },
},
}),
])
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { requiredReviews: true },
})
const projectsWithFullCoverage = projectCoverage.filter(
(p) => p._count.assignments >= round.requiredReviews
).length
return {
totalAssignments,
completedAssignments,
completionPercentage:
totalAssignments > 0
? Math.round((completedAssignments / totalAssignments) * 100)
: 0,
juryMembersAssigned: assignmentsByUser.length,
projectsWithFullCoverage,
totalProjects: projectCoverage.length,
coveragePercentage:
projectCoverage.length > 0
? Math.round(
(projectsWithFullCoverage / projectCoverage.length) * 100
)
: 0,
}
}),
/**
* Get smart assignment suggestions using algorithm
*/
getSuggestions: adminProcedure
.input(
z.object({
roundId: z.string(),
maxPerJuror: z.number().int().min(1).max(50).default(10),
minPerProject: z.number().int().min(1).max(10).default(3),
})
)
.query(async ({ ctx, input }) => {
// Get all active jury members with their expertise and current load
const jurors = await ctx.prisma.user.findMany({
where: { role: 'JURY_MEMBER', status: 'ACTIVE' },
select: {
id: true,
name: true,
email: true,
expertiseTags: true,
maxAssignments: true,
_count: {
select: {
assignments: { where: { roundId: input.roundId } },
},
},
},
})
// Get all projects that need more assignments
const projects = await ctx.prisma.project.findMany({
where: { roundId: input.roundId },
select: {
id: true,
title: true,
tags: true,
_count: { select: { assignments: true } },
},
})
// Get existing assignments to avoid duplicates
const existingAssignments = await ctx.prisma.assignment.findMany({
where: { roundId: input.roundId },
select: { userId: true, projectId: true },
})
const assignmentSet = new Set(
existingAssignments.map((a) => `${a.userId}-${a.projectId}`)
)
// Simple scoring algorithm
const suggestions: Array<{
userId: string
projectId: string
score: number
reasoning: string[]
}> = []
for (const project of projects) {
// Skip if project has enough assignments
if (project._count.assignments >= input.minPerProject) continue
const neededAssignments = input.minPerProject - project._count.assignments
// Score each juror for this project
const jurorScores = jurors
.filter((j) => {
// Skip if already assigned
if (assignmentSet.has(`${j.id}-${project.id}`)) return false
// Skip if at max capacity
const maxAllowed = j.maxAssignments ?? input.maxPerJuror
if (j._count.assignments >= maxAllowed) return false
return true
})
.map((juror) => {
const reasoning: string[] = []
let score = 0
// Expertise match (40% weight)
const matchingTags = juror.expertiseTags.filter((tag) =>
project.tags.includes(tag)
)
const expertiseScore =
matchingTags.length > 0
? matchingTags.length / Math.max(project.tags.length, 1)
: 0
score += expertiseScore * 40
if (matchingTags.length > 0) {
reasoning.push(`Expertise match: ${matchingTags.join(', ')}`)
}
// Load balancing (25% weight)
const maxAllowed = juror.maxAssignments ?? input.maxPerJuror
const loadScore = 1 - juror._count.assignments / maxAllowed
score += loadScore * 25
reasoning.push(
`Workload: ${juror._count.assignments}/${maxAllowed} assigned`
)
return {
userId: juror.id,
projectId: project.id,
score,
reasoning,
}
})
.sort((a, b) => b.score - a.score)
.slice(0, neededAssignments)
suggestions.push(...jurorScores)
}
// Sort by score and return
return suggestions.sort((a, b) => b.score - a.score)
}),
/**
* Check if AI assignment is available
*/
isAIAvailable: adminProcedure.query(async () => {
return isOpenAIConfigured()
}),
/**
* Get AI-powered assignment suggestions
*/
getAISuggestions: adminProcedure
.input(
z.object({
roundId: z.string(),
useAI: z.boolean().default(true),
maxPerJuror: z.number().int().min(1).max(50).default(10),
})
)
.query(async ({ ctx, input }) => {
// Get round info
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { requiredReviews: true },
})
// Get all active jury members with their expertise and current load
const jurors = await ctx.prisma.user.findMany({
where: { role: 'JURY_MEMBER', status: 'ACTIVE' },
select: {
id: true,
name: true,
email: true,
expertiseTags: true,
maxAssignments: true,
_count: {
select: {
assignments: { where: { roundId: input.roundId } },
},
},
},
})
// Get all projects in the round
const projects = await ctx.prisma.project.findMany({
where: { roundId: input.roundId },
select: {
id: true,
title: true,
description: true,
tags: true,
teamName: true,
_count: { select: { assignments: true } },
},
})
// Get existing assignments
const existingAssignments = await ctx.prisma.assignment.findMany({
where: { roundId: input.roundId },
select: { userId: true, projectId: true },
})
const constraints = {
requiredReviewsPerProject: round.requiredReviews,
maxAssignmentsPerJuror: input.maxPerJuror,
existingAssignments: existingAssignments.map((a) => ({
jurorId: a.userId,
projectId: a.projectId,
})),
}
// Use AI or fallback based on input and availability
let result
if (input.useAI) {
result = await generateAIAssignments(jurors, projects, constraints)
} else {
result = generateFallbackAssignments(jurors, projects, constraints)
}
// Enrich suggestions with user and project names for display
const enrichedSuggestions = await Promise.all(
result.suggestions.map(async (s) => {
const juror = jurors.find((j) => j.id === s.jurorId)
const project = projects.find((p) => p.id === s.projectId)
return {
...s,
jurorName: juror?.name || juror?.email || 'Unknown',
projectTitle: project?.title || 'Unknown',
}
})
)
return {
success: result.success,
suggestions: enrichedSuggestions,
fallbackUsed: result.fallbackUsed,
error: result.error,
}
}),
/**
* Apply AI-suggested assignments
*/
applyAISuggestions: adminProcedure
.input(
z.object({
roundId: z.string(),
assignments: z.array(
z.object({
userId: z.string(),
projectId: z.string(),
confidenceScore: z.number().optional(),
expertiseMatchScore: z.number().optional(),
reasoning: z.string().optional(),
})
),
usedAI: z.boolean().default(false),
})
)
.mutation(async ({ ctx, input }) => {
const created = await ctx.prisma.assignment.createMany({
data: input.assignments.map((a) => ({
userId: a.userId,
projectId: a.projectId,
roundId: input.roundId,
method: input.usedAI ? 'AI_SUGGESTED' : 'ALGORITHM',
aiConfidenceScore: a.confidenceScore,
expertiseMatchScore: a.expertiseMatchScore,
aiReasoning: a.reasoning,
createdBy: ctx.user.id,
})),
skipDuplicates: true,
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: input.usedAI ? 'APPLY_AI_SUGGESTIONS' : 'APPLY_SUGGESTIONS',
entityType: 'Assignment',
detailsJson: {
roundId: input.roundId,
count: created.count,
usedAI: input.usedAI,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return { created: created.count }
}),
/**
* Apply suggested assignments
*/
applySuggestions: 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 }) => {
const created = await ctx.prisma.assignment.createMany({
data: input.assignments.map((a) => ({
userId: a.userId,
projectId: a.projectId,
roundId: input.roundId,
method: 'ALGORITHM',
aiReasoning: a.reasoning,
createdBy: ctx.user.id,
})),
skipDuplicates: true,
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'APPLY_SUGGESTIONS',
entityType: 'Assignment',
detailsJson: {
roundId: input.roundId,
count: created.count,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return { created: created.count }
}),
})

184
src/server/routers/audit.ts Normal file
View File

@@ -0,0 +1,184 @@
import { z } from 'zod'
import { router, adminProcedure } from '../trpc'
export const auditRouter = router({
/**
* List audit logs with filtering and pagination
*/
list: adminProcedure
.input(
z.object({
userId: z.string().optional(),
action: z.string().optional(),
entityType: z.string().optional(),
entityId: z.string().optional(),
startDate: z.date().optional(),
endDate: z.date().optional(),
page: z.number().int().min(1).default(1),
perPage: z.number().int().min(1).max(100).default(50),
})
)
.query(async ({ ctx, input }) => {
const { userId, action, entityType, entityId, startDate, endDate, page, perPage } = input
const skip = (page - 1) * perPage
const where: Record<string, unknown> = {}
if (userId) where.userId = userId
if (action) where.action = { contains: action, mode: 'insensitive' }
if (entityType) where.entityType = entityType
if (entityId) where.entityId = entityId
if (startDate || endDate) {
where.timestamp = {}
if (startDate) (where.timestamp as Record<string, Date>).gte = startDate
if (endDate) (where.timestamp as Record<string, Date>).lte = endDate
}
const [logs, total] = await Promise.all([
ctx.prisma.auditLog.findMany({
where,
skip,
take: perPage,
orderBy: { timestamp: 'desc' },
include: {
user: { select: { name: true, email: true } },
},
}),
ctx.prisma.auditLog.count({ where }),
])
return {
logs,
total,
page,
perPage,
totalPages: Math.ceil(total / perPage),
}
}),
/**
* Get audit logs for a specific entity
*/
getByEntity: adminProcedure
.input(
z.object({
entityType: z.string(),
entityId: z.string(),
})
)
.query(async ({ ctx, input }) => {
return ctx.prisma.auditLog.findMany({
where: {
entityType: input.entityType,
entityId: input.entityId,
},
orderBy: { timestamp: 'desc' },
include: {
user: { select: { name: true, email: true } },
},
})
}),
/**
* Get audit logs for a specific user
*/
getByUser: adminProcedure
.input(
z.object({
userId: z.string(),
limit: z.number().int().min(1).max(100).default(50),
})
)
.query(async ({ ctx, input }) => {
return ctx.prisma.auditLog.findMany({
where: { userId: input.userId },
orderBy: { timestamp: 'desc' },
take: input.limit,
})
}),
/**
* Get recent activity summary
*/
getRecentActivity: adminProcedure
.input(z.object({ limit: z.number().int().min(1).max(50).default(20) }))
.query(async ({ ctx, input }) => {
return ctx.prisma.auditLog.findMany({
orderBy: { timestamp: 'desc' },
take: input.limit,
include: {
user: { select: { name: true, email: true } },
},
})
}),
/**
* Get action statistics
*/
getStats: adminProcedure
.input(
z.object({
startDate: z.date().optional(),
endDate: z.date().optional(),
})
)
.query(async ({ ctx, input }) => {
const where: Record<string, unknown> = {}
if (input.startDate || input.endDate) {
where.timestamp = {}
if (input.startDate) (where.timestamp as Record<string, Date>).gte = input.startDate
if (input.endDate) (where.timestamp as Record<string, Date>).lte = input.endDate
}
const [byAction, byEntity, byUser] = await Promise.all([
ctx.prisma.auditLog.groupBy({
by: ['action'],
where,
_count: true,
orderBy: { _count: { action: 'desc' } },
}),
ctx.prisma.auditLog.groupBy({
by: ['entityType'],
where,
_count: true,
orderBy: { _count: { entityType: 'desc' } },
}),
ctx.prisma.auditLog.groupBy({
by: ['userId'],
where,
_count: true,
orderBy: { _count: { userId: 'desc' } },
take: 10,
}),
])
// Get user names for top users
const userIds = byUser
.map((u) => u.userId)
.filter((id): id is string => id !== null)
const users = await ctx.prisma.user.findMany({
where: { id: { in: userIds } },
select: { id: true, name: true, email: true },
})
const userMap = new Map(users.map((u) => [u.id, u]))
return {
byAction: byAction.map((a) => ({
action: a.action,
count: a._count,
})),
byEntity: byEntity.map((e) => ({
entityType: e.entityType,
count: e._count,
})),
byUser: byUser.map((u) => ({
userId: u.userId,
user: u.userId ? userMap.get(u.userId) : null,
count: u._count,
})),
}
}),
})

View File

@@ -0,0 +1,187 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, protectedProcedure } from '../trpc'
import {
getStorageProviderWithType,
createStorageProvider,
generateAvatarKey,
getContentType,
isValidImageType,
type StorageProviderType,
} from '@/lib/storage'
export const avatarRouter = router({
/**
* Get a pre-signed URL for uploading an avatar
*/
getUploadUrl: protectedProcedure
.input(
z.object({
fileName: z.string(),
contentType: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
// Validate content type
if (!isValidImageType(input.contentType)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid image type. Allowed: JPEG, PNG, GIF, WebP' })
}
const userId = ctx.user.id
const key = generateAvatarKey(userId, input.fileName)
const contentType = getContentType(input.fileName)
const { provider, providerType } = await getStorageProviderWithType()
const uploadUrl = await provider.getUploadUrl(key, contentType)
return {
uploadUrl,
key,
providerType, // Return so client can pass it back on confirm
}
}),
/**
* Confirm avatar upload and update user profile
*/
confirmUpload: protectedProcedure
.input(
z.object({
key: z.string(),
providerType: z.enum(['s3', 'local']),
})
)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user.id
// Use the provider that was used for upload
const provider = createStorageProvider(input.providerType)
const exists = await provider.objectExists(input.key)
if (!exists) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Upload not found. Please try uploading again.' })
}
// Delete old avatar if exists (from its original provider)
const currentUser = await ctx.prisma.user.findUnique({
where: { id: userId },
select: { profileImageKey: true, profileImageProvider: true },
})
if (currentUser?.profileImageKey) {
try {
const oldProvider = createStorageProvider(
(currentUser.profileImageProvider as StorageProviderType) || 's3'
)
await oldProvider.deleteObject(currentUser.profileImageKey)
} catch (error) {
// Log but don't fail if old avatar deletion fails
console.warn('Failed to delete old avatar:', error)
}
}
// Update user with new avatar key and provider
const user = await ctx.prisma.user.update({
where: { id: userId },
data: {
profileImageKey: input.key,
profileImageProvider: input.providerType,
},
select: {
id: true,
profileImageKey: true,
profileImageProvider: true,
},
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'User',
entityId: userId,
detailsJson: {
field: 'profileImageKey',
newValue: input.key,
provider: input.providerType,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return user
}),
/**
* Get the current user's avatar URL
*/
getUrl: protectedProcedure.query(async ({ ctx }) => {
const userId = ctx.user.id
const user = await ctx.prisma.user.findUnique({
where: { id: userId },
select: { profileImageKey: true, profileImageProvider: true },
})
if (!user?.profileImageKey) {
return null
}
// Use the provider that was used when the file was stored
const providerType = (user.profileImageProvider as StorageProviderType) || 's3'
const provider = createStorageProvider(providerType)
const url = await provider.getDownloadUrl(user.profileImageKey)
return url
}),
/**
* Delete the current user's avatar
*/
delete: protectedProcedure.mutation(async ({ ctx }) => {
const userId = ctx.user.id
const user = await ctx.prisma.user.findUnique({
where: { id: userId },
select: { profileImageKey: true, profileImageProvider: true },
})
if (!user?.profileImageKey) {
return { success: true }
}
// Delete from the provider that was used when the file was stored
const providerType = (user.profileImageProvider as StorageProviderType) || 's3'
const provider = createStorageProvider(providerType)
try {
await provider.deleteObject(user.profileImageKey)
} catch (error) {
console.warn('Failed to delete avatar from storage:', error)
}
// Update user - clear both key and provider
await ctx.prisma.user.update({
where: { id: userId },
data: {
profileImageKey: null,
profileImageProvider: null,
},
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'DELETE',
entityType: 'User',
entityId: userId,
detailsJson: { field: 'profileImageKey' },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return { success: true }
}),
})

View File

@@ -0,0 +1,322 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, protectedProcedure, adminProcedure } from '../trpc'
export const evaluationRouter = router({
/**
* Get evaluation for an 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 },
include: {
form: true,
},
})
}),
/**
* Start an evaluation (creates draft)
*/
start: protectedProcedure
.input(
z.object({
assignmentId: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
// Verify assignment ownership
const assignment = await ctx.prisma.assignment.findUniqueOrThrow({
where: { id: input.assignmentId },
include: {
round: {
include: {
evaluationForms: { where: { isActive: true }, take: 1 },
},
},
},
})
if (assignment.userId !== ctx.user.id) {
throw new TRPCError({ code: 'FORBIDDEN' })
}
// Get active form
const form = assignment.round.evaluationForms[0]
if (!form) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'No active evaluation form for this round',
})
}
// 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: form.id,
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().nullable(),
binaryDecision: z.boolean().optional().nullable(),
feedbackText: z.string().optional().nullable(),
})
)
.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(10),
})
)
.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',
})
}
// Check for grace period
const gracePeriod = await ctx.prisma.gracePeriod.findFirst({
where: {
roundId: round.id,
userId: ctx.user.id,
OR: [
{ projectId: null },
{ projectId: evaluation.assignment.projectId },
],
extendedUntil: { gte: now },
},
})
const effectiveEndDate = gracePeriod?.extendedUntil ?? round.votingEndAt
if (round.votingStartAt && now < round.votingStartAt) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Voting has not started yet',
})
}
if (effectiveEndDate && now > effectiveEndDate) {
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: now,
},
})
// 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,
userAgent: ctx.userAgent,
},
})
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.length > 0
? globalScores.reduce((a, b) => a + b, 0) / globalScores.length
: null,
minScore: globalScores.length > 0 ? Math.min(...globalScores) : null,
maxScore: globalScores.length > 0 ? Math.max(...globalScores) : null,
yesVotes,
noVotes: evaluations.length - yesVotes,
yesPercentage: (yesVotes / evaluations.length) * 100,
}
}),
/**
* Get all evaluations for a round (admin only)
*/
listByRound: adminProcedure
.input(
z.object({
roundId: z.string(),
status: z.enum(['NOT_STARTED', 'DRAFT', 'SUBMITTED', 'LOCKED']).optional(),
})
)
.query(async ({ ctx, input }) => {
return ctx.prisma.evaluation.findMany({
where: {
assignment: { roundId: input.roundId },
...(input.status && { status: input.status }),
},
include: {
assignment: {
include: {
user: { select: { id: true, name: true, email: true } },
project: { select: { id: true, title: true } },
},
},
},
orderBy: { updatedAt: 'desc' },
})
}),
/**
* Get my past evaluations (read-only for jury)
*/
myPastEvaluations: protectedProcedure
.input(z.object({ roundId: z.string().optional() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.evaluation.findMany({
where: {
assignment: {
userId: ctx.user.id,
...(input.roundId && { roundId: input.roundId }),
},
status: 'SUBMITTED',
},
include: {
assignment: {
include: {
project: { select: { id: true, title: true } },
round: { select: { id: true, name: true } },
},
},
},
orderBy: { submittedAt: 'desc' },
})
}),
})

View File

@@ -0,0 +1,295 @@
import { z } from 'zod'
import { router, adminProcedure } from '../trpc'
export const exportRouter = router({
/**
* Export evaluations as CSV data
*/
evaluations: adminProcedure
.input(
z.object({
roundId: z.string(),
includeDetails: z.boolean().default(true),
})
)
.query(async ({ ctx, input }) => {
const evaluations = await ctx.prisma.evaluation.findMany({
where: {
status: 'SUBMITTED',
assignment: { roundId: input.roundId },
},
include: {
assignment: {
include: {
user: { select: { name: true, email: true } },
project: { select: { title: true, teamName: true, tags: true } },
},
},
form: { select: { criteriaJson: true } },
},
orderBy: [
{ assignment: { project: { title: 'asc' } } },
{ submittedAt: 'asc' },
],
})
// Get criteria labels from form
const criteriaLabels: Record<string, string> = {}
if (evaluations.length > 0) {
const criteria = evaluations[0].form.criteriaJson as Array<{
id: string
label: string
}>
criteria.forEach((c) => {
criteriaLabels[c.id] = c.label
})
}
// Build export data
const data = evaluations.map((e) => {
const scores = e.criterionScoresJson as Record<string, number> | null
const criteriaScores: Record<string, number | null> = {}
Object.keys(criteriaLabels).forEach((id) => {
criteriaScores[criteriaLabels[id]] = scores?.[id] ?? null
})
return {
projectTitle: e.assignment.project.title,
teamName: e.assignment.project.teamName,
tags: e.assignment.project.tags.join(', '),
jurorName: e.assignment.user.name,
jurorEmail: e.assignment.user.email,
...criteriaScores,
globalScore: e.globalScore,
decision: e.binaryDecision ? 'Yes' : 'No',
feedback: input.includeDetails ? e.feedbackText : null,
submittedAt: e.submittedAt?.toISOString(),
}
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'EXPORT',
entityType: 'Evaluation',
detailsJson: { roundId: input.roundId, count: data.length },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return {
data,
columns: [
'projectTitle',
'teamName',
'tags',
'jurorName',
'jurorEmail',
...Object.values(criteriaLabels),
'globalScore',
'decision',
...(input.includeDetails ? ['feedback'] : []),
'submittedAt',
],
}
}),
/**
* Export project scores summary
*/
projectScores: adminProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const projects = await ctx.prisma.project.findMany({
where: { roundId: input.roundId },
include: {
assignments: {
include: {
evaluation: {
where: { status: 'SUBMITTED' },
},
},
},
},
orderBy: { title: 'asc' },
})
const data = projects.map((p) => {
const evaluations = p.assignments
.map((a) => a.evaluation)
.filter((e) => e !== 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 {
title: p.title,
teamName: p.teamName,
status: p.status,
tags: p.tags.join(', '),
totalEvaluations: evaluations.length,
averageScore:
globalScores.length > 0
? (
globalScores.reduce((a, b) => a + b, 0) / globalScores.length
).toFixed(2)
: null,
minScore: globalScores.length > 0 ? Math.min(...globalScores) : null,
maxScore: globalScores.length > 0 ? Math.max(...globalScores) : null,
yesVotes,
noVotes: evaluations.length - yesVotes,
yesPercentage:
evaluations.length > 0
? ((yesVotes / evaluations.length) * 100).toFixed(1)
: null,
}
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'EXPORT',
entityType: 'ProjectScores',
detailsJson: { roundId: input.roundId, count: data.length },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return {
data,
columns: [
'title',
'teamName',
'status',
'tags',
'totalEvaluations',
'averageScore',
'minScore',
'maxScore',
'yesVotes',
'noVotes',
'yesPercentage',
],
}
}),
/**
* Export assignments
*/
assignments: adminProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const assignments = await ctx.prisma.assignment.findMany({
where: { roundId: input.roundId },
include: {
user: { select: { name: true, email: true } },
project: { select: { title: true, teamName: true } },
evaluation: { select: { status: true, submittedAt: true } },
},
orderBy: [{ project: { title: 'asc' } }, { user: { name: 'asc' } }],
})
const data = assignments.map((a) => ({
projectTitle: a.project.title,
teamName: a.project.teamName,
jurorName: a.user.name,
jurorEmail: a.user.email,
method: a.method,
isRequired: a.isRequired ? 'Yes' : 'No',
isCompleted: a.isCompleted ? 'Yes' : 'No',
evaluationStatus: a.evaluation?.status ?? 'NOT_STARTED',
submittedAt: a.evaluation?.submittedAt?.toISOString() ?? null,
assignedAt: a.createdAt.toISOString(),
}))
return {
data,
columns: [
'projectTitle',
'teamName',
'jurorName',
'jurorEmail',
'method',
'isRequired',
'isCompleted',
'evaluationStatus',
'submittedAt',
'assignedAt',
],
}
}),
/**
* Export audit logs as CSV data
*/
auditLogs: adminProcedure
.input(
z.object({
userId: z.string().optional(),
action: z.string().optional(),
entityType: z.string().optional(),
startDate: z.date().optional(),
endDate: z.date().optional(),
})
)
.query(async ({ ctx, input }) => {
const { userId, action, entityType, startDate, endDate } = input
const where: Record<string, unknown> = {}
if (userId) where.userId = userId
if (action) where.action = { contains: action, mode: 'insensitive' }
if (entityType) where.entityType = entityType
if (startDate || endDate) {
where.timestamp = {}
if (startDate) (where.timestamp as Record<string, Date>).gte = startDate
if (endDate) (where.timestamp as Record<string, Date>).lte = endDate
}
const logs = await ctx.prisma.auditLog.findMany({
where,
orderBy: { timestamp: 'desc' },
include: {
user: { select: { name: true, email: true } },
},
take: 10000, // Limit export to 10k records
})
const data = logs.map((log) => ({
timestamp: log.timestamp.toISOString(),
userName: log.user?.name ?? 'System',
userEmail: log.user?.email ?? 'N/A',
action: log.action,
entityType: log.entityType,
entityId: log.entityId ?? '',
ipAddress: log.ipAddress ?? '',
userAgent: log.userAgent ?? '',
details: log.detailsJson ? JSON.stringify(log.detailsJson) : '',
}))
return {
data,
columns: [
'timestamp',
'userName',
'userEmail',
'action',
'entityType',
'entityId',
'ipAddress',
'userAgent',
'details',
],
}
}),
})

194
src/server/routers/file.ts Normal file
View File

@@ -0,0 +1,194 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, protectedProcedure, adminProcedure } from '../trpc'
import { getPresignedUrl, generateObjectKey, BUCKET_NAME } from '@/lib/minio'
export const fileRouter = router({
/**
* Get pre-signed download URL
* Checks that the user is authorized to access the file's project
*/
getDownloadUrl: protectedProcedure
.input(
z.object({
bucket: z.string(),
objectKey: z.string(),
})
)
.query(async ({ ctx, input }) => {
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
if (!isAdmin) {
// Find the file record to get the project
const file = await ctx.prisma.projectFile.findFirst({
where: { bucket: input.bucket, objectKey: input.objectKey },
select: { projectId: true },
})
if (!file) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'File not found',
})
}
// Check if user is assigned as jury or mentor for this project
const [juryAssignment, mentorAssignment] = await Promise.all([
ctx.prisma.assignment.findFirst({
where: { userId: ctx.user.id, projectId: file.projectId },
select: { id: true },
}),
ctx.prisma.mentorAssignment.findFirst({
where: { mentorId: ctx.user.id, projectId: file.projectId },
select: { id: true },
}),
])
if (!juryAssignment && !mentorAssignment) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You do not have access to this file',
})
}
}
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 = BUCKET_NAME
const objectKey = generateObjectKey(input.projectId, 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,
},
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'UPLOAD_FILE',
entityType: 'ProjectFile',
entityId: file.id,
detailsJson: {
projectId: input.projectId,
fileName: input.fileName,
fileType: input.fileType,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return {
uploadUrl,
file,
}
}),
/**
* Confirm file upload completed
*/
confirmUpload: adminProcedure
.input(z.object({ fileId: z.string() }))
.mutation(async ({ ctx, input }) => {
// In the future, we could verify the file exists in MinIO
// For now, just return the file
return ctx.prisma.projectFile.findUniqueOrThrow({
where: { id: input.fileId },
})
}),
/**
* 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
// For now, we just delete the database record
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'DELETE_FILE',
entityType: 'ProjectFile',
entityId: input.id,
detailsJson: {
fileName: file.fileName,
bucket: file.bucket,
objectKey: file.objectKey,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return file
}),
/**
* List files for a project
* Checks that the user is authorized to view the project's files
*/
listByProject: protectedProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
if (!isAdmin) {
const [juryAssignment, mentorAssignment] = await Promise.all([
ctx.prisma.assignment.findFirst({
where: { userId: ctx.user.id, projectId: input.projectId },
select: { id: true },
}),
ctx.prisma.mentorAssignment.findFirst({
where: { mentorId: ctx.user.id, projectId: input.projectId },
select: { id: true },
}),
])
if (!juryAssignment && !mentorAssignment) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You do not have access to this project\'s files',
})
}
}
return ctx.prisma.projectFile.findMany({
where: { projectId: input.projectId },
orderBy: [{ fileType: 'asc' }, { createdAt: 'asc' }],
})
}),
})

View File

@@ -0,0 +1,208 @@
import { z } from 'zod'
import { router, adminProcedure } from '../trpc'
export const gracePeriodRouter = router({
/**
* Grant a grace period to a juror
*/
grant: adminProcedure
.input(
z.object({
roundId: z.string(),
userId: z.string(),
projectId: z.string().optional(), // Optional: specific project or all projects
extendedUntil: z.date(),
reason: z.string().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const gracePeriod = await ctx.prisma.gracePeriod.create({
data: {
...input,
grantedById: ctx.user.id,
},
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'GRANT_GRACE_PERIOD',
entityType: 'GracePeriod',
entityId: gracePeriod.id,
detailsJson: {
roundId: input.roundId,
userId: input.userId,
projectId: input.projectId,
extendedUntil: input.extendedUntil.toISOString(),
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return gracePeriod
}),
/**
* 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 } },
grantedBy: { select: { id: true, name: true } },
},
orderBy: { createdAt: 'desc' },
})
}),
/**
* List active grace periods for a round
*/
listActiveByRound: adminProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.gracePeriod.findMany({
where: {
roundId: input.roundId,
extendedUntil: { gte: new Date() },
},
include: {
user: { select: { id: true, name: true, email: true } },
grantedBy: { select: { id: true, name: true } },
},
orderBy: { extendedUntil: 'asc' },
})
}),
/**
* Get grace periods for a specific user in a round
*/
getByUser: adminProcedure
.input(
z.object({
roundId: z.string(),
userId: z.string(),
})
)
.query(async ({ ctx, input }) => {
return ctx.prisma.gracePeriod.findMany({
where: {
roundId: input.roundId,
userId: input.userId,
},
orderBy: { createdAt: 'desc' },
})
}),
/**
* Update a grace period
*/
update: adminProcedure
.input(
z.object({
id: z.string(),
extendedUntil: z.date().optional(),
reason: z.string().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input
const gracePeriod = await ctx.prisma.gracePeriod.update({
where: { id },
data,
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'UPDATE_GRACE_PERIOD',
entityType: 'GracePeriod',
entityId: id,
detailsJson: data,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return gracePeriod
}),
/**
* Revoke a grace period
*/
revoke: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const gracePeriod = await ctx.prisma.gracePeriod.delete({
where: { id: input.id },
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'REVOKE_GRACE_PERIOD',
entityType: 'GracePeriod',
entityId: input.id,
detailsJson: {
userId: gracePeriod.userId,
roundId: gracePeriod.roundId,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return gracePeriod
}),
/**
* Bulk grant grace periods
*/
bulkGrant: adminProcedure
.input(
z.object({
roundId: z.string(),
userIds: z.array(z.string()),
extendedUntil: z.date(),
reason: z.string().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const created = await ctx.prisma.gracePeriod.createMany({
data: input.userIds.map((userId) => ({
roundId: input.roundId,
userId,
extendedUntil: input.extendedUntil,
reason: input.reason,
grantedById: ctx.user.id,
})),
skipDuplicates: true,
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'BULK_GRANT_GRACE_PERIOD',
entityType: 'GracePeriod',
detailsJson: {
roundId: input.roundId,
userCount: input.userIds.length,
created: created.count,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return { created: created.count }
}),
})

View File

@@ -0,0 +1,479 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import {
router,
protectedProcedure,
adminProcedure,
} from '../trpc'
import { getPresignedUrl } from '@/lib/minio'
// Bucket for learning resources
export const LEARNING_BUCKET = 'mopc-learning'
export const learningResourceRouter = router({
/**
* List all resources (admin view)
*/
list: adminProcedure
.input(
z.object({
programId: z.string().optional(),
resourceType: z.enum(['PDF', 'VIDEO', 'DOCUMENT', 'LINK', 'OTHER']).optional(),
cohortLevel: z.enum(['ALL', 'SEMIFINALIST', 'FINALIST']).optional(),
isPublished: z.boolean().optional(),
page: z.number().int().min(1).default(1),
perPage: z.number().int().min(1).max(100).default(20),
})
)
.query(async ({ ctx, input }) => {
const where: Record<string, unknown> = {}
if (input.programId !== undefined) {
where.programId = input.programId
}
if (input.resourceType) {
where.resourceType = input.resourceType
}
if (input.cohortLevel) {
where.cohortLevel = input.cohortLevel
}
if (input.isPublished !== undefined) {
where.isPublished = input.isPublished
}
const [data, total] = await Promise.all([
ctx.prisma.learningResource.findMany({
where,
include: {
program: { select: { id: true, name: true, year: true } },
createdBy: { select: { id: true, name: true, email: true } },
_count: { select: { accessLogs: true } },
},
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
skip: (input.page - 1) * input.perPage,
take: input.perPage,
}),
ctx.prisma.learningResource.count({ where }),
])
return {
data,
total,
page: input.page,
perPage: input.perPage,
totalPages: Math.ceil(total / input.perPage),
}
}),
/**
* Get resources accessible to the current user (jury view)
*/
myResources: protectedProcedure
.input(
z.object({
programId: z.string().optional(),
resourceType: z.enum(['PDF', 'VIDEO', 'DOCUMENT', 'LINK', 'OTHER']).optional(),
})
)
.query(async ({ ctx, input }) => {
// Determine user's cohort level based on their assignments
const assignments = await ctx.prisma.assignment.findMany({
where: { userId: ctx.user.id },
include: {
project: { select: { status: true } },
},
})
// Determine highest cohort level
let userCohortLevel: 'ALL' | 'SEMIFINALIST' | 'FINALIST' = 'ALL'
for (const assignment of assignments) {
if (assignment.project.status === 'FINALIST') {
userCohortLevel = 'FINALIST'
break
}
if (assignment.project.status === 'SEMIFINALIST') {
userCohortLevel = 'SEMIFINALIST'
}
}
// Build query based on cohort level
const cohortLevels = ['ALL']
if (userCohortLevel === 'SEMIFINALIST' || userCohortLevel === 'FINALIST') {
cohortLevels.push('SEMIFINALIST')
}
if (userCohortLevel === 'FINALIST') {
cohortLevels.push('FINALIST')
}
const where: Record<string, unknown> = {
isPublished: true,
cohortLevel: { in: cohortLevels },
}
if (input.programId) {
where.OR = [{ programId: input.programId }, { programId: null }]
}
if (input.resourceType) {
where.resourceType = input.resourceType
}
const resources = await ctx.prisma.learningResource.findMany({
where,
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
})
return {
resources,
userCohortLevel,
}
}),
/**
* Get a single resource by ID
*/
get: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const resource = await ctx.prisma.learningResource.findUniqueOrThrow({
where: { id: input.id },
include: {
program: { select: { id: true, name: true, year: true } },
createdBy: { select: { id: true, name: true, email: true } },
},
})
// Check access for non-admins
if (ctx.user.role === 'JURY_MEMBER') {
if (!resource.isPublished) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'This resource is not available',
})
}
// Check cohort level access
const assignments = await ctx.prisma.assignment.findMany({
where: { userId: ctx.user.id },
include: {
project: { select: { status: true } },
},
})
let userCohortLevel: 'ALL' | 'SEMIFINALIST' | 'FINALIST' = 'ALL'
for (const assignment of assignments) {
if (assignment.project.status === 'FINALIST') {
userCohortLevel = 'FINALIST'
break
}
if (assignment.project.status === 'SEMIFINALIST') {
userCohortLevel = 'SEMIFINALIST'
}
}
const accessibleLevels = ['ALL']
if (userCohortLevel === 'SEMIFINALIST' || userCohortLevel === 'FINALIST') {
accessibleLevels.push('SEMIFINALIST')
}
if (userCohortLevel === 'FINALIST') {
accessibleLevels.push('FINALIST')
}
if (!accessibleLevels.includes(resource.cohortLevel)) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You do not have access to this resource',
})
}
}
return resource
}),
/**
* Get download URL for a resource file
* Checks cohort level access for non-admin users
*/
getDownloadUrl: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const resource = await ctx.prisma.learningResource.findUniqueOrThrow({
where: { id: input.id },
})
if (!resource.bucket || !resource.objectKey) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'This resource does not have a file',
})
}
// Check access for non-admins
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
if (!isAdmin) {
if (!resource.isPublished) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'This resource is not available',
})
}
// Check cohort level access
const assignments = await ctx.prisma.assignment.findMany({
where: { userId: ctx.user.id },
include: { project: { select: { status: true } } },
})
let userCohortLevel: 'ALL' | 'SEMIFINALIST' | 'FINALIST' = 'ALL'
for (const assignment of assignments) {
if (assignment.project.status === 'FINALIST') {
userCohortLevel = 'FINALIST'
break
}
if (assignment.project.status === 'SEMIFINALIST') {
userCohortLevel = 'SEMIFINALIST'
}
}
const accessibleLevels = ['ALL']
if (userCohortLevel === 'SEMIFINALIST' || userCohortLevel === 'FINALIST') {
accessibleLevels.push('SEMIFINALIST')
}
if (userCohortLevel === 'FINALIST') {
accessibleLevels.push('FINALIST')
}
if (!accessibleLevels.includes(resource.cohortLevel)) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You do not have access to this resource',
})
}
}
// Log access
await ctx.prisma.resourceAccess.create({
data: {
resourceId: resource.id,
userId: ctx.user.id,
ipAddress: ctx.ip,
},
})
const url = await getPresignedUrl(resource.bucket, resource.objectKey, 'GET', 900)
return { url }
}),
/**
* Create a new resource (admin only)
*/
create: adminProcedure
.input(
z.object({
programId: z.string().nullable(),
title: z.string().min(1).max(255),
description: z.string().optional(),
contentJson: z.any().optional(), // BlockNote document structure
resourceType: z.enum(['PDF', 'VIDEO', 'DOCUMENT', 'LINK', 'OTHER']),
cohortLevel: z.enum(['ALL', 'SEMIFINALIST', 'FINALIST']).default('ALL'),
externalUrl: z.string().url().optional(),
sortOrder: z.number().int().default(0),
isPublished: z.boolean().default(false),
// File info (set after upload)
fileName: z.string().optional(),
mimeType: z.string().optional(),
size: z.number().int().optional(),
bucket: z.string().optional(),
objectKey: z.string().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const resource = await ctx.prisma.learningResource.create({
data: {
...input,
createdById: ctx.user.id,
},
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'CREATE',
entityType: 'LearningResource',
entityId: resource.id,
detailsJson: { title: input.title, resourceType: input.resourceType },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return resource
}),
/**
* Update a resource (admin only)
*/
update: adminProcedure
.input(
z.object({
id: z.string(),
title: z.string().min(1).max(255).optional(),
description: z.string().optional(),
contentJson: z.any().optional(), // BlockNote document structure
resourceType: z.enum(['PDF', 'VIDEO', 'DOCUMENT', 'LINK', 'OTHER']).optional(),
cohortLevel: z.enum(['ALL', 'SEMIFINALIST', 'FINALIST']).optional(),
externalUrl: z.string().url().optional().nullable(),
sortOrder: z.number().int().optional(),
isPublished: z.boolean().optional(),
// File info (set after upload)
fileName: z.string().optional(),
mimeType: z.string().optional(),
size: z.number().int().optional(),
bucket: z.string().optional(),
objectKey: z.string().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input
const resource = await ctx.prisma.learningResource.update({
where: { id },
data,
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'LearningResource',
entityId: id,
detailsJson: data,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return resource
}),
/**
* Delete a resource (admin only)
*/
delete: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const resource = await ctx.prisma.learningResource.delete({
where: { id: input.id },
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'DELETE',
entityType: 'LearningResource',
entityId: input.id,
detailsJson: { title: resource.title },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return resource
}),
/**
* Get upload URL for a resource file (admin only)
*/
getUploadUrl: adminProcedure
.input(
z.object({
fileName: z.string(),
mimeType: z.string(),
})
)
.mutation(async ({ input }) => {
const timestamp = Date.now()
const sanitizedName = input.fileName.replace(/[^a-zA-Z0-9.-]/g, '_')
const objectKey = `resources/${timestamp}-${sanitizedName}`
const url = await getPresignedUrl(LEARNING_BUCKET, objectKey, 'PUT', 3600)
return {
url,
bucket: LEARNING_BUCKET,
objectKey,
}
}),
/**
* Get access statistics for a resource (admin only)
*/
getStats: adminProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const [totalViews, uniqueUsers, recentAccess] = await Promise.all([
ctx.prisma.resourceAccess.count({
where: { resourceId: input.id },
}),
ctx.prisma.resourceAccess.groupBy({
by: ['userId'],
where: { resourceId: input.id },
}),
ctx.prisma.resourceAccess.findMany({
where: { resourceId: input.id },
include: {
user: { select: { id: true, name: true, email: true } },
},
orderBy: { accessedAt: 'desc' },
take: 10,
}),
])
return {
totalViews,
uniqueUsers: uniqueUsers.length,
recentAccess,
}
}),
/**
* Reorder resources (admin only)
*/
reorder: adminProcedure
.input(
z.object({
items: z.array(
z.object({
id: z.string(),
sortOrder: z.number().int(),
})
),
})
)
.mutation(async ({ ctx, input }) => {
await ctx.prisma.$transaction(
input.items.map((item) =>
ctx.prisma.learningResource.update({
where: { id: item.id },
data: { sortOrder: item.sortOrder },
})
)
)
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'REORDER',
entityType: 'LearningResource',
detailsJson: { count: input.items.length },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return { success: true }
}),
})

View File

@@ -0,0 +1,403 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, protectedProcedure, adminProcedure } from '../trpc'
export const liveVotingRouter = router({
/**
* Get or create a live voting session for a round
*/
getSession: adminProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
let session = await ctx.prisma.liveVotingSession.findUnique({
where: { roundId: input.roundId },
include: {
round: {
include: {
program: { select: { name: true, year: true } },
projects: {
where: { status: { in: ['FINALIST', 'SEMIFINALIST'] } },
select: { id: true, title: true, teamName: true },
},
},
},
},
})
if (!session) {
// Create session
session = await ctx.prisma.liveVotingSession.create({
data: {
roundId: input.roundId,
},
include: {
round: {
include: {
program: { select: { name: true, year: true } },
projects: {
where: { status: { in: ['FINALIST', 'SEMIFINALIST'] } },
select: { id: true, title: true, teamName: true },
},
},
},
},
})
}
// Get current votes if voting is in progress
let currentVotes: { userId: string; score: number }[] = []
if (session.currentProjectId) {
const votes = await ctx.prisma.liveVote.findMany({
where: {
sessionId: session.id,
projectId: session.currentProjectId,
},
select: { userId: true, score: true },
})
currentVotes = votes
}
return {
...session,
currentVotes,
}
}),
/**
* Get session for jury member voting
*/
getSessionForVoting: protectedProcedure
.input(z.object({ sessionId: z.string() }))
.query(async ({ ctx, input }) => {
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
where: { id: input.sessionId },
include: {
round: {
include: {
program: { select: { name: true, year: true } },
},
},
},
})
// Get current project if in progress
let currentProject = null
if (session.currentProjectId && session.status === 'IN_PROGRESS') {
currentProject = await ctx.prisma.project.findUnique({
where: { id: session.currentProjectId },
select: { id: true, title: true, teamName: true, description: true },
})
}
// Get user's vote for current project
let userVote = null
if (session.currentProjectId) {
userVote = await ctx.prisma.liveVote.findFirst({
where: {
sessionId: session.id,
projectId: session.currentProjectId,
userId: ctx.user.id,
},
})
}
// Calculate time remaining
let timeRemaining = null
if (session.votingEndsAt && session.status === 'IN_PROGRESS') {
const remaining = new Date(session.votingEndsAt).getTime() - Date.now()
timeRemaining = Math.max(0, Math.floor(remaining / 1000))
}
return {
session: {
id: session.id,
status: session.status,
votingStartedAt: session.votingStartedAt,
votingEndsAt: session.votingEndsAt,
},
round: session.round,
currentProject,
userVote,
timeRemaining,
}
}),
/**
* Get public session info for display
*/
getPublicSession: protectedProcedure
.input(z.object({ sessionId: z.string() }))
.query(async ({ ctx, input }) => {
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
where: { id: input.sessionId },
include: {
round: {
include: {
program: { select: { name: true, year: true } },
},
},
},
})
// Get all projects in order
const projectOrder = (session.projectOrderJson as string[]) || []
const projects = await ctx.prisma.project.findMany({
where: { id: { in: projectOrder } },
select: { id: true, title: true, teamName: true },
})
// Sort by order
const sortedProjects = projectOrder
.map((id) => projects.find((p) => p.id === id))
.filter(Boolean)
// Get scores for each project
const scores = await ctx.prisma.liveVote.groupBy({
by: ['projectId'],
where: { sessionId: session.id },
_avg: { score: true },
_count: true,
})
const projectsWithScores = sortedProjects.map((project) => {
const projectScore = scores.find((s) => s.projectId === project!.id)
return {
...project,
averageScore: projectScore?._avg.score || null,
voteCount: projectScore?._count || 0,
}
})
return {
session: {
id: session.id,
status: session.status,
currentProjectId: session.currentProjectId,
votingEndsAt: session.votingEndsAt,
},
round: session.round,
projects: projectsWithScores,
}
}),
/**
* Set project order for voting
*/
setProjectOrder: adminProcedure
.input(
z.object({
sessionId: z.string(),
projectIds: z.array(z.string()),
})
)
.mutation(async ({ ctx, input }) => {
const session = await ctx.prisma.liveVotingSession.update({
where: { id: input.sessionId },
data: {
projectOrderJson: input.projectIds,
},
})
return session
}),
/**
* Start voting for a project
*/
startVoting: adminProcedure
.input(
z.object({
sessionId: z.string(),
projectId: z.string(),
durationSeconds: z.number().int().min(10).max(300).default(30),
})
)
.mutation(async ({ ctx, input }) => {
const now = new Date()
const votingEndsAt = new Date(now.getTime() + input.durationSeconds * 1000)
const session = await ctx.prisma.liveVotingSession.update({
where: { id: input.sessionId },
data: {
status: 'IN_PROGRESS',
currentProjectId: input.projectId,
votingStartedAt: now,
votingEndsAt,
},
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'START_VOTING',
entityType: 'LiveVotingSession',
entityId: session.id,
detailsJson: { projectId: input.projectId, durationSeconds: input.durationSeconds },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return session
}),
/**
* Stop voting
*/
stopVoting: adminProcedure
.input(z.object({ sessionId: z.string() }))
.mutation(async ({ ctx, input }) => {
const session = await ctx.prisma.liveVotingSession.update({
where: { id: input.sessionId },
data: {
status: 'PAUSED',
votingEndsAt: new Date(),
},
})
return session
}),
/**
* End session
*/
endSession: adminProcedure
.input(z.object({ sessionId: z.string() }))
.mutation(async ({ ctx, input }) => {
const session = await ctx.prisma.liveVotingSession.update({
where: { id: input.sessionId },
data: {
status: 'COMPLETED',
},
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'END_SESSION',
entityType: 'LiveVotingSession',
entityId: session.id,
detailsJson: {},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return session
}),
/**
* Submit a vote
*/
vote: protectedProcedure
.input(
z.object({
sessionId: z.string(),
projectId: z.string(),
score: z.number().int().min(1).max(10),
})
)
.mutation(async ({ ctx, input }) => {
// Verify session is in progress
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
where: { id: input.sessionId },
})
if (session.status !== 'IN_PROGRESS') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Voting is not currently active',
})
}
if (session.currentProjectId !== input.projectId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Cannot vote for this project right now',
})
}
// Check if voting window is still open
if (session.votingEndsAt && new Date() > session.votingEndsAt) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Voting window has closed',
})
}
// Upsert vote (allow vote change during window)
const vote = await ctx.prisma.liveVote.upsert({
where: {
sessionId_projectId_userId: {
sessionId: input.sessionId,
projectId: input.projectId,
userId: ctx.user.id,
},
},
create: {
sessionId: input.sessionId,
projectId: input.projectId,
userId: ctx.user.id,
score: input.score,
},
update: {
score: input.score,
votedAt: new Date(),
},
})
return vote
}),
/**
* Get results for a session
*/
getResults: protectedProcedure
.input(z.object({ sessionId: z.string() }))
.query(async ({ ctx, input }) => {
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
where: { id: input.sessionId },
include: {
round: {
include: {
program: { select: { name: true, year: true } },
},
},
},
})
// Get all votes grouped by project
const projectScores = await ctx.prisma.liveVote.groupBy({
by: ['projectId'],
where: { sessionId: input.sessionId },
_avg: { score: true },
_count: true,
})
// Get project details
const projectIds = projectScores.map((s) => s.projectId)
const projects = await ctx.prisma.project.findMany({
where: { id: { in: projectIds } },
select: { id: true, title: true, teamName: true },
})
// Combine and sort by average score
const results = projectScores
.map((score) => {
const project = projects.find((p) => p.id === score.projectId)
return {
project,
averageScore: score._avg.score || 0,
voteCount: score._count,
}
})
.sort((a, b) => b.averageScore - a.averageScore)
return {
session,
results,
}
}),
})

196
src/server/routers/logo.ts Normal file
View File

@@ -0,0 +1,196 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, adminProcedure } from '../trpc'
import {
getStorageProviderWithType,
createStorageProvider,
generateLogoKey,
getContentType,
isValidImageType,
type StorageProviderType,
} from '@/lib/storage'
export const logoRouter = router({
/**
* Get a pre-signed URL for uploading a project logo
*/
getUploadUrl: adminProcedure
.input(
z.object({
projectId: z.string(),
fileName: z.string(),
contentType: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
// Validate content type
if (!isValidImageType(input.contentType)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid image type. Allowed: JPEG, PNG, GIF, WebP' })
}
// Verify project exists
const project = await ctx.prisma.project.findUnique({
where: { id: input.projectId },
select: { id: true },
})
if (!project) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found' })
}
const key = generateLogoKey(input.projectId, input.fileName)
const contentType = getContentType(input.fileName)
const { provider, providerType } = await getStorageProviderWithType()
const uploadUrl = await provider.getUploadUrl(key, contentType)
return {
uploadUrl,
key,
providerType, // Return so client can pass it back on confirm
}
}),
/**
* Confirm logo upload and update project
*/
confirmUpload: adminProcedure
.input(
z.object({
projectId: z.string(),
key: z.string(),
providerType: z.enum(['s3', 'local']),
})
)
.mutation(async ({ ctx, input }) => {
// Use the provider that was used for upload
const provider = createStorageProvider(input.providerType)
const exists = await provider.objectExists(input.key)
if (!exists) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Upload not found. Please try uploading again.' })
}
// Delete old logo if exists (from its original provider)
const currentProject = await ctx.prisma.project.findUnique({
where: { id: input.projectId },
select: { logoKey: true, logoProvider: true },
})
if (currentProject?.logoKey) {
try {
const oldProvider = createStorageProvider(
(currentProject.logoProvider as StorageProviderType) || 's3'
)
await oldProvider.deleteObject(currentProject.logoKey)
} catch (error) {
// Log but don't fail if old logo deletion fails
console.warn('Failed to delete old logo:', error)
}
}
// Update project with new logo key and provider
const project = await ctx.prisma.project.update({
where: { id: input.projectId },
data: {
logoKey: input.key,
logoProvider: input.providerType,
},
select: {
id: true,
logoKey: true,
logoProvider: true,
},
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'Project',
entityId: input.projectId,
detailsJson: {
field: 'logoKey',
newValue: input.key,
provider: input.providerType,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return project
}),
/**
* Get a project's logo URL
*/
getUrl: adminProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
const project = await ctx.prisma.project.findUnique({
where: { id: input.projectId },
select: { logoKey: true, logoProvider: true },
})
if (!project?.logoKey) {
return null
}
// Use the provider that was used when the file was stored
const providerType = (project.logoProvider as StorageProviderType) || 's3'
const provider = createStorageProvider(providerType)
const url = await provider.getDownloadUrl(project.logoKey)
return url
}),
/**
* Delete a project's logo
*/
delete: adminProcedure
.input(z.object({ projectId: z.string() }))
.mutation(async ({ ctx, input }) => {
const project = await ctx.prisma.project.findUnique({
where: { id: input.projectId },
select: { logoKey: true, logoProvider: true },
})
if (!project?.logoKey) {
return { success: true }
}
// Delete from the provider that was used when the file was stored
const providerType = (project.logoProvider as StorageProviderType) || 's3'
const provider = createStorageProvider(providerType)
try {
await provider.deleteObject(project.logoKey)
} catch (error) {
console.warn('Failed to delete logo from storage:', error)
}
// Update project - clear both key and provider
await ctx.prisma.project.update({
where: { id: input.projectId },
data: {
logoKey: null,
logoProvider: null,
},
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'DELETE',
entityType: 'Project',
entityId: input.projectId,
detailsJson: { field: 'logoKey' },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return { success: true }
}),
})

View File

@@ -0,0 +1,573 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, mentorProcedure, adminProcedure } from '../trpc'
import { MentorAssignmentMethod } from '@prisma/client'
import {
getAIMentorSuggestions,
getRoundRobinMentor,
} from '../services/mentor-matching'
export const mentorRouter = router({
/**
* Get AI-suggested mentor matches for a project
*/
getSuggestions: adminProcedure
.input(
z.object({
projectId: z.string(),
limit: z.number().min(1).max(10).default(5),
})
)
.query(async ({ ctx, input }) => {
// Verify project exists
const project = await ctx.prisma.project.findUniqueOrThrow({
where: { id: input.projectId },
include: {
mentorAssignment: true,
},
})
if (project.mentorAssignment) {
return {
currentMentor: project.mentorAssignment,
suggestions: [],
message: 'Project already has a mentor assigned',
}
}
const suggestions = await getAIMentorSuggestions(
ctx.prisma,
input.projectId,
input.limit
)
// Enrich with mentor details
const enrichedSuggestions = await Promise.all(
suggestions.map(async (suggestion) => {
const mentor = await ctx.prisma.user.findUnique({
where: { id: suggestion.mentorId },
select: {
id: true,
name: true,
email: true,
expertiseTags: true,
mentorAssignments: {
select: { id: true },
},
},
})
return {
...suggestion,
mentor: mentor
? {
id: mentor.id,
name: mentor.name,
email: mentor.email,
expertiseTags: mentor.expertiseTags,
assignmentCount: mentor.mentorAssignments.length,
}
: null,
}
})
)
return {
currentMentor: null,
suggestions: enrichedSuggestions.filter((s) => s.mentor !== null),
message: null,
}
}),
/**
* Manually assign a mentor to a project
*/
assign: adminProcedure
.input(
z.object({
projectId: z.string(),
mentorId: z.string(),
method: z.nativeEnum(MentorAssignmentMethod).default('MANUAL'),
aiConfidenceScore: z.number().optional(),
expertiseMatchScore: z.number().optional(),
aiReasoning: z.string().optional(),
})
)
.mutation(async ({ ctx, input }) => {
// Verify project exists and doesn't have a mentor
const project = await ctx.prisma.project.findUniqueOrThrow({
where: { id: input.projectId },
include: { mentorAssignment: true },
})
if (project.mentorAssignment) {
throw new TRPCError({
code: 'CONFLICT',
message: 'Project already has a mentor assigned',
})
}
// Verify mentor exists
const mentor = await ctx.prisma.user.findUniqueOrThrow({
where: { id: input.mentorId },
})
// Create assignment
const assignment = await ctx.prisma.mentorAssignment.create({
data: {
projectId: input.projectId,
mentorId: input.mentorId,
method: input.method,
assignedBy: ctx.user.id,
aiConfidenceScore: input.aiConfidenceScore,
expertiseMatchScore: input.expertiseMatchScore,
aiReasoning: input.aiReasoning,
},
include: {
mentor: {
select: {
id: true,
name: true,
email: true,
expertiseTags: true,
},
},
project: {
select: {
id: true,
title: true,
},
},
},
})
// Create audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'MENTOR_ASSIGN',
entityType: 'MentorAssignment',
entityId: assignment.id,
detailsJson: {
projectId: input.projectId,
projectTitle: assignment.project.title,
mentorId: input.mentorId,
mentorName: assignment.mentor.name,
method: input.method,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return assignment
}),
/**
* Auto-assign a mentor using AI or round-robin
*/
autoAssign: adminProcedure
.input(
z.object({
projectId: z.string(),
useAI: z.boolean().default(true),
})
)
.mutation(async ({ ctx, input }) => {
// Verify project exists and doesn't have a mentor
const project = await ctx.prisma.project.findUniqueOrThrow({
where: { id: input.projectId },
include: { mentorAssignment: true },
})
if (project.mentorAssignment) {
throw new TRPCError({
code: 'CONFLICT',
message: 'Project already has a mentor assigned',
})
}
let mentorId: string | null = null
let method: MentorAssignmentMethod = 'ALGORITHM'
let aiConfidenceScore: number | undefined
let expertiseMatchScore: number | undefined
let aiReasoning: string | undefined
if (input.useAI) {
// Try AI matching first
const suggestions = await getAIMentorSuggestions(ctx.prisma, input.projectId, 1)
if (suggestions.length > 0) {
const best = suggestions[0]
mentorId = best.mentorId
method = 'AI_AUTO'
aiConfidenceScore = best.confidenceScore
expertiseMatchScore = best.expertiseMatchScore
aiReasoning = best.reasoning
}
}
// Fallback to round-robin
if (!mentorId) {
mentorId = await getRoundRobinMentor(ctx.prisma)
method = 'ALGORITHM'
}
if (!mentorId) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'No available mentors found',
})
}
// Create assignment
const assignment = await ctx.prisma.mentorAssignment.create({
data: {
projectId: input.projectId,
mentorId,
method,
assignedBy: ctx.user.id,
aiConfidenceScore,
expertiseMatchScore,
aiReasoning,
},
include: {
mentor: {
select: {
id: true,
name: true,
email: true,
expertiseTags: true,
},
},
project: {
select: {
id: true,
title: true,
},
},
},
})
// Create audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'MENTOR_AUTO_ASSIGN',
entityType: 'MentorAssignment',
entityId: assignment.id,
detailsJson: {
projectId: input.projectId,
projectTitle: assignment.project.title,
mentorId,
mentorName: assignment.mentor.name,
method,
aiConfidenceScore,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return assignment
}),
/**
* Remove mentor assignment
*/
unassign: adminProcedure
.input(z.object({ projectId: z.string() }))
.mutation(async ({ ctx, input }) => {
const assignment = await ctx.prisma.mentorAssignment.findUnique({
where: { projectId: input.projectId },
include: {
mentor: { select: { id: true, name: true } },
project: { select: { id: true, title: true } },
},
})
if (!assignment) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'No mentor assignment found for this project',
})
}
await ctx.prisma.mentorAssignment.delete({
where: { projectId: input.projectId },
})
// Create audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'MENTOR_UNASSIGN',
entityType: 'MentorAssignment',
entityId: assignment.id,
detailsJson: {
projectId: input.projectId,
projectTitle: assignment.project.title,
mentorId: assignment.mentor.id,
mentorName: assignment.mentor.name,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return { success: true }
}),
/**
* Bulk auto-assign mentors to projects without one
*/
bulkAutoAssign: adminProcedure
.input(
z.object({
roundId: z.string(),
useAI: z.boolean().default(true),
maxAssignments: z.number().min(1).max(100).default(50),
})
)
.mutation(async ({ ctx, input }) => {
// Get projects without mentors
const projects = await ctx.prisma.project.findMany({
where: {
roundId: input.roundId,
mentorAssignment: null,
wantsMentorship: true,
},
select: { id: true },
take: input.maxAssignments,
})
if (projects.length === 0) {
return {
assigned: 0,
failed: 0,
message: 'No projects need mentor assignment',
}
}
let assigned = 0
let failed = 0
for (const project of projects) {
try {
let mentorId: string | null = null
let method: MentorAssignmentMethod = 'ALGORITHM'
let aiConfidenceScore: number | undefined
let expertiseMatchScore: number | undefined
let aiReasoning: string | undefined
if (input.useAI) {
const suggestions = await getAIMentorSuggestions(ctx.prisma, project.id, 1)
if (suggestions.length > 0) {
const best = suggestions[0]
mentorId = best.mentorId
method = 'AI_AUTO'
aiConfidenceScore = best.confidenceScore
expertiseMatchScore = best.expertiseMatchScore
aiReasoning = best.reasoning
}
}
if (!mentorId) {
mentorId = await getRoundRobinMentor(ctx.prisma)
method = 'ALGORITHM'
}
if (mentorId) {
await ctx.prisma.mentorAssignment.create({
data: {
projectId: project.id,
mentorId,
method,
assignedBy: ctx.user.id,
aiConfidenceScore,
expertiseMatchScore,
aiReasoning,
},
})
assigned++
} else {
failed++
}
} catch {
failed++
}
}
// Create audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'MENTOR_BULK_ASSIGN',
entityType: 'Round',
entityId: input.roundId,
detailsJson: {
assigned,
failed,
useAI: input.useAI,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return {
assigned,
failed,
message: `Assigned ${assigned} mentor(s), ${failed} failed`,
}
}),
/**
* Get mentor's assigned projects
*/
getMyProjects: mentorProcedure.query(async ({ ctx }) => {
const assignments = await ctx.prisma.mentorAssignment.findMany({
where: { mentorId: ctx.user.id },
include: {
project: {
include: {
round: {
include: {
program: { select: { name: true, year: true } },
},
},
teamMembers: {
include: {
user: { select: { id: true, name: true, email: true } },
},
},
},
},
},
orderBy: { assignedAt: 'desc' },
})
return assignments
}),
/**
* Get detailed project info for a mentor's assigned project
*/
getProjectDetail: mentorProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
// Verify the mentor is assigned to this project
const assignment = await ctx.prisma.mentorAssignment.findFirst({
where: {
projectId: input.projectId,
mentorId: ctx.user.id,
},
})
// Allow admins to access any project
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
if (!assignment && !isAdmin) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You are not assigned to mentor this project',
})
}
const project = await ctx.prisma.project.findUniqueOrThrow({
where: { id: input.projectId },
include: {
round: {
include: {
program: { select: { id: true, name: true, year: true } },
},
},
teamMembers: {
include: {
user: {
select: {
id: true,
name: true,
email: true,
phoneNumber: true,
},
},
},
orderBy: { role: 'asc' },
},
files: {
orderBy: { createdAt: 'desc' },
},
mentorAssignment: {
include: {
mentor: {
select: { id: true, name: true, email: true },
},
},
},
},
})
return {
...project,
assignedAt: assignment?.assignedAt,
}
}),
/**
* List all mentor assignments (admin)
*/
listAssignments: adminProcedure
.input(
z.object({
roundId: z.string().optional(),
mentorId: z.string().optional(),
page: z.number().min(1).default(1),
perPage: z.number().min(1).max(100).default(20),
})
)
.query(async ({ ctx, input }) => {
const where = {
...(input.roundId && { project: { roundId: input.roundId } }),
...(input.mentorId && { mentorId: input.mentorId }),
}
const [assignments, total] = await Promise.all([
ctx.prisma.mentorAssignment.findMany({
where,
include: {
project: {
select: {
id: true,
title: true,
teamName: true,
status: true,
oceanIssue: true,
competitionCategory: true,
},
},
mentor: {
select: {
id: true,
name: true,
email: true,
expertiseTags: true,
},
},
},
orderBy: { assignedAt: 'desc' },
skip: (input.page - 1) * input.perPage,
take: input.perPage,
}),
ctx.prisma.mentorAssignment.count({ where }),
])
return {
assignments,
total,
page: input.page,
perPage: input.perPage,
totalPages: Math.ceil(total / input.perPage),
}
}),
})

View File

@@ -0,0 +1,230 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { Prisma } from '@prisma/client'
import { router, adminProcedure } from '../trpc'
import {
testNotionConnection,
getNotionDatabaseSchema,
queryNotionDatabase,
} from '@/lib/notion'
export const notionImportRouter = router({
/**
* Test connection to Notion API
*/
testConnection: adminProcedure
.input(
z.object({
apiKey: z.string().min(1),
})
)
.mutation(async ({ input }) => {
return testNotionConnection(input.apiKey)
}),
/**
* Get database schema (properties) for mapping
*/
getDatabaseSchema: adminProcedure
.input(
z.object({
apiKey: z.string().min(1),
databaseId: z.string().min(1),
})
)
.query(async ({ input }) => {
try {
return await getNotionDatabaseSchema(input.apiKey, input.databaseId)
} catch (error) {
throw new TRPCError({
code: 'BAD_REQUEST',
message:
error instanceof Error
? error.message
: 'Failed to fetch database schema',
})
}
}),
/**
* Preview data from Notion database
*/
previewData: adminProcedure
.input(
z.object({
apiKey: z.string().min(1),
databaseId: z.string().min(1),
limit: z.number().int().min(1).max(10).default(5),
})
)
.query(async ({ input }) => {
try {
const records = await queryNotionDatabase(
input.apiKey,
input.databaseId,
input.limit
)
return { records, count: records.length }
} catch (error) {
throw new TRPCError({
code: 'BAD_REQUEST',
message:
error instanceof Error
? error.message
: 'Failed to fetch data from Notion',
})
}
}),
/**
* Import projects from Notion database
*/
importProjects: adminProcedure
.input(
z.object({
apiKey: z.string().min(1),
databaseId: z.string().min(1),
roundId: z.string(),
// Column mappings: Notion property name -> Project field
mappings: z.object({
title: z.string(), // Required
teamName: z.string().optional(),
description: z.string().optional(),
tags: z.string().optional(), // Multi-select property
}),
// Store unmapped columns in metadataJson
includeUnmappedInMetadata: z.boolean().default(true),
})
)
.mutation(async ({ ctx, input }) => {
// Verify round exists
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
})
// Fetch all records from Notion
const records = await queryNotionDatabase(input.apiKey, input.databaseId)
if (records.length === 0) {
return { imported: 0, skipped: 0, errors: [] }
}
const results = {
imported: 0,
skipped: 0,
errors: [] as Array<{ recordId: string; error: string }>,
}
// Process each record
for (const record of records) {
try {
// Get mapped values
const title = getPropertyValue(record.properties, input.mappings.title)
if (!title || typeof title !== 'string' || !title.trim()) {
results.errors.push({
recordId: record.id,
error: 'Missing or invalid title',
})
results.skipped++
continue
}
const teamName = input.mappings.teamName
? getPropertyValue(record.properties, input.mappings.teamName)
: null
const description = input.mappings.description
? getPropertyValue(record.properties, input.mappings.description)
: null
let tags: string[] = []
if (input.mappings.tags) {
const tagsValue = getPropertyValue(record.properties, input.mappings.tags)
if (Array.isArray(tagsValue)) {
tags = tagsValue.filter((t): t is string => typeof t === 'string')
} else if (typeof tagsValue === 'string') {
tags = tagsValue.split(',').map((t) => t.trim()).filter(Boolean)
}
}
// Build metadata from unmapped columns
let metadataJson: Record<string, unknown> | null = null
if (input.includeUnmappedInMetadata) {
const mappedKeys = new Set([
input.mappings.title,
input.mappings.teamName,
input.mappings.description,
input.mappings.tags,
].filter(Boolean))
metadataJson = {}
for (const [key, value] of Object.entries(record.properties)) {
if (!mappedKeys.has(key) && value !== null && value !== undefined) {
metadataJson[key] = value
}
}
if (Object.keys(metadataJson).length === 0) {
metadataJson = null
}
}
// Create project
await ctx.prisma.project.create({
data: {
roundId: round.id,
title: title.trim(),
teamName: typeof teamName === 'string' ? teamName.trim() : null,
description: typeof description === 'string' ? description : null,
tags,
metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,
externalIdsJson: {
notionPageId: record.id,
notionDatabaseId: input.databaseId,
} as Prisma.InputJsonValue,
status: 'SUBMITTED',
},
})
results.imported++
} catch (error) {
results.errors.push({
recordId: record.id,
error: error instanceof Error ? error.message : 'Unknown error',
})
results.skipped++
}
}
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'IMPORT',
entityType: 'Project',
detailsJson: {
source: 'notion',
databaseId: input.databaseId,
roundId: input.roundId,
imported: results.imported,
skipped: results.skipped,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return results
}),
})
/**
* Helper to get a property value from a record
*/
function getPropertyValue(
properties: Record<string, unknown>,
propertyName: string
): unknown {
return properties[propertyName] ?? null
}

View File

@@ -0,0 +1,355 @@
import { z } from 'zod'
import { router, protectedProcedure, adminProcedure } from '../trpc'
import { getPresignedUrl } from '@/lib/minio'
// Bucket for partner logos
export const PARTNER_BUCKET = 'mopc-partners'
export const partnerRouter = router({
/**
* List all partners (admin view)
*/
list: adminProcedure
.input(
z.object({
programId: z.string().optional(),
partnerType: z.enum(['SPONSOR', 'PARTNER', 'SUPPORTER', 'MEDIA', 'OTHER']).optional(),
visibility: z.enum(['ADMIN_ONLY', 'JURY_VISIBLE', 'PUBLIC']).optional(),
isActive: z.boolean().optional(),
page: z.number().int().min(1).default(1),
perPage: z.number().int().min(1).max(100).default(50),
})
)
.query(async ({ ctx, input }) => {
const where: Record<string, unknown> = {}
if (input.programId !== undefined) {
where.programId = input.programId
}
if (input.partnerType) {
where.partnerType = input.partnerType
}
if (input.visibility) {
where.visibility = input.visibility
}
if (input.isActive !== undefined) {
where.isActive = input.isActive
}
const [data, total] = await Promise.all([
ctx.prisma.partner.findMany({
where,
include: {
program: { select: { id: true, name: true, year: true } },
},
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }],
skip: (input.page - 1) * input.perPage,
take: input.perPage,
}),
ctx.prisma.partner.count({ where }),
])
return {
data,
total,
page: input.page,
perPage: input.perPage,
totalPages: Math.ceil(total / input.perPage),
}
}),
/**
* Get partners visible to jury members
*/
getJuryVisible: protectedProcedure
.input(
z.object({
programId: z.string().optional(),
partnerType: z.enum(['SPONSOR', 'PARTNER', 'SUPPORTER', 'MEDIA', 'OTHER']).optional(),
})
)
.query(async ({ ctx, input }) => {
const where: Record<string, unknown> = {
isActive: true,
visibility: { in: ['JURY_VISIBLE', 'PUBLIC'] },
}
if (input.programId) {
where.OR = [{ programId: input.programId }, { programId: null }]
}
if (input.partnerType) {
where.partnerType = input.partnerType
}
return ctx.prisma.partner.findMany({
where,
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }],
})
}),
/**
* Get public partners (for public website)
*/
getPublic: protectedProcedure
.input(
z.object({
programId: z.string().optional(),
partnerType: z.enum(['SPONSOR', 'PARTNER', 'SUPPORTER', 'MEDIA', 'OTHER']).optional(),
})
)
.query(async ({ ctx, input }) => {
const where: Record<string, unknown> = {
isActive: true,
visibility: 'PUBLIC',
}
if (input.programId) {
where.OR = [{ programId: input.programId }, { programId: null }]
}
if (input.partnerType) {
where.partnerType = input.partnerType
}
return ctx.prisma.partner.findMany({
where,
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }],
})
}),
/**
* Get a single partner by ID
*/
get: adminProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.partner.findUniqueOrThrow({
where: { id: input.id },
include: {
program: { select: { id: true, name: true, year: true } },
},
})
}),
/**
* Get logo URL for a partner
*/
getLogoUrl: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const partner = await ctx.prisma.partner.findUniqueOrThrow({
where: { id: input.id },
})
if (!partner.logoBucket || !partner.logoObjectKey) {
return { url: null }
}
const url = await getPresignedUrl(partner.logoBucket, partner.logoObjectKey, 'GET', 900)
return { url }
}),
/**
* Create a new partner (admin only)
*/
create: adminProcedure
.input(
z.object({
programId: z.string().nullable(),
name: z.string().min(1).max(255),
description: z.string().optional(),
website: z.string().url().optional(),
partnerType: z.enum(['SPONSOR', 'PARTNER', 'SUPPORTER', 'MEDIA', 'OTHER']).default('PARTNER'),
visibility: z.enum(['ADMIN_ONLY', 'JURY_VISIBLE', 'PUBLIC']).default('ADMIN_ONLY'),
sortOrder: z.number().int().default(0),
isActive: z.boolean().default(true),
// Logo info (set after upload)
logoFileName: z.string().optional(),
logoBucket: z.string().optional(),
logoObjectKey: z.string().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const partner = await ctx.prisma.partner.create({
data: input,
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'CREATE',
entityType: 'Partner',
entityId: partner.id,
detailsJson: { name: input.name, partnerType: input.partnerType },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return partner
}),
/**
* Update a partner (admin only)
*/
update: adminProcedure
.input(
z.object({
id: z.string(),
name: z.string().min(1).max(255).optional(),
description: z.string().optional().nullable(),
website: z.string().url().optional().nullable(),
partnerType: z.enum(['SPONSOR', 'PARTNER', 'SUPPORTER', 'MEDIA', 'OTHER']).optional(),
visibility: z.enum(['ADMIN_ONLY', 'JURY_VISIBLE', 'PUBLIC']).optional(),
sortOrder: z.number().int().optional(),
isActive: z.boolean().optional(),
// Logo info
logoFileName: z.string().optional().nullable(),
logoBucket: z.string().optional().nullable(),
logoObjectKey: z.string().optional().nullable(),
})
)
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input
const partner = await ctx.prisma.partner.update({
where: { id },
data,
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'Partner',
entityId: id,
detailsJson: data,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return partner
}),
/**
* Delete a partner (admin only)
*/
delete: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const partner = await ctx.prisma.partner.delete({
where: { id: input.id },
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'DELETE',
entityType: 'Partner',
entityId: input.id,
detailsJson: { name: partner.name },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return partner
}),
/**
* Get upload URL for a partner logo (admin only)
*/
getUploadUrl: adminProcedure
.input(
z.object({
fileName: z.string(),
mimeType: z.string(),
})
)
.mutation(async ({ input }) => {
const timestamp = Date.now()
const sanitizedName = input.fileName.replace(/[^a-zA-Z0-9.-]/g, '_')
const objectKey = `logos/${timestamp}-${sanitizedName}`
const url = await getPresignedUrl(PARTNER_BUCKET, objectKey, 'PUT', 3600)
return {
url,
bucket: PARTNER_BUCKET,
objectKey,
}
}),
/**
* Reorder partners (admin only)
*/
reorder: adminProcedure
.input(
z.object({
items: z.array(
z.object({
id: z.string(),
sortOrder: z.number().int(),
})
),
})
)
.mutation(async ({ ctx, input }) => {
await ctx.prisma.$transaction(
input.items.map((item) =>
ctx.prisma.partner.update({
where: { id: item.id },
data: { sortOrder: item.sortOrder },
})
)
)
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'REORDER',
entityType: 'Partner',
detailsJson: { count: input.items.length },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return { success: true }
}),
/**
* Bulk update visibility (admin only)
*/
bulkUpdateVisibility: adminProcedure
.input(
z.object({
ids: z.array(z.string()),
visibility: z.enum(['ADMIN_ONLY', 'JURY_VISIBLE', 'PUBLIC']),
})
)
.mutation(async ({ ctx, input }) => {
await ctx.prisma.partner.updateMany({
where: { id: { in: input.ids } },
data: { visibility: input.visibility },
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'BULK_UPDATE',
entityType: 'Partner',
detailsJson: { ids: input.ids, visibility: input.visibility },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return { updated: input.ids.length }
}),
})

View File

@@ -0,0 +1,145 @@
import { z } from 'zod'
import { router, protectedProcedure, adminProcedure } from '../trpc'
export const programRouter = router({
/**
* List all programs with optional filtering
*/
list: protectedProcedure
.input(
z.object({
status: z.enum(['DRAFT', 'ACTIVE', 'ARCHIVED']).optional(),
includeRounds: z.boolean().optional(),
}).optional()
)
.query(async ({ ctx, input }) => {
return ctx.prisma.program.findMany({
where: input?.status ? { status: input.status } : undefined,
orderBy: { year: 'desc' },
include: {
_count: {
select: { rounds: true },
},
rounds: input?.includeRounds ? {
orderBy: { createdAt: 'asc' },
} : false,
},
})
}),
/**
* Get a single program with its rounds
*/
get: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.program.findUniqueOrThrow({
where: { id: input.id },
include: {
rounds: {
orderBy: { createdAt: 'asc' },
include: {
_count: {
select: { projects: true, assignments: true },
},
},
},
},
})
}),
/**
* Create a new 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,
userAgent: ctx.userAgent,
},
})
return program
}),
/**
* Update a 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,
userAgent: ctx.userAgent,
},
})
return program
}),
/**
* Delete a program (admin only)
* Note: This will cascade delete all rounds, projects, etc.
*/
delete: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const program = await ctx.prisma.program.delete({
where: { id: input.id },
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'DELETE',
entityType: 'Program',
entityId: input.id,
detailsJson: { name: program.name, year: program.year },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return program
}),
})

View File

@@ -0,0 +1,349 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { Prisma } from '@prisma/client'
import { router, protectedProcedure, adminProcedure } from '../trpc'
export const projectRouter = router({
/**
* List projects with filtering and pagination
* Admin sees all, jury sees only assigned projects
*/
list: protectedProcedure
.input(
z.object({
roundId: z.string(),
status: z
.enum([
'SUBMITTED',
'ELIGIBLE',
'ASSIGNED',
'SEMIFINALIST',
'FINALIST',
'REJECTED',
])
.optional(),
search: z.string().optional(),
tags: z.array(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, tags, page, perPage } = input
const skip = (page - 1) * perPage
// Build where clause
const where: Record<string, unknown> = { roundId }
if (status) where.status = status
if (tags && tags.length > 0) {
where.tags = { hasSome: tags }
}
if (search) {
where.OR = [
{ title: { contains: search, mode: 'insensitive' } },
{ teamName: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
]
}
// Jury members can only see assigned projects
if (ctx.user.role === 'JURY_MEMBER') {
where.assignments = {
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 a single project with details
*/
get: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const project = await ctx.prisma.project.findUniqueOrThrow({
where: { id: input.id },
include: {
files: true,
round: true,
teamMembers: {
include: {
user: {
select: { id: true, name: true, email: true },
},
},
orderBy: { joinedAt: 'asc' },
},
mentorAssignment: {
include: {
mentor: {
select: { id: true, name: true, email: true, expertiseTags: 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
}),
/**
* Create a single project (admin only)
*/
create: adminProcedure
.input(
z.object({
roundId: z.string(),
title: z.string().min(1).max(500),
teamName: z.string().optional(),
description: z.string().optional(),
tags: z.array(z.string()).optional(),
metadataJson: z.record(z.unknown()).optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { metadataJson, ...rest } = input
const project = await ctx.prisma.project.create({
data: {
...rest,
metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,
},
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'CREATE',
entityType: 'Project',
entityId: project.id,
detailsJson: { title: input.title, roundId: input.roundId },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return project
}),
/**
* Update a project (admin only)
*/
update: adminProcedure
.input(
z.object({
id: z.string(),
title: z.string().min(1).max(500).optional(),
teamName: z.string().optional().nullable(),
description: z.string().optional().nullable(),
status: z
.enum([
'SUBMITTED',
'ELIGIBLE',
'ASSIGNED',
'SEMIFINALIST',
'FINALIST',
'REJECTED',
])
.optional(),
tags: z.array(z.string()).optional(),
metadataJson: z.record(z.unknown()).optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { id, metadataJson, ...data } = input
const project = await ctx.prisma.project.update({
where: { id },
data: {
...data,
metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,
},
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'Project',
entityId: id,
detailsJson: { ...data, metadataJson } as Prisma.InputJsonValue,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return project
}),
/**
* Delete a project (admin only)
*/
delete: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const project = await ctx.prisma.project.delete({
where: { id: input.id },
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'DELETE',
entityType: 'Project',
entityId: input.id,
detailsJson: { title: project.title },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return project
}),
/**
* Import projects from CSV data (admin only)
*/
importCSV: adminProcedure
.input(
z.object({
roundId: z.string(),
projects: z.array(
z.object({
title: z.string().min(1),
teamName: z.string().optional(),
description: z.string().optional(),
tags: z.array(z.string()).optional(),
metadataJson: z.record(z.unknown()).optional(),
})
),
})
)
.mutation(async ({ ctx, input }) => {
// Verify round exists
await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
})
const created = await ctx.prisma.project.createMany({
data: input.projects.map((p) => {
const { metadataJson, ...rest } = p
return {
...rest,
roundId: input.roundId,
metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,
}
}),
})
// 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,
userAgent: ctx.userAgent,
},
})
return { imported: created.count }
}),
/**
* Get all unique tags used in projects
*/
getTags: protectedProcedure
.input(z.object({ roundId: z.string().optional() }))
.query(async ({ ctx, input }) => {
const projects = await ctx.prisma.project.findMany({
where: input.roundId ? { roundId: input.roundId } : undefined,
select: { tags: true },
})
const allTags = projects.flatMap((p) => p.tags)
const uniqueTags = [...new Set(allTags)].sort()
return uniqueTags
}),
/**
* Update project status in bulk (admin only)
*/
bulkUpdateStatus: adminProcedure
.input(
z.object({
ids: z.array(z.string()),
status: z.enum([
'SUBMITTED',
'ELIGIBLE',
'ASSIGNED',
'SEMIFINALIST',
'FINALIST',
'REJECTED',
]),
})
)
.mutation(async ({ ctx, input }) => {
const updated = await ctx.prisma.project.updateMany({
where: { id: { in: input.ids } },
data: { status: input.status },
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'BULK_UPDATE_STATUS',
entityType: 'Project',
detailsJson: { ids: input.ids, status: input.status },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return { updated: updated.count }
}),
})

358
src/server/routers/round.ts Normal file
View File

@@ -0,0 +1,358 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { Prisma } from '@prisma/client'
import { router, protectedProcedure, adminProcedure } from '../trpc'
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' },
include: {
_count: {
select: { projects: true, assignments: true },
},
},
})
}),
/**
* Get a single 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 },
},
evaluationForms: {
where: { isActive: true },
take: 1,
},
},
})
// Get evaluation stats
const evaluationStats = await ctx.prisma.evaluation.groupBy({
by: ['status'],
where: {
assignment: { roundId: input.id },
},
_count: true,
})
return {
...round,
evaluationStats,
}
}),
/**
* Create a new 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',
})
}
}
const round = await ctx.prisma.round.create({
data: input,
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'CREATE',
entityType: 'Round',
entityId: round.id,
detailsJson: input,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return round
}),
/**
* Update round details (admin only)
*/
update: adminProcedure
.input(
z.object({
id: z.string(),
name: z.string().min(1).max(255).optional(),
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/).optional().nullable(),
roundType: z.enum(['FILTERING', 'EVALUATION', 'LIVE_EVENT']).optional(),
requiredReviews: z.number().int().min(1).max(10).optional(),
submissionDeadline: z.date().optional().nullable(),
votingStartAt: z.date().optional().nullable(),
votingEndAt: z.date().optional().nullable(),
settingsJson: z.record(z.unknown()).optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { id, settingsJson, ...data } = input
// Validate dates if both provided
if (data.votingStartAt && data.votingEndAt) {
if (data.votingEndAt <= data.votingStartAt) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'End date must be after start date',
})
}
}
const round = await ctx.prisma.round.update({
where: { id },
data: {
...data,
settingsJson: settingsJson as Prisma.InputJsonValue ?? undefined,
},
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'Round',
entityId: id,
detailsJson: { ...data, settingsJson } as Prisma.InputJsonValue,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return round
}),
/**
* Update round status (admin only)
*/
updateStatus: adminProcedure
.input(
z.object({
id: z.string(),
status: z.enum(['DRAFT', 'ACTIVE', 'CLOSED', 'ARCHIVED']),
})
)
.mutation(async ({ ctx, input }) => {
const round = await ctx.prisma.round.update({
where: { id: input.id },
data: { status: input.status },
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'UPDATE_STATUS',
entityType: 'Round',
entityId: input.id,
detailsJson: { status: input.status },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return round
}),
/**
* Check if voting is currently open for a round
*/
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()
const isOpen =
round.status === 'ACTIVE' &&
round.votingStartAt !== null &&
round.votingEndAt !== null &&
now >= round.votingStartAt &&
now <= round.votingEndAt
return {
isOpen,
startsAt: round.votingStartAt,
endsAt: round.votingEndAt,
status: round.status,
}
}),
/**
* Get round progress statistics
*/
getProgress: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const [totalProjects, totalAssignments, completedAssignments] =
await Promise.all([
ctx.prisma.project.count({ where: { roundId: input.id } }),
ctx.prisma.assignment.count({ where: { roundId: input.id } }),
ctx.prisma.assignment.count({
where: { roundId: input.id, isCompleted: true },
}),
])
const evaluationsByStatus = await ctx.prisma.evaluation.groupBy({
by: ['status'],
where: {
assignment: { roundId: input.id },
},
_count: true,
})
return {
totalProjects,
totalAssignments,
completedAssignments,
completionPercentage:
totalAssignments > 0
? Math.round((completedAssignments / totalAssignments) * 100)
: 0,
evaluationsByStatus: evaluationsByStatus.reduce(
(acc, curr) => {
acc[curr.status] = curr._count
return acc
},
{} as Record<string, number>
),
}
}),
/**
* Update or create evaluation form for a round (admin only)
*/
updateEvaluationForm: adminProcedure
.input(
z.object({
roundId: z.string(),
criteria: z.array(
z.object({
id: z.string(),
label: z.string().min(1),
description: z.string().optional(),
scale: z.number().int().min(1).max(10),
weight: z.number().optional(),
required: z.boolean(),
})
),
})
)
.mutation(async ({ ctx, input }) => {
const { roundId, criteria } = input
// Check if there are existing evaluations
const existingEvaluations = await ctx.prisma.evaluation.count({
where: {
assignment: { roundId },
status: { in: ['SUBMITTED', 'LOCKED'] },
},
})
if (existingEvaluations > 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Cannot modify criteria after evaluations have been submitted',
})
}
// Get or create the active evaluation form
const existingForm = await ctx.prisma.evaluationForm.findFirst({
where: { roundId, isActive: true },
})
let form
if (existingForm) {
// Update existing form
form = await ctx.prisma.evaluationForm.update({
where: { id: existingForm.id },
data: { criteriaJson: criteria },
})
} else {
// Create new form
form = await ctx.prisma.evaluationForm.create({
data: {
roundId,
criteriaJson: criteria,
isActive: true,
},
})
}
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'UPDATE_EVALUATION_FORM',
entityType: 'EvaluationForm',
entityId: form.id,
detailsJson: { roundId, criteriaCount: criteria.length },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return form
}),
/**
* Get evaluation form for a round
*/
getEvaluationForm: protectedProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.evaluationForm.findFirst({
where: { roundId: input.roundId, isActive: true },
})
}),
/**
* Check if a round has any submitted evaluations
*/
hasEvaluations: adminProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const count = await ctx.prisma.evaluation.count({
where: {
assignment: { roundId: input.roundId },
status: { in: ['SUBMITTED', 'LOCKED'] },
},
})
return count > 0
}),
})

View File

@@ -0,0 +1,376 @@
import { z } from 'zod'
import { router, adminProcedure, superAdminProcedure, protectedProcedure } from '../trpc'
import { getWhatsAppProvider, getWhatsAppProviderType } from '@/lib/whatsapp'
export const settingsRouter = router({
/**
* Get all settings by category
*/
getByCategory: adminProcedure
.input(z.object({ category: z.string() }))
.query(async ({ ctx, input }) => {
const settings = await ctx.prisma.systemSettings.findMany({
where: { category: input.category as any },
orderBy: { key: 'asc' },
})
// Mask secret values for non-super-admins
if (ctx.user.role !== 'SUPER_ADMIN') {
return settings.map((s) => ({
...s,
value: s.isSecret ? '********' : s.value,
}))
}
return settings
}),
/**
* Get a single setting
*/
get: adminProcedure
.input(z.object({ key: z.string() }))
.query(async ({ ctx, input }) => {
const setting = await ctx.prisma.systemSettings.findUnique({
where: { key: input.key },
})
if (!setting) return null
// Mask secret values for non-super-admins
if (setting.isSecret && ctx.user.role !== 'SUPER_ADMIN') {
return { ...setting, value: '********' }
}
return setting
}),
/**
* Get multiple settings by keys (for client-side caching)
*/
getMultiple: adminProcedure
.input(z.object({ keys: z.array(z.string()) }))
.query(async ({ ctx, input }) => {
const settings = await ctx.prisma.systemSettings.findMany({
where: { key: { in: input.keys } },
})
// Mask secret values for non-super-admins
if (ctx.user.role !== 'SUPER_ADMIN') {
return settings.map((s) => ({
...s,
value: s.isSecret ? '********' : s.value,
}))
}
return settings
}),
/**
* Update a setting (super admin only for secrets)
*/
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,
updatedBy: ctx.user.id,
},
})
// Audit log (don't log actual value for secrets)
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'UPDATE_SETTING',
entityType: 'SystemSettings',
entityId: setting.id,
detailsJson: {
key: input.key,
isSecret: setting.isSecret,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return setting
}),
/**
* Update multiple settings at once (upsert - creates if not exists)
*/
updateMultiple: superAdminProcedure
.input(
z.object({
settings: z.array(
z.object({
key: z.string(),
value: z.string(),
category: z.enum(['AI', 'BRANDING', 'EMAIL', 'STORAGE', 'SECURITY', 'DEFAULTS', 'WHATSAPP']).optional(),
})
),
})
)
.mutation(async ({ ctx, input }) => {
// Infer category from key prefix if not provided
const inferCategory = (key: string): 'AI' | 'BRANDING' | 'EMAIL' | 'STORAGE' | 'SECURITY' | 'DEFAULTS' | 'WHATSAPP' => {
if (key.startsWith('openai') || key.startsWith('ai_')) return 'AI'
if (key.startsWith('smtp_') || key.startsWith('email_')) return 'EMAIL'
if (key.startsWith('storage_') || key.startsWith('local_storage') || key.startsWith('max_file') || key.startsWith('avatar_') || key.startsWith('allowed_file')) return 'STORAGE'
if (key.startsWith('brand_') || key.startsWith('logo_') || key.startsWith('primary_') || key.startsWith('theme_')) return 'BRANDING'
if (key.startsWith('whatsapp_')) return 'WHATSAPP'
if (key.startsWith('security_') || key.startsWith('session_')) return 'SECURITY'
return 'DEFAULTS'
}
const results = await Promise.all(
input.settings.map((s) =>
ctx.prisma.systemSettings.upsert({
where: { key: s.key },
update: {
value: s.value,
updatedBy: ctx.user.id,
},
create: {
key: s.key,
value: s.value,
category: s.category || inferCategory(s.key),
updatedBy: ctx.user.id,
},
})
)
)
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'UPDATE_SETTINGS_BATCH',
entityType: 'SystemSettings',
detailsJson: { keys: input.settings.map((s) => s.key) },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return results
}),
/**
* Get all categories
*/
getCategories: adminProcedure.query(async ({ ctx }) => {
const settings = await ctx.prisma.systemSettings.findMany({
select: { category: true },
distinct: ['category'],
})
return settings.map((s) => s.category)
}),
/**
* Test AI connection
*/
testAIConnection: superAdminProcedure.mutation(async ({ ctx }) => {
const apiKeySetting = await ctx.prisma.systemSettings.findUnique({
where: { key: 'openai_api_key' },
})
if (!apiKeySetting?.value) {
return { success: false, error: 'API key not configured' }
}
try {
// Test OpenAI connection with a minimal request
const response = await fetch('https://api.openai.com/v1/models', {
headers: {
Authorization: `Bearer ${apiKeySetting.value}`,
},
})
if (response.ok) {
return { success: true }
} else {
const error = await response.json()
return { success: false, error: error.error?.message || 'Unknown error' }
}
} catch (error) {
return { success: false, error: 'Connection failed' }
}
}),
/**
* Test email connection
*/
testEmailConnection: superAdminProcedure
.input(z.object({ testEmail: z.string().email() }))
.mutation(async ({ ctx, input }) => {
try {
const { sendTestEmail } = await import('@/lib/email')
const success = await sendTestEmail(input.testEmail)
return { success, error: success ? null : 'Failed to send test email' }
} catch (error) {
return { success: false, error: 'Email configuration error' }
}
}),
/**
* Get WhatsApp settings status
*/
getWhatsAppStatus: adminProcedure.query(async ({ ctx }) => {
const [enabledSetting, providerSetting] = await Promise.all([
ctx.prisma.systemSettings.findUnique({
where: { key: 'whatsapp_enabled' },
}),
ctx.prisma.systemSettings.findUnique({
where: { key: 'whatsapp_provider' },
}),
])
return {
enabled: enabledSetting?.value === 'true',
provider: providerSetting?.value || 'META',
}
}),
/**
* Test WhatsApp connection
*/
testWhatsAppConnection: superAdminProcedure.mutation(async () => {
const provider = await getWhatsAppProvider()
if (!provider) {
return { success: false, error: 'WhatsApp not configured' }
}
const result = await provider.testConnection()
const providerType = await getWhatsAppProviderType()
return {
...result,
provider: providerType,
}
}),
/**
* Send a test WhatsApp message
*/
sendTestWhatsApp: superAdminProcedure
.input(z.object({ phoneNumber: z.string() }))
.mutation(async ({ input }) => {
const provider = await getWhatsAppProvider()
if (!provider) {
return { success: false, error: 'WhatsApp not configured' }
}
return provider.sendText(
input.phoneNumber,
'This is a test message from MOPC Platform.'
)
}),
/**
* Update user notification preferences
*/
updateNotificationPreferences: protectedProcedure
.input(
z.object({
phoneNumber: z.string().optional().nullable(),
notificationPreference: z.enum(['EMAIL', 'WHATSAPP', 'BOTH', 'NONE']),
whatsappOptIn: z.boolean(),
})
)
.mutation(async ({ ctx, input }) => {
const user = await ctx.prisma.user.update({
where: { id: ctx.user.id },
data: {
phoneNumber: input.phoneNumber,
notificationPreference: input.notificationPreference,
whatsappOptIn: input.whatsappOptIn,
},
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'UPDATE_NOTIFICATION_PREFERENCES',
entityType: 'User',
entityId: ctx.user.id,
detailsJson: {
notificationPreference: input.notificationPreference,
whatsappOptIn: input.whatsappOptIn,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return user
}),
/**
* Get notification statistics (admin only)
*/
getNotificationStats: adminProcedure
.input(
z.object({
startDate: z.string().datetime().optional(),
endDate: z.string().datetime().optional(),
})
)
.query(async ({ ctx, input }) => {
const where: Record<string, unknown> = {}
if (input.startDate || input.endDate) {
where.createdAt = {}
if (input.startDate) {
(where.createdAt as Record<string, Date>).gte = new Date(input.startDate)
}
if (input.endDate) {
(where.createdAt as Record<string, Date>).lte = new Date(input.endDate)
}
}
const [total, byChannel, byStatus, byType] = await Promise.all([
ctx.prisma.notificationLog.count({ where }),
ctx.prisma.notificationLog.groupBy({
by: ['channel'],
where,
_count: true,
}),
ctx.prisma.notificationLog.groupBy({
by: ['status'],
where,
_count: true,
}),
ctx.prisma.notificationLog.groupBy({
by: ['type'],
where,
_count: true,
}),
])
return {
total,
byChannel: Object.fromEntries(
byChannel.map((r) => [r.channel, r._count])
),
byStatus: Object.fromEntries(
byStatus.map((r) => [r.status, r._count])
),
byType: Object.fromEntries(
byType.map((r) => [r.type, r._count])
),
}
}),
})

396
src/server/routers/tag.ts Normal file
View File

@@ -0,0 +1,396 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, adminProcedure, protectedProcedure } from '../trpc'
export const tagRouter = router({
/**
* List all expertise tags
*/
list: protectedProcedure
.input(
z.object({
category: z.string().optional(),
isActive: z.boolean().optional(),
includeUsageCount: z.boolean().default(false),
})
)
.query(async ({ ctx, input }) => {
const where: Record<string, unknown> = {}
if (input.category) {
where.category = input.category
}
if (input.isActive !== undefined) {
where.isActive = input.isActive
}
const tags = await ctx.prisma.expertiseTag.findMany({
where,
orderBy: [{ category: 'asc' }, { sortOrder: 'asc' }, { name: 'asc' }],
})
if (!input.includeUsageCount) {
return { tags }
}
// Count usage for each tag
const tagsWithCounts = await Promise.all(
tags.map(async (tag) => {
const userCount = await ctx.prisma.user.count({
where: {
expertiseTags: { has: tag.name },
},
})
const projectCount = await ctx.prisma.project.count({
where: {
tags: { has: tag.name },
},
})
return {
...tag,
userCount,
projectCount,
totalUsage: userCount + projectCount,
}
})
)
return { tags: tagsWithCounts }
}),
/**
* Get a single tag by ID
*/
get: adminProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const tag = await ctx.prisma.expertiseTag.findUniqueOrThrow({
where: { id: input.id },
})
// Get usage counts
const [userCount, projectCount] = await Promise.all([
ctx.prisma.user.count({
where: { expertiseTags: { has: tag.name } },
}),
ctx.prisma.project.count({
where: { tags: { has: tag.name } },
}),
])
return {
...tag,
userCount,
projectCount,
}
}),
/**
* Get all unique categories
*/
getCategories: protectedProcedure.query(async ({ ctx }) => {
const tags = await ctx.prisma.expertiseTag.findMany({
where: { isActive: true },
select: { category: true },
distinct: ['category'],
})
return tags
.map((t) => t.category)
.filter((c): c is string => c !== null)
.sort()
}),
/**
* Create a new expertise tag (admin only)
*/
create: adminProcedure
.input(
z.object({
name: z.string().min(1).max(100),
description: z.string().optional(),
category: z.string().optional(),
color: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(),
sortOrder: z.number().int().default(0),
})
)
.mutation(async ({ ctx, input }) => {
// Check if tag name already exists
const existing = await ctx.prisma.expertiseTag.findUnique({
where: { name: input.name },
})
if (existing) {
throw new TRPCError({
code: 'CONFLICT',
message: 'A tag with this name already exists',
})
}
const tag = await ctx.prisma.expertiseTag.create({
data: input,
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'CREATE',
entityType: 'ExpertiseTag',
entityId: tag.id,
detailsJson: { name: input.name, category: input.category },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return tag
}),
/**
* Update an expertise tag (admin only)
*/
update: adminProcedure
.input(
z.object({
id: z.string(),
name: z.string().min(1).max(100).optional(),
description: z.string().optional().nullable(),
category: z.string().optional().nullable(),
color: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional().nullable(),
isActive: z.boolean().optional(),
sortOrder: z.number().int().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input
// If name is being changed, check for conflicts
if (data.name) {
const existing = await ctx.prisma.expertiseTag.findFirst({
where: {
name: data.name,
id: { not: id },
},
})
if (existing) {
throw new TRPCError({
code: 'CONFLICT',
message: 'A tag with this name already exists',
})
}
}
// Get old tag for name change handling
const oldTag = await ctx.prisma.expertiseTag.findUniqueOrThrow({
where: { id },
})
const tag = await ctx.prisma.expertiseTag.update({
where: { id },
data,
})
// If name changed, update all users and projects with this tag
if (data.name && data.name !== oldTag.name) {
const newName = data.name
// Update users
const usersWithTag = await ctx.prisma.user.findMany({
where: { expertiseTags: { has: oldTag.name } },
select: { id: true, expertiseTags: true },
})
for (const user of usersWithTag) {
await ctx.prisma.user.update({
where: { id: user.id },
data: {
expertiseTags: user.expertiseTags.map((t) =>
t === oldTag.name ? newName : t
),
},
})
}
// Update projects
const projectsWithTag = await ctx.prisma.project.findMany({
where: { tags: { has: oldTag.name } },
select: { id: true, tags: true },
})
for (const project of projectsWithTag) {
await ctx.prisma.project.update({
where: { id: project.id },
data: {
tags: project.tags.map((t) =>
t === oldTag.name ? newName : t
),
},
})
}
}
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'ExpertiseTag',
entityId: id,
detailsJson: data,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return tag
}),
/**
* Delete an expertise tag (admin only)
*/
delete: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const tag = await ctx.prisma.expertiseTag.findUniqueOrThrow({
where: { id: input.id },
})
// Remove tag from all users
const usersWithTag = await ctx.prisma.user.findMany({
where: { expertiseTags: { has: tag.name } },
select: { id: true, expertiseTags: true },
})
for (const user of usersWithTag) {
await ctx.prisma.user.update({
where: { id: user.id },
data: {
expertiseTags: user.expertiseTags.filter((t) => t !== tag.name),
},
})
}
// Remove tag from all projects
const projectsWithTag = await ctx.prisma.project.findMany({
where: { tags: { has: tag.name } },
select: { id: true, tags: true },
})
for (const project of projectsWithTag) {
await ctx.prisma.project.update({
where: { id: project.id },
data: {
tags: project.tags.filter((t) => t !== tag.name),
},
})
}
// Delete the tag
await ctx.prisma.expertiseTag.delete({
where: { id: input.id },
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'DELETE',
entityType: 'ExpertiseTag',
entityId: input.id,
detailsJson: { name: tag.name },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return tag
}),
/**
* Bulk create tags (admin only)
*/
bulkCreate: adminProcedure
.input(
z.object({
tags: z.array(
z.object({
name: z.string().min(1).max(100),
description: z.string().optional(),
category: z.string().optional(),
color: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(),
})
),
})
)
.mutation(async ({ ctx, input }) => {
// Get existing tag names
const existingTags = await ctx.prisma.expertiseTag.findMany({
where: { name: { in: input.tags.map((t) => t.name) } },
select: { name: true },
})
const existingNames = new Set(existingTags.map((t) => t.name))
// Filter out existing tags
const newTags = input.tags.filter((t) => !existingNames.has(t.name))
if (newTags.length === 0) {
return { created: 0, skipped: input.tags.length }
}
// Get max sort order
const maxOrder = await ctx.prisma.expertiseTag.aggregate({
_max: { sortOrder: true },
})
const startOrder = (maxOrder._max.sortOrder || 0) + 1
const created = await ctx.prisma.expertiseTag.createMany({
data: newTags.map((t, i) => ({
...t,
sortOrder: startOrder + i,
})),
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'BULK_CREATE',
entityType: 'ExpertiseTag',
detailsJson: { count: created.count, skipped: existingNames.size },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return { created: created.count, skipped: existingNames.size }
}),
/**
* Reorder tags (admin only)
*/
reorder: adminProcedure
.input(
z.object({
items: z.array(
z.object({
id: z.string(),
sortOrder: z.number().int(),
})
),
})
)
.mutation(async ({ ctx, input }) => {
await ctx.prisma.$transaction(
input.items.map((item) =>
ctx.prisma.expertiseTag.update({
where: { id: item.id },
data: { sortOrder: item.sortOrder },
})
)
)
return { success: true }
}),
})

View File

@@ -0,0 +1,248 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { Prisma } from '@prisma/client'
import { router, adminProcedure } from '../trpc'
import {
testTypeformConnection,
getTypeformSchema,
getAllTypeformResponses,
responseToObject,
} from '@/lib/typeform'
export const typeformImportRouter = router({
/**
* Test connection to Typeform API
*/
testConnection: adminProcedure
.input(
z.object({
apiKey: z.string().min(1),
})
)
.mutation(async ({ input }) => {
return testTypeformConnection(input.apiKey)
}),
/**
* Get form schema (questions/fields) for mapping
*/
getFormSchema: adminProcedure
.input(
z.object({
apiKey: z.string().min(1),
formId: z.string().min(1),
})
)
.query(async ({ input }) => {
try {
return await getTypeformSchema(input.apiKey, input.formId)
} catch (error) {
throw new TRPCError({
code: 'BAD_REQUEST',
message:
error instanceof Error
? error.message
: 'Failed to fetch form schema',
})
}
}),
/**
* Preview responses from Typeform
*/
previewResponses: adminProcedure
.input(
z.object({
apiKey: z.string().min(1),
formId: z.string().min(1),
limit: z.number().int().min(1).max(10).default(5),
})
)
.query(async ({ input }) => {
try {
const schema = await getTypeformSchema(input.apiKey, input.formId)
const responses = await getAllTypeformResponses(
input.apiKey,
input.formId,
input.limit
)
// Convert responses to flat objects for preview
const records = responses.map((r) => responseToObject(r, schema.fields))
return {
records,
count: records.length,
formTitle: schema.title,
}
} catch (error) {
throw new TRPCError({
code: 'BAD_REQUEST',
message:
error instanceof Error
? error.message
: 'Failed to fetch responses from Typeform',
})
}
}),
/**
* Import projects from Typeform responses
*/
importProjects: adminProcedure
.input(
z.object({
apiKey: z.string().min(1),
formId: z.string().min(1),
roundId: z.string(),
// Field mappings: Typeform field title -> Project field
mappings: z.object({
title: z.string(), // Required
teamName: z.string().optional(),
description: z.string().optional(),
tags: z.string().optional(), // Multi-select or text field
email: z.string().optional(), // For tracking submission email
}),
// Store unmapped columns in metadataJson
includeUnmappedInMetadata: z.boolean().default(true),
})
)
.mutation(async ({ ctx, input }) => {
// Verify round exists
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
})
// Fetch form schema and all responses
const schema = await getTypeformSchema(input.apiKey, input.formId)
const responses = await getAllTypeformResponses(input.apiKey, input.formId)
if (responses.length === 0) {
return { imported: 0, skipped: 0, errors: [] }
}
const results = {
imported: 0,
skipped: 0,
errors: [] as Array<{ responseId: string; error: string }>,
}
// Process each response
for (const response of responses) {
try {
const record = responseToObject(response, schema.fields)
// Get mapped values
const title = record[input.mappings.title]
if (!title || typeof title !== 'string' || !title.trim()) {
results.errors.push({
responseId: response.response_id,
error: 'Missing or invalid title',
})
results.skipped++
continue
}
const teamName = input.mappings.teamName
? record[input.mappings.teamName]
: null
const description = input.mappings.description
? record[input.mappings.description]
: null
let tags: string[] = []
if (input.mappings.tags) {
const tagsValue = record[input.mappings.tags]
if (Array.isArray(tagsValue)) {
tags = tagsValue.filter((t): t is string => typeof t === 'string')
} else if (typeof tagsValue === 'string') {
tags = tagsValue.split(',').map((t) => t.trim()).filter(Boolean)
}
}
// Build metadata from unmapped columns
let metadataJson: Record<string, unknown> | null = null
if (input.includeUnmappedInMetadata) {
const mappedKeys = new Set([
input.mappings.title,
input.mappings.teamName,
input.mappings.description,
input.mappings.tags,
input.mappings.email,
'_response_id',
'_submitted_at',
].filter(Boolean))
metadataJson = {}
for (const [key, value] of Object.entries(record)) {
if (!mappedKeys.has(key) && value !== null && value !== undefined) {
metadataJson[key] = value
}
}
// Add submission email if mapped
if (input.mappings.email) {
const email = record[input.mappings.email]
if (email) {
metadataJson._submissionEmail = email
}
}
// Add submission timestamp
metadataJson._submittedAt = response.submitted_at
if (Object.keys(metadataJson).length === 0) {
metadataJson = null
}
}
// Create project
await ctx.prisma.project.create({
data: {
roundId: round.id,
title: String(title).trim(),
teamName: typeof teamName === 'string' ? teamName.trim() : null,
description: typeof description === 'string' ? description : null,
tags,
metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,
externalIdsJson: {
typeformResponseId: response.response_id,
typeformFormId: input.formId,
} as Prisma.InputJsonValue,
status: 'SUBMITTED',
},
})
results.imported++
} catch (error) {
results.errors.push({
responseId: response.response_id,
error: error instanceof Error ? error.message : 'Unknown error',
})
results.skipped++
}
}
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'IMPORT',
entityType: 'Project',
detailsJson: {
source: 'typeform',
formId: input.formId,
roundId: input.roundId,
imported: results.imported,
skipped: results.skipped,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return results
}),
})

759
src/server/routers/user.ts Normal file
View File

@@ -0,0 +1,759 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, protectedProcedure, adminProcedure, superAdminProcedure, publicProcedure } from '../trpc'
import { sendInvitationEmail, sendMagicLinkEmail } from '@/lib/email'
import { hashPassword, validatePassword } from '@/lib/password'
export const userRouter = router({
/**
* Get current user profile
*/
me: protectedProcedure.query(async ({ ctx }) => {
return ctx.prisma.user.findUniqueOrThrow({
where: { id: ctx.user.id },
select: {
id: true,
email: true,
name: true,
role: true,
status: true,
expertiseTags: true,
createdAt: true,
lastLoginAt: true,
},
})
}),
/**
* Update current user profile
*/
updateProfile: protectedProcedure
.input(
z.object({
name: z.string().min(1).max(255).optional(),
})
)
.mutation(async ({ ctx, input }) => {
return ctx.prisma.user.update({
where: { id: ctx.user.id },
data: input,
})
}),
/**
* List all users (admin only)
*/
list: adminProcedure
.input(
z.object({
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(),
status: z.enum(['INVITED', 'ACTIVE', 'SUSPENDED']).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 { role, status, search, page, perPage } = input
const skip = (page - 1) * perPage
const where: Record<string, unknown> = {}
if (role) where.role = role
if (status) where.status = status
if (search) {
where.OR = [
{ email: { contains: search, mode: 'insensitive' } },
{ name: { contains: search, mode: 'insensitive' } },
]
}
const [users, total] = await Promise.all([
ctx.prisma.user.findMany({
where,
skip,
take: perPage,
orderBy: { createdAt: 'desc' },
select: {
id: true,
email: true,
name: true,
role: true,
status: true,
expertiseTags: true,
maxAssignments: true,
createdAt: true,
lastLoginAt: true,
_count: {
select: { assignments: true },
},
},
}),
ctx.prisma.user.count({ where }),
])
return {
users,
total,
page,
perPage,
totalPages: Math.ceil(total / perPage),
}
}),
/**
* Get a single user (admin only)
*/
get: adminProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.user.findUniqueOrThrow({
where: { id: input.id },
include: {
_count: {
select: { assignments: true },
},
},
})
}),
/**
* Create/invite a new user (admin only)
*/
create: adminProcedure
.input(
z.object({
email: z.string().email(),
name: z.string().optional(),
role: z.enum(['PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).default('JURY_MEMBER'),
expertiseTags: z.array(z.string()).optional(),
maxAssignments: z.number().int().min(1).max(100).optional(),
})
)
.mutation(async ({ ctx, input }) => {
// Check if user already exists
const existing = await ctx.prisma.user.findUnique({
where: { email: input.email },
})
if (existing) {
throw new TRPCError({
code: 'CONFLICT',
message: 'A user with this email already exists',
})
}
// Prevent non-super-admins from creating admins
if (input.role === 'PROGRAM_ADMIN' && ctx.user.role !== 'SUPER_ADMIN') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Only super admins can create program admins',
})
}
const user = await ctx.prisma.user.create({
data: {
...input,
status: 'INVITED',
},
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'CREATE',
entityType: 'User',
entityId: user.id,
detailsJson: { email: input.email, role: input.role },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return user
}),
/**
* Update a user (admin only)
*/
update: adminProcedure
.input(
z.object({
id: z.string(),
name: z.string().optional().nullable(),
role: z.enum(['PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(),
status: z.enum(['INVITED', 'ACTIVE', 'SUSPENDED']).optional(),
expertiseTags: z.array(z.string()).optional(),
maxAssignments: z.number().int().min(1).max(100).optional().nullable(),
})
)
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input
// Prevent changing super admin role
const targetUser = await ctx.prisma.user.findUniqueOrThrow({
where: { id },
})
if (targetUser.role === 'SUPER_ADMIN' && ctx.user.role !== 'SUPER_ADMIN') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Cannot modify super admin',
})
}
// Prevent non-super-admins from assigning admin role
if (data.role === 'PROGRAM_ADMIN' && ctx.user.role !== 'SUPER_ADMIN') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Only super admins can assign admin role',
})
}
const user = await ctx.prisma.user.update({
where: { id },
data,
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'User',
entityId: id,
detailsJson: data,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return user
}),
/**
* Delete a user (super admin only)
*/
delete: superAdminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
// Prevent self-deletion
if (input.id === ctx.user.id) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Cannot delete yourself',
})
}
const user = await ctx.prisma.user.delete({
where: { id: input.id },
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'DELETE',
entityType: 'User',
entityId: input.id,
detailsJson: { email: user.email },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return user
}),
/**
* Bulk import users (admin only)
*/
bulkCreate: adminProcedure
.input(
z.object({
users: z.array(
z.object({
email: z.string().email(),
name: z.string().optional(),
role: z.enum(['JURY_MEMBER', 'MENTOR', 'OBSERVER']).default('JURY_MEMBER'),
expertiseTags: z.array(z.string()).optional(),
})
),
})
)
.mutation(async ({ ctx, input }) => {
// Deduplicate input by email (keep first occurrence)
const seenEmails = new Set<string>()
const uniqueUsers = input.users.filter((u) => {
const email = u.email.toLowerCase()
if (seenEmails.has(email)) return false
seenEmails.add(email)
return true
})
// Get existing emails from database
const existingUsers = await ctx.prisma.user.findMany({
where: { email: { in: uniqueUsers.map((u) => u.email.toLowerCase()) } },
select: { email: true },
})
const existingEmails = new Set(existingUsers.map((u) => u.email.toLowerCase()))
// Filter out existing users
const newUsers = uniqueUsers.filter((u) => !existingEmails.has(u.email.toLowerCase()))
const duplicatesInInput = input.users.length - uniqueUsers.length
const skipped = existingEmails.size + duplicatesInInput
if (newUsers.length === 0) {
return { created: 0, skipped }
}
const created = await ctx.prisma.user.createMany({
data: newUsers.map((u) => ({
...u,
email: u.email.toLowerCase(),
status: 'INVITED',
})),
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'BULK_CREATE',
entityType: 'User',
detailsJson: { count: created.count, skipped, duplicatesInInput },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return { created: created.count, skipped }
}),
/**
* Get jury members for assignment
*/
getJuryMembers: adminProcedure
.input(
z.object({
roundId: z.string().optional(),
search: z.string().optional(),
})
)
.query(async ({ ctx, input }) => {
const where: Record<string, unknown> = {
role: 'JURY_MEMBER',
status: 'ACTIVE',
}
if (input.search) {
where.OR = [
{ email: { contains: input.search, mode: 'insensitive' } },
{ name: { contains: input.search, mode: 'insensitive' } },
]
}
const users = await ctx.prisma.user.findMany({
where,
select: {
id: true,
email: true,
name: true,
expertiseTags: true,
maxAssignments: true,
_count: {
select: {
assignments: input.roundId
? { where: { roundId: input.roundId } }
: true,
},
},
},
orderBy: { name: 'asc' },
})
return users.map((u) => ({
...u,
currentAssignments: u._count.assignments,
availableSlots:
u.maxAssignments !== null
? Math.max(0, u.maxAssignments - u._count.assignments)
: null,
}))
}),
/**
* Send invitation email to a user
*/
sendInvitation: adminProcedure
.input(z.object({ userId: z.string() }))
.mutation(async ({ ctx, input }) => {
const user = await ctx.prisma.user.findUniqueOrThrow({
where: { id: input.userId },
})
if (user.status !== 'INVITED') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'User has already accepted their invitation',
})
}
// Generate magic link URL
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'
const inviteUrl = `${baseUrl}/api/auth/signin?email=${encodeURIComponent(user.email)}`
// Send invitation email
await sendInvitationEmail(user.email, user.name, inviteUrl, user.role)
// Log notification
await ctx.prisma.notificationLog.create({
data: {
userId: user.id,
channel: 'EMAIL',
provider: 'SMTP',
type: 'JURY_INVITATION',
status: 'SENT',
},
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'SEND_INVITATION',
entityType: 'User',
entityId: user.id,
detailsJson: { email: user.email },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return { success: true, email: user.email }
}),
/**
* Send invitation emails to multiple users
*/
bulkSendInvitations: adminProcedure
.input(z.object({ userIds: z.array(z.string()) }))
.mutation(async ({ ctx, input }) => {
const users = await ctx.prisma.user.findMany({
where: {
id: { in: input.userIds },
status: 'INVITED',
},
})
if (users.length === 0) {
return { sent: 0, skipped: input.userIds.length }
}
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'
let sent = 0
const errors: string[] = []
for (const user of users) {
try {
const inviteUrl = `${baseUrl}/api/auth/signin?email=${encodeURIComponent(user.email)}`
await sendInvitationEmail(user.email, user.name, inviteUrl, user.role)
await ctx.prisma.notificationLog.create({
data: {
userId: user.id,
channel: 'EMAIL',
provider: 'SMTP',
type: 'JURY_INVITATION',
status: 'SENT',
},
})
sent++
} catch (e) {
errors.push(user.email)
await ctx.prisma.notificationLog.create({
data: {
userId: user.id,
channel: 'EMAIL',
provider: 'SMTP',
type: 'JURY_INVITATION',
status: 'FAILED',
errorMsg: e instanceof Error ? e.message : 'Unknown error',
},
})
}
}
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'BULK_SEND_INVITATIONS',
entityType: 'User',
detailsJson: { sent, errors },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return { sent, skipped: input.userIds.length - users.length, errors }
}),
/**
* Complete onboarding for current user
*/
completeOnboarding: protectedProcedure
.input(
z.object({
name: z.string().min(1).max(255),
phoneNumber: z.string().optional(),
expertiseTags: z.array(z.string()).optional(),
notificationPreference: z.enum(['EMAIL', 'WHATSAPP', 'BOTH', 'NONE']).optional(),
})
)
.mutation(async ({ ctx, input }) => {
const user = await ctx.prisma.user.update({
where: { id: ctx.user.id },
data: {
name: input.name,
phoneNumber: input.phoneNumber,
expertiseTags: input.expertiseTags || [],
notificationPreference: input.notificationPreference || 'EMAIL',
onboardingCompletedAt: new Date(),
status: 'ACTIVE', // Activate user after onboarding
},
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'COMPLETE_ONBOARDING',
entityType: 'User',
entityId: ctx.user.id,
detailsJson: { name: input.name },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return user
}),
/**
* Check if current user needs onboarding
*/
needsOnboarding: protectedProcedure.query(async ({ ctx }) => {
const user = await ctx.prisma.user.findUniqueOrThrow({
where: { id: ctx.user.id },
select: { onboardingCompletedAt: true, role: true },
})
// Only jury members need onboarding
if (user.role !== 'JURY_MEMBER') {
return false
}
return user.onboardingCompletedAt === null
}),
/**
* Check if current user needs to set a password
*/
needsPasswordSetup: protectedProcedure.query(async ({ ctx }) => {
const user = await ctx.prisma.user.findUniqueOrThrow({
where: { id: ctx.user.id },
select: { mustSetPassword: true, passwordHash: true },
})
return user.mustSetPassword || user.passwordHash === null
}),
/**
* Set password for current user
*/
setPassword: protectedProcedure
.input(
z.object({
password: z.string().min(8),
confirmPassword: z.string().min(8),
})
)
.mutation(async ({ ctx, input }) => {
// Validate passwords match
if (input.password !== input.confirmPassword) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Passwords do not match',
})
}
// Validate password requirements
const validation = validatePassword(input.password)
if (!validation.valid) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: validation.errors.join('. '),
})
}
// Hash the password
const passwordHash = await hashPassword(input.password)
// Update user with new password
const user = await ctx.prisma.user.update({
where: { id: ctx.user.id },
data: {
passwordHash,
passwordSetAt: new Date(),
mustSetPassword: false,
},
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'SET_PASSWORD',
entityType: 'User',
entityId: ctx.user.id,
detailsJson: { timestamp: new Date().toISOString() },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return { success: true, email: user.email }
}),
/**
* Change password for current user (requires current password)
*/
changePassword: protectedProcedure
.input(
z.object({
currentPassword: z.string().min(1),
newPassword: z.string().min(8),
confirmNewPassword: z.string().min(8),
})
)
.mutation(async ({ ctx, input }) => {
// Get current user with password hash
const user = await ctx.prisma.user.findUniqueOrThrow({
where: { id: ctx.user.id },
select: { passwordHash: true },
})
if (!user.passwordHash) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'No password set. Please use magic link to sign in.',
})
}
// Verify current password
const { verifyPassword } = await import('@/lib/password')
const isValid = await verifyPassword(input.currentPassword, user.passwordHash)
if (!isValid) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Current password is incorrect',
})
}
// Validate new passwords match
if (input.newPassword !== input.confirmNewPassword) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'New passwords do not match',
})
}
// Validate new password requirements
const validation = validatePassword(input.newPassword)
if (!validation.valid) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: validation.errors.join('. '),
})
}
// Hash the new password
const passwordHash = await hashPassword(input.newPassword)
// Update user with new password
await ctx.prisma.user.update({
where: { id: ctx.user.id },
data: {
passwordHash,
passwordSetAt: new Date(),
},
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'CHANGE_PASSWORD',
entityType: 'User',
entityId: ctx.user.id,
detailsJson: { timestamp: new Date().toISOString() },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return { success: true }
}),
/**
* Request password reset (public - no auth required)
* Sends a magic link and marks user for password reset
*/
requestPasswordReset: publicProcedure
.input(z.object({ email: z.string().email() }))
.mutation(async ({ ctx, input }) => {
// Find user by email
const user = await ctx.prisma.user.findUnique({
where: { email: input.email },
select: { id: true, email: true, status: true },
})
// Always return success to prevent email enumeration
if (!user || user.status === 'SUSPENDED') {
return { success: true, message: 'If an account exists with this email, a password reset link will be sent.' }
}
// Mark user for password reset
await ctx.prisma.user.update({
where: { id: user.id },
data: { mustSetPassword: true },
})
// Generate a callback URL for the magic link
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'
const callbackUrl = `${baseUrl}/set-password`
// We don't send the email here - the user will use the magic link form
// This just marks them for password reset
// The actual email is sent through NextAuth's email provider
// Audit log (without user ID since this is public)
await ctx.prisma.auditLog.create({
data: {
userId: null, // No authenticated user
action: 'REQUEST_PASSWORD_RESET',
entityType: 'User',
entityId: user.id,
detailsJson: { email: input.email, timestamp: new Date().toISOString() },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return { success: true, message: 'If an account exists with this email, a password reset link will be sent.' }
}),
})

View File

@@ -0,0 +1,421 @@
/**
* AI-Powered Assignment Service
*
* Uses GPT to analyze juror expertise and project requirements
* to generate optimal assignment suggestions.
*/
import { getOpenAI, AI_MODELS } from '@/lib/openai'
import {
anonymizeForAI,
deanonymizeResults,
validateAnonymization,
type AnonymizationResult,
} from './anonymization'
// Types for AI assignment
export interface AIAssignmentSuggestion {
jurorId: string
projectId: string
confidenceScore: number // 0-1
reasoning: string
expertiseMatchScore: number // 0-1
}
export interface AIAssignmentResult {
success: boolean
suggestions: AIAssignmentSuggestion[]
error?: string
tokensUsed?: number
fallbackUsed?: boolean
}
interface JurorForAssignment {
id: string
name?: string | null
email: string
expertiseTags: string[]
maxAssignments?: number | null
_count?: {
assignments: number
}
}
interface ProjectForAssignment {
id: string
title: string
description?: string | null
tags: string[]
teamName?: string | null
_count?: {
assignments: number
}
}
interface AssignmentConstraints {
requiredReviewsPerProject: number
maxAssignmentsPerJuror?: number
existingAssignments: Array<{
jurorId: string
projectId: string
}>
}
/**
* System prompt for AI assignment
*/
const ASSIGNMENT_SYSTEM_PROMPT = `You are an expert at matching jury members to projects based on expertise alignment.
Your task is to suggest optimal juror-project assignments that:
1. Match juror expertise tags with project tags and content
2. Distribute workload fairly among jurors
3. Ensure each project gets the required number of reviews
4. Avoid assigning jurors who are already at their limit
For each suggestion, provide:
- A confidence score (0-1) based on how well the juror's expertise matches the project
- An expertise match score (0-1) based purely on tag/content alignment
- A brief reasoning explaining why this is a good match
Return your response as a JSON array of assignments.`
/**
* Generate AI-powered assignment suggestions
*/
export async function generateAIAssignments(
jurors: JurorForAssignment[],
projects: ProjectForAssignment[],
constraints: AssignmentConstraints
): Promise<AIAssignmentResult> {
// Anonymize data before sending to AI
const anonymizedData = anonymizeForAI(jurors, projects)
// Validate anonymization
if (!validateAnonymization(anonymizedData)) {
console.error('Anonymization validation failed, falling back to algorithm')
return generateFallbackAssignments(jurors, projects, constraints)
}
try {
const openai = await getOpenAI()
if (!openai) {
console.log('OpenAI not configured, using fallback algorithm')
return generateFallbackAssignments(jurors, projects, constraints)
}
const suggestions = await callAIForAssignments(
openai,
anonymizedData,
constraints
)
// De-anonymize results
const deanonymizedSuggestions = deanonymizeResults(
suggestions.map((s) => ({
...s,
jurorId: s.jurorId,
projectId: s.projectId,
})),
anonymizedData.jurorMappings,
anonymizedData.projectMappings
).map((s) => ({
jurorId: s.realJurorId,
projectId: s.realProjectId,
confidenceScore: s.confidenceScore,
reasoning: s.reasoning,
expertiseMatchScore: s.expertiseMatchScore,
}))
return {
success: true,
suggestions: deanonymizedSuggestions,
fallbackUsed: false,
}
} catch (error) {
console.error('AI assignment failed, using fallback:', error)
return generateFallbackAssignments(jurors, projects, constraints)
}
}
/**
* Call OpenAI API for assignment suggestions
*/
async function callAIForAssignments(
openai: Awaited<ReturnType<typeof getOpenAI>>,
anonymizedData: AnonymizationResult,
constraints: AssignmentConstraints
): Promise<AIAssignmentSuggestion[]> {
if (!openai) {
throw new Error('OpenAI client not available')
}
// Build the user prompt
const userPrompt = buildAssignmentPrompt(anonymizedData, constraints)
const response = await openai.chat.completions.create({
model: AI_MODELS.ASSIGNMENT,
messages: [
{ role: 'system', content: ASSIGNMENT_SYSTEM_PROMPT },
{ role: 'user', content: userPrompt },
],
response_format: { type: 'json_object' },
temperature: 0.3, // Lower temperature for more consistent results
max_tokens: 4000,
})
const content = response.choices[0]?.message?.content
if (!content) {
throw new Error('No response from AI')
}
// Parse the response
const parsed = JSON.parse(content) as {
assignments: Array<{
juror_id: string
project_id: string
confidence_score: number
expertise_match_score: number
reasoning: string
}>
}
return (parsed.assignments || []).map((a) => ({
jurorId: a.juror_id,
projectId: a.project_id,
confidenceScore: Math.min(1, Math.max(0, a.confidence_score)),
expertiseMatchScore: Math.min(1, Math.max(0, a.expertise_match_score)),
reasoning: a.reasoning,
}))
}
/**
* Build the prompt for AI assignment
*/
function buildAssignmentPrompt(
data: AnonymizationResult,
constraints: AssignmentConstraints
): string {
const { jurors, projects } = data
// Map existing assignments to anonymous IDs
const jurorIdMap = new Map(
data.jurorMappings.map((m) => [m.realId, m.anonymousId])
)
const projectIdMap = new Map(
data.projectMappings.map((m) => [m.realId, m.anonymousId])
)
const anonymousExisting = constraints.existingAssignments
.map((a) => ({
jurorId: jurorIdMap.get(a.jurorId),
projectId: projectIdMap.get(a.projectId),
}))
.filter((a) => a.jurorId && a.projectId)
return `## Jurors Available
${JSON.stringify(jurors, null, 2)}
## Projects to Assign
${JSON.stringify(projects, null, 2)}
## Constraints
- Each project needs ${constraints.requiredReviewsPerProject} reviews
- Maximum assignments per juror: ${constraints.maxAssignmentsPerJuror || 'No limit'}
- Existing assignments to avoid duplicating:
${JSON.stringify(anonymousExisting, null, 2)}
## Instructions
Generate optimal juror-project assignments. Return a JSON object with an "assignments" array where each assignment has:
- juror_id: The anonymous juror ID
- project_id: The anonymous project ID
- confidence_score: 0-1 confidence in this match
- expertise_match_score: 0-1 expertise alignment score
- reasoning: Brief explanation (1-2 sentences)
Focus on matching expertise tags with project tags and descriptions. Distribute assignments fairly.`
}
/**
* Fallback algorithm-based assignment when AI is unavailable
*/
export function generateFallbackAssignments(
jurors: JurorForAssignment[],
projects: ProjectForAssignment[],
constraints: AssignmentConstraints
): AIAssignmentResult {
const suggestions: AIAssignmentSuggestion[] = []
const existingSet = new Set(
constraints.existingAssignments.map((a) => `${a.jurorId}:${a.projectId}`)
)
// Track assignments per juror and project
const jurorAssignments = new Map<string, number>()
const projectAssignments = new Map<string, number>()
// Initialize counts from existing assignments
for (const assignment of constraints.existingAssignments) {
jurorAssignments.set(
assignment.jurorId,
(jurorAssignments.get(assignment.jurorId) || 0) + 1
)
projectAssignments.set(
assignment.projectId,
(projectAssignments.get(assignment.projectId) || 0) + 1
)
}
// Also include current assignment counts
for (const juror of jurors) {
const current = juror._count?.assignments || 0
jurorAssignments.set(
juror.id,
Math.max(jurorAssignments.get(juror.id) || 0, current)
)
}
for (const project of projects) {
const current = project._count?.assignments || 0
projectAssignments.set(
project.id,
Math.max(projectAssignments.get(project.id) || 0, current)
)
}
// Sort projects by need (fewest assignments first)
const sortedProjects = [...projects].sort((a, b) => {
const aCount = projectAssignments.get(a.id) || 0
const bCount = projectAssignments.get(b.id) || 0
return aCount - bCount
})
// For each project, find best matching jurors
for (const project of sortedProjects) {
const currentProjectAssignments = projectAssignments.get(project.id) || 0
const neededReviews = Math.max(
0,
constraints.requiredReviewsPerProject - currentProjectAssignments
)
if (neededReviews === 0) continue
// Score all available jurors
const scoredJurors = jurors
.filter((juror) => {
// Check not already assigned
if (existingSet.has(`${juror.id}:${project.id}`)) return false
// Check not at limit
const currentAssignments = jurorAssignments.get(juror.id) || 0
const maxAssignments =
juror.maxAssignments ?? constraints.maxAssignmentsPerJuror ?? Infinity
if (currentAssignments >= maxAssignments) return false
return true
})
.map((juror) => ({
juror,
score: calculateExpertiseScore(juror.expertiseTags, project.tags),
loadScore: calculateLoadScore(
jurorAssignments.get(juror.id) || 0,
juror.maxAssignments ?? constraints.maxAssignmentsPerJuror ?? 10
),
}))
.sort((a, b) => {
// Combined score: 60% expertise, 40% load balancing
const aTotal = a.score * 0.6 + a.loadScore * 0.4
const bTotal = b.score * 0.6 + b.loadScore * 0.4
return bTotal - aTotal
})
// Assign top jurors
for (let i = 0; i < Math.min(neededReviews, scoredJurors.length); i++) {
const { juror, score } = scoredJurors[i]
suggestions.push({
jurorId: juror.id,
projectId: project.id,
confidenceScore: score,
expertiseMatchScore: score,
reasoning: generateFallbackReasoning(
juror.expertiseTags,
project.tags,
score
),
})
// Update tracking
existingSet.add(`${juror.id}:${project.id}`)
jurorAssignments.set(juror.id, (jurorAssignments.get(juror.id) || 0) + 1)
projectAssignments.set(
project.id,
(projectAssignments.get(project.id) || 0) + 1
)
}
}
return {
success: true,
suggestions,
fallbackUsed: true,
}
}
/**
* Calculate expertise match score based on tag overlap
*/
function calculateExpertiseScore(
jurorTags: string[],
projectTags: string[]
): number {
if (jurorTags.length === 0 || projectTags.length === 0) {
return 0.5 // Neutral score if no tags
}
const jurorTagsLower = new Set(jurorTags.map((t) => t.toLowerCase()))
const matchingTags = projectTags.filter((t) =>
jurorTagsLower.has(t.toLowerCase())
)
// Score based on percentage of project tags matched
const matchRatio = matchingTags.length / projectTags.length
// Boost for having expertise, even if not all match
const hasExpertise = matchingTags.length > 0 ? 0.2 : 0
return Math.min(1, matchRatio * 0.8 + hasExpertise)
}
/**
* Calculate load balancing score (higher score = less loaded)
*/
function calculateLoadScore(currentLoad: number, maxLoad: number): number {
if (maxLoad === 0) return 0
const utilization = currentLoad / maxLoad
return Math.max(0, 1 - utilization)
}
/**
* Generate reasoning for fallback assignments
*/
function generateFallbackReasoning(
jurorTags: string[],
projectTags: string[],
score: number
): string {
const jurorTagsLower = new Set(jurorTags.map((t) => t.toLowerCase()))
const matchingTags = projectTags.filter((t) =>
jurorTagsLower.has(t.toLowerCase())
)
if (matchingTags.length > 0) {
return `Expertise match: ${matchingTags.join(', ')}. Match score: ${(score * 100).toFixed(0)}%.`
}
if (score >= 0.5) {
return `Assigned for workload balance. No direct expertise match but available capacity.`
}
return `Assigned to ensure project coverage.`
}

View File

@@ -0,0 +1,211 @@
/**
* Data Anonymization Service
*
* Strips PII (names, emails, etc.) from data before sending to AI services.
* Returns ID mappings for de-anonymization of results.
*/
export interface AnonymizedJuror {
anonymousId: string
expertiseTags: string[]
currentAssignmentCount: number
maxAssignments: number | null
}
export interface AnonymizedProject {
anonymousId: string
title: string
description: string | null
tags: string[]
teamName: string | null
}
export interface JurorMapping {
anonymousId: string
realId: string
}
export interface ProjectMapping {
anonymousId: string
realId: string
}
export interface AnonymizationResult {
jurors: AnonymizedJuror[]
projects: AnonymizedProject[]
jurorMappings: JurorMapping[]
projectMappings: ProjectMapping[]
}
/**
* Juror data from database
*/
interface JurorInput {
id: string
name?: string | null
email: string
expertiseTags: string[]
maxAssignments?: number | null
_count?: {
assignments: number
}
}
/**
* Project data from database
*/
interface ProjectInput {
id: string
title: string
description?: string | null
tags: string[]
teamName?: string | null
}
/**
* Anonymize juror and project data for AI processing
*
* This function:
* 1. Strips all PII (names, emails) from juror data
* 2. Replaces real IDs with sequential anonymous IDs
* 3. Keeps only expertise tags and assignment counts
* 4. Returns mappings for de-anonymization
*/
export function anonymizeForAI(
jurors: JurorInput[],
projects: ProjectInput[]
): AnonymizationResult {
const jurorMappings: JurorMapping[] = []
const projectMappings: ProjectMapping[] = []
// Anonymize jurors
const anonymizedJurors: AnonymizedJuror[] = jurors.map((juror, index) => {
const anonymousId = `juror_${(index + 1).toString().padStart(3, '0')}`
jurorMappings.push({
anonymousId,
realId: juror.id,
})
return {
anonymousId,
expertiseTags: juror.expertiseTags,
currentAssignmentCount: juror._count?.assignments ?? 0,
maxAssignments: juror.maxAssignments ?? null,
}
})
// Anonymize projects (keep content but replace IDs)
const anonymizedProjects: AnonymizedProject[] = projects.map(
(project, index) => {
const anonymousId = `project_${(index + 1).toString().padStart(3, '0')}`
projectMappings.push({
anonymousId,
realId: project.id,
})
return {
anonymousId,
title: sanitizeText(project.title),
description: project.description
? sanitizeText(project.description)
: null,
tags: project.tags,
// Replace specific team names with generic identifier
teamName: project.teamName ? `Team ${index + 1}` : null,
}
}
)
return {
jurors: anonymizedJurors,
projects: anonymizedProjects,
jurorMappings,
projectMappings,
}
}
/**
* De-anonymize AI results back to real IDs
*/
export function deanonymizeResults<T extends { jurorId: string; projectId: string }>(
results: T[],
jurorMappings: JurorMapping[],
projectMappings: ProjectMapping[]
): (T & { realJurorId: string; realProjectId: string })[] {
const jurorMap = new Map(
jurorMappings.map((m) => [m.anonymousId, m.realId])
)
const projectMap = new Map(
projectMappings.map((m) => [m.anonymousId, m.realId])
)
return results.map((result) => ({
...result,
realJurorId: jurorMap.get(result.jurorId) || result.jurorId,
realProjectId: projectMap.get(result.projectId) || result.projectId,
}))
}
/**
* Sanitize text to remove potential PII patterns
* Removes emails, phone numbers, and URLs from text
*/
function sanitizeText(text: string): string {
// Remove email addresses
let sanitized = text.replace(
/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g,
'[email removed]'
)
// Remove phone numbers (various formats)
sanitized = sanitized.replace(
/(\+?\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}/g,
'[phone removed]'
)
// Remove URLs
sanitized = sanitized.replace(
/https?:\/\/[^\s]+/g,
'[url removed]'
)
return sanitized
}
/**
* Validate that data has been properly anonymized
* Returns true if no PII patterns are detected
*/
export function validateAnonymization(data: AnonymizationResult): boolean {
const piiPatterns = [
/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/, // Email
/(\+?\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}/, // Phone
]
const checkText = (text: string | null | undefined): boolean => {
if (!text) return true
return !piiPatterns.some((pattern) => pattern.test(text))
}
// Check jurors (they should only have expertise tags)
for (const juror of data.jurors) {
// Jurors should not have any text fields that could contain PII
// Only check expertiseTags
for (const tag of juror.expertiseTags) {
if (!checkText(tag)) return false
}
}
// Check projects
for (const project of data.projects) {
if (!checkText(project.title)) return false
if (!checkText(project.description)) return false
for (const tag of project.tags) {
if (!checkText(tag)) return false
}
}
return true
}

View File

@@ -0,0 +1,332 @@
import { PrismaClient, OceanIssue, CompetitionCategory } from '@prisma/client'
import OpenAI from 'openai'
// Lazy initialization to avoid errors when API key is not set
let openaiClient: OpenAI | null = null
function getOpenAIClient(): OpenAI | null {
if (!process.env.OPENAI_API_KEY) {
return null
}
if (!openaiClient) {
openaiClient = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
})
}
return openaiClient
}
interface ProjectInfo {
id: string
title: string
description: string | null
oceanIssue: OceanIssue | null
competitionCategory: CompetitionCategory | null
tags: string[]
}
interface MentorInfo {
id: string
name: string | null
email: string
expertiseTags: string[]
currentAssignments: number
maxAssignments: number | null
}
interface MentorMatch {
mentorId: string
confidenceScore: number
expertiseMatchScore: number
reasoning: string
}
/**
* Get AI-suggested mentor matches for a project
*/
export async function getAIMentorSuggestions(
prisma: PrismaClient,
projectId: string,
limit: number = 5
): Promise<MentorMatch[]> {
// Get project details
const project = await prisma.project.findUniqueOrThrow({
where: { id: projectId },
select: {
id: true,
title: true,
description: true,
oceanIssue: true,
competitionCategory: true,
tags: true,
},
})
// Get available mentors (users with expertise tags)
// In a full implementation, you'd have a MENTOR role
// For now, we use users with expertiseTags and consider them potential mentors
const mentors = await prisma.user.findMany({
where: {
OR: [
{ expertiseTags: { isEmpty: false } },
{ role: 'JURY_MEMBER' }, // Jury members can also be mentors
],
status: 'ACTIVE',
},
select: {
id: true,
name: true,
email: true,
expertiseTags: true,
maxAssignments: true,
mentorAssignments: {
select: { id: true },
},
},
})
// Filter mentors who haven't reached max assignments
const availableMentors: MentorInfo[] = mentors
.filter((m) => {
const currentAssignments = m.mentorAssignments.length
return !m.maxAssignments || currentAssignments < m.maxAssignments
})
.map((m) => ({
id: m.id,
name: m.name,
email: m.email,
expertiseTags: m.expertiseTags,
currentAssignments: m.mentorAssignments.length,
maxAssignments: m.maxAssignments,
}))
if (availableMentors.length === 0) {
return []
}
// Try AI matching if API key is configured
if (process.env.OPENAI_API_KEY) {
try {
return await getAIMatches(project, availableMentors, limit)
} catch (error) {
console.error('AI mentor matching failed, falling back to algorithm:', error)
}
}
// Fallback to algorithmic matching
return getAlgorithmicMatches(project, availableMentors, limit)
}
/**
* Use OpenAI to match mentors to projects
*/
async function getAIMatches(
project: ProjectInfo,
mentors: MentorInfo[],
limit: number
): Promise<MentorMatch[]> {
// Anonymize data before sending to AI
const anonymizedProject = {
description: project.description?.slice(0, 500) || 'No description',
category: project.competitionCategory,
oceanIssue: project.oceanIssue,
tags: project.tags,
}
const anonymizedMentors = mentors.map((m, index) => ({
index,
expertise: m.expertiseTags,
availability: m.maxAssignments
? `${m.currentAssignments}/${m.maxAssignments}`
: 'unlimited',
}))
const prompt = `You are matching mentors to an ocean protection project.
PROJECT:
- Category: ${anonymizedProject.category || 'Not specified'}
- Ocean Issue: ${anonymizedProject.oceanIssue || 'Not specified'}
- Tags: ${anonymizedProject.tags.join(', ') || 'None'}
- Description: ${anonymizedProject.description}
AVAILABLE MENTORS:
${anonymizedMentors.map((m) => `${m.index}: Expertise: [${m.expertise.join(', ')}], Availability: ${m.availability}`).join('\n')}
Rank the top ${limit} mentors by suitability. For each, provide:
1. Mentor index (0-based)
2. Confidence score (0-1)
3. Expertise match score (0-1)
4. Brief reasoning (1-2 sentences)
Respond in JSON format:
{
"matches": [
{
"mentorIndex": 0,
"confidenceScore": 0.85,
"expertiseMatchScore": 0.9,
"reasoning": "Strong expertise alignment..."
}
]
}`
const openai = getOpenAIClient()
if (!openai) {
throw new Error('OpenAI client not available')
}
const response = await openai.chat.completions.create({
model: 'gpt-4o-mini',
messages: [
{
role: 'system',
content: 'You are an expert at matching mentors to projects based on expertise alignment. Always respond with valid JSON.',
},
{ role: 'user', content: prompt },
],
response_format: { type: 'json_object' },
temperature: 0.3,
max_tokens: 1000,
})
const content = response.choices[0]?.message?.content
if (!content) {
throw new Error('No response from AI')
}
const parsed = JSON.parse(content) as {
matches: Array<{
mentorIndex: number
confidenceScore: number
expertiseMatchScore: number
reasoning: string
}>
}
return parsed.matches
.filter((m) => m.mentorIndex >= 0 && m.mentorIndex < mentors.length)
.map((m) => ({
mentorId: mentors[m.mentorIndex].id,
confidenceScore: m.confidenceScore,
expertiseMatchScore: m.expertiseMatchScore,
reasoning: m.reasoning,
}))
}
/**
* Algorithmic fallback for mentor matching
*/
function getAlgorithmicMatches(
project: ProjectInfo,
mentors: MentorInfo[],
limit: number
): MentorMatch[] {
// Build keyword set from project
const projectKeywords = new Set<string>()
if (project.oceanIssue) {
projectKeywords.add(project.oceanIssue.toLowerCase().replace(/_/g, ' '))
}
if (project.competitionCategory) {
projectKeywords.add(project.competitionCategory.toLowerCase().replace(/_/g, ' '))
}
project.tags.forEach((tag) => {
tag.toLowerCase().split(/\s+/).forEach((word) => {
if (word.length > 3) projectKeywords.add(word)
})
})
if (project.description) {
// Extract key words from description
const words = project.description.toLowerCase().split(/\s+/)
words.forEach((word) => {
if (word.length > 4) projectKeywords.add(word.replace(/[^a-z]/g, ''))
})
}
// Score each mentor
const scored = mentors.map((mentor) => {
const mentorKeywords = new Set<string>()
mentor.expertiseTags.forEach((tag) => {
tag.toLowerCase().split(/\s+/).forEach((word) => {
if (word.length > 2) mentorKeywords.add(word)
})
})
// Calculate overlap
let matchCount = 0
projectKeywords.forEach((keyword) => {
mentorKeywords.forEach((mentorKeyword) => {
if (keyword.includes(mentorKeyword) || mentorKeyword.includes(keyword)) {
matchCount++
}
})
})
const expertiseMatchScore = mentorKeywords.size > 0
? Math.min(1, matchCount / mentorKeywords.size)
: 0
// Factor in availability
const availabilityScore = mentor.maxAssignments
? 1 - (mentor.currentAssignments / mentor.maxAssignments)
: 1
const confidenceScore = (expertiseMatchScore * 0.7 + availabilityScore * 0.3)
return {
mentorId: mentor.id,
confidenceScore: Math.round(confidenceScore * 100) / 100,
expertiseMatchScore: Math.round(expertiseMatchScore * 100) / 100,
reasoning: `Matched ${matchCount} keyword(s) with mentor expertise. Availability: ${availabilityScore > 0.5 ? 'Good' : 'Limited'}.`,
}
})
// Sort by confidence and return top matches
return scored
.sort((a, b) => b.confidenceScore - a.confidenceScore)
.slice(0, limit)
}
/**
* Round-robin assignment for load balancing
*/
export async function getRoundRobinMentor(
prisma: PrismaClient,
excludeMentorIds: string[] = []
): Promise<string | null> {
const mentors = await prisma.user.findMany({
where: {
OR: [
{ expertiseTags: { isEmpty: false } },
{ role: 'JURY_MEMBER' },
],
status: 'ACTIVE',
id: { notIn: excludeMentorIds },
},
select: {
id: true,
maxAssignments: true,
mentorAssignments: {
select: { id: true },
},
},
orderBy: {
mentorAssignments: {
_count: 'asc',
},
},
})
// Find mentor with fewest assignments who hasn't reached max
for (const mentor of mentors) {
const currentCount = mentor.mentorAssignments.length
if (!mentor.maxAssignments || currentCount < mentor.maxAssignments) {
return mentor.id
}
}
return null
}

View File

@@ -0,0 +1,321 @@
/**
* Unified Notification Service
*
* Handles sending notifications via multiple channels:
* - Email (via nodemailer)
* - WhatsApp (via Meta or Twilio)
*/
import { prisma } from '@/lib/prisma'
import {
sendMagicLinkEmail,
sendJuryInvitationEmail,
sendEvaluationReminderEmail,
sendAnnouncementEmail,
} from '@/lib/email'
import { getWhatsAppProvider, getWhatsAppProviderType } from '@/lib/whatsapp'
import type { NotificationChannel } from '@prisma/client'
export type NotificationType =
| 'MAGIC_LINK'
| 'JURY_INVITATION'
| 'EVALUATION_REMINDER'
| 'ANNOUNCEMENT'
interface NotificationResult {
success: boolean
channels: {
email?: { success: boolean; error?: string }
whatsapp?: { success: boolean; messageId?: string; error?: string }
}
}
/**
* Send a notification to a user based on their preferences
*/
export async function sendNotification(
userId: string,
type: NotificationType,
data: Record<string, string>
): Promise<NotificationResult> {
// Get user with notification preferences
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
name: true,
phoneNumber: true,
notificationPreference: true,
whatsappOptIn: true,
},
})
if (!user) {
return {
success: false,
channels: {},
}
}
const result: NotificationResult = {
success: true,
channels: {},
}
const preference = user.notificationPreference
// Determine which channels to use
const sendEmail = preference === 'EMAIL' || preference === 'BOTH'
const sendWhatsApp =
(preference === 'WHATSAPP' || preference === 'BOTH') &&
user.whatsappOptIn &&
user.phoneNumber
// Send via email
if (sendEmail) {
const emailResult = await sendEmailNotification(user.email, user.name, type, data)
result.channels.email = emailResult
// Log the notification
await logNotification(user.id, 'EMAIL', 'SMTP', type, emailResult)
}
// Send via WhatsApp
if (sendWhatsApp && user.phoneNumber) {
const whatsappResult = await sendWhatsAppNotification(
user.phoneNumber,
user.name,
type,
data
)
result.channels.whatsapp = whatsappResult
// Log the notification
const providerType = await getWhatsAppProviderType()
await logNotification(
user.id,
'WHATSAPP',
providerType || 'UNKNOWN',
type,
whatsappResult
)
}
// Overall success if at least one channel succeeded
result.success =
(result.channels.email?.success ?? true) ||
(result.channels.whatsapp?.success ?? true)
return result
}
/**
* Send email notification
*/
async function sendEmailNotification(
email: string,
name: string | null,
type: NotificationType,
data: Record<string, string>
): Promise<{ success: boolean; error?: string }> {
try {
switch (type) {
case 'MAGIC_LINK':
await sendMagicLinkEmail(email, data.url)
return { success: true }
case 'JURY_INVITATION':
await sendJuryInvitationEmail(
email,
data.inviteUrl,
data.programName,
data.roundName
)
return { success: true }
case 'EVALUATION_REMINDER':
await sendEvaluationReminderEmail(
email,
name,
parseInt(data.pendingCount || '0'),
data.roundName || 'Current Round',
data.deadline || 'Soon',
data.assignmentsUrl || `${process.env.NEXTAUTH_URL}/jury/assignments`
)
return { success: true }
case 'ANNOUNCEMENT':
await sendAnnouncementEmail(
email,
name,
data.title || 'Announcement',
data.message || '',
data.ctaText,
data.ctaUrl
)
return { success: true }
default:
return { success: false, error: `Unknown notification type: ${type}` }
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Email send failed',
}
}
}
/**
* Send WhatsApp notification
*/
async function sendWhatsAppNotification(
phoneNumber: string,
name: string | null,
type: NotificationType,
data: Record<string, string>
): Promise<{ success: boolean; messageId?: string; error?: string }> {
const provider = await getWhatsAppProvider()
if (!provider) {
return { success: false, error: 'WhatsApp not configured' }
}
try {
// Map notification types to templates
const templateMap: Record<NotificationType, string> = {
MAGIC_LINK: 'mopc_magic_link',
JURY_INVITATION: 'mopc_jury_invitation',
EVALUATION_REMINDER: 'mopc_evaluation_reminder',
ANNOUNCEMENT: 'mopc_announcement',
}
const template = templateMap[type]
// Build template params
const params: Record<string, string> = {
name: name || 'User',
...data,
}
const result = await provider.sendTemplate(phoneNumber, template, params)
return result
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'WhatsApp send failed',
}
}
}
/**
* Log notification to database
*/
async function logNotification(
userId: string,
channel: NotificationChannel,
provider: string,
type: NotificationType,
result: { success: boolean; messageId?: string; error?: string }
): Promise<void> {
try {
await prisma.notificationLog.create({
data: {
userId,
channel,
provider,
type,
status: result.success ? 'SENT' : 'FAILED',
externalId: result.messageId,
errorMsg: result.error,
},
})
} catch (error) {
console.error('Failed to log notification:', error)
}
}
/**
* Send bulk notifications to multiple users
*/
export async function sendBulkNotification(
userIds: string[],
type: NotificationType,
data: Record<string, string>
): Promise<{ sent: number; failed: number }> {
let sent = 0
let failed = 0
for (const userId of userIds) {
const result = await sendNotification(userId, type, data)
if (result.success) {
sent++
} else {
failed++
}
}
return { sent, failed }
}
/**
* Get notification statistics
*/
export async function getNotificationStats(options?: {
userId?: string
startDate?: Date
endDate?: Date
}): Promise<{
total: number
byChannel: Record<string, number>
byStatus: Record<string, number>
byType: Record<string, number>
}> {
const where: Record<string, unknown> = {}
if (options?.userId) {
where.userId = options.userId
}
if (options?.startDate || options?.endDate) {
where.createdAt = {}
if (options.startDate) {
(where.createdAt as Record<string, Date>).gte = options.startDate
}
if (options.endDate) {
(where.createdAt as Record<string, Date>).lte = options.endDate
}
}
const [total, byChannel, byStatus, byType] = await Promise.all([
prisma.notificationLog.count({ where }),
prisma.notificationLog.groupBy({
by: ['channel'],
where,
_count: true,
}),
prisma.notificationLog.groupBy({
by: ['status'],
where,
_count: true,
}),
prisma.notificationLog.groupBy({
by: ['type'],
where,
_count: true,
}),
])
return {
total,
byChannel: Object.fromEntries(
byChannel.map((r) => [r.channel, r._count])
),
byStatus: Object.fromEntries(
byStatus.map((r) => [r.status, r._count])
),
byType: Object.fromEntries(
byType.map((r) => [r.type, r._count])
),
}
}

147
src/server/trpc.ts Normal file
View File

@@ -0,0 +1,147 @@
import { initTRPC, TRPCError } from '@trpc/server'
import superjson from 'superjson'
import { ZodError } from 'zod'
import type { Context } from './context'
import type { UserRole } from '@prisma/client'
/**
* Initialize tRPC with context type and configuration
*/
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 reusable router and procedure helpers
*/
export const router = t.router
export const publicProcedure = t.procedure
export const middleware = t.middleware
export const createCallerFactory = t.createCallerFactory
// =============================================================================
// Middleware
// =============================================================================
/**
* Middleware to require authenticated user
*/
const isAuthenticated = middleware(async ({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'You must be logged in to perform this action',
})
}
return next({
ctx: {
...ctx,
user: ctx.session.user,
},
})
})
/**
* Middleware to require specific role(s)
*/
const hasRole = (...roles: UserRole[]) =>
middleware(async ({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'You must be logged in to perform this action',
})
}
if (!roles.includes(ctx.session.user.role)) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You do not have permission to perform this action',
})
}
return next({
ctx: {
...ctx,
user: ctx.session.user,
},
})
})
/**
* Middleware for audit logging
*/
const withAuditLog = middleware(async ({ ctx, next, path }) => {
const result = await next()
// Log successful mutations
if (result.ok && path.includes('.')) {
const [, action] = path.split('.')
const mutationActions = ['create', 'update', 'delete', 'import', 'submit', 'grant', 'revoke']
if (mutationActions.some((a) => action?.toLowerCase().includes(a))) {
// Audit logging would happen here
// We'll implement this in the audit service
}
}
return result
})
// =============================================================================
// Procedure Types
// =============================================================================
/**
* Protected procedure - requires authenticated user
*/
export const protectedProcedure = t.procedure.use(isAuthenticated)
/**
* Admin procedure - requires SUPER_ADMIN or PROGRAM_ADMIN role
*/
export const adminProcedure = t.procedure.use(
hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN')
)
/**
* Super admin procedure - requires SUPER_ADMIN role
*/
export const superAdminProcedure = t.procedure.use(hasRole('SUPER_ADMIN'))
/**
* Jury procedure - requires JURY_MEMBER role
*/
export const juryProcedure = t.procedure.use(hasRole('JURY_MEMBER'))
/**
* Mentor procedure - requires MENTOR role (or admin)
*/
export const mentorProcedure = t.procedure.use(
hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'MENTOR')
)
/**
* Observer procedure - requires OBSERVER role (read-only access)
*/
export const observerProcedure = t.procedure.use(
hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER')
)
/**
* Protected procedure with audit logging
*/
export const auditedProcedure = t.procedure
.use(isAuthenticated)
.use(withAuditLog)