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:
@@ -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 },
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
|
||||
/**
|
||||
|
||||
@@ -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