MOPC-App/src/server/routers/user.ts

1150 lines
35 KiB
TypeScript
Raw Normal View History

import crypto from 'crypto'
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import type { Prisma } from '@prisma/client'
import { router, protectedProcedure, adminProcedure, superAdminProcedure, publicProcedure } from '../trpc'
import { sendInvitationEmail, sendMagicLinkEmail } from '@/lib/email'
import { hashPassword, validatePassword } from '@/lib/password'
import { attachAvatarUrls } from '@/server/utils/avatar-url'
import { logAudit } from '@/server/utils/audit'
const INVITE_TOKEN_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
function generateInviteToken(): string {
return crypto.randomBytes(32).toString('hex')
}
export const userRouter = router({
/**
* Get current user profile
*/
me: protectedProcedure.query(async ({ ctx }) => {
return ctx.prisma.user.findUniqueOrThrow({
where: { id: ctx.user.id },
select: {
id: true,
email: true,
name: true,
role: true,
status: true,
expertiseTags: true,
metadataJson: true,
phoneNumber: true,
country: 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,
},
})
}),
/**
* 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 }
}
return {
valid: true,
user: { name: user.name, email: user.email, role: user.role },
}
}),
/**
* Update current user profile
*/
updateProfile: protectedProcedure
.input(
z.object({
name: z.string().min(1).max(255).optional(),
bio: z.string().max(1000).optional(),
phoneNumber: z.string().max(20).optional().nullable(),
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 }) => {
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
const { bio, expertiseTags, availabilityJson, preferredWorkload, digestFrequency, ...directFields } = input
// If bio is provided, merge it into metadataJson
let metadataJson: Prisma.InputJsonValue | undefined
if (bio !== undefined) {
const currentUser = await ctx.prisma.user.findUniqueOrThrow({
where: { id: ctx.user.id },
select: { metadataJson: true },
})
const currentMeta = (currentUser.metadataJson as Record<string, string>) || {}
metadataJson = { ...currentMeta, bio } as Prisma.InputJsonValue
}
return ctx.prisma.user.update({
where: { id: ctx.user.id },
data: {
...directFields,
...(metadataJson !== undefined && { metadataJson }),
...(expertiseTags !== undefined && { expertiseTags }),
Implement 15 platform features: digest, availability, templates, comparison, live voting SSE, file versioning, mentorship, messaging, analytics, drafts, webhooks, peer review, audit enhancements, i18n Features implemented: - F1: Email digest notifications with cron endpoint and per-user frequency - F2: Jury availability windows and workload preferences in smart assignment - F3: Round templates with save-from-round and CRUD management - F4: Side-by-side project comparison view for jury members - F5: Real-time voting dashboard with Server-Sent Events (SSE) - F6: Live voting UX: QR codes, audience voting, tie-breaking, score animations - F7: File versioning, inline preview, bulk download with presigned URLs - F8: Mentor dashboard: milestones, private notes, activity tracking - F9: Communication hub with broadcasts, templates, and recipient targeting - F10: Advanced analytics: cross-round comparison, juror consistency, diversity metrics, PDF export - F11: Applicant draft saving with magic link resume and cron cleanup - F12: Webhook integration layer with HMAC signing, retry, and delivery logs - F13: Peer review discussions with anonymized scores and threaded comments - F14: Audit log enhancements: before/after diffs, session grouping, anomaly detection, retention - F15: i18n foundation with next-intl (EN/FR), cookie-based locale, language switcher Schema: 12 new models, field additions to User, Project, ProjectFile, LiveVotingSession, LiveVote, MentorAssignment, AuditLog, Program New routers: roundTemplate, message, webhook (registered in _app.ts) New services: email-digest, webhook-dispatcher New cron endpoints: /api/cron/digest, /api/cron/draft-cleanup, /api/cron/audit-cleanup New API routes: /api/live-voting/stream (SSE), /api/files/bulk-download All features are admin-configurable via SystemSettings or per-model settingsJson fields. Docker build verified successfully. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 23:31:41 +01:00
...(digestFrequency !== undefined && { digestFrequency }),
...(availabilityJson !== undefined && { availabilityJson: availabilityJson as Prisma.InputJsonValue }),
...(preferredWorkload !== undefined && { preferredWorkload }),
},
})
}),
/**
* Delete own account (requires password confirmation)
*/
deleteAccount: protectedProcedure
.input(
z.object({
password: z.string().min(1),
})
)
.mutation(async ({ ctx, input }) => {
// Get current user with password hash
const user = await ctx.prisma.user.findUniqueOrThrow({
where: { id: ctx.user.id },
select: { id: true, email: true, passwordHash: true, role: true },
})
// Prevent super admins from self-deleting
if (user.role === 'SUPER_ADMIN') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Super admins cannot delete their own account',
})
}
if (!user.passwordHash) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'No password set. Please set a password first.',
})
}
// Verify password
const { verifyPassword } = await import('@/lib/password')
const isValid = await verifyPassword(input.password, user.passwordHash)
if (!isValid) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Password is incorrect',
})
}
// Wrap audit + deletion in a transaction
await ctx.prisma.$transaction(async (tx) => {
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'DELETE_OWN_ACCOUNT',
entityType: 'User',
entityId: ctx.user.id,
detailsJson: { email: user.email },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
await tx.user.delete({
where: { id: ctx.user.id },
})
})
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,
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),
}
}),
/**
* 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 },
},
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
},
})
return user
}),
/**
* 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.$transaction(async (tx) => {
const created = await tx.user.create({
data: {
...input,
status: 'INVITED',
},
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'CREATE',
entityType: 'User',
entityId: created.id,
detailsJson: { email: input.email, role: input.role },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return created
})
return user
}),
/**
* Update a user (admin only)
*/
update: adminProcedure
.input(
z.object({
id: z.string(),
name: z.string().optional().nullable(),
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).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
// 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',
})
}
const user = await ctx.prisma.$transaction(async (tx) => {
const updated = await tx.user.update({
where: { id },
data,
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'User',
entityId: id,
detailsJson: data,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
// Track role change specifically
if (data.role && data.role !== targetUser.role) {
await logAudit({
prisma: tx,
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 updated
})
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',
})
}
const user = await ctx.prisma.$transaction(async (tx) => {
// Fetch user data before deletion for the audit log
const target = await tx.user.findUniqueOrThrow({
where: { id: input.id },
select: { email: true },
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'DELETE',
entityType: 'User',
entityId: input.id,
detailsJson: { email: target.email },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return tx.user.delete({
where: { id: input.id },
})
})
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(),
stageId: 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; stageId: string }>>()
for (const u of newUsers) {
if (u.assignments && u.assignments.length > 0) {
emailToAssignments.set(u.email.toLowerCase(), u.assignments)
}
}
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 },
})
// Create pre-assignments for users who have them
let assignmentsCreated = 0
for (const user of createdUsers) {
const assignments = emailToAssignments.get(user.email.toLowerCase())
if (assignments && assignments.length > 0) {
for (const assignment of assignments) {
try {
await ctx.prisma.assignment.create({
data: {
userId: user.id,
projectId: assignment.projectId,
stageId: assignment.stageId,
method: 'MANUAL',
createdBy: ctx.user.id,
},
})
assignmentsCreated++
} catch {
// Skip if assignment already exists (shouldn't happen for new users)
}
}
}
}
// 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,
})
}
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 || 'http://localhost:3000'
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() + INVITE_TOKEN_EXPIRY_MS),
},
})
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
await sendInvitationEmail(user.email, user.name, inviteUrl, user.role)
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',
},
})
}
}
}
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
return { created: created.count, skipped, emailsSent, emailErrors, assignmentsCreated, invitationSent: input.sendInvitation }
}),
/**
* Get jury members for assignment
*/
getJuryMembers: adminProcedure
.input(
z.object({
stageId: z.string().optional(),
search: z.string().optional(),
})
)
.query(async ({ ctx, input }) => {
const where: Record<string, unknown> = {
role: '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.stageId
? { where: { stageId: input.stageId } }
: 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() }))
.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',
})
}
// Generate invite token, set status to INVITED, and store on user
const token = generateInviteToken()
await ctx.prisma.user.update({
where: { id: user.id },
data: {
status: 'INVITED',
inviteToken: token,
inviteTokenExpiresAt: new Date(Date.now() + INVITE_TOKEN_EXPIRY_MS),
},
})
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
// Send invitation email
await sendInvitationEmail(user.email, user.name, inviteUrl, user.role)
// 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 || 'http://localhost:3000'
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() + INVITE_TOKEN_EXPIRY_MS),
},
})
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
await sendInvitationEmail(user.email, user.name, inviteUrl, user.role)
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(),
bio: z.string().max(500).optional(),
expertiseTags: z.array(z.string()).optional(),
notificationPreference: z.enum(['EMAIL', 'WHATSAPP', 'BOTH', 'NONE']).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,
bio: input.bio,
expertiseTags: mergedTags,
notificationPreference: input.notificationPreference || 'EMAIL',
onboardingCompletedAt: new Date(),
status: 'ACTIVE', // Activate user after onboarding
},
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'COMPLETE_ONBOARDING',
entityType: 'User',
entityId: ctx.user.id,
detailsJson: { name: input.name },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updated
})
return user
}),
/**
* 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 + audit in transaction
const user = await ctx.prisma.$transaction(async (tx) => {
const updated = await tx.user.update({
where: { id: ctx.user.id },
data: {
passwordHash,
passwordSetAt: new Date(),
mustSetPassword: false,
},
})
await logAudit({
prisma: tx,
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 updated
})
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 + audit in transaction
await ctx.prisma.$transaction(async (tx) => {
await tx.user.update({
where: { id: ctx.user.id },
data: {
passwordHash,
passwordSetAt: new Date(),
},
})
await logAudit({
prisma: tx,
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 }) => {
// Find user by email
const user = await ctx.prisma.user.findUnique({
where: { email: input.email },
select: { id: true, email: true, status: true },
})
// Always return success to prevent email enumeration
if (!user || user.status === 'SUSPENDED') {
return { success: true, message: 'If an account exists with this email, a password reset link will be sent.' }
}
// Mark user for password reset
await ctx.prisma.user.update({
where: { id: user.id },
data: { mustSetPassword: true },
})
// Generate a callback URL for the magic link
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'
const callbackUrl = `${baseUrl}/set-password`
// We don't send the email here - the user will use the magic link form
// This just marks them for password reset
// The actual email is sent through NextAuth's email provider
// Audit log (without user ID since this is public)
await logAudit({
prisma: ctx.prisma,
userId: null, // No authenticated user
action: 'REQUEST_PASSWORD_RESET',
entityType: 'User',
entityId: user.id,
detailsJson: { email: input.email, timestamp: new Date().toISOString() },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { success: true, message: 'If an account exists with this email, a password reset link will be sent.' }
}),
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) : [],
}
}),
})