2026-01-30 13:41:32 +01:00
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router , publicProcedure } from '../trpc'
Implement 15 platform features: digest, availability, templates, comparison, live voting SSE, file versioning, mentorship, messaging, analytics, drafts, webhooks, peer review, audit enhancements, i18n
Features implemented:
- F1: Email digest notifications with cron endpoint and per-user frequency
- F2: Jury availability windows and workload preferences in smart assignment
- F3: Round templates with save-from-round and CRUD management
- F4: Side-by-side project comparison view for jury members
- F5: Real-time voting dashboard with Server-Sent Events (SSE)
- F6: Live voting UX: QR codes, audience voting, tie-breaking, score animations
- F7: File versioning, inline preview, bulk download with presigned URLs
- F8: Mentor dashboard: milestones, private notes, activity tracking
- F9: Communication hub with broadcasts, templates, and recipient targeting
- F10: Advanced analytics: cross-round comparison, juror consistency, diversity metrics, PDF export
- F11: Applicant draft saving with magic link resume and cron cleanup
- F12: Webhook integration layer with HMAC signing, retry, and delivery logs
- F13: Peer review discussions with anonymized scores and threaded comments
- F14: Audit log enhancements: before/after diffs, session grouping, anomaly detection, retention
- F15: i18n foundation with next-intl (EN/FR), cookie-based locale, language switcher
Schema: 12 new models, field additions to User, Project, ProjectFile, LiveVotingSession, LiveVote, MentorAssignment, AuditLog, Program
New routers: roundTemplate, message, webhook (registered in _app.ts)
New services: email-digest, webhook-dispatcher
New cron endpoints: /api/cron/digest, /api/cron/draft-cleanup, /api/cron/audit-cleanup
New API routes: /api/live-voting/stream (SSE), /api/files/bulk-download
All features are admin-configurable via SystemSettings or per-model settingsJson fields.
Docker build verified successfully.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 23:31:41 +01:00
import { Prisma , CompetitionCategory , OceanIssue , TeamMemberRole } from '@prisma/client'
2026-02-04 00:10:51 +01:00
import {
createNotification ,
notifyAdmins ,
NotificationTypes ,
} from '../services/in-app-notification'
2026-02-05 20:31:08 +01:00
import { checkRateLimit } from '@/lib/rate-limit'
2026-02-05 21:09:06 +01:00
import { logAudit } from '@/server/utils/audit'
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 } ) = > {
2026-02-05 20:31:08 +01:00
// Stricter rate limit for application submissions: 5 per hour per IP
const ip = ctx . ip || 'unknown'
const submitRateLimit = checkRateLimit ( ` app-submit: ${ ip } ` , 5 , 60 * 60 * 1000 )
if ( ! submitRateLimit . success ) {
throw new TRPCError ( {
code : 'TOO_MANY_REQUESTS' ,
message : 'Too many application submissions. Please try again later.' ,
} )
}
2026-01-30 13:41:32 +01:00
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
2026-02-05 21:09:06 +01:00
await logAudit ( {
prisma : ctx.prisma ,
userId : user.id ,
action : 'CREATE' ,
entityType : 'Project' ,
entityId : project.id ,
detailsJson : {
source : 'public_application_form' ,
title : data.projectName ,
category : data.competitionCategory ,
2026-01-30 13:41:32 +01:00
} ,
2026-02-05 21:09:06 +01:00
ipAddress : ctx.ip ,
userAgent : ctx.userAgent ,
2026-01-30 13:41:32 +01:00
} )
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 } ) = > {
2026-02-05 20:31:08 +01:00
// Rate limit to prevent email enumeration
const ip = ctx . ip || 'unknown'
const emailCheckLimit = checkRateLimit ( ` email-check: ${ ip } ` , 20 , 15 * 60 * 1000 )
if ( ! emailCheckLimit . success ) {
throw new TRPCError ( {
code : 'TOO_MANY_REQUESTS' ,
message : 'Too many requests. Please try again later.' ,
} )
}
2026-01-30 13:41:32 +01:00
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 ,
}
} ) ,
Implement 15 platform features: digest, availability, templates, comparison, live voting SSE, file versioning, mentorship, messaging, analytics, drafts, webhooks, peer review, audit enhancements, i18n
Features implemented:
- F1: Email digest notifications with cron endpoint and per-user frequency
- F2: Jury availability windows and workload preferences in smart assignment
- F3: Round templates with save-from-round and CRUD management
- F4: Side-by-side project comparison view for jury members
- F5: Real-time voting dashboard with Server-Sent Events (SSE)
- F6: Live voting UX: QR codes, audience voting, tie-breaking, score animations
- F7: File versioning, inline preview, bulk download with presigned URLs
- F8: Mentor dashboard: milestones, private notes, activity tracking
- F9: Communication hub with broadcasts, templates, and recipient targeting
- F10: Advanced analytics: cross-round comparison, juror consistency, diversity metrics, PDF export
- F11: Applicant draft saving with magic link resume and cron cleanup
- F12: Webhook integration layer with HMAC signing, retry, and delivery logs
- F13: Peer review discussions with anonymized scores and threaded comments
- F14: Audit log enhancements: before/after diffs, session grouping, anomaly detection, retention
- F15: i18n foundation with next-intl (EN/FR), cookie-based locale, language switcher
Schema: 12 new models, field additions to User, Project, ProjectFile, LiveVotingSession, LiveVote, MentorAssignment, AuditLog, Program
New routers: roundTemplate, message, webhook (registered in _app.ts)
New services: email-digest, webhook-dispatcher
New cron endpoints: /api/cron/digest, /api/cron/draft-cleanup, /api/cron/audit-cleanup
New API routes: /api/live-voting/stream (SSE), /api/files/bulk-download
All features are admin-configurable via SystemSettings or per-model settingsJson fields.
Docker build verified successfully.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 23:31:41 +01:00
// =========================================================================
// Draft Saving & Resume (F11)
// =========================================================================
/ * *
* Save application as draft with resume token
* /
saveDraft : publicProcedure
. input (
z . object ( {
roundSlug : z.string ( ) ,
email : z.string ( ) . email ( ) ,
draftDataJson : z.record ( z . unknown ( ) ) ,
title : z.string ( ) . optional ( ) ,
} )
)
. mutation ( async ( { ctx , input } ) = > {
// Find round by slug
const round = await ctx . prisma . round . findFirst ( {
where : { slug : input.roundSlug } ,
} )
if ( ! round ) {
throw new TRPCError ( {
code : 'NOT_FOUND' ,
message : 'Round not found' ,
} )
}
// Check if drafts are enabled
const settings = ( round . settingsJson as Record < string , unknown > ) || { }
if ( settings . drafts_enabled === false ) {
throw new TRPCError ( {
code : 'BAD_REQUEST' ,
message : 'Draft saving is not enabled for this round' ,
} )
}
// Calculate draft expiry
const draftExpiryDays = ( settings . draft_expiry_days as number ) || 30
const draftExpiresAt = new Date ( )
draftExpiresAt . setDate ( draftExpiresAt . getDate ( ) + draftExpiryDays )
// Generate resume token
const draftToken = ` draft_ ${ Date . now ( ) } _ ${ Math . random ( ) . toString ( 36 ) . substring ( 2 , 15 ) } `
// Find or create draft project for this email+round
const existingDraft = await ctx . prisma . project . findFirst ( {
where : {
roundId : round.id ,
submittedByEmail : input.email ,
isDraft : true ,
} ,
} )
if ( existingDraft ) {
// Update existing draft
const updated = await ctx . prisma . project . update ( {
where : { id : existingDraft.id } ,
data : {
title : input.title || existingDraft . title ,
draftDataJson : input.draftDataJson as Prisma . InputJsonValue ,
draftExpiresAt ,
metadataJson : {
. . . ( ( existingDraft . metadataJson as Record < string , unknown > ) || { } ) ,
draftToken ,
} as Prisma . InputJsonValue ,
} ,
} )
return { projectId : updated.id , draftToken }
}
// Create new draft project
const project = await ctx . prisma . project . create ( {
data : {
roundId : round.id ,
title : input.title || 'Untitled Draft' ,
isDraft : true ,
draftDataJson : input.draftDataJson as Prisma . InputJsonValue ,
draftExpiresAt ,
submittedByEmail : input.email ,
metadataJson : {
draftToken ,
} ,
} ,
} )
return { projectId : project.id , draftToken }
} ) ,
/ * *
* Resume a draft application using a token
* /
resumeDraft : publicProcedure
. input ( z . object ( { draftToken : z.string ( ) } ) )
. query ( async ( { ctx , input } ) = > {
const projects = await ctx . prisma . project . findMany ( {
where : {
isDraft : true ,
} ,
} )
// Find project with matching token in metadataJson
const project = projects . find ( ( p ) = > {
const metadata = p . metadataJson as Record < string , unknown > | null
return metadata ? . draftToken === input . draftToken
} )
if ( ! project ) {
throw new TRPCError ( {
code : 'NOT_FOUND' ,
message : 'Draft not found or invalid token' ,
} )
}
// Check expiry
if ( project . draftExpiresAt && new Date ( ) > project . draftExpiresAt ) {
throw new TRPCError ( {
code : 'BAD_REQUEST' ,
message : 'This draft has expired' ,
} )
}
return {
projectId : project.id ,
draftDataJson : project.draftDataJson ,
title : project.title ,
roundId : project.roundId ,
}
} ) ,
/ * *
* Submit a saved draft as a final application
* /
submitDraft : publicProcedure
. input (
z . object ( {
projectId : z.string ( ) ,
draftToken : z.string ( ) ,
data : applicationSchema ,
} )
)
. mutation ( async ( { ctx , input } ) = > {
const project = await ctx . prisma . project . findUniqueOrThrow ( {
where : { id : input.projectId } ,
include : { round : { include : { program : true } } } ,
} )
// Verify token
const metadata = ( project . metadataJson as Record < string , unknown > ) || { }
if ( metadata . draftToken !== input . draftToken ) {
throw new TRPCError ( {
code : 'FORBIDDEN' ,
message : 'Invalid draft token' ,
} )
}
if ( ! project . isDraft ) {
throw new TRPCError ( {
code : 'BAD_REQUEST' ,
message : 'This project has already been submitted' ,
} )
}
const now = new Date ( )
const { data } = input
// Find or create 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 ,
} ,
} )
}
// Update project with final data
const updated = await ctx . prisma . project . update ( {
where : { id : input.projectId } ,
data : {
isDraft : false ,
draftDataJson : Prisma.DbNull ,
draftExpiresAt : null ,
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 ,
status : 'SUBMITTED' ,
metadataJson : {
contactPhone : data.contactPhone ,
startupCreatedDate : data.startupCreatedDate ,
gdprConsentAt : now.toISOString ( ) ,
} ,
} ,
} )
// Audit log
try {
await logAudit ( {
prisma : ctx.prisma ,
userId : user.id ,
action : 'DRAFT_SUBMITTED' ,
entityType : 'Project' ,
entityId : updated.id ,
detailsJson : {
source : 'draft_submission' ,
title : data.projectName ,
category : data.competitionCategory ,
} ,
ipAddress : ctx.ip ,
userAgent : ctx.userAgent ,
} )
} catch {
// Never throw on audit failure
}
return {
success : true ,
projectId : updated.id ,
message : ` Thank you for applying to ${ project . round . program . name } ! ` ,
}
} ) ,
/ * *
* Get a read - only preview of draft data
* /
getPreview : publicProcedure
. input ( z . object ( { draftToken : z.string ( ) } ) )
. query ( async ( { ctx , input } ) = > {
const projects = await ctx . prisma . project . findMany ( {
where : {
isDraft : true ,
} ,
} )
const project = projects . find ( ( p ) = > {
const metadata = p . metadataJson as Record < string , unknown > | null
return metadata ? . draftToken === input . draftToken
} )
if ( ! project ) {
throw new TRPCError ( {
code : 'NOT_FOUND' ,
message : 'Draft not found or invalid token' ,
} )
}
return {
title : project.title ,
draftDataJson : project.draftDataJson ,
createdAt : project.createdAt ,
expiresAt : project.draftExpiresAt ,
}
} ) ,
2026-01-30 13:41:32 +01:00
} )