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

2424 lines
79 KiB
TypeScript
Raw Normal View History

import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import type { Prisma } from '@prisma/client'
import { UserRole } from '@prisma/client'
import { router, protectedProcedure, adminProcedure, superAdminProcedure, publicProcedure } from '../trpc'
import { sendInvitationEmail, sendJuryInvitationEmail, sendMagicLinkEmail, sendMentorOnboardingEmail, sendPasswordResetEmail } from '@/lib/email'
import { hashPassword, validatePassword } from '@/lib/password'
import { attachAvatarUrls, getUserAvatarUrl } from '@/server/utils/avatar-url'
import { logAudit } from '@/server/utils/audit'
import { generateInviteToken, getInviteExpiryHours, getInviteExpiryMs } from '@/server/utils/invite'
export const userRouter = router({
/**
* Get current user profile
*/
me: protectedProcedure.query(async ({ ctx }) => {
const user = await ctx.prisma.user.findUnique({
where: { id: ctx.user.id },
select: {
id: true,
email: true,
name: true,
role: true,
status: true,
expertiseTags: true,
metadataJson: true,
phoneNumber: true,
country: true,
nationality: true,
institution: true,
bio: true,
notificationPreference: true,
profileImageKey: true,
Implement 15 platform features: digest, availability, templates, comparison, live voting SSE, file versioning, mentorship, messaging, analytics, drafts, webhooks, peer review, audit enhancements, i18n Features implemented: - F1: Email digest notifications with cron endpoint and per-user frequency - F2: Jury availability windows and workload preferences in smart assignment - F3: Round templates with save-from-round and CRUD management - F4: Side-by-side project comparison view for jury members - F5: Real-time voting dashboard with Server-Sent Events (SSE) - F6: Live voting UX: QR codes, audience voting, tie-breaking, score animations - F7: File versioning, inline preview, bulk download with presigned URLs - F8: Mentor dashboard: milestones, private notes, activity tracking - F9: Communication hub with broadcasts, templates, and recipient targeting - F10: Advanced analytics: cross-round comparison, juror consistency, diversity metrics, PDF export - F11: Applicant draft saving with magic link resume and cron cleanup - F12: Webhook integration layer with HMAC signing, retry, and delivery logs - F13: Peer review discussions with anonymized scores and threaded comments - F14: Audit log enhancements: before/after diffs, session grouping, anomaly detection, retention - F15: i18n foundation with next-intl (EN/FR), cookie-based locale, language switcher Schema: 12 new models, field additions to User, Project, ProjectFile, LiveVotingSession, LiveVote, MentorAssignment, AuditLog, Program New routers: roundTemplate, message, webhook (registered in _app.ts) New services: email-digest, webhook-dispatcher New cron endpoints: /api/cron/digest, /api/cron/draft-cleanup, /api/cron/audit-cleanup New API routes: /api/live-voting/stream (SSE), /api/files/bulk-download All features are admin-configurable via SystemSettings or per-model settingsJson fields. Docker build verified successfully. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 23:31:41 +01:00
digestFrequency: true,
availabilityJson: true,
preferredWorkload: true,
createdAt: true,
lastLoginAt: true,
},
})
if (!user) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User session is stale. Please log out and log back in.',
})
}
return user
}),
/**
* Validate an invitation token (public, no auth required)
*/
validateInviteToken: publicProcedure
.input(z.object({ token: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const user = await ctx.prisma.user.findUnique({
where: { inviteToken: input.token },
select: { id: true, name: true, email: true, role: true, status: true, inviteTokenExpiresAt: true },
})
if (!user) {
return { valid: false, error: 'INVALID_TOKEN' as const }
}
if (user.status !== 'INVITED') {
return { valid: false, error: 'ALREADY_ACCEPTED' as const }
}
if (user.inviteTokenExpiresAt && user.inviteTokenExpiresAt < new Date()) {
return { valid: false, error: 'EXPIRED_TOKEN' as const }
}
// Check if user belongs to a team (was invited as team member)
const teamMembership = await ctx.prisma.teamMember.findFirst({
where: { userId: user.id },
select: {
role: true,
project: { select: { title: true, teamName: true } },
},
})
return {
valid: true,
user: { name: user.name, email: user.email, role: user.role },
team: teamMembership
? { projectTitle: teamMembership.project.title, teamName: teamMembership.project.teamName }
: null,
}
}),
/**
* Update current user profile
*/
updateProfile: protectedProcedure
.input(
z.object({
name: z.string().min(1).max(255).optional(),
bio: z.string().max(1000).optional(),
phoneNumber: z.string().max(20).optional().nullable(),
nationality: z.string().max(100).optional().nullable(),
institution: z.string().max(255).optional().nullable(),
country: z.string().max(100).optional(),
notificationPreference: z.enum(['EMAIL', 'WHATSAPP', 'BOTH', 'NONE']).optional(),
expertiseTags: z.array(z.string()).max(15).optional(),
Implement 15 platform features: digest, availability, templates, comparison, live voting SSE, file versioning, mentorship, messaging, analytics, drafts, webhooks, peer review, audit enhancements, i18n Features implemented: - F1: Email digest notifications with cron endpoint and per-user frequency - F2: Jury availability windows and workload preferences in smart assignment - F3: Round templates with save-from-round and CRUD management - F4: Side-by-side project comparison view for jury members - F5: Real-time voting dashboard with Server-Sent Events (SSE) - F6: Live voting UX: QR codes, audience voting, tie-breaking, score animations - F7: File versioning, inline preview, bulk download with presigned URLs - F8: Mentor dashboard: milestones, private notes, activity tracking - F9: Communication hub with broadcasts, templates, and recipient targeting - F10: Advanced analytics: cross-round comparison, juror consistency, diversity metrics, PDF export - F11: Applicant draft saving with magic link resume and cron cleanup - F12: Webhook integration layer with HMAC signing, retry, and delivery logs - F13: Peer review discussions with anonymized scores and threaded comments - F14: Audit log enhancements: before/after diffs, session grouping, anomaly detection, retention - F15: i18n foundation with next-intl (EN/FR), cookie-based locale, language switcher Schema: 12 new models, field additions to User, Project, ProjectFile, LiveVotingSession, LiveVote, MentorAssignment, AuditLog, Program New routers: roundTemplate, message, webhook (registered in _app.ts) New services: email-digest, webhook-dispatcher New cron endpoints: /api/cron/digest, /api/cron/draft-cleanup, /api/cron/audit-cleanup New API routes: /api/live-voting/stream (SSE), /api/files/bulk-download All features are admin-configurable via SystemSettings or per-model settingsJson fields. Docker build verified successfully. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 23:31:41 +01:00
digestFrequency: z.enum(['none', 'daily', 'weekly']).optional(),
availabilityJson: z.array(z.object({ start: z.string(), end: z.string() })).optional(),
Implement 15 platform features: digest, availability, templates, comparison, live voting SSE, file versioning, mentorship, messaging, analytics, drafts, webhooks, peer review, audit enhancements, i18n Features implemented: - F1: Email digest notifications with cron endpoint and per-user frequency - F2: Jury availability windows and workload preferences in smart assignment - F3: Round templates with save-from-round and CRUD management - F4: Side-by-side project comparison view for jury members - F5: Real-time voting dashboard with Server-Sent Events (SSE) - F6: Live voting UX: QR codes, audience voting, tie-breaking, score animations - F7: File versioning, inline preview, bulk download with presigned URLs - F8: Mentor dashboard: milestones, private notes, activity tracking - F9: Communication hub with broadcasts, templates, and recipient targeting - F10: Advanced analytics: cross-round comparison, juror consistency, diversity metrics, PDF export - F11: Applicant draft saving with magic link resume and cron cleanup - F12: Webhook integration layer with HMAC signing, retry, and delivery logs - F13: Peer review discussions with anonymized scores and threaded comments - F14: Audit log enhancements: before/after diffs, session grouping, anomaly detection, retention - F15: i18n foundation with next-intl (EN/FR), cookie-based locale, language switcher Schema: 12 new models, field additions to User, Project, ProjectFile, LiveVotingSession, LiveVote, MentorAssignment, AuditLog, Program New routers: roundTemplate, message, webhook (registered in _app.ts) New services: email-digest, webhook-dispatcher New cron endpoints: /api/cron/digest, /api/cron/draft-cleanup, /api/cron/audit-cleanup New API routes: /api/live-voting/stream (SSE), /api/files/bulk-download All features are admin-configurable via SystemSettings or per-model settingsJson fields. Docker build verified successfully. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 23:31:41 +01:00
preferredWorkload: z.number().int().min(1).max(100).optional().nullable(),
})
)
.mutation(async ({ ctx, input }) => {
2026-04-29 03:29:09 +02:00
// Email is intentionally NOT in the input schema. Allowing self-service
// email changes turns any short-lived session compromise into permanent
// account takeover via password reset on the new address. Email changes
// require an admin-driven flow (or a future verified-change procedure).
const {
bio,
expertiseTags,
availabilityJson,
preferredWorkload,
digestFrequency,
...directFields
} = input
// If bio is provided, merge it into metadataJson
let metadataJson: Prisma.InputJsonValue | undefined
if (bio !== undefined) {
const currentUser = await ctx.prisma.user.findUniqueOrThrow({
where: { id: ctx.user.id },
select: { metadataJson: true },
})
const currentMeta = (currentUser.metadataJson as Record<string, string>) || {}
metadataJson = { ...currentMeta, bio } as Prisma.InputJsonValue
}
return ctx.prisma.user.update({
where: { id: ctx.user.id },
data: {
...directFields,
...(metadataJson !== undefined && { metadataJson }),
...(expertiseTags !== undefined && { expertiseTags }),
Implement 15 platform features: digest, availability, templates, comparison, live voting SSE, file versioning, mentorship, messaging, analytics, drafts, webhooks, peer review, audit enhancements, i18n Features implemented: - F1: Email digest notifications with cron endpoint and per-user frequency - F2: Jury availability windows and workload preferences in smart assignment - F3: Round templates with save-from-round and CRUD management - F4: Side-by-side project comparison view for jury members - F5: Real-time voting dashboard with Server-Sent Events (SSE) - F6: Live voting UX: QR codes, audience voting, tie-breaking, score animations - F7: File versioning, inline preview, bulk download with presigned URLs - F8: Mentor dashboard: milestones, private notes, activity tracking - F9: Communication hub with broadcasts, templates, and recipient targeting - F10: Advanced analytics: cross-round comparison, juror consistency, diversity metrics, PDF export - F11: Applicant draft saving with magic link resume and cron cleanup - F12: Webhook integration layer with HMAC signing, retry, and delivery logs - F13: Peer review discussions with anonymized scores and threaded comments - F14: Audit log enhancements: before/after diffs, session grouping, anomaly detection, retention - F15: i18n foundation with next-intl (EN/FR), cookie-based locale, language switcher Schema: 12 new models, field additions to User, Project, ProjectFile, LiveVotingSession, LiveVote, MentorAssignment, AuditLog, Program New routers: roundTemplate, message, webhook (registered in _app.ts) New services: email-digest, webhook-dispatcher New cron endpoints: /api/cron/digest, /api/cron/draft-cleanup, /api/cron/audit-cleanup New API routes: /api/live-voting/stream (SSE), /api/files/bulk-download All features are admin-configurable via SystemSettings or per-model settingsJson fields. Docker build verified successfully. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 23:31:41 +01:00
...(digestFrequency !== undefined && { digestFrequency }),
...(availabilityJson !== undefined && { availabilityJson: availabilityJson as Prisma.InputJsonValue }),
...(preferredWorkload !== undefined && { preferredWorkload }),
},
})
}),
/**
* Delete own account (requires password confirmation)
*/
deleteAccount: protectedProcedure
.input(
z.object({
password: z.string().min(1),
})
)
.mutation(async ({ ctx, input }) => {
// Get current user with password hash
const user = await ctx.prisma.user.findUniqueOrThrow({
where: { id: ctx.user.id },
select: { id: true, email: true, passwordHash: true, role: true },
})
// Prevent super admins from self-deleting
if (user.role === 'SUPER_ADMIN') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Super admins cannot delete their own account',
})
}
if (!user.passwordHash) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'No password set. Please set a password first.',
})
}
// Verify password
const { verifyPassword } = await import('@/lib/password')
const isValid = await verifyPassword(input.password, user.passwordHash)
if (!isValid) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Password is incorrect',
})
}
// TODO: This delete will fail with a FK violation for any user with activity
// (COI declarations, mentor assignments, messages, evaluations, etc.).
// A proper purge flow with pre-deletion cleanup or soft-delete is needed
// before hard-delete can work reliably for active users.
await ctx.prisma.user.delete({
where: { id: ctx.user.id },
})
// Audit outside transaction so failures don't roll back the deletion
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'DELETE_OWN_ACCOUNT',
entityType: 'User',
entityId: ctx.user.id,
detailsJson: { email: user.email },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { success: true }
}),
/**
* List all users (admin only)
*/
list: adminProcedure
.input(
z.object({
refactor(awards): remove AWARD_MASTER role, fold features into jury chair flow The AWARD_MASTER role split sponsor jurors into a parallel UI that hid project files (only showed when the award was anchored to an evaluation round) and duplicated the jury voting path with no real difference in authority — tie-break and finalize were already governed by AwardJuror.isChair regardless of the user's global role. Inviting a juror via the award page defaulted to AWARD_MASTER, randomly fragmenting jury panels. This collapses the role into JURY_MEMBER + isChair: - specialAward.getMyAwardDetail now returns evaluation scores, chair visibility into other jurors' votes, and juror roster - specialAward.submitVote accepts an optional justification per vote - specialAward.confirmWinner moves from awardMasterProcedure to protectedProcedure (juror+chair check inside) - bulkInviteJurors creates JURY_MEMBER accounts and, when the award has a juryGroupId, also adds them to that JuryGroup so they appear on the round-page jury panel - jury award page renders justification, eval-score badges, and a chair tools panel with vote tally + finalize-winner CTA - juryGroup.list includes attached SpecialAwards; the jury-list UI shows a trophy pill alongside round pills - (award-master) route group, awardMasterProcedure, AWARD_MASTER role enum value, and AWARD_MASTER_DECISION decisionMode are deleted - migration demotes any residual AWARD_MASTER users to JURY_MEMBER and recreates the UserRole enum without the value Coup de Coeur on prod: Didier (the sponsor juror added today as AWARD_MASTER by the buggy invite form) was migrated to JURY_MEMBER and attached to the existing "Coup de Coeur" JuryGroup; the SpecialAward itself was linked to that group (juryGroupId was NULL). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:21:09 +02:00
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(),
roles: z.array(z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER'])).optional(),
status: z.enum(['NONE', 'INVITED', 'ACTIVE', 'SUSPENDED']).optional(),
search: z.string().optional(),
page: z.number().int().min(1).default(1),
perPage: z.number().int().min(1).max(100).default(20),
sortBy: z.enum(['name', 'email', 'role', 'status', 'lastLoginAt', 'createdAt']).optional(),
sortDir: z.enum(['asc', 'desc']).optional(),
})
)
.query(async ({ ctx, input }) => {
const { role, roles, status, search, page, perPage, sortBy, sortDir } = input
const skip = (page - 1) * perPage
const where: Record<string, unknown> = {}
if (roles && roles.length > 0) {
where.role = { in: roles }
} else if (role) {
where.role = role
}
if (status) where.status = status
if (search) {
where.OR = [
{ email: { contains: search, mode: 'insensitive' } },
{ name: { contains: search, mode: 'insensitive' } },
]
}
const dir = sortDir ?? 'asc'
const orderBy: Record<string, string> = sortBy
? { [sortBy]: dir }
: { createdAt: 'desc' }
const [users, total] = await Promise.all([
ctx.prisma.user.findMany({
where,
skip,
take: perPage,
orderBy,
select: {
id: true,
email: true,
name: true,
role: true,
roles: true,
status: true,
expertiseTags: true,
maxAssignments: true,
Implement 15 platform features: digest, availability, templates, comparison, live voting SSE, file versioning, mentorship, messaging, analytics, drafts, webhooks, peer review, audit enhancements, i18n Features implemented: - F1: Email digest notifications with cron endpoint and per-user frequency - F2: Jury availability windows and workload preferences in smart assignment - F3: Round templates with save-from-round and CRUD management - F4: Side-by-side project comparison view for jury members - F5: Real-time voting dashboard with Server-Sent Events (SSE) - F6: Live voting UX: QR codes, audience voting, tie-breaking, score animations - F7: File versioning, inline preview, bulk download with presigned URLs - F8: Mentor dashboard: milestones, private notes, activity tracking - F9: Communication hub with broadcasts, templates, and recipient targeting - F10: Advanced analytics: cross-round comparison, juror consistency, diversity metrics, PDF export - F11: Applicant draft saving with magic link resume and cron cleanup - F12: Webhook integration layer with HMAC signing, retry, and delivery logs - F13: Peer review discussions with anonymized scores and threaded comments - F14: Audit log enhancements: before/after diffs, session grouping, anomaly detection, retention - F15: i18n foundation with next-intl (EN/FR), cookie-based locale, language switcher Schema: 12 new models, field additions to User, Project, ProjectFile, LiveVotingSession, LiveVote, MentorAssignment, AuditLog, Program New routers: roundTemplate, message, webhook (registered in _app.ts) New services: email-digest, webhook-dispatcher New cron endpoints: /api/cron/digest, /api/cron/draft-cleanup, /api/cron/audit-cleanup New API routes: /api/live-voting/stream (SSE), /api/files/bulk-download All features are admin-configurable via SystemSettings or per-model settingsJson fields. Docker build verified successfully. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 23:31:41 +01:00
availabilityJson: true,
preferredWorkload: true,
profileImageKey: true,
profileImageProvider: true,
createdAt: true,
lastLoginAt: true,
_count: {
select: { assignments: true, mentorAssignments: true },
},
},
}),
ctx.prisma.user.count({ where }),
])
const usersWithAvatars = await attachAvatarUrls(users)
// For APPLICANT users, attach their project's current round info
const applicantIds = users.filter((u) => u.role === 'APPLICANT').map((u) => u.id)
const applicantRoundMap = new Map<string, { projectName: string; roundName: string; state: string } | null>()
if (applicantIds.length > 0) {
// Find each applicant's project, then the latest round state
const projects = await ctx.prisma.project.findMany({
where: {
OR: [
{ submittedByUserId: { in: applicantIds } },
{ teamMembers: { some: { userId: { in: applicantIds } } } },
],
},
include: {
teamMembers: { select: { userId: true } },
projectRoundStates: {
select: {
state: true,
round: { select: { name: true, sortOrder: true } },
},
orderBy: { round: { sortOrder: 'desc' } },
},
},
})
// Build a map of userId -> project's current round info
for (const proj of projects) {
const userIds = [
proj.submittedByUserId,
...proj.teamMembers.map((tm) => tm.userId),
].filter((id): id is string => id !== null)
// Find the latest active round state (non-terminal first, fallback to terminal)
const latestActive = proj.projectRoundStates.find((rs) =>
rs.state === 'IN_PROGRESS' || rs.state === 'PENDING'
)
const latestTerminal = proj.projectRoundStates.find((rs) =>
rs.state === 'REJECTED' || rs.state === 'WITHDRAWN'
)
const latest = latestActive ?? proj.projectRoundStates[0]
for (const uid of userIds) {
if (!applicantIds.includes(uid)) continue
if (latestTerminal && !latestActive) {
applicantRoundMap.set(uid, {
projectName: proj.title,
roundName: latestTerminal.round.name,
state: latestTerminal.state,
})
} else if (latest) {
applicantRoundMap.set(uid, {
projectName: proj.title,
roundName: latest.round.name,
state: latest.state,
})
} else {
applicantRoundMap.set(uid, null)
}
}
}
}
const enrichedUsers = usersWithAvatars.map((u) => ({
...u,
applicantRoundInfo: applicantRoundMap.get(u.id) ?? null,
}))
return {
users: enrichedUsers,
total,
page,
perPage,
totalPages: Math.ceil(total / perPage),
}
}),
/**
* List all invitable user IDs for current filters (not paginated)
*/
listInvitableIds: adminProcedure
.input(
z.object({
refactor(awards): remove AWARD_MASTER role, fold features into jury chair flow The AWARD_MASTER role split sponsor jurors into a parallel UI that hid project files (only showed when the award was anchored to an evaluation round) and duplicated the jury voting path with no real difference in authority — tie-break and finalize were already governed by AwardJuror.isChair regardless of the user's global role. Inviting a juror via the award page defaulted to AWARD_MASTER, randomly fragmenting jury panels. This collapses the role into JURY_MEMBER + isChair: - specialAward.getMyAwardDetail now returns evaluation scores, chair visibility into other jurors' votes, and juror roster - specialAward.submitVote accepts an optional justification per vote - specialAward.confirmWinner moves from awardMasterProcedure to protectedProcedure (juror+chair check inside) - bulkInviteJurors creates JURY_MEMBER accounts and, when the award has a juryGroupId, also adds them to that JuryGroup so they appear on the round-page jury panel - jury award page renders justification, eval-score badges, and a chair tools panel with vote tally + finalize-winner CTA - juryGroup.list includes attached SpecialAwards; the jury-list UI shows a trophy pill alongside round pills - (award-master) route group, awardMasterProcedure, AWARD_MASTER role enum value, and AWARD_MASTER_DECISION decisionMode are deleted - migration demotes any residual AWARD_MASTER users to JURY_MEMBER and recreates the UserRole enum without the value Coup de Coeur on prod: Didier (the sponsor juror added today as AWARD_MASTER by the buggy invite form) was migrated to JURY_MEMBER and attached to the existing "Coup de Coeur" JuryGroup; the SpecialAward itself was linked to that group (juryGroupId was NULL). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:21:09 +02:00
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(),
roles: z.array(z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER'])).optional(),
search: z.string().optional(),
})
)
.query(async ({ ctx, input }) => {
const where: Record<string, unknown> = {
status: { in: ['NONE', 'INVITED'] },
}
if (input.roles && input.roles.length > 0) {
where.role = { in: input.roles }
} else if (input.role) {
where.role = input.role
}
if (input.search) {
where.OR = [
{ email: { contains: input.search, mode: 'insensitive' } },
{ name: { contains: input.search, mode: 'insensitive' } },
]
}
const users = await ctx.prisma.user.findMany({
where,
select: { id: true },
})
return {
userIds: users.map((u) => u.id),
total: users.length,
}
}),
/**
* Get a single user (admin only)
*/
get: adminProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
Comprehensive platform review: security fixes, query optimization, UI improvements, and code cleanup Security (Critical/High): - Fix path traversal bypass in local storage provider (path.resolve + prefix check) - Fix timing-unsafe HMAC comparison (crypto.timingSafeEqual) - Add auth + ownership checks to email API routes (verify-credentials, change-password) - Remove hardcoded secret key fallback in local storage provider - Add production credential check for MinIO (fail loudly if not set) - Remove DB error details from health check response - Add stricter rate limiting on application submissions (5/hour) - Add rate limiting on email availability check (anti-enumeration) - Change getAIAssignmentJobStatus to adminProcedure - Block dangerous file extensions on upload - Reduce project list max perPage from 5000 to 200 Query Optimization: - Optimize analytics getProjectRankings with select instead of full includes - Fix N+1 in mentor.getSuggestions (batch findMany instead of loop) - Use _count for files instead of fetching full file records in project list - Switch to bulk notifications in assignment and user bulk operations - Batch filtering upserts (25 per transaction instead of all at once) UI/UX: - Replace Inter font with Montserrat in public layout (brand consistency) - Use Logo component in public layout instead of placeholder - Create branded 404 and error pages - Make admin rounds table responsive with mobile card layout - Fix notification bell paths to be role-aware - Replace hardcoded slate colors with semantic tokens in admin sidebar - Force light mode (dark mode untested) - Adjust CardTitle default size - Improve muted-foreground contrast for accessibility (A11Y) - Move profile form state initialization to useEffect Code Quality: - Extract shared toProjectWithRelations to anonymization.ts (removed 3 duplicates) - Remove dead code: getObjectInfo, isValidImageSize, unused batch tag functions, debug logs - Remove unused twilio dependency - Remove redundant email index from schema - Add actual storage object deletion when file records are deleted - Wrap evaluation submit + assignment update in - Add comprehensive platform review document Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 20:31:08 +01:00
const user = await ctx.prisma.user.findUniqueOrThrow({
where: { id: input.id },
include: {
_count: {
select: { assignments: true, mentorAssignments: true },
},
teamMemberships: {
include: {
project: {
select: { id: true, title: true, teamName: true, competitionCategory: true },
},
},
},
juryGroupMemberships: {
include: {
juryGroup: {
select: { id: true, name: true },
},
},
},
Comprehensive platform review: security fixes, query optimization, UI improvements, and code cleanup Security (Critical/High): - Fix path traversal bypass in local storage provider (path.resolve + prefix check) - Fix timing-unsafe HMAC comparison (crypto.timingSafeEqual) - Add auth + ownership checks to email API routes (verify-credentials, change-password) - Remove hardcoded secret key fallback in local storage provider - Add production credential check for MinIO (fail loudly if not set) - Remove DB error details from health check response - Add stricter rate limiting on application submissions (5/hour) - Add rate limiting on email availability check (anti-enumeration) - Change getAIAssignmentJobStatus to adminProcedure - Block dangerous file extensions on upload - Reduce project list max perPage from 5000 to 200 Query Optimization: - Optimize analytics getProjectRankings with select instead of full includes - Fix N+1 in mentor.getSuggestions (batch findMany instead of loop) - Use _count for files instead of fetching full file records in project list - Switch to bulk notifications in assignment and user bulk operations - Batch filtering upserts (25 per transaction instead of all at once) UI/UX: - Replace Inter font with Montserrat in public layout (brand consistency) - Use Logo component in public layout instead of placeholder - Create branded 404 and error pages - Make admin rounds table responsive with mobile card layout - Fix notification bell paths to be role-aware - Replace hardcoded slate colors with semantic tokens in admin sidebar - Force light mode (dark mode untested) - Adjust CardTitle default size - Improve muted-foreground contrast for accessibility (A11Y) - Move profile form state initialization to useEffect Code Quality: - Extract shared toProjectWithRelations to anonymization.ts (removed 3 duplicates) - Remove dead code: getObjectInfo, isValidImageSize, unused batch tag functions, debug logs - Remove unused twilio dependency - Remove redundant email index from schema - Add actual storage object deletion when file records are deleted - Wrap evaluation submit + assignment update in - Add comprehensive platform review document Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 20:31:08 +01:00
},
})
const avatarUrl = await getUserAvatarUrl(user.profileImageKey, user.profileImageProvider)
return { ...user, avatarUrl }
}),
/**
* Resolve a batch of user IDs to names (admin only).
* Returns a map of id name for displaying in audit logs, etc.
*/
resolveNames: adminProcedure
.input(z.object({ ids: z.array(z.string()).max(50) }))
.query(async ({ ctx, input }) => {
if (input.ids.length === 0) return {}
const users = await ctx.prisma.user.findMany({
where: { id: { in: input.ids } },
select: { id: true, name: true, email: true },
})
const map: Record<string, string> = {}
for (const u of users) {
map[u.id] = u.name || u.email
}
return map
}),
/**
* Create/invite a new user (admin only)
*/
create: adminProcedure
.input(
z.object({
email: z.string().email(),
name: z.string().optional(),
refactor(awards): remove AWARD_MASTER role, fold features into jury chair flow The AWARD_MASTER role split sponsor jurors into a parallel UI that hid project files (only showed when the award was anchored to an evaluation round) and duplicated the jury voting path with no real difference in authority — tie-break and finalize were already governed by AwardJuror.isChair regardless of the user's global role. Inviting a juror via the award page defaulted to AWARD_MASTER, randomly fragmenting jury panels. This collapses the role into JURY_MEMBER + isChair: - specialAward.getMyAwardDetail now returns evaluation scores, chair visibility into other jurors' votes, and juror roster - specialAward.submitVote accepts an optional justification per vote - specialAward.confirmWinner moves from awardMasterProcedure to protectedProcedure (juror+chair check inside) - bulkInviteJurors creates JURY_MEMBER accounts and, when the award has a juryGroupId, also adds them to that JuryGroup so they appear on the round-page jury panel - jury award page renders justification, eval-score badges, and a chair tools panel with vote tally + finalize-winner CTA - juryGroup.list includes attached SpecialAwards; the jury-list UI shows a trophy pill alongside round pills - (award-master) route group, awardMasterProcedure, AWARD_MASTER role enum value, and AWARD_MASTER_DECISION decisionMode are deleted - migration demotes any residual AWARD_MASTER users to JURY_MEMBER and recreates the UserRole enum without the value Coup de Coeur on prod: Didier (the sponsor juror added today as AWARD_MASTER by the buggy invite form) was migrated to JURY_MEMBER and attached to the existing "Coup de Coeur" JuryGroup; the SpecialAward itself was linked to that group (juryGroupId was NULL). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:21:09 +02:00
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).default('JURY_MEMBER'),
expertiseTags: z.array(z.string()).optional(),
maxAssignments: z.number().int().min(1).max(100).optional(),
})
)
.mutation(async ({ ctx, input }) => {
// Check if user already exists
const existing = await ctx.prisma.user.findUnique({
where: { email: input.email },
})
if (existing) {
throw new TRPCError({
code: 'CONFLICT',
message: 'A user with this email already exists',
})
}
// Prevent non-super-admins from creating super admins or program admins
if (input.role === 'SUPER_ADMIN' && ctx.user.role !== 'SUPER_ADMIN') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Only super admins can create super admins',
})
}
if (input.role === 'PROGRAM_ADMIN' && ctx.user.role !== 'SUPER_ADMIN') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Only super admins can create program admins',
})
}
// Generate invite token upfront so the user can accept even if the
// subsequent invitation email fails to send. Re-sending from the
// members table will just overwrite the token.
const inviteToken = generateInviteToken()
const expiryHours = await getInviteExpiryHours(ctx.prisma)
const user = await ctx.prisma.user.create({
data: {
...input,
status: 'INVITED',
inviteToken,
inviteTokenExpiresAt: new Date(Date.now() + expiryHours * 60 * 60 * 1000),
},
})
// Audit outside transaction so failures don't roll back the user creation
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'CREATE',
entityType: 'User',
entityId: user.id,
detailsJson: { email: input.email, role: input.role },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return user
}),
/**
* Update a user (admin only)
*/
update: adminProcedure
.input(
z.object({
id: z.string(),
email: z.string().email().optional(),
name: z.string().optional().nullable(),
refactor(awards): remove AWARD_MASTER role, fold features into jury chair flow The AWARD_MASTER role split sponsor jurors into a parallel UI that hid project files (only showed when the award was anchored to an evaluation round) and duplicated the jury voting path with no real difference in authority — tie-break and finalize were already governed by AwardJuror.isChair regardless of the user's global role. Inviting a juror via the award page defaulted to AWARD_MASTER, randomly fragmenting jury panels. This collapses the role into JURY_MEMBER + isChair: - specialAward.getMyAwardDetail now returns evaluation scores, chair visibility into other jurors' votes, and juror roster - specialAward.submitVote accepts an optional justification per vote - specialAward.confirmWinner moves from awardMasterProcedure to protectedProcedure (juror+chair check inside) - bulkInviteJurors creates JURY_MEMBER accounts and, when the award has a juryGroupId, also adds them to that JuryGroup so they appear on the round-page jury panel - jury award page renders justification, eval-score badges, and a chair tools panel with vote tally + finalize-winner CTA - juryGroup.list includes attached SpecialAwards; the jury-list UI shows a trophy pill alongside round pills - (award-master) route group, awardMasterProcedure, AWARD_MASTER role enum value, and AWARD_MASTER_DECISION decisionMode are deleted - migration demotes any residual AWARD_MASTER users to JURY_MEMBER and recreates the UserRole enum without the value Coup de Coeur on prod: Didier (the sponsor juror added today as AWARD_MASTER by the buggy invite form) was migrated to JURY_MEMBER and attached to the existing "Coup de Coeur" JuryGroup; the SpecialAward itself was linked to that group (juryGroupId was NULL). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:21:09 +02:00
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER', 'APPLICANT', 'AUDIENCE']).optional(),
roles: z.array(z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER', 'APPLICANT', 'AUDIENCE'])).optional(),
status: z.enum(['NONE', 'INVITED', 'ACTIVE', 'SUSPENDED']).optional(),
expertiseTags: z.array(z.string()).optional(),
maxAssignments: z.number().int().min(1).max(100).optional().nullable(),
availabilityJson: z.array(z.object({ start: z.string(), end: z.string() })).optional(),
Implement 15 platform features: digest, availability, templates, comparison, live voting SSE, file versioning, mentorship, messaging, analytics, drafts, webhooks, peer review, audit enhancements, i18n Features implemented: - F1: Email digest notifications with cron endpoint and per-user frequency - F2: Jury availability windows and workload preferences in smart assignment - F3: Round templates with save-from-round and CRUD management - F4: Side-by-side project comparison view for jury members - F5: Real-time voting dashboard with Server-Sent Events (SSE) - F6: Live voting UX: QR codes, audience voting, tie-breaking, score animations - F7: File versioning, inline preview, bulk download with presigned URLs - F8: Mentor dashboard: milestones, private notes, activity tracking - F9: Communication hub with broadcasts, templates, and recipient targeting - F10: Advanced analytics: cross-round comparison, juror consistency, diversity metrics, PDF export - F11: Applicant draft saving with magic link resume and cron cleanup - F12: Webhook integration layer with HMAC signing, retry, and delivery logs - F13: Peer review discussions with anonymized scores and threaded comments - F14: Audit log enhancements: before/after diffs, session grouping, anomaly detection, retention - F15: i18n foundation with next-intl (EN/FR), cookie-based locale, language switcher Schema: 12 new models, field additions to User, Project, ProjectFile, LiveVotingSession, LiveVote, MentorAssignment, AuditLog, Program New routers: roundTemplate, message, webhook (registered in _app.ts) New services: email-digest, webhook-dispatcher New cron endpoints: /api/cron/digest, /api/cron/draft-cleanup, /api/cron/audit-cleanup New API routes: /api/live-voting/stream (SSE), /api/files/bulk-download All features are admin-configurable via SystemSettings or per-model settingsJson fields. Docker build verified successfully. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 23:31:41 +01:00
preferredWorkload: z.number().int().min(1).max(100).optional().nullable(),
})
)
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input
const normalizedEmail = data.email?.toLowerCase().trim()
// Prevent changing super admin role
const targetUser = await ctx.prisma.user.findUniqueOrThrow({
where: { id },
})
2026-04-29 03:29:09 +02:00
const callerIsSuperAdmin = ctx.user.role === 'SUPER_ADMIN'
const targetHasSuperAdmin =
targetUser.role === 'SUPER_ADMIN' || targetUser.roles.includes('SUPER_ADMIN')
const targetHasProgramAdmin =
targetUser.role === 'PROGRAM_ADMIN' || targetUser.roles.includes('PROGRAM_ADMIN')
if (targetHasSuperAdmin && !callerIsSuperAdmin) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Cannot modify super admin',
})
}
2026-04-29 03:29:09 +02:00
// Prevent non-super-admins from changing admin roles (singular OR array)
if (
(data.role || data.roles) &&
targetHasProgramAdmin &&
!callerIsSuperAdmin
) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Only super admins can change admin roles',
})
}
// Prevent non-super-admins from assigning super admin or admin role
2026-04-29 03:29:09 +02:00
// — check both the singular `role` field AND the `roles[]` array.
if (data.role === 'SUPER_ADMIN' && !callerIsSuperAdmin) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Only super admins can assign super admin role',
})
}
2026-04-29 03:29:09 +02:00
if (data.role === 'PROGRAM_ADMIN' && !callerIsSuperAdmin) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Only super admins can assign admin role',
})
}
if (data.roles?.includes('SUPER_ADMIN') && !callerIsSuperAdmin) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Only super admins can assign super admin role',
})
}
if (data.roles?.includes('PROGRAM_ADMIN') && !callerIsSuperAdmin) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Only super admins can assign admin role',
})
}
if (normalizedEmail !== undefined) {
const existing = await ctx.prisma.user.findFirst({
where: {
email: normalizedEmail,
NOT: { id },
},
select: { id: true },
})
if (existing) {
throw new TRPCError({
code: 'CONFLICT',
message: 'Another user already uses this email address',
})
}
}
const updateData = {
...data,
...(normalizedEmail !== undefined && { email: normalizedEmail }),
}
const user = await ctx.prisma.user.update({
where: { id },
data: updateData,
})
// Audit outside transaction so failures don't roll back the update
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'User',
entityId: id,
detailsJson: updateData,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
// Track role change specifically
if (data.role && data.role !== targetUser.role) {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'ROLE_CHANGED',
entityType: 'User',
entityId: id,
detailsJson: { previousRole: targetUser.role, newRole: data.role },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
}
return user
}),
/**
* Delete a user (super admin only)
*/
delete: superAdminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
// Prevent self-deletion
if (input.id === ctx.user.id) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Cannot delete yourself',
})
}
// Fetch user data before deletion for the audit log
const target = await ctx.prisma.user.findUniqueOrThrow({
where: { id: input.id },
select: { email: true },
})
// TODO: This delete will fail with a FK violation for any user with activity
// (COI declarations, mentor assignments, messages, evaluations, etc.).
// A proper purge flow with pre-deletion cleanup or soft-delete is needed
// before hard-delete can work reliably for active users.
const user = await ctx.prisma.user.delete({
where: { id: input.id },
})
// Audit outside transaction so failures don't roll back the deletion
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'DELETE',
entityType: 'User',
entityId: input.id,
detailsJson: { email: target.email },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return user
}),
/**
* Bulk import users (admin only)
* Optionally pre-assign projects to jury members during invitation
*/
bulkCreate: adminProcedure
.input(
z.object({
users: z.array(
z.object({
email: z.string().email(),
name: z.string().optional(),
refactor(awards): remove AWARD_MASTER role, fold features into jury chair flow The AWARD_MASTER role split sponsor jurors into a parallel UI that hid project files (only showed when the award was anchored to an evaluation round) and duplicated the jury voting path with no real difference in authority — tie-break and finalize were already governed by AwardJuror.isChair regardless of the user's global role. Inviting a juror via the award page defaulted to AWARD_MASTER, randomly fragmenting jury panels. This collapses the role into JURY_MEMBER + isChair: - specialAward.getMyAwardDetail now returns evaluation scores, chair visibility into other jurors' votes, and juror roster - specialAward.submitVote accepts an optional justification per vote - specialAward.confirmWinner moves from awardMasterProcedure to protectedProcedure (juror+chair check inside) - bulkInviteJurors creates JURY_MEMBER accounts and, when the award has a juryGroupId, also adds them to that JuryGroup so they appear on the round-page jury panel - jury award page renders justification, eval-score badges, and a chair tools panel with vote tally + finalize-winner CTA - juryGroup.list includes attached SpecialAwards; the jury-list UI shows a trophy pill alongside round pills - (award-master) route group, awardMasterProcedure, AWARD_MASTER role enum value, and AWARD_MASTER_DECISION decisionMode are deleted - migration demotes any residual AWARD_MASTER users to JURY_MEMBER and recreates the UserRole enum without the value Coup de Coeur on prod: Didier (the sponsor juror added today as AWARD_MASTER by the buggy invite form) was migrated to JURY_MEMBER and attached to the existing "Coup de Coeur" JuryGroup; the SpecialAward itself was linked to that group (juryGroupId was NULL). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:21:09 +02:00
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).default('JURY_MEMBER'),
expertiseTags: z.array(z.string()).optional(),
// Optional pre-assignments for jury members
assignments: z
.array(
z.object({
projectId: z.string(),
roundId: z.string(),
})
)
.optional(),
// Competition architecture: optional jury group memberships
juryGroupIds: z.array(z.string()).optional(),
juryGroupRole: z.enum(['CHAIR', 'MEMBER', 'OBSERVER']).default('MEMBER'),
// Competition architecture: optional assignment intents
assignmentIntents: z
.array(
z.object({
roundId: z.string(),
projectId: z.string(),
})
)
.optional(),
})
),
Platform-wide visual overhaul, team invites, analytics improvements, and deployment hardening UI overhaul applying jury dashboard design patterns across all pages: - Stat cards with border-l-4 accent + icon pills on admin, observer, mentor, applicant dashboards and reports - Card section headers with color-coded icon pills throughout - Hover lift effects (translate-y + shadow) on cards and list items - Gradient progress bars (brand-teal to brand-blue) platform-wide - AnimatedCard stagger animations on all dashboard sections - Auth pages with gradient accent strip and polished icon containers - EmptyState component upgraded with rounded icon pill containers - Replaced AI-looking icons (Brain/Sparkles/Bot/Wand2/Cpu) with descriptive alternatives across 12 files - Removed gradient overlay from jury dashboard header - Quick actions restyled as card links with group hover effects Backend improvements: - Team member invite emails with account setup flow and notification logging - Analytics routers accept edition-wide queries (programId) in addition to roundId - Round detail endpoint returns inline progress data (eliminates extra getProgress call) - Award voting endpoints parallelized with Promise.all - Bulk invite supports optional sendInvitation flag - AwardVote composite index migration for query performance Infrastructure: - Docker entrypoint with migration retry loop (configurable retries/delay) - docker-compose pull_policy: always for automatic image refresh - Simplified deploy/update scripts using docker compose up -d --pull always - Updated deployment documentation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 13:20:52 +01:00
sendInvitation: z.boolean().default(true),
})
)
.mutation(async ({ ctx, input }) => {
// Prevent non-super-admins from creating super admins or program admins
const hasSuperAdminRole = input.users.some((u) => u.role === 'SUPER_ADMIN')
if (hasSuperAdminRole && ctx.user.role !== 'SUPER_ADMIN') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Only super admins can create super admins',
})
}
const hasAdminRole = input.users.some((u) => u.role === 'PROGRAM_ADMIN')
if (hasAdminRole && ctx.user.role !== 'SUPER_ADMIN') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Only super admins can create program admins',
})
}
// Deduplicate input by email (keep first occurrence)
const seenEmails = new Set<string>()
const uniqueUsers = input.users.filter((u) => {
const email = u.email.toLowerCase()
if (seenEmails.has(email)) return false
seenEmails.add(email)
return true
})
// Get existing emails from database
const existingUsers = await ctx.prisma.user.findMany({
where: { email: { in: uniqueUsers.map((u) => u.email.toLowerCase()) } },
select: { email: true },
})
const existingEmails = new Set(existingUsers.map((u) => u.email.toLowerCase()))
// Filter out existing users
const newUsers = uniqueUsers.filter((u) => !existingEmails.has(u.email.toLowerCase()))
const duplicatesInInput = input.users.length - uniqueUsers.length
const skipped = existingEmails.size + duplicatesInInput
if (newUsers.length === 0) {
return { created: 0, skipped }
}
const emailToAssignments = new Map<string, Array<{ projectId: string; roundId: string }>>()
const emailToJuryGroupIds = new Map<string, { ids: string[]; role: 'CHAIR' | 'MEMBER' | 'OBSERVER' }>()
const emailToIntents = new Map<string, Array<{ roundId: string; projectId: string }>>()
for (const u of newUsers) {
if (u.assignments && u.assignments.length > 0) {
emailToAssignments.set(u.email.toLowerCase(), u.assignments)
}
if (u.juryGroupIds && u.juryGroupIds.length > 0) {
emailToJuryGroupIds.set(u.email.toLowerCase(), { ids: u.juryGroupIds, role: u.juryGroupRole })
}
if (u.assignmentIntents && u.assignmentIntents.length > 0) {
emailToIntents.set(u.email.toLowerCase(), u.assignmentIntents)
}
}
const created = await ctx.prisma.user.createMany({
data: newUsers.map((u) => ({
email: u.email.toLowerCase(),
name: u.name,
role: u.role,
expertiseTags: u.expertiseTags,
Platform-wide visual overhaul, team invites, analytics improvements, and deployment hardening UI overhaul applying jury dashboard design patterns across all pages: - Stat cards with border-l-4 accent + icon pills on admin, observer, mentor, applicant dashboards and reports - Card section headers with color-coded icon pills throughout - Hover lift effects (translate-y + shadow) on cards and list items - Gradient progress bars (brand-teal to brand-blue) platform-wide - AnimatedCard stagger animations on all dashboard sections - Auth pages with gradient accent strip and polished icon containers - EmptyState component upgraded with rounded icon pill containers - Replaced AI-looking icons (Brain/Sparkles/Bot/Wand2/Cpu) with descriptive alternatives across 12 files - Removed gradient overlay from jury dashboard header - Quick actions restyled as card links with group hover effects Backend improvements: - Team member invite emails with account setup flow and notification logging - Analytics routers accept edition-wide queries (programId) in addition to roundId - Round detail endpoint returns inline progress data (eliminates extra getProgress call) - Award voting endpoints parallelized with Promise.all - Bulk invite supports optional sendInvitation flag - AwardVote composite index migration for query performance Infrastructure: - Docker entrypoint with migration retry loop (configurable retries/delay) - docker-compose pull_policy: always for automatic image refresh - Simplified deploy/update scripts using docker compose up -d --pull always - Updated deployment documentation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 13:20:52 +01:00
status: input.sendInvitation ? 'INVITED' : 'NONE',
})),
})
// Audit log
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'BULK_CREATE',
entityType: 'User',
detailsJson: { count: created.count, skipped, duplicatesInInput },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
Platform-wide visual overhaul, team invites, analytics improvements, and deployment hardening UI overhaul applying jury dashboard design patterns across all pages: - Stat cards with border-l-4 accent + icon pills on admin, observer, mentor, applicant dashboards and reports - Card section headers with color-coded icon pills throughout - Hover lift effects (translate-y + shadow) on cards and list items - Gradient progress bars (brand-teal to brand-blue) platform-wide - AnimatedCard stagger animations on all dashboard sections - Auth pages with gradient accent strip and polished icon containers - EmptyState component upgraded with rounded icon pill containers - Replaced AI-looking icons (Brain/Sparkles/Bot/Wand2/Cpu) with descriptive alternatives across 12 files - Removed gradient overlay from jury dashboard header - Quick actions restyled as card links with group hover effects Backend improvements: - Team member invite emails with account setup flow and notification logging - Analytics routers accept edition-wide queries (programId) in addition to roundId - Round detail endpoint returns inline progress data (eliminates extra getProgress call) - Award voting endpoints parallelized with Promise.all - Bulk invite supports optional sendInvitation flag - AwardVote composite index migration for query performance Infrastructure: - Docker entrypoint with migration retry loop (configurable retries/delay) - docker-compose pull_policy: always for automatic image refresh - Simplified deploy/update scripts using docker compose up -d --pull always - Updated deployment documentation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 13:20:52 +01:00
// Fetch newly created users for assignments and optional invitation emails
const createdUsers = await ctx.prisma.user.findMany({
where: { email: { in: newUsers.map((u) => u.email.toLowerCase()) } },
select: { id: true, email: true, name: true, role: true },
})
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
// Create pre-assignments for users who have them (batched)
const assignmentData: Array<{ userId: string; projectId: string; roundId: string; method: 'MANUAL'; createdBy: string }> = []
for (const user of createdUsers) {
const assignments = emailToAssignments.get(user.email.toLowerCase())
if (assignments && assignments.length > 0) {
for (const assignment of assignments) {
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
assignmentData.push({
userId: user.id,
projectId: assignment.projectId,
roundId: assignment.roundId,
method: 'MANUAL',
createdBy: ctx.user.id,
})
}
}
}
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
let assignmentsCreated = 0
if (assignmentData.length > 0) {
const result = await ctx.prisma.assignment.createMany({
data: assignmentData,
skipDuplicates: true,
})
assignmentsCreated = result.count
}
// Audit log for assignments if any were created
if (assignmentsCreated > 0) {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'BULK_ASSIGN',
entityType: 'Assignment',
detailsJson: { count: assignmentsCreated, context: 'invitation_pre_assignment' },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
}
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
// Create JuryGroupMember records for users with juryGroupIds (batched)
const juryGroupMemberData: Array<{ juryGroupId: string; userId: string; role: 'CHAIR' | 'MEMBER' | 'OBSERVER' }> = []
for (const user of createdUsers) {
const groupInfo = emailToJuryGroupIds.get(user.email.toLowerCase())
if (groupInfo) {
for (const groupId of groupInfo.ids) {
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
juryGroupMemberData.push({
juryGroupId: groupId,
userId: user.id,
role: groupInfo.role,
})
}
}
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
}
let juryGroupMembershipsCreated = 0
if (juryGroupMemberData.length > 0) {
const result = await ctx.prisma.juryGroupMember.createMany({
data: juryGroupMemberData,
skipDuplicates: true,
})
juryGroupMembershipsCreated = result.count
}
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
// Create AssignmentIntents for users who have them
let assignmentIntentsCreated = 0
const allIntentUsers = createdUsers.filter(
(u) => emailToIntents.has(u.email.toLowerCase())
)
if (allIntentUsers.length > 0) {
// Batch-fetch all relevant rounds to avoid N+1 lookups
const allIntentRoundIds = new Set<string>()
for (const u of allIntentUsers) {
for (const intent of emailToIntents.get(u.email.toLowerCase())!) {
allIntentRoundIds.add(intent.roundId)
}
}
const rounds = await ctx.prisma.round.findMany({
where: { id: { in: [...allIntentRoundIds] } },
select: { id: true, juryGroupId: true },
})
const roundJuryGroupMap = new Map(rounds.map((r) => [r.id, r.juryGroupId]))
// Batch-fetch all matching JuryGroupMembers
const memberLookups = allIntentUsers.flatMap((u) => {
const intents = emailToIntents.get(u.email.toLowerCase())!
return intents
.map((intent) => {
const juryGroupId = roundJuryGroupMap.get(intent.roundId)
return juryGroupId ? { juryGroupId, userId: u.id } : null
})
.filter((x): x is { juryGroupId: string; userId: string } => x !== null)
})
const members = memberLookups.length > 0
? await ctx.prisma.juryGroupMember.findMany({
where: {
OR: memberLookups.map((l) => ({
juryGroupId: l.juryGroupId,
userId: l.userId,
})),
},
select: { id: true, juryGroupId: true, userId: true },
})
: []
const memberMap = new Map(
members.map((m) => [`${m.juryGroupId}:${m.userId}`, m.id])
)
// Batch-create all intents
const intentData: Array<{
juryGroupMemberId: string
roundId: string
projectId: string
source: 'INVITE'
status: 'INTENT_PENDING'
}> = []
for (const user of allIntentUsers) {
const intents = emailToIntents.get(user.email.toLowerCase())!
for (const intent of intents) {
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
const juryGroupId = roundJuryGroupMap.get(intent.roundId)
if (!juryGroupId) continue
const memberId = memberMap.get(`${juryGroupId}:${user.id}`)
if (!memberId) continue
intentData.push({
juryGroupMemberId: memberId,
roundId: intent.roundId,
projectId: intent.projectId,
source: 'INVITE',
status: 'INTENT_PENDING',
})
}
}
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
if (intentData.length > 0) {
const result = await ctx.prisma.assignmentIntent.createMany({
data: intentData,
skipDuplicates: true,
})
assignmentIntentsCreated = result.count
}
}
if (juryGroupMembershipsCreated > 0) {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'BULK_CREATE',
entityType: 'JuryGroupMember',
detailsJson: { count: juryGroupMembershipsCreated, context: 'invitation_jury_group_binding' },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
}
Platform-wide visual overhaul, team invites, analytics improvements, and deployment hardening UI overhaul applying jury dashboard design patterns across all pages: - Stat cards with border-l-4 accent + icon pills on admin, observer, mentor, applicant dashboards and reports - Card section headers with color-coded icon pills throughout - Hover lift effects (translate-y + shadow) on cards and list items - Gradient progress bars (brand-teal to brand-blue) platform-wide - AnimatedCard stagger animations on all dashboard sections - Auth pages with gradient accent strip and polished icon containers - EmptyState component upgraded with rounded icon pill containers - Replaced AI-looking icons (Brain/Sparkles/Bot/Wand2/Cpu) with descriptive alternatives across 12 files - Removed gradient overlay from jury dashboard header - Quick actions restyled as card links with group hover effects Backend improvements: - Team member invite emails with account setup flow and notification logging - Analytics routers accept edition-wide queries (programId) in addition to roundId - Round detail endpoint returns inline progress data (eliminates extra getProgress call) - Award voting endpoints parallelized with Promise.all - Bulk invite supports optional sendInvitation flag - AwardVote composite index migration for query performance Infrastructure: - Docker entrypoint with migration retry loop (configurable retries/delay) - docker-compose pull_policy: always for automatic image refresh - Simplified deploy/update scripts using docker compose up -d --pull always - Updated deployment documentation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 13:20:52 +01:00
// Send invitation emails if requested
let emailsSent = 0
const emailErrors: string[] = []
Platform-wide visual overhaul, team invites, analytics improvements, and deployment hardening UI overhaul applying jury dashboard design patterns across all pages: - Stat cards with border-l-4 accent + icon pills on admin, observer, mentor, applicant dashboards and reports - Card section headers with color-coded icon pills throughout - Hover lift effects (translate-y + shadow) on cards and list items - Gradient progress bars (brand-teal to brand-blue) platform-wide - AnimatedCard stagger animations on all dashboard sections - Auth pages with gradient accent strip and polished icon containers - EmptyState component upgraded with rounded icon pill containers - Replaced AI-looking icons (Brain/Sparkles/Bot/Wand2/Cpu) with descriptive alternatives across 12 files - Removed gradient overlay from jury dashboard header - Quick actions restyled as card links with group hover effects Backend improvements: - Team member invite emails with account setup flow and notification logging - Analytics routers accept edition-wide queries (programId) in addition to roundId - Round detail endpoint returns inline progress data (eliminates extra getProgress call) - Award voting endpoints parallelized with Promise.all - Bulk invite supports optional sendInvitation flag - AwardVote composite index migration for query performance Infrastructure: - Docker entrypoint with migration retry loop (configurable retries/delay) - docker-compose pull_policy: always for automatic image refresh - Simplified deploy/update scripts using docker compose up -d --pull always - Updated deployment documentation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 13:20:52 +01:00
if (input.sendInvitation) {
const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'
const expiryHours = await getInviteExpiryHours(ctx.prisma)
const expiryMs = expiryHours * 60 * 60 * 1000
Platform-wide visual overhaul, team invites, analytics improvements, and deployment hardening UI overhaul applying jury dashboard design patterns across all pages: - Stat cards with border-l-4 accent + icon pills on admin, observer, mentor, applicant dashboards and reports - Card section headers with color-coded icon pills throughout - Hover lift effects (translate-y + shadow) on cards and list items - Gradient progress bars (brand-teal to brand-blue) platform-wide - AnimatedCard stagger animations on all dashboard sections - Auth pages with gradient accent strip and polished icon containers - EmptyState component upgraded with rounded icon pill containers - Replaced AI-looking icons (Brain/Sparkles/Bot/Wand2/Cpu) with descriptive alternatives across 12 files - Removed gradient overlay from jury dashboard header - Quick actions restyled as card links with group hover effects Backend improvements: - Team member invite emails with account setup flow and notification logging - Analytics routers accept edition-wide queries (programId) in addition to roundId - Round detail endpoint returns inline progress data (eliminates extra getProgress call) - Award voting endpoints parallelized with Promise.all - Bulk invite supports optional sendInvitation flag - AwardVote composite index migration for query performance Infrastructure: - Docker entrypoint with migration retry loop (configurable retries/delay) - docker-compose pull_policy: always for automatic image refresh - Simplified deploy/update scripts using docker compose up -d --pull always - Updated deployment documentation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 13:20:52 +01:00
for (const user of createdUsers) {
try {
const token = generateInviteToken()
await ctx.prisma.user.update({
where: { id: user.id },
data: {
inviteToken: token,
inviteTokenExpiresAt: new Date(Date.now() + expiryMs),
status: 'INVITED',
Platform-wide visual overhaul, team invites, analytics improvements, and deployment hardening UI overhaul applying jury dashboard design patterns across all pages: - Stat cards with border-l-4 accent + icon pills on admin, observer, mentor, applicant dashboards and reports - Card section headers with color-coded icon pills throughout - Hover lift effects (translate-y + shadow) on cards and list items - Gradient progress bars (brand-teal to brand-blue) platform-wide - AnimatedCard stagger animations on all dashboard sections - Auth pages with gradient accent strip and polished icon containers - EmptyState component upgraded with rounded icon pill containers - Replaced AI-looking icons (Brain/Sparkles/Bot/Wand2/Cpu) with descriptive alternatives across 12 files - Removed gradient overlay from jury dashboard header - Quick actions restyled as card links with group hover effects Backend improvements: - Team member invite emails with account setup flow and notification logging - Analytics routers accept edition-wide queries (programId) in addition to roundId - Round detail endpoint returns inline progress data (eliminates extra getProgress call) - Award voting endpoints parallelized with Promise.all - Bulk invite supports optional sendInvitation flag - AwardVote composite index migration for query performance Infrastructure: - Docker entrypoint with migration retry loop (configurable retries/delay) - docker-compose pull_policy: always for automatic image refresh - Simplified deploy/update scripts using docker compose up -d --pull always - Updated deployment documentation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 13:20:52 +01:00
},
})
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
// Use jury-specific template for jury members
if (user.role === 'JURY_MEMBER') {
await sendJuryInvitationEmail(user.email, user.name, inviteUrl, 'the evaluation round')
} else {
await sendInvitationEmail(user.email, user.name, inviteUrl, user.role, expiryHours)
}
Platform-wide visual overhaul, team invites, analytics improvements, and deployment hardening UI overhaul applying jury dashboard design patterns across all pages: - Stat cards with border-l-4 accent + icon pills on admin, observer, mentor, applicant dashboards and reports - Card section headers with color-coded icon pills throughout - Hover lift effects (translate-y + shadow) on cards and list items - Gradient progress bars (brand-teal to brand-blue) platform-wide - AnimatedCard stagger animations on all dashboard sections - Auth pages with gradient accent strip and polished icon containers - EmptyState component upgraded with rounded icon pill containers - Replaced AI-looking icons (Brain/Sparkles/Bot/Wand2/Cpu) with descriptive alternatives across 12 files - Removed gradient overlay from jury dashboard header - Quick actions restyled as card links with group hover effects Backend improvements: - Team member invite emails with account setup flow and notification logging - Analytics routers accept edition-wide queries (programId) in addition to roundId - Round detail endpoint returns inline progress data (eliminates extra getProgress call) - Award voting endpoints parallelized with Promise.all - Bulk invite supports optional sendInvitation flag - AwardVote composite index migration for query performance Infrastructure: - Docker entrypoint with migration retry loop (configurable retries/delay) - docker-compose pull_policy: always for automatic image refresh - Simplified deploy/update scripts using docker compose up -d --pull always - Updated deployment documentation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 13:20:52 +01:00
await ctx.prisma.notificationLog.create({
data: {
userId: user.id,
channel: 'EMAIL',
provider: 'SMTP',
type: 'JURY_INVITATION',
status: 'SENT',
},
})
emailsSent++
} catch (e) {
emailErrors.push(user.email)
await ctx.prisma.notificationLog.create({
data: {
userId: user.id,
channel: 'EMAIL',
provider: 'SMTP',
type: 'JURY_INVITATION',
status: 'FAILED',
errorMsg: e instanceof Error ? e.message : 'Unknown error',
},
})
}
}
}
return { created: created.count, skipped, emailsSent, emailErrors, assignmentsCreated, juryGroupMembershipsCreated, assignmentIntentsCreated, invitationSent: input.sendInvitation }
}),
/**
* Get jury members for assignment
*/
getJuryMembers: adminProcedure
.input(
z.object({
roundId: z.string().optional(),
search: z.string().optional(),
})
)
.query(async ({ ctx, input }) => {
const where: Record<string, unknown> = {
roles: { has: 'JURY_MEMBER' },
status: 'ACTIVE',
}
if (input.search) {
where.OR = [
{ email: { contains: input.search, mode: 'insensitive' } },
{ name: { contains: input.search, mode: 'insensitive' } },
]
}
const users = await ctx.prisma.user.findMany({
where,
select: {
id: true,
email: true,
name: true,
expertiseTags: true,
maxAssignments: true,
Implement 15 platform features: digest, availability, templates, comparison, live voting SSE, file versioning, mentorship, messaging, analytics, drafts, webhooks, peer review, audit enhancements, i18n Features implemented: - F1: Email digest notifications with cron endpoint and per-user frequency - F2: Jury availability windows and workload preferences in smart assignment - F3: Round templates with save-from-round and CRUD management - F4: Side-by-side project comparison view for jury members - F5: Real-time voting dashboard with Server-Sent Events (SSE) - F6: Live voting UX: QR codes, audience voting, tie-breaking, score animations - F7: File versioning, inline preview, bulk download with presigned URLs - F8: Mentor dashboard: milestones, private notes, activity tracking - F9: Communication hub with broadcasts, templates, and recipient targeting - F10: Advanced analytics: cross-round comparison, juror consistency, diversity metrics, PDF export - F11: Applicant draft saving with magic link resume and cron cleanup - F12: Webhook integration layer with HMAC signing, retry, and delivery logs - F13: Peer review discussions with anonymized scores and threaded comments - F14: Audit log enhancements: before/after diffs, session grouping, anomaly detection, retention - F15: i18n foundation with next-intl (EN/FR), cookie-based locale, language switcher Schema: 12 new models, field additions to User, Project, ProjectFile, LiveVotingSession, LiveVote, MentorAssignment, AuditLog, Program New routers: roundTemplate, message, webhook (registered in _app.ts) New services: email-digest, webhook-dispatcher New cron endpoints: /api/cron/digest, /api/cron/draft-cleanup, /api/cron/audit-cleanup New API routes: /api/live-voting/stream (SSE), /api/files/bulk-download All features are admin-configurable via SystemSettings or per-model settingsJson fields. Docker build verified successfully. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 23:31:41 +01:00
availabilityJson: true,
preferredWorkload: true,
profileImageKey: true,
profileImageProvider: true,
_count: {
select: {
assignments: input.roundId
? { where: { roundId: input.roundId } }
: true,
},
},
},
orderBy: { name: 'asc' },
})
const mapped = users.map((u) => ({
...u,
currentAssignments: u._count.assignments,
availableSlots:
u.maxAssignments !== null
? Math.max(0, u.maxAssignments - u._count.assignments)
: null,
}))
return attachAvatarUrls(mapped)
}),
/**
* Send invitation email to a user
*/
sendInvitation: adminProcedure
.input(z.object({
userId: z.string(),
juryGroupId: z.string().optional(),
}))
.mutation(async ({ ctx, input }) => {
const user = await ctx.prisma.user.findUniqueOrThrow({
where: { id: input.userId },
})
if (user.status !== 'NONE' && user.status !== 'INVITED') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'User has already accepted their invitation',
})
}
// Bind to jury group if specified (upsert to be idempotent)
if (input.juryGroupId) {
await ctx.prisma.juryGroupMember.upsert({
where: {
juryGroupId_userId: {
juryGroupId: input.juryGroupId,
userId: user.id,
},
},
create: {
juryGroupId: input.juryGroupId,
userId: user.id,
role: 'MEMBER',
},
update: {}, // No-op if already exists
})
}
// Generate invite token, set status to INVITED, and store on user
const token = generateInviteToken()
const expiryHours = await getInviteExpiryHours(ctx.prisma)
await ctx.prisma.user.update({
where: { id: user.id },
data: {
status: 'INVITED',
inviteToken: token,
inviteTokenExpiresAt: new Date(Date.now() + expiryHours * 60 * 60 * 1000),
},
})
const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
// Send invitation email — use jury-specific template for jury members
if (user.role === 'JURY_MEMBER') {
// Try to resolve a round name for the jury invitation email
let roundName = 'the evaluation round'
if (input.juryGroupId) {
const juryGroup = await ctx.prisma.juryGroup.findUnique({
where: { id: input.juryGroupId },
select: { rounds: { select: { name: true }, take: 1, orderBy: { sortOrder: 'asc' } } },
})
if (juryGroup?.rounds[0]?.name) {
roundName = juryGroup.rounds[0].name
}
}
await sendJuryInvitationEmail(user.email, user.name, inviteUrl, roundName)
} else {
await sendInvitationEmail(user.email, user.name, inviteUrl, user.role, expiryHours)
}
// Log notification
await ctx.prisma.notificationLog.create({
data: {
userId: user.id,
channel: 'EMAIL',
provider: 'SMTP',
type: 'JURY_INVITATION',
status: 'SENT',
},
})
// Audit log
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'SEND_INVITATION',
entityType: 'User',
entityId: user.id,
detailsJson: { email: user.email },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { success: true, email: user.email }
}),
feat(admin): generate access link for users when email isn't reaching them Adds a "Copy Access Link" button on the member detail page that mints a one-time URL the admin can share over Slack, WhatsApp, or any other channel. Solves the "we sent them an invite three weeks ago and it silently dropped into spam" failure mode that left jurors stranded. Server: user.generateAccessLink (adminProcedure) inspects the target user's state and picks the right flow: - INVITED / NONE / mustSetPassword / no password ever set → invite-flow URL (/accept-invite?token=…); the existing flow takes them through accept → set password → onboarding without further admin help. - Active user with a password → password-reset URL (/reset-password?token=…); they pick a new password and middleware bounces them to onboarding if it's still pending. Both flows already exist; this just exposes a way to mint a fresh token without sending an email. The token has a 24h hard expiry and is consumed on successful completion of the flow, so a leaked or screenshot link can't be replayed against a different user later in the day. Each generation is audit-logged with the admin's id, the target user's id + email, and the link kind. UI: button next to Resend Invite on /admin/members/[id]; opens a dialog with a read-only input pre-selected, a one-click copy button, expiry timestamp, and a warning not to paste in public channels. Side benefit: users like Didier who have stale JWTs from a recent role change can use a fresh access link to force a re-login that picks up their updated role. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 17:28:43 +02:00
/**
* Generate an access link an admin can copy and hand to the user out-of-band
* (Slack, WhatsApp, in person) when email isn't reaching them.
*
* Picks the right flow based on the user's state:
* - INVITED / mustSetPassword / no password ever set invite-flow URL
* (`/accept-invite?token=…`); the existing flow walks them through
* accept set-password onboarding without further help.
* - Active user with a password password-reset URL
* (`/reset-password?token=…`); they choose a new password and the
* middleware bounces them to onboarding if they haven't finished.
*
* Each generation rotates the token, sets a 24h expiry, and is consumed
* the first time the user completes the flow (so leaked or screenshot
* links can't be replayed). Audit-logged with the admin's id.
*/
generateAccessLink: adminProcedure
.input(z.object({ userId: z.string() }))
.mutation(async ({ ctx, input }) => {
const user = await ctx.prisma.user.findUniqueOrThrow({
where: { id: input.userId },
select: {
id: true,
email: true,
name: true,
status: true,
mustSetPassword: true,
passwordSetAt: true,
},
})
if (user.status === 'SUSPENDED') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Cannot generate an access link for a suspended user',
})
}
const token = generateInviteToken()
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000) // 24h
const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'
const needsInvite =
user.status === 'INVITED' ||
user.status === 'NONE' ||
user.mustSetPassword ||
!user.passwordSetAt
let url: string
let kind: 'invite' | 'reset'
if (needsInvite) {
await ctx.prisma.user.update({
where: { id: user.id },
data: {
inviteToken: token,
inviteTokenExpiresAt: expiresAt,
// Bump NONE → INVITED so the accept-invite credentials provider works
...(user.status === 'NONE' ? { status: 'INVITED' as const } : {}),
},
})
url = `${baseUrl}/accept-invite?token=${token}`
kind = 'invite'
} else {
await ctx.prisma.user.update({
where: { id: user.id },
data: {
passwordResetToken: token,
passwordResetExpiresAt: expiresAt,
},
})
url = `${baseUrl}/reset-password?token=${token}`
kind = 'reset'
}
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'GENERATE_ACCESS_LINK',
entityType: 'User',
entityId: user.id,
detailsJson: {
targetEmail: user.email,
targetName: user.name,
kind,
expiresAt: expiresAt.toISOString(),
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { url, kind, expiresAt, email: user.email, name: user.name }
}),
/**
* Send invitation emails to multiple users
*/
bulkSendInvitations: adminProcedure
.input(z.object({ userIds: z.array(z.string()) }))
.mutation(async ({ ctx, input }) => {
const users = await ctx.prisma.user.findMany({
where: {
id: { in: input.userIds },
status: { in: ['NONE', 'INVITED'] },
},
})
if (users.length === 0) {
return { sent: 0, skipped: input.userIds.length }
}
const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'
const expiryHours = await getInviteExpiryHours(ctx.prisma)
const expiryMs = expiryHours * 60 * 60 * 1000
let sent = 0
const errors: string[] = []
for (const user of users) {
try {
// Generate invite token for each user and set status to INVITED
const token = generateInviteToken()
await ctx.prisma.user.update({
where: { id: user.id },
data: {
status: 'INVITED',
inviteToken: token,
inviteTokenExpiresAt: new Date(Date.now() + expiryMs),
},
})
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
// Use jury-specific template for jury members
if (user.role === 'JURY_MEMBER') {
await sendJuryInvitationEmail(user.email, user.name, inviteUrl, 'the evaluation round')
} else {
await sendInvitationEmail(user.email, user.name, inviteUrl, user.role, expiryHours)
}
await ctx.prisma.notificationLog.create({
data: {
userId: user.id,
channel: 'EMAIL',
provider: 'SMTP',
type: 'JURY_INVITATION',
status: 'SENT',
},
})
sent++
} catch (e) {
errors.push(user.email)
await ctx.prisma.notificationLog.create({
data: {
userId: user.id,
channel: 'EMAIL',
provider: 'SMTP',
type: 'JURY_INVITATION',
status: 'FAILED',
errorMsg: e instanceof Error ? e.message : 'Unknown error',
},
})
}
}
// Audit log
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'BULK_SEND_INVITATIONS',
entityType: 'User',
detailsJson: { sent, errors },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { sent, skipped: input.userIds.length - users.length, errors }
}),
/**
* Complete onboarding for current user
*/
completeOnboarding: protectedProcedure
.input(
z.object({
name: z.string().min(1).max(255),
phoneNumber: z.string().optional(),
country: z.string().optional(),
nationality: z.string().optional(),
institution: z.string().optional(),
bio: z.string().max(500).optional(),
expertiseTags: z.array(z.string()).optional(),
notificationPreference: z.enum(['EMAIL', 'WHATSAPP', 'BOTH', 'NONE']).optional(),
// Competition architecture: jury self-service preferences
juryPreferences: z
.array(
z.object({
juryGroupMemberId: z.string(),
selfServiceCap: z.number().int().positive().optional(),
selfServiceRatio: z.number().min(0).max(1).optional(),
})
)
.optional(),
})
)
.mutation(async ({ ctx, input }) => {
// Get existing user to preserve admin-set tags
const existingUser = await ctx.prisma.user.findUniqueOrThrow({
where: { id: ctx.user.id },
select: { expertiseTags: true },
})
// Merge admin-set tags with user-selected tags (preserving order: admin first, then user)
const adminTags = existingUser.expertiseTags || []
const userTags = input.expertiseTags || []
const mergedTags = [...new Set([...adminTags, ...userTags])]
const user = await ctx.prisma.$transaction(async (tx) => {
const updated = await tx.user.update({
where: { id: ctx.user.id },
data: {
name: input.name,
phoneNumber: input.phoneNumber,
country: input.country,
nationality: input.nationality,
institution: input.institution,
bio: input.bio,
expertiseTags: mergedTags,
notificationPreference: input.notificationPreference || 'EMAIL',
onboardingCompletedAt: new Date(),
status: 'ACTIVE', // Activate user after onboarding
},
})
// Process jury self-service preferences
if (input.juryPreferences && input.juryPreferences.length > 0) {
for (const pref of input.juryPreferences) {
// Security: verify this member belongs to the current user
const member = await tx.juryGroupMember.findUnique({
where: { id: pref.juryGroupMemberId },
})
if (!member || member.userId !== ctx.user.id) continue
await tx.juryGroupMember.update({
where: { id: pref.juryGroupMemberId },
data: {
selfServiceCap: pref.selfServiceCap != null ? Math.min(pref.selfServiceCap, 50) : undefined,
selfServiceRatio: pref.selfServiceRatio,
},
})
}
}
return updated
})
// Audit outside transaction so failures don't roll back the onboarding
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'COMPLETE_ONBOARDING',
entityType: 'User',
entityId: ctx.user.id,
detailsJson: { name: input.name, juryPreferencesCount: input.juryPreferences?.length ?? 0 },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return user
}),
/**
* Update jury preferences outside of onboarding (e.g., when a new round opens).
*/
updateJuryPreferences: protectedProcedure
.input(
z.object({
preferences: z.array(
z.object({
juryGroupMemberId: z.string(),
selfServiceCap: z.number().int().min(1).max(50),
selfServiceRatio: z.number().min(0).max(1),
})
),
})
)
.mutation(async ({ ctx, input }) => {
for (const pref of input.preferences) {
const member = await ctx.prisma.juryGroupMember.findUnique({
where: { id: pref.juryGroupMemberId },
})
if (!member || member.userId !== ctx.user.id) continue
await ctx.prisma.juryGroupMember.update({
where: { id: pref.juryGroupMemberId },
data: {
selfServiceCap: pref.selfServiceCap,
selfServiceRatio: pref.selfServiceRatio,
},
})
}
return { success: true }
}),
/**
* Get onboarding context for the current user.
* Returns jury group memberships for self-service preferences.
*/
getOnboardingContext: protectedProcedure.query(async ({ ctx }) => {
const memberships = await ctx.prisma.juryGroupMember.findMany({
where: {
userId: ctx.user.id,
juryGroup: {
rounds: {
some: {
roundType: {
in: ['INTAKE', 'FILTERING', 'EVALUATION', 'SUBMISSION', 'MENTORING'],
},
},
},
},
},
include: {
juryGroup: {
select: {
id: true,
name: true,
defaultMaxAssignments: true,
},
},
},
})
return {
hasSelfServiceOptions: memberships.length > 0,
memberships: memberships.map((m) => ({
juryGroupMemberId: m.id,
juryGroupName: m.juryGroup.name,
currentCap: m.maxAssignmentsOverride ?? m.juryGroup.defaultMaxAssignments,
selfServiceCap: m.selfServiceCap,
selfServiceRatio: m.selfServiceRatio,
preferredStartupRatio: m.preferredStartupRatio,
})),
}
}),
/**
* Check if current user needs onboarding
*/
needsOnboarding: protectedProcedure.query(async ({ ctx }) => {
const user = await ctx.prisma.user.findUniqueOrThrow({
where: { id: ctx.user.id },
select: { onboardingCompletedAt: true, role: true },
})
// Jury members, mentors, and admins need onboarding
const rolesRequiringOnboarding = ['JURY_MEMBER', 'MENTOR', 'PROGRAM_ADMIN', 'SUPER_ADMIN']
if (!rolesRequiringOnboarding.includes(user.role)) {
return false
}
return user.onboardingCompletedAt === null
}),
/**
* Check if current user needs to set a password
*/
needsPasswordSetup: protectedProcedure.query(async ({ ctx }) => {
const user = await ctx.prisma.user.findUniqueOrThrow({
where: { id: ctx.user.id },
select: { mustSetPassword: true, passwordHash: true },
})
return user.mustSetPassword || user.passwordHash === null
}),
/**
* Set password for current user
*/
setPassword: protectedProcedure
.input(
z.object({
password: z.string().min(8),
confirmPassword: z.string().min(8),
})
)
.mutation(async ({ ctx, input }) => {
// Validate passwords match
if (input.password !== input.confirmPassword) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Passwords do not match',
})
}
// Validate password requirements
const validation = validatePassword(input.password)
if (!validation.valid) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: validation.errors.join('. '),
})
}
// Hash the password
const passwordHash = await hashPassword(input.password)
// Update user with new password
const user = await ctx.prisma.user.update({
where: { id: ctx.user.id },
data: {
passwordHash,
passwordSetAt: new Date(),
mustSetPassword: false,
},
})
// Audit outside transaction so failures don't roll back the password set
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'PASSWORD_SET',
entityType: 'User',
entityId: ctx.user.id,
detailsJson: { timestamp: new Date().toISOString() },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { success: true, email: user.email }
}),
/**
* Change password for current user (requires current password)
*/
changePassword: protectedProcedure
.input(
z.object({
currentPassword: z.string().min(1),
newPassword: z.string().min(8),
confirmNewPassword: z.string().min(8),
})
)
.mutation(async ({ ctx, input }) => {
// Get current user with password hash
const user = await ctx.prisma.user.findUniqueOrThrow({
where: { id: ctx.user.id },
select: { passwordHash: true },
})
if (!user.passwordHash) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'No password set. Please use magic link to sign in.',
})
}
// Verify current password
const { verifyPassword } = await import('@/lib/password')
const isValid = await verifyPassword(input.currentPassword, user.passwordHash)
if (!isValid) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Current password is incorrect',
})
}
// Validate new passwords match
if (input.newPassword !== input.confirmNewPassword) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'New passwords do not match',
})
}
// Validate new password requirements
const validation = validatePassword(input.newPassword)
if (!validation.valid) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: validation.errors.join('. '),
})
}
// Hash the new password
const passwordHash = await hashPassword(input.newPassword)
// Update user with new password
await ctx.prisma.user.update({
where: { id: ctx.user.id },
data: {
passwordHash,
passwordSetAt: new Date(),
},
})
// Audit outside transaction so failures don't roll back the password change
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'PASSWORD_CHANGED',
entityType: 'User',
entityId: ctx.user.id,
detailsJson: { timestamp: new Date().toISOString() },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { success: true }
}),
/**
* Request password reset (public - no auth required)
* Sends a magic link and marks user for password reset
*/
requestPasswordReset: publicProcedure
.input(z.object({ email: z.string().email() }))
.mutation(async ({ ctx, input }) => {
const email = input.email.toLowerCase().trim()
// Find user by email (case-insensitive — DB may store mixed-case emails)
const user = await ctx.prisma.user.findFirst({
where: { email: { equals: email, mode: 'insensitive' } },
select: { id: true, email: true, name: true, status: true },
})
// Always return success to prevent email enumeration
if (!user || user.status === 'SUSPENDED') {
return { success: true }
}
// Generate reset token + expiry (30 minutes)
const token = generateInviteToken()
const expiryMinutes = 30
const expiresAt = new Date(Date.now() + expiryMinutes * 60 * 1000)
await ctx.prisma.user.update({
where: { id: user.id },
data: {
passwordResetToken: token,
passwordResetExpiresAt: expiresAt,
},
})
// Send password reset email
const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'
const resetUrl = `${baseUrl}/reset-password?token=${token}`
try {
await sendPasswordResetEmail(user.email, resetUrl, expiryMinutes)
} catch (e) {
console.error('[auth] Failed to send password reset email:', e)
// Don't reveal failure to prevent enumeration
}
// Audit log
await logAudit({
prisma: ctx.prisma,
userId: null,
action: 'REQUEST_PASSWORD_RESET',
entityType: 'User',
entityId: user.id,
detailsJson: { email, timestamp: new Date().toISOString() },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
}).catch(() => {})
return { success: true }
}),
/**
* Reset password using a token (public from the reset-password page)
*/
resetPassword: publicProcedure
.input(z.object({
token: z.string().min(1),
password: z.string().min(8),
confirmPassword: z.string().min(8),
}))
.mutation(async ({ ctx, input }) => {
if (input.password !== input.confirmPassword) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Passwords do not match' })
}
const validation = validatePassword(input.password)
if (!validation.valid) {
throw new TRPCError({ code: 'BAD_REQUEST', message: validation.errors.join('. ') })
}
// Find user by reset token
const user = await ctx.prisma.user.findUnique({
where: { passwordResetToken: input.token },
select: { id: true, email: true, status: true, passwordResetExpiresAt: true },
})
if (!user) {
await logAudit({
prisma: ctx.prisma,
userId: null,
action: 'PASSWORD_RESET_LINK_INVALID',
entityType: 'User',
entityId: 'unknown',
detailsJson: { reason: 'token_not_found', timestamp: new Date().toISOString() },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
}).catch(() => {})
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid or expired reset link. Please request a new one.' })
}
if (user.status === 'SUSPENDED') {
await logAudit({
prisma: ctx.prisma,
userId: user.id,
action: 'PASSWORD_RESET_LINK_INVALID',
entityType: 'User',
entityId: user.id,
detailsJson: { reason: 'account_suspended', email: user.email, timestamp: new Date().toISOString() },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
}).catch(() => {})
throw new TRPCError({ code: 'FORBIDDEN', message: 'This account has been suspended.' })
}
if (user.passwordResetExpiresAt && user.passwordResetExpiresAt < new Date()) {
await logAudit({
prisma: ctx.prisma,
userId: user.id,
action: 'PASSWORD_RESET_LINK_EXPIRED',
entityType: 'User',
entityId: user.id,
detailsJson: { email: user.email, expiredAt: user.passwordResetExpiresAt.toISOString(), timestamp: new Date().toISOString() },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
}).catch(() => {})
// Clear expired token
await ctx.prisma.user.update({
where: { id: user.id },
data: { passwordResetToken: null, passwordResetExpiresAt: null },
})
throw new TRPCError({ code: 'BAD_REQUEST', message: 'This reset link has expired. Please request a new one.' })
}
// Audit: reset link clicked and valid
await logAudit({
prisma: ctx.prisma,
userId: user.id,
action: 'PASSWORD_RESET_LINK_CLICKED',
entityType: 'User',
entityId: user.id,
detailsJson: { email: user.email, timestamp: new Date().toISOString() },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
}).catch(() => {})
// Hash and save new password, clear reset token
const passwordHash = await hashPassword(input.password)
await ctx.prisma.user.update({
where: { id: user.id },
data: {
passwordHash,
passwordSetAt: new Date(),
mustSetPassword: false,
passwordResetToken: null,
passwordResetExpiresAt: null,
},
})
// Audit log
await logAudit({
prisma: ctx.prisma,
userId: user.id,
action: 'PASSWORD_RESET',
entityType: 'User',
entityId: user.id,
detailsJson: { email: user.email },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
}).catch(() => {})
return { success: true }
}),
Implement 15 platform features: digest, availability, templates, comparison, live voting SSE, file versioning, mentorship, messaging, analytics, drafts, webhooks, peer review, audit enhancements, i18n Features implemented: - F1: Email digest notifications with cron endpoint and per-user frequency - F2: Jury availability windows and workload preferences in smart assignment - F3: Round templates with save-from-round and CRUD management - F4: Side-by-side project comparison view for jury members - F5: Real-time voting dashboard with Server-Sent Events (SSE) - F6: Live voting UX: QR codes, audience voting, tie-breaking, score animations - F7: File versioning, inline preview, bulk download with presigned URLs - F8: Mentor dashboard: milestones, private notes, activity tracking - F9: Communication hub with broadcasts, templates, and recipient targeting - F10: Advanced analytics: cross-round comparison, juror consistency, diversity metrics, PDF export - F11: Applicant draft saving with magic link resume and cron cleanup - F12: Webhook integration layer with HMAC signing, retry, and delivery logs - F13: Peer review discussions with anonymized scores and threaded comments - F14: Audit log enhancements: before/after diffs, session grouping, anomaly detection, retention - F15: i18n foundation with next-intl (EN/FR), cookie-based locale, language switcher Schema: 12 new models, field additions to User, Project, ProjectFile, LiveVotingSession, LiveVote, MentorAssignment, AuditLog, Program New routers: roundTemplate, message, webhook (registered in _app.ts) New services: email-digest, webhook-dispatcher New cron endpoints: /api/cron/digest, /api/cron/draft-cleanup, /api/cron/audit-cleanup New API routes: /api/live-voting/stream (SSE), /api/files/bulk-download All features are admin-configurable via SystemSettings or per-model settingsJson fields. Docker build verified successfully. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 23:31:41 +01:00
/**
* Get current user's digest settings along with global digest config
*/
getDigestSettings: protectedProcedure.query(async ({ ctx }) => {
const [user, digestEnabled, digestSections] = await Promise.all([
ctx.prisma.user.findUniqueOrThrow({
where: { id: ctx.user.id },
select: { digestFrequency: true },
}),
ctx.prisma.systemSettings.findUnique({
where: { key: 'digest_enabled' },
select: { value: true },
}),
ctx.prisma.systemSettings.findUnique({
where: { key: 'digest_sections' },
select: { value: true },
}),
])
return {
digestFrequency: user.digestFrequency,
globalDigestEnabled: digestEnabled?.value === 'true',
globalDigestSections: digestSections?.value ? JSON.parse(digestSections.value) : [],
}
}),
/**
* Update a user's roles array (admin only)
* Also updates the primary role to the highest privilege role in the array.
*/
updateRoles: adminProcedure
.input(z.object({
userId: z.string(),
roles: z.array(z.nativeEnum(UserRole)).min(1),
}))
.mutation(async ({ ctx, input }) => {
2026-04-29 03:29:09 +02:00
const callerIsSuperAdmin = ctx.user.role === 'SUPER_ADMIN'
// Guard: only SUPER_ADMIN can grant SUPER_ADMIN or PROGRAM_ADMIN
if (input.roles.includes('SUPER_ADMIN') && !callerIsSuperAdmin) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only super admins can grant super admin role' })
}
2026-04-29 03:29:09 +02:00
if (input.roles.includes('PROGRAM_ADMIN') && !callerIsSuperAdmin) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only super admins can grant admin role' })
}
// Guard: only SUPER_ADMIN can modify a user who currently holds SUPER_ADMIN
// or PROGRAM_ADMIN — otherwise a PROGRAM_ADMIN could demote peers/super-admins.
const target = await ctx.prisma.user.findUniqueOrThrow({
where: { id: input.userId },
select: { role: true, roles: true },
})
const targetHasAdmin =
target.role === 'SUPER_ADMIN' ||
target.role === 'PROGRAM_ADMIN' ||
target.roles.includes('SUPER_ADMIN') ||
target.roles.includes('PROGRAM_ADMIN')
if (targetHasAdmin && !callerIsSuperAdmin) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only super admins can change admin roles' })
}
// Set primary role to highest privilege role
refactor(awards): remove AWARD_MASTER role, fold features into jury chair flow The AWARD_MASTER role split sponsor jurors into a parallel UI that hid project files (only showed when the award was anchored to an evaluation round) and duplicated the jury voting path with no real difference in authority — tie-break and finalize were already governed by AwardJuror.isChair regardless of the user's global role. Inviting a juror via the award page defaulted to AWARD_MASTER, randomly fragmenting jury panels. This collapses the role into JURY_MEMBER + isChair: - specialAward.getMyAwardDetail now returns evaluation scores, chair visibility into other jurors' votes, and juror roster - specialAward.submitVote accepts an optional justification per vote - specialAward.confirmWinner moves from awardMasterProcedure to protectedProcedure (juror+chair check inside) - bulkInviteJurors creates JURY_MEMBER accounts and, when the award has a juryGroupId, also adds them to that JuryGroup so they appear on the round-page jury panel - jury award page renders justification, eval-score badges, and a chair tools panel with vote tally + finalize-winner CTA - juryGroup.list includes attached SpecialAwards; the jury-list UI shows a trophy pill alongside round pills - (award-master) route group, awardMasterProcedure, AWARD_MASTER role enum value, and AWARD_MASTER_DECISION decisionMode are deleted - migration demotes any residual AWARD_MASTER users to JURY_MEMBER and recreates the UserRole enum without the value Coup de Coeur on prod: Didier (the sponsor juror added today as AWARD_MASTER by the buggy invite form) was migrated to JURY_MEMBER and attached to the existing "Coup de Coeur" JuryGroup; the SpecialAward itself was linked to that group (juryGroupId was NULL). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:21:09 +02:00
const rolePriority: UserRole[] = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER', 'APPLICANT', 'AUDIENCE']
const primaryRole = rolePriority.find(r => input.roles.includes(r)) || input.roles[0]
return ctx.prisma.user.update({
where: { id: input.userId },
data: { roles: input.roles, role: primaryRole },
})
}),
/**
* Bulk add/remove a single role across multiple users. Fires the mentor
* onboarding email exactly once per user when MENTOR is freshly added,
* idempotent via User.mentorOnboardingSentAt.
*/
bulkUpdateRoles: adminProcedure
.input(
z.object({
userIds: z.array(z.string()).min(1).max(200),
addRole: z.nativeEnum(UserRole).optional(),
removeRole: z.nativeEnum(UserRole).optional(),
}).refine((d) => d.addRole || d.removeRole, {
message: 'Provide addRole or removeRole',
}),
)
.mutation(async ({ ctx, input }) => {
2026-04-29 03:29:09 +02:00
const callerIsSuperAdmin = ctx.user.role === 'SUPER_ADMIN'
// Self-demote guards
if (input.removeRole === 'SUPER_ADMIN' && input.userIds.includes(ctx.user.id)) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You cannot remove SUPER_ADMIN from self',
})
}
2026-04-29 03:29:09 +02:00
if (input.removeRole === 'PROGRAM_ADMIN' && input.userIds.includes(ctx.user.id)) {
throw new TRPCError({
code: 'FORBIDDEN',
2026-04-29 03:29:09 +02:00
message: 'You cannot remove PROGRAM_ADMIN from self',
})
}
// Privilege guards: only SUPER_ADMIN may add/remove SUPER_ADMIN or PROGRAM_ADMIN.
// Without the symmetric remove-side guard a PROGRAM_ADMIN could strip
// SUPER_ADMIN from peers; without the add-side PROGRAM_ADMIN guard a
// PROGRAM_ADMIN could grant peer-admin laterally.
if (
(input.addRole === 'SUPER_ADMIN' || input.removeRole === 'SUPER_ADMIN') &&
!callerIsSuperAdmin
) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Only super admins can change super admin role',
})
}
if (
(input.addRole === 'PROGRAM_ADMIN' || input.removeRole === 'PROGRAM_ADMIN') &&
!callerIsSuperAdmin
) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Only super admins can change admin role',
})
}
const targets = await ctx.prisma.user.findMany({
where: { id: { in: input.userIds } },
2026-04-29 03:29:09 +02:00
select: { id: true, name: true, email: true, role: true, roles: true, mentorOnboardingSentAt: true },
})
2026-04-29 03:29:09 +02:00
// Block modifying any target who currently holds an admin tier role
// unless the caller is SUPER_ADMIN. This prevents a PROGRAM_ADMIN from
// using a non-admin add/remove (e.g. addRole: MENTOR) to mutate the
// record of a SUPER_ADMIN target — even though Prisma would only touch
// `roles[]`, the audit trail and downstream logic shouldn't allow
// peer admins to mutate higher-tier accounts at all.
if (!callerIsSuperAdmin) {
const adminTarget = targets.find(
(t) =>
t.role === 'SUPER_ADMIN' ||
t.role === 'PROGRAM_ADMIN' ||
t.roles.includes('SUPER_ADMIN') ||
t.roles.includes('PROGRAM_ADMIN'),
)
if (adminTarget) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Only super admins can modify admin accounts',
})
}
}
let updated = 0
let alreadyHadRole = 0
const newlyMentor: typeof targets = []
const rolePriority: UserRole[] = [
'SUPER_ADMIN',
'PROGRAM_ADMIN',
'JURY_MEMBER',
'MENTOR',
'OBSERVER',
'APPLICANT',
'AUDIENCE',
]
for (const u of targets) {
const current = new Set(u.roles)
const next = new Set(current)
if (input.addRole) {
if (current.has(input.addRole)) {
alreadyHadRole++
continue
}
next.add(input.addRole)
}
if (input.removeRole) {
if (!current.has(input.removeRole)) {
alreadyHadRole++
continue
}
next.delete(input.removeRole)
}
if (next.size === 0) next.add('APPLICANT' as UserRole) // safety: never empty
const nextArr = Array.from(next)
const primary = rolePriority.find((r) => nextArr.includes(r)) ?? nextArr[0]
const isFreshMentor =
input.addRole === 'MENTOR' && !current.has('MENTOR') && !u.mentorOnboardingSentAt
await ctx.prisma.user.update({
where: { id: u.id },
data: {
roles: nextArr,
role: primary,
...(isFreshMentor ? { mentorOnboardingSentAt: new Date() } : {}),
},
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: input.addRole ? 'USER_ROLE_ADD' : 'USER_ROLE_REMOVE',
entityType: 'User',
entityId: u.id,
detailsJson: {
addRole: input.addRole,
removeRole: input.removeRole,
before: u.roles,
after: nextArr,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
if (isFreshMentor) newlyMentor.push(u)
updated++
}
// Send onboarding emails outside the per-user mutation loop. Errors
// caught and logged — we don't roll back the role grant if email
// delivery fails.
for (const u of newlyMentor) {
try {
await sendMentorOnboardingEmail(u.email, u.name)
} catch (err) {
console.error('[bulkUpdateRoles] mentor-onboarding email failed for', u.email, err)
}
}
return { updated, alreadyHadRole, total: targets.length }
}),
/**
* List applicant users with project info for admin bulk-invite page.
*/
getApplicants: adminProcedure
.input(
z.object({
search: z.string().optional(),
roundId: z.string().optional(),
status: z.enum(['NONE', 'INVITED', 'ACTIVE', 'SUSPENDED']).optional(),
page: z.number().int().positive().default(1),
perPage: z.number().int().positive().max(100).default(20),
})
)
.query(async ({ ctx, input }) => {
const where: Prisma.UserWhereInput = {
role: 'APPLICANT',
...(input.status && { status: input.status }),
...(input.search && {
OR: [
{ name: { contains: input.search, mode: 'insensitive' as const } },
{ email: { contains: input.search, mode: 'insensitive' as const } },
{ teamMemberships: { some: { project: { title: { contains: input.search, mode: 'insensitive' as const } } } } },
],
}),
...(input.roundId && {
teamMemberships: { some: { project: { projectRoundStates: { some: { roundId: input.roundId } } } } },
}),
}
const [users, total] = await Promise.all([
ctx.prisma.user.findMany({
where,
select: {
id: true,
email: true,
name: true,
status: true,
nationality: true,
institution: true,
lastLoginAt: true,
onboardingCompletedAt: true,
teamMemberships: {
take: 1,
select: {
role: true,
project: { select: { id: true, title: true } },
},
},
submittedProjects: {
take: 1,
select: { id: true, title: true },
},
},
orderBy: { name: 'asc' },
skip: (input.page - 1) * input.perPage,
take: input.perPage,
}),
ctx.prisma.user.count({ where }),
])
return {
users: users.map((u) => {
const project = u.submittedProjects[0] || u.teamMemberships[0]?.project || null
return {
id: u.id,
email: u.email,
name: u.name,
status: u.status,
nationality: u.nationality,
institution: u.institution,
lastLoginAt: u.lastLoginAt,
onboardingCompleted: !!u.onboardingCompletedAt,
projectName: project?.title ?? null,
projectId: project?.id ?? null,
}
}),
total,
totalPages: Math.ceil(total / input.perPage),
perPage: input.perPage,
}
}),
/**
* Bulk invite applicant users generates tokens, sets INVITED, sends emails.
*/
bulkInviteApplicants: adminProcedure
.input(z.object({ userIds: z.array(z.string()).min(1).max(500) }))
.mutation(async ({ ctx, input }) => {
const users = await ctx.prisma.user.findMany({
where: {
id: { in: input.userIds },
role: 'APPLICANT',
status: { in: ['NONE', 'INVITED'] },
},
select: { id: true, email: true, name: true, status: true },
})
const expiryMs = await getInviteExpiryMs(ctx.prisma)
let sent = 0
let skipped = 0
const failed: string[] = []
const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'
for (const user of users) {
try {
const token = generateInviteToken()
await ctx.prisma.user.update({
where: { id: user.id },
data: {
status: 'INVITED',
inviteToken: token,
inviteTokenExpiresAt: new Date(Date.now() + expiryMs),
},
})
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
await sendInvitationEmail(user.email, user.name || 'Applicant', inviteUrl, 'APPLICANT')
sent++
} catch (error) {
failed.push(user.email)
}
}
skipped = input.userIds.length - users.length
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'BULK_INVITE_APPLICANTS',
entityType: 'User',
entityId: 'bulk',
detailsJson: { sent, skipped, failed: failed.length, total: input.userIds.length },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { sent, skipped, failed }
}),
/**
* Start impersonating a user (super admin only)
*/
startImpersonation: superAdminProcedure
.input(z.object({ targetUserId: z.string() }))
.mutation(async ({ ctx, input }) => {
// Block nested impersonation
if ((ctx.session as unknown as { user?: { impersonating?: unknown } })?.user?.impersonating) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Cannot start nested impersonation. End current impersonation first.',
})
}
const target = await ctx.prisma.user.findUnique({
where: { id: input.targetUserId },
select: { id: true, email: true, name: true, role: true, roles: true, status: true },
})
if (!target) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found' })
}
if (target.status === 'SUSPENDED') {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Cannot impersonate a suspended user' })
}
if (target.role === 'SUPER_ADMIN') {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Cannot impersonate another super admin' })
}
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'IMPERSONATION_START',
entityType: 'User',
entityId: target.id,
detailsJson: {
adminId: ctx.user.id,
adminEmail: ctx.user.email,
targetId: target.id,
targetEmail: target.email,
targetRole: target.role,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { targetUserId: target.id, targetRole: target.role }
}),
/**
* End impersonation and return to admin
*/
endImpersonation: protectedProcedure
.mutation(async ({ ctx }) => {
const session = ctx.session as unknown as { user?: { impersonating?: { originalId: string; originalEmail: string } } }
const impersonating = session?.user?.impersonating
if (!impersonating) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Not currently impersonating' })
}
await logAudit({
prisma: ctx.prisma,
userId: impersonating.originalId,
action: 'IMPERSONATION_END',
entityType: 'User',
entityId: ctx.user.id,
detailsJson: {
adminId: impersonating.originalId,
adminEmail: impersonating.originalEmail,
targetId: ctx.user.id,
targetEmail: ctx.user.email,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { ended: true }
}),
/**
* Context-aware default dashboard. Returns the highest-priority role for
* which the user has actionable work right now, or the highest-priority
* role they hold (static fallback) if nothing is actionable.
*
refactor(awards): remove AWARD_MASTER role, fold features into jury chair flow The AWARD_MASTER role split sponsor jurors into a parallel UI that hid project files (only showed when the award was anchored to an evaluation round) and duplicated the jury voting path with no real difference in authority — tie-break and finalize were already governed by AwardJuror.isChair regardless of the user's global role. Inviting a juror via the award page defaulted to AWARD_MASTER, randomly fragmenting jury panels. This collapses the role into JURY_MEMBER + isChair: - specialAward.getMyAwardDetail now returns evaluation scores, chair visibility into other jurors' votes, and juror roster - specialAward.submitVote accepts an optional justification per vote - specialAward.confirmWinner moves from awardMasterProcedure to protectedProcedure (juror+chair check inside) - bulkInviteJurors creates JURY_MEMBER accounts and, when the award has a juryGroupId, also adds them to that JuryGroup so they appear on the round-page jury panel - jury award page renders justification, eval-score badges, and a chair tools panel with vote tally + finalize-winner CTA - juryGroup.list includes attached SpecialAwards; the jury-list UI shows a trophy pill alongside round pills - (award-master) route group, awardMasterProcedure, AWARD_MASTER role enum value, and AWARD_MASTER_DECISION decisionMode are deleted - migration demotes any residual AWARD_MASTER users to JURY_MEMBER and recreates the UserRole enum without the value Coup de Coeur on prod: Didier (the sponsor juror added today as AWARD_MASTER by the buggy invite form) was migrated to JURY_MEMBER and attached to the existing "Coup de Coeur" JuryGroup; the SpecialAward itself was linked to that group (juryGroupId was NULL). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:21:09 +02:00
* Priority order: SUPER_ADMIN > PROGRAM_ADMIN > JURY_MEMBER >
* MENTOR > APPLICANT > OBSERVER > AUDIENCE.
*
* Used by src/app/page.tsx to route users at login.
*/
getDefaultDashboard: protectedProcedure.query(async ({ ctx }) => {
const user = await ctx.prisma.user.findUniqueOrThrow({
where: { id: ctx.user.id },
select: { id: true, roles: true, role: true },
})
const userRoles = new Set<UserRole>([...(user.roles ?? []), user.role])
type Entry = { role: UserRole; path: string; predicate: () => Promise<boolean> | boolean }
const PRIORITY: Entry[] = [
{ role: 'SUPER_ADMIN', path: '/admin', predicate: () => true },
{ role: 'PROGRAM_ADMIN', path: '/admin', predicate: () => true },
{
role: 'JURY_MEMBER',
path: '/jury',
predicate: async () => {
const cnt = await ctx.prisma.assignment.count({
where: {
userId: user.id,
isCompleted: false,
round: { status: 'ROUND_ACTIVE' },
},
})
return cnt > 0
},
},
{
role: 'MENTOR',
path: '/mentor',
predicate: async () => {
const cnt = await ctx.prisma.mentorAssignment.count({
where: {
mentorId: user.id,
workspaceEnabled: true,
project: {
projectRoundStates: {
some: { round: { status: 'ROUND_ACTIVE' } },
},
},
},
})
return cnt > 0
},
},
{
role: 'APPLICANT',
path: '/applicant',
predicate: async () => {
const cnt = await ctx.prisma.teamMember.count({
where: {
userId: user.id,
project: {
projectRoundStates: {
some: {
round: { status: 'ROUND_ACTIVE' },
state: { in: ['PENDING', 'IN_PROGRESS'] },
},
},
},
},
})
return cnt > 0
},
},
{ role: 'OBSERVER', path: '/observer', predicate: () => false },
{ role: 'AUDIENCE', path: '/applicant', predicate: () => false },
]
// Walk priority. Return first role the user holds whose predicate is true.
for (const entry of PRIORITY) {
if (!userRoles.has(entry.role)) continue
const has = await entry.predicate()
if (has) {
return { role: entry.role, path: entry.path, reason: 'has-active-work' as const }
}
}
// Static fallback: highest-priority role they hold.
for (const entry of PRIORITY) {
if (userRoles.has(entry.role)) {
return { role: entry.role, path: entry.path, reason: 'static-fallback' as const }
}
}
return { role: 'APPLICANT' as UserRole, path: '/applicant', reason: 'static-fallback' as const }
}),
})