336 lines
10 KiB
TypeScript
336 lines
10 KiB
TypeScript
|
|
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,
|
||
|
|
}
|
||
|
|
}),
|
||
|
|
})
|