Comprehensive platform review: security fixes, query optimization, UI improvements, and code cleanup
Security (Critical/High): - Fix path traversal bypass in local storage provider (path.resolve + prefix check) - Fix timing-unsafe HMAC comparison (crypto.timingSafeEqual) - Add auth + ownership checks to email API routes (verify-credentials, change-password) - Remove hardcoded secret key fallback in local storage provider - Add production credential check for MinIO (fail loudly if not set) - Remove DB error details from health check response - Add stricter rate limiting on application submissions (5/hour) - Add rate limiting on email availability check (anti-enumeration) - Change getAIAssignmentJobStatus to adminProcedure - Block dangerous file extensions on upload - Reduce project list max perPage from 5000 to 200 Query Optimization: - Optimize analytics getProjectRankings with select instead of full includes - Fix N+1 in mentor.getSuggestions (batch findMany instead of loop) - Use _count for files instead of fetching full file records in project list - Switch to bulk notifications in assignment and user bulk operations - Batch filtering upserts (25 per transaction instead of all at once) UI/UX: - Replace Inter font with Montserrat in public layout (brand consistency) - Use Logo component in public layout instead of placeholder - Create branded 404 and error pages - Make admin rounds table responsive with mobile card layout - Fix notification bell paths to be role-aware - Replace hardcoded slate colors with semantic tokens in admin sidebar - Force light mode (dark mode untested) - Adjust CardTitle default size - Improve muted-foreground contrast for accessibility (A11Y) - Move profile form state initialization to useEffect Code Quality: - Extract shared toProjectWithRelations to anonymization.ts (removed 3 duplicates) - Remove dead code: getObjectInfo, isValidImageSize, unused batch tag functions, debug logs - Remove unused twilio dependency - Remove redundant email index from schema - Add actual storage object deletion when file records are deleted - Wrap evaluation submit + assignment update in - Add comprehensive platform review document Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -17,7 +17,7 @@ import { classifyAIError, createParseError, logAIError } from './ai-errors'
|
||||
import {
|
||||
anonymizeProjectsForAI,
|
||||
validateAnonymizedProjects,
|
||||
type ProjectWithRelations,
|
||||
toProjectWithRelations,
|
||||
type AnonymizedProjectForAI,
|
||||
type ProjectAIMapping,
|
||||
} from './anonymization'
|
||||
@@ -131,32 +131,6 @@ function getFieldValue(
|
||||
|
||||
// ─── AI Criteria Interpretation ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Convert project to enhanced format for anonymization
|
||||
*/
|
||||
function toProjectWithRelations(project: ProjectForEligibility): ProjectWithRelations {
|
||||
return {
|
||||
id: project.id,
|
||||
title: project.title,
|
||||
description: project.description,
|
||||
competitionCategory: project.competitionCategory as any,
|
||||
oceanIssue: project.oceanIssue as any,
|
||||
country: project.country,
|
||||
geographicZone: project.geographicZone,
|
||||
institution: project.institution,
|
||||
tags: project.tags,
|
||||
foundedAt: project.foundedAt,
|
||||
wantsMentorship: project.wantsMentorship ?? false,
|
||||
submissionSource: project.submissionSource ?? 'MANUAL',
|
||||
submittedAt: project.submittedAt,
|
||||
_count: {
|
||||
teamMembers: project._count?.teamMembers ?? 0,
|
||||
files: project._count?.files ?? 0,
|
||||
},
|
||||
files: project.files?.map(f => ({ fileType: f.fileType as any })) ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a batch for AI eligibility evaluation
|
||||
*/
|
||||
|
||||
@@ -18,7 +18,7 @@ import { classifyAIError, createParseError, logAIError } from './ai-errors'
|
||||
import {
|
||||
anonymizeProjectsForAI,
|
||||
validateAnonymizedProjects,
|
||||
type ProjectWithRelations,
|
||||
toProjectWithRelations,
|
||||
type AnonymizedProjectForAI,
|
||||
type ProjectAIMapping,
|
||||
} from './anonymization'
|
||||
@@ -275,32 +275,6 @@ interface AIScreeningResult {
|
||||
spamRisk: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert project to enhanced format for anonymization
|
||||
*/
|
||||
function toProjectWithRelations(project: ProjectForFiltering): ProjectWithRelations {
|
||||
return {
|
||||
id: project.id,
|
||||
title: project.title,
|
||||
description: project.description,
|
||||
competitionCategory: project.competitionCategory as any,
|
||||
oceanIssue: project.oceanIssue as any,
|
||||
country: project.country,
|
||||
geographicZone: project.geographicZone,
|
||||
institution: project.institution,
|
||||
tags: project.tags,
|
||||
foundedAt: project.foundedAt,
|
||||
wantsMentorship: project.wantsMentorship ?? false,
|
||||
submissionSource: project.submissionSource ?? 'MANUAL',
|
||||
submittedAt: project.submittedAt,
|
||||
_count: {
|
||||
teamMembers: project._count?.teamMembers ?? 0,
|
||||
files: project.files?.length ?? 0,
|
||||
},
|
||||
files: project.files?.map(f => ({ fileType: f.fileType ?? null })) ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute AI screening on a batch of projects
|
||||
*/
|
||||
|
||||
@@ -22,9 +22,8 @@ import { classifyAIError, createParseError, logAIError } from './ai-errors'
|
||||
import {
|
||||
anonymizeProjectsForAI,
|
||||
validateAnonymizedProjects,
|
||||
type ProjectWithRelations,
|
||||
toProjectWithRelations,
|
||||
type AnonymizedProjectForAI,
|
||||
type ProjectAIMapping,
|
||||
} from './anonymization'
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
@@ -43,14 +42,6 @@ export interface TaggingResult {
|
||||
tokensUsed: number
|
||||
}
|
||||
|
||||
export interface BatchTaggingResult {
|
||||
processed: number
|
||||
failed: number
|
||||
skipped: number
|
||||
errors: string[]
|
||||
results: TaggingResult[]
|
||||
}
|
||||
|
||||
interface AvailableTag {
|
||||
id: string
|
||||
name: string
|
||||
@@ -60,8 +51,6 @@ interface AvailableTag {
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_BATCH_SIZE = 10
|
||||
const MAX_BATCH_SIZE = 25
|
||||
const CONFIDENCE_THRESHOLD = 0.5
|
||||
const DEFAULT_MAX_TAGS = 5
|
||||
|
||||
@@ -138,48 +127,6 @@ export async function getAvailableTags(): Promise<AvailableTag[]> {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert project to format for anonymization
|
||||
*/
|
||||
function toProjectWithRelations(project: {
|
||||
id: string
|
||||
title: string
|
||||
description?: string | null
|
||||
competitionCategory?: string | null
|
||||
oceanIssue?: string | null
|
||||
country?: string | null
|
||||
geographicZone?: string | null
|
||||
institution?: string | null
|
||||
tags: string[]
|
||||
foundedAt?: Date | null
|
||||
wantsMentorship?: boolean
|
||||
submissionSource?: string
|
||||
submittedAt?: Date | null
|
||||
_count?: { teamMembers?: number; files?: number }
|
||||
files?: Array<{ fileType: string | null }>
|
||||
}): ProjectWithRelations {
|
||||
return {
|
||||
id: project.id,
|
||||
title: project.title,
|
||||
description: project.description,
|
||||
competitionCategory: project.competitionCategory as any,
|
||||
oceanIssue: project.oceanIssue as any,
|
||||
country: project.country,
|
||||
geographicZone: project.geographicZone,
|
||||
institution: project.institution,
|
||||
tags: project.tags,
|
||||
foundedAt: project.foundedAt,
|
||||
wantsMentorship: project.wantsMentorship ?? false,
|
||||
submissionSource: (project.submissionSource as any) ?? 'MANUAL',
|
||||
submittedAt: project.submittedAt,
|
||||
_count: {
|
||||
teamMembers: project._count?.teamMembers ?? 0,
|
||||
files: project._count?.files ?? 0,
|
||||
},
|
||||
files: project.files?.map((f) => ({ fileType: (f.fileType as any) ?? null })) ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
// ─── AI Tagging Core ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -406,254 +353,6 @@ export async function tagProject(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Common validation and setup for batch tagging
|
||||
*/
|
||||
async function validateBatchTagging(): Promise<{
|
||||
valid: boolean
|
||||
error?: string
|
||||
availableTags?: AvailableTag[]
|
||||
}> {
|
||||
const settings = await getTaggingSettings()
|
||||
console.log('[AI Tagging] Settings:', settings)
|
||||
|
||||
if (!settings.enabled) {
|
||||
console.log('[AI Tagging] AI tagging is disabled in settings')
|
||||
return {
|
||||
valid: false,
|
||||
error: 'AI tagging is disabled. Enable it in Settings > AI or set ai_enabled to true.',
|
||||
}
|
||||
}
|
||||
|
||||
// Check if OpenAI is configured
|
||||
const openai = await getOpenAI()
|
||||
if (!openai) {
|
||||
console.log('[AI Tagging] OpenAI is not configured')
|
||||
return {
|
||||
valid: false,
|
||||
error: 'OpenAI API is not configured. Add your API key in Settings > AI.',
|
||||
}
|
||||
}
|
||||
|
||||
// Check if there are any available tags
|
||||
const availableTags = await getAvailableTags()
|
||||
console.log(`[AI Tagging] Found ${availableTags.length} available expertise tags`)
|
||||
if (availableTags.length === 0) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'No expertise tags defined. Create tags in Settings > Tags first.',
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true, availableTags }
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch tag all untagged projects in a round
|
||||
*
|
||||
* Only processes projects with zero tags.
|
||||
*/
|
||||
export async function batchTagProjects(
|
||||
roundId: string,
|
||||
userId?: string,
|
||||
onProgress?: (processed: number, total: number) => void
|
||||
): Promise<BatchTaggingResult> {
|
||||
const validation = await validateBatchTagging()
|
||||
if (!validation.valid) {
|
||||
return {
|
||||
processed: 0,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
errors: [validation.error!],
|
||||
results: [],
|
||||
}
|
||||
}
|
||||
|
||||
// Get ALL projects in round to check their tag status
|
||||
const allProjects = await prisma.project.findMany({
|
||||
where: { roundId },
|
||||
include: {
|
||||
files: { select: { fileType: true } },
|
||||
_count: { select: { teamMembers: true, files: true } },
|
||||
},
|
||||
})
|
||||
|
||||
console.log(`[AI Tagging] Found ${allProjects.length} total projects in round`)
|
||||
|
||||
// Filter to only projects that truly have no tags (empty tags array)
|
||||
const untaggedProjects = allProjects.filter(p => p.tags.length === 0)
|
||||
|
||||
const alreadyTaggedCount = allProjects.length - untaggedProjects.length
|
||||
console.log(`[AI Tagging] ${untaggedProjects.length} untagged projects, ${alreadyTaggedCount} already have tags`)
|
||||
|
||||
if (untaggedProjects.length === 0) {
|
||||
return {
|
||||
processed: 0,
|
||||
failed: 0,
|
||||
skipped: alreadyTaggedCount,
|
||||
errors: alreadyTaggedCount > 0
|
||||
? []
|
||||
: ['No projects found in this round'],
|
||||
results: [],
|
||||
}
|
||||
}
|
||||
|
||||
const results: TaggingResult[] = []
|
||||
let processed = 0
|
||||
let failed = 0
|
||||
const errors: string[] = []
|
||||
|
||||
console.log(`[AI Tagging] Starting batch processing of ${untaggedProjects.length} projects in round...`)
|
||||
const startTime = Date.now()
|
||||
|
||||
for (let i = 0; i < untaggedProjects.length; i++) {
|
||||
const project = untaggedProjects[i]
|
||||
const projectStartTime = Date.now()
|
||||
console.log(`[AI Tagging] Processing project ${i + 1}/${untaggedProjects.length}: "${project.title.substring(0, 50)}..."`)
|
||||
|
||||
try {
|
||||
const result = await tagProject(project.id, userId)
|
||||
results.push(result)
|
||||
processed++
|
||||
const elapsed = ((Date.now() - projectStartTime) / 1000).toFixed(1)
|
||||
console.log(`[AI Tagging] ✓ Tagged "${project.title.substring(0, 30)}..." with ${result.applied.length} tags (${elapsed}s)`)
|
||||
} catch (error) {
|
||||
failed++
|
||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error'
|
||||
errors.push(`${project.title}: ${errorMsg}`)
|
||||
console.error(`[AI Tagging] ✗ Failed "${project.title.substring(0, 30)}...": ${errorMsg}`)
|
||||
}
|
||||
|
||||
// Report progress
|
||||
if (onProgress) {
|
||||
onProgress(i + 1, untaggedProjects.length)
|
||||
}
|
||||
|
||||
// Log progress every 10 projects
|
||||
if ((i + 1) % 10 === 0) {
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(0)
|
||||
const avgTime = (Date.now() - startTime) / (i + 1) / 1000
|
||||
const remaining = avgTime * (untaggedProjects.length - i - 1)
|
||||
console.log(`[AI Tagging] Progress: ${i + 1}/${untaggedProjects.length} (${elapsed}s elapsed, ~${remaining.toFixed(0)}s remaining)`)
|
||||
}
|
||||
}
|
||||
|
||||
const totalTime = ((Date.now() - startTime) / 1000).toFixed(1)
|
||||
console.log(`[AI Tagging] Batch complete: ${processed} tagged, ${failed} failed, ${alreadyTaggedCount} skipped in ${totalTime}s`)
|
||||
|
||||
return {
|
||||
processed,
|
||||
failed,
|
||||
skipped: alreadyTaggedCount,
|
||||
errors,
|
||||
results,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch tag all untagged projects in an entire program (edition)
|
||||
*
|
||||
* Processes all projects across all rounds in the program.
|
||||
*/
|
||||
export async function batchTagProgramProjects(
|
||||
programId: string,
|
||||
userId?: string,
|
||||
onProgress?: (processed: number, total: number) => void
|
||||
): Promise<BatchTaggingResult> {
|
||||
const validation = await validateBatchTagging()
|
||||
if (!validation.valid) {
|
||||
return {
|
||||
processed: 0,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
errors: [validation.error!],
|
||||
results: [],
|
||||
}
|
||||
}
|
||||
|
||||
// Get ALL projects in the program (across all rounds)
|
||||
const allProjects = await prisma.project.findMany({
|
||||
where: {
|
||||
round: { programId },
|
||||
},
|
||||
include: {
|
||||
files: { select: { fileType: true } },
|
||||
_count: { select: { teamMembers: true, files: true } },
|
||||
},
|
||||
})
|
||||
|
||||
console.log(`[AI Tagging] Found ${allProjects.length} total projects in program`)
|
||||
|
||||
// Filter to only projects that truly have no tags (empty tags array)
|
||||
const untaggedProjects = allProjects.filter(p => p.tags.length === 0)
|
||||
|
||||
const alreadyTaggedCount = allProjects.length - untaggedProjects.length
|
||||
console.log(`[AI Tagging] ${untaggedProjects.length} untagged projects, ${alreadyTaggedCount} already have tags`)
|
||||
|
||||
if (untaggedProjects.length === 0) {
|
||||
return {
|
||||
processed: 0,
|
||||
failed: 0,
|
||||
skipped: alreadyTaggedCount,
|
||||
errors: alreadyTaggedCount > 0
|
||||
? []
|
||||
: ['No projects found in this program'],
|
||||
results: [],
|
||||
}
|
||||
}
|
||||
|
||||
const results: TaggingResult[] = []
|
||||
let processed = 0
|
||||
let failed = 0
|
||||
const errors: string[] = []
|
||||
|
||||
console.log(`[AI Tagging] Starting batch processing of ${untaggedProjects.length} projects...`)
|
||||
const startTime = Date.now()
|
||||
|
||||
for (let i = 0; i < untaggedProjects.length; i++) {
|
||||
const project = untaggedProjects[i]
|
||||
const projectStartTime = Date.now()
|
||||
console.log(`[AI Tagging] Processing project ${i + 1}/${untaggedProjects.length}: "${project.title.substring(0, 50)}..."`)
|
||||
|
||||
try {
|
||||
const result = await tagProject(project.id, userId)
|
||||
results.push(result)
|
||||
processed++
|
||||
const elapsed = ((Date.now() - projectStartTime) / 1000).toFixed(1)
|
||||
console.log(`[AI Tagging] ✓ Tagged "${project.title.substring(0, 30)}..." with ${result.applied.length} tags (${elapsed}s)`)
|
||||
} catch (error) {
|
||||
failed++
|
||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error'
|
||||
errors.push(`${project.title}: ${errorMsg}`)
|
||||
console.error(`[AI Tagging] ✗ Failed "${project.title.substring(0, 30)}...": ${errorMsg}`)
|
||||
}
|
||||
|
||||
// Report progress
|
||||
if (onProgress) {
|
||||
onProgress(i + 1, untaggedProjects.length)
|
||||
}
|
||||
|
||||
// Log progress every 10 projects
|
||||
if ((i + 1) % 10 === 0) {
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(0)
|
||||
const avgTime = (Date.now() - startTime) / (i + 1) / 1000
|
||||
const remaining = avgTime * (untaggedProjects.length - i - 1)
|
||||
console.log(`[AI Tagging] Progress: ${i + 1}/${untaggedProjects.length} (${elapsed}s elapsed, ~${remaining.toFixed(0)}s remaining)`)
|
||||
}
|
||||
}
|
||||
|
||||
const totalTime = ((Date.now() - startTime) / 1000).toFixed(1)
|
||||
console.log(`[AI Tagging] Batch complete: ${processed} tagged, ${failed} failed, ${alreadyTaggedCount} skipped in ${totalTime}s`)
|
||||
|
||||
return {
|
||||
processed,
|
||||
failed,
|
||||
skipped: alreadyTaggedCount,
|
||||
errors,
|
||||
results,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tag suggestions for a project without applying them
|
||||
* Useful for preview/review before applying
|
||||
|
||||
@@ -132,6 +132,51 @@ export interface ProjectAIMapping {
|
||||
realId: string
|
||||
}
|
||||
|
||||
// ─── Project Conversion Helper ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Convert a loosely-typed Prisma project result to ProjectWithRelations.
|
||||
* Used by ai-tagging, ai-filtering, and ai-award-eligibility services.
|
||||
*/
|
||||
export function toProjectWithRelations(project: {
|
||||
id: string
|
||||
title: string
|
||||
description?: string | null
|
||||
competitionCategory?: string | null
|
||||
oceanIssue?: string | null
|
||||
country?: string | null
|
||||
geographicZone?: string | null
|
||||
institution?: string | null
|
||||
tags: string[]
|
||||
foundedAt?: Date | null
|
||||
wantsMentorship?: boolean | null
|
||||
submissionSource?: string
|
||||
submittedAt?: Date | null
|
||||
_count?: { teamMembers?: number; files?: number }
|
||||
files?: Array<{ fileType?: string | null; [key: string]: unknown }>
|
||||
}): ProjectWithRelations {
|
||||
return {
|
||||
id: project.id,
|
||||
title: project.title,
|
||||
description: project.description,
|
||||
competitionCategory: project.competitionCategory as ProjectWithRelations['competitionCategory'],
|
||||
oceanIssue: project.oceanIssue as ProjectWithRelations['oceanIssue'],
|
||||
country: project.country,
|
||||
geographicZone: project.geographicZone,
|
||||
institution: project.institution,
|
||||
tags: project.tags,
|
||||
foundedAt: project.foundedAt,
|
||||
wantsMentorship: project.wantsMentorship ?? false,
|
||||
submissionSource: (project.submissionSource as ProjectWithRelations['submissionSource']) ?? 'MANUAL',
|
||||
submittedAt: project.submittedAt,
|
||||
_count: {
|
||||
teamMembers: project._count?.teamMembers ?? 0,
|
||||
files: project._count?.files ?? project.files?.length ?? 0,
|
||||
},
|
||||
files: project.files?.map((f) => ({ fileType: (f.fileType as FileType) ?? null })) ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Basic Anonymization (Assignment Service) ────────────────────────────────
|
||||
|
||||
interface JurorInput {
|
||||
|
||||
Reference in New Issue
Block a user