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

@@ -485,6 +485,7 @@ export const userRouter = router({
.optional(),
})
),
sendInvitation: z.boolean().default(true),
})
)
.mutation(async ({ ctx, input }) => {
@@ -544,7 +545,7 @@ export const userRouter = router({
name: u.name,
role: u.role,
expertiseTags: u.expertiseTags,
status: 'INVITED',
status: input.sendInvitation ? 'INVITED' : 'NONE',
})),
})
@@ -559,8 +560,7 @@ export const userRouter = router({
userAgent: ctx.userAgent,
})
// Auto-send invitation emails to newly created users
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'
// 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 },
@@ -603,49 +603,54 @@ export const userRouter = router({
})
}
// Send invitation emails if requested
let emailsSent = 0
const emailErrors: string[] = []
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),
},
})
if (input.sendInvitation) {
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
await sendInvitationEmail(user.email, user.name, inviteUrl, user.role)
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),
},
})
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',
},
})
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',
},
})
}
}
}
return { created: created.count, skipped, emailsSent, emailErrors, assignmentsCreated }
return { created: created.count, skipped, emailsSent, emailErrors, assignmentsCreated, invitationSent: input.sendInvitation }
}),
/**