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

@@ -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.
*/