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:
24
src/server/context.ts
Normal file
24
src/server/context.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { inferAsyncReturnType } from '@trpc/server'
|
||||
import type { FetchCreateContextFnOptions } from '@trpc/server/adapters/fetch'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
/**
|
||||
* Create tRPC context for each request
|
||||
*/
|
||||
export async function createContext(opts?: FetchCreateContextFnOptions) {
|
||||
const session = await auth()
|
||||
|
||||
// Extract IP and user agent from headers
|
||||
const ip = opts?.req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown'
|
||||
const userAgent = opts?.req.headers.get('user-agent') ?? 'unknown'
|
||||
|
||||
return {
|
||||
session,
|
||||
prisma,
|
||||
ip,
|
||||
userAgent,
|
||||
}
|
||||
}
|
||||
|
||||
export type Context = inferAsyncReturnType<typeof createContext>
|
||||
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.' }
|
||||
}),
|
||||
})
|
||||
421
src/server/services/ai-assignment.ts
Normal file
421
src/server/services/ai-assignment.ts
Normal file
@@ -0,0 +1,421 @@
|
||||
/**
|
||||
* AI-Powered Assignment Service
|
||||
*
|
||||
* Uses GPT to analyze juror expertise and project requirements
|
||||
* to generate optimal assignment suggestions.
|
||||
*/
|
||||
|
||||
import { getOpenAI, AI_MODELS } from '@/lib/openai'
|
||||
import {
|
||||
anonymizeForAI,
|
||||
deanonymizeResults,
|
||||
validateAnonymization,
|
||||
type AnonymizationResult,
|
||||
} from './anonymization'
|
||||
|
||||
// Types for AI assignment
|
||||
export interface AIAssignmentSuggestion {
|
||||
jurorId: string
|
||||
projectId: string
|
||||
confidenceScore: number // 0-1
|
||||
reasoning: string
|
||||
expertiseMatchScore: number // 0-1
|
||||
}
|
||||
|
||||
export interface AIAssignmentResult {
|
||||
success: boolean
|
||||
suggestions: AIAssignmentSuggestion[]
|
||||
error?: string
|
||||
tokensUsed?: number
|
||||
fallbackUsed?: boolean
|
||||
}
|
||||
|
||||
interface JurorForAssignment {
|
||||
id: string
|
||||
name?: string | null
|
||||
email: string
|
||||
expertiseTags: string[]
|
||||
maxAssignments?: number | null
|
||||
_count?: {
|
||||
assignments: number
|
||||
}
|
||||
}
|
||||
|
||||
interface ProjectForAssignment {
|
||||
id: string
|
||||
title: string
|
||||
description?: string | null
|
||||
tags: string[]
|
||||
teamName?: string | null
|
||||
_count?: {
|
||||
assignments: number
|
||||
}
|
||||
}
|
||||
|
||||
interface AssignmentConstraints {
|
||||
requiredReviewsPerProject: number
|
||||
maxAssignmentsPerJuror?: number
|
||||
existingAssignments: Array<{
|
||||
jurorId: string
|
||||
projectId: string
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* System prompt for AI assignment
|
||||
*/
|
||||
const ASSIGNMENT_SYSTEM_PROMPT = `You are an expert at matching jury members to projects based on expertise alignment.
|
||||
|
||||
Your task is to suggest optimal juror-project assignments that:
|
||||
1. Match juror expertise tags with project tags and content
|
||||
2. Distribute workload fairly among jurors
|
||||
3. Ensure each project gets the required number of reviews
|
||||
4. Avoid assigning jurors who are already at their limit
|
||||
|
||||
For each suggestion, provide:
|
||||
- A confidence score (0-1) based on how well the juror's expertise matches the project
|
||||
- An expertise match score (0-1) based purely on tag/content alignment
|
||||
- A brief reasoning explaining why this is a good match
|
||||
|
||||
Return your response as a JSON array of assignments.`
|
||||
|
||||
/**
|
||||
* Generate AI-powered assignment suggestions
|
||||
*/
|
||||
export async function generateAIAssignments(
|
||||
jurors: JurorForAssignment[],
|
||||
projects: ProjectForAssignment[],
|
||||
constraints: AssignmentConstraints
|
||||
): Promise<AIAssignmentResult> {
|
||||
// Anonymize data before sending to AI
|
||||
const anonymizedData = anonymizeForAI(jurors, projects)
|
||||
|
||||
// Validate anonymization
|
||||
if (!validateAnonymization(anonymizedData)) {
|
||||
console.error('Anonymization validation failed, falling back to algorithm')
|
||||
return generateFallbackAssignments(jurors, projects, constraints)
|
||||
}
|
||||
|
||||
try {
|
||||
const openai = await getOpenAI()
|
||||
|
||||
if (!openai) {
|
||||
console.log('OpenAI not configured, using fallback algorithm')
|
||||
return generateFallbackAssignments(jurors, projects, constraints)
|
||||
}
|
||||
|
||||
const suggestions = await callAIForAssignments(
|
||||
openai,
|
||||
anonymizedData,
|
||||
constraints
|
||||
)
|
||||
|
||||
// De-anonymize results
|
||||
const deanonymizedSuggestions = deanonymizeResults(
|
||||
suggestions.map((s) => ({
|
||||
...s,
|
||||
jurorId: s.jurorId,
|
||||
projectId: s.projectId,
|
||||
})),
|
||||
anonymizedData.jurorMappings,
|
||||
anonymizedData.projectMappings
|
||||
).map((s) => ({
|
||||
jurorId: s.realJurorId,
|
||||
projectId: s.realProjectId,
|
||||
confidenceScore: s.confidenceScore,
|
||||
reasoning: s.reasoning,
|
||||
expertiseMatchScore: s.expertiseMatchScore,
|
||||
}))
|
||||
|
||||
return {
|
||||
success: true,
|
||||
suggestions: deanonymizedSuggestions,
|
||||
fallbackUsed: false,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('AI assignment failed, using fallback:', error)
|
||||
return generateFallbackAssignments(jurors, projects, constraints)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call OpenAI API for assignment suggestions
|
||||
*/
|
||||
async function callAIForAssignments(
|
||||
openai: Awaited<ReturnType<typeof getOpenAI>>,
|
||||
anonymizedData: AnonymizationResult,
|
||||
constraints: AssignmentConstraints
|
||||
): Promise<AIAssignmentSuggestion[]> {
|
||||
if (!openai) {
|
||||
throw new Error('OpenAI client not available')
|
||||
}
|
||||
|
||||
// Build the user prompt
|
||||
const userPrompt = buildAssignmentPrompt(anonymizedData, constraints)
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: AI_MODELS.ASSIGNMENT,
|
||||
messages: [
|
||||
{ role: 'system', content: ASSIGNMENT_SYSTEM_PROMPT },
|
||||
{ role: 'user', content: userPrompt },
|
||||
],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0.3, // Lower temperature for more consistent results
|
||||
max_tokens: 4000,
|
||||
})
|
||||
|
||||
const content = response.choices[0]?.message?.content
|
||||
|
||||
if (!content) {
|
||||
throw new Error('No response from AI')
|
||||
}
|
||||
|
||||
// Parse the response
|
||||
const parsed = JSON.parse(content) as {
|
||||
assignments: Array<{
|
||||
juror_id: string
|
||||
project_id: string
|
||||
confidence_score: number
|
||||
expertise_match_score: number
|
||||
reasoning: string
|
||||
}>
|
||||
}
|
||||
|
||||
return (parsed.assignments || []).map((a) => ({
|
||||
jurorId: a.juror_id,
|
||||
projectId: a.project_id,
|
||||
confidenceScore: Math.min(1, Math.max(0, a.confidence_score)),
|
||||
expertiseMatchScore: Math.min(1, Math.max(0, a.expertise_match_score)),
|
||||
reasoning: a.reasoning,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the prompt for AI assignment
|
||||
*/
|
||||
function buildAssignmentPrompt(
|
||||
data: AnonymizationResult,
|
||||
constraints: AssignmentConstraints
|
||||
): string {
|
||||
const { jurors, projects } = data
|
||||
|
||||
// Map existing assignments to anonymous IDs
|
||||
const jurorIdMap = new Map(
|
||||
data.jurorMappings.map((m) => [m.realId, m.anonymousId])
|
||||
)
|
||||
const projectIdMap = new Map(
|
||||
data.projectMappings.map((m) => [m.realId, m.anonymousId])
|
||||
)
|
||||
|
||||
const anonymousExisting = constraints.existingAssignments
|
||||
.map((a) => ({
|
||||
jurorId: jurorIdMap.get(a.jurorId),
|
||||
projectId: projectIdMap.get(a.projectId),
|
||||
}))
|
||||
.filter((a) => a.jurorId && a.projectId)
|
||||
|
||||
return `## Jurors Available
|
||||
${JSON.stringify(jurors, null, 2)}
|
||||
|
||||
## Projects to Assign
|
||||
${JSON.stringify(projects, null, 2)}
|
||||
|
||||
## Constraints
|
||||
- Each project needs ${constraints.requiredReviewsPerProject} reviews
|
||||
- Maximum assignments per juror: ${constraints.maxAssignmentsPerJuror || 'No limit'}
|
||||
- Existing assignments to avoid duplicating:
|
||||
${JSON.stringify(anonymousExisting, null, 2)}
|
||||
|
||||
## Instructions
|
||||
Generate optimal juror-project assignments. Return a JSON object with an "assignments" array where each assignment has:
|
||||
- juror_id: The anonymous juror ID
|
||||
- project_id: The anonymous project ID
|
||||
- confidence_score: 0-1 confidence in this match
|
||||
- expertise_match_score: 0-1 expertise alignment score
|
||||
- reasoning: Brief explanation (1-2 sentences)
|
||||
|
||||
Focus on matching expertise tags with project tags and descriptions. Distribute assignments fairly.`
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback algorithm-based assignment when AI is unavailable
|
||||
*/
|
||||
export function generateFallbackAssignments(
|
||||
jurors: JurorForAssignment[],
|
||||
projects: ProjectForAssignment[],
|
||||
constraints: AssignmentConstraints
|
||||
): AIAssignmentResult {
|
||||
const suggestions: AIAssignmentSuggestion[] = []
|
||||
const existingSet = new Set(
|
||||
constraints.existingAssignments.map((a) => `${a.jurorId}:${a.projectId}`)
|
||||
)
|
||||
|
||||
// Track assignments per juror and project
|
||||
const jurorAssignments = new Map<string, number>()
|
||||
const projectAssignments = new Map<string, number>()
|
||||
|
||||
// Initialize counts from existing assignments
|
||||
for (const assignment of constraints.existingAssignments) {
|
||||
jurorAssignments.set(
|
||||
assignment.jurorId,
|
||||
(jurorAssignments.get(assignment.jurorId) || 0) + 1
|
||||
)
|
||||
projectAssignments.set(
|
||||
assignment.projectId,
|
||||
(projectAssignments.get(assignment.projectId) || 0) + 1
|
||||
)
|
||||
}
|
||||
|
||||
// Also include current assignment counts
|
||||
for (const juror of jurors) {
|
||||
const current = juror._count?.assignments || 0
|
||||
jurorAssignments.set(
|
||||
juror.id,
|
||||
Math.max(jurorAssignments.get(juror.id) || 0, current)
|
||||
)
|
||||
}
|
||||
|
||||
for (const project of projects) {
|
||||
const current = project._count?.assignments || 0
|
||||
projectAssignments.set(
|
||||
project.id,
|
||||
Math.max(projectAssignments.get(project.id) || 0, current)
|
||||
)
|
||||
}
|
||||
|
||||
// Sort projects by need (fewest assignments first)
|
||||
const sortedProjects = [...projects].sort((a, b) => {
|
||||
const aCount = projectAssignments.get(a.id) || 0
|
||||
const bCount = projectAssignments.get(b.id) || 0
|
||||
return aCount - bCount
|
||||
})
|
||||
|
||||
// For each project, find best matching jurors
|
||||
for (const project of sortedProjects) {
|
||||
const currentProjectAssignments = projectAssignments.get(project.id) || 0
|
||||
const neededReviews = Math.max(
|
||||
0,
|
||||
constraints.requiredReviewsPerProject - currentProjectAssignments
|
||||
)
|
||||
|
||||
if (neededReviews === 0) continue
|
||||
|
||||
// Score all available jurors
|
||||
const scoredJurors = jurors
|
||||
.filter((juror) => {
|
||||
// Check not already assigned
|
||||
if (existingSet.has(`${juror.id}:${project.id}`)) return false
|
||||
|
||||
// Check not at limit
|
||||
const currentAssignments = jurorAssignments.get(juror.id) || 0
|
||||
const maxAssignments =
|
||||
juror.maxAssignments ?? constraints.maxAssignmentsPerJuror ?? Infinity
|
||||
if (currentAssignments >= maxAssignments) return false
|
||||
|
||||
return true
|
||||
})
|
||||
.map((juror) => ({
|
||||
juror,
|
||||
score: calculateExpertiseScore(juror.expertiseTags, project.tags),
|
||||
loadScore: calculateLoadScore(
|
||||
jurorAssignments.get(juror.id) || 0,
|
||||
juror.maxAssignments ?? constraints.maxAssignmentsPerJuror ?? 10
|
||||
),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
// Combined score: 60% expertise, 40% load balancing
|
||||
const aTotal = a.score * 0.6 + a.loadScore * 0.4
|
||||
const bTotal = b.score * 0.6 + b.loadScore * 0.4
|
||||
return bTotal - aTotal
|
||||
})
|
||||
|
||||
// Assign top jurors
|
||||
for (let i = 0; i < Math.min(neededReviews, scoredJurors.length); i++) {
|
||||
const { juror, score } = scoredJurors[i]
|
||||
|
||||
suggestions.push({
|
||||
jurorId: juror.id,
|
||||
projectId: project.id,
|
||||
confidenceScore: score,
|
||||
expertiseMatchScore: score,
|
||||
reasoning: generateFallbackReasoning(
|
||||
juror.expertiseTags,
|
||||
project.tags,
|
||||
score
|
||||
),
|
||||
})
|
||||
|
||||
// Update tracking
|
||||
existingSet.add(`${juror.id}:${project.id}`)
|
||||
jurorAssignments.set(juror.id, (jurorAssignments.get(juror.id) || 0) + 1)
|
||||
projectAssignments.set(
|
||||
project.id,
|
||||
(projectAssignments.get(project.id) || 0) + 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
suggestions,
|
||||
fallbackUsed: true,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate expertise match score based on tag overlap
|
||||
*/
|
||||
function calculateExpertiseScore(
|
||||
jurorTags: string[],
|
||||
projectTags: string[]
|
||||
): number {
|
||||
if (jurorTags.length === 0 || projectTags.length === 0) {
|
||||
return 0.5 // Neutral score if no tags
|
||||
}
|
||||
|
||||
const jurorTagsLower = new Set(jurorTags.map((t) => t.toLowerCase()))
|
||||
const matchingTags = projectTags.filter((t) =>
|
||||
jurorTagsLower.has(t.toLowerCase())
|
||||
)
|
||||
|
||||
// Score based on percentage of project tags matched
|
||||
const matchRatio = matchingTags.length / projectTags.length
|
||||
|
||||
// Boost for having expertise, even if not all match
|
||||
const hasExpertise = matchingTags.length > 0 ? 0.2 : 0
|
||||
|
||||
return Math.min(1, matchRatio * 0.8 + hasExpertise)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate load balancing score (higher score = less loaded)
|
||||
*/
|
||||
function calculateLoadScore(currentLoad: number, maxLoad: number): number {
|
||||
if (maxLoad === 0) return 0
|
||||
const utilization = currentLoad / maxLoad
|
||||
return Math.max(0, 1 - utilization)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate reasoning for fallback assignments
|
||||
*/
|
||||
function generateFallbackReasoning(
|
||||
jurorTags: string[],
|
||||
projectTags: string[],
|
||||
score: number
|
||||
): string {
|
||||
const jurorTagsLower = new Set(jurorTags.map((t) => t.toLowerCase()))
|
||||
const matchingTags = projectTags.filter((t) =>
|
||||
jurorTagsLower.has(t.toLowerCase())
|
||||
)
|
||||
|
||||
if (matchingTags.length > 0) {
|
||||
return `Expertise match: ${matchingTags.join(', ')}. Match score: ${(score * 100).toFixed(0)}%.`
|
||||
}
|
||||
|
||||
if (score >= 0.5) {
|
||||
return `Assigned for workload balance. No direct expertise match but available capacity.`
|
||||
}
|
||||
|
||||
return `Assigned to ensure project coverage.`
|
||||
}
|
||||
211
src/server/services/anonymization.ts
Normal file
211
src/server/services/anonymization.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Data Anonymization Service
|
||||
*
|
||||
* Strips PII (names, emails, etc.) from data before sending to AI services.
|
||||
* Returns ID mappings for de-anonymization of results.
|
||||
*/
|
||||
|
||||
export interface AnonymizedJuror {
|
||||
anonymousId: string
|
||||
expertiseTags: string[]
|
||||
currentAssignmentCount: number
|
||||
maxAssignments: number | null
|
||||
}
|
||||
|
||||
export interface AnonymizedProject {
|
||||
anonymousId: string
|
||||
title: string
|
||||
description: string | null
|
||||
tags: string[]
|
||||
teamName: string | null
|
||||
}
|
||||
|
||||
export interface JurorMapping {
|
||||
anonymousId: string
|
||||
realId: string
|
||||
}
|
||||
|
||||
export interface ProjectMapping {
|
||||
anonymousId: string
|
||||
realId: string
|
||||
}
|
||||
|
||||
export interface AnonymizationResult {
|
||||
jurors: AnonymizedJuror[]
|
||||
projects: AnonymizedProject[]
|
||||
jurorMappings: JurorMapping[]
|
||||
projectMappings: ProjectMapping[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Juror data from database
|
||||
*/
|
||||
interface JurorInput {
|
||||
id: string
|
||||
name?: string | null
|
||||
email: string
|
||||
expertiseTags: string[]
|
||||
maxAssignments?: number | null
|
||||
_count?: {
|
||||
assignments: number
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Project data from database
|
||||
*/
|
||||
interface ProjectInput {
|
||||
id: string
|
||||
title: string
|
||||
description?: string | null
|
||||
tags: string[]
|
||||
teamName?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Anonymize juror and project data for AI processing
|
||||
*
|
||||
* This function:
|
||||
* 1. Strips all PII (names, emails) from juror data
|
||||
* 2. Replaces real IDs with sequential anonymous IDs
|
||||
* 3. Keeps only expertise tags and assignment counts
|
||||
* 4. Returns mappings for de-anonymization
|
||||
*/
|
||||
export function anonymizeForAI(
|
||||
jurors: JurorInput[],
|
||||
projects: ProjectInput[]
|
||||
): AnonymizationResult {
|
||||
const jurorMappings: JurorMapping[] = []
|
||||
const projectMappings: ProjectMapping[] = []
|
||||
|
||||
// Anonymize jurors
|
||||
const anonymizedJurors: AnonymizedJuror[] = jurors.map((juror, index) => {
|
||||
const anonymousId = `juror_${(index + 1).toString().padStart(3, '0')}`
|
||||
|
||||
jurorMappings.push({
|
||||
anonymousId,
|
||||
realId: juror.id,
|
||||
})
|
||||
|
||||
return {
|
||||
anonymousId,
|
||||
expertiseTags: juror.expertiseTags,
|
||||
currentAssignmentCount: juror._count?.assignments ?? 0,
|
||||
maxAssignments: juror.maxAssignments ?? null,
|
||||
}
|
||||
})
|
||||
|
||||
// Anonymize projects (keep content but replace IDs)
|
||||
const anonymizedProjects: AnonymizedProject[] = projects.map(
|
||||
(project, index) => {
|
||||
const anonymousId = `project_${(index + 1).toString().padStart(3, '0')}`
|
||||
|
||||
projectMappings.push({
|
||||
anonymousId,
|
||||
realId: project.id,
|
||||
})
|
||||
|
||||
return {
|
||||
anonymousId,
|
||||
title: sanitizeText(project.title),
|
||||
description: project.description
|
||||
? sanitizeText(project.description)
|
||||
: null,
|
||||
tags: project.tags,
|
||||
// Replace specific team names with generic identifier
|
||||
teamName: project.teamName ? `Team ${index + 1}` : null,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
jurors: anonymizedJurors,
|
||||
projects: anonymizedProjects,
|
||||
jurorMappings,
|
||||
projectMappings,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* De-anonymize AI results back to real IDs
|
||||
*/
|
||||
export function deanonymizeResults<T extends { jurorId: string; projectId: string }>(
|
||||
results: T[],
|
||||
jurorMappings: JurorMapping[],
|
||||
projectMappings: ProjectMapping[]
|
||||
): (T & { realJurorId: string; realProjectId: string })[] {
|
||||
const jurorMap = new Map(
|
||||
jurorMappings.map((m) => [m.anonymousId, m.realId])
|
||||
)
|
||||
const projectMap = new Map(
|
||||
projectMappings.map((m) => [m.anonymousId, m.realId])
|
||||
)
|
||||
|
||||
return results.map((result) => ({
|
||||
...result,
|
||||
realJurorId: jurorMap.get(result.jurorId) || result.jurorId,
|
||||
realProjectId: projectMap.get(result.projectId) || result.projectId,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize text to remove potential PII patterns
|
||||
* Removes emails, phone numbers, and URLs from text
|
||||
*/
|
||||
function sanitizeText(text: string): string {
|
||||
// Remove email addresses
|
||||
let sanitized = text.replace(
|
||||
/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g,
|
||||
'[email removed]'
|
||||
)
|
||||
|
||||
// Remove phone numbers (various formats)
|
||||
sanitized = sanitized.replace(
|
||||
/(\+?\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}/g,
|
||||
'[phone removed]'
|
||||
)
|
||||
|
||||
// Remove URLs
|
||||
sanitized = sanitized.replace(
|
||||
/https?:\/\/[^\s]+/g,
|
||||
'[url removed]'
|
||||
)
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that data has been properly anonymized
|
||||
* Returns true if no PII patterns are detected
|
||||
*/
|
||||
export function validateAnonymization(data: AnonymizationResult): boolean {
|
||||
const piiPatterns = [
|
||||
/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/, // Email
|
||||
/(\+?\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}/, // Phone
|
||||
]
|
||||
|
||||
const checkText = (text: string | null | undefined): boolean => {
|
||||
if (!text) return true
|
||||
return !piiPatterns.some((pattern) => pattern.test(text))
|
||||
}
|
||||
|
||||
// Check jurors (they should only have expertise tags)
|
||||
for (const juror of data.jurors) {
|
||||
// Jurors should not have any text fields that could contain PII
|
||||
// Only check expertiseTags
|
||||
for (const tag of juror.expertiseTags) {
|
||||
if (!checkText(tag)) return false
|
||||
}
|
||||
}
|
||||
|
||||
// Check projects
|
||||
for (const project of data.projects) {
|
||||
if (!checkText(project.title)) return false
|
||||
if (!checkText(project.description)) return false
|
||||
for (const tag of project.tags) {
|
||||
if (!checkText(tag)) return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
332
src/server/services/mentor-matching.ts
Normal file
332
src/server/services/mentor-matching.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
import { PrismaClient, OceanIssue, CompetitionCategory } from '@prisma/client'
|
||||
import OpenAI from 'openai'
|
||||
|
||||
// Lazy initialization to avoid errors when API key is not set
|
||||
let openaiClient: OpenAI | null = null
|
||||
|
||||
function getOpenAIClient(): OpenAI | null {
|
||||
if (!process.env.OPENAI_API_KEY) {
|
||||
return null
|
||||
}
|
||||
if (!openaiClient) {
|
||||
openaiClient = new OpenAI({
|
||||
apiKey: process.env.OPENAI_API_KEY,
|
||||
})
|
||||
}
|
||||
return openaiClient
|
||||
}
|
||||
|
||||
interface ProjectInfo {
|
||||
id: string
|
||||
title: string
|
||||
description: string | null
|
||||
oceanIssue: OceanIssue | null
|
||||
competitionCategory: CompetitionCategory | null
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
interface MentorInfo {
|
||||
id: string
|
||||
name: string | null
|
||||
email: string
|
||||
expertiseTags: string[]
|
||||
currentAssignments: number
|
||||
maxAssignments: number | null
|
||||
}
|
||||
|
||||
interface MentorMatch {
|
||||
mentorId: string
|
||||
confidenceScore: number
|
||||
expertiseMatchScore: number
|
||||
reasoning: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Get AI-suggested mentor matches for a project
|
||||
*/
|
||||
export async function getAIMentorSuggestions(
|
||||
prisma: PrismaClient,
|
||||
projectId: string,
|
||||
limit: number = 5
|
||||
): Promise<MentorMatch[]> {
|
||||
// Get project details
|
||||
const project = await prisma.project.findUniqueOrThrow({
|
||||
where: { id: projectId },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
oceanIssue: true,
|
||||
competitionCategory: true,
|
||||
tags: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Get available mentors (users with expertise tags)
|
||||
// In a full implementation, you'd have a MENTOR role
|
||||
// For now, we use users with expertiseTags and consider them potential mentors
|
||||
const mentors = await prisma.user.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ expertiseTags: { isEmpty: false } },
|
||||
{ role: 'JURY_MEMBER' }, // Jury members can also be mentors
|
||||
],
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
expertiseTags: true,
|
||||
maxAssignments: true,
|
||||
mentorAssignments: {
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Filter mentors who haven't reached max assignments
|
||||
const availableMentors: MentorInfo[] = mentors
|
||||
.filter((m) => {
|
||||
const currentAssignments = m.mentorAssignments.length
|
||||
return !m.maxAssignments || currentAssignments < m.maxAssignments
|
||||
})
|
||||
.map((m) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
email: m.email,
|
||||
expertiseTags: m.expertiseTags,
|
||||
currentAssignments: m.mentorAssignments.length,
|
||||
maxAssignments: m.maxAssignments,
|
||||
}))
|
||||
|
||||
if (availableMentors.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Try AI matching if API key is configured
|
||||
if (process.env.OPENAI_API_KEY) {
|
||||
try {
|
||||
return await getAIMatches(project, availableMentors, limit)
|
||||
} catch (error) {
|
||||
console.error('AI mentor matching failed, falling back to algorithm:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to algorithmic matching
|
||||
return getAlgorithmicMatches(project, availableMentors, limit)
|
||||
}
|
||||
|
||||
/**
|
||||
* Use OpenAI to match mentors to projects
|
||||
*/
|
||||
async function getAIMatches(
|
||||
project: ProjectInfo,
|
||||
mentors: MentorInfo[],
|
||||
limit: number
|
||||
): Promise<MentorMatch[]> {
|
||||
// Anonymize data before sending to AI
|
||||
const anonymizedProject = {
|
||||
description: project.description?.slice(0, 500) || 'No description',
|
||||
category: project.competitionCategory,
|
||||
oceanIssue: project.oceanIssue,
|
||||
tags: project.tags,
|
||||
}
|
||||
|
||||
const anonymizedMentors = mentors.map((m, index) => ({
|
||||
index,
|
||||
expertise: m.expertiseTags,
|
||||
availability: m.maxAssignments
|
||||
? `${m.currentAssignments}/${m.maxAssignments}`
|
||||
: 'unlimited',
|
||||
}))
|
||||
|
||||
const prompt = `You are matching mentors to an ocean protection project.
|
||||
|
||||
PROJECT:
|
||||
- Category: ${anonymizedProject.category || 'Not specified'}
|
||||
- Ocean Issue: ${anonymizedProject.oceanIssue || 'Not specified'}
|
||||
- Tags: ${anonymizedProject.tags.join(', ') || 'None'}
|
||||
- Description: ${anonymizedProject.description}
|
||||
|
||||
AVAILABLE MENTORS:
|
||||
${anonymizedMentors.map((m) => `${m.index}: Expertise: [${m.expertise.join(', ')}], Availability: ${m.availability}`).join('\n')}
|
||||
|
||||
Rank the top ${limit} mentors by suitability. For each, provide:
|
||||
1. Mentor index (0-based)
|
||||
2. Confidence score (0-1)
|
||||
3. Expertise match score (0-1)
|
||||
4. Brief reasoning (1-2 sentences)
|
||||
|
||||
Respond in JSON format:
|
||||
{
|
||||
"matches": [
|
||||
{
|
||||
"mentorIndex": 0,
|
||||
"confidenceScore": 0.85,
|
||||
"expertiseMatchScore": 0.9,
|
||||
"reasoning": "Strong expertise alignment..."
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
const openai = getOpenAIClient()
|
||||
if (!openai) {
|
||||
throw new Error('OpenAI client not available')
|
||||
}
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: 'You are an expert at matching mentors to projects based on expertise alignment. Always respond with valid JSON.',
|
||||
},
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0.3,
|
||||
max_tokens: 1000,
|
||||
})
|
||||
|
||||
const content = response.choices[0]?.message?.content
|
||||
if (!content) {
|
||||
throw new Error('No response from AI')
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(content) as {
|
||||
matches: Array<{
|
||||
mentorIndex: number
|
||||
confidenceScore: number
|
||||
expertiseMatchScore: number
|
||||
reasoning: string
|
||||
}>
|
||||
}
|
||||
|
||||
return parsed.matches
|
||||
.filter((m) => m.mentorIndex >= 0 && m.mentorIndex < mentors.length)
|
||||
.map((m) => ({
|
||||
mentorId: mentors[m.mentorIndex].id,
|
||||
confidenceScore: m.confidenceScore,
|
||||
expertiseMatchScore: m.expertiseMatchScore,
|
||||
reasoning: m.reasoning,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Algorithmic fallback for mentor matching
|
||||
*/
|
||||
function getAlgorithmicMatches(
|
||||
project: ProjectInfo,
|
||||
mentors: MentorInfo[],
|
||||
limit: number
|
||||
): MentorMatch[] {
|
||||
// Build keyword set from project
|
||||
const projectKeywords = new Set<string>()
|
||||
|
||||
if (project.oceanIssue) {
|
||||
projectKeywords.add(project.oceanIssue.toLowerCase().replace(/_/g, ' '))
|
||||
}
|
||||
|
||||
if (project.competitionCategory) {
|
||||
projectKeywords.add(project.competitionCategory.toLowerCase().replace(/_/g, ' '))
|
||||
}
|
||||
|
||||
project.tags.forEach((tag) => {
|
||||
tag.toLowerCase().split(/\s+/).forEach((word) => {
|
||||
if (word.length > 3) projectKeywords.add(word)
|
||||
})
|
||||
})
|
||||
|
||||
if (project.description) {
|
||||
// Extract key words from description
|
||||
const words = project.description.toLowerCase().split(/\s+/)
|
||||
words.forEach((word) => {
|
||||
if (word.length > 4) projectKeywords.add(word.replace(/[^a-z]/g, ''))
|
||||
})
|
||||
}
|
||||
|
||||
// Score each mentor
|
||||
const scored = mentors.map((mentor) => {
|
||||
const mentorKeywords = new Set<string>()
|
||||
mentor.expertiseTags.forEach((tag) => {
|
||||
tag.toLowerCase().split(/\s+/).forEach((word) => {
|
||||
if (word.length > 2) mentorKeywords.add(word)
|
||||
})
|
||||
})
|
||||
|
||||
// Calculate overlap
|
||||
let matchCount = 0
|
||||
projectKeywords.forEach((keyword) => {
|
||||
mentorKeywords.forEach((mentorKeyword) => {
|
||||
if (keyword.includes(mentorKeyword) || mentorKeyword.includes(keyword)) {
|
||||
matchCount++
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const expertiseMatchScore = mentorKeywords.size > 0
|
||||
? Math.min(1, matchCount / mentorKeywords.size)
|
||||
: 0
|
||||
|
||||
// Factor in availability
|
||||
const availabilityScore = mentor.maxAssignments
|
||||
? 1 - (mentor.currentAssignments / mentor.maxAssignments)
|
||||
: 1
|
||||
|
||||
const confidenceScore = (expertiseMatchScore * 0.7 + availabilityScore * 0.3)
|
||||
|
||||
return {
|
||||
mentorId: mentor.id,
|
||||
confidenceScore: Math.round(confidenceScore * 100) / 100,
|
||||
expertiseMatchScore: Math.round(expertiseMatchScore * 100) / 100,
|
||||
reasoning: `Matched ${matchCount} keyword(s) with mentor expertise. Availability: ${availabilityScore > 0.5 ? 'Good' : 'Limited'}.`,
|
||||
}
|
||||
})
|
||||
|
||||
// Sort by confidence and return top matches
|
||||
return scored
|
||||
.sort((a, b) => b.confidenceScore - a.confidenceScore)
|
||||
.slice(0, limit)
|
||||
}
|
||||
|
||||
/**
|
||||
* Round-robin assignment for load balancing
|
||||
*/
|
||||
export async function getRoundRobinMentor(
|
||||
prisma: PrismaClient,
|
||||
excludeMentorIds: string[] = []
|
||||
): Promise<string | null> {
|
||||
const mentors = await prisma.user.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ expertiseTags: { isEmpty: false } },
|
||||
{ role: 'JURY_MEMBER' },
|
||||
],
|
||||
status: 'ACTIVE',
|
||||
id: { notIn: excludeMentorIds },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
maxAssignments: true,
|
||||
mentorAssignments: {
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
mentorAssignments: {
|
||||
_count: 'asc',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Find mentor with fewest assignments who hasn't reached max
|
||||
for (const mentor of mentors) {
|
||||
const currentCount = mentor.mentorAssignments.length
|
||||
if (!mentor.maxAssignments || currentCount < mentor.maxAssignments) {
|
||||
return mentor.id
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
321
src/server/services/notification.ts
Normal file
321
src/server/services/notification.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* Unified Notification Service
|
||||
*
|
||||
* Handles sending notifications via multiple channels:
|
||||
* - Email (via nodemailer)
|
||||
* - WhatsApp (via Meta or Twilio)
|
||||
*/
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import {
|
||||
sendMagicLinkEmail,
|
||||
sendJuryInvitationEmail,
|
||||
sendEvaluationReminderEmail,
|
||||
sendAnnouncementEmail,
|
||||
} from '@/lib/email'
|
||||
import { getWhatsAppProvider, getWhatsAppProviderType } from '@/lib/whatsapp'
|
||||
import type { NotificationChannel } from '@prisma/client'
|
||||
|
||||
export type NotificationType =
|
||||
| 'MAGIC_LINK'
|
||||
| 'JURY_INVITATION'
|
||||
| 'EVALUATION_REMINDER'
|
||||
| 'ANNOUNCEMENT'
|
||||
|
||||
interface NotificationResult {
|
||||
success: boolean
|
||||
channels: {
|
||||
email?: { success: boolean; error?: string }
|
||||
whatsapp?: { success: boolean; messageId?: string; error?: string }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a notification to a user based on their preferences
|
||||
*/
|
||||
export async function sendNotification(
|
||||
userId: string,
|
||||
type: NotificationType,
|
||||
data: Record<string, string>
|
||||
): Promise<NotificationResult> {
|
||||
// Get user with notification preferences
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
phoneNumber: true,
|
||||
notificationPreference: true,
|
||||
whatsappOptIn: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
success: false,
|
||||
channels: {},
|
||||
}
|
||||
}
|
||||
|
||||
const result: NotificationResult = {
|
||||
success: true,
|
||||
channels: {},
|
||||
}
|
||||
|
||||
const preference = user.notificationPreference
|
||||
|
||||
// Determine which channels to use
|
||||
const sendEmail = preference === 'EMAIL' || preference === 'BOTH'
|
||||
const sendWhatsApp =
|
||||
(preference === 'WHATSAPP' || preference === 'BOTH') &&
|
||||
user.whatsappOptIn &&
|
||||
user.phoneNumber
|
||||
|
||||
// Send via email
|
||||
if (sendEmail) {
|
||||
const emailResult = await sendEmailNotification(user.email, user.name, type, data)
|
||||
result.channels.email = emailResult
|
||||
|
||||
// Log the notification
|
||||
await logNotification(user.id, 'EMAIL', 'SMTP', type, emailResult)
|
||||
}
|
||||
|
||||
// Send via WhatsApp
|
||||
if (sendWhatsApp && user.phoneNumber) {
|
||||
const whatsappResult = await sendWhatsAppNotification(
|
||||
user.phoneNumber,
|
||||
user.name,
|
||||
type,
|
||||
data
|
||||
)
|
||||
result.channels.whatsapp = whatsappResult
|
||||
|
||||
// Log the notification
|
||||
const providerType = await getWhatsAppProviderType()
|
||||
await logNotification(
|
||||
user.id,
|
||||
'WHATSAPP',
|
||||
providerType || 'UNKNOWN',
|
||||
type,
|
||||
whatsappResult
|
||||
)
|
||||
}
|
||||
|
||||
// Overall success if at least one channel succeeded
|
||||
result.success =
|
||||
(result.channels.email?.success ?? true) ||
|
||||
(result.channels.whatsapp?.success ?? true)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Send email notification
|
||||
*/
|
||||
async function sendEmailNotification(
|
||||
email: string,
|
||||
name: string | null,
|
||||
type: NotificationType,
|
||||
data: Record<string, string>
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
switch (type) {
|
||||
case 'MAGIC_LINK':
|
||||
await sendMagicLinkEmail(email, data.url)
|
||||
return { success: true }
|
||||
|
||||
case 'JURY_INVITATION':
|
||||
await sendJuryInvitationEmail(
|
||||
email,
|
||||
data.inviteUrl,
|
||||
data.programName,
|
||||
data.roundName
|
||||
)
|
||||
return { success: true }
|
||||
|
||||
case 'EVALUATION_REMINDER':
|
||||
await sendEvaluationReminderEmail(
|
||||
email,
|
||||
name,
|
||||
parseInt(data.pendingCount || '0'),
|
||||
data.roundName || 'Current Round',
|
||||
data.deadline || 'Soon',
|
||||
data.assignmentsUrl || `${process.env.NEXTAUTH_URL}/jury/assignments`
|
||||
)
|
||||
return { success: true }
|
||||
|
||||
case 'ANNOUNCEMENT':
|
||||
await sendAnnouncementEmail(
|
||||
email,
|
||||
name,
|
||||
data.title || 'Announcement',
|
||||
data.message || '',
|
||||
data.ctaText,
|
||||
data.ctaUrl
|
||||
)
|
||||
return { success: true }
|
||||
|
||||
default:
|
||||
return { success: false, error: `Unknown notification type: ${type}` }
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Email send failed',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send WhatsApp notification
|
||||
*/
|
||||
async function sendWhatsAppNotification(
|
||||
phoneNumber: string,
|
||||
name: string | null,
|
||||
type: NotificationType,
|
||||
data: Record<string, string>
|
||||
): Promise<{ success: boolean; messageId?: string; error?: string }> {
|
||||
const provider = await getWhatsAppProvider()
|
||||
|
||||
if (!provider) {
|
||||
return { success: false, error: 'WhatsApp not configured' }
|
||||
}
|
||||
|
||||
try {
|
||||
// Map notification types to templates
|
||||
const templateMap: Record<NotificationType, string> = {
|
||||
MAGIC_LINK: 'mopc_magic_link',
|
||||
JURY_INVITATION: 'mopc_jury_invitation',
|
||||
EVALUATION_REMINDER: 'mopc_evaluation_reminder',
|
||||
ANNOUNCEMENT: 'mopc_announcement',
|
||||
}
|
||||
|
||||
const template = templateMap[type]
|
||||
|
||||
// Build template params
|
||||
const params: Record<string, string> = {
|
||||
name: name || 'User',
|
||||
...data,
|
||||
}
|
||||
|
||||
const result = await provider.sendTemplate(phoneNumber, template, params)
|
||||
return result
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'WhatsApp send failed',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log notification to database
|
||||
*/
|
||||
async function logNotification(
|
||||
userId: string,
|
||||
channel: NotificationChannel,
|
||||
provider: string,
|
||||
type: NotificationType,
|
||||
result: { success: boolean; messageId?: string; error?: string }
|
||||
): Promise<void> {
|
||||
try {
|
||||
await prisma.notificationLog.create({
|
||||
data: {
|
||||
userId,
|
||||
channel,
|
||||
provider,
|
||||
type,
|
||||
status: result.success ? 'SENT' : 'FAILED',
|
||||
externalId: result.messageId,
|
||||
errorMsg: result.error,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to log notification:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send bulk notifications to multiple users
|
||||
*/
|
||||
export async function sendBulkNotification(
|
||||
userIds: string[],
|
||||
type: NotificationType,
|
||||
data: Record<string, string>
|
||||
): Promise<{ sent: number; failed: number }> {
|
||||
let sent = 0
|
||||
let failed = 0
|
||||
|
||||
for (const userId of userIds) {
|
||||
const result = await sendNotification(userId, type, data)
|
||||
if (result.success) {
|
||||
sent++
|
||||
} else {
|
||||
failed++
|
||||
}
|
||||
}
|
||||
|
||||
return { sent, failed }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get notification statistics
|
||||
*/
|
||||
export async function getNotificationStats(options?: {
|
||||
userId?: string
|
||||
startDate?: Date
|
||||
endDate?: Date
|
||||
}): Promise<{
|
||||
total: number
|
||||
byChannel: Record<string, number>
|
||||
byStatus: Record<string, number>
|
||||
byType: Record<string, number>
|
||||
}> {
|
||||
const where: Record<string, unknown> = {}
|
||||
|
||||
if (options?.userId) {
|
||||
where.userId = options.userId
|
||||
}
|
||||
if (options?.startDate || options?.endDate) {
|
||||
where.createdAt = {}
|
||||
if (options.startDate) {
|
||||
(where.createdAt as Record<string, Date>).gte = options.startDate
|
||||
}
|
||||
if (options.endDate) {
|
||||
(where.createdAt as Record<string, Date>).lte = options.endDate
|
||||
}
|
||||
}
|
||||
|
||||
const [total, byChannel, byStatus, byType] = await Promise.all([
|
||||
prisma.notificationLog.count({ where }),
|
||||
prisma.notificationLog.groupBy({
|
||||
by: ['channel'],
|
||||
where,
|
||||
_count: true,
|
||||
}),
|
||||
prisma.notificationLog.groupBy({
|
||||
by: ['status'],
|
||||
where,
|
||||
_count: true,
|
||||
}),
|
||||
prisma.notificationLog.groupBy({
|
||||
by: ['type'],
|
||||
where,
|
||||
_count: true,
|
||||
}),
|
||||
])
|
||||
|
||||
return {
|
||||
total,
|
||||
byChannel: Object.fromEntries(
|
||||
byChannel.map((r) => [r.channel, r._count])
|
||||
),
|
||||
byStatus: Object.fromEntries(
|
||||
byStatus.map((r) => [r.status, r._count])
|
||||
),
|
||||
byType: Object.fromEntries(
|
||||
byType.map((r) => [r.type, r._count])
|
||||
),
|
||||
}
|
||||
}
|
||||
147
src/server/trpc.ts
Normal file
147
src/server/trpc.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { initTRPC, TRPCError } from '@trpc/server'
|
||||
import superjson from 'superjson'
|
||||
import { ZodError } from 'zod'
|
||||
import type { Context } from './context'
|
||||
import type { UserRole } from '@prisma/client'
|
||||
|
||||
/**
|
||||
* Initialize tRPC with context type and configuration
|
||||
*/
|
||||
const t = initTRPC.context<Context>().create({
|
||||
transformer: superjson,
|
||||
errorFormatter({ shape, error }) {
|
||||
return {
|
||||
...shape,
|
||||
data: {
|
||||
...shape.data,
|
||||
zodError:
|
||||
error.cause instanceof ZodError ? error.cause.flatten() : null,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Export reusable router and procedure helpers
|
||||
*/
|
||||
export const router = t.router
|
||||
export const publicProcedure = t.procedure
|
||||
export const middleware = t.middleware
|
||||
export const createCallerFactory = t.createCallerFactory
|
||||
|
||||
// =============================================================================
|
||||
// Middleware
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Middleware to require authenticated user
|
||||
*/
|
||||
const isAuthenticated = middleware(async ({ ctx, next }) => {
|
||||
if (!ctx.session?.user) {
|
||||
throw new TRPCError({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'You must be logged in to perform this action',
|
||||
})
|
||||
}
|
||||
|
||||
return next({
|
||||
ctx: {
|
||||
...ctx,
|
||||
user: ctx.session.user,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Middleware to require specific role(s)
|
||||
*/
|
||||
const hasRole = (...roles: UserRole[]) =>
|
||||
middleware(async ({ ctx, next }) => {
|
||||
if (!ctx.session?.user) {
|
||||
throw new TRPCError({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'You must be logged in to perform this action',
|
||||
})
|
||||
}
|
||||
|
||||
if (!roles.includes(ctx.session.user.role)) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'You do not have permission to perform this action',
|
||||
})
|
||||
}
|
||||
|
||||
return next({
|
||||
ctx: {
|
||||
...ctx,
|
||||
user: ctx.session.user,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Middleware for audit logging
|
||||
*/
|
||||
const withAuditLog = middleware(async ({ ctx, next, path }) => {
|
||||
const result = await next()
|
||||
|
||||
// Log successful mutations
|
||||
if (result.ok && path.includes('.')) {
|
||||
const [, action] = path.split('.')
|
||||
const mutationActions = ['create', 'update', 'delete', 'import', 'submit', 'grant', 'revoke']
|
||||
|
||||
if (mutationActions.some((a) => action?.toLowerCase().includes(a))) {
|
||||
// Audit logging would happen here
|
||||
// We'll implement this in the audit service
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Procedure Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Protected procedure - requires authenticated user
|
||||
*/
|
||||
export const protectedProcedure = t.procedure.use(isAuthenticated)
|
||||
|
||||
/**
|
||||
* Admin procedure - requires SUPER_ADMIN or PROGRAM_ADMIN role
|
||||
*/
|
||||
export const adminProcedure = t.procedure.use(
|
||||
hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN')
|
||||
)
|
||||
|
||||
/**
|
||||
* Super admin procedure - requires SUPER_ADMIN role
|
||||
*/
|
||||
export const superAdminProcedure = t.procedure.use(hasRole('SUPER_ADMIN'))
|
||||
|
||||
/**
|
||||
* Jury procedure - requires JURY_MEMBER role
|
||||
*/
|
||||
export const juryProcedure = t.procedure.use(hasRole('JURY_MEMBER'))
|
||||
|
||||
/**
|
||||
* Mentor procedure - requires MENTOR role (or admin)
|
||||
*/
|
||||
export const mentorProcedure = t.procedure.use(
|
||||
hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'MENTOR')
|
||||
)
|
||||
|
||||
/**
|
||||
* Observer procedure - requires OBSERVER role (read-only access)
|
||||
*/
|
||||
export const observerProcedure = t.procedure.use(
|
||||
hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER')
|
||||
)
|
||||
|
||||
/**
|
||||
* Protected procedure with audit logging
|
||||
*/
|
||||
export const auditedProcedure = t.procedure
|
||||
.use(isAuthenticated)
|
||||
.use(withAuditLog)
|
||||
Reference in New Issue
Block a user