Initial commit: MOPC platform with Docker deployment setup
Full Next.js 15 platform with tRPC, Prisma, PostgreSQL, NextAuth. Includes production Dockerfile (multi-stage, port 7600), docker-compose with registry-based image pull, Gitea Actions CI workflow, nginx config for portal.monaco-opc.com, deployment scripts, and DEPLOYMENT.md guide. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
335
src/server/routers/application.ts
Normal file
335
src/server/routers/application.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
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,
|
||||
}
|
||||
}),
|
||||
})
|
||||
Reference in New Issue
Block a user