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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user