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:
@@ -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.' }
|
||||
|
||||
Reference in New Issue
Block a user