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

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.' }
}),
})