2026-01-30 13:41:32 +01:00
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router , publicProcedure } from '../trpc'
import { CompetitionCategory , OceanIssue , TeamMemberRole } from '@prisma/client'
2026-02-04 00:10:51 +01:00
import {
createNotification ,
notifyAdmins ,
NotificationTypes ,
} from '../services/in-app-notification'
2026-01-30 13:41:32 +01:00
// 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 < typeof applicationSchema >
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 : {
2026-02-04 14:15:06 +01:00
roundId ,
2026-01-30 13:41:32 +01:00
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 : {
2026-02-04 14:15:06 +01:00
roundId ,
2026-01-30 13:41:32 +01:00
title : data.projectName ,
teamName : data.teamName ,
description : data.description ,
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 ,
} ,
} )
2026-02-04 00:10:51 +01:00
// 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 ${ round . program . name } . ` ,
linkUrl : ` /team/projects/ ${ project . id } ` ,
linkLabel : 'View Application' ,
metadata : {
projectName : data.projectName ,
programName : round.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 : round.program.name ,
} ,
} )
2026-01-30 13:41:32 +01:00
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 : {
2026-02-04 14:15:06 +01:00
roundId : input.roundId ,
2026-01-30 13:41:32 +01:00
submittedByEmail : input.email ,
} ,
} )
return {
available : ! existing ,
message : existing
? 'An application with this email already exists for this round'
: null ,
}
} ) ,
} )