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:
2026-02-04 14:15:06 +01:00
parent 7bcd2ce6ca
commit 29827268b2
71 changed files with 2139 additions and 6609 deletions

View File

@@ -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,

View File

@@ -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({

View File

@@ -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: {

View File

@@ -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

View File

@@ -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({

View File

@@ -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:

View File

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

View File

@@ -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'
}
}

View File

@@ -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 },
},
},
},

View File

@@ -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: {

View File

@@ -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({

View File

@@ -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,
}
}),
})

View File

@@ -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 },
},
},
},

View File

@@ -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) {

View File

@@ -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({

View File

@@ -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({

View File

@@ -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 }
}),
})

View File

@@ -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({

View File

@@ -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
}

View 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 },
})
}

View 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)
}

View File

@@ -16,6 +16,7 @@ export type AIAction =
| 'FILTERING'
| 'AWARD_ELIGIBILITY'
| 'MENTOR_MATCHING'
| 'PROJECT_TAGGING'
export type AIStatus = 'SUCCESS' | 'PARTIAL' | 'ERROR'