Remove dynamic form builder and complete RoundProject→roundId migration
Major cleanup and schema migration: - Remove unused dynamic form builder system (ApplicationForm, ApplicationFormField, etc.) - Complete migration from RoundProject junction table to direct Project.roundId - Add sortOrder and entryNotificationType fields to Round model - Add country field to User model for mentor matching - Enhance onboarding with profile photo and country selection steps - Fix all TypeScript errors related to roundProjects references - Remove unused libraries (@radix-ui/react-toast, embla-carousel-react, vaul) Files removed: - admin/forms/* pages and related components - admin/onboarding/* pages - applicationForm.ts and onboarding.ts routers - Dynamic form builder Prisma models and enums Schema changes: - Removed ApplicationForm, ApplicationFormField, OnboardingStep, ApplicationFormSubmission, SubmissionFile models - Removed FormFieldType and SpecialFieldType enums - Added Round.sortOrder, Round.entryNotificationType - Added User.country Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,8 +15,6 @@ import { learningResourceRouter } from './learningResource'
|
||||
import { partnerRouter } from './partner'
|
||||
import { notionImportRouter } from './notion-import'
|
||||
import { typeformImportRouter } from './typeform-import'
|
||||
import { applicationFormRouter } from './applicationForm'
|
||||
import { onboardingRouter } from './onboarding'
|
||||
// Phase 2B routers
|
||||
import { tagRouter } from './tag'
|
||||
import { applicantRouter } from './applicant'
|
||||
@@ -52,8 +50,6 @@ export const appRouter = router({
|
||||
partner: partnerRouter,
|
||||
notionImport: notionImportRouter,
|
||||
typeformImport: typeformImportRouter,
|
||||
applicationForm: applicationFormRouter,
|
||||
onboarding: onboardingRouter,
|
||||
// Phase 2B routers
|
||||
tag: tagRouter,
|
||||
applicant: applicantRouter,
|
||||
|
||||
@@ -148,17 +148,13 @@ export const analyticsRouter = router({
|
||||
getProjectRankings: adminProcedure
|
||||
.input(z.object({ roundId: z.string(), limit: z.number().optional() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const roundProjects = await ctx.prisma.roundProject.findMany({
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: { roundId: input.roundId },
|
||||
include: {
|
||||
project: {
|
||||
assignments: {
|
||||
include: {
|
||||
assignments: {
|
||||
include: {
|
||||
evaluation: {
|
||||
select: { criterionScoresJson: true, status: true },
|
||||
},
|
||||
},
|
||||
evaluation: {
|
||||
select: { criterionScoresJson: true, status: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -166,9 +162,8 @@ export const analyticsRouter = router({
|
||||
})
|
||||
|
||||
// Calculate average scores
|
||||
const rankings = roundProjects
|
||||
.map((rp) => {
|
||||
const project = rp.project
|
||||
const rankings = projects
|
||||
.map((project) => {
|
||||
const allScores: number[] = []
|
||||
|
||||
project.assignments.forEach((assignment) => {
|
||||
@@ -200,7 +195,7 @@ export const analyticsRouter = router({
|
||||
id: project.id,
|
||||
title: project.title,
|
||||
teamName: project.teamName,
|
||||
status: rp.status,
|
||||
status: project.status,
|
||||
averageScore,
|
||||
evaluationCount: allScores.length,
|
||||
}
|
||||
@@ -217,15 +212,15 @@ export const analyticsRouter = router({
|
||||
getStatusBreakdown: adminProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const roundProjects = await ctx.prisma.roundProject.groupBy({
|
||||
const projects = await ctx.prisma.project.groupBy({
|
||||
by: ['status'],
|
||||
where: { roundId: input.roundId },
|
||||
_count: true,
|
||||
})
|
||||
|
||||
return roundProjects.map((rp) => ({
|
||||
status: rp.status,
|
||||
count: rp._count,
|
||||
return projects.map((p) => ({
|
||||
status: p.status,
|
||||
count: p._count,
|
||||
}))
|
||||
}),
|
||||
|
||||
@@ -242,7 +237,7 @@ export const analyticsRouter = router({
|
||||
jurorCount,
|
||||
statusCounts,
|
||||
] = await Promise.all([
|
||||
ctx.prisma.roundProject.count({ where: { roundId: input.roundId } }),
|
||||
ctx.prisma.project.count({ where: { roundId: input.roundId } }),
|
||||
ctx.prisma.assignment.count({ where: { roundId: input.roundId } }),
|
||||
ctx.prisma.evaluation.count({
|
||||
where: {
|
||||
@@ -254,7 +249,7 @@ export const analyticsRouter = router({
|
||||
by: ['userId'],
|
||||
where: { roundId: input.roundId },
|
||||
}),
|
||||
ctx.prisma.roundProject.groupBy({
|
||||
ctx.prisma.project.groupBy({
|
||||
by: ['status'],
|
||||
where: { roundId: input.roundId },
|
||||
_count: true,
|
||||
@@ -353,7 +348,7 @@ export const analyticsRouter = router({
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const where = input.roundId
|
||||
? { roundProjects: { some: { roundId: input.roundId } } }
|
||||
? { roundId: input.roundId }
|
||||
: { programId: input.programId }
|
||||
|
||||
const distribution = await ctx.prisma.project.groupBy({
|
||||
|
||||
@@ -62,7 +62,7 @@ export const applicantRouter = router({
|
||||
|
||||
const project = await ctx.prisma.project.findFirst({
|
||||
where: {
|
||||
roundProjects: { some: { roundId: input.roundId } },
|
||||
roundId: input.roundId,
|
||||
OR: [
|
||||
{ submittedByUserId: ctx.user.id },
|
||||
{
|
||||
@@ -74,14 +74,9 @@ export const applicantRouter = router({
|
||||
},
|
||||
include: {
|
||||
files: true,
|
||||
roundProjects: {
|
||||
where: { roundId: input.roundId },
|
||||
round: {
|
||||
include: {
|
||||
round: {
|
||||
include: {
|
||||
program: { select: { name: true, year: true } },
|
||||
},
|
||||
},
|
||||
program: { select: { name: true, year: true } },
|
||||
},
|
||||
},
|
||||
teamMembers: {
|
||||
@@ -179,10 +174,10 @@ export const applicantRouter = router({
|
||||
},
|
||||
})
|
||||
|
||||
// Update RoundProject status if submitting
|
||||
// Update Project status if submitting
|
||||
if (submit) {
|
||||
await ctx.prisma.roundProject.updateMany({
|
||||
where: { projectId: projectId },
|
||||
await ctx.prisma.project.update({
|
||||
where: { id: projectId },
|
||||
data: { status: 'SUBMITTED' },
|
||||
})
|
||||
}
|
||||
@@ -198,21 +193,13 @@ export const applicantRouter = router({
|
||||
// Create new project
|
||||
const project = await ctx.prisma.project.create({
|
||||
data: {
|
||||
programId: roundForCreate.programId,
|
||||
roundId,
|
||||
...data,
|
||||
metadataJson: metadataJson as unknown ?? undefined,
|
||||
submittedByUserId: ctx.user.id,
|
||||
submittedByEmail: ctx.user.email,
|
||||
submissionSource: 'MANUAL',
|
||||
submittedAt: submit ? now : null,
|
||||
},
|
||||
})
|
||||
|
||||
// Create RoundProject entry
|
||||
await ctx.prisma.roundProject.create({
|
||||
data: {
|
||||
roundId,
|
||||
projectId: project.id,
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
})
|
||||
@@ -412,15 +399,10 @@ export const applicantRouter = router({
|
||||
],
|
||||
},
|
||||
include: {
|
||||
roundProjects: {
|
||||
round: {
|
||||
include: {
|
||||
round: {
|
||||
include: {
|
||||
program: { select: { name: true, year: true } },
|
||||
},
|
||||
},
|
||||
program: { select: { name: true, year: true } },
|
||||
},
|
||||
orderBy: { addedAt: 'desc' },
|
||||
},
|
||||
files: true,
|
||||
teamMembers: {
|
||||
@@ -440,9 +422,8 @@ export const applicantRouter = router({
|
||||
})
|
||||
}
|
||||
|
||||
// Get the latest round project status
|
||||
const latestRoundProject = project.roundProjects[0]
|
||||
const currentStatus = latestRoundProject?.status ?? 'SUBMITTED'
|
||||
// Get the project status
|
||||
const currentStatus = project.status ?? 'SUBMITTED'
|
||||
|
||||
// Build timeline
|
||||
const timeline = [
|
||||
@@ -509,15 +490,10 @@ export const applicantRouter = router({
|
||||
],
|
||||
},
|
||||
include: {
|
||||
roundProjects: {
|
||||
round: {
|
||||
include: {
|
||||
round: {
|
||||
include: {
|
||||
program: { select: { name: true, year: true } },
|
||||
},
|
||||
},
|
||||
program: { select: { name: true, year: true } },
|
||||
},
|
||||
orderBy: { addedAt: 'desc' },
|
||||
},
|
||||
files: true,
|
||||
teamMembers: {
|
||||
|
||||
@@ -191,7 +191,7 @@ export const applicationRouter = router({
|
||||
// Check if email already submitted for this round
|
||||
const existingProject = await ctx.prisma.project.findFirst({
|
||||
where: {
|
||||
roundProjects: { some: { roundId } },
|
||||
roundId,
|
||||
submittedByEmail: data.contactEmail,
|
||||
},
|
||||
})
|
||||
@@ -223,7 +223,7 @@ export const applicationRouter = router({
|
||||
// Create the project
|
||||
const project = await ctx.prisma.project.create({
|
||||
data: {
|
||||
programId: round.programId,
|
||||
roundId,
|
||||
title: data.projectName,
|
||||
teamName: data.teamName,
|
||||
description: data.description,
|
||||
@@ -246,15 +246,6 @@ export const applicationRouter = router({
|
||||
},
|
||||
})
|
||||
|
||||
// Create RoundProject entry
|
||||
await ctx.prisma.roundProject.create({
|
||||
data: {
|
||||
roundId,
|
||||
projectId: project.id,
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
})
|
||||
|
||||
// Create team lead membership
|
||||
await ctx.prisma.teamMember.create({
|
||||
data: {
|
||||
@@ -362,7 +353,7 @@ export const applicationRouter = router({
|
||||
.query(async ({ ctx, input }) => {
|
||||
const existing = await ctx.prisma.project.findFirst({
|
||||
where: {
|
||||
roundProjects: { some: { roundId: input.roundId } },
|
||||
roundId: input.roundId,
|
||||
submittedByEmail: input.email,
|
||||
},
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -374,16 +374,12 @@ export const assignmentRouter = router({
|
||||
where: { roundId: input.roundId },
|
||||
_count: true,
|
||||
}),
|
||||
ctx.prisma.roundProject.findMany({
|
||||
ctx.prisma.project.findMany({
|
||||
where: { roundId: input.roundId },
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
_count: { select: { assignments: true } },
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
_count: { select: { assignments: true } },
|
||||
},
|
||||
}),
|
||||
])
|
||||
@@ -394,7 +390,7 @@ export const assignmentRouter = router({
|
||||
})
|
||||
|
||||
const projectsWithFullCoverage = projectCoverage.filter(
|
||||
(rp) => rp.project._count.assignments >= round.requiredReviews
|
||||
(p) => p._count.assignments >= round.requiredReviews
|
||||
).length
|
||||
|
||||
return {
|
||||
@@ -446,20 +442,15 @@ export const assignmentRouter = router({
|
||||
})
|
||||
|
||||
// Get all projects that need more assignments
|
||||
const roundProjectEntries = await ctx.prisma.roundProject.findMany({
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: { roundId: input.roundId },
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
tags: true,
|
||||
_count: { select: { assignments: true } },
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
tags: true,
|
||||
_count: { select: { assignments: true } },
|
||||
},
|
||||
})
|
||||
const projects = roundProjectEntries.map((rp) => rp.project)
|
||||
|
||||
// Get existing assignments to avoid duplicates
|
||||
const existingAssignments = await ctx.prisma.assignment.findMany({
|
||||
@@ -583,22 +574,17 @@ export const assignmentRouter = router({
|
||||
})
|
||||
|
||||
// Get all projects in the round
|
||||
const roundProjectEntries = await ctx.prisma.roundProject.findMany({
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: { roundId: input.roundId },
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
tags: true,
|
||||
teamName: true,
|
||||
_count: { select: { assignments: true } },
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
tags: true,
|
||||
teamName: true,
|
||||
_count: { select: { assignments: true } },
|
||||
},
|
||||
})
|
||||
const projects = roundProjectEntries.map((rp) => rp.project)
|
||||
|
||||
// Get existing assignments
|
||||
const existingAssignments = await ctx.prisma.assignment.findMany({
|
||||
|
||||
@@ -103,26 +103,21 @@ export const exportRouter = router({
|
||||
projectScores: adminProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const roundProjectEntries = await ctx.prisma.roundProject.findMany({
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: { roundId: input.roundId },
|
||||
include: {
|
||||
project: {
|
||||
assignments: {
|
||||
include: {
|
||||
assignments: {
|
||||
include: {
|
||||
evaluation: {
|
||||
where: { status: 'SUBMITTED' },
|
||||
},
|
||||
},
|
||||
evaluation: {
|
||||
where: { status: 'SUBMITTED' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { project: { title: 'asc' } },
|
||||
orderBy: { title: 'asc' },
|
||||
})
|
||||
|
||||
const data = roundProjectEntries.map((rp) => {
|
||||
const p = rp.project
|
||||
const data = projects.map((p) => {
|
||||
const evaluations = p.assignments
|
||||
.map((a) => a.evaluation)
|
||||
.filter((e) => e !== null)
|
||||
@@ -138,7 +133,7 @@ export const exportRouter = router({
|
||||
return {
|
||||
title: p.title,
|
||||
teamName: p.teamName,
|
||||
status: rp.status,
|
||||
status: p.status,
|
||||
tags: p.tags.join(', '),
|
||||
totalEvaluations: evaluations.length,
|
||||
averageScore:
|
||||
|
||||
@@ -27,19 +27,14 @@ async function runFilteringJob(jobId: string, roundId: string, userId: string) {
|
||||
})
|
||||
|
||||
// Get projects
|
||||
const roundProjectEntries = await prisma.roundProject.findMany({
|
||||
const projects = await prisma.project.findMany({
|
||||
where: { roundId },
|
||||
include: {
|
||||
project: {
|
||||
include: {
|
||||
files: {
|
||||
select: { id: true, fileName: true, fileType: true },
|
||||
},
|
||||
},
|
||||
files: {
|
||||
select: { id: true, fileName: true, fileType: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
const projects = roundProjectEntries.map((rp) => rp.project)
|
||||
|
||||
// Calculate batch info
|
||||
const BATCH_SIZE = 20
|
||||
@@ -387,7 +382,7 @@ export const filteringRouter = router({
|
||||
}
|
||||
|
||||
// Count projects
|
||||
const projectCount = await ctx.prisma.roundProject.count({
|
||||
const projectCount = await ctx.prisma.project.count({
|
||||
where: { roundId: input.roundId },
|
||||
})
|
||||
if (projectCount === 0) {
|
||||
@@ -485,19 +480,14 @@ export const filteringRouter = router({
|
||||
}
|
||||
|
||||
// Get projects in this round
|
||||
const roundProjectEntries = await ctx.prisma.roundProject.findMany({
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: { roundId: input.roundId },
|
||||
include: {
|
||||
project: {
|
||||
include: {
|
||||
files: {
|
||||
select: { id: true, fileName: true, fileType: true },
|
||||
},
|
||||
},
|
||||
files: {
|
||||
select: { id: true, fileName: true, fileType: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
const projects = roundProjectEntries.map((rp) => rp.project)
|
||||
|
||||
if (projects.length === 0) {
|
||||
throw new TRPCError({
|
||||
@@ -755,32 +745,29 @@ export const filteringRouter = router({
|
||||
// Filtered out projects get REJECTED status (data preserved)
|
||||
if (filteredOutIds.length > 0) {
|
||||
operations.push(
|
||||
ctx.prisma.roundProject.updateMany({
|
||||
where: { roundId: input.roundId, projectId: { in: filteredOutIds } },
|
||||
ctx.prisma.project.updateMany({
|
||||
where: { roundId: input.roundId, id: { in: filteredOutIds } },
|
||||
data: { status: 'REJECTED' },
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Passed projects get ELIGIBLE status
|
||||
// Passed projects get ELIGIBLE status (or advance to next round)
|
||||
if (passedIds.length > 0) {
|
||||
operations.push(
|
||||
ctx.prisma.roundProject.updateMany({
|
||||
where: { roundId: input.roundId, projectId: { in: passedIds } },
|
||||
data: { status: 'ELIGIBLE' },
|
||||
})
|
||||
)
|
||||
|
||||
// If there's a next round, advance passed projects to it
|
||||
if (nextRound) {
|
||||
// Advance passed projects to next round
|
||||
operations.push(
|
||||
ctx.prisma.roundProject.createMany({
|
||||
data: passedIds.map((projectId) => ({
|
||||
roundId: nextRound.id,
|
||||
projectId,
|
||||
status: 'SUBMITTED' as const,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
ctx.prisma.project.updateMany({
|
||||
where: { roundId: input.roundId, id: { in: passedIds } },
|
||||
data: { roundId: nextRound.id, status: 'SUBMITTED' },
|
||||
})
|
||||
)
|
||||
} else {
|
||||
// No next round, just mark as eligible
|
||||
operations.push(
|
||||
ctx.prisma.project.updateMany({
|
||||
where: { roundId: input.roundId, id: { in: passedIds } },
|
||||
data: { status: 'ELIGIBLE' },
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -837,9 +824,9 @@ export const filteringRouter = router({
|
||||
},
|
||||
})
|
||||
|
||||
// Restore RoundProject status
|
||||
await ctx.prisma.roundProject.updateMany({
|
||||
where: { roundId: input.roundId, projectId: input.projectId },
|
||||
// Restore project status
|
||||
await ctx.prisma.project.updateMany({
|
||||
where: { roundId: input.roundId, id: input.projectId },
|
||||
data: { status: 'ELIGIBLE' },
|
||||
})
|
||||
|
||||
@@ -883,8 +870,8 @@ export const filteringRouter = router({
|
||||
},
|
||||
})
|
||||
),
|
||||
ctx.prisma.roundProject.updateMany({
|
||||
where: { roundId: input.roundId, projectId: { in: input.projectIds } },
|
||||
ctx.prisma.project.updateMany({
|
||||
where: { roundId: input.roundId, id: { in: input.projectIds } },
|
||||
data: { status: 'ELIGIBLE' },
|
||||
}),
|
||||
])
|
||||
|
||||
@@ -82,11 +82,7 @@ export const learningResourceRouter = router({
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
roundProjects: {
|
||||
select: { status: true },
|
||||
orderBy: { addedAt: 'desc' },
|
||||
take: 1,
|
||||
},
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -95,12 +91,12 @@ export const learningResourceRouter = router({
|
||||
// Determine highest cohort level
|
||||
let userCohortLevel: 'ALL' | 'SEMIFINALIST' | 'FINALIST' = 'ALL'
|
||||
for (const assignment of assignments) {
|
||||
const rpStatus = assignment.project.roundProjects[0]?.status
|
||||
if (rpStatus === 'FINALIST') {
|
||||
const projectStatus = assignment.project.status
|
||||
if (projectStatus === 'FINALIST') {
|
||||
userCohortLevel = 'FINALIST'
|
||||
break
|
||||
}
|
||||
if (rpStatus === 'SEMIFINALIST') {
|
||||
if (projectStatus === 'SEMIFINALIST') {
|
||||
userCohortLevel = 'SEMIFINALIST'
|
||||
}
|
||||
}
|
||||
@@ -166,11 +162,7 @@ export const learningResourceRouter = router({
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
roundProjects: {
|
||||
select: { status: true },
|
||||
orderBy: { addedAt: 'desc' as const },
|
||||
take: 1,
|
||||
},
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -178,12 +170,12 @@ export const learningResourceRouter = router({
|
||||
|
||||
let userCohortLevel: 'ALL' | 'SEMIFINALIST' | 'FINALIST' = 'ALL'
|
||||
for (const assignment of assignments) {
|
||||
const rpStatus = assignment.project.roundProjects[0]?.status
|
||||
if (rpStatus === 'FINALIST') {
|
||||
const projectStatus = assignment.project.status
|
||||
if (projectStatus === 'FINALIST') {
|
||||
userCohortLevel = 'FINALIST'
|
||||
break
|
||||
}
|
||||
if (rpStatus === 'SEMIFINALIST') {
|
||||
if (projectStatus === 'SEMIFINALIST') {
|
||||
userCohortLevel = 'SEMIFINALIST'
|
||||
}
|
||||
}
|
||||
@@ -241,11 +233,7 @@ export const learningResourceRouter = router({
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
roundProjects: {
|
||||
select: { status: true },
|
||||
orderBy: { addedAt: 'desc' as const },
|
||||
take: 1,
|
||||
},
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -253,12 +241,12 @@ export const learningResourceRouter = router({
|
||||
|
||||
let userCohortLevel: 'ALL' | 'SEMIFINALIST' | 'FINALIST' = 'ALL'
|
||||
for (const assignment of assignments) {
|
||||
const rpStatus = assignment.project.roundProjects[0]?.status
|
||||
if (rpStatus === 'FINALIST') {
|
||||
const projectStatus = assignment.project.status
|
||||
if (projectStatus === 'FINALIST') {
|
||||
userCohortLevel = 'FINALIST'
|
||||
break
|
||||
}
|
||||
if (rpStatus === 'SEMIFINALIST') {
|
||||
if (projectStatus === 'SEMIFINALIST') {
|
||||
userCohortLevel = 'SEMIFINALIST'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,13 +15,9 @@ export const liveVotingRouter = router({
|
||||
round: {
|
||||
include: {
|
||||
program: { select: { name: true, year: true } },
|
||||
roundProjects: {
|
||||
projects: {
|
||||
where: { status: { in: ['FINALIST', 'SEMIFINALIST'] } },
|
||||
include: {
|
||||
project: {
|
||||
select: { id: true, title: true, teamName: true },
|
||||
},
|
||||
},
|
||||
select: { id: true, title: true, teamName: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -38,13 +34,9 @@ export const liveVotingRouter = router({
|
||||
round: {
|
||||
include: {
|
||||
program: { select: { name: true, year: true } },
|
||||
roundProjects: {
|
||||
projects: {
|
||||
where: { status: { in: ['FINALIST', 'SEMIFINALIST'] } },
|
||||
include: {
|
||||
project: {
|
||||
select: { id: true, title: true, teamName: true },
|
||||
},
|
||||
},
|
||||
select: { id: true, title: true, teamName: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -410,7 +410,7 @@ export const mentorRouter = router({
|
||||
// Get projects without mentors
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: {
|
||||
roundProjects: { some: { roundId: input.roundId } },
|
||||
roundId: input.roundId,
|
||||
mentorAssignment: null,
|
||||
wantsMentorship: true,
|
||||
},
|
||||
@@ -549,17 +549,10 @@ export const mentorRouter = router({
|
||||
include: {
|
||||
project: {
|
||||
include: {
|
||||
program: { select: { name: true, year: true } },
|
||||
roundProjects: {
|
||||
round: {
|
||||
include: {
|
||||
round: {
|
||||
include: {
|
||||
program: { select: { name: true, year: true } },
|
||||
},
|
||||
},
|
||||
program: { select: { name: true, year: true } },
|
||||
},
|
||||
orderBy: { addedAt: 'desc' },
|
||||
take: 1,
|
||||
},
|
||||
teamMembers: {
|
||||
include: {
|
||||
@@ -602,17 +595,10 @@ export const mentorRouter = router({
|
||||
const project = await ctx.prisma.project.findUniqueOrThrow({
|
||||
where: { id: input.projectId },
|
||||
include: {
|
||||
program: { select: { id: true, name: true, year: true } },
|
||||
roundProjects: {
|
||||
round: {
|
||||
include: {
|
||||
round: {
|
||||
include: {
|
||||
program: { select: { id: true, name: true, year: true } },
|
||||
},
|
||||
},
|
||||
program: { select: { id: true, name: true, year: true } },
|
||||
},
|
||||
orderBy: { addedAt: 'desc' },
|
||||
take: 1,
|
||||
},
|
||||
teamMembers: {
|
||||
include: {
|
||||
@@ -660,7 +646,7 @@ export const mentorRouter = router({
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const where = {
|
||||
...(input.roundId && { project: { roundProjects: { some: { roundId: input.roundId } } } }),
|
||||
...(input.roundId && { project: { roundId: input.roundId } }),
|
||||
...(input.mentorId && { mentorId: input.mentorId }),
|
||||
}
|
||||
|
||||
@@ -675,10 +661,7 @@ export const mentorRouter = router({
|
||||
teamName: true,
|
||||
oceanIssue: true,
|
||||
competitionCategory: true,
|
||||
roundProjects: {
|
||||
select: { status: true },
|
||||
take: 1,
|
||||
},
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
mentor: {
|
||||
|
||||
@@ -171,9 +171,10 @@ export const notionImportRouter = router({
|
||||
}
|
||||
|
||||
// Create project
|
||||
const createdProject = await ctx.prisma.project.create({
|
||||
await ctx.prisma.project.create({
|
||||
data: {
|
||||
programId: round.programId,
|
||||
roundId: round.id,
|
||||
status: 'SUBMITTED',
|
||||
title: title.trim(),
|
||||
teamName: typeof teamName === 'string' ? teamName.trim() : null,
|
||||
description: typeof description === 'string' ? description : null,
|
||||
@@ -186,15 +187,6 @@ export const notionImportRouter = router({
|
||||
},
|
||||
})
|
||||
|
||||
// Create RoundProject entry
|
||||
await ctx.prisma.roundProject.create({
|
||||
data: {
|
||||
roundId: round.id,
|
||||
projectId: createdProject.id,
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
})
|
||||
|
||||
results.imported++
|
||||
} catch (error) {
|
||||
results.errors.push({
|
||||
|
||||
@@ -1,433 +0,0 @@
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { router, publicProcedure } from '../trpc'
|
||||
import { sendApplicationConfirmationEmail, sendTeamMemberInviteEmail } from '@/lib/email'
|
||||
import { nanoid } from 'nanoid'
|
||||
import {
|
||||
createNotification,
|
||||
notifyAdmins,
|
||||
NotificationTypes,
|
||||
} from '../services/in-app-notification'
|
||||
|
||||
// Team member input for submission
|
||||
const teamMemberInputSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
email: z.string().email().optional(),
|
||||
title: z.string().optional(),
|
||||
role: z.enum(['LEAD', 'MEMBER', 'ADVISOR']).default('MEMBER'),
|
||||
})
|
||||
|
||||
export const onboardingRouter = router({
|
||||
/**
|
||||
* Get onboarding form configuration for public wizard
|
||||
* Returns form + steps + fields + program info
|
||||
*/
|
||||
getConfig: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
slug: z.string(), // Round slug or form publicSlug
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
// Try to find by round slug first
|
||||
let form = await ctx.prisma.applicationForm.findFirst({
|
||||
where: {
|
||||
round: { slug: input.slug },
|
||||
status: 'PUBLISHED',
|
||||
isPublic: true,
|
||||
},
|
||||
include: {
|
||||
program: { select: { id: true, name: true, year: true } },
|
||||
round: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
submissionStartDate: true,
|
||||
submissionEndDate: true,
|
||||
submissionDeadline: true,
|
||||
},
|
||||
},
|
||||
steps: {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
include: {
|
||||
fields: { orderBy: { sortOrder: 'asc' } },
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
where: { stepId: null },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// If not found by round slug, try form publicSlug
|
||||
if (!form) {
|
||||
form = await ctx.prisma.applicationForm.findFirst({
|
||||
where: {
|
||||
publicSlug: input.slug,
|
||||
status: 'PUBLISHED',
|
||||
isPublic: true,
|
||||
},
|
||||
include: {
|
||||
program: { select: { id: true, name: true, year: true } },
|
||||
round: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
submissionStartDate: true,
|
||||
submissionEndDate: true,
|
||||
submissionDeadline: true,
|
||||
},
|
||||
},
|
||||
steps: {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
include: {
|
||||
fields: { orderBy: { sortOrder: 'asc' } },
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
where: { stepId: null },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (!form) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Application form not found or not accepting submissions',
|
||||
})
|
||||
}
|
||||
|
||||
// Check submission window
|
||||
const now = new Date()
|
||||
const startDate = form.round?.submissionStartDate || form.opensAt
|
||||
const endDate = form.round?.submissionEndDate || form.round?.submissionDeadline || form.closesAt
|
||||
|
||||
if (startDate && now < startDate) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Applications are not yet open',
|
||||
})
|
||||
}
|
||||
|
||||
if (endDate && now > endDate) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Applications have 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: {
|
||||
id: form.id,
|
||||
name: form.name,
|
||||
description: form.description,
|
||||
confirmationMessage: form.confirmationMessage,
|
||||
},
|
||||
program: form.program,
|
||||
round: form.round,
|
||||
steps: form.steps,
|
||||
orphanFields: form.fields, // Fields not assigned to any step
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Submit an application through the onboarding wizard
|
||||
* Creates Project, TeamMembers, and sends emails
|
||||
*/
|
||||
submit: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
formId: z.string(),
|
||||
// Contact info
|
||||
contactName: z.string().min(1),
|
||||
contactEmail: z.string().email(),
|
||||
contactPhone: z.string().optional(),
|
||||
// Project info
|
||||
projectName: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
competitionCategory: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(),
|
||||
oceanIssue: z
|
||||
.enum([
|
||||
'POLLUTION_REDUCTION',
|
||||
'CLIMATE_MITIGATION',
|
||||
'TECHNOLOGY_INNOVATION',
|
||||
'SUSTAINABLE_SHIPPING',
|
||||
'BLUE_CARBON',
|
||||
'HABITAT_RESTORATION',
|
||||
'COMMUNITY_CAPACITY',
|
||||
'SUSTAINABLE_FISHING',
|
||||
'CONSUMER_AWARENESS',
|
||||
'OCEAN_ACIDIFICATION',
|
||||
'OTHER',
|
||||
])
|
||||
.optional(),
|
||||
country: z.string().optional(),
|
||||
institution: z.string().optional(),
|
||||
teamName: z.string().optional(),
|
||||
wantsMentorship: z.boolean().optional(),
|
||||
referralSource: z.string().optional(),
|
||||
foundedAt: z.string().datetime().optional(),
|
||||
// Team members
|
||||
teamMembers: z.array(teamMemberInputSchema).optional(),
|
||||
// Additional metadata (unmapped fields)
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
// GDPR consent
|
||||
gdprConsent: z.boolean(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (!input.gdprConsent) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'You must accept the terms and conditions to submit',
|
||||
})
|
||||
}
|
||||
|
||||
// Get form with round info
|
||||
const form = await ctx.prisma.applicationForm.findUniqueOrThrow({
|
||||
where: { id: input.formId },
|
||||
include: {
|
||||
round: true,
|
||||
program: 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 if we need a round/program for project creation
|
||||
const programId = form.round?.programId || form.programId
|
||||
if (!programId) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'This form is not linked to a program',
|
||||
})
|
||||
}
|
||||
|
||||
// Create or find user for contact email
|
||||
let contactUser = await ctx.prisma.user.findUnique({
|
||||
where: { email: input.contactEmail },
|
||||
})
|
||||
|
||||
if (!contactUser) {
|
||||
contactUser = await ctx.prisma.user.create({
|
||||
data: {
|
||||
email: input.contactEmail,
|
||||
name: input.contactName,
|
||||
role: 'APPLICANT',
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Create project
|
||||
const project = await ctx.prisma.project.create({
|
||||
data: {
|
||||
programId,
|
||||
title: input.projectName,
|
||||
description: input.description,
|
||||
teamName: input.teamName || input.projectName,
|
||||
competitionCategory: input.competitionCategory,
|
||||
oceanIssue: input.oceanIssue,
|
||||
country: input.country,
|
||||
institution: input.institution,
|
||||
wantsMentorship: input.wantsMentorship ?? false,
|
||||
referralSource: input.referralSource,
|
||||
foundedAt: input.foundedAt ? new Date(input.foundedAt) : null,
|
||||
submissionSource: 'PUBLIC_FORM',
|
||||
submittedByEmail: input.contactEmail,
|
||||
submittedByUserId: contactUser.id,
|
||||
submittedAt: new Date(),
|
||||
metadataJson: input.metadata as Prisma.InputJsonValue ?? {},
|
||||
},
|
||||
})
|
||||
|
||||
// Create RoundProject entry if form is linked to a round
|
||||
if (form.roundId) {
|
||||
await ctx.prisma.roundProject.create({
|
||||
data: {
|
||||
roundId: form.roundId,
|
||||
projectId: project.id,
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Create TeamMember for contact as LEAD
|
||||
await ctx.prisma.teamMember.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
userId: contactUser.id,
|
||||
role: 'LEAD',
|
||||
title: 'Team Lead',
|
||||
},
|
||||
})
|
||||
|
||||
// Process additional team members
|
||||
const invitePromises: Promise<void>[] = []
|
||||
|
||||
if (input.teamMembers && input.teamMembers.length > 0) {
|
||||
for (const member of input.teamMembers) {
|
||||
// Skip if same email as contact
|
||||
if (member.email === input.contactEmail) continue
|
||||
|
||||
let memberUser = member.email
|
||||
? await ctx.prisma.user.findUnique({ where: { email: member.email } })
|
||||
: null
|
||||
|
||||
if (member.email && !memberUser) {
|
||||
// Create user with invite token
|
||||
const inviteToken = nanoid(32)
|
||||
const inviteTokenExpiresAt = new Date()
|
||||
inviteTokenExpiresAt.setDate(inviteTokenExpiresAt.getDate() + 30) // 30 days
|
||||
|
||||
memberUser = await ctx.prisma.user.create({
|
||||
data: {
|
||||
email: member.email,
|
||||
name: member.name,
|
||||
role: 'APPLICANT',
|
||||
status: 'INVITED',
|
||||
inviteToken,
|
||||
inviteTokenExpiresAt,
|
||||
},
|
||||
})
|
||||
|
||||
// Queue invite email
|
||||
if (form.sendTeamInviteEmails) {
|
||||
const inviteUrl = `${process.env.NEXTAUTH_URL || ''}/accept-invite?token=${inviteToken}`
|
||||
invitePromises.push(
|
||||
sendTeamMemberInviteEmail(
|
||||
member.email,
|
||||
member.name,
|
||||
input.projectName,
|
||||
input.contactName,
|
||||
inviteUrl
|
||||
).catch((err) => {
|
||||
console.error(`Failed to send invite email to ${member.email}:`, err)
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Create team member if we have a user
|
||||
if (memberUser) {
|
||||
await ctx.prisma.teamMember.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
userId: memberUser.id,
|
||||
role: member.role,
|
||||
title: member.title,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create form submission record
|
||||
await ctx.prisma.applicationFormSubmission.create({
|
||||
data: {
|
||||
formId: input.formId,
|
||||
email: input.contactEmail,
|
||||
name: input.contactName,
|
||||
dataJson: input as unknown as Prisma.InputJsonValue,
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
})
|
||||
|
||||
// Send confirmation email
|
||||
if (form.sendConfirmationEmail) {
|
||||
const programName = form.program?.name || form.round?.name || 'the program'
|
||||
try {
|
||||
await sendApplicationConfirmationEmail(
|
||||
input.contactEmail,
|
||||
input.contactName,
|
||||
input.projectName,
|
||||
programName,
|
||||
form.confirmationEmailBody || form.confirmationMessage || undefined
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('Failed to send confirmation email:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for invite emails (don't block on failure)
|
||||
await Promise.allSettled(invitePromises)
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: contactUser.id,
|
||||
action: 'SUBMIT_APPLICATION',
|
||||
entityType: 'Project',
|
||||
entityId: project.id,
|
||||
detailsJson: {
|
||||
formId: input.formId,
|
||||
projectName: input.projectName,
|
||||
teamMemberCount: (input.teamMembers?.length || 0) + 1,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
})
|
||||
|
||||
// In-app notification for applicant
|
||||
const programName = form.program?.name || form.round?.name || 'the program'
|
||||
await createNotification({
|
||||
userId: contactUser.id,
|
||||
type: NotificationTypes.APPLICATION_SUBMITTED,
|
||||
title: 'Application Received',
|
||||
message: `Your application for "${input.projectName}" has been successfully submitted.`,
|
||||
linkUrl: `/team/projects/${project.id}`,
|
||||
linkLabel: 'View Application',
|
||||
metadata: {
|
||||
projectName: input.projectName,
|
||||
programName,
|
||||
},
|
||||
})
|
||||
|
||||
// Notify admins of new application
|
||||
await notifyAdmins({
|
||||
type: NotificationTypes.NEW_APPLICATION,
|
||||
title: 'New Application',
|
||||
message: `New application received: "${input.projectName}" from ${input.contactName}.`,
|
||||
linkUrl: `/admin/projects/${project.id}`,
|
||||
linkLabel: 'Review Application',
|
||||
metadata: {
|
||||
projectName: input.projectName,
|
||||
applicantName: input.contactName,
|
||||
applicantEmail: input.contactEmail,
|
||||
programName,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
projectId: project.id,
|
||||
confirmationMessage: form.confirmationMessage,
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -20,14 +20,14 @@ export const programRouter = router({
|
||||
_count: {
|
||||
select: { rounds: true },
|
||||
},
|
||||
rounds: input?.includeRounds ? {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
rounds: {
|
||||
orderBy: { createdAt: 'asc' },
|
||||
include: {
|
||||
_count: {
|
||||
select: { roundProjects: true, assignments: true },
|
||||
select: { projects: true, assignments: true },
|
||||
},
|
||||
},
|
||||
} : false,
|
||||
},
|
||||
},
|
||||
})
|
||||
}),
|
||||
@@ -45,7 +45,7 @@ export const programRouter = router({
|
||||
orderBy: { createdAt: 'asc' },
|
||||
include: {
|
||||
_count: {
|
||||
select: { roundProjects: true, assignments: true },
|
||||
select: { projects: true, assignments: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -69,48 +69,29 @@ export const projectRouter = router({
|
||||
// Build where clause
|
||||
const where: Record<string, unknown> = {}
|
||||
|
||||
if (programId) where.programId = programId
|
||||
// Filter by program via round
|
||||
if (programId) where.round = { programId }
|
||||
|
||||
// Filter by round via RoundProject join
|
||||
// Filter by round
|
||||
if (roundId) {
|
||||
where.roundProjects = { some: { roundId } }
|
||||
where.roundId = roundId
|
||||
}
|
||||
|
||||
// Exclude projects already in a specific round
|
||||
// Exclude projects in a specific round
|
||||
if (notInRoundId) {
|
||||
where.roundProjects = {
|
||||
...(where.roundProjects as Record<string, unknown> || {}),
|
||||
none: { roundId: notInRoundId },
|
||||
}
|
||||
where.roundId = { not: notInRoundId }
|
||||
}
|
||||
|
||||
// Filter by unassigned (not in any round)
|
||||
// Filter by unassigned (no round)
|
||||
if (unassignedOnly) {
|
||||
where.roundProjects = { none: {} }
|
||||
where.roundId = null
|
||||
}
|
||||
|
||||
// Status filter via RoundProject
|
||||
if (roundId && (statuses?.length || status)) {
|
||||
// Status filter
|
||||
if (statuses?.length || status) {
|
||||
const statusValues = statuses?.length ? statuses : status ? [status] : []
|
||||
if (statusValues.length > 0) {
|
||||
where.roundProjects = {
|
||||
some: {
|
||||
roundId,
|
||||
status: { in: statusValues },
|
||||
},
|
||||
}
|
||||
}
|
||||
} else if (statuses?.length || status) {
|
||||
// Status filter without specific round — match any round with that status
|
||||
const statusValues = statuses?.length ? statuses : status ? [status] : []
|
||||
if (statusValues.length > 0) {
|
||||
where.roundProjects = {
|
||||
...(where.roundProjects as Record<string, unknown> || {}),
|
||||
some: {
|
||||
...((where.roundProjects as Record<string, unknown>)?.some as Record<string, unknown> || {}),
|
||||
status: { in: statusValues },
|
||||
},
|
||||
}
|
||||
where.status = { in: statusValues }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,16 +131,12 @@ export const projectRouter = router({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
files: true,
|
||||
program: {
|
||||
select: { id: true, name: true, year: true },
|
||||
},
|
||||
roundProjects: {
|
||||
include: {
|
||||
round: {
|
||||
select: { id: true, name: true, sortOrder: true },
|
||||
},
|
||||
round: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
program: { select: { id: true, name: true, year: true } },
|
||||
},
|
||||
orderBy: { addedAt: 'desc' },
|
||||
},
|
||||
_count: { select: { assignments: true } },
|
||||
},
|
||||
@@ -183,8 +160,8 @@ export const projectRouter = router({
|
||||
.query(async ({ ctx }) => {
|
||||
const [rounds, countries, categories, issues] = await Promise.all([
|
||||
ctx.prisma.round.findMany({
|
||||
select: { id: true, name: true, sortOrder: true, program: { select: { name: true, year: true } } },
|
||||
orderBy: [{ program: { year: 'desc' } }, { sortOrder: 'asc' }],
|
||||
select: { id: true, name: true, program: { select: { name: true, year: true } } },
|
||||
orderBy: [{ program: { year: 'desc' } }, { createdAt: 'asc' }],
|
||||
}),
|
||||
ctx.prisma.project.findMany({
|
||||
where: { country: { not: null } },
|
||||
@@ -228,17 +205,7 @@ export const projectRouter = router({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
files: true,
|
||||
program: {
|
||||
select: { id: true, name: true, year: true },
|
||||
},
|
||||
roundProjects: {
|
||||
include: {
|
||||
round: {
|
||||
select: { id: true, name: true, sortOrder: true, status: true },
|
||||
},
|
||||
},
|
||||
orderBy: { round: { sortOrder: 'asc' } },
|
||||
},
|
||||
round: true,
|
||||
teamMembers: {
|
||||
include: {
|
||||
user: {
|
||||
@@ -307,13 +274,12 @@ export const projectRouter = router({
|
||||
|
||||
/**
|
||||
* Create a single project (admin only)
|
||||
* Projects belong to a program. Optionally assign to a round immediately.
|
||||
* Projects belong to a round.
|
||||
*/
|
||||
create: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
programId: z.string(),
|
||||
roundId: z.string().optional(),
|
||||
roundId: z.string(),
|
||||
title: z.string().min(1).max(500),
|
||||
teamName: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
@@ -322,25 +288,15 @@ export const projectRouter = router({
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { metadataJson, roundId, ...rest } = input
|
||||
const { metadataJson, ...rest } = input
|
||||
const project = await ctx.prisma.project.create({
|
||||
data: {
|
||||
...rest,
|
||||
metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
})
|
||||
|
||||
// If roundId provided, also create RoundProject entry
|
||||
if (roundId) {
|
||||
await ctx.prisma.roundProject.create({
|
||||
data: {
|
||||
roundId,
|
||||
projectId: project.id,
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
@@ -348,7 +304,7 @@ export const projectRouter = router({
|
||||
action: 'CREATE',
|
||||
entityType: 'Project',
|
||||
entityId: project.id,
|
||||
detailsJson: { title: input.title, programId: input.programId, roundId },
|
||||
detailsJson: { title: input.title, roundId: input.roundId },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
@@ -391,22 +347,20 @@ export const projectRouter = router({
|
||||
where: { id },
|
||||
data: {
|
||||
...data,
|
||||
...(status && { status }),
|
||||
metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,
|
||||
},
|
||||
})
|
||||
|
||||
// Update status on RoundProject if both status and roundId provided
|
||||
if (status && roundId) {
|
||||
await ctx.prisma.roundProject.updateMany({
|
||||
where: { projectId: id, roundId },
|
||||
data: { status },
|
||||
// Send notifications if status changed
|
||||
if (status) {
|
||||
// Get round details for notification
|
||||
const projectWithRound = await ctx.prisma.project.findUnique({
|
||||
where: { id },
|
||||
include: { round: { select: { name: true, entryNotificationType: true, program: { select: { name: true } } } } },
|
||||
})
|
||||
|
||||
// Get round details including configured notification type
|
||||
const round = await ctx.prisma.round.findUnique({
|
||||
where: { id: roundId },
|
||||
select: { name: true, entryNotificationType: true, program: { select: { name: true } } },
|
||||
})
|
||||
const round = projectWithRound?.round
|
||||
|
||||
// Helper to get notification title based on type
|
||||
const getNotificationTitle = (type: string): string => {
|
||||
@@ -445,7 +399,7 @@ export const projectRouter = router({
|
||||
programName: round.program?.name,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
} else if (round) {
|
||||
// Fall back to hardcoded status-based notifications
|
||||
const notificationConfig: Record<
|
||||
string,
|
||||
@@ -494,7 +448,7 @@ export const projectRouter = router({
|
||||
action: 'UPDATE',
|
||||
entityType: 'Project',
|
||||
entityId: id,
|
||||
detailsJson: { ...data, status, roundId, metadataJson } as Prisma.InputJsonValue,
|
||||
detailsJson: { ...data, status, metadataJson } as Prisma.InputJsonValue,
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
@@ -570,12 +524,13 @@ export const projectRouter = router({
|
||||
|
||||
// Create projects in a transaction
|
||||
const result = await ctx.prisma.$transaction(async (tx) => {
|
||||
// Create all projects
|
||||
// Create all projects with roundId
|
||||
const projectData = input.projects.map((p) => {
|
||||
const { metadataJson, ...rest } = p
|
||||
return {
|
||||
...rest,
|
||||
programId: input.programId,
|
||||
roundId: input.roundId!,
|
||||
status: 'SUBMITTED' as const,
|
||||
metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,
|
||||
}
|
||||
})
|
||||
@@ -585,17 +540,6 @@ export const projectRouter = router({
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
// If roundId provided, create RoundProject entries
|
||||
if (input.roundId) {
|
||||
await tx.roundProject.createMany({
|
||||
data: created.map((p) => ({
|
||||
roundId: input.roundId!,
|
||||
projectId: p.id,
|
||||
status: 'SUBMITTED' as const,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
return { imported: created.length }
|
||||
})
|
||||
|
||||
@@ -624,8 +568,8 @@ export const projectRouter = router({
|
||||
}))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const where: Record<string, unknown> = {}
|
||||
if (input.programId) where.programId = input.programId
|
||||
if (input.roundId) where.roundProjects = { some: { roundId: input.roundId } }
|
||||
if (input.programId) where.round = { programId: input.programId }
|
||||
if (input.roundId) where.roundId = input.roundId
|
||||
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: Object.keys(where).length > 0 ? where : undefined,
|
||||
@@ -658,9 +602,9 @@ export const projectRouter = router({
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const updated = await ctx.prisma.roundProject.updateMany({
|
||||
const updated = await ctx.prisma.project.updateMany({
|
||||
where: {
|
||||
projectId: { in: input.ids },
|
||||
id: { in: input.ids },
|
||||
roundId: input.roundId,
|
||||
},
|
||||
data: { status: input.status },
|
||||
@@ -798,8 +742,8 @@ export const projectRouter = router({
|
||||
const skip = (page - 1) * perPage
|
||||
|
||||
const where: Record<string, unknown> = {
|
||||
programId,
|
||||
roundProjects: { none: {} },
|
||||
round: { programId },
|
||||
roundId: null,
|
||||
}
|
||||
|
||||
if (search) {
|
||||
|
||||
@@ -19,7 +19,7 @@ export const roundRouter = router({
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
include: {
|
||||
_count: {
|
||||
select: { roundProjects: true, assignments: true },
|
||||
select: { projects: true, assignments: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -36,7 +36,7 @@ export const roundRouter = router({
|
||||
include: {
|
||||
program: true,
|
||||
_count: {
|
||||
select: { roundProjects: true, assignments: true },
|
||||
select: { projects: true, assignments: true },
|
||||
},
|
||||
evaluationForms: {
|
||||
where: { isActive: true },
|
||||
@@ -113,23 +113,18 @@ export const roundRouter = router({
|
||||
},
|
||||
})
|
||||
|
||||
// For FILTERING rounds, automatically add all projects from the program
|
||||
// For FILTERING rounds, automatically move all projects from the program to this round
|
||||
if (input.roundType === 'FILTERING') {
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: { programId: input.programId },
|
||||
select: { id: true },
|
||||
await ctx.prisma.project.updateMany({
|
||||
where: {
|
||||
round: { programId: input.programId },
|
||||
roundId: { not: round.id },
|
||||
},
|
||||
data: {
|
||||
roundId: round.id,
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
})
|
||||
|
||||
if (projects.length > 0) {
|
||||
await ctx.prisma.roundProject.createMany({
|
||||
data: projects.map((p) => ({
|
||||
roundId: round.id,
|
||||
projectId: p.id,
|
||||
status: 'SUBMITTED',
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Audit log
|
||||
@@ -341,7 +336,7 @@ export const roundRouter = router({
|
||||
.query(async ({ ctx, input }) => {
|
||||
const [totalProjects, totalAssignments, completedAssignments] =
|
||||
await Promise.all([
|
||||
ctx.prisma.roundProject.count({ where: { roundId: input.id } }),
|
||||
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 },
|
||||
@@ -472,7 +467,7 @@ export const roundRouter = router({
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
_count: { select: { roundProjects: true, assignments: true } },
|
||||
_count: { select: { projects: true, assignments: true } },
|
||||
},
|
||||
})
|
||||
|
||||
@@ -490,7 +485,7 @@ export const roundRouter = router({
|
||||
detailsJson: {
|
||||
name: round.name,
|
||||
status: round.status,
|
||||
projectsDeleted: round._count.roundProjects,
|
||||
projectsDeleted: round._count.projects,
|
||||
assignmentsDeleted: round._count.assignments,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
@@ -532,29 +527,25 @@ export const roundRouter = router({
|
||||
where: { id: input.roundId },
|
||||
})
|
||||
|
||||
// Verify all projects belong to the same program
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: { id: { in: input.projectIds }, programId: round.programId },
|
||||
select: { id: true },
|
||||
// Update projects to assign them to this round
|
||||
const updated = await ctx.prisma.project.updateMany({
|
||||
where: {
|
||||
id: { in: input.projectIds },
|
||||
round: { programId: round.programId },
|
||||
},
|
||||
data: {
|
||||
roundId: input.roundId,
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
})
|
||||
|
||||
if (projects.length !== input.projectIds.length) {
|
||||
if (updated.count === 0) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Some projects do not belong to this program',
|
||||
message: 'No projects were assigned. Projects may not belong to this program.',
|
||||
})
|
||||
}
|
||||
|
||||
// Create RoundProject entries (skip duplicates)
|
||||
const created = await ctx.prisma.roundProject.createMany({
|
||||
data: input.projectIds.map((projectId) => ({
|
||||
roundId: input.roundId,
|
||||
projectId,
|
||||
status: 'SUBMITTED' as const,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
@@ -562,13 +553,13 @@ export const roundRouter = router({
|
||||
action: 'ASSIGN_PROJECTS_TO_ROUND',
|
||||
entityType: 'Round',
|
||||
entityId: input.roundId,
|
||||
detailsJson: { projectCount: created.count },
|
||||
detailsJson: { projectCount: updated.count },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
})
|
||||
|
||||
return { assigned: created.count }
|
||||
return { assigned: updated.count }
|
||||
}),
|
||||
|
||||
/**
|
||||
@@ -582,12 +573,17 @@ export const roundRouter = router({
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const deleted = await ctx.prisma.roundProject.deleteMany({
|
||||
// Set roundId to null for these projects (remove from round)
|
||||
const updated = await ctx.prisma.project.updateMany({
|
||||
where: {
|
||||
roundId: input.roundId,
|
||||
projectId: { in: input.projectIds },
|
||||
id: { in: input.projectIds },
|
||||
},
|
||||
data: {
|
||||
roundId: null as unknown as string, // Projects need to be orphaned
|
||||
},
|
||||
})
|
||||
const deleted = { count: updated.count }
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
@@ -632,12 +628,12 @@ export const roundRouter = router({
|
||||
}
|
||||
|
||||
// Verify all projects are in the source round
|
||||
const sourceProjects = await ctx.prisma.roundProject.findMany({
|
||||
const sourceProjects = await ctx.prisma.project.findMany({
|
||||
where: {
|
||||
roundId: input.fromRoundId,
|
||||
projectId: { in: input.projectIds },
|
||||
id: { in: input.projectIds },
|
||||
},
|
||||
select: { projectId: true },
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
if (sourceProjects.length !== input.projectIds.length) {
|
||||
@@ -647,15 +643,18 @@ export const roundRouter = router({
|
||||
})
|
||||
}
|
||||
|
||||
// Create entries in target round (skip duplicates)
|
||||
const created = await ctx.prisma.roundProject.createMany({
|
||||
data: input.projectIds.map((projectId) => ({
|
||||
// Move projects to target round
|
||||
const updated = await ctx.prisma.project.updateMany({
|
||||
where: {
|
||||
id: { in: input.projectIds },
|
||||
roundId: input.fromRoundId,
|
||||
},
|
||||
data: {
|
||||
roundId: input.toRoundId,
|
||||
projectId,
|
||||
status: 'SUBMITTED' as const,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
})
|
||||
const created = { count: updated.count }
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
|
||||
@@ -237,29 +237,22 @@ export const specialAwardRouter = router({
|
||||
const statusFilter = input.includeSubmitted
|
||||
? (['SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST'] as const)
|
||||
: (['ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST'] as const)
|
||||
const roundProjectEntries = await ctx.prisma.roundProject.findMany({
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: {
|
||||
round: { programId: award.programId },
|
||||
status: { in: [...statusFilter] },
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
competitionCategory: true,
|
||||
country: true,
|
||||
geographicZone: true,
|
||||
tags: true,
|
||||
oceanIssue: true,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
competitionCategory: true,
|
||||
country: true,
|
||||
geographicZone: true,
|
||||
tags: true,
|
||||
oceanIssue: true,
|
||||
},
|
||||
})
|
||||
// Deduplicate projects (same project may be in multiple rounds)
|
||||
const projectMap = new Map(roundProjectEntries.map((rp) => [rp.project.id, rp.project]))
|
||||
const projects = Array.from(projectMap.values())
|
||||
|
||||
if (projects.length === 0) {
|
||||
throw new TRPCError({
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { router, adminProcedure, protectedProcedure } from '../trpc'
|
||||
import {
|
||||
tagProject,
|
||||
batchTagProjects,
|
||||
getTagSuggestions,
|
||||
addProjectTag,
|
||||
removeProjectTag,
|
||||
} from '../services/ai-tagging'
|
||||
|
||||
export const tagRouter = router({
|
||||
/**
|
||||
@@ -391,6 +398,157 @@ export const tagRouter = router({
|
||||
)
|
||||
)
|
||||
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// PROJECT TAGGING (AI-powered)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Get tags for a project
|
||||
*/
|
||||
getProjectTags: protectedProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const tags = await ctx.prisma.projectTag.findMany({
|
||||
where: { projectId: input.projectId },
|
||||
include: { tag: true },
|
||||
orderBy: { confidence: 'desc' },
|
||||
})
|
||||
|
||||
return tags.map((pt) => ({
|
||||
id: pt.id,
|
||||
tagId: pt.tagId,
|
||||
name: pt.tag.name,
|
||||
category: pt.tag.category,
|
||||
color: pt.tag.color,
|
||||
confidence: pt.confidence,
|
||||
source: pt.source,
|
||||
createdAt: pt.createdAt,
|
||||
}))
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get AI tag suggestions for a project (without applying)
|
||||
*/
|
||||
getSuggestions: adminProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const suggestions = await getTagSuggestions(input.projectId, ctx.user.id)
|
||||
return suggestions
|
||||
}),
|
||||
|
||||
/**
|
||||
* Tag a single project with AI
|
||||
*/
|
||||
tagProject: adminProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const result = await tagProject(input.projectId, ctx.user.id)
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'AI_TAG',
|
||||
entityType: 'Project',
|
||||
entityId: input.projectId,
|
||||
detailsJson: {
|
||||
applied: result.applied.map((t) => t.tagName),
|
||||
tokensUsed: result.tokensUsed,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
})
|
||||
|
||||
return result
|
||||
}),
|
||||
|
||||
/**
|
||||
* Batch tag all untagged projects in a round
|
||||
*/
|
||||
batchTagProjects: adminProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const result = await batchTagProjects(input.roundId, ctx.user.id)
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'BATCH_AI_TAG',
|
||||
entityType: 'Round',
|
||||
entityId: input.roundId,
|
||||
detailsJson: {
|
||||
processed: result.processed,
|
||||
failed: result.failed,
|
||||
skipped: result.skipped,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
})
|
||||
|
||||
return result
|
||||
}),
|
||||
|
||||
/**
|
||||
* Manually add a tag to a project
|
||||
*/
|
||||
addProjectTag: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
tagId: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await addProjectTag(input.projectId, input.tagId)
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'ADD_TAG',
|
||||
entityType: 'Project',
|
||||
entityId: input.projectId,
|
||||
detailsJson: { tagId: input.tagId },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Remove a tag from a project
|
||||
*/
|
||||
removeProjectTag: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
tagId: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await removeProjectTag(input.projectId, input.tagId)
|
||||
|
||||
// Audit log
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'REMOVE_TAG',
|
||||
entityType: 'Project',
|
||||
entityId: input.projectId,
|
||||
detailsJson: { tagId: input.tagId },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -199,9 +199,10 @@ export const typeformImportRouter = router({
|
||||
}
|
||||
|
||||
// Create project
|
||||
const createdProject = await ctx.prisma.project.create({
|
||||
await ctx.prisma.project.create({
|
||||
data: {
|
||||
programId: round.programId,
|
||||
roundId: round.id,
|
||||
status: 'SUBMITTED',
|
||||
title: String(title).trim(),
|
||||
teamName: typeof teamName === 'string' ? teamName.trim() : null,
|
||||
description: typeof description === 'string' ? description : null,
|
||||
@@ -214,15 +215,6 @@ export const typeformImportRouter = router({
|
||||
},
|
||||
})
|
||||
|
||||
// Create RoundProject entry
|
||||
await ctx.prisma.roundProject.create({
|
||||
data: {
|
||||
roundId: round.id,
|
||||
projectId: createdProject.id,
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
})
|
||||
|
||||
results.imported++
|
||||
} catch (error) {
|
||||
results.errors.push({
|
||||
|
||||
@@ -29,6 +29,7 @@ export const userRouter = router({
|
||||
expertiseTags: true,
|
||||
metadataJson: true,
|
||||
phoneNumber: true,
|
||||
country: true,
|
||||
notificationPreference: true,
|
||||
profileImageKey: true,
|
||||
createdAt: true,
|
||||
@@ -415,6 +416,7 @@ export const userRouter = router({
|
||||
|
||||
/**
|
||||
* Bulk import users (admin only)
|
||||
* Optionally pre-assign projects to jury members during invitation
|
||||
*/
|
||||
bulkCreate: adminProcedure
|
||||
.input(
|
||||
@@ -425,6 +427,15 @@ export const userRouter = router({
|
||||
name: z.string().optional(),
|
||||
role: z.enum(['JURY_MEMBER', 'MENTOR', 'OBSERVER']).default('JURY_MEMBER'),
|
||||
expertiseTags: z.array(z.string()).optional(),
|
||||
// Optional pre-assignments for jury members
|
||||
assignments: z
|
||||
.array(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
roundId: z.string(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
),
|
||||
})
|
||||
@@ -456,10 +467,20 @@ export const userRouter = router({
|
||||
return { created: 0, skipped }
|
||||
}
|
||||
|
||||
// Build map of email -> assignments before createMany (since createMany removes extra fields)
|
||||
const emailToAssignments = new Map<string, Array<{ projectId: string; roundId: string }>>()
|
||||
for (const u of newUsers) {
|
||||
if (u.assignments && u.assignments.length > 0) {
|
||||
emailToAssignments.set(u.email.toLowerCase(), u.assignments)
|
||||
}
|
||||
}
|
||||
|
||||
const created = await ctx.prisma.user.createMany({
|
||||
data: newUsers.map((u) => ({
|
||||
...u,
|
||||
email: u.email.toLowerCase(),
|
||||
name: u.name,
|
||||
role: u.role,
|
||||
expertiseTags: u.expertiseTags,
|
||||
status: 'INVITED',
|
||||
})),
|
||||
})
|
||||
@@ -483,6 +504,44 @@ export const userRouter = router({
|
||||
select: { id: true, email: true, name: true, role: true },
|
||||
})
|
||||
|
||||
// Create pre-assignments for users who have them
|
||||
let assignmentsCreated = 0
|
||||
for (const user of createdUsers) {
|
||||
const assignments = emailToAssignments.get(user.email.toLowerCase())
|
||||
if (assignments && assignments.length > 0) {
|
||||
for (const assignment of assignments) {
|
||||
try {
|
||||
await ctx.prisma.assignment.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
projectId: assignment.projectId,
|
||||
roundId: assignment.roundId,
|
||||
method: 'MANUAL',
|
||||
createdBy: ctx.user.id,
|
||||
},
|
||||
})
|
||||
assignmentsCreated++
|
||||
} catch {
|
||||
// Skip if assignment already exists (shouldn't happen for new users)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Audit log for assignments if any were created
|
||||
if (assignmentsCreated > 0) {
|
||||
await ctx.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'BULK_ASSIGN',
|
||||
entityType: 'Assignment',
|
||||
detailsJson: { count: assignmentsCreated, context: 'invitation_pre_assignment' },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
let emailsSent = 0
|
||||
const emailErrors: string[] = []
|
||||
|
||||
@@ -525,7 +584,7 @@ export const userRouter = router({
|
||||
}
|
||||
}
|
||||
|
||||
return { created: created.count, skipped, emailsSent, emailErrors }
|
||||
return { created: created.count, skipped, emailsSent, emailErrors, assignmentsCreated }
|
||||
}),
|
||||
|
||||
/**
|
||||
@@ -729,6 +788,7 @@ export const userRouter = router({
|
||||
z.object({
|
||||
name: z.string().min(1).max(255),
|
||||
phoneNumber: z.string().optional(),
|
||||
country: z.string().optional(),
|
||||
expertiseTags: z.array(z.string()).optional(),
|
||||
notificationPreference: z.enum(['EMAIL', 'WHATSAPP', 'BOTH', 'NONE']).optional(),
|
||||
})
|
||||
@@ -750,6 +810,7 @@ export const userRouter = router({
|
||||
data: {
|
||||
name: input.name,
|
||||
phoneNumber: input.phoneNumber,
|
||||
country: input.country,
|
||||
expertiseTags: mergedTags,
|
||||
notificationPreference: input.notificationPreference || 'EMAIL',
|
||||
onboardingCompletedAt: new Date(),
|
||||
@@ -782,8 +843,8 @@ export const userRouter = router({
|
||||
select: { onboardingCompletedAt: true, role: true },
|
||||
})
|
||||
|
||||
// Jury members and mentors need onboarding
|
||||
const rolesRequiringOnboarding = ['JURY_MEMBER', 'MENTOR']
|
||||
// Jury members, mentors, and admins need onboarding
|
||||
const rolesRequiringOnboarding = ['JURY_MEMBER', 'MENTOR', 'PROGRAM_ADMIN', 'SUPER_ADMIN']
|
||||
if (!rolesRequiringOnboarding.includes(user.role)) {
|
||||
return false
|
||||
}
|
||||
|
||||
541
src/server/services/ai-tagging.ts
Normal file
541
src/server/services/ai-tagging.ts
Normal file
@@ -0,0 +1,541 @@
|
||||
/**
|
||||
* AI-Powered Project Tagging Service
|
||||
*
|
||||
* Analyzes projects and assigns expertise tags automatically.
|
||||
*
|
||||
* Features:
|
||||
* - Single project tagging (on-submit or manual)
|
||||
* - Batch tagging for rounds
|
||||
* - Confidence scores for each tag
|
||||
* - Additive only - never removes existing tags
|
||||
*
|
||||
* GDPR Compliance:
|
||||
* - All project data is anonymized before AI processing
|
||||
* - Only necessary fields sent to OpenAI
|
||||
* - No personal identifiers in prompts or responses
|
||||
*/
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getOpenAI, getConfiguredModel, buildCompletionParams } from '@/lib/openai'
|
||||
import { logAIUsage, extractTokenUsage } from '@/server/utils/ai-usage'
|
||||
import { classifyAIError, createParseError, logAIError } from './ai-errors'
|
||||
import {
|
||||
anonymizeProjectsForAI,
|
||||
validateAnonymizedProjects,
|
||||
type ProjectWithRelations,
|
||||
type AnonymizedProjectForAI,
|
||||
type ProjectAIMapping,
|
||||
} from './anonymization'
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface TagSuggestion {
|
||||
tagId: string
|
||||
tagName: string
|
||||
confidence: number
|
||||
reasoning: string
|
||||
}
|
||||
|
||||
export interface TaggingResult {
|
||||
projectId: string
|
||||
suggestions: TagSuggestion[]
|
||||
applied: TagSuggestion[]
|
||||
tokensUsed: number
|
||||
}
|
||||
|
||||
export interface BatchTaggingResult {
|
||||
processed: number
|
||||
failed: number
|
||||
skipped: number
|
||||
errors: string[]
|
||||
results: TaggingResult[]
|
||||
}
|
||||
|
||||
interface AvailableTag {
|
||||
id: string
|
||||
name: string
|
||||
category: string | null
|
||||
description: string | null
|
||||
}
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_BATCH_SIZE = 10
|
||||
const MAX_BATCH_SIZE = 25
|
||||
const CONFIDENCE_THRESHOLD = 0.5
|
||||
const DEFAULT_MAX_TAGS = 5
|
||||
|
||||
// System prompt optimized for tag suggestion
|
||||
const TAG_SUGGESTION_SYSTEM_PROMPT = `You are an expert at categorizing ocean conservation and sustainability projects.
|
||||
|
||||
Analyze the project and suggest the most relevant expertise tags from the provided list.
|
||||
Consider the project's focus areas, technology, methodology, and domain.
|
||||
|
||||
Return JSON with this format:
|
||||
{
|
||||
"suggestions": [
|
||||
{
|
||||
"tag_name": "exact tag name from list",
|
||||
"confidence": 0.0-1.0,
|
||||
"reasoning": "brief explanation why this tag fits"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Only suggest tags from the provided list (exact names)
|
||||
- Order by relevance (most relevant first)
|
||||
- Confidence should reflect how well the tag matches
|
||||
- Maximum 7 suggestions per project
|
||||
- Be conservative - only suggest tags that truly apply`
|
||||
|
||||
// ─── Helper Functions ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get system settings for AI tagging
|
||||
*/
|
||||
async function getTaggingSettings(): Promise<{
|
||||
enabled: boolean
|
||||
maxTags: number
|
||||
}> {
|
||||
const settings = await prisma.systemSettings.findMany({
|
||||
where: {
|
||||
key: {
|
||||
in: ['ai_tagging_enabled', 'ai_tagging_max_tags'],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const settingsMap = new Map(settings.map((s) => [s.key, s.value]))
|
||||
|
||||
return {
|
||||
enabled: settingsMap.get('ai_tagging_enabled') === 'true',
|
||||
maxTags: parseInt(settingsMap.get('ai_tagging_max_tags') || String(DEFAULT_MAX_TAGS)),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active expertise tags
|
||||
*/
|
||||
async function getAvailableTags(): Promise<AvailableTag[]> {
|
||||
return prisma.expertiseTag.findMany({
|
||||
where: { isActive: true },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
category: true,
|
||||
description: true,
|
||||
},
|
||||
orderBy: [{ category: 'asc' }, { sortOrder: 'asc' }],
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert project to format for anonymization
|
||||
*/
|
||||
function toProjectWithRelations(project: {
|
||||
id: string
|
||||
title: string
|
||||
description?: string | null
|
||||
competitionCategory?: string | null
|
||||
oceanIssue?: string | null
|
||||
country?: string | null
|
||||
geographicZone?: string | null
|
||||
institution?: string | null
|
||||
tags: string[]
|
||||
foundedAt?: Date | null
|
||||
wantsMentorship?: boolean
|
||||
submissionSource?: string
|
||||
submittedAt?: Date | null
|
||||
_count?: { teamMembers?: number; files?: number }
|
||||
files?: Array<{ fileType: string | null }>
|
||||
}): ProjectWithRelations {
|
||||
return {
|
||||
id: project.id,
|
||||
title: project.title,
|
||||
description: project.description,
|
||||
competitionCategory: project.competitionCategory as any,
|
||||
oceanIssue: project.oceanIssue as any,
|
||||
country: project.country,
|
||||
geographicZone: project.geographicZone,
|
||||
institution: project.institution,
|
||||
tags: project.tags,
|
||||
foundedAt: project.foundedAt,
|
||||
wantsMentorship: project.wantsMentorship ?? false,
|
||||
submissionSource: (project.submissionSource as any) ?? 'MANUAL',
|
||||
submittedAt: project.submittedAt,
|
||||
_count: {
|
||||
teamMembers: project._count?.teamMembers ?? 0,
|
||||
files: project._count?.files ?? 0,
|
||||
},
|
||||
files: project.files?.map((f) => ({ fileType: (f.fileType as any) ?? null })) ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
// ─── AI Tagging Core ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Call OpenAI to get tag suggestions for a project
|
||||
*/
|
||||
async function getAISuggestions(
|
||||
anonymizedProject: AnonymizedProjectForAI,
|
||||
availableTags: AvailableTag[],
|
||||
userId?: string
|
||||
): Promise<{ suggestions: TagSuggestion[]; tokensUsed: number }> {
|
||||
const openai = await getOpenAI()
|
||||
if (!openai) {
|
||||
console.warn('[AI Tagging] OpenAI not configured')
|
||||
return { suggestions: [], tokensUsed: 0 }
|
||||
}
|
||||
|
||||
const model = await getConfiguredModel()
|
||||
|
||||
// Build tag list for prompt
|
||||
const tagList = availableTags.map((t) => ({
|
||||
name: t.name,
|
||||
category: t.category,
|
||||
description: t.description,
|
||||
}))
|
||||
|
||||
const userPrompt = `PROJECT:
|
||||
${JSON.stringify(anonymizedProject, null, 2)}
|
||||
|
||||
AVAILABLE TAGS:
|
||||
${JSON.stringify(tagList, null, 2)}
|
||||
|
||||
Suggest relevant tags for this project.`
|
||||
|
||||
try {
|
||||
const params = buildCompletionParams(model, {
|
||||
messages: [
|
||||
{ role: 'system', content: TAG_SUGGESTION_SYSTEM_PROMPT },
|
||||
{ role: 'user', content: userPrompt },
|
||||
],
|
||||
jsonMode: true,
|
||||
temperature: 0.3,
|
||||
maxTokens: 2000,
|
||||
})
|
||||
|
||||
const response = await openai.chat.completions.create(params)
|
||||
const usage = extractTokenUsage(response)
|
||||
|
||||
// Log usage
|
||||
await logAIUsage({
|
||||
userId,
|
||||
action: 'PROJECT_TAGGING',
|
||||
entityType: 'Project',
|
||||
entityId: anonymizedProject.project_id,
|
||||
model,
|
||||
promptTokens: usage.promptTokens,
|
||||
completionTokens: usage.completionTokens,
|
||||
totalTokens: usage.totalTokens,
|
||||
batchSize: 1,
|
||||
itemsProcessed: 1,
|
||||
status: 'SUCCESS',
|
||||
})
|
||||
|
||||
const content = response.choices[0]?.message?.content
|
||||
if (!content) {
|
||||
throw new Error('Empty response from AI')
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(content) as {
|
||||
suggestions: Array<{
|
||||
tag_name: string
|
||||
confidence: number
|
||||
reasoning: string
|
||||
}>
|
||||
}
|
||||
|
||||
// Map to TagSuggestion format, matching tag names to IDs
|
||||
const suggestions: TagSuggestion[] = []
|
||||
for (const s of parsed.suggestions || []) {
|
||||
const tag = availableTags.find(
|
||||
(t) => t.name.toLowerCase() === s.tag_name.toLowerCase()
|
||||
)
|
||||
if (tag) {
|
||||
suggestions.push({
|
||||
tagId: tag.id,
|
||||
tagName: tag.name,
|
||||
confidence: Math.max(0, Math.min(1, s.confidence)),
|
||||
reasoning: s.reasoning,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { suggestions, tokensUsed: usage.totalTokens }
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
const parseError = createParseError(error.message)
|
||||
logAIError('Tagging', 'getAISuggestions', parseError)
|
||||
}
|
||||
|
||||
await logAIUsage({
|
||||
userId,
|
||||
action: 'PROJECT_TAGGING',
|
||||
entityType: 'Project',
|
||||
entityId: anonymizedProject.project_id,
|
||||
model,
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
totalTokens: 0,
|
||||
batchSize: 1,
|
||||
itemsProcessed: 0,
|
||||
status: 'ERROR',
|
||||
errorMessage: error instanceof Error ? error.message : 'Unknown error',
|
||||
})
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Tag a single project with AI-suggested expertise tags
|
||||
*
|
||||
* Behavior:
|
||||
* - Only applies tags with confidence >= 0.5
|
||||
* - Additive only - never removes existing tags
|
||||
* - Respects maxTags setting
|
||||
*/
|
||||
export async function tagProject(
|
||||
projectId: string,
|
||||
userId?: string
|
||||
): Promise<TaggingResult> {
|
||||
const settings = await getTaggingSettings()
|
||||
if (!settings.enabled) {
|
||||
return {
|
||||
projectId,
|
||||
suggestions: [],
|
||||
applied: [],
|
||||
tokensUsed: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch project with needed fields
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { id: projectId },
|
||||
include: {
|
||||
projectTags: true,
|
||||
files: { select: { fileType: true } },
|
||||
_count: { select: { teamMembers: true, files: true } },
|
||||
},
|
||||
})
|
||||
|
||||
if (!project) {
|
||||
throw new Error(`Project not found: ${projectId}`)
|
||||
}
|
||||
|
||||
// Get available tags
|
||||
const availableTags = await getAvailableTags()
|
||||
if (availableTags.length === 0) {
|
||||
return {
|
||||
projectId,
|
||||
suggestions: [],
|
||||
applied: [],
|
||||
tokensUsed: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Anonymize project data
|
||||
const projectWithRelations = toProjectWithRelations(project)
|
||||
const { anonymized, mappings } = anonymizeProjectsForAI([projectWithRelations], 'FILTERING')
|
||||
|
||||
// Validate anonymization
|
||||
if (!validateAnonymizedProjects(anonymized)) {
|
||||
throw new Error('GDPR compliance check failed: PII detected in anonymized data')
|
||||
}
|
||||
|
||||
// Get AI suggestions
|
||||
const { suggestions, tokensUsed } = await getAISuggestions(
|
||||
anonymized[0],
|
||||
availableTags,
|
||||
userId
|
||||
)
|
||||
|
||||
// Filter by confidence threshold
|
||||
const validSuggestions = suggestions.filter(
|
||||
(s) => s.confidence >= CONFIDENCE_THRESHOLD
|
||||
)
|
||||
|
||||
// Get existing tag IDs to avoid duplicates
|
||||
const existingTagIds = new Set(project.projectTags.map((pt) => pt.tagId))
|
||||
|
||||
// Calculate how many more tags we can add
|
||||
const currentTagCount = project.projectTags.length
|
||||
const remainingSlots = Math.max(0, settings.maxTags - currentTagCount)
|
||||
|
||||
// Filter out existing tags and limit to remaining slots
|
||||
const newSuggestions = validSuggestions
|
||||
.filter((s) => !existingTagIds.has(s.tagId))
|
||||
.slice(0, remainingSlots)
|
||||
|
||||
// Apply new tags
|
||||
const applied: TagSuggestion[] = []
|
||||
for (const suggestion of newSuggestions) {
|
||||
try {
|
||||
await prisma.projectTag.create({
|
||||
data: {
|
||||
projectId,
|
||||
tagId: suggestion.tagId,
|
||||
confidence: suggestion.confidence,
|
||||
source: 'AI',
|
||||
},
|
||||
})
|
||||
applied.push(suggestion)
|
||||
} catch (error) {
|
||||
// Skip if tag already exists (race condition)
|
||||
console.warn(`[AI Tagging] Failed to apply tag ${suggestion.tagName}: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
projectId,
|
||||
suggestions,
|
||||
applied,
|
||||
tokensUsed,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch tag all untagged projects in a round
|
||||
*
|
||||
* Only processes projects with zero tags.
|
||||
*/
|
||||
export async function batchTagProjects(
|
||||
roundId: string,
|
||||
userId?: string,
|
||||
onProgress?: (processed: number, total: number) => void
|
||||
): Promise<BatchTaggingResult> {
|
||||
const settings = await getTaggingSettings()
|
||||
if (!settings.enabled) {
|
||||
return {
|
||||
processed: 0,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
errors: ['AI tagging is disabled'],
|
||||
results: [],
|
||||
}
|
||||
}
|
||||
|
||||
// Get untagged projects in round
|
||||
const projects = await prisma.project.findMany({
|
||||
where: {
|
||||
roundId,
|
||||
projectTags: { none: {} }, // Only projects with no tags
|
||||
},
|
||||
include: {
|
||||
files: { select: { fileType: true } },
|
||||
_count: { select: { teamMembers: true, files: true } },
|
||||
},
|
||||
})
|
||||
|
||||
if (projects.length === 0) {
|
||||
return {
|
||||
processed: 0,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
errors: [],
|
||||
results: [],
|
||||
}
|
||||
}
|
||||
|
||||
const results: TaggingResult[] = []
|
||||
let processed = 0
|
||||
let failed = 0
|
||||
const errors: string[] = []
|
||||
|
||||
for (let i = 0; i < projects.length; i++) {
|
||||
const project = projects[i]
|
||||
try {
|
||||
const result = await tagProject(project.id, userId)
|
||||
results.push(result)
|
||||
processed++
|
||||
} catch (error) {
|
||||
failed++
|
||||
errors.push(`${project.title}: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||
}
|
||||
|
||||
// Report progress
|
||||
if (onProgress) {
|
||||
onProgress(i + 1, projects.length)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
processed,
|
||||
failed,
|
||||
skipped: 0,
|
||||
errors,
|
||||
results,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tag suggestions for a project without applying them
|
||||
* Useful for preview/review before applying
|
||||
*/
|
||||
export async function getTagSuggestions(
|
||||
projectId: string,
|
||||
userId?: string
|
||||
): Promise<TagSuggestion[]> {
|
||||
// Fetch project
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { id: projectId },
|
||||
include: {
|
||||
files: { select: { fileType: true } },
|
||||
_count: { select: { teamMembers: true, files: true } },
|
||||
},
|
||||
})
|
||||
|
||||
if (!project) {
|
||||
throw new Error(`Project not found: ${projectId}`)
|
||||
}
|
||||
|
||||
// Get available tags
|
||||
const availableTags = await getAvailableTags()
|
||||
if (availableTags.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Anonymize project data
|
||||
const projectWithRelations = toProjectWithRelations(project)
|
||||
const { anonymized } = anonymizeProjectsForAI([projectWithRelations], 'FILTERING')
|
||||
|
||||
// Validate anonymization
|
||||
if (!validateAnonymizedProjects(anonymized)) {
|
||||
throw new Error('GDPR compliance check failed')
|
||||
}
|
||||
|
||||
// Get AI suggestions
|
||||
const { suggestions } = await getAISuggestions(anonymized[0], availableTags, userId)
|
||||
|
||||
return suggestions
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually add a tag to a project
|
||||
*/
|
||||
export async function addProjectTag(
|
||||
projectId: string,
|
||||
tagId: string
|
||||
): Promise<void> {
|
||||
await prisma.projectTag.upsert({
|
||||
where: { projectId_tagId: { projectId, tagId } },
|
||||
create: { projectId, tagId, source: 'MANUAL', confidence: 1.0 },
|
||||
update: { source: 'MANUAL', confidence: 1.0 },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a tag from a project
|
||||
*/
|
||||
export async function removeProjectTag(
|
||||
projectId: string,
|
||||
tagId: string
|
||||
): Promise<void> {
|
||||
await prisma.projectTag.deleteMany({
|
||||
where: { projectId, tagId },
|
||||
})
|
||||
}
|
||||
381
src/server/services/smart-assignment.ts
Normal file
381
src/server/services/smart-assignment.ts
Normal file
@@ -0,0 +1,381 @@
|
||||
/**
|
||||
* Smart Assignment Scoring Service
|
||||
*
|
||||
* Calculates scores for jury/mentor-project matching based on:
|
||||
* - Tag overlap (expertise match)
|
||||
* - Workload balance
|
||||
* - Country match (mentors only)
|
||||
*
|
||||
* Score Breakdown (100 points max):
|
||||
* - Tag overlap: 0-50 points (weighted by confidence)
|
||||
* - Workload balance: 0-25 points
|
||||
* - Country match: 0-15 points (mentors only)
|
||||
* - Reserved: 0-10 points (future AI boost)
|
||||
*/
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ScoreBreakdown {
|
||||
tagOverlap: number
|
||||
workloadBalance: number
|
||||
countryMatch: number
|
||||
aiBoost: number
|
||||
}
|
||||
|
||||
export interface AssignmentScore {
|
||||
userId: string
|
||||
userName: string
|
||||
userEmail: string
|
||||
projectId: string
|
||||
projectTitle: string
|
||||
score: number
|
||||
breakdown: ScoreBreakdown
|
||||
reasoning: string[]
|
||||
matchingTags: string[]
|
||||
}
|
||||
|
||||
export interface ProjectTagData {
|
||||
tagId: string
|
||||
tagName: string
|
||||
confidence: number
|
||||
}
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
const MAX_TAG_OVERLAP_SCORE = 50
|
||||
const MAX_WORKLOAD_SCORE = 25
|
||||
const MAX_COUNTRY_SCORE = 15
|
||||
const POINTS_PER_TAG_MATCH = 10
|
||||
|
||||
// ─── Scoring Functions ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Calculate tag overlap score between user expertise and project tags
|
||||
*/
|
||||
export function calculateTagOverlapScore(
|
||||
userTagNames: string[],
|
||||
projectTags: ProjectTagData[]
|
||||
): { score: number; matchingTags: string[] } {
|
||||
if (projectTags.length === 0 || userTagNames.length === 0) {
|
||||
return { score: 0, matchingTags: [] }
|
||||
}
|
||||
|
||||
const userTagSet = new Set(userTagNames.map((t) => t.toLowerCase()))
|
||||
const matchingTags: string[] = []
|
||||
let weightedScore = 0
|
||||
|
||||
for (const pt of projectTags) {
|
||||
if (userTagSet.has(pt.tagName.toLowerCase())) {
|
||||
matchingTags.push(pt.tagName)
|
||||
// Weight by confidence - higher confidence = more points
|
||||
weightedScore += POINTS_PER_TAG_MATCH * pt.confidence
|
||||
}
|
||||
}
|
||||
|
||||
// Cap at max score
|
||||
const score = Math.min(MAX_TAG_OVERLAP_SCORE, Math.round(weightedScore))
|
||||
return { score, matchingTags }
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate workload balance score
|
||||
* Full points if under target, decreasing as over target
|
||||
*/
|
||||
export function calculateWorkloadScore(
|
||||
currentAssignments: number,
|
||||
targetAssignments: number,
|
||||
maxAssignments?: number | null
|
||||
): number {
|
||||
// If user is at or over their personal max, return 0
|
||||
if (maxAssignments !== null && maxAssignments !== undefined) {
|
||||
if (currentAssignments >= maxAssignments) {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// If under target, full points
|
||||
if (currentAssignments < targetAssignments) {
|
||||
return MAX_WORKLOAD_SCORE
|
||||
}
|
||||
|
||||
// Over target - decrease score
|
||||
const overload = currentAssignments - targetAssignments
|
||||
return Math.max(0, MAX_WORKLOAD_SCORE - overload * 5)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate country match score (mentors only)
|
||||
* Same country = bonus points
|
||||
*/
|
||||
export function calculateCountryMatchScore(
|
||||
userCountry: string | null | undefined,
|
||||
projectCountry: string | null | undefined
|
||||
): number {
|
||||
if (!userCountry || !projectCountry) {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Normalize for comparison
|
||||
const normalizedUser = userCountry.toLowerCase().trim()
|
||||
const normalizedProject = projectCountry.toLowerCase().trim()
|
||||
|
||||
if (normalizedUser === normalizedProject) {
|
||||
return MAX_COUNTRY_SCORE
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// ─── Main Scoring Function ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get smart assignment suggestions for a round
|
||||
*/
|
||||
export async function getSmartSuggestions(options: {
|
||||
roundId: string
|
||||
type: 'jury' | 'mentor'
|
||||
limit?: number
|
||||
aiMaxPerJudge?: number
|
||||
}): Promise<AssignmentScore[]> {
|
||||
const { roundId, type, limit = 50, aiMaxPerJudge = 20 } = options
|
||||
|
||||
// Get projects in round with their tags
|
||||
const projects = await prisma.project.findMany({
|
||||
where: {
|
||||
roundId,
|
||||
status: { not: 'REJECTED' },
|
||||
},
|
||||
include: {
|
||||
projectTags: {
|
||||
include: { tag: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (projects.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Get users of the appropriate role
|
||||
const role = type === 'jury' ? 'JURY_MEMBER' : 'MENTOR'
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
role,
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
assignments: {
|
||||
where: { roundId },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (users.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Get existing assignments to avoid duplicates
|
||||
const existingAssignments = await prisma.assignment.findMany({
|
||||
where: { roundId },
|
||||
select: { userId: true, projectId: true },
|
||||
})
|
||||
const assignedPairs = new Set(
|
||||
existingAssignments.map((a) => `${a.userId}:${a.projectId}`)
|
||||
)
|
||||
|
||||
// Calculate target assignments per user
|
||||
const targetPerUser = Math.ceil(projects.length / users.length)
|
||||
|
||||
// Calculate scores for all user-project pairs
|
||||
const suggestions: AssignmentScore[] = []
|
||||
|
||||
for (const user of users) {
|
||||
// Skip users at AI max (they won't appear in suggestions)
|
||||
const currentCount = user._count.assignments
|
||||
if (currentCount >= aiMaxPerJudge) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const project of projects) {
|
||||
// Skip if already assigned
|
||||
const pairKey = `${user.id}:${project.id}`
|
||||
if (assignedPairs.has(pairKey)) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get project tags data
|
||||
const projectTags: ProjectTagData[] = project.projectTags.map((pt) => ({
|
||||
tagId: pt.tagId,
|
||||
tagName: pt.tag.name,
|
||||
confidence: pt.confidence,
|
||||
}))
|
||||
|
||||
// Calculate scores
|
||||
const { score: tagScore, matchingTags } = calculateTagOverlapScore(
|
||||
user.expertiseTags,
|
||||
projectTags
|
||||
)
|
||||
|
||||
const workloadScore = calculateWorkloadScore(
|
||||
currentCount,
|
||||
targetPerUser,
|
||||
user.maxAssignments
|
||||
)
|
||||
|
||||
// Country match only for mentors
|
||||
const countryScore =
|
||||
type === 'mentor'
|
||||
? calculateCountryMatchScore(
|
||||
(user as any).country, // User might have country field
|
||||
project.country
|
||||
)
|
||||
: 0
|
||||
|
||||
const totalScore = tagScore + workloadScore + countryScore
|
||||
|
||||
// Build reasoning
|
||||
const reasoning: string[] = []
|
||||
if (matchingTags.length > 0) {
|
||||
reasoning.push(`Expertise match: ${matchingTags.length} tag(s)`)
|
||||
}
|
||||
if (workloadScore === MAX_WORKLOAD_SCORE) {
|
||||
reasoning.push('Available capacity')
|
||||
} else if (workloadScore > 0) {
|
||||
reasoning.push('Moderate workload')
|
||||
}
|
||||
if (countryScore > 0) {
|
||||
reasoning.push('Same country')
|
||||
}
|
||||
|
||||
suggestions.push({
|
||||
userId: user.id,
|
||||
userName: user.name || 'Unknown',
|
||||
userEmail: user.email,
|
||||
projectId: project.id,
|
||||
projectTitle: project.title,
|
||||
score: totalScore,
|
||||
breakdown: {
|
||||
tagOverlap: tagScore,
|
||||
workloadBalance: workloadScore,
|
||||
countryMatch: countryScore,
|
||||
aiBoost: 0,
|
||||
},
|
||||
reasoning,
|
||||
matchingTags,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by score descending and limit
|
||||
return suggestions.sort((a, b) => b.score - a.score).slice(0, limit)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mentor suggestions for a specific project
|
||||
*/
|
||||
export async function getMentorSuggestionsForProject(
|
||||
projectId: string,
|
||||
limit: number = 10
|
||||
): Promise<AssignmentScore[]> {
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { id: projectId },
|
||||
include: {
|
||||
projectTags: {
|
||||
include: { tag: true },
|
||||
},
|
||||
mentorAssignment: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!project) {
|
||||
throw new Error(`Project not found: ${projectId}`)
|
||||
}
|
||||
|
||||
// Get all active mentors
|
||||
const mentors = await prisma.user.findMany({
|
||||
where: {
|
||||
role: 'MENTOR',
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: { mentorAssignments: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (mentors.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const projectTags: ProjectTagData[] = project.projectTags.map((pt) => ({
|
||||
tagId: pt.tagId,
|
||||
tagName: pt.tag.name,
|
||||
confidence: pt.confidence,
|
||||
}))
|
||||
|
||||
const targetPerMentor = 5 // Target 5 projects per mentor
|
||||
|
||||
const suggestions: AssignmentScore[] = []
|
||||
|
||||
for (const mentor of mentors) {
|
||||
// Skip if already assigned to this project
|
||||
if (project.mentorAssignment?.mentorId === mentor.id) {
|
||||
continue
|
||||
}
|
||||
|
||||
const { score: tagScore, matchingTags } = calculateTagOverlapScore(
|
||||
mentor.expertiseTags,
|
||||
projectTags
|
||||
)
|
||||
|
||||
const workloadScore = calculateWorkloadScore(
|
||||
mentor._count.mentorAssignments,
|
||||
targetPerMentor,
|
||||
mentor.maxAssignments
|
||||
)
|
||||
|
||||
const countryScore = calculateCountryMatchScore(
|
||||
(mentor as any).country,
|
||||
project.country
|
||||
)
|
||||
|
||||
const totalScore = tagScore + workloadScore + countryScore
|
||||
|
||||
const reasoning: string[] = []
|
||||
if (matchingTags.length > 0) {
|
||||
reasoning.push(`${matchingTags.length} matching expertise tag(s)`)
|
||||
}
|
||||
if (countryScore > 0) {
|
||||
reasoning.push('Same country of origin')
|
||||
}
|
||||
if (workloadScore === MAX_WORKLOAD_SCORE) {
|
||||
reasoning.push('Available capacity')
|
||||
}
|
||||
|
||||
suggestions.push({
|
||||
userId: mentor.id,
|
||||
userName: mentor.name || 'Unknown',
|
||||
userEmail: mentor.email,
|
||||
projectId: project.id,
|
||||
projectTitle: project.title,
|
||||
score: totalScore,
|
||||
breakdown: {
|
||||
tagOverlap: tagScore,
|
||||
workloadBalance: workloadScore,
|
||||
countryMatch: countryScore,
|
||||
aiBoost: 0,
|
||||
},
|
||||
reasoning,
|
||||
matchingTags,
|
||||
})
|
||||
}
|
||||
|
||||
return suggestions.sort((a, b) => b.score - a.score).slice(0, limit)
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export type AIAction =
|
||||
| 'FILTERING'
|
||||
| 'AWARD_ELIGIBILITY'
|
||||
| 'MENTOR_MATCHING'
|
||||
| 'PROJECT_TAGGING'
|
||||
|
||||
export type AIStatus = 'SUCCESS' | 'PARTIAL' | 'ERROR'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user