From d4c946470a35cf6d08fd905b636f041bf43e7c7e Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 6 Mar 2026 11:49:49 +0100 Subject: [PATCH] feat: audit log clickable links, communication hub recipient details & link options - 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 --- src/app/(admin)/admin/audit/page.tsx | 106 ++++++++--- src/app/(admin)/admin/messages/page.tsx | 227 ++++++++++++++++++++++++ src/server/routers/audit.ts | 2 +- src/server/routers/message.ts | 145 ++++++++++++++- 4 files changed, 455 insertions(+), 25 deletions(-) diff --git a/src/app/(admin)/admin/audit/page.tsx b/src/app/(admin)/admin/audit/page.tsx index 79ad642..dcb2663 100644 --- a/src/app/(admin)/admin/audit/page.tsx +++ b/src/app/(admin)/admin/audit/page.tsx @@ -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 -
-

- {log.user?.name || 'System'} -

-

- {log.user?.email} -

-
+ {log.userId ? ( + e.stopPropagation()} + > +

+ {log.user?.name || 'System'} +

+

+ {log.user?.email} +

+ + ) : ( +
+

System

+
+ )}

{log.entityType}

- {log.entityId && ( -

- {log.entityId.slice(0, 8)}... -

- )} + {log.entityId && (() => { + const link = getEntityLink(log.entityType, log.entityId) + return link ? ( + e.stopPropagation()} + > + {log.entityId.slice(0, 8)}... + + ) : ( +

+ {log.entityId.slice(0, 8)}... +

+ ) + })()}
@@ -601,9 +643,18 @@ export default function AuditLogPage() {

Entity ID

-

- {log.entityId || 'N/A'} -

+ {log.entityId ? (() => { + const link = getEntityLink(log.entityType, log.entityId) + return link ? ( + e.stopPropagation()}> + {log.entityId} + + ) : ( +

{log.entityId}

+ ) + })() : ( +

N/A

+ )}

@@ -700,12 +751,23 @@ export default function AuditLogPage() { {formatDate(log.timestamp)}

-
- - - {log.user?.name || 'System'} - -
+ {log.userId ? ( + e.stopPropagation()} + > + + + {log.user?.name || 'System'} + + + ) : ( +
+ + System +
+ )} {isExpanded && ( diff --git a/src/app/(admin)/admin/messages/page.tsx b/src/app/(admin)/admin/messages/page.tsx index 9470976..afc6c1f 100644 --- a/src/app/(admin)/admin/messages/page.tsx +++ b/src/app/(admin)/admin/messages/page.tsx @@ -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(['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() { + {/* Link in Email */} + {deliveryChannels.includes('EMAIL') && ( +
+ + +

+ {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.'} +

+
+ )} + {/* Schedule */}
@@ -748,6 +815,15 @@ export default function MessagesPage() { )}
)} + + {/* Expandable recipient details */} +
) : (

@@ -991,6 +1067,11 @@ export default function MessagesPage() { Scheduled: {formatDate(new Date(scheduledAt))} )} + {deliveryChannels.includes('EMAIL') && ( +

+ Link: {linkType === 'NONE' ? 'None' : linkType === 'MESSAGES' ? 'Messages inbox' : linkType === 'LOGIN' ? 'Login page' : 'Invite / Accept link'} +

+ )} @@ -1027,3 +1108,149 @@ export default function MessagesPage() { ) } + +// ============================================================================= +// 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 ( + + + {open ? : } + View Recipients + + +
+ {isLoading ? ( +
+ {[1, 2, 3].map((i) => ( + + ))} +
+ ) : !data || (data.projects.length === 0 && data.users.length === 0) ? ( +

No recipients found.

+ ) : data.type === 'projects' ? ( + // ROUND_APPLICANTS: projects with their members + data.projects.map((project) => ( + + )) + ) : data.type === 'jurors' ? ( + // ROUND_JURY: jurors with project counts + data.users.map((user) => ( +
+
+ + {user.name || user.email} + + {user.projectCount !== undefined && ( + + {user.projectCount} project{user.projectCount !== 1 ? 's' : ''} assigned + + )} +
+
+ )) + ) : ( + // ALL, ROLE, USER, PROGRAM_TEAM: plain user list + data.users.map((user) => ( +
+
+ + {user.name || user.email} + + {user.projectName && ( + {user.projectName} + )} +
+ {user.role && ( + + {user.role.replace(/_/g, ' ')} + + )} +
+ )) + )} +
+
+
+ ) +} + +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 ( + + +
+ {open ? : } + {project.title} +
+
+ + {STATE_LABELS[project.state] || project.state} + + {project.members.length} +
+
+ +
+ {project.members.map((member) => ( + + {member.name || member.email} + + ))} +
+
+
+ ) +} diff --git a/src/server/routers/audit.ts b/src/server/routers/audit.ts index 899972c..8d3c541 100644 --- a/src/server/routers/audit.ts +++ b/src/server/routers/audit.ts @@ -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 }), diff --git a/src/server/routers/message.ts b/src/server/routers/message.ts index 3c37b5c..510e037 100644 --- a/src/server/routers/message.ts +++ b/src/server/routers/message.ts @@ -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 = { 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() + 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. */