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

1873 lines
59 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, sendMagicLinkEmail, 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({
email: z.string().email().optional(),
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.any().optional(),
preferredWorkload: z.number().int().min(1).max(100).optional().nullable(),
})
)
.mutation(async ({ ctx, input }) => {
const {
bio,
expertiseTags,
availabilityJson,
preferredWorkload,
digestFrequency,
email,
...directFields
} = input
const normalizedEmail = email?.toLowerCase().trim()
if (normalizedEmail !== undefined) {
const existing = await ctx.prisma.user.findFirst({
where: {
email: normalizedEmail,
NOT: { id: ctx.user.id },
},
select: { id: true },
})
if (existing) {
throw new TRPCError({
code: 'CONFLICT',
message: 'Another account already uses this email address',
})
}
}
// 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,
...(normalizedEmail !== undefined && { email: normalizedEmail }),
...(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',
})
}
// Delete user
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({
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(),
roles: z.array(z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', '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),
})
)
.query(async ({ ctx, input }) => {
const { role, roles, status, search, page, perPage } = 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 [users, total] = await Promise.all([
ctx.prisma.user.findMany({
where,
skip,
take: perPage,
orderBy: { createdAt: 'desc' },
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)
return {
users: usersWithAvatars,
total,
page,
perPage,
totalPages: Math.ceil(total / perPage),
}
}),
/**
* List all invitable user IDs for current filters (not paginated)
*/
listInvitableIds: adminProcedure
.input(
z.object({
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(),
roles: z.array(z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', '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(),
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', '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',
})
}
const user = await ctx.prisma.user.create({
data: {
...input,
status: 'INVITED',
},
})
// 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(),
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', '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(),
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: z.any().optional(),
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 },
})
if (targetUser.role === 'SUPER_ADMIN' && ctx.user.role !== 'SUPER_ADMIN') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Cannot modify super admin',
})
}
// Prevent non-super-admins from changing admin roles
if (data.role && targetUser.role === 'PROGRAM_ADMIN' && ctx.user.role !== 'SUPER_ADMIN') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Only super admins can change admin roles',
})
}
// Prevent non-super-admins from assigning super admin or admin role
if (data.role === 'SUPER_ADMIN' && ctx.user.role !== 'SUPER_ADMIN') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Only super admins can assign super admin role',
})
}
if (data.role === 'PROGRAM_ADMIN' && ctx.user.role !== 'SUPER_ADMIN') {
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 },
})
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(),
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', '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}`
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
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 }
}),
/**
* 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}`
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 },
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
const user = await ctx.prisma.user.findUnique({
where: { email },
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) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid or expired reset link. Please request a new one.' })
}
if (user.status === 'SUSPENDED') {
throw new TRPCError({ code: 'FORBIDDEN', message: 'This account has been suspended.' })
}
if (user.passwordResetExpiresAt && user.passwordResetExpiresAt < new Date()) {
// 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.' })
}
// 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 }) => {
// Guard: only SUPER_ADMIN can grant SUPER_ADMIN
if (input.roles.includes('SUPER_ADMIN') && ctx.user.role !== 'SUPER_ADMIN') {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only super admins can grant super admin role' })
}
// Set primary role to highest privilege role
const rolePriority: UserRole[] = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER', 'AWARD_MASTER', '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 },
})
}),
/**
* 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 }
}),
})