Add human-readable reshuffle details to audit log page
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m50s
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m50s
Audit log now renders JUROR_DROPOUT_RESHUFFLE and COI_REASSIGNMENT entries as formatted tables with resolved juror names instead of raw JSON with opaque IDs. Uses new user.resolveNames endpoint to batch- lookup user IDs. Also adds missing action types to the filter dropdown. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -83,6 +83,11 @@ const ACTION_TYPES = [
|
|||||||
'ROLE_CHANGED',
|
'ROLE_CHANGED',
|
||||||
'PASSWORD_SET',
|
'PASSWORD_SET',
|
||||||
'PASSWORD_CHANGED',
|
'PASSWORD_CHANGED',
|
||||||
|
'JUROR_DROPOUT_RESHUFFLE',
|
||||||
|
'COI_REASSIGNMENT',
|
||||||
|
'APPLY_AI_SUGGESTIONS',
|
||||||
|
'APPLY_SUGGESTIONS',
|
||||||
|
'NOTIFY_JURORS_OF_ASSIGNMENTS',
|
||||||
]
|
]
|
||||||
|
|
||||||
// Entity type options
|
// Entity type options
|
||||||
@@ -118,6 +123,11 @@ const actionColors: Record<string, 'default' | 'destructive' | 'secondary' | 'ou
|
|||||||
ROLE_CHANGED: 'secondary',
|
ROLE_CHANGED: 'secondary',
|
||||||
PASSWORD_SET: 'outline',
|
PASSWORD_SET: 'outline',
|
||||||
PASSWORD_CHANGED: 'outline',
|
PASSWORD_CHANGED: 'outline',
|
||||||
|
JUROR_DROPOUT_RESHUFFLE: 'destructive',
|
||||||
|
COI_REASSIGNMENT: 'secondary',
|
||||||
|
APPLY_AI_SUGGESTIONS: 'default',
|
||||||
|
APPLY_SUGGESTIONS: 'default',
|
||||||
|
NOTIFY_JURORS_OF_ASSIGNMENTS: 'outline',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AuditLogPage() {
|
export default function AuditLogPage() {
|
||||||
@@ -516,9 +526,15 @@ export default function AuditLogPage() {
|
|||||||
<p className="text-xs font-medium text-muted-foreground mb-1">
|
<p className="text-xs font-medium text-muted-foreground mb-1">
|
||||||
Details
|
Details
|
||||||
</p>
|
</p>
|
||||||
|
{log.action === 'JUROR_DROPOUT_RESHUFFLE' ? (
|
||||||
|
<ReshuffleDetailView details={log.detailsJson as Record<string, unknown>} />
|
||||||
|
) : log.action === 'COI_REASSIGNMENT' ? (
|
||||||
|
<COIReassignmentDetailView details={log.detailsJson as Record<string, unknown>} />
|
||||||
|
) : (
|
||||||
<pre className="text-xs bg-muted rounded p-2 overflow-x-auto">
|
<pre className="text-xs bg-muted rounded p-2 overflow-x-auto">
|
||||||
{JSON.stringify(log.detailsJson, null, 2)}
|
{JSON.stringify(log.detailsJson, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!!(log as Record<string, unknown>).previousDataJson && (
|
{!!(log as Record<string, unknown>).previousDataJson && (
|
||||||
@@ -622,9 +638,15 @@ export default function AuditLogPage() {
|
|||||||
<p className="text-xs font-medium text-muted-foreground mb-1">
|
<p className="text-xs font-medium text-muted-foreground mb-1">
|
||||||
Details
|
Details
|
||||||
</p>
|
</p>
|
||||||
|
{log.action === 'JUROR_DROPOUT_RESHUFFLE' ? (
|
||||||
|
<ReshuffleDetailView details={log.detailsJson as Record<string, unknown>} />
|
||||||
|
) : log.action === 'COI_REASSIGNMENT' ? (
|
||||||
|
<COIReassignmentDetailView details={log.detailsJson as Record<string, unknown>} />
|
||||||
|
) : (
|
||||||
<pre className="text-xs bg-muted rounded p-2 overflow-x-auto">
|
<pre className="text-xs bg-muted rounded p-2 overflow-x-auto">
|
||||||
{JSON.stringify(log.detailsJson, null, 2)}
|
{JSON.stringify(log.detailsJson, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -693,6 +715,129 @@ export default function AuditLogPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ReshuffleDetailView({ details }: { details: Record<string, unknown> }) {
|
||||||
|
const reassignedTo = (details.reassignedTo ?? {}) as Record<string, number>
|
||||||
|
const jurorIds = Object.keys(reassignedTo)
|
||||||
|
const moves = (details.moves ?? []) as { projectId: string; projectTitle: string; newJurorId: string; newJurorName: string }[]
|
||||||
|
|
||||||
|
// Resolve juror IDs to names
|
||||||
|
const { data: nameMap } = trpc.user.resolveNames.useQuery(
|
||||||
|
{ ids: [...jurorIds, details.droppedJurorId as string].filter(Boolean) },
|
||||||
|
{ enabled: jurorIds.length > 0 },
|
||||||
|
)
|
||||||
|
|
||||||
|
const droppedName = (details.droppedJurorName as string) || (nameMap && details.droppedJurorId ? nameMap[details.droppedJurorId as string] : null) || (details.droppedJurorId as string)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-white overflow-hidden text-sm">
|
||||||
|
{/* Summary */}
|
||||||
|
<div className="p-3 bg-muted/50 border-b space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="destructive">Juror Dropout</Badge>
|
||||||
|
<span className="font-semibold">{droppedName}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{String(details.movedCount)} project(s) reassigned, {String(details.failedCount)} failed
|
||||||
|
{details.removedFromGroup ? ' — removed from jury group' : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Per-project moves (new format) */}
|
||||||
|
{moves.length > 0 && (
|
||||||
|
<div className="p-3">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground mb-2">Project → New Juror</p>
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-muted-foreground border-b">
|
||||||
|
<th className="text-left py-1 font-medium">Project</th>
|
||||||
|
<th className="text-left py-1 font-medium">Reassigned To</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{moves.map((move, i) => (
|
||||||
|
<tr key={i} className="border-b last:border-0">
|
||||||
|
<td className="py-1.5 pr-2">{move.projectTitle}</td>
|
||||||
|
<td className="py-1.5 font-medium">{move.newJurorName}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Fallback: count-based view (old format, no per-project detail) */}
|
||||||
|
{moves.length === 0 && jurorIds.length > 0 && (
|
||||||
|
<div className="p-3">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground mb-2">Reassignment Summary (project detail not available)</p>
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-muted-foreground border-b">
|
||||||
|
<th className="text-left py-1 font-medium">Juror</th>
|
||||||
|
<th className="text-right py-1 font-medium">Projects Received</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{jurorIds.map((id) => (
|
||||||
|
<tr key={id} className="border-b last:border-0">
|
||||||
|
<td className="py-1.5">{nameMap?.[id] || id}</td>
|
||||||
|
<td className="py-1.5 text-right font-medium">{reassignedTo[id]}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Failed projects */}
|
||||||
|
{Array.isArray(details.failedProjects) && (details.failedProjects as string[]).length > 0 && (
|
||||||
|
<div className="p-3 border-t bg-red-50/50">
|
||||||
|
<p className="text-xs font-medium text-red-700 mb-1">Could not reassign:</p>
|
||||||
|
<ul className="text-xs text-muted-foreground list-disc list-inside">
|
||||||
|
{(details.failedProjects as string[]).map((p, i) => (
|
||||||
|
<li key={i}>{p}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function COIReassignmentDetailView({ details }: { details: Record<string, unknown> }) {
|
||||||
|
const ids = [details.oldJurorId, details.newJurorId].filter(Boolean) as string[]
|
||||||
|
const { data: nameMap } = trpc.user.resolveNames.useQuery(
|
||||||
|
{ ids },
|
||||||
|
{ enabled: ids.length > 0 },
|
||||||
|
)
|
||||||
|
|
||||||
|
const oldName = nameMap?.[details.oldJurorId as string] || (details.oldJurorId as string)
|
||||||
|
const newName = nameMap?.[details.newJurorId as string] || (details.newJurorId as string)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-white overflow-hidden text-sm">
|
||||||
|
<div className="p-3 space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="secondary">COI Reassignment</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-xs">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">From</p>
|
||||||
|
<p className="font-medium">{oldName}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">To</p>
|
||||||
|
<p className="font-medium">{newName}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Project: <span className="font-mono">{(details.projectId as string)?.slice(0, 12)}...</span>
|
||||||
|
{' | '}Round: <span className="font-mono">{(details.roundId as string)?.slice(0, 12)}...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function DiffViewer({ before, after }: { before: unknown; after: unknown }) {
|
function DiffViewer({ before, after }: { before: unknown; after: unknown }) {
|
||||||
const beforeObj = typeof before === 'object' && before !== null ? before as Record<string, unknown> : {}
|
const beforeObj = typeof before === 'object' && before !== null ? before as Record<string, unknown> : {}
|
||||||
const afterObj = typeof after === 'object' && after !== null ? after as Record<string, unknown> : {}
|
const afterObj = typeof after === 'object' && after !== null ? after as Record<string, unknown> : {}
|
||||||
|
|||||||
@@ -360,6 +360,25 @@ export const userRouter = router({
|
|||||||
return user
|
return user
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a batch of user IDs to names (admin only).
|
||||||
|
* Returns a map of id → name for displaying in audit logs, etc.
|
||||||
|
*/
|
||||||
|
resolveNames: adminProcedure
|
||||||
|
.input(z.object({ ids: z.array(z.string()).max(50) }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
if (input.ids.length === 0) return {}
|
||||||
|
const users = await ctx.prisma.user.findMany({
|
||||||
|
where: { id: { in: input.ids } },
|
||||||
|
select: { id: true, name: true, email: true },
|
||||||
|
})
|
||||||
|
const map: Record<string, string> = {}
|
||||||
|
for (const u of users) {
|
||||||
|
map[u.id] = u.name || u.email
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create/invite a new user (admin only)
|
* Create/invite a new user (admin only)
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user