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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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 }
|
||||
}),
|
||||
|
||||
@@ -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 }
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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