Files
MOPC-Portal/src/server/routers/application.ts

878 lines
28 KiB
TypeScript
Raw Normal View History

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'
import {
createNotification,
notifyAdmins,
NotificationTypes,
} from '../services/in-app-notification'
Comprehensive platform review: security fixes, query optimization, UI improvements, and code cleanup Security (Critical/High): - Fix path traversal bypass in local storage provider (path.resolve + prefix check) - Fix timing-unsafe HMAC comparison (crypto.timingSafeEqual) - Add auth + ownership checks to email API routes (verify-credentials, change-password) - Remove hardcoded secret key fallback in local storage provider - Add production credential check for MinIO (fail loudly if not set) - Remove DB error details from health check response - Add stricter rate limiting on application submissions (5/hour) - Add rate limiting on email availability check (anti-enumeration) - Change getAIAssignmentJobStatus to adminProcedure - Block dangerous file extensions on upload - Reduce project list max perPage from 5000 to 200 Query Optimization: - Optimize analytics getProjectRankings with select instead of full includes - Fix N+1 in mentor.getSuggestions (batch findMany instead of loop) - Use _count for files instead of fetching full file records in project list - Switch to bulk notifications in assignment and user bulk operations - Batch filtering upserts (25 per transaction instead of all at once) UI/UX: - Replace Inter font with Montserrat in public layout (brand consistency) - Use Logo component in public layout instead of placeholder - Create branded 404 and error pages - Make admin rounds table responsive with mobile card layout - Fix notification bell paths to be role-aware - Replace hardcoded slate colors with semantic tokens in admin sidebar - Force light mode (dark mode untested) - Adjust CardTitle default size - Improve muted-foreground contrast for accessibility (A11Y) - Move profile form state initialization to useEffect Code Quality: - Extract shared toProjectWithRelations to anonymization.ts (removed 3 duplicates) - Remove dead code: getObjectInfo, isValidImageSize, unused batch tag functions, debug logs - Remove unused twilio dependency - Remove redundant email index from schema - Add actual storage object deletion when file records are deleted - Wrap evaluation submit + assignment update in - Add comprehensive platform review document Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 20:31:08 +01:00
import { checkRateLimit } from '@/lib/rate-limit'
import { logAudit } from '@/server/utils/audit'
import { parseWizardConfig } from '@/lib/wizard-config'
// 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 (string to support admin-configured custom values)
competitionCategory: z.string().min(1, 'Competition category is required'),
// 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 (string to support admin-configured custom values)
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.string().min(1, 'Ocean issue is required'),
// 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',
}),
})
// Passthrough version for tRPC input (allows custom fields to pass through)
const applicationInputSchema = applicationSchema.passthrough()
export type ApplicationFormData = z.infer<typeof applicationSchema>
// Known core field names that are stored in dedicated columns (not custom fields)
const CORE_FIELD_NAMES = new Set([
'competitionCategory', 'contactName', 'contactEmail', 'contactPhone',
'country', 'city', 'projectName', 'teamName', 'description', 'oceanIssue',
'teamMembers', 'institution', 'startupCreatedDate', 'wantsMentorship',
'referralSource', 'gdprConsent',
])
/**
* Extract custom field values from form data based on wizard config.
* Returns an object with { customFields: { fieldId: value } } if any custom fields exist.
*/
function extractCustomFieldData(
settingsJson: unknown,
formData: Record<string, unknown>
): Record<string, unknown> {
const config = parseWizardConfig(settingsJson)
if (!config.customFields?.length) return {}
const customFieldData: Record<string, unknown> = {}
for (const field of config.customFields) {
const value = formData[field.id as keyof typeof formData]
if (value !== undefined && value !== '' && value !== null) {
customFieldData[field.id] = value
}
}
if (Object.keys(customFieldData).length === 0) return {}
return { customFields: customFieldData }
}
export const applicationRouter = router({
/**
* Get application configuration for a round or edition
*/
getConfig: publicProcedure
.input(
z.object({
slug: z.string(),
mode: z.enum(['edition', 'round']).default('round'),
})
)
.query(async ({ ctx, input }) => {
const now = new Date()
if (input.mode === 'edition') {
// Edition-wide application mode
const program = await ctx.prisma.program.findFirst({
where: { slug: input.slug },
})
if (!program) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Program not found',
})
}
// Check if program supports edition-wide applications
const settingsJson = (program.settingsJson || {}) as Record<string, unknown>
const applyMode = (settingsJson.applyMode as string) || 'round'
if (applyMode !== 'edition') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'This program does not support edition-wide applications',
})
}
// Check if applications are open (based on program dates)
const submissionStartDate = settingsJson.submissionStartDate
? new Date(settingsJson.submissionStartDate as string)
: null
const submissionEndDate = settingsJson.submissionEndDate
? new Date(settingsJson.submissionEndDate as string)
: null
let isOpen = false
let gracePeriodEnd: Date | null = null
if (submissionStartDate && submissionEndDate) {
isOpen = now >= submissionStartDate && now <= submissionEndDate
// Check grace period
const lateSubmissionGrace = settingsJson.lateSubmissionGrace as number | undefined
if (!isOpen && lateSubmissionGrace) {
gracePeriodEnd = new Date(submissionEndDate.getTime() + lateSubmissionGrace * 60 * 60 * 1000)
isOpen = now <= gracePeriodEnd
}
} else {
isOpen = program.status === 'ACTIVE'
}
const wizardConfig = parseWizardConfig(program.settingsJson)
return {
mode: 'edition' as const,
program: {
id: program.id,
name: program.name,
year: program.year,
description: program.description,
slug: program.slug,
submissionStartDate,
submissionEndDate,
gracePeriodEnd,
isOpen,
},
wizardConfig,
oceanIssueOptions: wizardConfig.oceanIssues ?? [],
competitionCategories: wizardConfig.competitionCategories ?? [],
}
} else {
// Round-specific application mode (backward compatible)
const round = await ctx.prisma.round.findFirst({
where: { slug: input.slug },
include: {
program: {
select: {
id: true,
name: true,
year: true,
description: true,
settingsJson: true,
},
},
},
})
if (!round) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Application round not found',
})
}
// Check if submissions are open
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
}
}
const roundWizardConfig = parseWizardConfig(round.program.settingsJson)
const { settingsJson: _s, ...programData } = round.program
return {
mode: 'round' as const,
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: programData,
wizardConfig: roundWizardConfig,
oceanIssueOptions: roundWizardConfig.oceanIssues ?? [],
competitionCategories: roundWizardConfig.competitionCategories ?? [],
}
}
}),
/**
* Submit a new application (edition-wide or round-specific)
*/
submit: publicProcedure
.input(
z.object({
mode: z.enum(['edition', 'round']).default('round'),
programId: z.string().optional(),
roundId: z.string().optional(),
data: applicationInputSchema,
})
)
.mutation(async ({ ctx, input }) => {
Comprehensive platform review: security fixes, query optimization, UI improvements, and code cleanup Security (Critical/High): - Fix path traversal bypass in local storage provider (path.resolve + prefix check) - Fix timing-unsafe HMAC comparison (crypto.timingSafeEqual) - Add auth + ownership checks to email API routes (verify-credentials, change-password) - Remove hardcoded secret key fallback in local storage provider - Add production credential check for MinIO (fail loudly if not set) - Remove DB error details from health check response - Add stricter rate limiting on application submissions (5/hour) - Add rate limiting on email availability check (anti-enumeration) - Change getAIAssignmentJobStatus to adminProcedure - Block dangerous file extensions on upload - Reduce project list max perPage from 5000 to 200 Query Optimization: - Optimize analytics getProjectRankings with select instead of full includes - Fix N+1 in mentor.getSuggestions (batch findMany instead of loop) - Use _count for files instead of fetching full file records in project list - Switch to bulk notifications in assignment and user bulk operations - Batch filtering upserts (25 per transaction instead of all at once) UI/UX: - Replace Inter font with Montserrat in public layout (brand consistency) - Use Logo component in public layout instead of placeholder - Create branded 404 and error pages - Make admin rounds table responsive with mobile card layout - Fix notification bell paths to be role-aware - Replace hardcoded slate colors with semantic tokens in admin sidebar - Force light mode (dark mode untested) - Adjust CardTitle default size - Improve muted-foreground contrast for accessibility (A11Y) - Move profile form state initialization to useEffect Code Quality: - Extract shared toProjectWithRelations to anonymization.ts (removed 3 duplicates) - Remove dead code: getObjectInfo, isValidImageSize, unused batch tag functions, debug logs - Remove unused twilio dependency - Remove redundant email index from schema - Add actual storage object deletion when file records are deleted - Wrap evaluation submit + assignment update in - Add comprehensive platform review document Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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.',
})
}
const { mode, programId, roundId, data } = input
// Validate input based on mode
if (mode === 'edition' && !programId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'programId is required for edition-wide applications',
})
}
if (mode === 'round' && !roundId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'roundId is required for round-specific applications',
})
}
const now = new Date()
let program: { id: string; name: string; year: number; status: string; settingsJson?: unknown }
let isOpen = false
if (mode === 'edition') {
// Edition-wide application
program = await ctx.prisma.program.findUniqueOrThrow({
where: { id: programId },
select: {
id: true,
name: true,
year: true,
status: true,
settingsJson: true,
},
})
// Check if program supports edition-wide applications
const settingsJson = (program.settingsJson || {}) as Record<string, unknown>
const applyMode = (settingsJson.applyMode as string) || 'round'
if (applyMode !== 'edition') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'This program does not support edition-wide applications',
})
}
// Check submission window
const submissionStartDate = settingsJson.submissionStartDate
? new Date(settingsJson.submissionStartDate as string)
: null
const submissionEndDate = settingsJson.submissionEndDate
? new Date(settingsJson.submissionEndDate as string)
: null
if (submissionStartDate && submissionEndDate) {
isOpen = now >= submissionStartDate && now <= submissionEndDate
// Check grace period
const lateSubmissionGrace = settingsJson.lateSubmissionGrace as number | undefined
if (!isOpen && lateSubmissionGrace) {
const gracePeriodEnd = new Date(submissionEndDate.getTime() + lateSubmissionGrace * 60 * 60 * 1000)
isOpen = now <= gracePeriodEnd
}
} else {
isOpen = program.status === 'ACTIVE'
}
if (!isOpen) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Applications are currently closed for this edition',
})
}
// Check if email already submitted for this edition
const existingProject = await ctx.prisma.project.findFirst({
where: {
programId,
roundId: null,
submittedByEmail: data.contactEmail,
},
})
if (existingProject) {
throw new TRPCError({
code: 'CONFLICT',
message: 'An application with this email already exists for this edition',
})
}
} else {
// Round-specific application (backward compatible)
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: roundId },
include: { program: true },
})
program = round.program
// Check submission window
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,
},
})
}
// Map string values to Prisma enums (safe for admin-configured custom values)
const validCategories = Object.values(CompetitionCategory) as string[]
const validOceanIssues = Object.values(OceanIssue) as string[]
const categoryEnum = validCategories.includes(data.competitionCategory)
? (data.competitionCategory as CompetitionCategory)
: null
const oceanIssueEnum = validOceanIssues.includes(data.oceanIssue)
? (data.oceanIssue as OceanIssue)
: null
// Create the project
const project = await ctx.prisma.project.create({
data: {
programId: program.id,
roundId: mode === 'round' ? roundId! : null,
title: data.projectName,
teamName: data.teamName,
description: data.description,
competitionCategory: categoryEnum,
oceanIssue: oceanIssueEnum,
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(),
applicationMode: mode,
// Store raw string values for custom categories/issues
...(categoryEnum ? {} : { competitionCategoryRaw: data.competitionCategory }),
...(oceanIssueEnum ? {} : { oceanIssueRaw: data.oceanIssue }),
// Store custom field values from wizard config
...extractCustomFieldData(program.settingsJson, data),
},
},
})
// 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 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,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
// 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 ${program.name}.`,
linkUrl: `/team/projects/${project.id}`,
linkLabel: 'View Application',
metadata: {
projectName: data.projectName,
programName: 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: program.name,
},
})
return {
success: true,
projectId: project.id,
message: `Thank you for applying to ${program.name} ${program.year}! We will review your application and contact you at ${data.contactEmail}.`,
}
}),
/**
* Check if email is already registered for a round or edition
*/
checkEmailAvailability: publicProcedure
.input(
z.object({
mode: z.enum(['edition', 'round']).default('round'),
programId: z.string().optional(),
roundId: z.string().optional(),
email: z.string().email(),
})
)
.query(async ({ ctx, input }) => {
Comprehensive platform review: security fixes, query optimization, UI improvements, and code cleanup Security (Critical/High): - Fix path traversal bypass in local storage provider (path.resolve + prefix check) - Fix timing-unsafe HMAC comparison (crypto.timingSafeEqual) - Add auth + ownership checks to email API routes (verify-credentials, change-password) - Remove hardcoded secret key fallback in local storage provider - Add production credential check for MinIO (fail loudly if not set) - Remove DB error details from health check response - Add stricter rate limiting on application submissions (5/hour) - Add rate limiting on email availability check (anti-enumeration) - Change getAIAssignmentJobStatus to adminProcedure - Block dangerous file extensions on upload - Reduce project list max perPage from 5000 to 200 Query Optimization: - Optimize analytics getProjectRankings with select instead of full includes - Fix N+1 in mentor.getSuggestions (batch findMany instead of loop) - Use _count for files instead of fetching full file records in project list - Switch to bulk notifications in assignment and user bulk operations - Batch filtering upserts (25 per transaction instead of all at once) UI/UX: - Replace Inter font with Montserrat in public layout (brand consistency) - Use Logo component in public layout instead of placeholder - Create branded 404 and error pages - Make admin rounds table responsive with mobile card layout - Fix notification bell paths to be role-aware - Replace hardcoded slate colors with semantic tokens in admin sidebar - Force light mode (dark mode untested) - Adjust CardTitle default size - Improve muted-foreground contrast for accessibility (A11Y) - Move profile form state initialization to useEffect Code Quality: - Extract shared toProjectWithRelations to anonymization.ts (removed 3 duplicates) - Remove dead code: getObjectInfo, isValidImageSize, unused batch tag functions, debug logs - Remove unused twilio dependency - Remove redundant email index from schema - Add actual storage object deletion when file records are deleted - Wrap evaluation submit + assignment update in - Add comprehensive platform review document Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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.',
})
}
let existing
if (input.mode === 'edition') {
existing = await ctx.prisma.project.findFirst({
where: {
programId: input.programId,
roundId: null,
submittedByEmail: input.email,
},
})
} else {
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 ${input.mode === 'edition' ? 'edition' : '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: {
programId: round.programId,
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
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 as CompetitionCategory,
oceanIssue: data.oceanIssue as OceanIssue,
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
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 ?? 'the program'}!`,
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
}
}),
/**
* 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,
}
}),
})