import { z } from 'zod' import { TRPCError } from '@trpc/server' import { router, publicProcedure } from '../trpc' import { CompetitionCategory, OceanIssue, TeamMemberRole } from '@prisma/client' // 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 competitionCategory: z.nativeEnum(CompetitionCategory), // 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 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), // 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', }), }) export type ApplicationFormData = z.infer export const applicationRouter = router({ /** * Get application configuration for a round */ getConfig: publicProcedure .input(z.object({ roundSlug: z.string() })) .query(async ({ ctx, input }) => { const round = await ctx.prisma.round.findFirst({ where: { slug: input.roundSlug }, include: { program: { select: { id: true, name: true, year: true, description: 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 } } 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', }, { value: 'STARTUP', label: 'Start-ups', description: 'For established companies working on ocean protection solutions', }, ], } }), /** * Submit a new application */ submit: publicProcedure .input( z.object({ roundId: z.string(), data: applicationSchema, }) ) .mutation(async ({ ctx, input }) => { const { 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) { 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, }, }) } // Create the project const project = await ctx.prisma.project.create({ data: { roundId, title: data.projectName, teamName: data.teamName, description: data.description, status: 'SUBMITTED', competitionCategory: data.competitionCategory, oceanIssue: data.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, metadataJson: { contactPhone: data.contactPhone, startupCreatedDate: data.startupCreatedDate, gdprConsentAt: now.toISOString(), }, }, }) // 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 ctx.prisma.auditLog.create({ data: { 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, }, }) 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}.`, } }), /** * Check if email is already registered for a round */ checkEmailAvailability: publicProcedure .input( z.object({ roundId: z.string(), email: z.string().email(), }) ) .query(async ({ ctx, input }) => { const 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' : null, } }), })