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
}