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:
@@ -33,6 +33,8 @@ import { notificationRouter } from './notification'
|
||||
import { roundTemplateRouter } from './roundTemplate'
|
||||
import { messageRouter } from './message'
|
||||
import { webhookRouter } from './webhook'
|
||||
import { projectPoolRouter } from './project-pool'
|
||||
import { wizardTemplateRouter } from './wizard-template'
|
||||
|
||||
/**
|
||||
* Root tRPC router that combines all domain routers
|
||||
@@ -72,6 +74,8 @@ export const appRouter = router({
|
||||
roundTemplate: roundTemplateRouter,
|
||||
message: messageRouter,
|
||||
webhook: webhookRouter,
|
||||
projectPool: projectPoolRouter,
|
||||
wizardTemplate: wizardTemplateRouter,
|
||||
})
|
||||
|
||||
export type AppRouter = typeof appRouter
|
||||
|
||||
@@ -195,6 +195,7 @@ 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,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from '../services/in-app-notification'
|
||||
import { checkRateLimit } from '@/lib/rate-limit'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
import { parseWizardConfig } from '@/lib/wizard-config'
|
||||
|
||||
// Zod schemas for the application form
|
||||
const teamMemberSchema = z.object({
|
||||
@@ -19,8 +20,8 @@ const teamMemberSchema = z.object({
|
||||
})
|
||||
|
||||
const applicationSchema = z.object({
|
||||
// Step 1: Category
|
||||
competitionCategory: z.nativeEnum(CompetitionCategory),
|
||||
// Step 1: Category (string to support admin-configured custom values)
|
||||
competitionCategory: z.string().min(1, 'Competition category is required'),
|
||||
|
||||
// Step 2: Contact Info
|
||||
contactName: z.string().min(2, 'Full name is required'),
|
||||
@@ -29,11 +30,11 @@ const applicationSchema = z.object({
|
||||
country: z.string().min(2, 'Country is required'),
|
||||
city: z.string().optional(),
|
||||
|
||||
// Step 3: Project Details
|
||||
// Step 3: Project Details (string to support admin-configured custom values)
|
||||
projectName: z.string().min(2, 'Project name is required').max(200),
|
||||
teamName: z.string().optional(),
|
||||
description: z.string().min(20, 'Description must be at least 20 characters'),
|
||||
oceanIssue: z.nativeEnum(OceanIssue),
|
||||
oceanIssue: z.string().min(1, 'Ocean issue is required'),
|
||||
|
||||
// Step 4: Team Members
|
||||
teamMembers: z.array(teamMemberSchema).optional(),
|
||||
@@ -50,108 +51,203 @@ const applicationSchema = z.object({
|
||||
}),
|
||||
})
|
||||
|
||||
// Passthrough version for tRPC input (allows custom fields to pass through)
|
||||
const applicationInputSchema = applicationSchema.passthrough()
|
||||
|
||||
export type ApplicationFormData = z.infer<typeof applicationSchema>
|
||||
|
||||
// Known core field names that are stored in dedicated columns (not custom fields)
|
||||
const CORE_FIELD_NAMES = new Set([
|
||||
'competitionCategory', 'contactName', 'contactEmail', 'contactPhone',
|
||||
'country', 'city', 'projectName', 'teamName', 'description', 'oceanIssue',
|
||||
'teamMembers', 'institution', 'startupCreatedDate', 'wantsMentorship',
|
||||
'referralSource', 'gdprConsent',
|
||||
])
|
||||
|
||||
/**
|
||||
* Extract custom field values from form data based on wizard config.
|
||||
* Returns an object with { customFields: { fieldId: value } } if any custom fields exist.
|
||||
*/
|
||||
function extractCustomFieldData(
|
||||
settingsJson: unknown,
|
||||
formData: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
const config = parseWizardConfig(settingsJson)
|
||||
if (!config.customFields?.length) return {}
|
||||
|
||||
const customFieldData: Record<string, unknown> = {}
|
||||
for (const field of config.customFields) {
|
||||
const value = formData[field.id as keyof typeof formData]
|
||||
if (value !== undefined && value !== '' && value !== null) {
|
||||
customFieldData[field.id] = value
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(customFieldData).length === 0) return {}
|
||||
return { customFields: customFieldData }
|
||||
}
|
||||
|
||||
export const applicationRouter = router({
|
||||
/**
|
||||
* Get application configuration for a round
|
||||
* Get application configuration for a round or edition
|
||||
*/
|
||||
getConfig: publicProcedure
|
||||
.input(z.object({ roundSlug: z.string() }))
|
||||
.input(
|
||||
z.object({
|
||||
slug: z.string(),
|
||||
mode: z.enum(['edition', 'round']).default('round'),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const round = await ctx.prisma.round.findFirst({
|
||||
where: { slug: input.roundSlug },
|
||||
include: {
|
||||
const now = new Date()
|
||||
|
||||
if (input.mode === 'edition') {
|
||||
// Edition-wide application mode
|
||||
const program = await ctx.prisma.program.findFirst({
|
||||
where: { slug: input.slug },
|
||||
})
|
||||
|
||||
if (!program) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Program not found',
|
||||
})
|
||||
}
|
||||
|
||||
// Check if program supports edition-wide applications
|
||||
const settingsJson = (program.settingsJson || {}) as Record<string, unknown>
|
||||
const applyMode = (settingsJson.applyMode as string) || 'round'
|
||||
|
||||
if (applyMode !== 'edition') {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'This program does not support edition-wide applications',
|
||||
})
|
||||
}
|
||||
|
||||
// Check if applications are open (based on program dates)
|
||||
const submissionStartDate = settingsJson.submissionStartDate
|
||||
? new Date(settingsJson.submissionStartDate as string)
|
||||
: null
|
||||
const submissionEndDate = settingsJson.submissionEndDate
|
||||
? new Date(settingsJson.submissionEndDate as string)
|
||||
: null
|
||||
|
||||
let isOpen = false
|
||||
let gracePeriodEnd: Date | null = null
|
||||
|
||||
if (submissionStartDate && submissionEndDate) {
|
||||
isOpen = now >= submissionStartDate && now <= submissionEndDate
|
||||
|
||||
// Check grace period
|
||||
const lateSubmissionGrace = settingsJson.lateSubmissionGrace as number | undefined
|
||||
if (!isOpen && lateSubmissionGrace) {
|
||||
gracePeriodEnd = new Date(submissionEndDate.getTime() + lateSubmissionGrace * 60 * 60 * 1000)
|
||||
isOpen = now <= gracePeriodEnd
|
||||
}
|
||||
} else {
|
||||
isOpen = program.status === 'ACTIVE'
|
||||
}
|
||||
|
||||
const wizardConfig = parseWizardConfig(program.settingsJson)
|
||||
|
||||
return {
|
||||
mode: 'edition' as const,
|
||||
program: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
year: true,
|
||||
description: true,
|
||||
id: program.id,
|
||||
name: program.name,
|
||||
year: program.year,
|
||||
description: program.description,
|
||||
slug: program.slug,
|
||||
submissionStartDate,
|
||||
submissionEndDate,
|
||||
gracePeriodEnd,
|
||||
isOpen,
|
||||
},
|
||||
wizardConfig,
|
||||
oceanIssueOptions: wizardConfig.oceanIssues ?? [],
|
||||
competitionCategories: wizardConfig.competitionCategories ?? [],
|
||||
}
|
||||
} else {
|
||||
// Round-specific application mode (backward compatible)
|
||||
const round = await ctx.prisma.round.findFirst({
|
||||
where: { slug: input.slug },
|
||||
include: {
|
||||
program: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
year: true,
|
||||
description: true,
|
||||
settingsJson: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!round) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Application round not found',
|
||||
})
|
||||
}
|
||||
|
||||
// Check if submissions are open
|
||||
const now = new Date()
|
||||
let isOpen = false
|
||||
|
||||
if (round.submissionStartDate && round.submissionEndDate) {
|
||||
isOpen = now >= round.submissionStartDate && now <= round.submissionEndDate
|
||||
} else if (round.submissionDeadline) {
|
||||
isOpen = now <= round.submissionDeadline
|
||||
} else {
|
||||
isOpen = round.status === 'ACTIVE'
|
||||
}
|
||||
|
||||
// Calculate grace period if applicable
|
||||
let gracePeriodEnd: Date | null = null
|
||||
if (round.lateSubmissionGrace && round.submissionEndDate) {
|
||||
gracePeriodEnd = new Date(round.submissionEndDate.getTime() + round.lateSubmissionGrace * 60 * 60 * 1000)
|
||||
if (now <= gracePeriodEnd) {
|
||||
isOpen = true
|
||||
if (!round) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Application round not found',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
round: {
|
||||
id: round.id,
|
||||
name: round.name,
|
||||
slug: round.slug,
|
||||
submissionStartDate: round.submissionStartDate,
|
||||
submissionEndDate: round.submissionEndDate,
|
||||
submissionDeadline: round.submissionDeadline,
|
||||
lateSubmissionGrace: round.lateSubmissionGrace,
|
||||
gracePeriodEnd,
|
||||
phase1Deadline: round.phase1Deadline,
|
||||
phase2Deadline: round.phase2Deadline,
|
||||
isOpen,
|
||||
},
|
||||
program: round.program,
|
||||
oceanIssueOptions: [
|
||||
{ value: 'POLLUTION_REDUCTION', label: 'Reduction of pollution (plastics, chemicals, noise, light,...)' },
|
||||
{ value: 'CLIMATE_MITIGATION', label: 'Mitigation of climate change and sea-level rise' },
|
||||
{ value: 'TECHNOLOGY_INNOVATION', label: 'Technology & innovations' },
|
||||
{ value: 'SUSTAINABLE_SHIPPING', label: 'Sustainable shipping & yachting' },
|
||||
{ value: 'BLUE_CARBON', label: 'Blue carbon' },
|
||||
{ value: 'HABITAT_RESTORATION', label: 'Restoration of marine habitats & ecosystems' },
|
||||
{ value: 'COMMUNITY_CAPACITY', label: 'Capacity building for coastal communities' },
|
||||
{ value: 'SUSTAINABLE_FISHING', label: 'Sustainable fishing and aquaculture & blue food' },
|
||||
{ value: 'CONSUMER_AWARENESS', label: 'Consumer awareness and education' },
|
||||
{ value: 'OCEAN_ACIDIFICATION', label: 'Mitigation of ocean acidification' },
|
||||
{ value: 'OTHER', label: 'Other' },
|
||||
],
|
||||
competitionCategories: [
|
||||
{
|
||||
value: 'BUSINESS_CONCEPT',
|
||||
label: 'Business Concepts',
|
||||
description: 'For students and recent graduates with innovative ocean-focused business ideas',
|
||||
// Check if submissions are open
|
||||
let isOpen = false
|
||||
|
||||
if (round.submissionStartDate && round.submissionEndDate) {
|
||||
isOpen = now >= round.submissionStartDate && now <= round.submissionEndDate
|
||||
} else if (round.submissionDeadline) {
|
||||
isOpen = now <= round.submissionDeadline
|
||||
} else {
|
||||
isOpen = round.status === 'ACTIVE'
|
||||
}
|
||||
|
||||
// Calculate grace period if applicable
|
||||
let gracePeriodEnd: Date | null = null
|
||||
if (round.lateSubmissionGrace && round.submissionEndDate) {
|
||||
gracePeriodEnd = new Date(round.submissionEndDate.getTime() + round.lateSubmissionGrace * 60 * 60 * 1000)
|
||||
if (now <= gracePeriodEnd) {
|
||||
isOpen = true
|
||||
}
|
||||
}
|
||||
|
||||
const roundWizardConfig = parseWizardConfig(round.program.settingsJson)
|
||||
const { settingsJson: _s, ...programData } = round.program
|
||||
|
||||
return {
|
||||
mode: 'round' as const,
|
||||
round: {
|
||||
id: round.id,
|
||||
name: round.name,
|
||||
slug: round.slug,
|
||||
submissionStartDate: round.submissionStartDate,
|
||||
submissionEndDate: round.submissionEndDate,
|
||||
submissionDeadline: round.submissionDeadline,
|
||||
lateSubmissionGrace: round.lateSubmissionGrace,
|
||||
gracePeriodEnd,
|
||||
phase1Deadline: round.phase1Deadline,
|
||||
phase2Deadline: round.phase2Deadline,
|
||||
isOpen,
|
||||
},
|
||||
{
|
||||
value: 'STARTUP',
|
||||
label: 'Start-ups',
|
||||
description: 'For established companies working on ocean protection solutions',
|
||||
},
|
||||
],
|
||||
program: programData,
|
||||
wizardConfig: roundWizardConfig,
|
||||
oceanIssueOptions: roundWizardConfig.oceanIssues ?? [],
|
||||
competitionCategories: roundWizardConfig.competitionCategories ?? [],
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Submit a new application
|
||||
* Submit a new application (edition-wide or round-specific)
|
||||
*/
|
||||
submit: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
roundId: z.string(),
|
||||
data: applicationSchema,
|
||||
mode: z.enum(['edition', 'round']).default('round'),
|
||||
programId: z.string().optional(),
|
||||
roundId: z.string().optional(),
|
||||
data: applicationInputSchema,
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
@@ -165,56 +261,143 @@ export const applicationRouter = router({
|
||||
})
|
||||
}
|
||||
|
||||
const { roundId, data } = input
|
||||
const { mode, programId, roundId, data } = input
|
||||
|
||||
// Verify round exists and is open
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: roundId },
|
||||
include: { program: true },
|
||||
})
|
||||
|
||||
const now = new Date()
|
||||
|
||||
// Check submission window
|
||||
let isOpen = false
|
||||
if (round.submissionStartDate && round.submissionEndDate) {
|
||||
isOpen = now >= round.submissionStartDate && now <= round.submissionEndDate
|
||||
|
||||
// Check grace period
|
||||
if (!isOpen && round.lateSubmissionGrace) {
|
||||
const gracePeriodEnd = new Date(
|
||||
round.submissionEndDate.getTime() + round.lateSubmissionGrace * 60 * 60 * 1000
|
||||
)
|
||||
isOpen = now <= gracePeriodEnd
|
||||
}
|
||||
} else if (round.submissionDeadline) {
|
||||
isOpen = now <= round.submissionDeadline
|
||||
} else {
|
||||
isOpen = round.status === 'ACTIVE'
|
||||
}
|
||||
|
||||
if (!isOpen) {
|
||||
// Validate input based on mode
|
||||
if (mode === 'edition' && !programId) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Applications are currently closed for this round',
|
||||
message: 'programId is required for edition-wide applications',
|
||||
})
|
||||
}
|
||||
|
||||
// Check if email already submitted for this round
|
||||
const existingProject = await ctx.prisma.project.findFirst({
|
||||
where: {
|
||||
roundId,
|
||||
submittedByEmail: data.contactEmail,
|
||||
},
|
||||
})
|
||||
|
||||
if (existingProject) {
|
||||
if (mode === 'round' && !roundId) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: 'An application with this email already exists for this round',
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'roundId is required for round-specific applications',
|
||||
})
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
let program: { id: string; name: string; year: number; status: string; settingsJson?: unknown }
|
||||
let isOpen = false
|
||||
|
||||
if (mode === 'edition') {
|
||||
// Edition-wide application
|
||||
program = await ctx.prisma.program.findUniqueOrThrow({
|
||||
where: { id: programId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
year: true,
|
||||
status: true,
|
||||
settingsJson: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Check if program supports edition-wide applications
|
||||
const settingsJson = (program.settingsJson || {}) as Record<string, unknown>
|
||||
const applyMode = (settingsJson.applyMode as string) || 'round'
|
||||
|
||||
if (applyMode !== 'edition') {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'This program does not support edition-wide applications',
|
||||
})
|
||||
}
|
||||
|
||||
// Check submission window
|
||||
const submissionStartDate = settingsJson.submissionStartDate
|
||||
? new Date(settingsJson.submissionStartDate as string)
|
||||
: null
|
||||
const submissionEndDate = settingsJson.submissionEndDate
|
||||
? new Date(settingsJson.submissionEndDate as string)
|
||||
: null
|
||||
|
||||
if (submissionStartDate && submissionEndDate) {
|
||||
isOpen = now >= submissionStartDate && now <= submissionEndDate
|
||||
|
||||
// Check grace period
|
||||
const lateSubmissionGrace = settingsJson.lateSubmissionGrace as number | undefined
|
||||
if (!isOpen && lateSubmissionGrace) {
|
||||
const gracePeriodEnd = new Date(submissionEndDate.getTime() + lateSubmissionGrace * 60 * 60 * 1000)
|
||||
isOpen = now <= gracePeriodEnd
|
||||
}
|
||||
} else {
|
||||
isOpen = program.status === 'ACTIVE'
|
||||
}
|
||||
|
||||
if (!isOpen) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Applications are currently closed for this edition',
|
||||
})
|
||||
}
|
||||
|
||||
// Check if email already submitted for this edition
|
||||
const existingProject = await ctx.prisma.project.findFirst({
|
||||
where: {
|
||||
programId,
|
||||
roundId: null,
|
||||
submittedByEmail: data.contactEmail,
|
||||
},
|
||||
})
|
||||
|
||||
if (existingProject) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: 'An application with this email already exists for this edition',
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Round-specific application (backward compatible)
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: roundId },
|
||||
include: { program: true },
|
||||
})
|
||||
|
||||
program = round.program
|
||||
|
||||
// Check submission window
|
||||
if (round.submissionStartDate && round.submissionEndDate) {
|
||||
isOpen = now >= round.submissionStartDate && now <= round.submissionEndDate
|
||||
|
||||
// Check grace period
|
||||
if (!isOpen && round.lateSubmissionGrace) {
|
||||
const gracePeriodEnd = new Date(
|
||||
round.submissionEndDate.getTime() + round.lateSubmissionGrace * 60 * 60 * 1000
|
||||
)
|
||||
isOpen = now <= gracePeriodEnd
|
||||
}
|
||||
} else if (round.submissionDeadline) {
|
||||
isOpen = now <= round.submissionDeadline
|
||||
} else {
|
||||
isOpen = round.status === 'ACTIVE'
|
||||
}
|
||||
|
||||
if (!isOpen) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Applications are currently closed for this round',
|
||||
})
|
||||
}
|
||||
|
||||
// Check if email already submitted for this round
|
||||
const existingProject = await ctx.prisma.project.findFirst({
|
||||
where: {
|
||||
roundId,
|
||||
submittedByEmail: data.contactEmail,
|
||||
},
|
||||
})
|
||||
|
||||
if (existingProject) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: 'An application with this email already exists for this round',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user exists, or create a new applicant user
|
||||
let user = await ctx.prisma.user.findUnique({
|
||||
where: { email: data.contactEmail },
|
||||
@@ -232,15 +415,26 @@ export const applicationRouter = router({
|
||||
})
|
||||
}
|
||||
|
||||
// Map string values to Prisma enums (safe for admin-configured custom values)
|
||||
const validCategories = Object.values(CompetitionCategory) as string[]
|
||||
const validOceanIssues = Object.values(OceanIssue) as string[]
|
||||
const categoryEnum = validCategories.includes(data.competitionCategory)
|
||||
? (data.competitionCategory as CompetitionCategory)
|
||||
: null
|
||||
const oceanIssueEnum = validOceanIssues.includes(data.oceanIssue)
|
||||
? (data.oceanIssue as OceanIssue)
|
||||
: null
|
||||
|
||||
// Create the project
|
||||
const project = await ctx.prisma.project.create({
|
||||
data: {
|
||||
roundId,
|
||||
programId: program.id,
|
||||
roundId: mode === 'round' ? roundId! : null,
|
||||
title: data.projectName,
|
||||
teamName: data.teamName,
|
||||
description: data.description,
|
||||
competitionCategory: data.competitionCategory,
|
||||
oceanIssue: data.oceanIssue,
|
||||
competitionCategory: categoryEnum,
|
||||
oceanIssue: oceanIssueEnum,
|
||||
country: data.country,
|
||||
geographicZone: data.city ? `${data.city}, ${data.country}` : data.country,
|
||||
institution: data.institution,
|
||||
@@ -254,6 +448,12 @@ export const applicationRouter = router({
|
||||
contactPhone: data.contactPhone,
|
||||
startupCreatedDate: data.startupCreatedDate,
|
||||
gdprConsentAt: now.toISOString(),
|
||||
applicationMode: mode,
|
||||
// Store raw string values for custom categories/issues
|
||||
...(categoryEnum ? {} : { competitionCategoryRaw: data.competitionCategory }),
|
||||
...(oceanIssueEnum ? {} : { oceanIssueRaw: data.oceanIssue }),
|
||||
// Store custom field values from wizard config
|
||||
...extractCustomFieldData(program.settingsJson, data),
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -320,12 +520,12 @@ export const applicationRouter = router({
|
||||
userId: user.id,
|
||||
type: NotificationTypes.APPLICATION_SUBMITTED,
|
||||
title: 'Application Received',
|
||||
message: `Your application for "${data.projectName}" has been successfully submitted to ${round.program.name}.`,
|
||||
message: `Your application for "${data.projectName}" has been successfully submitted to ${program.name}.`,
|
||||
linkUrl: `/team/projects/${project.id}`,
|
||||
linkLabel: 'View Application',
|
||||
metadata: {
|
||||
projectName: data.projectName,
|
||||
programName: round.program.name,
|
||||
programName: program.name,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -340,24 +540,26 @@ export const applicationRouter = router({
|
||||
projectName: data.projectName,
|
||||
applicantName: data.contactName,
|
||||
applicantEmail: data.contactEmail,
|
||||
programName: round.program.name,
|
||||
programName: program.name,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
projectId: project.id,
|
||||
message: `Thank you for applying to ${round.program.name} ${round.program.year}! We will review your application and contact you at ${data.contactEmail}.`,
|
||||
message: `Thank you for applying to ${program.name} ${program.year}! We will review your application and contact you at ${data.contactEmail}.`,
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Check if email is already registered for a round
|
||||
* Check if email is already registered for a round or edition
|
||||
*/
|
||||
checkEmailAvailability: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
roundId: z.string(),
|
||||
mode: z.enum(['edition', 'round']).default('round'),
|
||||
programId: z.string().optional(),
|
||||
roundId: z.string().optional(),
|
||||
email: z.string().email(),
|
||||
})
|
||||
)
|
||||
@@ -372,17 +574,28 @@ export const applicationRouter = router({
|
||||
})
|
||||
}
|
||||
|
||||
const existing = await ctx.prisma.project.findFirst({
|
||||
where: {
|
||||
roundId: input.roundId,
|
||||
submittedByEmail: input.email,
|
||||
},
|
||||
})
|
||||
let existing
|
||||
if (input.mode === 'edition') {
|
||||
existing = await ctx.prisma.project.findFirst({
|
||||
where: {
|
||||
programId: input.programId,
|
||||
roundId: null,
|
||||
submittedByEmail: input.email,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
existing = await ctx.prisma.project.findFirst({
|
||||
where: {
|
||||
roundId: input.roundId,
|
||||
submittedByEmail: input.email,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
available: !existing,
|
||||
message: existing
|
||||
? 'An application with this email already exists for this round'
|
||||
? `An application with this email already exists for this ${input.mode === 'edition' ? 'edition' : 'round'}`
|
||||
: null,
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -115,6 +115,7 @@ export const fileRouter = router({
|
||||
fileType: z.enum(['EXEC_SUMMARY', 'PRESENTATION', 'VIDEO', 'OTHER']),
|
||||
mimeType: z.string(),
|
||||
size: z.number().int().positive(),
|
||||
roundId: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
@@ -128,6 +129,19 @@ export const fileRouter = router({
|
||||
})
|
||||
}
|
||||
|
||||
// Calculate isLate flag if roundId is provided
|
||||
let isLate = false
|
||||
if (input.roundId) {
|
||||
const round = await ctx.prisma.round.findUnique({
|
||||
where: { id: input.roundId },
|
||||
select: { votingEndAt: true },
|
||||
})
|
||||
|
||||
if (round?.votingEndAt) {
|
||||
isLate = new Date() > round.votingEndAt
|
||||
}
|
||||
}
|
||||
|
||||
const bucket = BUCKET_NAME
|
||||
const objectKey = generateObjectKey(input.projectId, input.fileName)
|
||||
|
||||
@@ -143,6 +157,8 @@ export const fileRouter = router({
|
||||
size: input.size,
|
||||
bucket,
|
||||
objectKey,
|
||||
roundId: input.roundId,
|
||||
isLate,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -157,6 +173,8 @@ export const fileRouter = router({
|
||||
projectId: input.projectId,
|
||||
fileName: input.fileName,
|
||||
fileType: input.fileType,
|
||||
roundId: input.roundId,
|
||||
isLate,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
|
||||
@@ -185,6 +185,7 @@ export const notionImportRouter = router({
|
||||
// Create project
|
||||
await ctx.prisma.project.create({
|
||||
data: {
|
||||
programId: round.programId,
|
||||
roundId: round.id,
|
||||
status: 'SUBMITTED',
|
||||
title: title.trim(),
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { z } from 'zod'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
||||
import { logAudit } from '../utils/audit'
|
||||
import { wizardConfigSchema } from '@/types/wizard-config'
|
||||
import { parseWizardConfig } from '@/lib/wizard-config'
|
||||
|
||||
export const programRouter = router({
|
||||
/**
|
||||
@@ -93,8 +96,10 @@ export const programRouter = router({
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
slug: z.string().min(1).max(100).optional(),
|
||||
status: z.enum(['DRAFT', 'ACTIVE', 'ARCHIVED']).optional(),
|
||||
description: z.string().optional(),
|
||||
settingsJson: z.record(z.any()).optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
@@ -145,4 +150,66 @@ export const programRouter = router({
|
||||
|
||||
return program
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get wizard config for a program (parsed from settingsJson)
|
||||
*/
|
||||
getWizardConfig: protectedProcedure
|
||||
.input(z.object({ programId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const program = await ctx.prisma.program.findUniqueOrThrow({
|
||||
where: { id: input.programId },
|
||||
select: { settingsJson: true },
|
||||
})
|
||||
return parseWizardConfig(program.settingsJson)
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update wizard config for a program (admin only)
|
||||
*/
|
||||
updateWizardConfig: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
programId: z.string(),
|
||||
wizardConfig: wizardConfigSchema,
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const program = await ctx.prisma.program.findUniqueOrThrow({
|
||||
where: { id: input.programId },
|
||||
select: { settingsJson: true },
|
||||
})
|
||||
|
||||
const currentSettings = (program.settingsJson || {}) as Record<string, unknown>
|
||||
|
||||
const updatedSettings = {
|
||||
...currentSettings,
|
||||
wizardConfig: input.wizardConfig,
|
||||
}
|
||||
|
||||
await ctx.prisma.program.update({
|
||||
where: { id: input.programId },
|
||||
data: {
|
||||
settingsJson: updatedSettings as Prisma.InputJsonValue,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'Program',
|
||||
entityId: input.programId,
|
||||
detailsJson: {
|
||||
field: 'wizardConfig',
|
||||
stepsEnabled: input.wizardConfig.steps.filter((s) => s.enabled).length,
|
||||
totalSteps: input.wizardConfig.steps.length,
|
||||
customFieldsCount: input.wizardConfig.customFields?.length ?? 0,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
}),
|
||||
})
|
||||
|
||||
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,
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -298,10 +298,18 @@ export const projectRouter = router({
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { metadataJson, ...rest } = input
|
||||
|
||||
// Get round to fetch programId
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
select: { programId: true },
|
||||
})
|
||||
|
||||
const project = await ctx.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.project.create({
|
||||
data: {
|
||||
...rest,
|
||||
programId: round.programId,
|
||||
metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
@@ -557,11 +565,12 @@ export const projectRouter = router({
|
||||
|
||||
// Create projects in a transaction
|
||||
const result = await ctx.prisma.$transaction(async (tx) => {
|
||||
// Create all projects with roundId
|
||||
// Create all projects with roundId and programId
|
||||
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,
|
||||
|
||||
@@ -42,6 +42,23 @@ export const roundRouter = router({
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* List rounds for a program (alias for list)
|
||||
*/
|
||||
listByProgram: protectedProcedure
|
||||
.input(z.object({ programId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.round.findMany({
|
||||
where: { programId: input.programId },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
sortOrder: true,
|
||||
},
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get a single round with stats
|
||||
*/
|
||||
|
||||
@@ -213,6 +213,7 @@ export const typeformImportRouter = router({
|
||||
// Create project
|
||||
await ctx.prisma.project.create({
|
||||
data: {
|
||||
programId: round.programId,
|
||||
roundId: round.id,
|
||||
status: 'SUBMITTED',
|
||||
title: String(title).trim(),
|
||||
|
||||
@@ -452,7 +452,7 @@ export const userRouter = router({
|
||||
z.object({
|
||||
email: z.string().email(),
|
||||
name: z.string().optional(),
|
||||
role: z.enum(['JURY_MEMBER', 'MENTOR', 'OBSERVER']).default('JURY_MEMBER'),
|
||||
role: z.enum(['PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).default('JURY_MEMBER'),
|
||||
expertiseTags: z.array(z.string()).optional(),
|
||||
// Optional pre-assignments for jury members
|
||||
assignments: z
|
||||
@@ -468,6 +468,15 @@ export const userRouter = router({
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Prevent non-super-admins from creating program admins
|
||||
const hasAdminRole = input.users.some((u) => u.role === 'PROGRAM_ADMIN')
|
||||
if (hasAdminRole && ctx.user.role !== 'SUPER_ADMIN') {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Only super admins can create program admins',
|
||||
})
|
||||
}
|
||||
|
||||
// Deduplicate input by email (keep first occurrence)
|
||||
const seenEmails = new Set<string>()
|
||||
const uniqueUsers = input.users.filter((u) => {
|
||||
|
||||
76
src/server/routers/wizard-template.ts
Normal file
76
src/server/routers/wizard-template.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { z } from 'zod'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import { router, adminProcedure } from '../trpc'
|
||||
import { wizardConfigSchema } from '@/types/wizard-config'
|
||||
import { logAudit } from '../utils/audit'
|
||||
|
||||
export const wizardTemplateRouter = router({
|
||||
list: adminProcedure
|
||||
.input(z.object({ programId: z.string().optional() }).optional())
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.wizardTemplate.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ isGlobal: true },
|
||||
...(input?.programId ? [{ programId: input.programId }] : []),
|
||||
],
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { creator: { select: { name: true } } },
|
||||
})
|
||||
}),
|
||||
|
||||
create: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
description: z.string().max(500).optional(),
|
||||
config: wizardConfigSchema,
|
||||
isGlobal: z.boolean().default(false),
|
||||
programId: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const template = await ctx.prisma.wizardTemplate.create({
|
||||
data: {
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
config: input.config as unknown as Prisma.InputJsonValue,
|
||||
isGlobal: input.isGlobal,
|
||||
programId: input.programId,
|
||||
createdBy: ctx.user.id,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'WizardTemplate',
|
||||
entityId: template.id,
|
||||
detailsJson: { name: input.name },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return template
|
||||
}),
|
||||
|
||||
delete: adminProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.prisma.wizardTemplate.delete({ where: { id: input.id } })
|
||||
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'DELETE',
|
||||
entityType: 'WizardTemplate',
|
||||
entityId: input.id,
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
}),
|
||||
})
|
||||
Reference in New Issue
Block a user