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, 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 // 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 ): Record { const config = parseWizardConfig(settingsJson) if (!config.customFields?.length) return {} const customFieldData: Record = {} 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', 'round']).default('round'), }) ) .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 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 { // 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 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, }, program: programData, wizardConfig: roundWizardConfig, oceanIssueOptions: roundWizardConfig.oceanIssues ?? [], competitionCategories: roundWizardConfig.competitionCategories ?? [], } } }), /** * Submit a new application (edition-wide or round-specific) */ submit: publicProcedure .input( z.object({ mode: z.enum(['edition', 'round']).default('round'), programId: z.string().optional(), roundId: 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, roundId, 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 === 'round' && !roundId) { throw new TRPCError({ 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 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 }, }) 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, roundId: mode === 'round' ? roundId! : null, 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: 'INVITED', }, }) } // 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, }, 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', 'round']).default('round'), programId: z.string().optional(), roundId: 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, 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 ${input.mode === 'edition' ? 'edition' : 'round'}` : null, } }), // ========================================================================= // Draft Saving & Resume (F11) // ========================================================================= /** * Save application as draft with resume token */ saveDraft: publicProcedure .input( z.object({ roundSlug: z.string(), email: z.string().email(), draftDataJson: z.record(z.unknown()), title: z.string().optional(), }) ) .mutation(async ({ ctx, input }) => { // Find round by slug const round = await ctx.prisma.round.findFirst({ where: { slug: input.roundSlug }, }) if (!round) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Round not found', }) } // Check if drafts are enabled const settings = (round.settingsJson as Record) || {} if (settings.drafts_enabled === false) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Draft saving is not enabled for this round', }) } // Calculate draft expiry const draftExpiryDays = (settings.draft_expiry_days as number) || 30 const draftExpiresAt = new Date() draftExpiresAt.setDate(draftExpiresAt.getDate() + draftExpiryDays) // Generate resume token const draftToken = `draft_${Date.now()}_${Math.random().toString(36).substring(2, 15)}` // Find or create draft project for this email+round const existingDraft = await ctx.prisma.project.findFirst({ where: { roundId: round.id, submittedByEmail: input.email, isDraft: true, }, }) if (existingDraft) { // Update existing draft 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) || {}), draftToken, } as Prisma.InputJsonValue, }, }) return { projectId: updated.id, draftToken } } // Create new draft project const project = await ctx.prisma.project.create({ data: { programId: round.programId, roundId: round.id, 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 | 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, roundId: project.roundId, } }), /** * 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: { round: { include: { program: true } } }, }) // Verify token const metadata = (project.metadataJson as Record) || {} 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.round?.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 | 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, } }), })