Add dynamic apply wizard customization with admin settings UI
- Create wizard config types, utilities, and defaults (wizard-config.ts) - Add admin apply settings page with drag-and-drop step ordering, dropdown option management, feature toggles, welcome message customization, and custom field builder with select/multiselect options editor - Build dynamic apply wizard component with animated step transitions, mobile-first responsive design, and config-driven form validation - Update step components to accept dynamic config (categories, ocean issues, field visibility, feature flags) - Replace hardcoded enum validation with string-based validation for admin-configurable dropdown values, with safe enum casting at storage layer - Add wizard template system (model, router, admin UI) with built-in MOPC Classic preset - Add program wizard config CRUD procedures to program router - Update application router getConfig to return wizardConfig, submit handler to store custom field data in metadataJson - Add edition-based apply page, project pool page, and supporting routers - Fix CSS (invalid sm:fixed-none), Enter key handler (skip textarea), safe area insets for notched phones, buildStepsArray field visibility Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
218
src/server/routers/project-pool.ts
Normal file
218
src/server/routers/project-pool.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { router, adminProcedure } from '../trpc'
|
||||
import { logAudit } from '../utils/audit'
|
||||
|
||||
/**
|
||||
* Project Pool Router
|
||||
*
|
||||
* Manages the pool of unassigned projects (projects not yet assigned to a round).
|
||||
* Provides procedures for listing unassigned projects and bulk assigning them to rounds.
|
||||
*/
|
||||
export const projectPoolRouter = router({
|
||||
/**
|
||||
* List unassigned projects with filtering and pagination
|
||||
* Projects where roundId IS NULL
|
||||
*/
|
||||
listUnassigned: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
programId: z.string(), // Required - must specify which program
|
||||
competitionCategory: z
|
||||
.enum(['STARTUP', 'BUSINESS_CONCEPT'])
|
||||
.optional(),
|
||||
search: z.string().optional(), // Search in title, teamName, description
|
||||
page: z.number().int().min(1).default(1),
|
||||
perPage: z.number().int().min(1).max(200).default(20),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { programId, competitionCategory, search, page, perPage } = input
|
||||
const skip = (page - 1) * perPage
|
||||
|
||||
// Build where clause
|
||||
const where: Record<string, unknown> = {
|
||||
programId,
|
||||
roundId: null, // Only unassigned projects
|
||||
}
|
||||
|
||||
// Filter by competition category
|
||||
if (competitionCategory) {
|
||||
where.competitionCategory = competitionCategory
|
||||
}
|
||||
|
||||
// Search in title, teamName, description
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ title: { contains: search, mode: 'insensitive' } },
|
||||
{ teamName: { contains: search, mode: 'insensitive' } },
|
||||
{ description: { contains: search, mode: 'insensitive' } },
|
||||
]
|
||||
}
|
||||
|
||||
// Execute queries in parallel
|
||||
const [projects, total] = await Promise.all([
|
||||
ctx.prisma.project.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: perPage,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
teamName: true,
|
||||
description: true,
|
||||
competitionCategory: true,
|
||||
oceanIssue: true,
|
||||
country: true,
|
||||
status: true,
|
||||
submittedAt: true,
|
||||
createdAt: true,
|
||||
tags: true,
|
||||
wantsMentorship: true,
|
||||
programId: true,
|
||||
_count: {
|
||||
select: {
|
||||
files: true,
|
||||
teamMembers: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
ctx.prisma.project.count({ where }),
|
||||
])
|
||||
|
||||
return {
|
||||
projects,
|
||||
total,
|
||||
page,
|
||||
perPage,
|
||||
totalPages: Math.ceil(total / perPage),
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Bulk assign projects to a round
|
||||
*
|
||||
* Validates that:
|
||||
* - All projects exist
|
||||
* - All projects belong to the same program as the target round
|
||||
* - Round exists and belongs to a program
|
||||
*
|
||||
* Updates:
|
||||
* - Project.roundId
|
||||
* - Project.status to 'ASSIGNED'
|
||||
* - Creates ProjectStatusHistory records for each project
|
||||
* - Creates audit log
|
||||
*/
|
||||
assignToRound: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectIds: z.array(z.string()).min(1).max(200), // Max 200 projects at once
|
||||
roundId: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { projectIds, roundId } = input
|
||||
|
||||
// Step 1: Fetch round to get programId
|
||||
const round = await ctx.prisma.round.findUnique({
|
||||
where: { id: roundId },
|
||||
select: {
|
||||
id: true,
|
||||
programId: true,
|
||||
name: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!round) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Round not found',
|
||||
})
|
||||
}
|
||||
|
||||
// Step 2: Fetch all projects to validate
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: {
|
||||
id: { in: projectIds },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
programId: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Validate all projects were found
|
||||
if (projects.length !== projectIds.length) {
|
||||
const foundIds = new Set(projects.map((p) => p.id))
|
||||
const missingIds = projectIds.filter((id) => !foundIds.has(id))
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `Some projects were not found: ${missingIds.join(', ')}`,
|
||||
})
|
||||
}
|
||||
|
||||
// Validate all projects belong to the same program as the round
|
||||
const invalidProjects = projects.filter(
|
||||
(p) => p.programId !== round.programId
|
||||
)
|
||||
if (invalidProjects.length > 0) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `Cannot assign projects from different programs. The following projects do not belong to the target program: ${invalidProjects
|
||||
.map((p) => p.title)
|
||||
.join(', ')}`,
|
||||
})
|
||||
}
|
||||
|
||||
// Step 3: Perform bulk assignment in a transaction
|
||||
const result = await ctx.prisma.$transaction(async (tx) => {
|
||||
// Update all projects
|
||||
const updatedProjects = await tx.project.updateMany({
|
||||
where: {
|
||||
id: { in: projectIds },
|
||||
},
|
||||
data: {
|
||||
roundId: roundId,
|
||||
status: 'ASSIGNED',
|
||||
},
|
||||
})
|
||||
|
||||
// Create status history records for each project
|
||||
await tx.projectStatusHistory.createMany({
|
||||
data: projectIds.map((projectId) => ({
|
||||
projectId,
|
||||
status: 'ASSIGNED',
|
||||
changedBy: ctx.user?.id,
|
||||
})),
|
||||
})
|
||||
|
||||
// Create audit log
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user?.id,
|
||||
action: 'BULK_ASSIGN_TO_ROUND',
|
||||
entityType: 'Project',
|
||||
detailsJson: {
|
||||
roundId,
|
||||
roundName: round.name,
|
||||
projectCount: projectIds.length,
|
||||
projectIds,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return updatedProjects
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
assignedCount: result.count,
|
||||
roundId,
|
||||
roundName: round.name,
|
||||
}
|
||||
}),
|
||||
})
|
||||
Reference in New Issue
Block a user