Platform review round 2: audit logging migration, nav unification, DB indexes, and UI polish

- Migrate ~41 inline audit log calls to shared logAudit() utility across all routers
- Add transaction-aware prisma parameter to logAudit() for atomic operations
- Unify jury/mentor/observer navigation into shared RoleNav component
- Add composite DB indexes (Evaluation, GracePeriod, AuditLog) for query performance
- Fix profile page: consolidate dual save buttons, proper useEffect initialization
- Enhance auth error page with MOPC branding and navigation
- Improve observer dashboard with prominent read-only badge
- Fix DI-3: fetch projects before bulk status update for accurate notifications
- Remove unused aiBoost field from smart-assignment scoring
- Add shared image-upload utility and structured logger module

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-05 21:09:06 +01:00
parent 8d0979e649
commit 002a9dbfc3
34 changed files with 1688 additions and 1782 deletions

View File

@@ -6,6 +6,7 @@ import { router, protectedProcedure, adminProcedure, superAdminProcedure, public
import { sendInvitationEmail, sendMagicLinkEmail } from '@/lib/email'
import { hashPassword, validatePassword } from '@/lib/password'
import { attachAvatarUrls } from '@/server/utils/avatar-url'
import { logAudit } from '@/server/utils/audit'
const INVITE_TOKEN_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
@@ -146,9 +147,10 @@ export const userRouter = router({
})
}
// Audit log before deletion
await ctx.prisma.auditLog.create({
data: {
// Wrap audit + deletion in a transaction
await ctx.prisma.$transaction(async (tx) => {
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'DELETE_OWN_ACCOUNT',
entityType: 'User',
@@ -156,12 +158,11 @@ export const userRouter = router({
detailsJson: { email: user.email },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
})
// Delete the user
await ctx.prisma.user.delete({
where: { id: ctx.user.id },
await tx.user.delete({
where: { id: ctx.user.id },
})
})
return { success: true }
@@ -288,24 +289,26 @@ export const userRouter = router({
})
}
const user = await ctx.prisma.user.create({
data: {
...input,
status: 'INVITED',
},
})
const user = await ctx.prisma.$transaction(async (tx) => {
const created = await tx.user.create({
data: {
...input,
status: 'INVITED',
},
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'CREATE',
entityType: 'User',
entityId: user.id,
entityId: created.id,
detailsJson: { email: input.email, role: input.role },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return created
})
return user
@@ -348,14 +351,14 @@ export const userRouter = router({
})
}
const user = await ctx.prisma.user.update({
where: { id },
data,
})
const user = await ctx.prisma.$transaction(async (tx) => {
const updated = await tx.user.update({
where: { id },
data,
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'User',
@@ -363,13 +366,12 @@ export const userRouter = router({
detailsJson: data,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
})
// Track role change specifically
if (data.role && data.role !== targetUser.role) {
await ctx.prisma.auditLog.create({
data: {
// Track role change specifically
if (data.role && data.role !== targetUser.role) {
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'ROLE_CHANGED',
entityType: 'User',
@@ -377,9 +379,11 @@ export const userRouter = router({
detailsJson: { previousRole: targetUser.role, newRole: data.role },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
}).catch(() => {})
}
})
}
return updated
})
return user
}),
@@ -398,21 +402,27 @@ export const userRouter = router({
})
}
const user = await ctx.prisma.user.delete({
where: { id: input.id },
})
const user = await ctx.prisma.$transaction(async (tx) => {
// Fetch user data before deletion for the audit log
const target = await tx.user.findUniqueOrThrow({
where: { id: input.id },
select: { email: true },
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'DELETE',
entityType: 'User',
entityId: input.id,
detailsJson: { email: user.email },
detailsJson: { email: target.email },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return tx.user.delete({
where: { id: input.id },
})
})
return user
@@ -490,15 +500,14 @@ export const userRouter = router({
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'BULK_CREATE',
entityType: 'User',
detailsJson: { count: created.count, skipped, duplicatesInInput },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'BULK_CREATE',
entityType: 'User',
detailsJson: { count: created.count, skipped, duplicatesInInput },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
// Auto-send invitation emails to newly created users
@@ -534,15 +543,14 @@ export const userRouter = router({
// Audit log for assignments if any were created
if (assignmentsCreated > 0) {
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'BULK_ASSIGN',
entityType: 'Assignment',
detailsJson: { count: assignmentsCreated, context: 'invitation_pre_assignment' },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'BULK_ASSIGN',
entityType: 'Assignment',
detailsJson: { count: assignmentsCreated, context: 'invitation_pre_assignment' },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
}
@@ -692,16 +700,15 @@ export const userRouter = router({
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'SEND_INVITATION',
entityType: 'User',
entityId: user.id,
detailsJson: { email: user.email },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'SEND_INVITATION',
entityType: 'User',
entityId: user.id,
detailsJson: { email: user.email },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { success: true, email: user.email }
@@ -770,15 +777,14 @@ export const userRouter = router({
}
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'BULK_SEND_INVITATIONS',
entityType: 'User',
detailsJson: { sent, errors },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'BULK_SEND_INVITATIONS',
entityType: 'User',
detailsJson: { sent, errors },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { sent, skipped: input.userIds.length - users.length, errors }
@@ -810,23 +816,23 @@ export const userRouter = router({
const userTags = input.expertiseTags || []
const mergedTags = [...new Set([...adminTags, ...userTags])]
const user = await ctx.prisma.user.update({
where: { id: ctx.user.id },
data: {
name: input.name,
phoneNumber: input.phoneNumber,
country: input.country,
bio: input.bio,
expertiseTags: mergedTags,
notificationPreference: input.notificationPreference || 'EMAIL',
onboardingCompletedAt: new Date(),
status: 'ACTIVE', // Activate user after onboarding
},
})
const user = await ctx.prisma.$transaction(async (tx) => {
const updated = await tx.user.update({
where: { id: ctx.user.id },
data: {
name: input.name,
phoneNumber: input.phoneNumber,
country: input.country,
bio: input.bio,
expertiseTags: mergedTags,
notificationPreference: input.notificationPreference || 'EMAIL',
onboardingCompletedAt: new Date(),
status: 'ACTIVE', // Activate user after onboarding
},
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'COMPLETE_ONBOARDING',
entityType: 'User',
@@ -834,7 +840,9 @@ export const userRouter = router({
detailsJson: { name: input.name },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return updated
})
return user
@@ -901,19 +909,19 @@ export const userRouter = router({
// Hash the password
const passwordHash = await hashPassword(input.password)
// Update user with new password
const user = await ctx.prisma.user.update({
where: { id: ctx.user.id },
data: {
passwordHash,
passwordSetAt: new Date(),
mustSetPassword: false,
},
})
// Update user with new password + audit in transaction
const user = await ctx.prisma.$transaction(async (tx) => {
const updated = await tx.user.update({
where: { id: ctx.user.id },
data: {
passwordHash,
passwordSetAt: new Date(),
mustSetPassword: false,
},
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'PASSWORD_SET',
entityType: 'User',
@@ -921,7 +929,9 @@ export const userRouter = router({
detailsJson: { timestamp: new Date().toISOString() },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return updated
})
return { success: true, email: user.email }
@@ -982,18 +992,18 @@ export const userRouter = router({
// Hash the new password
const passwordHash = await hashPassword(input.newPassword)
// Update user with new password
await ctx.prisma.user.update({
where: { id: ctx.user.id },
data: {
passwordHash,
passwordSetAt: new Date(),
},
})
// Update user with new password + audit in transaction
await ctx.prisma.$transaction(async (tx) => {
await tx.user.update({
where: { id: ctx.user.id },
data: {
passwordHash,
passwordSetAt: new Date(),
},
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'PASSWORD_CHANGED',
entityType: 'User',
@@ -1001,7 +1011,7 @@ export const userRouter = router({
detailsJson: { timestamp: new Date().toISOString() },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
})
return { success: true }
@@ -1040,16 +1050,15 @@ export const userRouter = router({
// The actual email is sent through NextAuth's email provider
// Audit log (without user ID since this is public)
await ctx.prisma.auditLog.create({
data: {
userId: null, // No authenticated user
action: 'REQUEST_PASSWORD_RESET',
entityType: 'User',
entityId: user.id,
detailsJson: { email: input.email, timestamp: new Date().toISOString() },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
await logAudit({
prisma: ctx.prisma,
userId: null, // No authenticated user
action: 'REQUEST_PASSWORD_RESET',
entityType: 'User',
entityId: user.id,
detailsJson: { email: input.email, timestamp: new Date().toISOString() },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { success: true, message: 'If an account exists with this email, a password reset link will be sent.' }