feat: audit log clickable links, communication hub recipient details & link options
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled

- Audit log: user names link to /admin/members/{id}, entity IDs link to
  relevant detail pages (projects, rounds, awards, users)
- Communication hub: expandable "View Recipients" section in sidebar shows
  actual users/projects that will receive the message, with collapsible
  project-level detail for applicants and juror assignment counts
- Email link type selector: choose between no link, messages inbox, login
  page, or invite/accept link (auto-detects new vs existing members)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 11:49:49 +01:00
parent 2e8ab91e07
commit d4c946470a
4 changed files with 455 additions and 25 deletions

View File

@@ -56,6 +56,7 @@ import { Switch } from '@/components/ui/switch'
import { CsvExportDialog } from '@/components/shared/csv-export-dialog'
import { formatDate } from '@/lib/utils'
import { cn } from '@/lib/utils'
import Link from 'next/link'
// Action type options (manual audit actions + auto-generated mutation audit actions)
const ACTION_TYPES = [
@@ -223,6 +224,26 @@ const actionColors: Record<string, 'default' | 'destructive' | 'secondary' | 'ou
}
function getEntityLink(entityType: string, entityId: string): string | null {
switch (entityType) {
case 'User':
return `/admin/members/${entityId}`
case 'Project':
return `/admin/projects/${entityId}`
case 'Round':
return `/admin/rounds/${entityId}`
case 'Competition':
return `/admin/competitions`
case 'Evaluation':
case 'EvaluationForm':
return null // no dedicated page
case 'SpecialAward':
return `/admin/awards/${entityId}`
default:
return null
}
}
export default function AuditLogPage() {
// Filter state
const [filters, setFilters] = useState({
@@ -555,14 +576,24 @@ export default function AuditLogPage() {
{formatDate(log.timestamp)}
</TableCell>
<TableCell>
<div>
<p className="font-medium text-sm">
{log.user?.name || 'System'}
</p>
<p className="text-xs text-muted-foreground">
{log.user?.email}
</p>
</div>
{log.userId ? (
<Link
href={`/admin/members/${log.userId}`}
className="group block"
onClick={(e) => e.stopPropagation()}
>
<p className="font-medium text-sm group-hover:text-primary group-hover:underline">
{log.user?.name || 'System'}
</p>
<p className="text-xs text-muted-foreground">
{log.user?.email}
</p>
</Link>
) : (
<div>
<p className="font-medium text-sm">System</p>
</div>
)}
</TableCell>
<TableCell>
<Badge
@@ -574,11 +605,22 @@ export default function AuditLogPage() {
<TableCell>
<div>
<p className="text-sm">{log.entityType}</p>
{log.entityId && (
<p className="text-xs text-muted-foreground font-mono">
{log.entityId.slice(0, 8)}...
</p>
)}
{log.entityId && (() => {
const link = getEntityLink(log.entityType, log.entityId)
return link ? (
<Link
href={link}
className="text-xs text-primary font-mono hover:underline"
onClick={(e) => e.stopPropagation()}
>
{log.entityId.slice(0, 8)}...
</Link>
) : (
<p className="text-xs text-muted-foreground font-mono">
{log.entityId.slice(0, 8)}...
</p>
)
})()}
</div>
</TableCell>
<TableCell className="font-mono text-xs">
@@ -601,9 +643,18 @@ export default function AuditLogPage() {
<p className="text-xs font-medium text-muted-foreground">
Entity ID
</p>
<p className="font-mono text-sm">
{log.entityId || 'N/A'}
</p>
{log.entityId ? (() => {
const link = getEntityLink(log.entityType, log.entityId)
return link ? (
<Link href={link} className="font-mono text-sm text-primary hover:underline" onClick={(e) => e.stopPropagation()}>
{log.entityId}
</Link>
) : (
<p className="font-mono text-sm">{log.entityId}</p>
)
})() : (
<p className="font-mono text-sm">N/A</p>
)}
</div>
<div>
<p className="text-xs font-medium text-muted-foreground">
@@ -700,12 +751,23 @@ export default function AuditLogPage() {
{formatDate(log.timestamp)}
</span>
</div>
<div className="flex items-center gap-1 text-muted-foreground">
<User className="h-3 w-3" />
<span className="text-xs">
{log.user?.name || 'System'}
</span>
</div>
{log.userId ? (
<Link
href={`/admin/members/${log.userId}`}
className="flex items-center gap-1 text-muted-foreground hover:text-primary"
onClick={(e) => e.stopPropagation()}
>
<User className="h-3 w-3" />
<span className="text-xs hover:underline">
{log.user?.name || 'System'}
</span>
</Link>
) : (
<div className="flex items-center gap-1 text-muted-foreground">
<User className="h-3 w-3" />
<span className="text-xs">System</span>
</div>
)}
</div>
{isExpanded && (

View File

@@ -49,6 +49,11 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
Send,
Mail,
@@ -64,6 +69,8 @@ import {
FolderOpen,
XCircle,
ArrowRight,
ChevronDown,
ChevronRight,
} from 'lucide-react'
import { toast } from 'sonner'
import { formatDate } from '@/lib/utils'
@@ -109,6 +116,7 @@ export default function MessagesPage() {
const [body, setBody] = useState('')
const [selectedTemplateId, setSelectedTemplateId] = useState('')
const [deliveryChannels, setDeliveryChannels] = useState<string[]>(['EMAIL', 'IN_APP'])
const [linkType, setLinkType] = useState<'NONE' | 'MESSAGES' | 'LOGIN' | 'INVITE'>('MESSAGES')
const [isScheduled, setIsScheduled] = useState(false)
const [scheduledAt, setScheduledAt] = useState('')
const [showPreview, setShowPreview] = useState(false)
@@ -165,6 +173,32 @@ export default function MessagesPage() {
}
)
// Detailed recipient list (fetched on-demand when user expands section)
const [showRecipientDetails, setShowRecipientDetails] = useState(false)
const recipientDetails = trpc.message.listRecipientDetails.useQuery(
{
recipientType,
recipientFilter: buildRecipientFilterValue(),
roundId: roundId || undefined,
excludeStates: recipientType === 'ROUND_APPLICANTS' ? excludeStates : undefined,
},
{
enabled: showRecipientDetails && (
recipientType === 'ROUND_APPLICANTS'
? !!roundId
: recipientType === 'ROUND_JURY'
? !!roundId
: recipientType === 'ROLE'
? !!selectedRole
: recipientType === 'USER'
? !!selectedUserId
: recipientType === 'PROGRAM_TEAM'
? !!selectedProgramId
: recipientType === 'ALL'
),
}
)
const emailPreview = trpc.message.previewEmail.useQuery(
{ subject, body },
{ enabled: showPreview && subject.length > 0 && body.length > 0 }
@@ -195,6 +229,8 @@ export default function MessagesPage() {
setSelectedUserId('')
setIsScheduled(false)
setScheduledAt('')
setLinkType('MESSAGES')
setShowRecipientDetails(false)
}
const handleTemplateSelect = (templateId: string) => {
@@ -319,6 +355,7 @@ export default function MessagesPage() {
deliveryChannels,
scheduledAt: isScheduled && scheduledAt ? new Date(scheduledAt).toISOString() : undefined,
templateId: selectedTemplateId && selectedTemplateId !== '__none__' ? selectedTemplateId : undefined,
linkType,
})
setShowPreview(false)
}
@@ -614,6 +651,36 @@ export default function MessagesPage() {
</div>
</div>
{/* Link in Email */}
{deliveryChannels.includes('EMAIL') && (
<div className="space-y-2">
<Label>Email Link Button</Label>
<Select
value={linkType}
onValueChange={(v) => setLinkType(v as typeof linkType)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="NONE">No link button</SelectItem>
<SelectItem value="MESSAGES">Link to Messages</SelectItem>
<SelectItem value="LOGIN">Link to Login</SelectItem>
<SelectItem value="INVITE">Invite / Accept link (for new members)</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{linkType === 'NONE'
? 'No button will appear in the email.'
: linkType === 'MESSAGES'
? 'Recipients see a "View Details" button linking to their messages inbox.'
: linkType === 'LOGIN'
? 'Recipients see a button linking to the login page.'
: 'New members get their personal invite link; existing members get the login page.'}
</p>
</div>
)}
{/* Schedule */}
<div className="space-y-2">
<div className="flex items-center gap-2">
@@ -748,6 +815,15 @@ export default function MessagesPage() {
)}
</div>
)}
{/* Expandable recipient details */}
<RecipientDetailsList
open={showRecipientDetails}
onOpenChange={setShowRecipientDetails}
data={recipientDetails.data}
isLoading={recipientDetails.isLoading}
recipientType={recipientType}
/>
</div>
) : (
<p className="text-sm text-muted-foreground">
@@ -991,6 +1067,11 @@ export default function MessagesPage() {
Scheduled: {formatDate(new Date(scheduledAt))}
</div>
)}
{deliveryChannels.includes('EMAIL') && (
<p className="text-xs text-muted-foreground mt-1">
Link: {linkType === 'NONE' ? 'None' : linkType === 'MESSAGES' ? 'Messages inbox' : linkType === 'LOGIN' ? 'Login page' : 'Invite / Accept link'}
</p>
)}
</div>
</div>
</div>
@@ -1027,3 +1108,149 @@ export default function MessagesPage() {
</div>
)
}
// =============================================================================
// Expandable recipient details
// =============================================================================
type RecipientDetailsData = {
type: 'projects' | 'jurors' | 'users'
projects: Array<{
id: string
title: string
state: string
members: Array<{ id: string; name: string | null; email: string }>
}>
users: Array<{
id: string
name: string | null
email: string
projectCount?: number
projectNames?: string[]
projectName?: string | null
role?: string
}>
}
function RecipientDetailsList({
open,
onOpenChange,
data,
isLoading,
recipientType,
}: {
open: boolean
onOpenChange: (open: boolean) => void
data?: RecipientDetailsData
isLoading: boolean
recipientType: string
}) {
return (
<Collapsible open={open} onOpenChange={onOpenChange}>
<CollapsibleTrigger className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors w-full pt-2">
{open ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
View Recipients
</CollapsibleTrigger>
<CollapsibleContent>
<div className="mt-2 max-h-[300px] overflow-y-auto rounded-md border bg-muted/20 p-2 space-y-1">
{isLoading ? (
<div className="space-y-2 p-1">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-4 w-full" />
))}
</div>
) : !data || (data.projects.length === 0 && data.users.length === 0) ? (
<p className="text-xs text-muted-foreground p-1">No recipients found.</p>
) : data.type === 'projects' ? (
// ROUND_APPLICANTS: projects with their members
data.projects.map((project) => (
<ProjectRecipientRow key={project.id} project={project} />
))
) : data.type === 'jurors' ? (
// ROUND_JURY: jurors with project counts
data.users.map((user) => (
<div key={user.id} className="flex items-center justify-between rounded px-2 py-1.5 text-xs hover:bg-muted/50">
<div className="min-w-0">
<Link
href={`/admin/members/${user.id}`}
className="font-medium hover:underline text-primary truncate block"
>
{user.name || user.email}
</Link>
{user.projectCount !== undefined && (
<span className="text-muted-foreground">
{user.projectCount} project{user.projectCount !== 1 ? 's' : ''} assigned
</span>
)}
</div>
</div>
))
) : (
// ALL, ROLE, USER, PROGRAM_TEAM: plain user list
data.users.map((user) => (
<div key={user.id} className="flex items-center justify-between rounded px-2 py-1.5 text-xs hover:bg-muted/50">
<div className="min-w-0">
<Link
href={`/admin/members/${user.id}`}
className="font-medium hover:underline text-primary truncate block"
>
{user.name || user.email}
</Link>
{user.projectName && (
<span className="text-muted-foreground truncate block">{user.projectName}</span>
)}
</div>
{user.role && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 shrink-0 ml-2">
{user.role.replace(/_/g, ' ')}
</Badge>
)}
</div>
))
)}
</div>
</CollapsibleContent>
</Collapsible>
)
}
function ProjectRecipientRow({ project }: {
project: {
id: string
title: string
state: string
members: Array<{ id: string; name: string | null; email: string }>
}
}) {
const [open, setOpen] = useState(false)
return (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger className="flex items-center justify-between w-full rounded px-2 py-1.5 text-xs hover:bg-muted/50">
<div className="flex items-center gap-1.5 min-w-0">
{open ? <ChevronDown className="h-3 w-3 shrink-0" /> : <ChevronRight className="h-3 w-3 shrink-0" />}
<span className="font-medium truncate">{project.title}</span>
</div>
<div className="flex items-center gap-1.5 shrink-0 ml-2">
<Badge variant={STATE_BADGE_VARIANT[project.state] || 'secondary'} className="text-[10px] px-1.5 py-0">
{STATE_LABELS[project.state] || project.state}
</Badge>
<span className="text-muted-foreground">{project.members.length}</span>
</div>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="ml-5 space-y-0.5 pb-1">
{project.members.map((member) => (
<Link
key={member.id}
href={`/admin/members/${member.id}`}
className="block text-xs px-2 py-0.5 text-primary hover:underline truncate"
>
{member.name || member.email}
</Link>
))}
</div>
</CollapsibleContent>
</Collapsible>
)
}

View File

@@ -42,7 +42,7 @@ export const auditRouter = router({
take: perPage,
orderBy: { timestamp: 'desc' },
include: {
user: { select: { name: true, email: true } },
user: { select: { id: true, name: true, email: true } },
},
}),
ctx.prisma.auditLog.count({ where }),

View File

@@ -23,6 +23,7 @@ export const messageRouter = router({
deliveryChannels: z.array(z.string()).min(1),
scheduledAt: z.string().datetime().optional(),
templateId: z.string().optional(),
linkType: z.enum(['NONE', 'MESSAGES', 'LOGIN', 'INVITE']).default('MESSAGES'),
})
)
.mutation(async ({ ctx, input }) => {
@@ -76,11 +77,30 @@ export const messageRouter = router({
if (!isScheduled && input.deliveryChannels.includes('EMAIL')) {
const users = await ctx.prisma.user.findMany({
where: { id: { in: recipientUserIds } },
select: { id: true, name: true, email: true },
select: { id: true, name: true, email: true, passwordHash: true, inviteToken: true },
})
const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'
function getLinkUrl(user: { id: string; passwordHash: string | null; inviteToken: string | null }): string | undefined {
switch (input.linkType) {
case 'NONE':
return undefined
case 'LOGIN':
return `${baseUrl}/login`
case 'INVITE':
// Users who haven't set a password yet — provide invite/accept link if token exists
if (user.inviteToken && !user.passwordHash) {
return `${baseUrl}/accept-invite?token=${user.inviteToken}`
}
// Already activated — just link to login
return `${baseUrl}/login`
case 'MESSAGES':
default:
return `${baseUrl}/messages`
}
}
const items: NotificationItem[] = users.map((user) => ({
email: user.email,
name: user.name || '',
@@ -90,7 +110,7 @@ export const messageRouter = router({
name: user.name || undefined,
title: input.subject,
message: input.body,
linkUrl: `${baseUrl}/messages`,
linkUrl: getLinkUrl(user),
},
}))
@@ -453,6 +473,127 @@ export const messageRouter = router({
}
}),
/**
* Get detailed recipient list with names and project info.
* Used for the expandable recipient breakdown in the compose sidebar.
*/
listRecipientDetails: adminProcedure
.input(z.object({
recipientType: z.enum(['USER', 'ROLE', 'ROUND_JURY', 'ROUND_APPLICANTS', 'PROGRAM_TEAM', 'ALL']),
recipientFilter: z.any().optional(),
roundId: z.string().optional(),
excludeStates: z.array(z.string()).optional(),
}))
.query(async ({ ctx, input }) => {
// For ROUND_APPLICANTS, return users grouped by project
if (input.recipientType === 'ROUND_APPLICANTS' && input.roundId) {
const stateWhere: Record<string, unknown> = { roundId: input.roundId }
if (input.excludeStates && input.excludeStates.length > 0) {
stateWhere.state = { notIn: input.excludeStates }
}
const projectStates = await ctx.prisma.projectRoundState.findMany({
where: stateWhere,
select: {
state: true,
project: {
select: {
id: true,
title: true,
submittedBy: { select: { id: true, name: true, email: true } },
teamMembers: {
select: { user: { select: { id: true, name: true, email: true } } },
},
},
},
},
})
return {
type: 'projects' as const,
projects: projectStates.map((ps) => ({
id: ps.project.id,
title: ps.project.title,
state: ps.state,
members: [
...(ps.project.submittedBy ? [ps.project.submittedBy] : []),
...ps.project.teamMembers
.map((tm) => tm.user)
.filter((u) => u.id !== ps.project.submittedBy?.id),
],
})),
users: [],
}
}
// For ROUND_JURY, return users grouped by their assignments
if (input.recipientType === 'ROUND_JURY' && input.roundId) {
const assignments = await ctx.prisma.assignment.findMany({
where: { roundId: input.roundId },
select: {
user: { select: { id: true, name: true, email: true } },
project: { select: { id: true, title: true } },
},
})
// Group by user
const userMap = new Map<string, {
user: { id: string; name: string | null; email: string };
projects: { id: string; title: string }[];
}>()
for (const a of assignments) {
const existing = userMap.get(a.user.id)
if (existing) {
existing.projects.push(a.project)
} else {
userMap.set(a.user.id, { user: a.user, projects: [a.project] })
}
}
return {
type: 'jurors' as const,
projects: [],
users: Array.from(userMap.values()).map((entry) => ({
...entry.user,
projectCount: entry.projects.length,
projectNames: entry.projects.map((p) => p.title).slice(0, 5),
})),
}
}
// For all other types, just return the user list
const userIds = await resolveRecipients(
ctx.prisma,
input.recipientType,
input.recipientFilter,
input.roundId,
input.excludeStates
)
if (userIds.length === 0) return { type: 'users' as const, projects: [], users: [] }
const users = await ctx.prisma.user.findMany({
where: { id: { in: userIds } },
select: {
id: true,
name: true,
email: true,
role: true,
teamMemberships: {
select: { project: { select: { id: true, title: true } } },
take: 1,
},
},
})
return {
type: 'users' as const,
projects: [],
users: users.map((u) => ({
id: u.id,
name: u.name,
email: u.email,
role: u.role,
projectName: u.teamMemberships[0]?.project?.title ?? null,
})),
}
}),
/**
* Send a test email to the currently logged-in admin.
*/