feat: audit log clickable links, communication hub recipient details & link options
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
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:
@@ -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 }),
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user