2026-02-14 15:26:42 +01:00
|
|
|
import { z } from 'zod'
|
|
|
|
|
import { TRPCError } from '@trpc/server'
|
|
|
|
|
import { router, publicProcedure } from '../trpc'
|
|
|
|
|
import { Prisma, CompetitionCategory, OceanIssue, TeamMemberRole } from '@prisma/client'
|
|
|
|
|
import {
|
|
|
|
|
createNotification,
|
|
|
|
|
notifyAdmins,
|
|
|
|
|
notifyProjectTeam,
|
|
|
|
|
NotificationTypes,
|
|
|
|
|
} 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({
|
|
|
|
|
name: z.string().min(1, 'Name is required'),
|
|
|
|
|
email: z.string().email('Invalid email address'),
|
|
|
|
|
role: z.nativeEnum(TeamMemberRole).default('MEMBER'),
|
|
|
|
|
title: z.string().optional(),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const applicationSchema = z.object({
|
|
|
|
|
// 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'),
|
|
|
|
|
contactEmail: z.string().email('Invalid email address'),
|
|
|
|
|
contactPhone: z.string().min(5, 'Phone number is required'),
|
|
|
|
|
country: z.string().min(2, 'Country is required'),
|
|
|
|
|
city: z.string().optional(),
|
|
|
|
|
|
|
|
|
|
// 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.string().min(1, 'Ocean issue is required'),
|
|
|
|
|
|
|
|
|
|
// Step 4: Team Members
|
|
|
|
|
teamMembers: z.array(teamMemberSchema).optional(),
|
|
|
|
|
|
|
|
|
|
// Step 5: Additional Info (conditional & optional)
|
|
|
|
|
institution: z.string().optional(), // Required if BUSINESS_CONCEPT
|
|
|
|
|
startupCreatedDate: z.string().optional(), // Required if STARTUP
|
|
|
|
|
wantsMentorship: z.boolean().default(false),
|
|
|
|
|
referralSource: z.string().optional(),
|
|
|
|
|
|
|
|
|
|
// Consent
|
|
|
|
|
gdprConsent: z.boolean().refine((val) => val === true, {
|
|
|
|
|
message: 'You must agree to the data processing terms',
|
|
|
|
|
}),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// 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 or edition
|
|
|
|
|
*/
|
|
|
|
|
getConfig: publicProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
slug: z.string(),
|
|
|
|
|
mode: z.enum(['edition', 'stage']).default('stage'),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
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: {
|
|
|
|
|
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 {
|
|
|
|
|
// Stage-specific application mode (backward compatible with round slug)
|
|
|
|
|
const stage = await ctx.prisma.stage.findFirst({
|
|
|
|
|
where: { slug: input.slug },
|
|
|
|
|
include: {
|
|
|
|
|
track: {
|
|
|
|
|
include: {
|
|
|
|
|
pipeline: {
|
|
|
|
|
include: {
|
|
|
|
|
program: {
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
name: true,
|
|
|
|
|
year: true,
|
|
|
|
|
description: true,
|
|
|
|
|
settingsJson: true,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!stage) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'NOT_FOUND',
|
|
|
|
|
message: 'Application stage not found',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const stageProgram = stage.track.pipeline.program
|
|
|
|
|
const isOpen = stage.windowOpenAt && stage.windowCloseAt
|
|
|
|
|
? now >= stage.windowOpenAt && now <= stage.windowCloseAt
|
|
|
|
|
: stage.status === 'STAGE_ACTIVE'
|
|
|
|
|
|
|
|
|
|
const stageWizardConfig = parseWizardConfig(stageProgram.settingsJson)
|
|
|
|
|
const { settingsJson: _s, ...programData } = stageProgram
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
mode: 'stage' as const,
|
|
|
|
|
stage: {
|
|
|
|
|
id: stage.id,
|
|
|
|
|
name: stage.name,
|
|
|
|
|
slug: stage.slug,
|
|
|
|
|
submissionStartDate: stage.windowOpenAt,
|
|
|
|
|
submissionEndDate: stage.windowCloseAt,
|
|
|
|
|
submissionDeadline: stage.windowCloseAt,
|
|
|
|
|
lateSubmissionGrace: null,
|
|
|
|
|
gracePeriodEnd: null,
|
|
|
|
|
isOpen,
|
|
|
|
|
},
|
|
|
|
|
program: programData,
|
|
|
|
|
wizardConfig: stageWizardConfig,
|
|
|
|
|
oceanIssueOptions: stageWizardConfig.oceanIssues ?? [],
|
|
|
|
|
competitionCategories: stageWizardConfig.competitionCategories ?? [],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Submit a new application (edition-wide or round-specific)
|
|
|
|
|
*/
|
|
|
|
|
submit: publicProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
mode: z.enum(['edition', 'stage']).default('stage'),
|
|
|
|
|
programId: z.string().optional(),
|
|
|
|
|
stageId: z.string().optional(),
|
|
|
|
|
data: applicationInputSchema,
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
// Stricter rate limit for application submissions: 5 per hour per IP
|
|
|
|
|
const ip = ctx.ip || 'unknown'
|
|
|
|
|
const submitRateLimit = checkRateLimit(`app-submit:${ip}`, 5, 60 * 60 * 1000)
|
|
|
|
|
if (!submitRateLimit.success) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'TOO_MANY_REQUESTS',
|
|
|
|
|
message: 'Too many application submissions. Please try again later.',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const { mode, programId, stageId, data } = input
|
|
|
|
|
|
|
|
|
|
// Validate input based on mode
|
|
|
|
|
if (mode === 'edition' && !programId) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: 'programId is required for edition-wide applications',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (mode === 'stage' && !stageId) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: 'stageId is required for stage-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,
|
|
|
|
|
submittedByEmail: data.contactEmail,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (existingProject) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'CONFLICT',
|
|
|
|
|
message: 'An application with this email already exists for this edition',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// Stage-specific application
|
|
|
|
|
const stage = await ctx.prisma.stage.findUniqueOrThrow({
|
|
|
|
|
where: { id: stageId! },
|
|
|
|
|
include: {
|
|
|
|
|
track: {
|
|
|
|
|
include: {
|
|
|
|
|
pipeline: { include: { program: true } },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
program = stage.track.pipeline.program
|
|
|
|
|
|
|
|
|
|
// Check submission window
|
|
|
|
|
if (stage.windowOpenAt && stage.windowCloseAt) {
|
|
|
|
|
isOpen = now >= stage.windowOpenAt && now <= stage.windowCloseAt
|
|
|
|
|
} else {
|
|
|
|
|
isOpen = stage.status === 'STAGE_ACTIVE'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!isOpen) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: 'Applications are currently closed for this stage',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if email already submitted for this stage
|
|
|
|
|
const existingProject = await ctx.prisma.project.findFirst({
|
|
|
|
|
where: {
|
|
|
|
|
programId: program.id,
|
|
|
|
|
submittedByEmail: data.contactEmail,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (existingProject) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'CONFLICT',
|
|
|
|
|
message: 'An application with this email already exists for this stage',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if user exists, or create a new applicant user
|
|
|
|
|
let user = await ctx.prisma.user.findUnique({
|
|
|
|
|
where: { email: data.contactEmail },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!user) {
|
|
|
|
|
user = await ctx.prisma.user.create({
|
|
|
|
|
data: {
|
|
|
|
|
email: data.contactEmail,
|
|
|
|
|
name: data.contactName,
|
|
|
|
|
role: 'APPLICANT',
|
|
|
|
|
status: 'ACTIVE',
|
|
|
|
|
phoneNumber: data.contactPhone,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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: {
|
|
|
|
|
programId: program.id,
|
|
|
|
|
title: data.projectName,
|
|
|
|
|
teamName: data.teamName,
|
|
|
|
|
description: data.description,
|
|
|
|
|
competitionCategory: categoryEnum,
|
|
|
|
|
oceanIssue: oceanIssueEnum,
|
|
|
|
|
country: data.country,
|
|
|
|
|
geographicZone: data.city ? `${data.city}, ${data.country}` : data.country,
|
|
|
|
|
institution: data.institution,
|
|
|
|
|
wantsMentorship: data.wantsMentorship,
|
|
|
|
|
referralSource: data.referralSource,
|
|
|
|
|
submissionSource: 'PUBLIC_FORM',
|
|
|
|
|
submittedByEmail: data.contactEmail,
|
|
|
|
|
submittedByUserId: user.id,
|
|
|
|
|
submittedAt: now,
|
|
|
|
|
metadataJson: {
|
|
|
|
|
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),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Create team lead membership
|
|
|
|
|
await ctx.prisma.teamMember.create({
|
|
|
|
|
data: {
|
|
|
|
|
projectId: project.id,
|
|
|
|
|
userId: user.id,
|
|
|
|
|
role: 'LEAD',
|
|
|
|
|
title: 'Team Lead',
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Create additional team members
|
|
|
|
|
if (data.teamMembers && data.teamMembers.length > 0) {
|
|
|
|
|
for (const member of data.teamMembers) {
|
|
|
|
|
// Find or create user for team member
|
|
|
|
|
let memberUser = await ctx.prisma.user.findUnique({
|
|
|
|
|
where: { email: member.email },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!memberUser) {
|
|
|
|
|
memberUser = await ctx.prisma.user.create({
|
|
|
|
|
data: {
|
|
|
|
|
email: member.email,
|
|
|
|
|
name: member.name,
|
|
|
|
|
role: 'APPLICANT',
|
|
|
|
|
status: 'NONE',
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create team membership
|
|
|
|
|
await ctx.prisma.teamMember.create({
|
|
|
|
|
data: {
|
|
|
|
|
projectId: project.id,
|
|
|
|
|
userId: memberUser.id,
|
|
|
|
|
role: member.role,
|
|
|
|
|
title: member.title,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create audit log
|
|
|
|
|
await logAudit({
|
|
|
|
|
prisma: ctx.prisma,
|
|
|
|
|
userId: user.id,
|
|
|
|
|
action: 'CREATE',
|
|
|
|
|
entityType: 'Project',
|
|
|
|
|
entityId: project.id,
|
|
|
|
|
detailsJson: {
|
|
|
|
|
source: 'public_application_form',
|
|
|
|
|
title: data.projectName,
|
|
|
|
|
category: data.competitionCategory,
|
|
|
|
|
autoAssignedStage: null,
|
|
|
|
|
},
|
|
|
|
|
ipAddress: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Notify applicant of successful submission
|
|
|
|
|
await createNotification({
|
|
|
|
|
userId: user.id,
|
|
|
|
|
type: NotificationTypes.APPLICATION_SUBMITTED,
|
|
|
|
|
title: 'Application Received',
|
|
|
|
|
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: program.name,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Notify admins of new application
|
|
|
|
|
await notifyAdmins({
|
|
|
|
|
type: NotificationTypes.NEW_APPLICATION,
|
|
|
|
|
title: 'New Application',
|
|
|
|
|
message: `New application received: "${data.projectName}" from ${data.contactName}.`,
|
|
|
|
|
linkUrl: `/admin/projects/${project.id}`,
|
|
|
|
|
linkLabel: 'Review Application',
|
|
|
|
|
metadata: {
|
|
|
|
|
projectName: data.projectName,
|
|
|
|
|
applicantName: data.contactName,
|
|
|
|
|
applicantEmail: data.contactEmail,
|
|
|
|
|
programName: program.name,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
success: true,
|
|
|
|
|
projectId: project.id,
|
|
|
|
|
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 or edition
|
|
|
|
|
*/
|
|
|
|
|
checkEmailAvailability: publicProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
mode: z.enum(['edition', 'stage']).default('stage'),
|
|
|
|
|
programId: z.string().optional(),
|
|
|
|
|
stageId: z.string().optional(),
|
|
|
|
|
email: z.string().email(),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
// Rate limit to prevent email enumeration
|
|
|
|
|
const ip = ctx.ip || 'unknown'
|
|
|
|
|
const emailCheckLimit = checkRateLimit(`email-check:${ip}`, 20, 15 * 60 * 1000)
|
|
|
|
|
if (!emailCheckLimit.success) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'TOO_MANY_REQUESTS',
|
|
|
|
|
message: 'Too many requests. Please try again later.',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let existing
|
|
|
|
|
if (input.mode === 'edition') {
|
|
|
|
|
existing = await ctx.prisma.project.findFirst({
|
|
|
|
|
where: {
|
|
|
|
|
programId: input.programId,
|
|
|
|
|
submittedByEmail: input.email,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
} else {
|
|
|
|
|
// For stage-specific applications, check by program (derived from stage)
|
|
|
|
|
if (input.stageId) {
|
|
|
|
|
const stage = await ctx.prisma.stage.findUnique({
|
|
|
|
|
where: { id: input.stageId },
|
|
|
|
|
include: { track: { include: { pipeline: { select: { programId: true } } } } },
|
|
|
|
|
})
|
|
|
|
|
if (stage) {
|
|
|
|
|
existing = await ctx.prisma.project.findFirst({
|
|
|
|
|
where: {
|
|
|
|
|
programId: stage.track.pipeline.programId,
|
|
|
|
|
submittedByEmail: input.email,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
available: !existing,
|
|
|
|
|
message: existing
|
|
|
|
|
? `An application with this email already exists for this ${input.mode === 'edition' ? 'edition' : 'stage'}`
|
|
|
|
|
: null,
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
// =========================================================================
|
|
|
|
|
// Draft Saving & Resume (F11)
|
|
|
|
|
// =========================================================================
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Save application as draft with resume token
|
|
|
|
|
*/
|
|
|
|
|
saveDraft: publicProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
roundSlug: z.string(),
|
|
|
|
|
programId: z.string().optional(),
|
|
|
|
|
email: z.string().email(),
|
|
|
|
|
draftDataJson: z.record(z.unknown()),
|
|
|
|
|
title: z.string().optional(),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
// Find stage by slug
|
|
|
|
|
const stage = await ctx.prisma.stage.findFirst({
|
|
|
|
|
where: { slug: input.roundSlug },
|
|
|
|
|
include: {
|
|
|
|
|
track: {
|
|
|
|
|
include: {
|
|
|
|
|
pipeline: { select: { programId: true } },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!stage) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'NOT_FOUND',
|
|
|
|
|
message: 'Stage not found',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const stageConfig = (stage.configJson as Record<string, unknown>) || {}
|
|
|
|
|
if (stageConfig.drafts_enabled === false) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: 'Draft saving is not enabled for this stage',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const draftExpiryDays = (stageConfig.draft_expiry_days as number) || 30
|
|
|
|
|
const draftExpiresAt = new Date()
|
|
|
|
|
draftExpiresAt.setDate(draftExpiresAt.getDate() + draftExpiryDays)
|
|
|
|
|
|
|
|
|
|
const draftToken = `draft_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`
|
|
|
|
|
|
|
|
|
|
const programId = input.programId || stage.track.pipeline.programId
|
|
|
|
|
|
|
|
|
|
const existingDraft = await ctx.prisma.project.findFirst({
|
|
|
|
|
where: {
|
|
|
|
|
programId,
|
|
|
|
|
submittedByEmail: input.email,
|
|
|
|
|
isDraft: true,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (existingDraft) {
|
|
|
|
|
const updated = await ctx.prisma.project.update({
|
|
|
|
|
where: { id: existingDraft.id },
|
|
|
|
|
data: {
|
|
|
|
|
title: input.title || existingDraft.title,
|
|
|
|
|
draftDataJson: input.draftDataJson as Prisma.InputJsonValue,
|
|
|
|
|
draftExpiresAt,
|
|
|
|
|
metadataJson: {
|
|
|
|
|
...((existingDraft.metadataJson as Record<string, unknown>) || {}),
|
|
|
|
|
draftToken,
|
|
|
|
|
} as Prisma.InputJsonValue,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return { projectId: updated.id, draftToken }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const project = await ctx.prisma.project.create({
|
|
|
|
|
data: {
|
|
|
|
|
programId,
|
|
|
|
|
title: input.title || 'Untitled Draft',
|
|
|
|
|
isDraft: true,
|
|
|
|
|
draftDataJson: input.draftDataJson as Prisma.InputJsonValue,
|
|
|
|
|
draftExpiresAt,
|
|
|
|
|
submittedByEmail: input.email,
|
|
|
|
|
metadataJson: {
|
|
|
|
|
draftToken,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return { projectId: project.id, draftToken }
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Resume a draft application using a token
|
|
|
|
|
*/
|
|
|
|
|
resumeDraft: publicProcedure
|
|
|
|
|
.input(z.object({ draftToken: z.string() }))
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
const projects = await ctx.prisma.project.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
isDraft: true,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Find project with matching token in metadataJson
|
|
|
|
|
const project = projects.find((p) => {
|
|
|
|
|
const metadata = p.metadataJson as Record<string, unknown> | null
|
|
|
|
|
return metadata?.draftToken === input.draftToken
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!project) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'NOT_FOUND',
|
|
|
|
|
message: 'Draft not found or invalid token',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check expiry
|
|
|
|
|
if (project.draftExpiresAt && new Date() > project.draftExpiresAt) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: 'This draft has expired',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
projectId: project.id,
|
|
|
|
|
draftDataJson: project.draftDataJson,
|
|
|
|
|
title: project.title,
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Submit a saved draft as a final application
|
|
|
|
|
*/
|
|
|
|
|
submitDraft: publicProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
projectId: z.string(),
|
|
|
|
|
draftToken: z.string(),
|
|
|
|
|
data: applicationSchema,
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
const project = await ctx.prisma.project.findUniqueOrThrow({
|
|
|
|
|
where: { id: input.projectId },
|
|
|
|
|
include: { program: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Verify token
|
|
|
|
|
const metadata = (project.metadataJson as Record<string, unknown>) || {}
|
|
|
|
|
if (metadata.draftToken !== input.draftToken) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'FORBIDDEN',
|
|
|
|
|
message: 'Invalid draft token',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!project.isDraft) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: 'This project has already been submitted',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const now = new Date()
|
|
|
|
|
const { data } = input
|
|
|
|
|
|
|
|
|
|
// Find or create user
|
|
|
|
|
let user = await ctx.prisma.user.findUnique({
|
|
|
|
|
where: { email: data.contactEmail },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!user) {
|
|
|
|
|
user = await ctx.prisma.user.create({
|
|
|
|
|
data: {
|
|
|
|
|
email: data.contactEmail,
|
|
|
|
|
name: data.contactName,
|
|
|
|
|
role: 'APPLICANT',
|
|
|
|
|
status: 'ACTIVE',
|
|
|
|
|
phoneNumber: data.contactPhone,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Update project with final data
|
|
|
|
|
const updated = await ctx.prisma.project.update({
|
|
|
|
|
where: { id: input.projectId },
|
|
|
|
|
data: {
|
|
|
|
|
isDraft: false,
|
|
|
|
|
draftDataJson: Prisma.DbNull,
|
|
|
|
|
draftExpiresAt: null,
|
|
|
|
|
title: data.projectName,
|
|
|
|
|
teamName: data.teamName,
|
|
|
|
|
description: data.description,
|
|
|
|
|
competitionCategory: data.competitionCategory as CompetitionCategory,
|
|
|
|
|
oceanIssue: data.oceanIssue as OceanIssue,
|
|
|
|
|
country: data.country,
|
|
|
|
|
geographicZone: data.city ? `${data.city}, ${data.country}` : data.country,
|
|
|
|
|
institution: data.institution,
|
|
|
|
|
wantsMentorship: data.wantsMentorship,
|
|
|
|
|
referralSource: data.referralSource,
|
|
|
|
|
submissionSource: 'PUBLIC_FORM',
|
|
|
|
|
submittedByEmail: data.contactEmail,
|
|
|
|
|
submittedByUserId: user.id,
|
|
|
|
|
submittedAt: now,
|
|
|
|
|
status: 'SUBMITTED',
|
|
|
|
|
metadataJson: {
|
|
|
|
|
contactPhone: data.contactPhone,
|
|
|
|
|
startupCreatedDate: data.startupCreatedDate,
|
|
|
|
|
gdprConsentAt: now.toISOString(),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Audit log
|
|
|
|
|
try {
|
|
|
|
|
await logAudit({
|
|
|
|
|
prisma: ctx.prisma,
|
|
|
|
|
userId: user.id,
|
|
|
|
|
action: 'DRAFT_SUBMITTED',
|
|
|
|
|
entityType: 'Project',
|
|
|
|
|
entityId: updated.id,
|
|
|
|
|
detailsJson: {
|
|
|
|
|
source: 'draft_submission',
|
|
|
|
|
title: data.projectName,
|
|
|
|
|
category: data.competitionCategory,
|
|
|
|
|
},
|
|
|
|
|
ipAddress: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
|
|
|
|
})
|
|
|
|
|
} catch {
|
|
|
|
|
// Never throw on audit failure
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
success: true,
|
|
|
|
|
projectId: updated.id,
|
|
|
|
|
message: `Thank you for applying to ${project.program?.name ?? 'the program'}!`,
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get a read-only preview of draft data
|
|
|
|
|
*/
|
|
|
|
|
getPreview: publicProcedure
|
|
|
|
|
.input(z.object({ draftToken: z.string() }))
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
const projects = await ctx.prisma.project.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
isDraft: true,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const project = projects.find((p) => {
|
|
|
|
|
const metadata = p.metadataJson as Record<string, unknown> | null
|
|
|
|
|
return metadata?.draftToken === input.draftToken
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!project) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'NOT_FOUND',
|
|
|
|
|
message: 'Draft not found or invalid token',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
title: project.title,
|
|
|
|
|
draftDataJson: project.draftDataJson,
|
|
|
|
|
createdAt: project.createdAt,
|
|
|
|
|
expiresAt: project.draftExpiresAt,
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
})
|