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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user