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>
This commit is contained in:
2026-02-11 13:20:52 +01:00
parent 98f4a957cc
commit ce4069bf92
59 changed files with 1949 additions and 913 deletions

View File

@@ -1,12 +1,19 @@
import crypto from 'crypto'
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, publicProcedure, protectedProcedure } from '../trpc'
import { getPresignedUrl } from '@/lib/minio'
import { sendStyledNotificationEmail, sendTeamMemberInviteEmail } from '@/lib/email'
import { logAudit } from '@/server/utils/audit'
import { createNotification } from '../services/in-app-notification'
// Bucket for applicant submissions
export const SUBMISSIONS_BUCKET = 'mopc-submissions'
const TEAM_INVITE_TOKEN_EXPIRY_MS = 30 * 24 * 60 * 60 * 1000 // 30 days
function generateInviteToken(): string {
return crypto.randomBytes(32).toString('hex')
}
export const applicantRouter = router({
/**
@@ -775,6 +782,8 @@ export const applicantRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
const normalizedEmail = input.email.trim().toLowerCase()
// Verify user is team lead
const project = await ctx.prisma.project.findFirst({
where: {
@@ -804,7 +813,7 @@ export const applicantRouter = router({
const existingMember = await ctx.prisma.teamMember.findFirst({
where: {
projectId: input.projectId,
user: { email: input.email },
user: { email: normalizedEmail },
},
})
@@ -817,13 +826,13 @@ export const applicantRouter = router({
// Find or create user
let user = await ctx.prisma.user.findUnique({
where: { email: input.email },
where: { email: normalizedEmail },
})
if (!user) {
user = await ctx.prisma.user.create({
data: {
email: input.email,
email: normalizedEmail,
name: input.name,
role: 'APPLICANT',
status: 'NONE',
@@ -831,6 +840,77 @@ export const applicantRouter = router({
})
}
if (user.status === 'SUSPENDED') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'This user account is suspended and cannot be invited',
})
}
const teamLeadName = ctx.user.name?.trim() || 'A team lead'
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'
const requiresAccountSetup = user.status !== 'ACTIVE'
try {
if (requiresAccountSetup) {
const token = generateInviteToken()
await ctx.prisma.user.update({
where: { id: user.id },
data: {
status: 'INVITED',
inviteToken: token,
inviteTokenExpiresAt: new Date(Date.now() + TEAM_INVITE_TOKEN_EXPIRY_MS),
},
})
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
await sendTeamMemberInviteEmail(
user.email,
user.name || input.name,
project.title,
teamLeadName,
inviteUrl
)
} else {
await sendStyledNotificationEmail(
user.email,
user.name || input.name,
'TEAM_INVITATION',
{
title: 'You were added to a project team',
message: `${teamLeadName} added you to the project "${project.title}".`,
linkUrl: `${baseUrl}/applicant/team`,
linkLabel: 'Open Team',
metadata: {
projectId: project.id,
projectName: project.title,
},
},
`You've been added to "${project.title}"`
)
}
} catch (error) {
try {
await ctx.prisma.notificationLog.create({
data: {
userId: user.id,
channel: 'EMAIL',
provider: 'SMTP',
type: 'TEAM_INVITATION',
status: 'FAILED',
errorMsg: error instanceof Error ? error.message : 'Unknown error',
},
})
} catch {
// Never fail on notification logging
}
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to send invitation email. Please try again.',
})
}
// Create team membership
const teamMember = await ctx.prisma.teamMember.create({
data: {
@@ -846,9 +926,43 @@ export const applicantRouter = router({
},
})
// TODO: Send invitation email to the new team member
try {
await ctx.prisma.notificationLog.create({
data: {
userId: user.id,
channel: 'EMAIL',
provider: 'SMTP',
type: 'TEAM_INVITATION',
status: 'SENT',
},
})
} catch {
// Never fail on notification logging
}
return teamMember
try {
await createNotification({
userId: user.id,
type: 'TEAM_INVITATION',
title: 'Team Invitation',
message: `${teamLeadName} added you to "${project.title}"`,
linkUrl: '/applicant/team',
linkLabel: 'View Team',
priority: 'normal',
metadata: {
projectId: project.id,
projectName: project.title,
},
})
} catch {
// Never fail invitation flow on in-app notification issues
}
return {
teamMember,
inviteEmailSent: true,
requiresAccountSetup,
}
}),
/**