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:
65
src/server/routers/_app.ts
Normal file
65
src/server/routers/_app.ts
Normal 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
|
||||
365
src/server/routers/analytics.ts
Normal file
365
src/server/routers/analytics.ts
Normal 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,
|
||||
}))
|
||||
}),
|
||||
})
|
||||
701
src/server/routers/applicant.ts
Normal file
701
src/server/routers/applicant.ts
Normal 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 }
|
||||
}),
|
||||
})
|
||||
335
src/server/routers/application.ts
Normal file
335
src/server/routers/application.ts
Normal 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,
|
||||
}
|
||||
}),
|
||||
})
|
||||
777
src/server/routers/applicationForm.ts
Normal file
777
src/server/routers/applicationForm.ts
Normal 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),
|
||||
}
|
||||
}),
|
||||
})
|
||||
628
src/server/routers/assignment.ts
Normal file
628
src/server/routers/assignment.ts
Normal 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
184
src/server/routers/audit.ts
Normal 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,
|
||||
})),
|
||||
}
|
||||
}),
|
||||
})
|
||||
187
src/server/routers/avatar.ts
Normal file
187
src/server/routers/avatar.ts
Normal 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 }
|
||||
}),
|
||||
})
|
||||
322
src/server/routers/evaluation.ts
Normal file
322
src/server/routers/evaluation.ts
Normal 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' },
|
||||
})
|
||||
}),
|
||||
})
|
||||
295
src/server/routers/export.ts
Normal file
295
src/server/routers/export.ts
Normal 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
194
src/server/routers/file.ts
Normal 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' }],
|
||||
})
|
||||
}),
|
||||
})
|
||||
208
src/server/routers/gracePeriod.ts
Normal file
208
src/server/routers/gracePeriod.ts
Normal 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 }
|
||||
}),
|
||||
})
|
||||
479
src/server/routers/learningResource.ts
Normal file
479
src/server/routers/learningResource.ts
Normal 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 }
|
||||
}),
|
||||
})
|
||||
403
src/server/routers/live-voting.ts
Normal file
403
src/server/routers/live-voting.ts
Normal 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
196
src/server/routers/logo.ts
Normal 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 }
|
||||
}),
|
||||
})
|
||||
573
src/server/routers/mentor.ts
Normal file
573
src/server/routers/mentor.ts
Normal 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),
|
||||
}
|
||||
}),
|
||||
})
|
||||
230
src/server/routers/notion-import.ts
Normal file
230
src/server/routers/notion-import.ts
Normal 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
|
||||
}
|
||||
355
src/server/routers/partner.ts
Normal file
355
src/server/routers/partner.ts
Normal 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 }
|
||||
}),
|
||||
})
|
||||
145
src/server/routers/program.ts
Normal file
145
src/server/routers/program.ts
Normal 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
|
||||
}),
|
||||
})
|
||||
349
src/server/routers/project.ts
Normal file
349
src/server/routers/project.ts
Normal 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
358
src/server/routers/round.ts
Normal 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
|
||||
}),
|
||||
})
|
||||
376
src/server/routers/settings.ts
Normal file
376
src/server/routers/settings.ts
Normal 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
396
src/server/routers/tag.ts
Normal 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 }
|
||||
}),
|
||||
})
|
||||
248
src/server/routers/typeform-import.ts
Normal file
248
src/server/routers/typeform-import.ts
Normal 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
759
src/server/routers/user.ts
Normal 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.' }
|
||||
}),
|
||||
})
|
||||
Reference in New Issue
Block a user