Round detail overhaul, file requirements, project management, audit log fix
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m32s
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m32s
- Redesign round detail page with 6 tabs (overview, projects, filtering, assignments, config, documents) - Add jury group assignment selector in round stats bar - Add FileRequirementsEditor component replacing SubmissionWindowManager - Add FilteringDashboard component for AI-powered project screening - Add project removal from rounds (single + bulk) with cascading to subsequent rounds - Add project add/remove UI in ProjectStatesTable with confirmation dialogs - Fix logAudit inside $transaction pattern across all 12 router files (PostgreSQL aborted-transaction state caused silent operation failures) - Fix special awards creation, deletion, status update, and winner assignment Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -194,22 +194,21 @@ export const userRouter = router({
|
||||
})
|
||||
}
|
||||
|
||||
// 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',
|
||||
entityId: ctx.user.id,
|
||||
detailsJson: { email: user.email },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
// Delete user
|
||||
await ctx.prisma.user.delete({
|
||||
where: { id: ctx.user.id },
|
||||
})
|
||||
|
||||
await tx.user.delete({
|
||||
where: { id: ctx.user.id },
|
||||
})
|
||||
// Audit outside transaction so failures don't roll back the deletion
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'DELETE_OWN_ACCOUNT',
|
||||
entityType: 'User',
|
||||
entityId: ctx.user.id,
|
||||
detailsJson: { email: user.email },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
@@ -384,26 +383,23 @@ export const userRouter = router({
|
||||
})
|
||||
}
|
||||
|
||||
const user = await ctx.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.user.create({
|
||||
data: {
|
||||
...input,
|
||||
status: 'INVITED',
|
||||
},
|
||||
})
|
||||
const user = await ctx.prisma.user.create({
|
||||
data: {
|
||||
...input,
|
||||
status: 'INVITED',
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'User',
|
||||
entityId: created.id,
|
||||
detailsJson: { email: input.email, role: input.role },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return created
|
||||
// Audit outside transaction so failures don't roll back the user creation
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'User',
|
||||
entityId: user.id,
|
||||
detailsJson: { email: input.email, role: input.role },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return user
|
||||
@@ -486,39 +482,36 @@ export const userRouter = router({
|
||||
...(normalizedEmail !== undefined && { email: normalizedEmail }),
|
||||
}
|
||||
|
||||
const user = await ctx.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.user.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
})
|
||||
const user = await ctx.prisma.user.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
})
|
||||
|
||||
// Audit outside transaction so failures don't roll back the update
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'User',
|
||||
entityId: id,
|
||||
detailsJson: updateData,
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
// Track role change specifically
|
||||
if (data.role && data.role !== targetUser.role) {
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE',
|
||||
action: 'ROLE_CHANGED',
|
||||
entityType: 'User',
|
||||
entityId: id,
|
||||
detailsJson: updateData,
|
||||
detailsJson: { previousRole: targetUser.role, newRole: data.role },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
// Track role change specifically
|
||||
if (data.role && data.role !== targetUser.role) {
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'ROLE_CHANGED',
|
||||
entityType: 'User',
|
||||
entityId: id,
|
||||
detailsJson: { previousRole: targetUser.role, newRole: data.role },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
}
|
||||
|
||||
return updated
|
||||
})
|
||||
}
|
||||
|
||||
return user
|
||||
}),
|
||||
@@ -537,27 +530,26 @@ export const userRouter = router({
|
||||
})
|
||||
}
|
||||
|
||||
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 },
|
||||
})
|
||||
// Fetch user data before deletion for the audit log
|
||||
const target = await ctx.prisma.user.findUniqueOrThrow({
|
||||
where: { id: input.id },
|
||||
select: { email: true },
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'DELETE',
|
||||
entityType: 'User',
|
||||
entityId: input.id,
|
||||
detailsJson: { email: target.email },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
const user = await ctx.prisma.user.delete({
|
||||
where: { id: input.id },
|
||||
})
|
||||
|
||||
return tx.user.delete({
|
||||
where: { id: input.id },
|
||||
})
|
||||
// Audit outside transaction so failures don't roll back the deletion
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'DELETE',
|
||||
entityType: 'User',
|
||||
entityId: input.id,
|
||||
detailsJson: { email: target.email },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return user
|
||||
@@ -1147,20 +1139,21 @@ export const userRouter = router({
|
||||
}
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'COMPLETE_ONBOARDING',
|
||||
entityType: 'User',
|
||||
entityId: ctx.user.id,
|
||||
detailsJson: { name: input.name, juryPreferencesCount: input.juryPreferences?.length ?? 0 },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return updated
|
||||
})
|
||||
|
||||
// Audit outside transaction so failures don't roll back the onboarding
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'COMPLETE_ONBOARDING',
|
||||
entityType: 'User',
|
||||
entityId: ctx.user.id,
|
||||
detailsJson: { name: input.name, juryPreferencesCount: input.juryPreferences?.length ?? 0 },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return user
|
||||
}),
|
||||
|
||||
@@ -1265,29 +1258,26 @@ export const userRouter = router({
|
||||
// Hash the password
|
||||
const passwordHash = await hashPassword(input.password)
|
||||
|
||||
// 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,
|
||||
},
|
||||
})
|
||||
// Update user with new password
|
||||
const user = await ctx.prisma.user.update({
|
||||
where: { id: ctx.user.id },
|
||||
data: {
|
||||
passwordHash,
|
||||
passwordSetAt: new Date(),
|
||||
mustSetPassword: false,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'PASSWORD_SET',
|
||||
entityType: 'User',
|
||||
entityId: ctx.user.id,
|
||||
detailsJson: { timestamp: new Date().toISOString() },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return updated
|
||||
// Audit outside transaction so failures don't roll back the password set
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'PASSWORD_SET',
|
||||
entityType: 'User',
|
||||
entityId: ctx.user.id,
|
||||
detailsJson: { timestamp: new Date().toISOString() },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return { success: true, email: user.email }
|
||||
@@ -1348,26 +1338,25 @@ export const userRouter = router({
|
||||
// Hash the new password
|
||||
const passwordHash = await hashPassword(input.newPassword)
|
||||
|
||||
// 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(),
|
||||
},
|
||||
})
|
||||
// Update user with new password
|
||||
await ctx.prisma.user.update({
|
||||
where: { id: ctx.user.id },
|
||||
data: {
|
||||
passwordHash,
|
||||
passwordSetAt: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'PASSWORD_CHANGED',
|
||||
entityType: 'User',
|
||||
entityId: ctx.user.id,
|
||||
detailsJson: { timestamp: new Date().toISOString() },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
// Audit outside transaction so failures don't roll back the password change
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'PASSWORD_CHANGED',
|
||||
entityType: 'User',
|
||||
entityId: ctx.user.id,
|
||||
detailsJson: { timestamp: new Date().toISOString() },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
|
||||
Reference in New Issue
Block a user