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

1372 lines
44 KiB
TypeScript
Raw Normal View History

import crypto from 'crypto'
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { Prisma } from '@prisma/client'
import { router, protectedProcedure, adminProcedure } from '../trpc'
import { getUserAvatarUrl } from '../utils/avatar-url'
import {
notifyProjectTeam,
NotificationTypes,
} from '../services/in-app-notification'
import { normalizeCountryToCode } from '@/lib/countries'
import { logAudit } from '../utils/audit'
import { sendInvitationEmail } from '@/lib/email'
const INVITE_TOKEN_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
const STATUSES_WITH_TEAM_NOTIFICATIONS = ['SEMIFINALIST', 'FINALIST', 'REJECTED'] as const
// 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
* Admin sees all, jury sees only assigned projects
*/
list: protectedProcedure
.input(
z.object({
programId: z.string().optional(),
roundId: z.string().optional(),
status: z
.enum([
'SUBMITTED',
'ELIGIBLE',
'ASSIGNED',
'SEMIFINALIST',
'FINALIST',
'REJECTED',
])
.optional(),
statuses: z.array(
z.enum([
'SUBMITTED',
'ELIGIBLE',
'ASSIGNED',
'SEMIFINALIST',
'FINALIST',
'REJECTED',
])
).optional(),
excludeInRoundId: z.string().optional(), // Exclude projects already in this round
unassignedOnly: z.boolean().optional(), // Projects not in any round
search: z.string().optional(),
tags: z.array(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(),
country: z.string().optional(),
wantsMentorship: z.boolean().optional(),
hasFiles: z.boolean().optional(),
hasAssignments: z.boolean().optional(),
page: z.number().int().min(1).default(1),
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
perPage: z.number().int().min(1).max(200).default(20),
})
)
.query(async ({ ctx, input }) => {
const {
programId, roundId, excludeInRoundId, status, statuses, unassignedOnly, search, tags,
competitionCategory, oceanIssue, country,
wantsMentorship, hasFiles, hasAssignments,
page, perPage,
} = input
const skip = (page - 1) * perPage
// Build where clause
const where: Record<string, unknown> = {
isTest: false,
}
// Filter by program
if (programId) where.programId = programId
Admin UI audit round 2: fix 28 display bugs across 23 files HIGH fixes (broken features / wrong data): - H1: Fix roundAssignments → projectRoundStates in project router (7 occurrences) - H2: Fix deliberation results panel blank table (wrong field names) - H3: Fix deliberation participant names blank (wrong data path) - H4: Fix awards "Evaluated" stat duplicating "Eligible" count - H5: Fix cross-round comparison enabled at 1 round (backend requires 2) - H6: Fix setState during render anti-pattern (6 occurrences) - H7: Fix round detail jury member count always showing 0 - H8: Remove 4 invalid status values from observer dashboard filter - H9: Fix filtering progress bar always showing 100% MEDIUM fixes (misleading display): - M1: Filter special-award rounds from competition timeline - M2: Exclude special-award rounds from distinct project count - M3: Fix MENTORING pipeline node hardcoded "0 mentored" - M4: Fix DELIB_LOCKED badge using red for success state - M5: Add status label maps to deliberation session detail - M6: Humanize deliberation category + tie-break method displays - M8: Rename setStageId → setRoundId, "Select Stage" → "Select Round" - M9: Add missing INVITED/ACTIVE/SUSPENDED to members status labels - M10: Add ROUND_DRAFT/ACTIVE/CLOSED/ARCHIVED to StatusBadge - M11: Fix unsent messages showing "Scheduled" instead of "Draft" - M12: Rename misleading totalEvaluations → totalAssignments - M13: Rename "Stage" column to "Program" in projects page LOW fixes (cosmetic / edge-case): - L1: Use unfiltered rounds array for active round detection - L2: Use all rounds length for new round sort order - L3: Filter special-award rounds from header count - L4: Fix single-underscore replace in award status badges - L5: Fix score bucket boundary gaps (4.99 dropped between buckets) - L6: Title-case LIVE_FINAL pipeline metric status - L7: Fix roundType.replace only replacing first underscore - L8: Remove duplicate severity sort in smart-actions component Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 11:11:00 +01:00
// Filter by round (via ProjectRoundState)
if (roundId) {
Admin UI audit round 2: fix 28 display bugs across 23 files HIGH fixes (broken features / wrong data): - H1: Fix roundAssignments → projectRoundStates in project router (7 occurrences) - H2: Fix deliberation results panel blank table (wrong field names) - H3: Fix deliberation participant names blank (wrong data path) - H4: Fix awards "Evaluated" stat duplicating "Eligible" count - H5: Fix cross-round comparison enabled at 1 round (backend requires 2) - H6: Fix setState during render anti-pattern (6 occurrences) - H7: Fix round detail jury member count always showing 0 - H8: Remove 4 invalid status values from observer dashboard filter - H9: Fix filtering progress bar always showing 100% MEDIUM fixes (misleading display): - M1: Filter special-award rounds from competition timeline - M2: Exclude special-award rounds from distinct project count - M3: Fix MENTORING pipeline node hardcoded "0 mentored" - M4: Fix DELIB_LOCKED badge using red for success state - M5: Add status label maps to deliberation session detail - M6: Humanize deliberation category + tie-break method displays - M8: Rename setStageId → setRoundId, "Select Stage" → "Select Round" - M9: Add missing INVITED/ACTIVE/SUSPENDED to members status labels - M10: Add ROUND_DRAFT/ACTIVE/CLOSED/ARCHIVED to StatusBadge - M11: Fix unsent messages showing "Scheduled" instead of "Draft" - M12: Rename misleading totalEvaluations → totalAssignments - M13: Rename "Stage" column to "Program" in projects page LOW fixes (cosmetic / edge-case): - L1: Use unfiltered rounds array for active round detection - L2: Use all rounds length for new round sort order - L3: Filter special-award rounds from header count - L4: Fix single-underscore replace in award status badges - L5: Fix score bucket boundary gaps (4.99 dropped between buckets) - L6: Title-case LIVE_FINAL pipeline metric status - L7: Fix roundType.replace only replacing first underscore - L8: Remove duplicate severity sort in smart-actions component Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 11:11:00 +01:00
where.projectRoundStates = { some: { roundId } }
}
// Exclude projects already in a specific round
if (excludeInRoundId) {
Admin UI audit round 2: fix 28 display bugs across 23 files HIGH fixes (broken features / wrong data): - H1: Fix roundAssignments → projectRoundStates in project router (7 occurrences) - H2: Fix deliberation results panel blank table (wrong field names) - H3: Fix deliberation participant names blank (wrong data path) - H4: Fix awards "Evaluated" stat duplicating "Eligible" count - H5: Fix cross-round comparison enabled at 1 round (backend requires 2) - H6: Fix setState during render anti-pattern (6 occurrences) - H7: Fix round detail jury member count always showing 0 - H8: Remove 4 invalid status values from observer dashboard filter - H9: Fix filtering progress bar always showing 100% MEDIUM fixes (misleading display): - M1: Filter special-award rounds from competition timeline - M2: Exclude special-award rounds from distinct project count - M3: Fix MENTORING pipeline node hardcoded "0 mentored" - M4: Fix DELIB_LOCKED badge using red for success state - M5: Add status label maps to deliberation session detail - M6: Humanize deliberation category + tie-break method displays - M8: Rename setStageId → setRoundId, "Select Stage" → "Select Round" - M9: Add missing INVITED/ACTIVE/SUSPENDED to members status labels - M10: Add ROUND_DRAFT/ACTIVE/CLOSED/ARCHIVED to StatusBadge - M11: Fix unsent messages showing "Scheduled" instead of "Draft" - M12: Rename misleading totalEvaluations → totalAssignments - M13: Rename "Stage" column to "Program" in projects page LOW fixes (cosmetic / edge-case): - L1: Use unfiltered rounds array for active round detection - L2: Use all rounds length for new round sort order - L3: Filter special-award rounds from header count - L4: Fix single-underscore replace in award status badges - L5: Fix score bucket boundary gaps (4.99 dropped between buckets) - L6: Title-case LIVE_FINAL pipeline metric status - L7: Fix roundType.replace only replacing first underscore - L8: Remove duplicate severity sort in smart-actions component Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 11:11:00 +01:00
where.projectRoundStates = { none: { roundId: excludeInRoundId } }
}
// Filter by unassigned (not in any round)
if (unassignedOnly) {
Admin UI audit round 2: fix 28 display bugs across 23 files HIGH fixes (broken features / wrong data): - H1: Fix roundAssignments → projectRoundStates in project router (7 occurrences) - H2: Fix deliberation results panel blank table (wrong field names) - H3: Fix deliberation participant names blank (wrong data path) - H4: Fix awards "Evaluated" stat duplicating "Eligible" count - H5: Fix cross-round comparison enabled at 1 round (backend requires 2) - H6: Fix setState during render anti-pattern (6 occurrences) - H7: Fix round detail jury member count always showing 0 - H8: Remove 4 invalid status values from observer dashboard filter - H9: Fix filtering progress bar always showing 100% MEDIUM fixes (misleading display): - M1: Filter special-award rounds from competition timeline - M2: Exclude special-award rounds from distinct project count - M3: Fix MENTORING pipeline node hardcoded "0 mentored" - M4: Fix DELIB_LOCKED badge using red for success state - M5: Add status label maps to deliberation session detail - M6: Humanize deliberation category + tie-break method displays - M8: Rename setStageId → setRoundId, "Select Stage" → "Select Round" - M9: Add missing INVITED/ACTIVE/SUSPENDED to members status labels - M10: Add ROUND_DRAFT/ACTIVE/CLOSED/ARCHIVED to StatusBadge - M11: Fix unsent messages showing "Scheduled" instead of "Draft" - M12: Rename misleading totalEvaluations → totalAssignments - M13: Rename "Stage" column to "Program" in projects page LOW fixes (cosmetic / edge-case): - L1: Use unfiltered rounds array for active round detection - L2: Use all rounds length for new round sort order - L3: Filter special-award rounds from header count - L4: Fix single-underscore replace in award status badges - L5: Fix score bucket boundary gaps (4.99 dropped between buckets) - L6: Title-case LIVE_FINAL pipeline metric status - L7: Fix roundType.replace only replacing first underscore - L8: Remove duplicate severity sort in smart-actions component Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 11:11:00 +01:00
where.projectRoundStates = { none: {} }
}
// Status filter
if (statuses?.length || status) {
const statusValues = statuses?.length ? statuses : status ? [status] : []
if (statusValues.length > 0) {
where.status = { in: statusValues }
}
}
if (tags && tags.length > 0) {
where.tags = { hasSome: tags }
}
if (competitionCategory) where.competitionCategory = competitionCategory
if (oceanIssue) where.oceanIssue = oceanIssue
if (country) where.country = country
if (wantsMentorship !== undefined) where.wantsMentorship = wantsMentorship
if (hasFiles === true) where.files = { some: {} }
if (hasFiles === false) where.files = { none: {} }
if (hasAssignments === true) where.assignments = { some: {} }
if (hasAssignments === false) where.assignments = { none: {} }
if (search) {
where.OR = [
{ title: { contains: search, mode: 'insensitive' } },
{ teamName: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
]
}
// Jury members can only see assigned projects
if (ctx.user.role === 'JURY_MEMBER') {
where.assignments = {
...((where.assignments as Record<string, unknown>) || {}),
some: { userId: ctx.user.id },
}
}
const [projects, total, statusGroups] = await Promise.all([
ctx.prisma.project.findMany({
where,
skip,
take: perPage,
orderBy: { createdAt: 'desc' },
include: {
program: { select: { id: true, name: true, year: true } },
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
_count: { select: { assignments: true, files: true } },
},
}),
ctx.prisma.project.count({ where }),
ctx.prisma.project.groupBy({
by: ['status'],
where,
_count: true,
}),
])
// Build status counts from groupBy (across all pages)
const statusCounts: Record<string, number> = {}
for (const g of statusGroups) {
statusCounts[g.status] = g._count
}
return {
projects,
total,
page,
perPage,
totalPages: Math.ceil(total / perPage),
statusCounts,
}
}),
/**
* List all project IDs matching filters (no pagination).
* Used for "select all across pages" in bulk operations.
*/
listAllIds: adminProcedure
.input(
z.object({
programId: z.string().optional(),
roundId: z.string().optional(),
excludeInRoundId: z.string().optional(),
unassignedOnly: z.boolean().optional(),
search: z.string().optional(),
statuses: z.array(
z.enum([
'SUBMITTED',
'ELIGIBLE',
'ASSIGNED',
'SEMIFINALIST',
'FINALIST',
'REJECTED',
])
).optional(),
tags: z.array(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(),
country: z.string().optional(),
wantsMentorship: z.boolean().optional(),
hasFiles: z.boolean().optional(),
hasAssignments: z.boolean().optional(),
})
)
.query(async ({ ctx, input }) => {
const {
programId, roundId, excludeInRoundId, unassignedOnly,
search, statuses, tags,
competitionCategory, oceanIssue, country,
wantsMentorship, hasFiles, hasAssignments,
} = input
const where: Record<string, unknown> = {
isTest: false,
}
if (programId) where.programId = programId
if (roundId) {
Admin UI audit round 2: fix 28 display bugs across 23 files HIGH fixes (broken features / wrong data): - H1: Fix roundAssignments → projectRoundStates in project router (7 occurrences) - H2: Fix deliberation results panel blank table (wrong field names) - H3: Fix deliberation participant names blank (wrong data path) - H4: Fix awards "Evaluated" stat duplicating "Eligible" count - H5: Fix cross-round comparison enabled at 1 round (backend requires 2) - H6: Fix setState during render anti-pattern (6 occurrences) - H7: Fix round detail jury member count always showing 0 - H8: Remove 4 invalid status values from observer dashboard filter - H9: Fix filtering progress bar always showing 100% MEDIUM fixes (misleading display): - M1: Filter special-award rounds from competition timeline - M2: Exclude special-award rounds from distinct project count - M3: Fix MENTORING pipeline node hardcoded "0 mentored" - M4: Fix DELIB_LOCKED badge using red for success state - M5: Add status label maps to deliberation session detail - M6: Humanize deliberation category + tie-break method displays - M8: Rename setStageId → setRoundId, "Select Stage" → "Select Round" - M9: Add missing INVITED/ACTIVE/SUSPENDED to members status labels - M10: Add ROUND_DRAFT/ACTIVE/CLOSED/ARCHIVED to StatusBadge - M11: Fix unsent messages showing "Scheduled" instead of "Draft" - M12: Rename misleading totalEvaluations → totalAssignments - M13: Rename "Stage" column to "Program" in projects page LOW fixes (cosmetic / edge-case): - L1: Use unfiltered rounds array for active round detection - L2: Use all rounds length for new round sort order - L3: Filter special-award rounds from header count - L4: Fix single-underscore replace in award status badges - L5: Fix score bucket boundary gaps (4.99 dropped between buckets) - L6: Title-case LIVE_FINAL pipeline metric status - L7: Fix roundType.replace only replacing first underscore - L8: Remove duplicate severity sort in smart-actions component Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 11:11:00 +01:00
where.projectRoundStates = { some: { roundId } }
}
if (excludeInRoundId) {
Admin UI audit round 2: fix 28 display bugs across 23 files HIGH fixes (broken features / wrong data): - H1: Fix roundAssignments → projectRoundStates in project router (7 occurrences) - H2: Fix deliberation results panel blank table (wrong field names) - H3: Fix deliberation participant names blank (wrong data path) - H4: Fix awards "Evaluated" stat duplicating "Eligible" count - H5: Fix cross-round comparison enabled at 1 round (backend requires 2) - H6: Fix setState during render anti-pattern (6 occurrences) - H7: Fix round detail jury member count always showing 0 - H8: Remove 4 invalid status values from observer dashboard filter - H9: Fix filtering progress bar always showing 100% MEDIUM fixes (misleading display): - M1: Filter special-award rounds from competition timeline - M2: Exclude special-award rounds from distinct project count - M3: Fix MENTORING pipeline node hardcoded "0 mentored" - M4: Fix DELIB_LOCKED badge using red for success state - M5: Add status label maps to deliberation session detail - M6: Humanize deliberation category + tie-break method displays - M8: Rename setStageId → setRoundId, "Select Stage" → "Select Round" - M9: Add missing INVITED/ACTIVE/SUSPENDED to members status labels - M10: Add ROUND_DRAFT/ACTIVE/CLOSED/ARCHIVED to StatusBadge - M11: Fix unsent messages showing "Scheduled" instead of "Draft" - M12: Rename misleading totalEvaluations → totalAssignments - M13: Rename "Stage" column to "Program" in projects page LOW fixes (cosmetic / edge-case): - L1: Use unfiltered rounds array for active round detection - L2: Use all rounds length for new round sort order - L3: Filter special-award rounds from header count - L4: Fix single-underscore replace in award status badges - L5: Fix score bucket boundary gaps (4.99 dropped between buckets) - L6: Title-case LIVE_FINAL pipeline metric status - L7: Fix roundType.replace only replacing first underscore - L8: Remove duplicate severity sort in smart-actions component Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 11:11:00 +01:00
where.projectRoundStates = { none: { roundId: excludeInRoundId } }
}
if (unassignedOnly) {
Admin UI audit round 2: fix 28 display bugs across 23 files HIGH fixes (broken features / wrong data): - H1: Fix roundAssignments → projectRoundStates in project router (7 occurrences) - H2: Fix deliberation results panel blank table (wrong field names) - H3: Fix deliberation participant names blank (wrong data path) - H4: Fix awards "Evaluated" stat duplicating "Eligible" count - H5: Fix cross-round comparison enabled at 1 round (backend requires 2) - H6: Fix setState during render anti-pattern (6 occurrences) - H7: Fix round detail jury member count always showing 0 - H8: Remove 4 invalid status values from observer dashboard filter - H9: Fix filtering progress bar always showing 100% MEDIUM fixes (misleading display): - M1: Filter special-award rounds from competition timeline - M2: Exclude special-award rounds from distinct project count - M3: Fix MENTORING pipeline node hardcoded "0 mentored" - M4: Fix DELIB_LOCKED badge using red for success state - M5: Add status label maps to deliberation session detail - M6: Humanize deliberation category + tie-break method displays - M8: Rename setStageId → setRoundId, "Select Stage" → "Select Round" - M9: Add missing INVITED/ACTIVE/SUSPENDED to members status labels - M10: Add ROUND_DRAFT/ACTIVE/CLOSED/ARCHIVED to StatusBadge - M11: Fix unsent messages showing "Scheduled" instead of "Draft" - M12: Rename misleading totalEvaluations → totalAssignments - M13: Rename "Stage" column to "Program" in projects page LOW fixes (cosmetic / edge-case): - L1: Use unfiltered rounds array for active round detection - L2: Use all rounds length for new round sort order - L3: Filter special-award rounds from header count - L4: Fix single-underscore replace in award status badges - L5: Fix score bucket boundary gaps (4.99 dropped between buckets) - L6: Title-case LIVE_FINAL pipeline metric status - L7: Fix roundType.replace only replacing first underscore - L8: Remove duplicate severity sort in smart-actions component Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 11:11:00 +01:00
where.projectRoundStates = { none: {} }
}
if (statuses?.length) where.status = { in: statuses }
if (tags && tags.length > 0) where.tags = { hasSome: tags }
if (competitionCategory) where.competitionCategory = competitionCategory
if (oceanIssue) where.oceanIssue = oceanIssue
if (country) where.country = country
if (wantsMentorship !== undefined) where.wantsMentorship = wantsMentorship
if (hasFiles === true) where.files = { some: {} }
if (hasFiles === false) where.files = { none: {} }
if (hasAssignments === true) where.assignments = { some: {} }
if (hasAssignments === false) where.assignments = { none: {} }
if (search) {
where.OR = [
{ title: { contains: search, mode: 'insensitive' } },
{ teamName: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
]
}
const projects = await ctx.prisma.project.findMany({
where,
select: { id: true },
orderBy: { createdAt: 'desc' },
})
return { ids: projects.map((p) => p.id) }
}),
/**
* Preview project-team recipients before bulk status update notifications.
* Used by admin UI confirmation dialog to verify notification audience.
*/
previewStatusNotificationRecipients: adminProcedure
.input(
z.object({
ids: z.array(z.string()).min(1).max(10000),
status: z.enum([
'SUBMITTED',
'ELIGIBLE',
'ASSIGNED',
'SEMIFINALIST',
'FINALIST',
'REJECTED',
]),
})
)
.query(async ({ ctx, input }) => {
const statusTriggersNotification = STATUSES_WITH_TEAM_NOTIFICATIONS.includes(
input.status as (typeof STATUSES_WITH_TEAM_NOTIFICATIONS)[number]
)
if (!statusTriggersNotification) {
return {
status: input.status,
statusTriggersNotification,
totalProjects: 0,
projectsWithRecipients: 0,
totalRecipients: 0,
projects: [] as Array<{
id: string
title: string
recipientCount: number
recipientsPreview: string[]
hasMoreRecipients: boolean
}>,
}
}
const projects = await ctx.prisma.project.findMany({
where: { id: { in: input.ids } },
select: {
id: true,
title: true,
teamMembers: {
select: {
userId: true,
user: {
select: {
email: true,
},
},
},
},
},
orderBy: { title: 'asc' },
})
const MAX_PREVIEW_RECIPIENTS_PER_PROJECT = 8
const mappedProjects = projects.map((project) => {
const uniqueEmails = Array.from(
new Set(
project.teamMembers
.map((member) => member.user?.email?.toLowerCase().trim() ?? '')
.filter((email) => email.length > 0)
)
)
return {
id: project.id,
title: project.title,
recipientCount: uniqueEmails.length,
recipientsPreview: uniqueEmails.slice(0, MAX_PREVIEW_RECIPIENTS_PER_PROJECT),
hasMoreRecipients: uniqueEmails.length > MAX_PREVIEW_RECIPIENTS_PER_PROJECT,
}
})
const projectsWithRecipients = mappedProjects.filter((p) => p.recipientCount > 0).length
const totalRecipients = mappedProjects.reduce((sum, project) => sum + project.recipientCount, 0)
return {
status: input.status,
statusTriggersNotification,
totalProjects: mappedProjects.length,
projectsWithRecipients,
totalRecipients,
projects: mappedProjects,
}
}),
/**
* Get filter options for the project list (distinct values)
*/
getFilterOptions: protectedProcedure
.query(async ({ ctx }) => {
const [countries, categories, issues] = await Promise.all([
ctx.prisma.project.findMany({
where: { isTest: false, country: { not: null } },
select: { country: true },
distinct: ['country'],
orderBy: { country: 'asc' },
}),
ctx.prisma.project.groupBy({
by: ['competitionCategory'],
where: { isTest: false, competitionCategory: { not: null } },
_count: true,
}),
ctx.prisma.project.groupBy({
by: ['oceanIssue'],
where: { isTest: false, oceanIssue: { not: null } },
_count: true,
}),
])
return {
countries: countries.map((c) => c.country).filter(Boolean) as string[],
categories: categories.map((c) => ({
value: c.competitionCategory!,
count: c._count,
})),
issues: issues.map((i) => ({
value: i.oceanIssue!,
count: i._count,
})),
}
}),
/**
* Get a single project with details
*/
get: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const project = await ctx.prisma.project.findUniqueOrThrow({
where: { id: input.id },
include: {
files: true,
teamMembers: {
include: {
user: {
select: { id: true, name: true, email: true, profileImageKey: true, profileImageProvider: true },
},
},
orderBy: { joinedAt: 'asc' },
},
mentorAssignment: {
include: {
mentor: {
select: { id: true, name: true, email: true, expertiseTags: true, profileImageKey: true, profileImageProvider: true },
},
},
},
},
})
// Fetch project tags separately (table may not exist if migrations are pending)
let projectTags: { id: string; projectId: string; tagId: string; confidence: number; tag: { id: string; name: string; category: string | null; color: string | null } }[] = []
try {
projectTags = await ctx.prisma.projectTag.findMany({
where: { projectId: input.id },
include: { tag: { select: { id: true, name: true, category: true, color: true } } },
orderBy: { confidence: 'desc' },
})
} catch {
// ProjectTag table may not exist yet
}
// Check access for jury members
if (ctx.user.role === 'JURY_MEMBER') {
const assignment = await ctx.prisma.assignment.findFirst({
where: {
projectId: input.id,
userId: ctx.user.id,
},
})
if (!assignment) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You are not assigned to this project',
})
}
}
// Attach avatar URLs to team members and mentor
const teamMembersWithAvatars = await Promise.all(
project.teamMembers.map(async (member) => ({
...member,
user: {
...member.user,
avatarUrl: await getUserAvatarUrl(member.user.profileImageKey, member.user.profileImageProvider),
},
}))
)
const mentorWithAvatar = project.mentorAssignment
? {
...project.mentorAssignment,
mentor: {
...project.mentorAssignment.mentor,
avatarUrl: await getUserAvatarUrl(
project.mentorAssignment.mentor.profileImageKey,
project.mentorAssignment.mentor.profileImageProvider
),
},
}
: null
return {
...project,
projectTags,
teamMembers: teamMembersWithAvatars,
mentorAssignment: mentorWithAvatar,
}
}),
/**
* Create a single project (admin only)
* Projects belong to a program.
*/
create: adminProcedure
.input(
z.object({
programId: z.string(),
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(),
teamMembers: z.array(z.object({
name: z.string().min(1),
email: z.string().email(),
role: z.enum(['LEAD', 'MEMBER', 'ADVISOR']),
title: z.string().optional(),
phone: z.string().optional(),
sendInvite: z.boolean().default(false),
})).max(10).optional(),
})
)
.mutation(async ({ ctx, input }) => {
const {
metadataJson,
contactPhone, contactEmail, contactName, city,
teamMembers: teamMembersInput,
...rest
} = input
const resolvedProgramId = input.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, membersToInvite } = await ctx.prisma.$transaction(async (tx) => {
const created = await tx.project.create({
data: {
programId: resolvedProgramId,
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',
},
})
// Create team members if provided
const inviteList: { userId: string; email: string; name: string }[] = []
if (teamMembersInput && teamMembersInput.length > 0) {
for (const member of teamMembersInput) {
// Find or create user
let user = await tx.user.findUnique({
where: { email: member.email.toLowerCase() },
select: { id: true, status: true },
})
if (!user) {
user = await tx.user.create({
data: {
email: member.email.toLowerCase(),
name: member.name,
role: 'APPLICANT',
status: 'NONE',
phoneNumber: member.phone || null,
},
select: { id: true, status: true },
})
}
// Create TeamMember link (skip if already linked)
await tx.teamMember.upsert({
where: {
projectId_userId: {
projectId: created.id,
userId: user.id,
},
},
create: {
projectId: created.id,
userId: user.id,
role: member.role,
title: member.title || null,
},
update: {
role: member.role,
title: member.title || null,
},
})
if (member.sendInvite) {
inviteList.push({ userId: user.id, email: member.email.toLowerCase(), name: member.name })
}
}
}
return { project: created, membersToInvite: inviteList }
})
// Audit outside transaction so failures don't roll back the project creation
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'CREATE',
entityType: 'Project',
entityId: project.id,
detailsJson: {
title: input.title,
programId: resolvedProgramId,
teamMembersCount: teamMembersInput?.length || 0,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
// Send invite emails outside the transaction (never fail project creation)
if (membersToInvite.length > 0) {
const baseUrl = process.env.NEXTAUTH_URL || 'https://monaco-opc.com'
for (const member of membersToInvite) {
try {
const token = crypto.randomBytes(32).toString('hex')
await ctx.prisma.user.update({
where: { id: member.userId },
data: {
status: 'INVITED',
inviteToken: token,
inviteTokenExpiresAt: new Date(Date.now() + INVITE_TOKEN_EXPIRY_MS),
},
})
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
await sendInvitationEmail(member.email, member.name, inviteUrl, 'APPLICANT')
// Log notification
try {
await ctx.prisma.notificationLog.create({
data: {
userId: member.userId,
channel: 'EMAIL',
type: 'JURY_INVITATION',
status: 'SENT',
},
})
} catch {
// Never fail on notification logging
}
} catch {
// Email sending failure should not break project creation
console.error(`Failed to send invite to ${member.email}`)
}
}
}
return project
}),
/**
* Update a project (admin only)
*/
update: adminProcedure
.input(
z.object({
id: z.string(),
title: z.string().min(1).max(500).optional(),
teamName: z.string().optional().nullable(),
description: z.string().optional().nullable(),
country: z.string().optional().nullable(), // ISO-2 code or country name (will be normalized)
status: z
.enum([
'SUBMITTED',
'ELIGIBLE',
'ASSIGNED',
'SEMIFINALIST',
'FINALIST',
'REJECTED',
])
.optional(),
tags: z.array(z.string()).optional(),
metadataJson: z.record(z.unknown()).optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { id, metadataJson, status, country, ...data } = input
// Normalize country to ISO-2 code if provided
const normalizedCountry = country !== undefined
? (country === null ? null : normalizeCountryToCode(country))
: undefined
// Validate status transition if status is being changed
if (status) {
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: {
...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) {
const notificationConfig: Record<
string,
{ type: string; title: string; message: string }
> = {
SEMIFINALIST: {
type: NotificationTypes.ADVANCED_SEMIFINAL,
title: "Congratulations! You're a Semi-Finalist",
message: `Your project "${project.title}" has advanced to the semi-finals!`,
},
FINALIST: {
type: NotificationTypes.ADVANCED_FINAL,
title: "Amazing News! You're a Finalist",
message: `Your project "${project.title}" has been selected as a finalist!`,
},
REJECTED: {
type: NotificationTypes.NOT_SELECTED,
title: 'Application Status Update',
message: `We regret to inform you that "${project.title}" was not selected for the next round.`,
},
}
const config = notificationConfig[status]
if (config) {
await notifyProjectTeam(id, {
type: config.type,
title: config.title,
message: config.message,
linkUrl: `/team/projects/${id}`,
linkLabel: 'View Project',
priority: status === 'REJECTED' ? 'normal' : 'high',
metadata: {
projectName: project.title,
},
})
}
}
// Audit log
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'Project',
entityId: id,
detailsJson: { ...data, status, metadataJson } as Record<string, unknown>,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return project
}),
/**
* Delete a project (admin only)
*/
delete: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const target = await ctx.prisma.project.findUniqueOrThrow({
where: { id: input.id },
Admin platform audit: fix bugs, harden backend, add auto-refresh, clean dead code Phase 1 — Critical bugs: - Fix deliberation participant selection (wire jury group query) - Fix reports "By Round" tab (inline content instead of 404 route) - Fix messages "Sent History" (add message.sent procedure, wire tab) - Add missing fields to competition award form (criteriaText, maxRankedPicks) - Wire LiveControlPanel buttons (cursor, voting, scores) - Fix ResultLockControls empty snapshot (fetch actual data before lock) - Fix SubmissionWindowManager losing fields on edit Phase 2 — Backend fixes: - Remove write-in-query from specialAward.get - Fix award eligibility job overwriting manual shortlist overrides - Fix filtering startJob deleting all prior results (defer cleanup to post-success) - Tighten access control: protectedProcedure → adminProcedure on 8 procedures - Add audit logging to deliberation mutations - Add FINALIST/SEMIFINALIST delete guard on project.delete/bulkDelete Phase 3 — Auto-refresh: - Add refetchInterval to 15+ admin pages/components (10s–30s) - Fix AI job polling: derive speed from job status for all viewers Phase 4 — Dead code cleanup: - Delete unused command-palette, pdf-report, admin-page-transition - Remove dead subItems sidebar code, unused GripVertical import - Replace redundant isGenerating state with mutation.isPending - Add Role column to jury members table - Remove misleading manual mentor assignment stub Phase 5 — UX improvements: - Fix rounds page single-competition assumption (add selector) - Remove raw UUID fallback in deliberation config - Fix programs page "Stage" → "Round" terminology Phase 6 — Backend hardening: - Complete logAudit calls (add prisma, ipAddress, userAgent) - Batch analytics queries (fix N+1 in getCrossRoundComparison, getYearOverYear) - Batch user.bulkCreate writes (assignments, jury memberships, intents) - Remove any casts from deliberation service (typed PrismaClient + TransactionClient) - Fix stale DeliberationStatus enum values blocking build 40 files changed, 1010 insertions(+), 612 deletions(-) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 08:20:13 +01:00
select: { id: true, title: true, status: true },
})
Admin platform audit: fix bugs, harden backend, add auto-refresh, clean dead code Phase 1 — Critical bugs: - Fix deliberation participant selection (wire jury group query) - Fix reports "By Round" tab (inline content instead of 404 route) - Fix messages "Sent History" (add message.sent procedure, wire tab) - Add missing fields to competition award form (criteriaText, maxRankedPicks) - Wire LiveControlPanel buttons (cursor, voting, scores) - Fix ResultLockControls empty snapshot (fetch actual data before lock) - Fix SubmissionWindowManager losing fields on edit Phase 2 — Backend fixes: - Remove write-in-query from specialAward.get - Fix award eligibility job overwriting manual shortlist overrides - Fix filtering startJob deleting all prior results (defer cleanup to post-success) - Tighten access control: protectedProcedure → adminProcedure on 8 procedures - Add audit logging to deliberation mutations - Add FINALIST/SEMIFINALIST delete guard on project.delete/bulkDelete Phase 3 — Auto-refresh: - Add refetchInterval to 15+ admin pages/components (10s–30s) - Fix AI job polling: derive speed from job status for all viewers Phase 4 — Dead code cleanup: - Delete unused command-palette, pdf-report, admin-page-transition - Remove dead subItems sidebar code, unused GripVertical import - Replace redundant isGenerating state with mutation.isPending - Add Role column to jury members table - Remove misleading manual mentor assignment stub Phase 5 — UX improvements: - Fix rounds page single-competition assumption (add selector) - Remove raw UUID fallback in deliberation config - Fix programs page "Stage" → "Round" terminology Phase 6 — Backend hardening: - Complete logAudit calls (add prisma, ipAddress, userAgent) - Batch analytics queries (fix N+1 in getCrossRoundComparison, getYearOverYear) - Batch user.bulkCreate writes (assignments, jury memberships, intents) - Remove any casts from deliberation service (typed PrismaClient + TransactionClient) - Fix stale DeliberationStatus enum values blocking build 40 files changed, 1010 insertions(+), 612 deletions(-) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 08:20:13 +01:00
const protectedStatuses = ['FINALIST', 'SEMIFINALIST']
if (protectedStatuses.includes(target.status)) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: `Cannot delete a project with status ${target.status}. Change status first.`,
})
}
const project = await ctx.prisma.project.delete({
where: { id: input.id },
})
// Audit outside transaction so failures don't roll back the delete
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'DELETE',
entityType: 'Project',
entityId: input.id,
detailsJson: { title: target.title },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return project
}),
/**
* Bulk delete projects (admin only)
*/
bulkDelete: adminProcedure
.input(
z.object({
ids: z.array(z.string()).min(1).max(200),
})
)
.mutation(async ({ ctx, input }) => {
const projects = await ctx.prisma.project.findMany({
where: { id: { in: input.ids }, isTest: false },
Admin platform audit: fix bugs, harden backend, add auto-refresh, clean dead code Phase 1 — Critical bugs: - Fix deliberation participant selection (wire jury group query) - Fix reports "By Round" tab (inline content instead of 404 route) - Fix messages "Sent History" (add message.sent procedure, wire tab) - Add missing fields to competition award form (criteriaText, maxRankedPicks) - Wire LiveControlPanel buttons (cursor, voting, scores) - Fix ResultLockControls empty snapshot (fetch actual data before lock) - Fix SubmissionWindowManager losing fields on edit Phase 2 — Backend fixes: - Remove write-in-query from specialAward.get - Fix award eligibility job overwriting manual shortlist overrides - Fix filtering startJob deleting all prior results (defer cleanup to post-success) - Tighten access control: protectedProcedure → adminProcedure on 8 procedures - Add audit logging to deliberation mutations - Add FINALIST/SEMIFINALIST delete guard on project.delete/bulkDelete Phase 3 — Auto-refresh: - Add refetchInterval to 15+ admin pages/components (10s–30s) - Fix AI job polling: derive speed from job status for all viewers Phase 4 — Dead code cleanup: - Delete unused command-palette, pdf-report, admin-page-transition - Remove dead subItems sidebar code, unused GripVertical import - Replace redundant isGenerating state with mutation.isPending - Add Role column to jury members table - Remove misleading manual mentor assignment stub Phase 5 — UX improvements: - Fix rounds page single-competition assumption (add selector) - Remove raw UUID fallback in deliberation config - Fix programs page "Stage" → "Round" terminology Phase 6 — Backend hardening: - Complete logAudit calls (add prisma, ipAddress, userAgent) - Batch analytics queries (fix N+1 in getCrossRoundComparison, getYearOverYear) - Batch user.bulkCreate writes (assignments, jury memberships, intents) - Remove any casts from deliberation service (typed PrismaClient + TransactionClient) - Fix stale DeliberationStatus enum values blocking build 40 files changed, 1010 insertions(+), 612 deletions(-) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 08:20:13 +01:00
select: { id: true, title: true, status: true },
})
if (projects.length === 0) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'No projects found to delete',
})
}
Admin platform audit: fix bugs, harden backend, add auto-refresh, clean dead code Phase 1 — Critical bugs: - Fix deliberation participant selection (wire jury group query) - Fix reports "By Round" tab (inline content instead of 404 route) - Fix messages "Sent History" (add message.sent procedure, wire tab) - Add missing fields to competition award form (criteriaText, maxRankedPicks) - Wire LiveControlPanel buttons (cursor, voting, scores) - Fix ResultLockControls empty snapshot (fetch actual data before lock) - Fix SubmissionWindowManager losing fields on edit Phase 2 — Backend fixes: - Remove write-in-query from specialAward.get - Fix award eligibility job overwriting manual shortlist overrides - Fix filtering startJob deleting all prior results (defer cleanup to post-success) - Tighten access control: protectedProcedure → adminProcedure on 8 procedures - Add audit logging to deliberation mutations - Add FINALIST/SEMIFINALIST delete guard on project.delete/bulkDelete Phase 3 — Auto-refresh: - Add refetchInterval to 15+ admin pages/components (10s–30s) - Fix AI job polling: derive speed from job status for all viewers Phase 4 — Dead code cleanup: - Delete unused command-palette, pdf-report, admin-page-transition - Remove dead subItems sidebar code, unused GripVertical import - Replace redundant isGenerating state with mutation.isPending - Add Role column to jury members table - Remove misleading manual mentor assignment stub Phase 5 — UX improvements: - Fix rounds page single-competition assumption (add selector) - Remove raw UUID fallback in deliberation config - Fix programs page "Stage" → "Round" terminology Phase 6 — Backend hardening: - Complete logAudit calls (add prisma, ipAddress, userAgent) - Batch analytics queries (fix N+1 in getCrossRoundComparison, getYearOverYear) - Batch user.bulkCreate writes (assignments, jury memberships, intents) - Remove any casts from deliberation service (typed PrismaClient + TransactionClient) - Fix stale DeliberationStatus enum values blocking build 40 files changed, 1010 insertions(+), 612 deletions(-) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 08:20:13 +01:00
const protectedProjects = projects.filter((p) =>
['FINALIST', 'SEMIFINALIST'].includes(p.status)
)
if (protectedProjects.length > 0) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: `Cannot delete ${protectedProjects.length} project(s) with FINALIST/SEMIFINALIST status. Remove them from the selection first.`,
})
}
const result = await ctx.prisma.project.deleteMany({
where: { id: { in: projects.map((p) => p.id) } },
})
// Audit outside transaction so failures don't roll back the bulk delete
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'BULK_DELETE',
entityType: 'Project',
detailsJson: {
count: projects.length,
titles: projects.map((p) => p.title),
ids: projects.map((p) => p.id),
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { deleted: result.count }
}),
/**
* Import projects from CSV data (admin only)
* Projects belong to a program.
*/
importCSV: adminProcedure
.input(
z.object({
programId: z.string(),
projects: z.array(
z.object({
title: z.string().min(1),
teamName: z.string().optional(),
description: z.string().optional(),
tags: z.array(z.string()).optional(),
metadataJson: z.record(z.unknown()).optional(),
})
),
})
)
.mutation(async ({ ctx, input }) => {
// Verify program exists
await ctx.prisma.program.findUniqueOrThrow({
where: { id: input.programId },
})
// Create projects in a transaction
const result = await ctx.prisma.$transaction(async (tx) => {
const projectData = input.projects.map((p) => {
const { metadataJson, ...rest } = p
return {
...rest,
programId: input.programId,
status: 'SUBMITTED' as const,
metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,
}
})
const created = await tx.project.createManyAndReturn({
data: projectData,
select: { id: true },
})
return { imported: created.length }
})
// Audit log
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'IMPORT',
entityType: 'Project',
detailsJson: { programId: input.programId, count: result.imported },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return result
}),
/**
* Get all unique tags used in projects
*/
getTags: protectedProcedure
.input(z.object({
programId: z.string().optional(),
}))
.query(async ({ ctx, input }) => {
const where: Record<string, unknown> = {
isTest: false,
}
if (input.programId) where.programId = input.programId
const projects = await ctx.prisma.project.findMany({
where,
select: { tags: true },
})
const allTags = projects.flatMap((p) => p.tags)
const uniqueTags = [...new Set(allTags)].sort()
return uniqueTags
}),
/**
* Update project status in bulk (admin only)
*/
bulkUpdateStatus: adminProcedure
.input(
z.object({
ids: z.array(z.string()),
status: z.enum([
'SUBMITTED',
'ELIGIBLE',
'ASSIGNED',
'SEMIFINALIST',
'FINALIST',
'REJECTED',
]),
})
)
.mutation(async ({ ctx, input }) => {
// Fetch matching projects BEFORE update so notifications match actually-updated records
const projects = await ctx.prisma.project.findMany({
where: {
id: { in: input.ids },
isTest: false,
},
select: { id: true, title: true },
})
const matchingIds = projects.map((p) => p.id)
// Validate status transitions for all projects
const projectsWithStatus = await ctx.prisma.project.findMany({
where: { id: { in: matchingIds } },
select: { id: true, title: true, status: true },
})
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` : ''}`,
})
}
const updated = await ctx.prisma.$transaction(async (tx) => {
const result = await tx.project.updateMany({
where: { id: { in: matchingIds } },
data: { status: input.status },
})
if (matchingIds.length > 0) {
await tx.projectStatusHistory.createMany({
data: matchingIds.map((projectId) => ({
projectId,
status: input.status,
changedBy: ctx.user.id,
})),
})
}
return result
})
// Audit outside transaction so failures don't roll back the bulk update
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'BULK_UPDATE_STATUS',
entityType: 'Project',
detailsJson: { ids: matchingIds, status: input.status, count: updated.count },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
// Notify project teams based on status
if (projects.length > 0) {
const notificationConfig: Record<
string,
{ type: string; titleFn: (name: string) => string; messageFn: (name: string) => string }
> = {
SEMIFINALIST: {
type: NotificationTypes.ADVANCED_SEMIFINAL,
titleFn: () => "Congratulations! You're a Semi-Finalist",
messageFn: (name) => `Your project "${name}" has advanced to the semi-finals!`,
},
FINALIST: {
type: NotificationTypes.ADVANCED_FINAL,
titleFn: () => "Amazing News! You're a Finalist",
messageFn: (name) => `Your project "${name}" has been selected as a finalist!`,
},
REJECTED: {
type: NotificationTypes.NOT_SELECTED,
titleFn: () => 'Application Status Update',
messageFn: (name) =>
`We regret to inform you that "${name}" was not selected for the next round.`,
},
}
const config = notificationConfig[input.status]
if (config) {
for (const project of projects) {
await notifyProjectTeam(project.id, {
type: config.type,
title: config.titleFn(project.title),
message: config.messageFn(project.title),
linkUrl: `/team/projects/${project.id}`,
linkLabel: 'View Project',
priority: input.status === 'REJECTED' ? 'normal' : 'high',
metadata: {
projectName: project.title,
},
})
}
}
}
return { updated: updated.count }
}),
/**
* List projects in a program's pool (not assigned to any stage)
*/
listPool: adminProcedure
.input(
z.object({
programId: z.string(),
search: z.string().optional(),
page: z.number().int().min(1).default(1),
perPage: z.number().int().min(1).max(100).default(50),
})
)
.query(async ({ ctx, input }) => {
const { programId, search, page, perPage } = input
const skip = (page - 1) * perPage
const where: Record<string, unknown> = {
programId,
isTest: false,
Admin UI audit round 2: fix 28 display bugs across 23 files HIGH fixes (broken features / wrong data): - H1: Fix roundAssignments → projectRoundStates in project router (7 occurrences) - H2: Fix deliberation results panel blank table (wrong field names) - H3: Fix deliberation participant names blank (wrong data path) - H4: Fix awards "Evaluated" stat duplicating "Eligible" count - H5: Fix cross-round comparison enabled at 1 round (backend requires 2) - H6: Fix setState during render anti-pattern (6 occurrences) - H7: Fix round detail jury member count always showing 0 - H8: Remove 4 invalid status values from observer dashboard filter - H9: Fix filtering progress bar always showing 100% MEDIUM fixes (misleading display): - M1: Filter special-award rounds from competition timeline - M2: Exclude special-award rounds from distinct project count - M3: Fix MENTORING pipeline node hardcoded "0 mentored" - M4: Fix DELIB_LOCKED badge using red for success state - M5: Add status label maps to deliberation session detail - M6: Humanize deliberation category + tie-break method displays - M8: Rename setStageId → setRoundId, "Select Stage" → "Select Round" - M9: Add missing INVITED/ACTIVE/SUSPENDED to members status labels - M10: Add ROUND_DRAFT/ACTIVE/CLOSED/ARCHIVED to StatusBadge - M11: Fix unsent messages showing "Scheduled" instead of "Draft" - M12: Rename misleading totalEvaluations → totalAssignments - M13: Rename "Stage" column to "Program" in projects page LOW fixes (cosmetic / edge-case): - L1: Use unfiltered rounds array for active round detection - L2: Use all rounds length for new round sort order - L3: Filter special-award rounds from header count - L4: Fix single-underscore replace in award status badges - L5: Fix score bucket boundary gaps (4.99 dropped between buckets) - L6: Title-case LIVE_FINAL pipeline metric status - L7: Fix roundType.replace only replacing first underscore - L8: Remove duplicate severity sort in smart-actions component Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 11:11:00 +01:00
projectRoundStates: { none: {} }, // Projects not assigned to any round
}
if (search) {
where.OR = [
{ title: { contains: search, mode: 'insensitive' } },
{ teamName: { contains: search, mode: 'insensitive' } },
]
}
const [projects, total] = await Promise.all([
ctx.prisma.project.findMany({
where,
skip,
take: perPage,
orderBy: { createdAt: 'desc' },
select: {
id: true,
title: true,
teamName: true,
country: true,
competitionCategory: true,
createdAt: true,
},
}),
ctx.prisma.project.count({ where }),
])
return { projects, total, page, perPage, totalPages: Math.ceil(total / perPage) }
}),
/**
* Get full project detail with assignments and evaluation stats in one call.
* Reduces client-side waterfall by combining project.get + assignment.listByProject + evaluation.getProjectStats.
*/
getFullDetail: adminProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const [projectRaw, projectTags, assignments, submittedEvaluations] = await Promise.all([
ctx.prisma.project.findUniqueOrThrow({
where: { id: input.id },
include: {
files: true,
teamMembers: {
include: {
user: {
select: { id: true, name: true, email: true, profileImageKey: true, profileImageProvider: true },
},
},
orderBy: { joinedAt: 'asc' },
},
mentorAssignment: {
include: {
mentor: {
select: { id: true, name: true, email: true, expertiseTags: true, profileImageKey: true, profileImageProvider: true },
},
},
},
},
}),
ctx.prisma.projectTag.findMany({
where: { projectId: input.id },
include: { tag: { select: { id: true, name: true, category: true, color: true } } },
orderBy: { confidence: 'desc' },
}).catch(() => [] as { id: string; projectId: string; tagId: string; confidence: number; tag: { id: string; name: string; category: string | null; color: string | null } }[]),
ctx.prisma.assignment.findMany({
where: { projectId: input.id },
include: {
user: { select: { id: true, name: true, email: true, expertiseTags: true, profileImageKey: true, profileImageProvider: true } },
round: { select: { id: true, name: true } },
evaluation: { select: { id: true, status: true, submittedAt: true, globalScore: true, binaryDecision: true, criterionScoresJson: true, feedbackText: true } },
},
orderBy: { createdAt: 'desc' },
}),
ctx.prisma.evaluation.findMany({
where: {
status: 'SUBMITTED',
assignment: { projectId: input.id },
},
}),
])
// Compute evaluation stats
let stats = null
if (submittedEvaluations.length > 0) {
const globalScores = submittedEvaluations
.map((e) => e.globalScore)
.filter((s): s is number => s !== null)
// Count recommendations: check binaryDecision first, fall back to boolean criteria
const yesVotes = submittedEvaluations.filter((e) => {
if (e.binaryDecision != null) return e.binaryDecision === true
const scores = e.criterionScoresJson as Record<string, unknown> | null
if (!scores) return false
const boolValues = Object.values(scores).filter((v) => typeof v === 'boolean')
return boolValues.length > 0 && boolValues.every((v) => v === true)
}).length
const hasRecommendationData = submittedEvaluations.some((e) => {
if (e.binaryDecision != null) return true
const scores = e.criterionScoresJson as Record<string, unknown> | null
if (!scores) return false
return Object.values(scores).some((v) => typeof v === 'boolean')
})
stats = {
totalEvaluations: submittedEvaluations.length,
averageGlobalScore: globalScores.length > 0
? globalScores.reduce((a, b) => a + b, 0) / globalScores.length
: null,
minScore: globalScores.length > 0 ? Math.min(...globalScores) : null,
maxScore: globalScores.length > 0 ? Math.max(...globalScores) : null,
yesVotes,
noVotes: submittedEvaluations.length - yesVotes,
yesPercentage: hasRecommendationData
? (yesVotes / submittedEvaluations.length) * 100
: null,
}
}
// Attach avatar URLs in parallel
const [teamMembersWithAvatars, assignmentsWithAvatars, mentorWithAvatar] = await Promise.all([
Promise.all(
projectRaw.teamMembers.map(async (member) => ({
...member,
user: {
...member.user,
avatarUrl: await getUserAvatarUrl(member.user.profileImageKey, member.user.profileImageProvider),
},
}))
),
Promise.all(
assignments.map(async (a) => ({
...a,
user: {
...a.user,
avatarUrl: await getUserAvatarUrl(a.user.profileImageKey, a.user.profileImageProvider),
},
}))
),
projectRaw.mentorAssignment
? (async () => ({
...projectRaw.mentorAssignment!,
mentor: {
...projectRaw.mentorAssignment!.mentor,
avatarUrl: await getUserAvatarUrl(
projectRaw.mentorAssignment!.mentor.profileImageKey,
projectRaw.mentorAssignment!.mentor.profileImageProvider
),
},
}))()
: Promise.resolve(null),
])
return {
project: {
...projectRaw,
projectTags,
teamMembers: teamMembersWithAvatars,
mentorAssignment: mentorWithAvatar,
},
assignments: assignmentsWithAvatars,
stats,
}
}),
Comprehensive round system audit: fix 27 logic bugs, add manual project/assignment features, improve UI/UX ## Critical Logic Fixes (Tier 1) - Fix requiredReviews config key mismatch (always defaulted to 3) - Fix double-email + stageName/roundName metadata mismatch in notifications - Fix snake_case config reads in peer review (peerReviewEnabled was always blocked) - Add server-side COI check to evaluation submit (was client-only) - Fix hard-coded feedbackText.min(10) — now uses config values - Fix binaryDecision corruption in non-binary scoring modes - Fix advanceProjects: add competition/sort-order/status validations, move autoPass into tx - Fix removeFromRound: now cleans up orphaned Assignment records - Fix 3-day reminder sending wrong email template (was using 24h template) ## High-Priority Logic Fixes (Tier 2) - Add project state transition whitelist (prevent invalid transitions like REJECTED→PASSED) - Scope AI assignment job to jury group members (was querying all JURY_MEMBERs) - Add COI awareness to AI assignment generation - Enforce requireAllCriteriaScored server-side - Fix expireIntentsForRound nested transaction (now uses caller's tx) - Implement notifyOnEntry for advancement path - Implement notifyOnAdvance (was dead config) - Fix checkRequirementsAndTransition for SubmissionFileRequirement model ## New Features (Tier 3) - Add Project to Round: dialog with "Create New" and "From Pool" tabs - Assignment "By Project" mode: select project → assign multiple jurors - Backend: project.createAndAssignToRound procedure ## UI/UX Improvements (Tier 4+5) - Add AlertDialog confirmation to header status dropdown - Replace native confirm() with AlertDialog in assignments table - Jury stats card now display-only with "Change" link - Assignments tab restructured into logical card groups - Inline-editable round name in header - Back button shows destination label - Readiness checklist: green check instead of strikethrough - Gate assignments tab when no jury group assigned - Relative time on window stats card - Toast feedback on date saves - Disable advance button when no target round - COI section shows placeholder when empty - Round position shown as "Round X of Y" - InlineMemberCap edit icon always visible - Status badge tooltip with description - Add REMINDER_3_DAYS email template - Fix maybeSendEmail to respect notification preferences - Optimize bulk notification email loop Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:59:35 +01:00
/**
* Create a new project and assign it directly to a round.
* Used for late-arriving projects that need to enter a specific round immediately.
*/
createAndAssignToRound: adminProcedure
.input(
z.object({
title: z.string().min(1).max(500),
teamName: z.string().optional(),
description: z.string().optional(),
country: z.string().optional(),
competitionCategory: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(),
roundId: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
const { roundId, country, ...projectFields } = input
// Get the round to find competitionId, then competition to find programId
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: roundId },
select: {
id: true,
name: true,
competition: {
select: {
id: true,
programId: true,
},
},
},
})
// Normalize country to ISO code if provided
const normalizedCountry = country
? normalizeCountryToCode(country)
: undefined
const project = await ctx.prisma.$transaction(async (tx) => {
// 1. Create the project
const created = await tx.project.create({
data: {
programId: round.competition.programId,
title: projectFields.title,
teamName: projectFields.teamName,
description: projectFields.description,
country: normalizedCountry,
competitionCategory: projectFields.competitionCategory,
status: 'ASSIGNED',
},
})
// 2. Create ProjectRoundState entry
await tx.projectRoundState.create({
data: {
projectId: created.id,
roundId,
state: 'PENDING',
},
})
// 3. Create ProjectStatusHistory entry
await tx.projectStatusHistory.create({
data: {
projectId: created.id,
status: 'ASSIGNED',
changedBy: ctx.user.id,
},
})
return created
})
// Audit outside transaction
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'CREATE_AND_ASSIGN',
entityType: 'Project',
entityId: project.id,
detailsJson: {
title: input.title,
roundId,
roundName: round.name,
programId: round.competition.programId,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return project
}),
})