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
|
||||
}),
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user