2026-02-14 15:26:42 +01:00
|
|
|
import { z } from 'zod'
|
|
|
|
|
import { TRPCError } from '@trpc/server'
|
|
|
|
|
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
|
|
|
|
import { logAudit } from '@/server/utils/audit'
|
2026-03-03 19:14:41 +01:00
|
|
|
import { sendStyledNotificationEmail, getEmailPreviewHtml } from '@/lib/email'
|
2026-03-05 16:07:52 +01:00
|
|
|
import { sendBatchNotifications } from '../services/notification-sender'
|
|
|
|
|
import type { NotificationItem } from '../services/notification-sender'
|
2026-02-14 15:26:42 +01:00
|
|
|
|
|
|
|
|
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({
|
2026-03-03 19:14:41 +01:00
|
|
|
recipientType: z.enum(['USER', 'ROLE', 'ROUND_JURY', 'ROUND_APPLICANTS', 'PROGRAM_TEAM', 'ALL']),
|
2026-02-14 15:26:42 +01:00
|
|
|
recipientFilter: z.any().optional(),
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
roundId: z.string().optional(),
|
2026-03-06 12:22:01 +01:00
|
|
|
roundIds: z.array(z.string()).optional(),
|
2026-03-06 10:32:03 +01:00
|
|
|
excludeStates: z.array(z.string()).optional(),
|
2026-02-14 15:26:42 +01:00
|
|
|
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(),
|
2026-03-06 11:49:49 +01:00
|
|
|
linkType: z.enum(['NONE', 'MESSAGES', 'LOGIN', 'INVITE']).default('MESSAGES'),
|
2026-02-14 15:26:42 +01:00
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
2026-03-06 12:22:01 +01:00
|
|
|
// 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(
|
2026-02-14 15:26:42 +01:00
|
|
|
ctx.prisma,
|
|
|
|
|
input.recipientType,
|
|
|
|
|
input.recipientFilter,
|
2026-03-06 12:22:01 +01:00
|
|
|
effectiveRoundIds,
|
2026-03-06 10:32:03 +01:00
|
|
|
input.excludeStates
|
2026-02-14 15:26:42 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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,
|
2026-03-06 12:22:01 +01:00
|
|
|
roundId: effectiveRoundIds[0] ?? null,
|
|
|
|
|
metadata: effectiveRoundIds.length > 1 ? { roundIds: effectiveRoundIds } : undefined,
|
2026-02-14 15:26:42 +01:00
|
|
|
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,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
2026-03-05 16:07:52 +01:00
|
|
|
// If not scheduled, deliver immediately for EMAIL channel (batched to avoid overloading SMTP)
|
2026-02-14 15:26:42 +01:00
|
|
|
if (!isScheduled && input.deliveryChannels.includes('EMAIL')) {
|
|
|
|
|
const users = await ctx.prisma.user.findMany({
|
|
|
|
|
where: { id: { in: recipientUserIds } },
|
2026-03-06 11:49:49 +01:00
|
|
|
select: { id: true, name: true, email: true, passwordHash: true, inviteToken: true },
|
2026-02-14 15:26:42 +01:00
|
|
|
})
|
|
|
|
|
|
2026-02-23 14:27:58 +01:00
|
|
|
const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'
|
2026-02-14 15:26:42 +01:00
|
|
|
|
2026-03-06 11:49:49 +01:00
|
|
|
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`
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 16:07:52 +01:00
|
|
|
const items: NotificationItem[] = users.map((user) => ({
|
|
|
|
|
email: user.email,
|
|
|
|
|
name: user.name || '',
|
|
|
|
|
type: 'MESSAGE',
|
|
|
|
|
userId: user.id,
|
|
|
|
|
context: {
|
|
|
|
|
name: user.name || undefined,
|
|
|
|
|
title: input.subject,
|
|
|
|
|
message: input.body,
|
2026-03-06 11:49:49 +01:00
|
|
|
linkUrl: getLinkUrl(user),
|
2026-03-05 16:07:52 +01:00
|
|
|
},
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
})
|
2026-02-14 15:26:42 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 {}
|
|
|
|
|
|
|
|
|
|
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),
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
|
2026-02-14 15:26:42 +01:00
|
|
|
/**
|
|
|
|
|
* 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 {}
|
|
|
|
|
|
|
|
|
|
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 {}
|
|
|
|
|
|
|
|
|
|
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 {}
|
|
|
|
|
|
|
|
|
|
return template
|
|
|
|
|
}),
|
2026-03-03 19:14:41 +01:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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) }
|
|
|
|
|
}),
|
|
|
|
|
|
2026-03-06 10:32:03 +01:00
|
|
|
/**
|
|
|
|
|
* 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(),
|
2026-03-06 12:22:01 +01:00
|
|
|
roundIds: z.array(z.string()).optional(),
|
2026-03-06 10:32:03 +01:00
|
|
|
excludeStates: z.array(z.string()).optional(),
|
|
|
|
|
}))
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
2026-03-06 12:22:01 +01:00
|
|
|
const effectiveRoundIds = input.roundIds?.length
|
|
|
|
|
? input.roundIds
|
|
|
|
|
: input.roundId
|
|
|
|
|
? [input.roundId]
|
|
|
|
|
: []
|
|
|
|
|
|
2026-03-06 10:32:03 +01:00
|
|
|
// For ROUND_APPLICANTS, return a breakdown by project state
|
2026-03-06 12:22:01 +01:00
|
|
|
if (input.recipientType === 'ROUND_APPLICANTS' && effectiveRoundIds.length > 0) {
|
2026-03-06 10:32:03 +01:00
|
|
|
const projectStates = await ctx.prisma.projectRoundState.findMany({
|
2026-03-06 12:22:01 +01:00
|
|
|
where: { roundId: { in: effectiveRoundIds } },
|
2026-03-06 10:32:03 +01:00
|
|
|
select: {
|
|
|
|
|
state: true,
|
|
|
|
|
projectId: true,
|
2026-03-06 12:22:01 +01:00
|
|
|
roundId: true,
|
2026-03-06 10:32:03 +01:00
|
|
|
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
|
2026-03-06 12:22:01 +01:00
|
|
|
const userIds = await resolveRecipientsMultiRound(
|
2026-03-06 10:32:03 +01:00
|
|
|
ctx.prisma,
|
|
|
|
|
input.recipientType,
|
|
|
|
|
input.recipientFilter,
|
2026-03-06 12:22:01 +01:00
|
|
|
effectiveRoundIds,
|
2026-03-06 10:32:03 +01:00
|
|
|
input.excludeStates
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
totalProjects: 0,
|
|
|
|
|
totalApplicants: userIds.length,
|
|
|
|
|
stateBreakdown: {} as Record<string, number>,
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
|
2026-03-06 11:49:49 +01:00
|
|
|
/**
|
|
|
|
|
* 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(),
|
2026-03-06 12:22:01 +01:00
|
|
|
roundIds: z.array(z.string()).optional(),
|
2026-03-06 11:49:49 +01:00
|
|
|
excludeStates: z.array(z.string()).optional(),
|
|
|
|
|
}))
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
2026-03-06 12:22:01 +01:00
|
|
|
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 } }
|
2026-03-06 11:49:49 +01:00
|
|
|
if (input.excludeStates && input.excludeStates.length > 0) {
|
|
|
|
|
stateWhere.state = { notIn: input.excludeStates }
|
|
|
|
|
}
|
|
|
|
|
const projectStates = await ctx.prisma.projectRoundState.findMany({
|
|
|
|
|
where: stateWhere,
|
|
|
|
|
select: {
|
|
|
|
|
state: true,
|
2026-03-06 12:22:01 +01:00
|
|
|
roundId: true,
|
|
|
|
|
round: { select: { id: true, name: true, competition: { select: { name: true } } } },
|
2026-03-06 11:49:49 +01:00
|
|
|
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,
|
2026-03-06 12:22:01 +01:00
|
|
|
roundId: ps.roundId,
|
|
|
|
|
roundName: ps.round ? `${ps.round.competition?.name ? ps.round.competition.name + ' - ' : ''}${ps.round.name}` : undefined,
|
2026-03-06 11:49:49 +01:00
|
|
|
members: [
|
|
|
|
|
...(ps.project.submittedBy ? [ps.project.submittedBy] : []),
|
|
|
|
|
...ps.project.teamMembers
|
|
|
|
|
.map((tm) => tm.user)
|
|
|
|
|
.filter((u) => u.id !== ps.project.submittedBy?.id),
|
|
|
|
|
],
|
|
|
|
|
})),
|
|
|
|
|
users: [],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 12:22:01 +01:00
|
|
|
// For ROUND_JURY, return users grouped by their assignments (and round if multi-round)
|
|
|
|
|
if (input.recipientType === 'ROUND_JURY' && effectiveRoundIds.length > 0) {
|
2026-03-06 11:49:49 +01:00
|
|
|
const assignments = await ctx.prisma.assignment.findMany({
|
2026-03-06 12:22:01 +01:00
|
|
|
where: { roundId: { in: effectiveRoundIds } },
|
2026-03-06 11:49:49 +01:00
|
|
|
select: {
|
2026-03-06 12:22:01 +01:00
|
|
|
roundId: true,
|
|
|
|
|
round: { select: { id: true, name: true, competition: { select: { name: true } } } },
|
2026-03-06 11:49:49 +01:00
|
|
|
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 }[];
|
2026-03-06 12:22:01 +01:00
|
|
|
rounds: Set<string>;
|
|
|
|
|
roundNames: string[];
|
2026-03-06 11:49:49 +01:00
|
|
|
}>()
|
|
|
|
|
for (const a of assignments) {
|
|
|
|
|
const existing = userMap.get(a.user.id)
|
2026-03-06 12:22:01 +01:00
|
|
|
const roundLabel = a.round ? `${a.round.competition?.name ? a.round.competition.name + ' - ' : ''}${a.round.name}` : a.roundId
|
2026-03-06 11:49:49 +01:00
|
|
|
if (existing) {
|
|
|
|
|
existing.projects.push(a.project)
|
2026-03-06 12:22:01 +01:00
|
|
|
if (!existing.rounds.has(a.roundId)) {
|
|
|
|
|
existing.rounds.add(a.roundId)
|
|
|
|
|
existing.roundNames.push(roundLabel)
|
|
|
|
|
}
|
2026-03-06 11:49:49 +01:00
|
|
|
} else {
|
2026-03-06 12:22:01 +01:00
|
|
|
userMap.set(a.user.id, {
|
|
|
|
|
user: a.user,
|
|
|
|
|
projects: [a.project],
|
|
|
|
|
rounds: new Set([a.roundId]),
|
|
|
|
|
roundNames: [roundLabel],
|
|
|
|
|
})
|
2026-03-06 11:49:49 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
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),
|
2026-03-06 12:22:01 +01:00
|
|
|
roundNames: entry.roundNames,
|
2026-03-06 11:49:49 +01:00
|
|
|
})),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// For all other types, just return the user list
|
2026-03-06 12:22:01 +01:00
|
|
|
const userIds = await resolveRecipientsMultiRound(
|
2026-03-06 11:49:49 +01:00
|
|
|
ctx.prisma,
|
|
|
|
|
input.recipientType,
|
|
|
|
|
input.recipientFilter,
|
2026-03-06 12:22:01 +01:00
|
|
|
effectiveRoundIds,
|
2026-03-06 11:49:49 +01:00
|
|
|
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,
|
|
|
|
|
})),
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
|
2026-03-03 19:14:41 +01:00
|
|
|
/**
|
|
|
|
|
* 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 }) => {
|
|
|
|
|
await sendStyledNotificationEmail(
|
|
|
|
|
ctx.user.email,
|
|
|
|
|
ctx.user.name || '',
|
|
|
|
|
'MESSAGE',
|
|
|
|
|
{
|
|
|
|
|
title: input.subject,
|
|
|
|
|
message: input.body,
|
|
|
|
|
linkUrl: '/admin/messages',
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
return { sent: true, to: ctx.user.email }
|
|
|
|
|
}),
|
2026-02-14 15:26:42 +01:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// =============================================================================
|
2026-03-06 12:22:01 +01:00
|
|
|
// Helper: Resolve recipient user IDs based on recipientType (multi-round)
|
2026-02-14 15:26:42 +01:00
|
|
|
// =============================================================================
|
|
|
|
|
|
|
|
|
|
type PrismaClient = Parameters<Parameters<typeof adminProcedure.mutation>[0]>[0]['ctx']['prisma']
|
|
|
|
|
|
2026-03-06 12:22:01 +01:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-14 15:26:42 +01:00
|
|
|
async function resolveRecipients(
|
|
|
|
|
prisma: PrismaClient,
|
|
|
|
|
recipientType: string,
|
|
|
|
|
recipientFilter: unknown,
|
2026-03-06 10:32:03 +01:00
|
|
|
roundId?: string,
|
|
|
|
|
excludeStates?: string[]
|
2026-02-14 15:26:42 +01:00
|
|
|
): 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)
|
|
|
|
|
}
|
|
|
|
|
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
case 'ROUND_JURY': {
|
|
|
|
|
const targetRoundId = roundId || (filter?.roundId as string)
|
|
|
|
|
if (!targetRoundId) return []
|
2026-02-14 15:26:42 +01:00
|
|
|
const assignments = await prisma.assignment.findMany({
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
where: { roundId: targetRoundId },
|
2026-02-14 15:26:42 +01:00
|
|
|
select: { userId: true },
|
|
|
|
|
distinct: ['userId'],
|
|
|
|
|
})
|
|
|
|
|
return assignments.map((a) => a.userId)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 19:14:41 +01:00
|
|
|
case 'ROUND_APPLICANTS': {
|
|
|
|
|
const targetRoundId = roundId || (filter?.roundId as string)
|
|
|
|
|
if (!targetRoundId) return []
|
2026-03-06 10:32:03 +01:00
|
|
|
// 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 }
|
|
|
|
|
}
|
2026-03-03 19:14:41 +01:00
|
|
|
const projectStates = await prisma.projectRoundState.findMany({
|
2026-03-06 10:32:03 +01:00
|
|
|
where: stateWhere,
|
2026-03-03 19:14:41 +01:00
|
|
|
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]
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-14 15:26:42 +01:00
|
|
|
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 []
|
|
|
|
|
}
|
|
|
|
|
}
|