Round detail overhaul, file requirements, project management, audit log fix
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:
2026-02-16 07:49:39 +01:00
parent f572336781
commit 7f334ed095
18 changed files with 3387 additions and 968 deletions

View File

@@ -33,33 +33,30 @@ export const cohortRouter = router({
}
}
const cohort = await ctx.prisma.$transaction(async (tx) => {
const created = await tx.cohort.create({
data: {
roundId: input.roundId,
name: input.name,
votingMode: input.votingMode,
windowOpenAt: input.windowOpenAt ?? null,
windowCloseAt: input.windowCloseAt ?? null,
},
})
const cohort = await ctx.prisma.cohort.create({
data: {
roundId: input.roundId,
name: input.name,
votingMode: input.votingMode,
windowOpenAt: input.windowOpenAt ?? null,
windowCloseAt: input.windowCloseAt ?? null,
},
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'CREATE',
entityType: 'Cohort',
entityId: created.id,
detailsJson: {
roundId: input.roundId,
name: input.name,
votingMode: input.votingMode,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return created
// Audit outside transaction so failures don't roll back the create
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'CREATE',
entityType: 'Cohort',
entityId: cohort.id,
detailsJson: {
roundId: input.roundId,
name: input.name,
votingMode: input.votingMode,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return cohort
@@ -157,32 +154,29 @@ export const cohortRouter = router({
? new Date(now.getTime() + input.durationMinutes * 60 * 1000)
: cohort.windowCloseAt
const updated = await ctx.prisma.$transaction(async (tx) => {
const result = await tx.cohort.update({
where: { id: input.cohortId },
data: {
isOpen: true,
windowOpenAt: now,
windowCloseAt: closeAt,
},
})
const updated = await ctx.prisma.cohort.update({
where: { id: input.cohortId },
data: {
isOpen: true,
windowOpenAt: now,
windowCloseAt: closeAt,
},
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'COHORT_VOTING_OPENED',
entityType: 'Cohort',
entityId: input.cohortId,
detailsJson: {
openedAt: now.toISOString(),
closesAt: closeAt?.toISOString() ?? null,
projectCount: cohort._count.projects,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return result
// Audit outside transaction so failures don't roll back the voting open
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'COHORT_VOTING_OPENED',
entityType: 'Cohort',
entityId: input.cohortId,
detailsJson: {
openedAt: now.toISOString(),
closesAt: closeAt?.toISOString() ?? null,
projectCount: cohort._count.projects,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updated
@@ -207,30 +201,27 @@ export const cohortRouter = router({
const now = new Date()
const updated = await ctx.prisma.$transaction(async (tx) => {
const result = await tx.cohort.update({
where: { id: input.cohortId },
data: {
isOpen: false,
windowCloseAt: now,
},
})
const updated = await ctx.prisma.cohort.update({
where: { id: input.cohortId },
data: {
isOpen: false,
windowCloseAt: now,
},
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'COHORT_VOTING_CLOSED',
entityType: 'Cohort',
entityId: input.cohortId,
detailsJson: {
closedAt: now.toISOString(),
wasOpenSince: cohort.windowOpenAt?.toISOString(),
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return result
// Audit outside transaction so failures don't roll back the voting close
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'COHORT_VOTING_CLOSED',
entityType: 'Cohort',
entityId: input.cohortId,
detailsJson: {
closedAt: now.toISOString(),
wasOpenSince: cohort.windowOpenAt?.toISOString(),
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updated

View File

@@ -36,23 +36,19 @@ export const competitionRouter = router({
where: { id: input.programId },
})
const competition = await ctx.prisma.$transaction(async (tx) => {
const created = await tx.competition.create({
data: input,
})
const competition = await ctx.prisma.competition.create({
data: input,
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'CREATE',
entityType: 'Competition',
entityId: created.id,
detailsJson: { name: input.name, programId: input.programId },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return created
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'CREATE',
entityType: 'Competition',
entityId: competition.id,
detailsJson: { name: input.name, programId: input.programId },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return competition
@@ -178,33 +174,29 @@ export const competitionRouter = router({
}
}
const competition = await ctx.prisma.$transaction(async (tx) => {
const previous = await tx.competition.findUniqueOrThrow({ where: { id } })
const previous = await ctx.prisma.competition.findUniqueOrThrow({ where: { id } })
const updated = await tx.competition.update({
where: { id },
data,
})
const competition = await ctx.prisma.competition.update({
where: { id },
data,
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'Competition',
entityId: id,
detailsJson: {
changes: data,
previous: {
name: previous.name,
status: previous.status,
slug: previous.slug,
},
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'Competition',
entityId: id,
detailsJson: {
changes: data,
previous: {
name: previous.name,
status: previous.status,
slug: previous.slug,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updated
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return competition
@@ -216,24 +208,20 @@ export const competitionRouter = router({
delete: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const competition = await ctx.prisma.$transaction(async (tx) => {
const updated = await tx.competition.update({
where: { id: input.id },
data: { status: 'ARCHIVED' },
})
const competition = await ctx.prisma.competition.update({
where: { id: input.id },
data: { status: 'ARCHIVED' },
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'DELETE',
entityType: 'Competition',
entityId: input.id,
detailsJson: { action: 'archived' },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updated
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'DELETE',
entityType: 'Competition',
entityId: input.id,
detailsJson: { action: 'archived' },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return competition

View File

@@ -97,22 +97,23 @@ export const decisionRouter = router({
snapshotJson: previousValue as Prisma.InputJsonValue,
},
})
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'DECISION_OVERRIDE',
entityType: input.entityType,
entityId: input.entityId,
detailsJson: {
reasonCode: input.reasonCode,
reasonText: input.reasonText,
previousState: previousValue.state,
newState: input.newValue.state,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
// Audit outside transaction so failures don't roll back the override
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'DECISION_OVERRIDE',
entityType: input.entityType,
entityId: input.entityId,
detailsJson: {
reasonCode: input.reasonCode,
reasonText: input.reasonText,
previousState: previousValue.state,
newState: input.newValue.state,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
break
}
@@ -161,21 +162,22 @@ export const decisionRouter = router({
} as Prisma.InputJsonValue,
},
})
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'DECISION_OVERRIDE',
entityType: input.entityType,
entityId: input.entityId,
detailsJson: {
reasonCode: input.reasonCode,
previousOutcome: (previousValue as Record<string, unknown>).outcome,
newOutcome,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
// Audit outside transaction so failures don't roll back the override
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'DECISION_OVERRIDE',
entityType: input.entityType,
entityId: input.entityId,
detailsJson: {
reasonCode: input.reasonCode,
previousOutcome: (previousValue as Record<string, unknown>).outcome,
newOutcome,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
break
}
@@ -229,21 +231,22 @@ export const decisionRouter = router({
} as Prisma.InputJsonValue,
},
})
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'DECISION_OVERRIDE',
entityType: input.entityType,
entityId: input.entityId,
detailsJson: {
reasonCode: input.reasonCode,
previousEligible: previousValue.eligible,
newEligible,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
// Audit outside transaction so failures don't roll back the override
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'DECISION_OVERRIDE',
entityType: input.entityType,
entityId: input.entityId,
detailsJson: {
reasonCode: input.reasonCode,
previousEligible: previousValue.eligible,
newEligible,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
break
}

View File

@@ -517,26 +517,27 @@ export const fileRouter = router({
data: { replacedById: newFile.id },
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'REPLACE_FILE',
entityType: 'ProjectFile',
entityId: newFile.id,
detailsJson: {
projectId: input.projectId,
oldFileId: input.oldFileId,
oldVersion: oldFile.version,
newVersion: newFile.version,
fileName: input.fileName,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return newFile
})
// Audit outside transaction so failures don't roll back the file replacement
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'REPLACE_FILE',
entityType: 'ProjectFile',
entityId: result.id,
detailsJson: {
projectId: input.projectId,
oldFileId: input.oldFileId,
oldVersion: oldFile.version,
newVersion: result.version,
fileName: input.fileName,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return result
}),

View File

@@ -36,26 +36,23 @@ export const juryGroupRouter = router({
const { defaultCategoryQuotas, ...rest } = input
const juryGroup = await ctx.prisma.$transaction(async (tx) => {
const created = await tx.juryGroup.create({
data: {
...rest,
defaultCategoryQuotas: defaultCategoryQuotas ?? undefined,
},
})
const juryGroup = await ctx.prisma.juryGroup.create({
data: {
...rest,
defaultCategoryQuotas: defaultCategoryQuotas ?? undefined,
},
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'CREATE',
entityType: 'JuryGroup',
entityId: created.id,
detailsJson: { name: input.name, competitionId: input.competitionId },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return created
// Audit outside transaction so failures don't roll back the create
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'CREATE',
entityType: 'JuryGroup',
entityId: juryGroup.id,
detailsJson: { name: input.name, competitionId: input.competitionId },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return juryGroup
@@ -187,39 +184,36 @@ export const juryGroupRouter = router({
})
}
const member = await ctx.prisma.$transaction(async (tx) => {
const created = await tx.juryGroupMember.create({
data: {
juryGroupId: input.juryGroupId,
userId: input.userId,
role: input.role,
maxAssignmentsOverride: input.maxAssignmentsOverride ?? undefined,
capModeOverride: input.capModeOverride ?? undefined,
categoryQuotasOverride: input.categoryQuotasOverride ?? undefined,
preferredStartupRatio: input.preferredStartupRatio ?? undefined,
availabilityNotes: input.availabilityNotes ?? undefined,
},
include: {
user: { select: { id: true, name: true, email: true, role: true } },
},
})
const member = await ctx.prisma.juryGroupMember.create({
data: {
juryGroupId: input.juryGroupId,
userId: input.userId,
role: input.role,
maxAssignmentsOverride: input.maxAssignmentsOverride ?? undefined,
capModeOverride: input.capModeOverride ?? undefined,
categoryQuotasOverride: input.categoryQuotasOverride ?? undefined,
preferredStartupRatio: input.preferredStartupRatio ?? undefined,
availabilityNotes: input.availabilityNotes ?? undefined,
},
include: {
user: { select: { id: true, name: true, email: true, role: true } },
},
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'CREATE',
entityType: 'JuryGroupMember',
entityId: created.id,
detailsJson: {
juryGroupId: input.juryGroupId,
addedUserId: input.userId,
role: input.role,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return created
// Audit outside transaction so failures don't roll back the member add
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'CREATE',
entityType: 'JuryGroupMember',
entityId: member.id,
detailsJson: {
juryGroupId: input.juryGroupId,
addedUserId: input.userId,
role: input.role,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return member
@@ -231,31 +225,28 @@ export const juryGroupRouter = router({
removeMember: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const member = await ctx.prisma.$transaction(async (tx) => {
const existing = await tx.juryGroupMember.findUniqueOrThrow({
where: { id: input.id },
})
await tx.juryGroupMember.delete({ where: { id: input.id } })
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'DELETE',
entityType: 'JuryGroupMember',
entityId: input.id,
detailsJson: {
juryGroupId: existing.juryGroupId,
removedUserId: existing.userId,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return existing
const existing = await ctx.prisma.juryGroupMember.findUniqueOrThrow({
where: { id: input.id },
})
return member
await ctx.prisma.juryGroupMember.delete({ where: { id: input.id } })
// Audit outside transaction so failures don't roll back the member removal
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'DELETE',
entityType: 'JuryGroupMember',
entityId: input.id,
detailsJson: {
juryGroupId: existing.juryGroupId,
removedUserId: existing.userId,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return existing
}),
/**

View File

@@ -72,24 +72,25 @@ export const liveRouter = router({
},
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'LIVE_SESSION_STARTED',
entityType: 'Round',
entityId: input.roundId,
detailsJson: {
sessionId: created.sessionId,
projectCount: input.projectOrder.length,
firstProjectId: input.projectOrder[0],
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return created
})
// Audit outside transaction so failures don't roll back the session start
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'LIVE_SESSION_STARTED',
entityType: 'Round',
entityId: input.roundId,
detailsJson: {
sessionId: cursor.sessionId,
projectCount: input.projectOrder.length,
firstProjectId: input.projectOrder[0],
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return cursor
}),
@@ -123,30 +124,27 @@ export const liveRouter = router({
})
}
const updated = await ctx.prisma.$transaction(async (tx) => {
const result = await tx.liveProgressCursor.update({
where: { id: cursor.id },
data: {
activeProjectId: input.projectId,
activeOrderIndex: index,
},
})
const updated = await ctx.prisma.liveProgressCursor.update({
where: { id: cursor.id },
data: {
activeProjectId: input.projectId,
activeOrderIndex: index,
},
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'LIVE_ACTIVE_PROJECT_SET',
entityType: 'LiveProgressCursor',
entityId: cursor.id,
detailsJson: {
projectId: input.projectId,
orderIndex: index,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return result
// Audit outside transaction so failures don't roll back the project set
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'LIVE_ACTIVE_PROJECT_SET',
entityType: 'LiveProgressCursor',
entityId: cursor.id,
detailsJson: {
projectId: input.projectId,
orderIndex: index,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updated
@@ -182,31 +180,28 @@ export const liveRouter = router({
const targetProjectId = projectOrder[input.index]
const updated = await ctx.prisma.$transaction(async (tx) => {
const result = await tx.liveProgressCursor.update({
where: { id: cursor.id },
data: {
activeProjectId: targetProjectId,
activeOrderIndex: input.index,
},
})
const updated = await ctx.prisma.liveProgressCursor.update({
where: { id: cursor.id },
data: {
activeProjectId: targetProjectId,
activeOrderIndex: input.index,
},
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'LIVE_JUMP',
entityType: 'LiveProgressCursor',
entityId: cursor.id,
detailsJson: {
fromIndex: cursor.activeOrderIndex,
toIndex: input.index,
projectId: targetProjectId,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return result
// Audit outside transaction so failures don't roll back the jump
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'LIVE_JUMP',
entityType: 'LiveProgressCursor',
entityId: cursor.id,
detailsJson: {
fromIndex: cursor.activeOrderIndex,
toIndex: input.index,
projectId: targetProjectId,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updated
@@ -255,22 +250,23 @@ export const liveRouter = router({
},
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'LIVE_REORDER',
entityType: 'LiveProgressCursor',
entityId: cursor.id,
detailsJson: {
projectCount: input.projectOrder.length,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updatedCursor
})
// Audit outside transaction so failures don't roll back the reorder
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'LIVE_REORDER',
entityType: 'LiveProgressCursor',
entityId: cursor.id,
detailsJson: {
projectCount: input.projectOrder.length,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updated
}),
@@ -291,24 +287,21 @@ export const liveRouter = router({
})
}
const updated = await ctx.prisma.$transaction(async (tx) => {
const result = await tx.liveProgressCursor.update({
where: { id: cursor.id },
data: { isPaused: true },
})
const updated = await ctx.prisma.liveProgressCursor.update({
where: { id: cursor.id },
data: { isPaused: true },
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'LIVE_PAUSED',
entityType: 'LiveProgressCursor',
entityId: cursor.id,
detailsJson: { activeProjectId: cursor.activeProjectId },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return result
// Audit outside transaction so failures don't roll back the pause
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'LIVE_PAUSED',
entityType: 'LiveProgressCursor',
entityId: cursor.id,
detailsJson: { activeProjectId: cursor.activeProjectId },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updated
@@ -331,24 +324,21 @@ export const liveRouter = router({
})
}
const updated = await ctx.prisma.$transaction(async (tx) => {
const result = await tx.liveProgressCursor.update({
where: { id: cursor.id },
data: { isPaused: false },
})
const updated = await ctx.prisma.liveProgressCursor.update({
where: { id: cursor.id },
data: { isPaused: false },
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'LIVE_RESUMED',
entityType: 'LiveProgressCursor',
entityId: cursor.id,
detailsJson: { activeProjectId: cursor.activeProjectId },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return result
// Audit outside transaction so failures don't roll back the resume
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'LIVE_RESUMED',
entityType: 'LiveProgressCursor',
entityId: cursor.id,
detailsJson: { activeProjectId: cursor.activeProjectId },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updated

View File

@@ -128,54 +128,51 @@ export const mentorRouter = router({
where: { id: input.mentorId },
})
// Create assignment + audit log in transaction
const assignment = await ctx.prisma.$transaction(async (tx) => {
const created = await tx.mentorAssignment.create({
data: {
projectId: input.projectId,
mentorId: input.mentorId,
method: input.method,
assignedBy: ctx.user.id,
aiConfidenceScore: input.aiConfidenceScore,
expertiseMatchScore: input.expertiseMatchScore,
aiReasoning: input.aiReasoning,
},
include: {
mentor: {
select: {
id: true,
name: true,
email: true,
expertiseTags: true,
},
},
project: {
select: {
id: true,
title: true,
},
// Create assignment
const assignment = await ctx.prisma.mentorAssignment.create({
data: {
projectId: input.projectId,
mentorId: input.mentorId,
method: input.method,
assignedBy: ctx.user.id,
aiConfidenceScore: input.aiConfidenceScore,
expertiseMatchScore: input.expertiseMatchScore,
aiReasoning: input.aiReasoning,
},
include: {
mentor: {
select: {
id: true,
name: true,
email: true,
expertiseTags: true,
},
},
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'MENTOR_ASSIGN',
entityType: 'MentorAssignment',
entityId: created.id,
detailsJson: {
projectId: input.projectId,
projectTitle: created.project.title,
mentorId: input.mentorId,
mentorName: created.mentor.name,
method: input.method,
project: {
select: {
id: true,
title: true,
},
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
},
})
return created
// Audit outside transaction so failures don't roll back the assignment
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'MENTOR_ASSIGN',
entityType: 'MentorAssignment',
entityId: assignment.id,
detailsJson: {
projectId: input.projectId,
projectTitle: assignment.project.title,
mentorId: input.mentorId,
mentorName: assignment.mentor.name,
method: input.method,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
// Get team lead info for mentor notification
@@ -382,27 +379,26 @@ export const mentorRouter = router({
})
}
// Delete assignment + audit log in transaction
await ctx.prisma.$transaction(async (tx) => {
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'MENTOR_UNASSIGN',
entityType: 'MentorAssignment',
entityId: assignment.id,
detailsJson: {
projectId: input.projectId,
projectTitle: assignment.project.title,
mentorId: assignment.mentor.id,
mentorName: assignment.mentor.name,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
// Delete assignment
await ctx.prisma.mentorAssignment.delete({
where: { projectId: input.projectId },
})
await tx.mentorAssignment.delete({
where: { projectId: input.projectId },
})
// Audit outside transaction so failures don't roll back the unassignment
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'MENTOR_UNASSIGN',
entityType: 'MentorAssignment',
entityId: assignment.id,
detailsJson: {
projectId: input.projectId,
projectTitle: assignment.project.title,
mentorId: assignment.mentor.id,
mentorName: assignment.mentor.name,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { success: true }

View File

@@ -174,24 +174,24 @@ export const projectPoolRouter = router({
})),
})
// Create audit log
await logAudit({
prisma: tx,
userId: ctx.user?.id,
action: 'BULK_ASSIGN_TO_ROUND',
entityType: 'Project',
detailsJson: {
roundId,
projectCount: projectIds.length,
projectIds,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updatedProjects
})
// Audit outside transaction so failures don't roll back the assignment
await logAudit({
prisma: ctx.prisma,
userId: ctx.user?.id,
action: 'BULK_ASSIGN_TO_ROUND',
entityType: 'Project',
detailsJson: {
roundId,
projectCount: projectIds.length,
projectIds,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return {
success: true,
assignedCount: result.count,
@@ -258,24 +258,25 @@ export const projectPoolRouter = router({
})),
})
await logAudit({
prisma: tx,
userId: ctx.user?.id,
action: 'BULK_ASSIGN_ALL_TO_ROUND',
entityType: 'Project',
detailsJson: {
roundId,
programId,
competitionCategory: competitionCategory || 'ALL',
projectCount: projectIds.length,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updated
})
// Audit outside transaction so failures don't roll back the assignment
await logAudit({
prisma: ctx.prisma,
userId: ctx.user?.id,
action: 'BULK_ASSIGN_ALL_TO_ROUND',
entityType: 'Project',
detailsJson: {
roundId,
programId,
competitionCategory: competitionCategory || 'ALL',
projectCount: projectIds.length,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { success: true, assignedCount: result.count, roundId }
}),
})

View File

@@ -590,24 +590,25 @@ export const projectRouter = router({
}
}
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'CREATE',
entityType: 'Project',
entityId: created.id,
detailsJson: {
title: input.title,
programId: resolvedProgramId,
teamMembersCount: teamMembersInput?.length || 0,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { project: created, membersToInvite: inviteList }
})
// Audit outside transaction so failures don't roll back the project creation
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'CREATE',
entityType: 'Project',
entityId: project.id,
detailsJson: {
title: input.title,
programId: resolvedProgramId,
teamMembersCount: teamMembersInput?.length || 0,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
// Send invite emails outside the transaction (never fail project creation)
if (membersToInvite.length > 0) {
const baseUrl = process.env.NEXTAUTH_URL || 'https://monaco-opc.com'
@@ -782,26 +783,25 @@ export const projectRouter = router({
delete: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const project = await ctx.prisma.$transaction(async (tx) => {
const target = await tx.project.findUniqueOrThrow({
where: { id: input.id },
select: { id: true, title: true },
})
const target = await ctx.prisma.project.findUniqueOrThrow({
where: { id: input.id },
select: { id: true, title: true },
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'DELETE',
entityType: 'Project',
entityId: input.id,
detailsJson: { title: target.title },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
const project = await ctx.prisma.project.delete({
where: { id: input.id },
})
return tx.project.delete({
where: { id: input.id },
})
// Audit outside transaction so failures don't roll back the delete
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'DELETE',
entityType: 'Project',
entityId: input.id,
detailsJson: { title: target.title },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return project
@@ -829,24 +829,23 @@ export const projectRouter = router({
})
}
const result = await ctx.prisma.$transaction(async (tx) => {
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'BULK_DELETE',
entityType: 'Project',
detailsJson: {
count: projects.length,
titles: projects.map((p) => p.title),
ids: projects.map((p) => p.id),
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
const result = await ctx.prisma.project.deleteMany({
where: { id: { in: projects.map((p) => p.id) } },
})
return tx.project.deleteMany({
where: { id: { in: projects.map((p) => p.id) } },
})
// Audit outside transaction so failures don't roll back the bulk delete
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'BULK_DELETE',
entityType: 'Project',
detailsJson: {
count: projects.length,
titles: projects.map((p) => p.title),
ids: projects.map((p) => p.id),
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { deleted: result.count }
@@ -996,19 +995,20 @@ export const projectRouter = router({
})
}
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'BULK_UPDATE_STATUS',
entityType: 'Project',
detailsJson: { ids: matchingIds, status: input.status, count: result.count },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return result
})
// Audit outside transaction so failures don't roll back the bulk update
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'BULK_UPDATE_STATUS',
entityType: 'Project',
detailsJson: { ids: matchingIds, status: input.status, count: updated.count },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
// Notify project teams based on status
if (projects.length > 0) {
const notificationConfig: Record<

View File

@@ -54,39 +54,35 @@ export const roundRouter = router({
? validateRoundConfig(input.roundType, input.configJson)
: defaultRoundConfig(input.roundType)
const round = await ctx.prisma.$transaction(async (tx) => {
const created = await tx.round.create({
data: {
competitionId: input.competitionId,
name: input.name,
slug: input.slug,
roundType: input.roundType,
sortOrder: input.sortOrder,
configJson: config as unknown as Prisma.InputJsonValue,
windowOpenAt: input.windowOpenAt ?? undefined,
windowCloseAt: input.windowCloseAt ?? undefined,
juryGroupId: input.juryGroupId ?? undefined,
submissionWindowId: input.submissionWindowId ?? undefined,
purposeKey: input.purposeKey ?? undefined,
},
})
const round = await ctx.prisma.round.create({
data: {
competitionId: input.competitionId,
name: input.name,
slug: input.slug,
roundType: input.roundType,
sortOrder: input.sortOrder,
configJson: config as unknown as Prisma.InputJsonValue,
windowOpenAt: input.windowOpenAt ?? undefined,
windowCloseAt: input.windowCloseAt ?? undefined,
juryGroupId: input.juryGroupId ?? undefined,
submissionWindowId: input.submissionWindowId ?? undefined,
purposeKey: input.purposeKey ?? undefined,
},
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'CREATE',
entityType: 'Round',
entityId: created.id,
detailsJson: {
name: input.name,
roundType: input.roundType,
competitionId: input.competitionId,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return created
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'CREATE',
entityType: 'Round',
entityId: round.id,
detailsJson: {
name: input.name,
roundType: input.roundType,
competitionId: input.competitionId,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return round
@@ -145,42 +141,38 @@ export const roundRouter = router({
.mutation(async ({ ctx, input }) => {
const { id, configJson, ...data } = input
const round = await ctx.prisma.$transaction(async (tx) => {
const existing = await tx.round.findUniqueOrThrow({ where: { id } })
const existing = await ctx.prisma.round.findUniqueOrThrow({ where: { id } })
// If configJson provided, validate it against the round type
let validatedConfig: Prisma.InputJsonValue | undefined
if (configJson) {
const parsed = validateRoundConfig(existing.roundType, configJson)
validatedConfig = parsed as unknown as Prisma.InputJsonValue
}
// If configJson provided, validate it against the round type
let validatedConfig: Prisma.InputJsonValue | undefined
if (configJson) {
const parsed = validateRoundConfig(existing.roundType, configJson)
validatedConfig = parsed as unknown as Prisma.InputJsonValue
}
const updated = await tx.round.update({
where: { id },
data: {
...data,
...(validatedConfig !== undefined ? { configJson: validatedConfig } : {}),
const round = await ctx.prisma.round.update({
where: { id },
data: {
...data,
...(validatedConfig !== undefined ? { configJson: validatedConfig } : {}),
},
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'Round',
entityId: id,
detailsJson: {
changes: input,
previous: {
name: existing.name,
status: existing.status,
},
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'Round',
entityId: id,
detailsJson: {
changes: input,
previous: {
name: existing.name,
status: existing.status,
},
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updated
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return round
@@ -213,30 +205,26 @@ export const roundRouter = router({
delete: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const round = await ctx.prisma.$transaction(async (tx) => {
const existing = await tx.round.findUniqueOrThrow({ where: { id: input.id } })
const existing = await ctx.prisma.round.findUniqueOrThrow({ where: { id: input.id } })
await tx.round.delete({ where: { id: input.id } })
await ctx.prisma.round.delete({ where: { id: input.id } })
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'DELETE',
entityType: 'Round',
entityId: input.id,
detailsJson: {
name: existing.name,
roundType: existing.roundType,
competitionId: existing.competitionId,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return existing
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'DELETE',
entityType: 'Round',
entityId: input.id,
detailsJson: {
name: existing.name,
roundType: existing.roundType,
competitionId: existing.competitionId,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return round
return existing
}),
// =========================================================================
@@ -261,33 +249,29 @@ export const roundRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
const window = await ctx.prisma.$transaction(async (tx) => {
const created = await tx.submissionWindow.create({
data: {
competitionId: input.competitionId,
name: input.name,
slug: input.slug,
roundNumber: input.roundNumber,
windowOpenAt: input.windowOpenAt,
windowCloseAt: input.windowCloseAt,
deadlinePolicy: input.deadlinePolicy,
graceHours: input.graceHours,
lockOnClose: input.lockOnClose,
},
})
const window = await ctx.prisma.submissionWindow.create({
data: {
competitionId: input.competitionId,
name: input.name,
slug: input.slug,
roundNumber: input.roundNumber,
windowOpenAt: input.windowOpenAt,
windowCloseAt: input.windowCloseAt,
deadlinePolicy: input.deadlinePolicy,
graceHours: input.graceHours,
lockOnClose: input.lockOnClose,
},
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'CREATE',
entityType: 'SubmissionWindow',
entityId: created.id,
detailsJson: { name: input.name, competitionId: input.competitionId },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return created
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'CREATE',
entityType: 'SubmissionWindow',
entityId: window.id,
detailsJson: { name: input.name, competitionId: input.competitionId },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return window
@@ -313,22 +297,19 @@ export const roundRouter = router({
)
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input
const window = await ctx.prisma.$transaction(async (tx) => {
const updated = await tx.submissionWindow.update({
where: { id },
data,
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'SubmissionWindow',
entityId: id,
detailsJson: data,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updated
const window = await ctx.prisma.submissionWindow.update({
where: { id },
data,
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'SubmissionWindow',
entityId: id,
detailsJson: data,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return window
}),
@@ -350,18 +331,16 @@ export const roundRouter = router({
message: `Cannot delete window "${window.name}" — it has ${window._count.projectFiles} uploaded files. Remove files first.`,
})
}
await ctx.prisma.$transaction(async (tx) => {
await tx.submissionWindow.delete({ where: { id: input.id } })
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'DELETE',
entityType: 'SubmissionWindow',
entityId: input.id,
detailsJson: { name: window.name },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
await ctx.prisma.submissionWindow.delete({ where: { id: input.id } })
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'DELETE',
entityType: 'SubmissionWindow',
entityId: input.id,
detailsJson: { name: window.name },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { success: true }
}),

View File

@@ -140,4 +140,109 @@ export const roundEngineRouter = router({
.query(async ({ ctx, input }) => {
return getProjectRoundState(input.projectId, input.roundId, ctx.prisma)
}),
/**
* Remove a project from a round (and all subsequent rounds in that competition).
* The project remains in all prior rounds.
*/
removeFromRound: adminProcedure
.input(
z.object({
projectId: z.string(),
roundId: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
// Get the round to know its competition and sort order
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { id: true, competitionId: true, sortOrder: true },
})
// Find all rounds at this sort order or later in the same competition
const roundsToRemoveFrom = await ctx.prisma.round.findMany({
where: {
competitionId: round.competitionId,
sortOrder: { gte: round.sortOrder },
},
select: { id: true },
})
const roundIds = roundsToRemoveFrom.map((r) => r.id)
// Delete ProjectRoundState entries for this project in all affected rounds
const deleted = await ctx.prisma.projectRoundState.deleteMany({
where: {
projectId: input.projectId,
roundId: { in: roundIds },
},
})
// Check if the project is still in any round at all
const remainingStates = await ctx.prisma.projectRoundState.count({
where: { projectId: input.projectId },
})
// If no longer in any round, reset project status back to SUBMITTED
if (remainingStates === 0) {
await ctx.prisma.project.update({
where: { id: input.projectId },
data: { status: 'SUBMITTED' },
})
}
return { success: true, removedFromRounds: deleted.count }
}),
/**
* Batch remove projects from a round (and all subsequent rounds).
*/
batchRemoveFromRound: adminProcedure
.input(
z.object({
projectIds: z.array(z.string()).min(1),
roundId: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { id: true, competitionId: true, sortOrder: true },
})
const roundsToRemoveFrom = await ctx.prisma.round.findMany({
where: {
competitionId: round.competitionId,
sortOrder: { gte: round.sortOrder },
},
select: { id: true },
})
const roundIds = roundsToRemoveFrom.map((r) => r.id)
const deleted = await ctx.prisma.projectRoundState.deleteMany({
where: {
projectId: { in: input.projectIds },
roundId: { in: roundIds },
},
})
// For projects with no remaining round states, reset to SUBMITTED
const projectsStillInRounds = await ctx.prisma.projectRoundState.findMany({
where: { projectId: { in: input.projectIds } },
select: { projectId: true },
distinct: ['projectId'],
})
const stillInRoundIds = new Set(projectsStillInRounds.map((p) => p.projectId))
const orphanedIds = input.projectIds.filter((id) => !stillInRoundIds.has(id))
if (orphanedIds.length > 0) {
await ctx.prisma.project.updateMany({
where: { id: { in: orphanedIds } },
data: { status: 'SUBMITTED' },
})
}
return { success: true, removedCount: deleted.count }
}),
})

View File

@@ -106,37 +106,34 @@ export const specialAwardRouter = router({
_max: { sortOrder: true },
})
const award = await ctx.prisma.$transaction(async (tx) => {
const created = await tx.specialAward.create({
data: {
programId: input.programId,
name: input.name,
description: input.description,
criteriaText: input.criteriaText,
useAiEligibility: input.useAiEligibility ?? true,
scoringMode: input.scoringMode,
maxRankedPicks: input.maxRankedPicks,
autoTagRulesJson: input.autoTagRulesJson as Prisma.InputJsonValue ?? undefined,
competitionId: input.competitionId,
evaluationRoundId: input.evaluationRoundId,
juryGroupId: input.juryGroupId,
eligibilityMode: input.eligibilityMode,
sortOrder: (maxOrder._max.sortOrder || 0) + 1,
},
})
const award = await ctx.prisma.specialAward.create({
data: {
programId: input.programId,
name: input.name,
description: input.description,
criteriaText: input.criteriaText,
useAiEligibility: input.useAiEligibility ?? true,
scoringMode: input.scoringMode,
maxRankedPicks: input.maxRankedPicks,
autoTagRulesJson: input.autoTagRulesJson as Prisma.InputJsonValue ?? undefined,
competitionId: input.competitionId,
evaluationRoundId: input.evaluationRoundId,
juryGroupId: input.juryGroupId,
eligibilityMode: input.eligibilityMode,
sortOrder: (maxOrder._max.sortOrder || 0) + 1,
},
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'CREATE',
entityType: 'SpecialAward',
entityId: created.id,
detailsJson: { name: input.name, scoringMode: input.scoringMode },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return created
// Audit outside transaction so failures don't roll back the create
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'CREATE',
entityType: 'SpecialAward',
entityId: award.id,
detailsJson: { name: input.name, scoringMode: input.scoringMode },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return award
@@ -190,18 +187,17 @@ export const specialAwardRouter = router({
delete: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
await ctx.prisma.$transaction(async (tx) => {
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'DELETE',
entityType: 'SpecialAward',
entityId: input.id,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
await ctx.prisma.specialAward.delete({ where: { id: input.id } })
await tx.specialAward.delete({ where: { id: input.id } })
// Audit outside transaction so failures don't break the delete
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'DELETE',
entityType: 'SpecialAward',
entityId: input.id,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
}),
@@ -245,32 +241,29 @@ export const specialAwardRouter = router({
}
}
const award = await ctx.prisma.$transaction(async (tx) => {
const updated = await tx.specialAward.update({
where: { id: input.id },
data: updateData,
})
const award = await ctx.prisma.specialAward.update({
where: { id: input.id },
data: updateData,
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'UPDATE_STATUS',
entityType: 'SpecialAward',
entityId: input.id,
detailsJson: {
previousStatus: current.status,
newStatus: input.status,
...(votingStartAtUpdated && {
votingStartAtUpdated: true,
previousVotingStartAt: current.votingStartAt,
newVotingStartAt: now,
}),
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updated
// Audit outside transaction so failures don't break the status update
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'UPDATE_STATUS',
entityType: 'SpecialAward',
entityId: input.id,
detailsJson: {
previousStatus: current.status,
newStatus: input.status,
...(votingStartAtUpdated && {
votingStartAtUpdated: true,
previousVotingStartAt: current.votingStartAt,
newVotingStartAt: now,
}),
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return award
@@ -743,33 +736,30 @@ export const specialAwardRouter = router({
select: { winnerProjectId: true },
})
const award = await ctx.prisma.$transaction(async (tx) => {
const updated = await tx.specialAward.update({
where: { id: input.awardId },
data: {
winnerProjectId: input.projectId,
winnerOverridden: input.overridden,
winnerOverriddenBy: input.overridden ? ctx.user.id : null,
},
})
const award = await ctx.prisma.specialAward.update({
where: { id: input.awardId },
data: {
winnerProjectId: input.projectId,
winnerOverridden: input.overridden,
winnerOverriddenBy: input.overridden ? ctx.user.id : null,
},
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'SpecialAward',
entityId: input.awardId,
detailsJson: {
action: 'SET_AWARD_WINNER',
previousWinner: previous.winnerProjectId,
newWinner: input.projectId,
overridden: input.overridden,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updated
// Audit outside transaction so failures don't break the winner update
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'SpecialAward',
entityId: input.awardId,
detailsJson: {
action: 'SET_AWARD_WINNER',
previousWinner: previous.winnerProjectId,
newWinner: input.projectId,
overridden: input.overridden,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return award

View File

@@ -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 }