Inline filtering results, select-all across pages, country flags, settings RBAC, and inline role changes
- Round detail: add skeleton loading for filtering stats, inline results table
with expandable rows, pagination, override/reinstate, CSV export, and tooltip
on AI summaries button (removes need for separate results page)
- Projects: add select-all-across-pages with Gmail-style banner, show country
flags with tooltip instead of country codes (table + card views), add listAllIds
backend endpoint
- Settings: allow PROGRAM_ADMIN access to settings page, restrict infrastructure
tabs (AI, Email, Storage, Security, Webhooks) to SUPER_ADMIN only
- Members: add inline role change via dropdown submenu in user actions, enforce
role hierarchy (only super admins can modify admin/super-admin roles) in both
backend and UI
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 23:07:38 +01:00
import crypto from 'crypto'
2026-01-30 13:41:32 +01:00
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { Prisma } from '@prisma/client'
import { router , protectedProcedure , adminProcedure } from '../trpc'
2026-02-02 13:19:28 +01:00
import { getUserAvatarUrl } from '../utils/avatar-url'
2026-02-04 00:10:51 +01:00
import {
notifyProjectTeam ,
NotificationTypes ,
} from '../services/in-app-notification'
2026-02-12 15:06:11 +01:00
import { getFirstRoundForProgram } from '@/server/utils/round-helpers'
2026-02-04 16:13:40 +01:00
import { normalizeCountryToCode } from '@/lib/countries'
2026-02-05 21:09:06 +01:00
import { logAudit } from '../utils/audit'
Inline filtering results, select-all across pages, country flags, settings RBAC, and inline role changes
- Round detail: add skeleton loading for filtering stats, inline results table
with expandable rows, pagination, override/reinstate, CSV export, and tooltip
on AI summaries button (removes need for separate results page)
- Projects: add select-all-across-pages with Gmail-style banner, show country
flags with tooltip instead of country codes (table + card views), add listAllIds
backend endpoint
- Settings: allow PROGRAM_ADMIN access to settings page, restrict infrastructure
tabs (AI, Email, Storage, Security, Webhooks) to SUPER_ADMIN only
- Members: add inline role change via dropdown submenu in user actions, enforce
role hierarchy (only super admins can modify admin/super-admin roles) in both
backend and UI
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 23:07:38 +01:00
import { sendInvitationEmail } from '@/lib/email'
const INVITE_TOKEN_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
2026-01-30 13:41:32 +01:00
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>
2026-02-08 22:05:01 +01:00
// 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)
}
2026-01-30 13:41:32 +01:00
export const projectRouter = router ( {
/ * *
* List projects with filtering and pagination
* Admin sees all , jury sees only assigned projects
* /
list : protectedProcedure
. input (
z . object ( {
2026-02-02 22:33:55 +01:00
programId : z.string ( ) . optional ( ) ,
Implement Prototype 1 improvements: unified members, project filters, audit expansion, filtering rounds, special awards
- Unified Member Management: merge /admin/users and /admin/mentors into /admin/members with role tabs, search, pagination
- Project List Filters: add search, multi-status filter, round/category/country selects, boolean toggles, URL persistence
- Audit Log Expansion: track logins, round state changes, evaluation submissions, file access, role changes via shared logAudit utility
- Founding Date Field: add foundedAt to Project model with CSV import support
- Filtering Round System: configurable rules (field-based, document check, AI screening), execution engine, results review with override/reinstate
- Special Awards System: named awards with eligibility criteria, dedicated jury, PICK_WINNER/RANKED/SCORED voting modes, AI eligibility
- Dashboard resilience: wrap heavy queries in try-catch to prevent error boundary on transient DB failures
- Reusable pagination component extracted to src/components/shared/pagination.tsx
- Old /admin/users and /admin/mentors routes redirect to /admin/members
- Prisma migration for all schema additions (additive, no data loss)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 16:58:29 +01:00
roundId : z.string ( ) . optional ( ) ,
2026-01-30 13:41:32 +01:00
status : z
. enum ( [
'SUBMITTED' ,
'ELIGIBLE' ,
'ASSIGNED' ,
'SEMIFINALIST' ,
'FINALIST' ,
'REJECTED' ,
] )
. optional ( ) ,
Implement Prototype 1 improvements: unified members, project filters, audit expansion, filtering rounds, special awards
- Unified Member Management: merge /admin/users and /admin/mentors into /admin/members with role tabs, search, pagination
- Project List Filters: add search, multi-status filter, round/category/country selects, boolean toggles, URL persistence
- Audit Log Expansion: track logins, round state changes, evaluation submissions, file access, role changes via shared logAudit utility
- Founding Date Field: add foundedAt to Project model with CSV import support
- Filtering Round System: configurable rules (field-based, document check, AI screening), execution engine, results review with override/reinstate
- Special Awards System: named awards with eligibility criteria, dedicated jury, PICK_WINNER/RANKED/SCORED voting modes, AI eligibility
- Dashboard resilience: wrap heavy queries in try-catch to prevent error boundary on transient DB failures
- Reusable pagination component extracted to src/components/shared/pagination.tsx
- Old /admin/users and /admin/mentors routes redirect to /admin/members
- Prisma migration for all schema additions (additive, no data loss)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 16:58:29 +01:00
statuses : z.array (
z . enum ( [
'SUBMITTED' ,
'ELIGIBLE' ,
'ASSIGNED' ,
'SEMIFINALIST' ,
'FINALIST' ,
'REJECTED' ,
] )
) . optional ( ) ,
2026-02-02 22:33:55 +01:00
notInRoundId : z.string ( ) . optional ( ) , // Exclude projects already in this round
unassignedOnly : z.boolean ( ) . optional ( ) , // Projects not in any round
2026-01-30 13:41:32 +01:00
search : z.string ( ) . optional ( ) ,
tags : z.array ( z . string ( ) ) . optional ( ) ,
Implement Prototype 1 improvements: unified members, project filters, audit expansion, filtering rounds, special awards
- Unified Member Management: merge /admin/users and /admin/mentors into /admin/members with role tabs, search, pagination
- Project List Filters: add search, multi-status filter, round/category/country selects, boolean toggles, URL persistence
- Audit Log Expansion: track logins, round state changes, evaluation submissions, file access, role changes via shared logAudit utility
- Founding Date Field: add foundedAt to Project model with CSV import support
- Filtering Round System: configurable rules (field-based, document check, AI screening), execution engine, results review with override/reinstate
- Special Awards System: named awards with eligibility criteria, dedicated jury, PICK_WINNER/RANKED/SCORED voting modes, AI eligibility
- Dashboard resilience: wrap heavy queries in try-catch to prevent error boundary on transient DB failures
- Reusable pagination component extracted to src/components/shared/pagination.tsx
- Old /admin/users and /admin/mentors routes redirect to /admin/members
- Prisma migration for all schema additions (additive, no data loss)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 16:58:29 +01:00
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 ( ) ,
2026-01-30 13:41:32 +01:00
page : z.number ( ) . int ( ) . min ( 1 ) . default ( 1 ) ,
2026-02-05 20:31:08 +01:00
perPage : z.number ( ) . int ( ) . min ( 1 ) . max ( 200 ) . default ( 20 ) ,
2026-01-30 13:41:32 +01:00
} )
)
. query ( async ( { ctx , input } ) = > {
Implement Prototype 1 improvements: unified members, project filters, audit expansion, filtering rounds, special awards
- Unified Member Management: merge /admin/users and /admin/mentors into /admin/members with role tabs, search, pagination
- Project List Filters: add search, multi-status filter, round/category/country selects, boolean toggles, URL persistence
- Audit Log Expansion: track logins, round state changes, evaluation submissions, file access, role changes via shared logAudit utility
- Founding Date Field: add foundedAt to Project model with CSV import support
- Filtering Round System: configurable rules (field-based, document check, AI screening), execution engine, results review with override/reinstate
- Special Awards System: named awards with eligibility criteria, dedicated jury, PICK_WINNER/RANKED/SCORED voting modes, AI eligibility
- Dashboard resilience: wrap heavy queries in try-catch to prevent error boundary on transient DB failures
- Reusable pagination component extracted to src/components/shared/pagination.tsx
- Old /admin/users and /admin/mentors routes redirect to /admin/members
- Prisma migration for all schema additions (additive, no data loss)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 16:58:29 +01:00
const {
2026-02-02 22:33:55 +01:00
programId , roundId , notInRoundId , status , statuses , unassignedOnly , search , tags ,
Implement Prototype 1 improvements: unified members, project filters, audit expansion, filtering rounds, special awards
- Unified Member Management: merge /admin/users and /admin/mentors into /admin/members with role tabs, search, pagination
- Project List Filters: add search, multi-status filter, round/category/country selects, boolean toggles, URL persistence
- Audit Log Expansion: track logins, round state changes, evaluation submissions, file access, role changes via shared logAudit utility
- Founding Date Field: add foundedAt to Project model with CSV import support
- Filtering Round System: configurable rules (field-based, document check, AI screening), execution engine, results review with override/reinstate
- Special Awards System: named awards with eligibility criteria, dedicated jury, PICK_WINNER/RANKED/SCORED voting modes, AI eligibility
- Dashboard resilience: wrap heavy queries in try-catch to prevent error boundary on transient DB failures
- Reusable pagination component extracted to src/components/shared/pagination.tsx
- Old /admin/users and /admin/mentors routes redirect to /admin/members
- Prisma migration for all schema additions (additive, no data loss)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 16:58:29 +01:00
competitionCategory , oceanIssue , country ,
wantsMentorship , hasFiles , hasAssignments ,
page , perPage ,
} = input
2026-01-30 13:41:32 +01:00
const skip = ( page - 1 ) * perPage
// Build where clause
Implement Prototype 1 improvements: unified members, project filters, audit expansion, filtering rounds, special awards
- Unified Member Management: merge /admin/users and /admin/mentors into /admin/members with role tabs, search, pagination
- Project List Filters: add search, multi-status filter, round/category/country selects, boolean toggles, URL persistence
- Audit Log Expansion: track logins, round state changes, evaluation submissions, file access, role changes via shared logAudit utility
- Founding Date Field: add foundedAt to Project model with CSV import support
- Filtering Round System: configurable rules (field-based, document check, AI screening), execution engine, results review with override/reinstate
- Special Awards System: named awards with eligibility criteria, dedicated jury, PICK_WINNER/RANKED/SCORED voting modes, AI eligibility
- Dashboard resilience: wrap heavy queries in try-catch to prevent error boundary on transient DB failures
- Reusable pagination component extracted to src/components/shared/pagination.tsx
- Old /admin/users and /admin/mentors routes redirect to /admin/members
- Prisma migration for all schema additions (additive, no data loss)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 16:58:29 +01:00
const where : Record < string , unknown > = { }
2026-01-30 13:41:32 +01:00
Inline filtering results, select-all across pages, country flags, settings RBAC, and inline role changes
- Round detail: add skeleton loading for filtering stats, inline results table
with expandable rows, pagination, override/reinstate, CSV export, and tooltip
on AI summaries button (removes need for separate results page)
- Projects: add select-all-across-pages with Gmail-style banner, show country
flags with tooltip instead of country codes (table + card views), add listAllIds
backend endpoint
- Settings: allow PROGRAM_ADMIN access to settings page, restrict infrastructure
tabs (AI, Email, Storage, Security, Webhooks) to SUPER_ADMIN only
- Members: add inline role change via dropdown submenu in user actions, enforce
role hierarchy (only super admins can modify admin/super-admin roles) in both
backend and UI
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 23:07:38 +01:00
// Filter by program
if ( programId ) where . programId = programId
2026-02-02 22:33:55 +01:00
2026-02-04 14:15:06 +01:00
// Filter by round
2026-02-02 22:33:55 +01:00
if ( roundId ) {
2026-02-04 14:15:06 +01:00
where . roundId = roundId
2026-02-02 22:33:55 +01:00
}
Inline filtering results, select-all across pages, country flags, settings RBAC, and inline role changes
- Round detail: add skeleton loading for filtering stats, inline results table
with expandable rows, pagination, override/reinstate, CSV export, and tooltip
on AI summaries button (removes need for separate results page)
- Projects: add select-all-across-pages with Gmail-style banner, show country
flags with tooltip instead of country codes (table + card views), add listAllIds
backend endpoint
- Settings: allow PROGRAM_ADMIN access to settings page, restrict infrastructure
tabs (AI, Email, Storage, Security, Webhooks) to SUPER_ADMIN only
- Members: add inline role change via dropdown submenu in user actions, enforce
role hierarchy (only super admins can modify admin/super-admin roles) in both
backend and UI
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 23:07:38 +01:00
// Exclude projects in a specific round (include unassigned projects with roundId=null)
2026-02-02 22:33:55 +01:00
if ( notInRoundId ) {
Inline filtering results, select-all across pages, country flags, settings RBAC, and inline role changes
- Round detail: add skeleton loading for filtering stats, inline results table
with expandable rows, pagination, override/reinstate, CSV export, and tooltip
on AI summaries button (removes need for separate results page)
- Projects: add select-all-across-pages with Gmail-style banner, show country
flags with tooltip instead of country codes (table + card views), add listAllIds
backend endpoint
- Settings: allow PROGRAM_ADMIN access to settings page, restrict infrastructure
tabs (AI, Email, Storage, Security, Webhooks) to SUPER_ADMIN only
- Members: add inline role change via dropdown submenu in user actions, enforce
role hierarchy (only super admins can modify admin/super-admin roles) in both
backend and UI
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 23:07:38 +01:00
if ( ! where . AND ) where . AND = [ ]
; ( where . AND as unknown [ ] ) . push ( {
OR : [
{ roundId : null } ,
{ roundId : { not : notInRoundId } } ,
] ,
} )
2026-02-02 22:33:55 +01:00
}
2026-02-04 14:15:06 +01:00
// Filter by unassigned (no round)
2026-02-02 22:33:55 +01:00
if ( unassignedOnly ) {
2026-02-04 14:15:06 +01:00
where . roundId = null
2026-02-02 22:33:55 +01:00
}
2026-02-04 14:15:06 +01:00
// Status filter
if ( statuses ? . length || status ) {
2026-02-02 22:33:55 +01:00
const statusValues = statuses ? . length ? statuses : status ? [ status ] : [ ]
if ( statusValues . length > 0 ) {
2026-02-04 14:15:06 +01:00
where . status = { in : statusValues }
2026-02-02 22:33:55 +01:00
}
Implement Prototype 1 improvements: unified members, project filters, audit expansion, filtering rounds, special awards
- Unified Member Management: merge /admin/users and /admin/mentors into /admin/members with role tabs, search, pagination
- Project List Filters: add search, multi-status filter, round/category/country selects, boolean toggles, URL persistence
- Audit Log Expansion: track logins, round state changes, evaluation submissions, file access, role changes via shared logAudit utility
- Founding Date Field: add foundedAt to Project model with CSV import support
- Filtering Round System: configurable rules (field-based, document check, AI screening), execution engine, results review with override/reinstate
- Special Awards System: named awards with eligibility criteria, dedicated jury, PICK_WINNER/RANKED/SCORED voting modes, AI eligibility
- Dashboard resilience: wrap heavy queries in try-catch to prevent error boundary on transient DB failures
- Reusable pagination component extracted to src/components/shared/pagination.tsx
- Old /admin/users and /admin/mentors routes redirect to /admin/members
- Prisma migration for all schema additions (additive, no data loss)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 16:58:29 +01:00
}
2026-02-02 22:33:55 +01:00
2026-01-30 13:41:32 +01:00
if ( tags && tags . length > 0 ) {
where . tags = { hasSome : tags }
}
Implement Prototype 1 improvements: unified members, project filters, audit expansion, filtering rounds, special awards
- Unified Member Management: merge /admin/users and /admin/mentors into /admin/members with role tabs, search, pagination
- Project List Filters: add search, multi-status filter, round/category/country selects, boolean toggles, URL persistence
- Audit Log Expansion: track logins, round state changes, evaluation submissions, file access, role changes via shared logAudit utility
- Founding Date Field: add foundedAt to Project model with CSV import support
- Filtering Round System: configurable rules (field-based, document check, AI screening), execution engine, results review with override/reinstate
- Special Awards System: named awards with eligibility criteria, dedicated jury, PICK_WINNER/RANKED/SCORED voting modes, AI eligibility
- Dashboard resilience: wrap heavy queries in try-catch to prevent error boundary on transient DB failures
- Reusable pagination component extracted to src/components/shared/pagination.tsx
- Old /admin/users and /admin/mentors routes redirect to /admin/members
- Prisma migration for all schema additions (additive, no data loss)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 16:58:29 +01:00
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 : { } }
2026-01-30 13:41:32 +01:00
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 = {
Implement Prototype 1 improvements: unified members, project filters, audit expansion, filtering rounds, special awards
- Unified Member Management: merge /admin/users and /admin/mentors into /admin/members with role tabs, search, pagination
- Project List Filters: add search, multi-status filter, round/category/country selects, boolean toggles, URL persistence
- Audit Log Expansion: track logins, round state changes, evaluation submissions, file access, role changes via shared logAudit utility
- Founding Date Field: add foundedAt to Project model with CSV import support
- Filtering Round System: configurable rules (field-based, document check, AI screening), execution engine, results review with override/reinstate
- Special Awards System: named awards with eligibility criteria, dedicated jury, PICK_WINNER/RANKED/SCORED voting modes, AI eligibility
- Dashboard resilience: wrap heavy queries in try-catch to prevent error boundary on transient DB failures
- Reusable pagination component extracted to src/components/shared/pagination.tsx
- Old /admin/users and /admin/mentors routes redirect to /admin/members
- Prisma migration for all schema additions (additive, no data loss)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 16:58:29 +01:00
. . . ( ( where . assignments as Record < string , unknown > ) || { } ) ,
2026-01-30 13:41:32 +01:00
some : { userId : ctx.user.id } ,
}
}
const [ projects , total ] = await Promise . all ( [
ctx . prisma . project . findMany ( {
where ,
skip ,
take : perPage ,
orderBy : { createdAt : 'desc' } ,
include : {
2026-02-04 14:15:06 +01:00
round : {
select : {
id : true ,
name : true ,
program : { select : { id : true , name : true , year : true } } ,
2026-02-02 22:33:55 +01:00
} ,
Implement Prototype 1 improvements: unified members, project filters, audit expansion, filtering rounds, special awards
- Unified Member Management: merge /admin/users and /admin/mentors into /admin/members with role tabs, search, pagination
- Project List Filters: add search, multi-status filter, round/category/country selects, boolean toggles, URL persistence
- Audit Log Expansion: track logins, round state changes, evaluation submissions, file access, role changes via shared logAudit utility
- Founding Date Field: add foundedAt to Project model with CSV import support
- Filtering Round System: configurable rules (field-based, document check, AI screening), execution engine, results review with override/reinstate
- Special Awards System: named awards with eligibility criteria, dedicated jury, PICK_WINNER/RANKED/SCORED voting modes, AI eligibility
- Dashboard resilience: wrap heavy queries in try-catch to prevent error boundary on transient DB failures
- Reusable pagination component extracted to src/components/shared/pagination.tsx
- Old /admin/users and /admin/mentors routes redirect to /admin/members
- Prisma migration for all schema additions (additive, no data loss)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 16:58:29 +01:00
} ,
2026-02-05 20:31:08 +01:00
_count : { select : { assignments : true , files : true } } ,
2026-01-30 13:41:32 +01:00
} ,
} ) ,
ctx . prisma . project . count ( { where } ) ,
] )
return {
projects ,
total ,
page ,
perPage ,
totalPages : Math.ceil ( total / perPage ) ,
}
} ) ,
Inline filtering results, select-all across pages, country flags, settings RBAC, and inline role changes
- Round detail: add skeleton loading for filtering stats, inline results table
with expandable rows, pagination, override/reinstate, CSV export, and tooltip
on AI summaries button (removes need for separate results page)
- Projects: add select-all-across-pages with Gmail-style banner, show country
flags with tooltip instead of country codes (table + card views), add listAllIds
backend endpoint
- Settings: allow PROGRAM_ADMIN access to settings page, restrict infrastructure
tabs (AI, Email, Storage, Security, Webhooks) to SUPER_ADMIN only
- Members: add inline role change via dropdown submenu in user actions, enforce
role hierarchy (only super admins can modify admin/super-admin roles) in both
backend and UI
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 23:07:38 +01:00
/ * *
* 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 ( ) ,
notInRoundId : 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 , notInRoundId , unassignedOnly ,
search , statuses , tags ,
competitionCategory , oceanIssue , country ,
wantsMentorship , hasFiles , hasAssignments ,
} = input
const where : Record < string , unknown > = { }
if ( programId ) where . programId = programId
if ( roundId ) where . roundId = roundId
if ( notInRoundId ) {
if ( ! where . AND ) where . AND = [ ]
; ( where . AND as unknown [ ] ) . push ( {
OR : [
{ roundId : null } ,
{ roundId : { not : notInRoundId } } ,
] ,
} )
}
if ( unassignedOnly ) where . roundId = null
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 ) }
} ) ,
Implement Prototype 1 improvements: unified members, project filters, audit expansion, filtering rounds, special awards
- Unified Member Management: merge /admin/users and /admin/mentors into /admin/members with role tabs, search, pagination
- Project List Filters: add search, multi-status filter, round/category/country selects, boolean toggles, URL persistence
- Audit Log Expansion: track logins, round state changes, evaluation submissions, file access, role changes via shared logAudit utility
- Founding Date Field: add foundedAt to Project model with CSV import support
- Filtering Round System: configurable rules (field-based, document check, AI screening), execution engine, results review with override/reinstate
- Special Awards System: named awards with eligibility criteria, dedicated jury, PICK_WINNER/RANKED/SCORED voting modes, AI eligibility
- Dashboard resilience: wrap heavy queries in try-catch to prevent error boundary on transient DB failures
- Reusable pagination component extracted to src/components/shared/pagination.tsx
- Old /admin/users and /admin/mentors routes redirect to /admin/members
- Prisma migration for all schema additions (additive, no data loss)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 16:58:29 +01:00
/ * *
* Get filter options for the project list ( distinct values )
* /
getFilterOptions : protectedProcedure
. query ( async ( { ctx } ) = > {
const [ rounds , countries , categories , issues ] = await Promise . all ( [
ctx . prisma . round . findMany ( {
2026-02-05 10:27:52 +01:00
select : { id : true , name : true , program : { select : { id : true , name : true , year : true } } } ,
2026-02-04 14:15:06 +01:00
orderBy : [ { program : { year : 'desc' } } , { createdAt : 'asc' } ] ,
Implement Prototype 1 improvements: unified members, project filters, audit expansion, filtering rounds, special awards
- Unified Member Management: merge /admin/users and /admin/mentors into /admin/members with role tabs, search, pagination
- Project List Filters: add search, multi-status filter, round/category/country selects, boolean toggles, URL persistence
- Audit Log Expansion: track logins, round state changes, evaluation submissions, file access, role changes via shared logAudit utility
- Founding Date Field: add foundedAt to Project model with CSV import support
- Filtering Round System: configurable rules (field-based, document check, AI screening), execution engine, results review with override/reinstate
- Special Awards System: named awards with eligibility criteria, dedicated jury, PICK_WINNER/RANKED/SCORED voting modes, AI eligibility
- Dashboard resilience: wrap heavy queries in try-catch to prevent error boundary on transient DB failures
- Reusable pagination component extracted to src/components/shared/pagination.tsx
- Old /admin/users and /admin/mentors routes redirect to /admin/members
- Prisma migration for all schema additions (additive, no data loss)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 16:58:29 +01:00
} ) ,
ctx . prisma . project . findMany ( {
where : { country : { not : null } } ,
select : { country : true } ,
distinct : [ 'country' ] ,
orderBy : { country : 'asc' } ,
} ) ,
ctx . prisma . project . groupBy ( {
by : [ 'competitionCategory' ] ,
where : { competitionCategory : { not : null } } ,
_count : true ,
} ) ,
ctx . prisma . project . groupBy ( {
by : [ 'oceanIssue' ] ,
where : { oceanIssue : { not : null } } ,
_count : true ,
} ) ,
] )
return {
rounds ,
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 ,
} ) ) ,
}
} ) ,
2026-01-30 13:41:32 +01:00
/ * *
* 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 ,
2026-02-04 14:15:06 +01:00
round : true ,
2026-01-30 13:41:32 +01:00
teamMembers : {
include : {
user : {
2026-02-02 13:19:28 +01:00
select : { id : true , name : true , email : true , profileImageKey : true , profileImageProvider : true } ,
2026-01-30 13:41:32 +01:00
} ,
} ,
orderBy : { joinedAt : 'asc' } ,
} ,
mentorAssignment : {
include : {
mentor : {
2026-02-02 13:19:28 +01:00
select : { id : true , name : true , email : true , expertiseTags : true , profileImageKey : true , profileImageProvider : true } ,
2026-01-30 13:41:32 +01:00
} ,
} ,
} ,
} ,
} )
2026-02-11 00:20:28 +01:00
// 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
}
2026-01-30 13:41:32 +01:00
// 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' ,
} )
}
}
2026-02-02 13:19:28 +01:00
// 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 ,
2026-02-11 00:20:28 +01:00
projectTags ,
2026-02-02 13:19:28 +01:00
teamMembers : teamMembersWithAvatars ,
mentorAssignment : mentorWithAvatar ,
}
2026-01-30 13:41:32 +01:00
} ) ,
/ * *
* Create a single project ( admin only )
2026-02-04 14:15:06 +01:00
* Projects belong to a round .
2026-01-30 13:41:32 +01:00
* /
create : adminProcedure
. input (
z . object ( {
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>
2026-02-08 22:05:01 +01:00
programId : z.string ( ) ,
roundId : z.string ( ) . optional ( ) ,
2026-01-30 13:41:32 +01:00
title : z.string ( ) . min ( 1 ) . max ( 500 ) ,
teamName : z.string ( ) . optional ( ) ,
description : z.string ( ) . optional ( ) ,
tags : z.array ( z . string ( ) ) . optional ( ) ,
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>
2026-02-08 22:05:01 +01:00
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 ( ) ,
2026-01-30 13:41:32 +01:00
metadataJson : z.record ( z . unknown ( ) ) . optional ( ) ,
Inline filtering results, select-all across pages, country flags, settings RBAC, and inline role changes
- Round detail: add skeleton loading for filtering stats, inline results table
with expandable rows, pagination, override/reinstate, CSV export, and tooltip
on AI summaries button (removes need for separate results page)
- Projects: add select-all-across-pages with Gmail-style banner, show country
flags with tooltip instead of country codes (table + card views), add listAllIds
backend endpoint
- Settings: allow PROGRAM_ADMIN access to settings page, restrict infrastructure
tabs (AI, Email, Storage, Security, Webhooks) to SUPER_ADMIN only
- Members: add inline role change via dropdown submenu in user actions, enforce
role hierarchy (only super admins can modify admin/super-admin roles) in both
backend and UI
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 23:07:38 +01:00
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 ( ) ,
2026-01-30 13:41:32 +01:00
} )
)
. mutation ( async ( { ctx , input } ) = > {
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>
2026-02-08 22:05:01 +01:00
const {
metadataJson ,
contactPhone , contactEmail , contactName , city ,
Inline filtering results, select-all across pages, country flags, settings RBAC, and inline role changes
- Round detail: add skeleton loading for filtering stats, inline results table
with expandable rows, pagination, override/reinstate, CSV export, and tooltip
on AI summaries button (removes need for separate results page)
- Projects: add select-all-across-pages with Gmail-style banner, show country
flags with tooltip instead of country codes (table + card views), add listAllIds
backend endpoint
- Settings: allow PROGRAM_ADMIN access to settings page, restrict infrastructure
tabs (AI, Email, Storage, Security, Webhooks) to SUPER_ADMIN only
- Members: add inline role change via dropdown submenu in user actions, enforce
role hierarchy (only super admins can modify admin/super-admin roles) in both
backend and UI
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 23:07:38 +01:00
teamMembers : teamMembersInput ,
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>
2026-02-08 22:05:01 +01:00
. . . rest
} = input
Add dynamic apply wizard customization with admin settings UI
- Create wizard config types, utilities, and defaults (wizard-config.ts)
- Add admin apply settings page with drag-and-drop step ordering, dropdown
option management, feature toggles, welcome message customization, and
custom field builder with select/multiselect options editor
- Build dynamic apply wizard component with animated step transitions,
mobile-first responsive design, and config-driven form validation
- Update step components to accept dynamic config (categories, ocean issues,
field visibility, feature flags)
- Replace hardcoded enum validation with string-based validation for
admin-configurable dropdown values, with safe enum casting at storage layer
- Add wizard template system (model, router, admin UI) with built-in
MOPC Classic preset
- Add program wizard config CRUD procedures to program router
- Update application router getConfig to return wizardConfig, submit handler
to store custom field data in metadataJson
- Add edition-based apply page, project pool page, and supporting routers
- Fix CSS (invalid sm:fixed-none), Enter key handler (skip textarea),
safe area insets for notched phones, buildStepsArray field visibility
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 13:18:20 +01:00
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>
2026-02-08 22:05:01 +01:00
// 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
Add dynamic apply wizard customization with admin settings UI
- Create wizard config types, utilities, and defaults (wizard-config.ts)
- Add admin apply settings page with drag-and-drop step ordering, dropdown
option management, feature toggles, welcome message customization, and
custom field builder with select/multiselect options editor
- Build dynamic apply wizard component with animated step transitions,
mobile-first responsive design, and config-driven form validation
- Update step components to accept dynamic config (categories, ocean issues,
field visibility, feature flags)
- Replace hardcoded enum validation with string-based validation for
admin-configurable dropdown values, with safe enum casting at storage layer
- Add wizard template system (model, router, admin UI) with built-in
MOPC Classic preset
- Add program wizard config CRUD procedures to program router
- Update application router getConfig to return wizardConfig, submit handler
to store custom field data in metadataJson
- Add edition-based apply page, project pool page, and supporting routers
- Fix CSS (invalid sm:fixed-none), Enter key handler (skip textarea),
safe area insets for notched phones, buildStepsArray field visibility
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 13:18:20 +01:00
Inline filtering results, select-all across pages, country flags, settings RBAC, and inline role changes
- Round detail: add skeleton loading for filtering stats, inline results table
with expandable rows, pagination, override/reinstate, CSV export, and tooltip
on AI summaries button (removes need for separate results page)
- Projects: add select-all-across-pages with Gmail-style banner, show country
flags with tooltip instead of country codes (table + card views), add listAllIds
backend endpoint
- Settings: allow PROGRAM_ADMIN access to settings page, restrict infrastructure
tabs (AI, Email, Storage, Security, Webhooks) to SUPER_ADMIN only
- Members: add inline role change via dropdown submenu in user actions, enforce
role hierarchy (only super admins can modify admin/super-admin roles) in both
backend and UI
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 23:07:38 +01:00
const { project , membersToInvite } = await ctx . prisma . $transaction ( async ( tx ) = > {
2026-02-12 15:06:11 +01:00
// Auto-assign to first round if no roundId provided
let resolvedRoundId = input . roundId || null
if ( ! resolvedRoundId ) {
const firstRound = await getFirstRoundForProgram ( tx , resolvedProgramId )
if ( firstRound ) {
resolvedRoundId = firstRound . id
}
}
2026-02-05 21:09:06 +01:00
const created = await tx . project . create ( {
data : {
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>
2026-02-08 22:05:01 +01:00
programId : resolvedProgramId ,
2026-02-12 15:06:11 +01:00
roundId : resolvedRoundId ,
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>
2026-02-08 22:05:01 +01:00
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 ,
2026-02-05 21:09:06 +01:00
status : 'SUBMITTED' ,
} ,
} )
2026-01-30 13:41:32 +01:00
Inline filtering results, select-all across pages, country flags, settings RBAC, and inline role changes
- Round detail: add skeleton loading for filtering stats, inline results table
with expandable rows, pagination, override/reinstate, CSV export, and tooltip
on AI summaries button (removes need for separate results page)
- Projects: add select-all-across-pages with Gmail-style banner, show country
flags with tooltip instead of country codes (table + card views), add listAllIds
backend endpoint
- Settings: allow PROGRAM_ADMIN access to settings page, restrict infrastructure
tabs (AI, Email, Storage, Security, Webhooks) to SUPER_ADMIN only
- Members: add inline role change via dropdown submenu in user actions, enforce
role hierarchy (only super admins can modify admin/super-admin roles) in both
backend and UI
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 23:07:38 +01:00
// 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 } )
}
}
}
2026-02-05 21:09:06 +01:00
await logAudit ( {
prisma : tx ,
2026-01-30 13:41:32 +01:00
userId : ctx.user.id ,
action : 'CREATE' ,
entityType : 'Project' ,
2026-02-05 21:09:06 +01:00
entityId : created.id ,
Inline filtering results, select-all across pages, country flags, settings RBAC, and inline role changes
- Round detail: add skeleton loading for filtering stats, inline results table
with expandable rows, pagination, override/reinstate, CSV export, and tooltip
on AI summaries button (removes need for separate results page)
- Projects: add select-all-across-pages with Gmail-style banner, show country
flags with tooltip instead of country codes (table + card views), add listAllIds
backend endpoint
- Settings: allow PROGRAM_ADMIN access to settings page, restrict infrastructure
tabs (AI, Email, Storage, Security, Webhooks) to SUPER_ADMIN only
- Members: add inline role change via dropdown submenu in user actions, enforce
role hierarchy (only super admins can modify admin/super-admin roles) in both
backend and UI
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 23:07:38 +01:00
detailsJson : {
title : input.title ,
roundId : input.roundId ,
programId : resolvedProgramId ,
teamMembersCount : teamMembersInput?.length || 0 ,
} ,
2026-01-30 13:41:32 +01:00
ipAddress : ctx.ip ,
userAgent : ctx.userAgent ,
2026-02-05 21:09:06 +01:00
} )
Inline filtering results, select-all across pages, country flags, settings RBAC, and inline role changes
- Round detail: add skeleton loading for filtering stats, inline results table
with expandable rows, pagination, override/reinstate, CSV export, and tooltip
on AI summaries button (removes need for separate results page)
- Projects: add select-all-across-pages with Gmail-style banner, show country
flags with tooltip instead of country codes (table + card views), add listAllIds
backend endpoint
- Settings: allow PROGRAM_ADMIN access to settings page, restrict infrastructure
tabs (AI, Email, Storage, Security, Webhooks) to SUPER_ADMIN only
- Members: add inline role change via dropdown submenu in user actions, enforce
role hierarchy (only super admins can modify admin/super-admin roles) in both
backend and UI
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 23:07:38 +01:00
return { project : created , membersToInvite : inviteList }
2026-01-30 13:41:32 +01:00
} )
Inline filtering results, select-all across pages, country flags, settings RBAC, and inline role changes
- Round detail: add skeleton loading for filtering stats, inline results table
with expandable rows, pagination, override/reinstate, CSV export, and tooltip
on AI summaries button (removes need for separate results page)
- Projects: add select-all-across-pages with Gmail-style banner, show country
flags with tooltip instead of country codes (table + card views), add listAllIds
backend endpoint
- Settings: allow PROGRAM_ADMIN access to settings page, restrict infrastructure
tabs (AI, Email, Storage, Security, Webhooks) to SUPER_ADMIN only
- Members: add inline role change via dropdown submenu in user actions, enforce
role hierarchy (only super admins can modify admin/super-admin roles) in both
backend and UI
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 23:07:38 +01:00
// 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 ) ,
} ,
} )
2026-02-11 01:26:19 +01:00
const inviteUrl = ` ${ baseUrl } /accept-invite?token= ${ token } `
Inline filtering results, select-all across pages, country flags, settings RBAC, and inline role changes
- Round detail: add skeleton loading for filtering stats, inline results table
with expandable rows, pagination, override/reinstate, CSV export, and tooltip
on AI summaries button (removes need for separate results page)
- Projects: add select-all-across-pages with Gmail-style banner, show country
flags with tooltip instead of country codes (table + card views), add listAllIds
backend endpoint
- Settings: allow PROGRAM_ADMIN access to settings page, restrict infrastructure
tabs (AI, Email, Storage, Security, Webhooks) to SUPER_ADMIN only
- Members: add inline role change via dropdown submenu in user actions, enforce
role hierarchy (only super admins can modify admin/super-admin roles) in both
backend and UI
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 23:07:38 +01:00
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 } ` )
}
}
}
2026-01-30 13:41:32 +01:00
return project
} ) ,
/ * *
* Update a project ( admin only )
2026-02-02 22:33:55 +01:00
* Status updates require a roundId context since status is per - round .
2026-01-30 13:41:32 +01:00
* /
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 ( ) ,
2026-02-04 16:13:40 +01:00
country : z.string ( ) . optional ( ) . nullable ( ) , // ISO-2 code or country name (will be normalized)
2026-02-02 22:33:55 +01:00
// Status update requires roundId
roundId : z.string ( ) . optional ( ) ,
2026-01-30 13:41:32 +01:00
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 } ) = > {
2026-02-04 16:13:40 +01:00
const { id , metadataJson , status , roundId , country , . . . data } = input
// Normalize country to ISO-2 code if provided
const normalizedCountry = country !== undefined
? ( country === null ? null : normalizeCountryToCode ( country ) )
: undefined
2026-01-30 13:41:32 +01:00
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>
2026-02-08 22:05:01 +01:00
// Validate status transition if status is being changed
Implement 10 platform features: evaluation UX, admin tools, AI summaries, applicant portal
Batch 1 - Quick Wins:
- F1: Evaluation progress indicator with touch tracking in sticky status bar
- F2: Export filtering results as CSV with dynamic AI column flattening
- F3: Observer access to analytics dashboards (8 procedures changed to observerProcedure)
Batch 2 - Jury Experience:
- F4: Countdown timer component with urgency colors + email reminder service with cron endpoint
- F5: Conflict of interest declaration system (dialog, admin management, review workflow)
Batch 3 - Admin & AI Enhancements:
- F6: Bulk status update UI with selection checkboxes, floating toolbar, status history recording
- F7: AI-powered evaluation summary with anonymized data, OpenAI integration, scoring patterns
- F8: Smart assignment improvements (geo diversity penalty, round familiarity bonus, COI blocking)
Batch 4 - Form Flexibility & Applicant Portal:
- F9: Evaluation form flexibility (text, boolean, section_header types, conditional visibility)
- F10: Applicant portal (status timeline, per-round documents, mentor messaging)
Schema: 5 new models (ReminderLog, ConflictOfInterest, EvaluationSummary, ProjectStatusHistory, MentorMessage), ProjectFile extended with roundId + isLate.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 21:58:27 +01:00
if ( status ) {
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>
2026-02-08 22:05:01 +01:00
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 } ,
Implement 10 platform features: evaluation UX, admin tools, AI summaries, applicant portal
Batch 1 - Quick Wins:
- F1: Evaluation progress indicator with touch tracking in sticky status bar
- F2: Export filtering results as CSV with dynamic AI column flattening
- F3: Observer access to analytics dashboards (8 procedures changed to observerProcedure)
Batch 2 - Jury Experience:
- F4: Countdown timer component with urgency colors + email reminder service with cron endpoint
- F5: Conflict of interest declaration system (dialog, admin management, review workflow)
Batch 3 - Admin & AI Enhancements:
- F6: Bulk status update UI with selection checkboxes, floating toolbar, status history recording
- F7: AI-powered evaluation summary with anonymized data, OpenAI integration, scoring patterns
- F8: Smart assignment improvements (geo diversity penalty, round familiarity bonus, COI blocking)
Batch 4 - Form Flexibility & Applicant Portal:
- F9: Evaluation form flexibility (text, boolean, section_header types, conditional visibility)
- F10: Applicant portal (status timeline, per-round documents, mentor messaging)
Schema: 5 new models (ReminderLog, ConflictOfInterest, EvaluationSummary, ProjectStatusHistory, MentorMessage), ProjectFile extended with roundId + isLate.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 21:58:27 +01:00
data : {
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>
2026-02-08 22:05:01 +01:00
. . . data ,
. . . ( status && { status } ) ,
. . . ( normalizedCountry !== undefined && { country : normalizedCountry } ) ,
metadataJson : metadataJson as Prisma . InputJsonValue ? ? undefined ,
Implement 10 platform features: evaluation UX, admin tools, AI summaries, applicant portal
Batch 1 - Quick Wins:
- F1: Evaluation progress indicator with touch tracking in sticky status bar
- F2: Export filtering results as CSV with dynamic AI column flattening
- F3: Observer access to analytics dashboards (8 procedures changed to observerProcedure)
Batch 2 - Jury Experience:
- F4: Countdown timer component with urgency colors + email reminder service with cron endpoint
- F5: Conflict of interest declaration system (dialog, admin management, review workflow)
Batch 3 - Admin & AI Enhancements:
- F6: Bulk status update UI with selection checkboxes, floating toolbar, status history recording
- F7: AI-powered evaluation summary with anonymized data, OpenAI integration, scoring patterns
- F8: Smart assignment improvements (geo diversity penalty, round familiarity bonus, COI blocking)
Batch 4 - Form Flexibility & Applicant Portal:
- F9: Evaluation form flexibility (text, boolean, section_header types, conditional visibility)
- F10: Applicant portal (status timeline, per-round documents, mentor messaging)
Schema: 5 new models (ReminderLog, ConflictOfInterest, EvaluationSummary, ProjectStatusHistory, MentorMessage), ProjectFile extended with roundId + isLate.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 21:58:27 +01:00
} ,
} )
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>
2026-02-08 22:05:01 +01:00
// Record status change in history
if ( status ) {
await tx . projectStatusHistory . create ( {
data : {
projectId : id ,
status ,
changedBy : ctx.user.id ,
} ,
} )
}
return updated
} )
Implement 10 platform features: evaluation UX, admin tools, AI summaries, applicant portal
Batch 1 - Quick Wins:
- F1: Evaluation progress indicator with touch tracking in sticky status bar
- F2: Export filtering results as CSV with dynamic AI column flattening
- F3: Observer access to analytics dashboards (8 procedures changed to observerProcedure)
Batch 2 - Jury Experience:
- F4: Countdown timer component with urgency colors + email reminder service with cron endpoint
- F5: Conflict of interest declaration system (dialog, admin management, review workflow)
Batch 3 - Admin & AI Enhancements:
- F6: Bulk status update UI with selection checkboxes, floating toolbar, status history recording
- F7: AI-powered evaluation summary with anonymized data, OpenAI integration, scoring patterns
- F8: Smart assignment improvements (geo diversity penalty, round familiarity bonus, COI blocking)
Batch 4 - Form Flexibility & Applicant Portal:
- F9: Evaluation form flexibility (text, boolean, section_header types, conditional visibility)
- F10: Applicant portal (status timeline, per-round documents, mentor messaging)
Schema: 5 new models (ReminderLog, ConflictOfInterest, EvaluationSummary, ProjectStatusHistory, MentorMessage), ProjectFile extended with roundId + isLate.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 21:58:27 +01:00
2026-02-04 14:15:06 +01:00
// Send notifications if status changed
if ( status ) {
// Get round details for notification
const projectWithRound = await ctx . prisma . project . findUnique ( {
where : { id } ,
include : { round : { select : { name : true , entryNotificationType : true , program : { select : { name : true } } } } } ,
2026-02-02 22:33:55 +01:00
} )
2026-02-04 00:10:51 +01:00
2026-02-04 14:15:06 +01:00
const round = projectWithRound ? . round
2026-02-04 00:10:51 +01:00
// Helper to get notification title based on type
const getNotificationTitle = ( type : string ) : string = > {
const titles : Record < string , string > = {
ADVANCED_SEMIFINAL : "Congratulations! You're a Semi-Finalist" ,
ADVANCED_FINAL : "Amazing News! You're a Finalist" ,
NOT_SELECTED : 'Application Status Update' ,
WINNER_ANNOUNCEMENT : 'Congratulations! You Won!' ,
}
return titles [ type ] || 'Project Update'
}
// Helper to get notification message based on type
const getNotificationMessage = ( type : string , projectName : string ) : string = > {
const messages : Record < string , ( name : string ) = > string > = {
ADVANCED_SEMIFINAL : ( name ) = > ` Your project " ${ name } " has advanced to the semi-finals! ` ,
ADVANCED_FINAL : ( name ) = > ` Your project " ${ name } " has been selected as a finalist! ` ,
NOT_SELECTED : ( name ) = > ` We regret to inform you that " ${ name } " was not selected for the next round. ` ,
WINNER_ANNOUNCEMENT : ( name ) = > ` Your project " ${ name } " has been selected as a winner! ` ,
}
return messages [ type ] ? . ( projectName ) || ` Update regarding your project " ${ projectName } ". `
}
// Use round's configured notification type, or fall back to status-based defaults
if ( round ? . entryNotificationType ) {
await notifyProjectTeam ( id , {
type : round . entryNotificationType ,
title : getNotificationTitle ( round . entryNotificationType ) ,
message : getNotificationMessage ( round . entryNotificationType , project . title ) ,
linkUrl : ` /team/projects/ ${ id } ` ,
linkLabel : 'View Project' ,
priority : round.entryNotificationType === 'NOT_SELECTED' ? 'normal' : 'high' ,
metadata : {
projectName : project.title ,
roundName : round.name ,
programName : round.program?.name ,
} ,
} )
2026-02-04 14:15:06 +01:00
} else if ( round ) {
2026-02-04 00:10:51 +01:00
// Fall back to hardcoded status-based notifications
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 ,
roundName : round?.name ,
programName : round?.program?.name ,
} ,
} )
}
}
2026-02-02 22:33:55 +01:00
}
2026-01-30 13:41:32 +01:00
// Audit log
2026-02-05 21:09:06 +01:00
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 ,
2026-01-30 13:41:32 +01:00
} )
return project
} ) ,
/ * *
* Delete a project ( admin only )
* /
delete : adminProcedure
. input ( z . object ( { id : z.string ( ) } ) )
. mutation ( async ( { ctx , input } ) = > {
2026-02-05 21:09:06 +01:00
const project = await ctx . prisma . $transaction ( async ( tx ) = > {
const target = await tx . project . findUniqueOrThrow ( {
where : { id : input.id } ,
select : { id : true , title : true } ,
} )
2026-01-30 13:41:32 +01:00
2026-02-05 21:09:06 +01:00
await logAudit ( {
prisma : tx ,
2026-01-30 13:41:32 +01:00
userId : ctx.user.id ,
action : 'DELETE' ,
entityType : 'Project' ,
entityId : input.id ,
2026-02-05 21:09:06 +01:00
detailsJson : { title : target.title } ,
2026-01-30 13:41:32 +01:00
ipAddress : ctx.ip ,
userAgent : ctx.userAgent ,
2026-02-05 21:09:06 +01:00
} )
return tx . project . delete ( {
where : { id : input.id } ,
} )
2026-01-30 13:41:32 +01:00
} )
return project
} ) ,
2026-02-10 21:21:54 +01:00
/ * *
* 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 } } ,
select : { id : true , title : true } ,
} )
if ( projects . length === 0 ) {
throw new TRPCError ( {
code : 'NOT_FOUND' ,
message : 'No projects found to delete' ,
} )
}
const result = await ctx . prisma . $transaction ( async ( tx ) = > {
await logAudit ( {
prisma : tx ,
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 tx . project . deleteMany ( {
where : { id : { in : projects . map ( ( p ) = > p . id ) } } ,
} )
} )
return { deleted : result.count }
} ) ,
2026-01-30 13:41:32 +01:00
/ * *
* Import projects from CSV data ( admin only )
2026-02-02 22:33:55 +01:00
* Projects belong to a program . Optionally assign to a round .
2026-01-30 13:41:32 +01:00
* /
importCSV : adminProcedure
. input (
z . object ( {
2026-02-02 22:33:55 +01:00
programId : z.string ( ) ,
roundId : z.string ( ) . optional ( ) ,
2026-01-30 13:41:32 +01:00
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 } ) = > {
2026-02-02 22:33:55 +01:00
// Verify program exists
await ctx . prisma . program . findUniqueOrThrow ( {
where : { id : input.programId } ,
2026-01-30 13:41:32 +01:00
} )
2026-02-02 22:33:55 +01:00
// Verify round exists and belongs to program if provided
if ( input . roundId ) {
const round = await ctx . prisma . round . findUniqueOrThrow ( {
where : { id : input.roundId } ,
} )
if ( round . programId !== input . programId ) {
throw new TRPCError ( {
code : 'BAD_REQUEST' ,
message : 'Round does not belong to the selected program' ,
} )
}
}
2026-02-12 15:06:11 +01:00
// Auto-assign to first round if no roundId provided
let resolvedImportRoundId = input . roundId || null
if ( ! resolvedImportRoundId ) {
const firstRound = await getFirstRoundForProgram ( ctx . prisma , input . programId )
if ( firstRound ) {
resolvedImportRoundId = firstRound . id
}
}
2026-02-02 22:33:55 +01:00
// Create projects in a transaction
const result = await ctx . prisma . $transaction ( async ( tx ) = > {
Add dynamic apply wizard customization with admin settings UI
- Create wizard config types, utilities, and defaults (wizard-config.ts)
- Add admin apply settings page with drag-and-drop step ordering, dropdown
option management, feature toggles, welcome message customization, and
custom field builder with select/multiselect options editor
- Build dynamic apply wizard component with animated step transitions,
mobile-first responsive design, and config-driven form validation
- Update step components to accept dynamic config (categories, ocean issues,
field visibility, feature flags)
- Replace hardcoded enum validation with string-based validation for
admin-configurable dropdown values, with safe enum casting at storage layer
- Add wizard template system (model, router, admin UI) with built-in
MOPC Classic preset
- Add program wizard config CRUD procedures to program router
- Update application router getConfig to return wizardConfig, submit handler
to store custom field data in metadataJson
- Add edition-based apply page, project pool page, and supporting routers
- Fix CSS (invalid sm:fixed-none), Enter key handler (skip textarea),
safe area insets for notched phones, buildStepsArray field visibility
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 13:18:20 +01:00
// Create all projects with roundId and programId
2026-02-02 22:33:55 +01:00
const projectData = input . projects . map ( ( p ) = > {
2026-01-30 13:41:32 +01:00
const { metadataJson , . . . rest } = p
return {
. . . rest ,
Add dynamic apply wizard customization with admin settings UI
- Create wizard config types, utilities, and defaults (wizard-config.ts)
- Add admin apply settings page with drag-and-drop step ordering, dropdown
option management, feature toggles, welcome message customization, and
custom field builder with select/multiselect options editor
- Build dynamic apply wizard component with animated step transitions,
mobile-first responsive design, and config-driven form validation
- Update step components to accept dynamic config (categories, ocean issues,
field visibility, feature flags)
- Replace hardcoded enum validation with string-based validation for
admin-configurable dropdown values, with safe enum casting at storage layer
- Add wizard template system (model, router, admin UI) with built-in
MOPC Classic preset
- Add program wizard config CRUD procedures to program router
- Update application router getConfig to return wizardConfig, submit handler
to store custom field data in metadataJson
- Add edition-based apply page, project pool page, and supporting routers
- Fix CSS (invalid sm:fixed-none), Enter key handler (skip textarea),
safe area insets for notched phones, buildStepsArray field visibility
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 13:18:20 +01:00
programId : input.programId ,
2026-02-12 15:06:11 +01:00
roundId : resolvedImportRoundId ,
2026-02-04 14:15:06 +01:00
status : 'SUBMITTED' as const ,
2026-01-30 13:41:32 +01:00
metadataJson : metadataJson as Prisma . InputJsonValue ? ? undefined ,
}
2026-02-02 22:33:55 +01:00
} )
const created = await tx . project . createManyAndReturn ( {
data : projectData ,
select : { id : true } ,
} )
return { imported : created.length }
2026-01-30 13:41:32 +01:00
} )
// Audit log
2026-02-05 21:09:06 +01:00
await logAudit ( {
prisma : ctx.prisma ,
userId : ctx.user.id ,
action : 'IMPORT' ,
entityType : 'Project' ,
detailsJson : { programId : input.programId , roundId : input.roundId , count : result.imported } ,
ipAddress : ctx.ip ,
userAgent : ctx.userAgent ,
2026-01-30 13:41:32 +01:00
} )
2026-02-02 22:33:55 +01:00
return result
2026-01-30 13:41:32 +01:00
} ) ,
/ * *
* Get all unique tags used in projects
* /
getTags : protectedProcedure
2026-02-02 22:33:55 +01:00
. input ( z . object ( {
roundId : z.string ( ) . optional ( ) ,
programId : z.string ( ) . optional ( ) ,
} ) )
2026-01-30 13:41:32 +01:00
. query ( async ( { ctx , input } ) = > {
2026-02-02 22:33:55 +01:00
const where : Record < string , unknown > = { }
2026-02-04 14:15:06 +01:00
if ( input . programId ) where . round = { programId : input.programId }
if ( input . roundId ) where . roundId = input . roundId
2026-02-02 22:33:55 +01:00
2026-01-30 13:41:32 +01:00
const projects = await ctx . prisma . project . findMany ( {
2026-02-02 22:33:55 +01:00
where : Object.keys ( where ) . length > 0 ? where : undefined ,
2026-01-30 13:41:32 +01:00
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 )
2026-02-02 22:33:55 +01:00
* Status is per - round , so roundId is required .
2026-01-30 13:41:32 +01:00
* /
bulkUpdateStatus : adminProcedure
. input (
z . object ( {
ids : z.array ( z . string ( ) ) ,
2026-02-02 22:33:55 +01:00
roundId : z.string ( ) ,
2026-01-30 13:41:32 +01:00
status : z.enum ( [
'SUBMITTED' ,
'ELIGIBLE' ,
'ASSIGNED' ,
'SEMIFINALIST' ,
'FINALIST' ,
'REJECTED' ,
] ) ,
} )
)
. mutation ( async ( { ctx , input } ) = > {
2026-02-05 21:09:06 +01:00
// Fetch matching projects BEFORE update so notifications match actually-updated records
const [ projects , round ] = await Promise . all ( [
ctx . prisma . project . findMany ( {
where : {
id : { in : input . ids } ,
roundId : input.roundId ,
} ,
select : { id : true , title : true } ,
} ) ,
ctx . prisma . round . findUnique ( {
where : { id : input.roundId } ,
select : { name : true , entryNotificationType : true , program : { select : { name : true } } } ,
} ) ,
] )
const matchingIds = projects . map ( ( p ) = > p . id )
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>
2026-02-08 22:05:01 +01:00
// 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 } ,
2026-01-30 13:41:32 +01:00
} )
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>
2026-02-08 22:05:01 +01:00
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 ` : '' } ` ,
Implement 10 platform features: evaluation UX, admin tools, AI summaries, applicant portal
Batch 1 - Quick Wins:
- F1: Evaluation progress indicator with touch tracking in sticky status bar
- F2: Export filtering results as CSV with dynamic AI column flattening
- F3: Observer access to analytics dashboards (8 procedures changed to observerProcedure)
Batch 2 - Jury Experience:
- F4: Countdown timer component with urgency colors + email reminder service with cron endpoint
- F5: Conflict of interest declaration system (dialog, admin management, review workflow)
Batch 3 - Admin & AI Enhancements:
- F6: Bulk status update UI with selection checkboxes, floating toolbar, status history recording
- F7: AI-powered evaluation summary with anonymized data, OpenAI integration, scoring patterns
- F8: Smart assignment improvements (geo diversity penalty, round familiarity bonus, COI blocking)
Batch 4 - Form Flexibility & Applicant Portal:
- F9: Evaluation form flexibility (text, boolean, section_header types, conditional visibility)
- F10: Applicant portal (status timeline, per-round documents, mentor messaging)
Schema: 5 new models (ReminderLog, ConflictOfInterest, EvaluationSummary, ProjectStatusHistory, MentorMessage), ProjectFile extended with roundId + isLate.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 21:58:27 +01:00
} )
}
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>
2026-02-08 22:05:01 +01:00
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
2026-01-30 13:41:32 +01:00
} )
2026-02-04 00:10:51 +01:00
// Helper to get notification title based on type
const getNotificationTitle = ( type : string ) : string = > {
const titles : Record < string , string > = {
ADVANCED_SEMIFINAL : "Congratulations! You're a Semi-Finalist" ,
ADVANCED_FINAL : "Amazing News! You're a Finalist" ,
NOT_SELECTED : 'Application Status Update' ,
WINNER_ANNOUNCEMENT : 'Congratulations! You Won!' ,
}
return titles [ type ] || 'Project Update'
}
// Helper to get notification message based on type
const getNotificationMessage = ( type : string , projectName : string ) : string = > {
const messages : Record < string , ( name : string ) = > string > = {
ADVANCED_SEMIFINAL : ( name ) = > ` Your project " ${ name } " has advanced to the semi-finals! ` ,
ADVANCED_FINAL : ( name ) = > ` Your project " ${ name } " has been selected as a finalist! ` ,
NOT_SELECTED : ( name ) = > ` We regret to inform you that " ${ name } " was not selected for the next round. ` ,
WINNER_ANNOUNCEMENT : ( name ) = > ` Your project " ${ name } " has been selected as a winner! ` ,
}
return messages [ type ] ? . ( projectName ) || ` Update regarding your project " ${ projectName } ". `
}
// Notify project teams based on round's configured notification or status-based fallback
if ( projects . length > 0 ) {
if ( round ? . entryNotificationType ) {
// Use round's configured notification type
for ( const project of projects ) {
await notifyProjectTeam ( project . id , {
type : round . entryNotificationType ,
title : getNotificationTitle ( round . entryNotificationType ) ,
message : getNotificationMessage ( round . entryNotificationType , project . title ) ,
linkUrl : ` /team/projects/ ${ project . id } ` ,
linkLabel : 'View Project' ,
priority : round.entryNotificationType === 'NOT_SELECTED' ? 'normal' : 'high' ,
metadata : {
projectName : project.title ,
roundName : round.name ,
programName : round.program?.name ,
} ,
} )
}
} else {
// Fall back to hardcoded status-based notifications
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 ,
roundName : round?.name ,
programName : round?.program?.name ,
} ,
} )
}
}
}
}
2026-01-30 13:41:32 +01:00
return { updated : updated.count }
} ) ,
2026-02-02 22:33:55 +01:00
/ * *
* List projects in a program ' s pool ( not assigned to any round )
* /
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 > = {
2026-02-10 21:21:54 +01:00
programId ,
2026-02-04 14:15:06 +01:00
roundId : null ,
2026-02-02 22:33:55 +01:00
}
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 ) }
} ) ,
Performance optimization, applicant portal, and missing DB migration
Performance:
- Convert admin dashboard from SSR to client-side tRPC (fixes 503/ChunkLoadError)
- New dashboard.getStats tRPC endpoint batches 16 queries into single response
- Parallelize jury dashboard queries (assignments + gracePeriods via Promise.all)
- Add project.getFullDetail combined endpoint (project + assignments + stats)
- Configure Prisma connection pool (connection_limit=20, pool_timeout=10)
- Add optimizePackageImports for lucide-react tree-shaking
- Increase React Query staleTime from 1min to 5min
Applicant portal:
- Add applicant layout, nav, dashboard, documents, team, and mentor pages
- Add applicant router with document and team management endpoints
- Add chunk error recovery utility
- Update role nav and auth redirect for applicant role
Database:
- Add migration for missing schema elements (SpecialAward job tracking
columns, WizardTemplate table, missing indexes)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 11:04:26 +01:00
/ * *
* 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 ,
round : 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 } } ,
evaluation : { select : { status : true , submittedAt : true , globalScore : true , binaryDecision : 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 )
const yesVotes = submittedEvaluations . filter ( ( e ) = > e . binaryDecision === true ) . length
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 : ( yesVotes / submittedEvaluations . length ) * 100 ,
}
}
// 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 ,
}
} ) ,
2026-01-30 13:41:32 +01:00
} )