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:
2026-02-05 20:31:08 +01:00
parent a1f32597a0
commit 8d0979e649
35 changed files with 2463 additions and 730 deletions

View File

@@ -150,9 +150,13 @@ export const analyticsRouter = router({
.query(async ({ ctx, input }) => {
const projects = await ctx.prisma.project.findMany({
where: { roundId: input.roundId },
include: {
select: {
id: true,
title: true,
teamName: true,
status: true,
assignments: {
include: {
select: {
evaluation: {
select: { criterionScoresJson: true, status: true },
},

View File

@@ -7,6 +7,7 @@ import {
notifyAdmins,
NotificationTypes,
} from '../services/in-app-notification'
import { checkRateLimit } from '@/lib/rate-limit'
// Zod schemas for the application form
const teamMemberSchema = z.object({
@@ -153,6 +154,16 @@ export const applicationRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
// Stricter rate limit for application submissions: 5 per hour per IP
const ip = ctx.ip || 'unknown'
const submitRateLimit = checkRateLimit(`app-submit:${ip}`, 5, 60 * 60 * 1000)
if (!submitRateLimit.success) {
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: 'Too many application submissions. Please try again later.',
})
}
const { roundId, data } = input
// Verify round exists and is open
@@ -351,6 +362,16 @@ export const applicationRouter = router({
})
)
.query(async ({ ctx, input }) => {
// Rate limit to prevent email enumeration
const ip = ctx.ip || 'unknown'
const emailCheckLimit = checkRateLimit(`email-check:${ip}`, 20, 15 * 60 * 1000)
if (!emailCheckLimit.success) {
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: 'Too many requests. Please try again later.',
})
}
const existing = await ctx.prisma.project.findFirst({
where: {
roundId: input.roundId,

View File

@@ -472,10 +472,19 @@ export const assignmentRouter = router({
})
: undefined
// Send batch notification to each user
// Group users by project count so we can send bulk notifications per group
const usersByProjectCount = new Map<number, string[]>()
for (const [userId, projectCount] of Object.entries(userAssignmentCounts)) {
await createNotification({
userId,
const existing = usersByProjectCount.get(projectCount) || []
existing.push(userId)
usersByProjectCount.set(projectCount, existing)
}
// Send bulk notifications for each project count group
for (const [projectCount, userIds] of usersByProjectCount) {
if (userIds.length === 0) continue
await createBulkNotifications({
userIds,
type: NotificationTypes.BATCH_ASSIGNED,
title: `${projectCount} Projects Assigned`,
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${round?.name || 'this round'}.`,
@@ -884,9 +893,19 @@ export const assignmentRouter = router({
})
: undefined
// Group users by project count so we can send bulk notifications per group
const usersByProjectCount = new Map<number, string[]>()
for (const [userId, projectCount] of Object.entries(userAssignmentCounts)) {
await createNotification({
userId,
const existing = usersByProjectCount.get(projectCount) || []
existing.push(userId)
usersByProjectCount.set(projectCount, existing)
}
// Send bulk notifications for each project count group
for (const [projectCount, userIds] of usersByProjectCount) {
if (userIds.length === 0) continue
await createBulkNotifications({
userIds,
type: NotificationTypes.BATCH_ASSIGNED,
title: `${projectCount} Projects Assigned`,
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${round?.name || 'this round'}.`,
@@ -972,9 +991,19 @@ export const assignmentRouter = router({
})
: undefined
// Group users by project count so we can send bulk notifications per group
const usersByProjectCount = new Map<number, string[]>()
for (const [userId, projectCount] of Object.entries(userAssignmentCounts)) {
await createNotification({
userId,
const existing = usersByProjectCount.get(projectCount) || []
existing.push(userId)
usersByProjectCount.set(projectCount, existing)
}
// Send bulk notifications for each project count group
for (const [projectCount, userIds] of usersByProjectCount) {
if (userIds.length === 0) continue
await createBulkNotifications({
userIds,
type: NotificationTypes.BATCH_ASSIGNED,
title: `${projectCount} Projects Assigned`,
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${round?.name || 'this round'}.`,
@@ -1038,7 +1067,7 @@ export const assignmentRouter = router({
/**
* Get AI assignment job status (for polling)
*/
getAIAssignmentJobStatus: protectedProcedure
getAIAssignmentJobStatus: adminProcedure
.input(z.object({ jobId: z.string() }))
.query(async ({ ctx, input }) => {
const job = await ctx.prisma.assignmentJob.findUniqueOrThrow({

View File

@@ -196,21 +196,21 @@ export const evaluationRouter = router({
})
}
// Submit
const updated = await ctx.prisma.evaluation.update({
where: { id },
data: {
...data,
status: 'SUBMITTED',
submittedAt: now,
},
})
// Mark assignment as completed
await ctx.prisma.assignment.update({
where: { id: evaluation.assignmentId },
data: { isCompleted: true },
})
// Submit evaluation and mark assignment as completed atomically
const [updated] = await ctx.prisma.$transaction([
ctx.prisma.evaluation.update({
where: { id },
data: {
...data,
status: 'SUBMITTED',
submittedAt: now,
},
}),
ctx.prisma.assignment.update({
where: { id: evaluation.assignmentId },
data: { isCompleted: true },
}),
])
// Audit log
await ctx.prisma.auditLog.create({

View File

@@ -1,7 +1,7 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, protectedProcedure, adminProcedure } from '../trpc'
import { getPresignedUrl, generateObjectKey, BUCKET_NAME } from '@/lib/minio'
import { getPresignedUrl, generateObjectKey, deleteObject, BUCKET_NAME } from '@/lib/minio'
export const fileRouter = router({
/**
@@ -83,6 +83,16 @@ export const fileRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
// Block dangerous file extensions
const dangerousExtensions = ['.exe', '.sh', '.bat', '.cmd', '.ps1', '.php', '.jsp', '.cgi', '.dll', '.msi']
const ext = input.fileName.toLowerCase().slice(input.fileName.lastIndexOf('.'))
if (dangerousExtensions.includes(ext)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `File type "${ext}" is not allowed`,
})
}
const bucket = BUCKET_NAME
const objectKey = generateObjectKey(input.projectId, input.fileName)
@@ -147,8 +157,14 @@ export const fileRouter = router({
where: { id: input.id },
})
// Note: Actual MinIO deletion could be done here or via background job
// For now, we just delete the database record
// Delete actual storage object (best-effort, don't fail the operation)
try {
if (file.bucket && file.objectKey) {
await deleteObject(file.bucket, file.objectKey)
}
} catch (error) {
console.error(`[File] Failed to delete storage object ${file.objectKey}:`, error)
}
// Audit log
await ctx.prisma.auditLog.create({

View File

@@ -499,36 +499,40 @@ export const filteringRouter = router({
// Execute rules
const results = await executeFilteringRules(rules, projects)
// Upsert results
await ctx.prisma.$transaction(
results.map((r) =>
ctx.prisma.filteringResult.upsert({
where: {
roundId_projectId: {
// Upsert results in batches to avoid long-held locks
const BATCH_SIZE = 25
for (let i = 0; i < results.length; i += BATCH_SIZE) {
const batch = results.slice(i, i + BATCH_SIZE)
await ctx.prisma.$transaction(
batch.map((r) =>
ctx.prisma.filteringResult.upsert({
where: {
roundId_projectId: {
roundId: input.roundId,
projectId: r.projectId,
},
},
create: {
roundId: input.roundId,
projectId: r.projectId,
outcome: r.outcome,
ruleResultsJson: r.ruleResults as unknown as Prisma.InputJsonValue,
aiScreeningJson: (r.aiScreeningJson ?? Prisma.JsonNull) as Prisma.InputJsonValue,
},
},
create: {
roundId: input.roundId,
projectId: r.projectId,
outcome: r.outcome,
ruleResultsJson: r.ruleResults as unknown as Prisma.InputJsonValue,
aiScreeningJson: (r.aiScreeningJson ?? Prisma.JsonNull) as Prisma.InputJsonValue,
},
update: {
outcome: r.outcome,
ruleResultsJson: r.ruleResults as unknown as Prisma.InputJsonValue,
aiScreeningJson: (r.aiScreeningJson ?? Prisma.JsonNull) as Prisma.InputJsonValue,
// Clear any previous override
overriddenBy: null,
overriddenAt: null,
overrideReason: null,
finalOutcome: null,
},
})
update: {
outcome: r.outcome,
ruleResultsJson: r.ruleResults as unknown as Prisma.InputJsonValue,
aiScreeningJson: (r.aiScreeningJson ?? Prisma.JsonNull) as Prisma.InputJsonValue,
// Clear any previous override
overriddenBy: null,
overriddenAt: null,
overrideReason: null,
finalOutcome: null,
},
})
)
)
)
}
await logAudit({
userId: ctx.user.id,

View File

@@ -46,36 +46,37 @@ export const mentorRouter = router({
input.limit
)
// Enrich with mentor details
const enrichedSuggestions = await Promise.all(
suggestions.map(async (suggestion) => {
const mentor = await ctx.prisma.user.findUnique({
where: { id: suggestion.mentorId },
select: {
id: true,
name: true,
email: true,
expertiseTags: true,
mentorAssignments: {
select: { id: true },
},
},
})
// Enrich with mentor details (batch query to avoid N+1)
const mentorIds = suggestions.map((s) => s.mentorId)
const mentors = await ctx.prisma.user.findMany({
where: { id: { in: mentorIds } },
select: {
id: true,
name: true,
email: true,
expertiseTags: true,
mentorAssignments: {
select: { id: true },
},
},
})
const mentorMap = new Map(mentors.map((m) => [m.id, m]))
return {
...suggestion,
mentor: mentor
? {
id: mentor.id,
name: mentor.name,
email: mentor.email,
expertiseTags: mentor.expertiseTags,
assignmentCount: mentor.mentorAssignments.length,
}
: null,
}
})
)
const enrichedSuggestions = suggestions.map((suggestion) => {
const mentor = mentorMap.get(suggestion.mentorId)
return {
...suggestion,
mentor: mentor
? {
id: mentor.id,
name: mentor.name,
email: mentor.email,
expertiseTags: mentor.expertiseTags,
assignmentCount: mentor.mentorAssignments.length,
}
: null,
}
})
return {
currentMentor: null,

View File

@@ -55,7 +55,7 @@ export const projectRouter = router({
hasFiles: z.boolean().optional(),
hasAssignments: z.boolean().optional(),
page: z.number().int().min(1).default(1),
perPage: z.number().int().min(1).max(5000).default(20),
perPage: z.number().int().min(1).max(200).default(20),
})
)
.query(async ({ ctx, input }) => {
@@ -131,7 +131,6 @@ export const projectRouter = router({
take: perPage,
orderBy: { createdAt: 'desc' },
include: {
files: true,
round: {
select: {
id: true,
@@ -139,7 +138,7 @@ export const projectRouter = router({
program: { select: { id: true, name: true, year: true } },
},
},
_count: { select: { assignments: true } },
_count: { select: { assignments: true, files: true } },
},
}),
ctx.prisma.project.count({ where }),

View File

@@ -3,6 +3,7 @@ import { router, adminProcedure, superAdminProcedure, protectedProcedure } from
import { getWhatsAppProvider, getWhatsAppProviderType } from '@/lib/whatsapp'
import { listAvailableModels, testOpenAIConnection, isReasoningModel } from '@/lib/openai'
import { getAIUsageStats, getCurrentMonthCost, formatCost } from '@/server/utils/ai-usage'
import { clearStorageProviderCache } from '@/lib/storage'
/**
* Categorize an OpenAI model for display
@@ -117,6 +118,11 @@ export const settingsRouter = router({
},
})
// Clear storage provider cache when storage_provider setting changes
if (input.key === 'storage_provider') {
clearStorageProviderCache()
}
// Audit log (don't log actual value for secrets)
await ctx.prisma.auditLog.create({
data: {
@@ -181,6 +187,11 @@ export const settingsRouter = router({
)
)
// Clear storage provider cache if storage_provider was updated
if (input.settings.some((s) => s.key === 'storage_provider')) {
clearStorageProviderCache()
}
// Audit log
await ctx.prisma.auditLog.create({
data: {

View File

@@ -760,59 +760,6 @@ export const tagRouter = router({
}
}),
// Legacy endpoints kept for backward compatibility (redirect to job-based)
/**
* @deprecated Use startTaggingJob instead
*/
batchTagProjects: adminProcedure
.input(z.object({ roundId: z.string() }))
.mutation(async ({ ctx, input }) => {
// Start job and return immediately with placeholder
const job = await ctx.prisma.taggingJob.create({
data: {
roundId: input.roundId,
status: 'PENDING',
},
})
runTaggingJob(job.id, ctx.user.id).catch(console.error)
return {
processed: 0,
failed: 0,
skipped: 0,
errors: [],
jobId: job.id,
message: 'Tagging job started in background. Check job status for progress.',
}
}),
/**
* @deprecated Use startTaggingJob instead
*/
batchTagProgramProjects: adminProcedure
.input(z.object({ programId: z.string() }))
.mutation(async ({ ctx, input }) => {
// Start job and return immediately with placeholder
const job = await ctx.prisma.taggingJob.create({
data: {
programId: input.programId,
status: 'PENDING',
},
})
runTaggingJob(job.id, ctx.user.id).catch(console.error)
return {
processed: 0,
failed: 0,
skipped: 0,
errors: [],
jobId: job.id,
message: 'Tagging job started in background. Check job status for progress.',
}
}),
/**
* Manually add a tag to a project
*/

View File

@@ -243,22 +243,15 @@ export const userRouter = router({
get: adminProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
console.log('[user.get] Fetching user:', input.id)
try {
const user = await ctx.prisma.user.findUniqueOrThrow({
where: { id: input.id },
include: {
_count: {
select: { assignments: true, mentorAssignments: true },
},
const user = await ctx.prisma.user.findUniqueOrThrow({
where: { id: input.id },
include: {
_count: {
select: { assignments: true, mentorAssignments: true },
},
})
console.log('[user.get] Found user:', user.email)
return user
} catch (error) {
console.error('[user.get] Error fetching user:', input.id, error)
throw error
}
},
})
return user
}),
/**

View File

@@ -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
*/

View File

@@ -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
*/

View File

@@ -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

View File

@@ -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 {