MOPC-App/src/server/routers/application.ts

336 lines
10 KiB
TypeScript
Raw Normal View History

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<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: {
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,
}
}),
})