Files
MOPC-Portal/src/server/routers/message.ts

854 lines
28 KiB
TypeScript
Raw Normal View History

import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, protectedProcedure, adminProcedure } from '../trpc'
import { logAudit } from '@/server/utils/audit'
import { sendStyledNotificationEmail, getEmailPreviewHtml } from '@/lib/email'
import { sendBatchNotifications } from '../services/notification-sender'
import type { NotificationItem } from '../services/notification-sender'
export const messageRouter = router({
/**
* Send a message to recipients.
* Resolves recipient list based on recipientType and delivers via specified channels.
*/
send: adminProcedure
.input(
z.object({
recipientType: z.enum(['USER', 'ROLE', 'ROUND_JURY', 'ROUND_APPLICANTS', 'PROGRAM_TEAM', 'ALL']),
recipientFilter: z.any().optional(),
roundId: z.string().optional(),
roundIds: z.array(z.string()).optional(),
excludeStates: z.array(z.string()).optional(),
subject: z.string().min(1).max(500),
body: z.string().min(1),
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 }) => {
// Normalize: prefer roundIds array, fall back to single roundId
const effectiveRoundIds = input.roundIds?.length
? input.roundIds
: input.roundId
? [input.roundId]
: []
// Resolve recipients based on type (union across all selected rounds)
const recipientUserIds = await resolveRecipientsMultiRound(
ctx.prisma,
input.recipientType,
input.recipientFilter,
effectiveRoundIds,
input.excludeStates
)
if (recipientUserIds.length === 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'No recipients found for the given criteria',
})
}
const isScheduled = !!input.scheduledAt
const now = new Date()
// Create message
const message = await ctx.prisma.message.create({
data: {
senderId: ctx.user.id,
recipientType: input.recipientType,
recipientFilter: input.recipientFilter ?? undefined,
roundId: effectiveRoundIds[0] ?? null,
metadata: effectiveRoundIds.length > 1 ? { roundIds: effectiveRoundIds } : undefined,
templateId: input.templateId,
subject: input.subject,
body: input.body,
deliveryChannels: input.deliveryChannels,
scheduledAt: input.scheduledAt ? new Date(input.scheduledAt) : undefined,
sentAt: isScheduled ? undefined : now,
recipients: {
create: recipientUserIds.flatMap((userId) =>
input.deliveryChannels.map((channel) => ({
userId,
channel,
}))
),
},
},
include: {
recipients: true,
},
})
// If not scheduled, deliver immediately for EMAIL channel (batched to avoid overloading SMTP)
if (!isScheduled && input.deliveryChannels.includes('EMAIL')) {
const users = await ctx.prisma.user.findMany({
where: { id: { in: recipientUserIds } },
select: {
id: true, name: true, email: true, passwordHash: true, inviteToken: true,
teamMemberships: {
select: { project: { select: { title: true } } },
take: 1,
},
},
})
// Fetch round & program context for template variable substitution
let roundName = ''
let programName = ''
let deadline = ''
if (effectiveRoundIds.length > 0) {
const rounds = await ctx.prisma.round.findMany({
where: { id: { in: effectiveRoundIds } },
select: {
name: true,
windowCloseAt: true,
competition: {
select: {
program: { select: { name: true } },
},
},
},
})
if (rounds.length > 0) {
roundName = rounds.map((r) => r.name).join(', ')
programName = rounds[0].competition?.program?.name ?? ''
// Use the earliest upcoming deadline across selected rounds
const deadlines = rounds
.map((r) => r.windowCloseAt)
.filter((d): d is Date => d !== null)
.sort((a, b) => a.getTime() - b.getTime())
if (deadlines.length > 0) {
deadline = deadlines[0].toLocaleDateString('en-GB', {
day: 'numeric', month: 'long', year: 'numeric',
})
}
}
}
/** Substitute template variables in a string for a specific user */
function substituteVariables(
text: string,
user: { name: string | null; teamMemberships: { project: { title: string } }[] }
): string {
return text
.replace(/\{\{userName\}\}/g, user.name || '')
.replace(/\{\{projectName\}\}/g, user.teamMemberships[0]?.project?.title || '')
.replace(/\{\{roundName\}\}/g, roundName)
.replace(/\{\{programName\}\}/g, programName)
.replace(/\{\{deadline\}\}/g, deadline)
}
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 || '',
type: 'MESSAGE',
userId: user.id,
context: {
name: user.name || undefined,
title: substituteVariables(input.subject, user),
message: substituteVariables(input.body, user),
linkUrl: getLinkUrl(user),
},
}))
// Fire-and-forget: batch send in background so the mutation returns quickly
sendBatchNotifications(items).then((result) => {
console.log(`[Message] Batch ${result.batchId}: ${result.sent} sent, ${result.failed} failed`)
}).catch((err) => {
console.error('[Message] Batch send error:', err)
})
}
try {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'SEND_MESSAGE',
entityType: 'Message',
entityId: message.id,
detailsJson: {
recipientType: input.recipientType,
recipientCount: recipientUserIds.length,
channels: input.deliveryChannels,
scheduled: isScheduled,
},
})
} catch (err) {
console.error('[Message] Audit log failed:', err)
}
return {
...message,
recipientCount: recipientUserIds.length,
}
}),
/**
* Get the current user's inbox (messages sent to them).
*/
inbox: protectedProcedure
.input(
z.object({
page: z.number().int().min(1).default(1),
pageSize: z.number().int().min(1).max(100).default(20),
}).optional()
)
.query(async ({ ctx, input }) => {
const page = input?.page ?? 1
const pageSize = input?.pageSize ?? 20
const skip = (page - 1) * pageSize
const [items, total] = await Promise.all([
ctx.prisma.messageRecipient.findMany({
where: { userId: ctx.user.id },
include: {
message: {
include: {
sender: {
select: { id: true, name: true, email: true },
},
},
},
},
orderBy: { message: { createdAt: 'desc' } },
skip,
take: pageSize,
}),
ctx.prisma.messageRecipient.count({
where: { userId: ctx.user.id },
}),
])
return {
items,
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
}
}),
/**
* Mark a message as read.
*/
markRead: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const recipient = await ctx.prisma.messageRecipient.findUnique({
where: { id: input.id },
})
if (!recipient || recipient.userId !== ctx.user.id) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Message not found',
})
}
return ctx.prisma.messageRecipient.update({
where: { id: input.id },
data: {
isRead: true,
readAt: new Date(),
},
})
}),
Admin platform audit: fix bugs, harden backend, add auto-refresh, clean dead code Phase 1 — Critical bugs: - Fix deliberation participant selection (wire jury group query) - Fix reports "By Round" tab (inline content instead of 404 route) - Fix messages "Sent History" (add message.sent procedure, wire tab) - Add missing fields to competition award form (criteriaText, maxRankedPicks) - Wire LiveControlPanel buttons (cursor, voting, scores) - Fix ResultLockControls empty snapshot (fetch actual data before lock) - Fix SubmissionWindowManager losing fields on edit Phase 2 — Backend fixes: - Remove write-in-query from specialAward.get - Fix award eligibility job overwriting manual shortlist overrides - Fix filtering startJob deleting all prior results (defer cleanup to post-success) - Tighten access control: protectedProcedure → adminProcedure on 8 procedures - Add audit logging to deliberation mutations - Add FINALIST/SEMIFINALIST delete guard on project.delete/bulkDelete Phase 3 — Auto-refresh: - Add refetchInterval to 15+ admin pages/components (10s–30s) - Fix AI job polling: derive speed from job status for all viewers Phase 4 — Dead code cleanup: - Delete unused command-palette, pdf-report, admin-page-transition - Remove dead subItems sidebar code, unused GripVertical import - Replace redundant isGenerating state with mutation.isPending - Add Role column to jury members table - Remove misleading manual mentor assignment stub Phase 5 — UX improvements: - Fix rounds page single-competition assumption (add selector) - Remove raw UUID fallback in deliberation config - Fix programs page "Stage" → "Round" terminology Phase 6 — Backend hardening: - Complete logAudit calls (add prisma, ipAddress, userAgent) - Batch analytics queries (fix N+1 in getCrossRoundComparison, getYearOverYear) - Batch user.bulkCreate writes (assignments, jury memberships, intents) - Remove any casts from deliberation service (typed PrismaClient + TransactionClient) - Fix stale DeliberationStatus enum values blocking build 40 files changed, 1010 insertions(+), 612 deletions(-) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 08:20:13 +01:00
/**
* Get messages sent by the current admin user.
*/
sent: adminProcedure
.input(
z.object({
page: z.number().int().min(1).default(1),
pageSize: z.number().int().min(1).max(100).default(20),
}).optional()
)
.query(async ({ ctx, input }) => {
const page = input?.page ?? 1
const pageSize = input?.pageSize ?? 20
const skip = (page - 1) * pageSize
const [items, total] = await Promise.all([
ctx.prisma.message.findMany({
where: { senderId: ctx.user.id },
include: {
_count: { select: { recipients: true } },
},
orderBy: { createdAt: 'desc' },
skip,
take: pageSize,
}),
ctx.prisma.message.count({
where: { senderId: ctx.user.id },
}),
])
return {
items,
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
}
}),
/**
* Get unread message count for the current user.
*/
getUnreadCount: protectedProcedure.query(async ({ ctx }) => {
const count = await ctx.prisma.messageRecipient.count({
where: {
userId: ctx.user.id,
isRead: false,
},
})
return { count }
}),
// =========================================================================
// Template procedures
// =========================================================================
/**
* List all message templates.
*/
listTemplates: adminProcedure
.input(
z.object({
category: z.string().optional(),
activeOnly: z.boolean().default(true),
}).optional()
)
.query(async ({ ctx, input }) => {
return ctx.prisma.messageTemplate.findMany({
where: {
...(input?.category ? { category: input.category } : {}),
...(input?.activeOnly !== false ? { isActive: true } : {}),
},
orderBy: { createdAt: 'desc' },
})
}),
/**
* Create a message template.
*/
createTemplate: adminProcedure
.input(
z.object({
name: z.string().min(1).max(200),
category: z.string().min(1).max(100),
subject: z.string().min(1).max(500),
body: z.string().min(1),
variables: z.any().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const template = await ctx.prisma.messageTemplate.create({
data: {
name: input.name,
category: input.category,
subject: input.subject,
body: input.body,
variables: input.variables ?? undefined,
createdBy: ctx.user.id,
},
})
try {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'CREATE_MESSAGE_TEMPLATE',
entityType: 'MessageTemplate',
entityId: template.id,
detailsJson: { name: input.name, category: input.category },
})
} catch (err) {
console.error('[Message] Audit log failed:', err)
}
return template
}),
/**
* Update a message template.
*/
updateTemplate: adminProcedure
.input(
z.object({
id: z.string(),
name: z.string().min(1).max(200).optional(),
category: z.string().min(1).max(100).optional(),
subject: z.string().min(1).max(500).optional(),
body: z.string().min(1).optional(),
variables: z.any().optional(),
isActive: z.boolean().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input
const template = await ctx.prisma.messageTemplate.update({
where: { id },
data: {
...(data.name !== undefined ? { name: data.name } : {}),
...(data.category !== undefined ? { category: data.category } : {}),
...(data.subject !== undefined ? { subject: data.subject } : {}),
...(data.body !== undefined ? { body: data.body } : {}),
...(data.variables !== undefined ? { variables: data.variables } : {}),
...(data.isActive !== undefined ? { isActive: data.isActive } : {}),
},
})
try {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'UPDATE_MESSAGE_TEMPLATE',
entityType: 'MessageTemplate',
entityId: id,
detailsJson: { updatedFields: Object.keys(data) },
})
} catch (err) {
console.error('[Message] Audit log failed:', err)
}
return template
}),
/**
* Soft-delete a message template (set isActive=false).
*/
deleteTemplate: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const template = await ctx.prisma.messageTemplate.update({
where: { id: input.id },
data: { isActive: false },
})
try {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'DELETE_MESSAGE_TEMPLATE',
entityType: 'MessageTemplate',
entityId: input.id,
})
} catch (err) {
console.error('[Message] Audit log failed:', err)
}
return template
}),
/**
* Preview styled email HTML for admin compose dialog.
*/
previewEmail: adminProcedure
.input(z.object({ subject: z.string(), body: z.string() }))
.query(({ input }) => {
return { html: getEmailPreviewHtml(input.subject, input.body) }
}),
/**
* Preview recipient counts for a given recipient type + filters.
* Returns project breakdown by state for ROUND_APPLICANTS, or total user count for others.
*/
previewRecipients: adminProcedure
.input(z.object({
recipientType: z.enum(['USER', 'ROLE', 'ROUND_JURY', 'ROUND_APPLICANTS', 'PROGRAM_TEAM', 'ALL']),
recipientFilter: z.any().optional(),
roundId: z.string().optional(),
roundIds: z.array(z.string()).optional(),
excludeStates: z.array(z.string()).optional(),
}))
.query(async ({ ctx, input }) => {
const effectiveRoundIds = input.roundIds?.length
? input.roundIds
: input.roundId
? [input.roundId]
: []
// For ROUND_APPLICANTS, return a breakdown by project state
if (input.recipientType === 'ROUND_APPLICANTS' && effectiveRoundIds.length > 0) {
const projectStates = await ctx.prisma.projectRoundState.findMany({
where: { roundId: { in: effectiveRoundIds } },
select: {
state: true,
projectId: true,
roundId: true,
project: {
select: {
submittedByUserId: true,
teamMembers: { select: { userId: true } },
},
},
},
})
// Count projects per state
const stateBreakdown: Record<string, number> = {}
for (const ps of projectStates) {
stateBreakdown[ps.state] = (stateBreakdown[ps.state] || 0) + 1
}
// Compute total unique users respecting exclusions
const excludeSet = new Set(input.excludeStates ?? [])
const includedUserIds = new Set<string>()
for (const ps of projectStates) {
if (excludeSet.has(ps.state)) continue
if (ps.project.submittedByUserId) includedUserIds.add(ps.project.submittedByUserId)
for (const tm of ps.project.teamMembers) includedUserIds.add(tm.userId)
}
return {
totalProjects: projectStates.length,
totalApplicants: includedUserIds.size,
stateBreakdown,
}
}
// For other recipient types, just count resolved users
const userIds = await resolveRecipientsMultiRound(
ctx.prisma,
input.recipientType,
input.recipientFilter,
effectiveRoundIds,
input.excludeStates
)
return {
totalProjects: 0,
totalApplicants: userIds.length,
stateBreakdown: {} as Record<string, number>,
}
}),
/**
* 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(),
roundIds: z.array(z.string()).optional(),
excludeStates: z.array(z.string()).optional(),
}))
.query(async ({ ctx, input }) => {
const effectiveRoundIds = input.roundIds?.length
? input.roundIds
: input.roundId
? [input.roundId]
: []
// For ROUND_APPLICANTS, return users grouped by project (and round if multi-round)
if (input.recipientType === 'ROUND_APPLICANTS' && effectiveRoundIds.length > 0) {
const stateWhere: Record<string, unknown> = { roundId: { in: effectiveRoundIds } }
if (input.excludeStates && input.excludeStates.length > 0) {
stateWhere.state = { notIn: input.excludeStates }
}
const projectStates = await ctx.prisma.projectRoundState.findMany({
where: stateWhere,
select: {
state: true,
roundId: true,
round: { select: { id: true, name: true, competition: { select: { name: 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,
roundId: ps.roundId,
roundName: ps.round ? `${ps.round.competition?.name ? ps.round.competition.name + ' - ' : ''}${ps.round.name}` : undefined,
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 (and round if multi-round)
if (input.recipientType === 'ROUND_JURY' && effectiveRoundIds.length > 0) {
const assignments = await ctx.prisma.assignment.findMany({
where: { roundId: { in: effectiveRoundIds } },
select: {
roundId: true,
round: { select: { id: true, name: true, competition: { select: { name: true } } } },
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 }[];
rounds: Set<string>;
roundNames: string[];
}>()
for (const a of assignments) {
const existing = userMap.get(a.user.id)
const roundLabel = a.round ? `${a.round.competition?.name ? a.round.competition.name + ' - ' : ''}${a.round.name}` : a.roundId
if (existing) {
existing.projects.push(a.project)
if (!existing.rounds.has(a.roundId)) {
existing.rounds.add(a.roundId)
existing.roundNames.push(roundLabel)
}
} else {
userMap.set(a.user.id, {
user: a.user,
projects: [a.project],
rounds: new Set([a.roundId]),
roundNames: [roundLabel],
})
}
}
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),
roundNames: entry.roundNames,
})),
}
}
// For all other types, just return the user list
const userIds = await resolveRecipientsMultiRound(
ctx.prisma,
input.recipientType,
input.recipientFilter,
effectiveRoundIds,
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.
*/
sendTest: adminProcedure
.input(z.object({ subject: z.string(), body: z.string() }))
.mutation(async ({ ctx, input }) => {
const userName = ctx.user.name || ''
/** Substitute template variables with admin name + placeholder values for test emails */
function substituteTestVariables(text: string): string {
return text
.replace(/\{\{userName\}\}/g, userName)
.replace(/\{\{projectName\}\}/g, '[Project Name]')
.replace(/\{\{roundName\}\}/g, '[Round Name]')
.replace(/\{\{programName\}\}/g, '[Program Name]')
.replace(/\{\{deadline\}\}/g, '[Deadline]')
}
await sendStyledNotificationEmail(
ctx.user.email,
userName,
'MESSAGE',
{
title: substituteTestVariables(input.subject),
message: substituteTestVariables(input.body),
linkUrl: '/admin/messages',
}
)
return { sent: true, to: ctx.user.email }
}),
})
// =============================================================================
// Helper: Resolve recipient user IDs based on recipientType (multi-round)
// =============================================================================
type PrismaClient = Parameters<Parameters<typeof adminProcedure.mutation>[0]>[0]['ctx']['prisma']
async function resolveRecipientsMultiRound(
prisma: PrismaClient,
recipientType: string,
recipientFilter: unknown,
roundIds: string[],
excludeStates?: string[]
): Promise<string[]> {
// For round-based types, union across all selected rounds
if ((recipientType === 'ROUND_JURY' || recipientType === 'ROUND_APPLICANTS') && roundIds.length > 0) {
const allUserIds = new Set<string>()
for (const rid of roundIds) {
const ids = await resolveRecipients(prisma, recipientType, recipientFilter, rid, excludeStates)
for (const id of ids) allUserIds.add(id)
}
return [...allUserIds]
}
// For non-round types, delegate to single-round resolver
return resolveRecipients(prisma, recipientType, recipientFilter, roundIds[0], excludeStates)
}
async function resolveRecipients(
prisma: PrismaClient,
recipientType: string,
recipientFilter: unknown,
roundId?: string,
excludeStates?: string[]
): Promise<string[]> {
const filter = recipientFilter as Record<string, unknown> | undefined
switch (recipientType) {
case 'USER': {
const userId = filter?.userId as string
if (!userId) return []
const user = await prisma.user.findUnique({
where: { id: userId },
select: { id: true },
})
return user ? [user.id] : []
}
case 'ROLE': {
const role = filter?.role as string
if (!role) return []
const users = await prisma.user.findMany({
where: { role: role as any, status: 'ACTIVE' },
select: { id: true },
})
return users.map((u) => u.id)
}
case 'ROUND_JURY': {
const targetRoundId = roundId || (filter?.roundId as string)
if (!targetRoundId) return []
const assignments = await prisma.assignment.findMany({
where: { roundId: targetRoundId },
select: { userId: true },
distinct: ['userId'],
})
return assignments.map((a) => a.userId)
}
case 'ROUND_APPLICANTS': {
const targetRoundId = roundId || (filter?.roundId as string)
if (!targetRoundId) return []
// Get all projects in this round, optionally excluding certain states
const stateWhere: Record<string, unknown> = { roundId: targetRoundId }
if (excludeStates && excludeStates.length > 0) {
stateWhere.state = { notIn: excludeStates }
}
const projectStates = await prisma.projectRoundState.findMany({
where: stateWhere,
select: { projectId: true },
})
const projectIds = projectStates.map((ps) => ps.projectId)
if (projectIds.length === 0) return []
// Get team members + submittedByUserId
const [teamMembers, projects] = await Promise.all([
prisma.teamMember.findMany({
where: { projectId: { in: projectIds } },
select: { userId: true },
}),
prisma.project.findMany({
where: { id: { in: projectIds } },
select: { submittedByUserId: true },
}),
])
const userIds = new Set<string>()
for (const tm of teamMembers) userIds.add(tm.userId)
for (const p of projects) {
if (p.submittedByUserId) userIds.add(p.submittedByUserId)
}
return [...userIds]
}
case 'PROGRAM_TEAM': {
const programId = filter?.programId as string
if (!programId) return []
const projects = await prisma.project.findMany({
where: { programId },
select: { submittedByUserId: true },
})
const ids = new Set(projects.map((p) => p.submittedByUserId).filter(Boolean) as string[])
return [...ids]
}
case 'ALL': {
const users = await prisma.user.findMany({
where: { status: 'ACTIVE' },
select: { id: true },
})
return users.map((u) => u.id)
}
default:
return []
}
}