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>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { router, publicProcedure } from '../trpc'
|
||||
import { CompetitionCategory, OceanIssue, TeamMemberRole } from '@prisma/client'
|
||||
import { Prisma, CompetitionCategory, OceanIssue, TeamMemberRole } from '@prisma/client'
|
||||
import {
|
||||
createNotification,
|
||||
notifyAdmins,
|
||||
@@ -386,4 +386,278 @@ export const applicationRouter = router({
|
||||
: null,
|
||||
}
|
||||
}),
|
||||
|
||||
// =========================================================================
|
||||
// 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,
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user