Comprehensive platform audit: security, UX, performance, and visual polish

Phase 1: Security - status transition validation, Zod tightening, DB indexes, transactions

Phase 2: Admin UX - search/filter for awards, learning, partners pages

Phase 3: Dashboard - Recent Activity feed, Pending Actions card, quick actions

Phase 4: Jury - assignments progress/urgency, autosave indicator, divergence highlighting

Phase 5: Portals - observer charts, mentor search, login/onboarding polish

Phase 6: Messages preview dialog, CsvExportDialog with column selection

Phase 7: Performance - query optimizations, loading skeletons, useDebounce hook

Phase 8: Visual - AnimatedCard, hover effects, StatusBadge, empty state CTAs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-08 22:05:01 +01:00
parent e0e4cb2a32
commit e73a676412
33 changed files with 3193 additions and 977 deletions

View File

@@ -10,6 +10,16 @@ import {
import { normalizeCountryToCode } from '@/lib/countries'
import { logAudit } from '../utils/audit'
// Valid project status transitions
const VALID_PROJECT_TRANSITIONS: Record<string, string[]> = {
SUBMITTED: ['ELIGIBLE', 'REJECTED'], // New submissions get screened
ELIGIBLE: ['ASSIGNED', 'REJECTED'], // Eligible projects get assigned to jurors
ASSIGNED: ['SEMIFINALIST', 'FINALIST', 'REJECTED'], // After evaluation
SEMIFINALIST: ['FINALIST', 'REJECTED'], // Semi-finalists advance or get cut
FINALIST: ['REJECTED'], // Finalists can only be rejected (rare)
REJECTED: ['SUBMITTED'], // Rejected can be re-submitted (admin override)
}
export const projectRouter = router({
/**
* List projects with filtering and pagination
@@ -288,29 +298,73 @@ export const projectRouter = router({
create: adminProcedure
.input(
z.object({
roundId: z.string(),
programId: z.string(),
roundId: z.string().optional(),
title: z.string().min(1).max(500),
teamName: z.string().optional(),
description: z.string().optional(),
tags: z.array(z.string()).optional(),
country: z.string().optional(),
competitionCategory: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(),
oceanIssue: z.enum([
'POLLUTION_REDUCTION', 'CLIMATE_MITIGATION', 'TECHNOLOGY_INNOVATION',
'SUSTAINABLE_SHIPPING', 'BLUE_CARBON', 'HABITAT_RESTORATION',
'COMMUNITY_CAPACITY', 'SUSTAINABLE_FISHING', 'CONSUMER_AWARENESS',
'OCEAN_ACIDIFICATION', 'OTHER',
]).optional(),
institution: z.string().optional(),
contactPhone: z.string().optional(),
contactEmail: z.string().email('Invalid email address').optional(),
contactName: z.string().optional(),
city: z.string().optional(),
metadataJson: z.record(z.unknown()).optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { metadataJson, ...rest } = input
const {
metadataJson,
contactPhone, contactEmail, contactName, city,
...rest
} = input
// Get round to fetch programId
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { programId: true },
})
// If roundId provided, derive programId from round for validation
let resolvedProgramId = input.programId
if (input.roundId) {
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { programId: true },
})
resolvedProgramId = round.programId
}
// Build metadata from contact fields + any additional metadata
const fullMetadata: Record<string, unknown> = { ...metadataJson }
if (contactPhone) fullMetadata.contactPhone = contactPhone
if (contactEmail) fullMetadata.contactEmail = contactEmail
if (contactName) fullMetadata.contactName = contactName
if (city) fullMetadata.city = city
// Normalize country to ISO code if provided
const normalizedCountry = input.country
? normalizeCountryToCode(input.country)
: undefined
const project = await ctx.prisma.$transaction(async (tx) => {
const created = await tx.project.create({
data: {
...rest,
programId: round.programId,
metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,
programId: resolvedProgramId,
roundId: input.roundId || null,
title: input.title,
teamName: input.teamName,
description: input.description,
tags: input.tags || [],
country: normalizedCountry,
competitionCategory: input.competitionCategory,
oceanIssue: input.oceanIssue,
institution: input.institution,
metadataJson: Object.keys(fullMetadata).length > 0
? (fullMetadata as Prisma.InputJsonValue)
: undefined,
status: 'SUBMITTED',
},
})
@@ -321,7 +375,7 @@ export const projectRouter = router({
action: 'CREATE',
entityType: 'Project',
entityId: created.id,
detailsJson: { title: input.title, roundId: input.roundId },
detailsJson: { title: input.title, roundId: input.roundId, programId: resolvedProgramId },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
@@ -368,26 +422,45 @@ export const projectRouter = router({
? (country === null ? null : normalizeCountryToCode(country))
: undefined
const project = await ctx.prisma.project.update({
where: { id },
data: {
...data,
...(status && { status }),
...(normalizedCountry !== undefined && { country: normalizedCountry }),
metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,
},
})
// Record status change in history
// Validate status transition if status is being changed
if (status) {
await ctx.prisma.projectStatusHistory.create({
const currentProject = await ctx.prisma.project.findUniqueOrThrow({
where: { id },
select: { status: true },
})
const allowedTransitions = VALID_PROJECT_TRANSITIONS[currentProject.status] || []
if (!allowedTransitions.includes(status)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Invalid status transition: cannot change from ${currentProject.status} to ${status}. Allowed: ${allowedTransitions.join(', ') || 'none'}`,
})
}
}
const project = await ctx.prisma.$transaction(async (tx) => {
const updated = await tx.project.update({
where: { id },
data: {
projectId: id,
status,
changedBy: ctx.user.id,
...data,
...(status && { status }),
...(normalizedCountry !== undefined && { country: normalizedCountry }),
metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,
},
})
}
// Record status change in history
if (status) {
await tx.projectStatusHistory.create({
data: {
projectId: id,
status,
changedBy: ctx.user.id,
},
})
}
return updated
})
// Send notifications if status changed
if (status) {
@@ -660,34 +733,52 @@ export const projectRouter = router({
const matchingIds = projects.map((p) => p.id)
const updated = await ctx.prisma.project.updateMany({
where: {
id: { in: matchingIds },
roundId: input.roundId,
},
data: { status: input.status },
// Validate status transitions for all projects
const projectsWithStatus = await ctx.prisma.project.findMany({
where: { id: { in: matchingIds }, roundId: input.roundId },
select: { id: true, title: true, status: true },
})
// Record status change in history for each project
if (matchingIds.length > 0) {
await ctx.prisma.projectStatusHistory.createMany({
data: matchingIds.map((projectId) => ({
projectId,
status: input.status,
changedBy: ctx.user.id,
})),
const invalidTransitions: string[] = []
for (const p of projectsWithStatus) {
const allowed = VALID_PROJECT_TRANSITIONS[p.status] || []
if (!allowed.includes(input.status)) {
invalidTransitions.push(`"${p.title}" (${p.status}${input.status})`)
}
}
if (invalidTransitions.length > 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Invalid transitions for ${invalidTransitions.length} project(s): ${invalidTransitions.slice(0, 3).join('; ')}${invalidTransitions.length > 3 ? ` and ${invalidTransitions.length - 3} more` : ''}`,
})
}
// Audit log
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'BULK_UPDATE_STATUS',
entityType: 'Project',
detailsJson: { ids: matchingIds, roundId: input.roundId, status: input.status, count: updated.count },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
const updated = await ctx.prisma.$transaction(async (tx) => {
const result = await tx.project.updateMany({
where: { id: { in: matchingIds }, roundId: input.roundId },
data: { status: input.status },
})
if (matchingIds.length > 0) {
await tx.projectStatusHistory.createMany({
data: matchingIds.map((projectId) => ({
projectId,
status: input.status,
changedBy: ctx.user.id,
})),
})
}
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'BULK_UPDATE_STATUS',
entityType: 'Project',
detailsJson: { ids: matchingIds, roundId: input.roundId, status: input.status, count: result.count },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return result
})
// Helper to get notification title based on type