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:
@@ -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 }
|
||||
}),
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user