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:
@@ -1,18 +1,41 @@
|
||||
import { z } from 'zod'
|
||||
import { router, protectedProcedure, adminProcedure, observerProcedure } from '../trpc'
|
||||
|
||||
// Shared input schema: either roundId or programId (for entire edition)
|
||||
const editionOrRoundInput = z.object({
|
||||
roundId: z.string().optional(),
|
||||
programId: z.string().optional(),
|
||||
}).refine(data => data.roundId || data.programId, {
|
||||
message: 'Either roundId or programId is required',
|
||||
})
|
||||
|
||||
// Build Prisma where-clauses from the shared input
|
||||
function projectWhere(input: { roundId?: string; programId?: string }) {
|
||||
if (input.roundId) return { roundId: input.roundId }
|
||||
return { programId: input.programId! }
|
||||
}
|
||||
|
||||
function assignmentWhere(input: { roundId?: string; programId?: string }) {
|
||||
if (input.roundId) return { roundId: input.roundId }
|
||||
return { round: { programId: input.programId! } }
|
||||
}
|
||||
|
||||
function evalWhere(input: { roundId?: string; programId?: string }, extra: Record<string, unknown> = {}) {
|
||||
const base = input.roundId
|
||||
? { assignment: { roundId: input.roundId } }
|
||||
: { assignment: { round: { programId: input.programId! } } }
|
||||
return { ...base, ...extra }
|
||||
}
|
||||
|
||||
export const analyticsRouter = router({
|
||||
/**
|
||||
* Get score distribution for a round (histogram data)
|
||||
*/
|
||||
getScoreDistribution: observerProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.input(editionOrRoundInput)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const evaluations = await ctx.prisma.evaluation.findMany({
|
||||
where: {
|
||||
assignment: { roundId: input.roundId },
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
where: evalWhere(input, { status: 'SUBMITTED' }),
|
||||
select: {
|
||||
criterionScoresJson: true,
|
||||
},
|
||||
@@ -51,13 +74,10 @@ export const analyticsRouter = router({
|
||||
* Get evaluation completion over time (timeline data)
|
||||
*/
|
||||
getEvaluationTimeline: observerProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.input(editionOrRoundInput)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const evaluations = await ctx.prisma.evaluation.findMany({
|
||||
where: {
|
||||
assignment: { roundId: input.roundId },
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
where: evalWhere(input, { status: 'SUBMITTED' }),
|
||||
select: {
|
||||
submittedAt: true,
|
||||
},
|
||||
@@ -97,10 +117,10 @@ export const analyticsRouter = router({
|
||||
* Get juror workload distribution
|
||||
*/
|
||||
getJurorWorkload: observerProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.input(editionOrRoundInput)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const assignments = await ctx.prisma.assignment.findMany({
|
||||
where: { roundId: input.roundId },
|
||||
where: assignmentWhere(input),
|
||||
include: {
|
||||
user: { select: { name: true, email: true } },
|
||||
evaluation: {
|
||||
@@ -146,10 +166,10 @@ export const analyticsRouter = router({
|
||||
* Get project rankings with average scores
|
||||
*/
|
||||
getProjectRankings: observerProcedure
|
||||
.input(z.object({ roundId: z.string(), limit: z.number().optional() }))
|
||||
.input(editionOrRoundInput.and(z.object({ limit: z.number().optional() })))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: { roundId: input.roundId },
|
||||
where: projectWhere(input),
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
@@ -214,11 +234,11 @@ export const analyticsRouter = router({
|
||||
* Get status breakdown (pie chart data)
|
||||
*/
|
||||
getStatusBreakdown: observerProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.input(editionOrRoundInput)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const projects = await ctx.prisma.project.groupBy({
|
||||
by: ['status'],
|
||||
where: { roundId: input.roundId },
|
||||
where: projectWhere(input),
|
||||
_count: true,
|
||||
})
|
||||
|
||||
@@ -232,7 +252,7 @@ export const analyticsRouter = router({
|
||||
* Get overview stats for dashboard
|
||||
*/
|
||||
getOverviewStats: observerProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.input(editionOrRoundInput)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const [
|
||||
projectCount,
|
||||
@@ -241,21 +261,18 @@ export const analyticsRouter = router({
|
||||
jurorCount,
|
||||
statusCounts,
|
||||
] = await Promise.all([
|
||||
ctx.prisma.project.count({ where: { roundId: input.roundId } }),
|
||||
ctx.prisma.assignment.count({ where: { roundId: input.roundId } }),
|
||||
ctx.prisma.project.count({ where: projectWhere(input) }),
|
||||
ctx.prisma.assignment.count({ where: assignmentWhere(input) }),
|
||||
ctx.prisma.evaluation.count({
|
||||
where: {
|
||||
assignment: { roundId: input.roundId },
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
where: evalWhere(input, { status: 'SUBMITTED' }),
|
||||
}),
|
||||
ctx.prisma.assignment.groupBy({
|
||||
by: ['userId'],
|
||||
where: { roundId: input.roundId },
|
||||
where: assignmentWhere(input),
|
||||
}),
|
||||
ctx.prisma.project.groupBy({
|
||||
by: ['status'],
|
||||
where: { roundId: input.roundId },
|
||||
where: projectWhere(input),
|
||||
_count: true,
|
||||
}),
|
||||
])
|
||||
@@ -282,33 +299,44 @@ export const analyticsRouter = router({
|
||||
* Get criteria-level score distribution
|
||||
*/
|
||||
getCriteriaScores: observerProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.input(editionOrRoundInput)
|
||||
.query(async ({ ctx, input }) => {
|
||||
// Get active evaluation form for this round
|
||||
const evaluationForm = await ctx.prisma.evaluationForm.findFirst({
|
||||
where: { roundId: input.roundId, isActive: true },
|
||||
// Get active evaluation forms — either for a specific round or all rounds in the edition
|
||||
const formWhere = input.roundId
|
||||
? { roundId: input.roundId, isActive: true }
|
||||
: { round: { programId: input.programId! }, isActive: true }
|
||||
|
||||
const evaluationForms = await ctx.prisma.evaluationForm.findMany({
|
||||
where: formWhere,
|
||||
})
|
||||
|
||||
if (!evaluationForm?.criteriaJson) {
|
||||
if (!evaluationForms.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Parse criteria from JSON
|
||||
const criteria = evaluationForm.criteriaJson as Array<{
|
||||
id: string
|
||||
label: string
|
||||
}>
|
||||
// Merge criteria from all forms (deduplicate by label for edition-wide)
|
||||
const criteriaMap = new Map<string, { id: string; label: string }>()
|
||||
evaluationForms.forEach((form) => {
|
||||
const criteria = form.criteriaJson as Array<{ id: string; label: string }> | null
|
||||
if (criteria) {
|
||||
criteria.forEach((c) => {
|
||||
// Use label as dedup key for edition-wide, id for single round
|
||||
const key = input.roundId ? c.id : c.label
|
||||
if (!criteriaMap.has(key)) {
|
||||
criteriaMap.set(key, c)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if (!criteria || criteria.length === 0) {
|
||||
const criteria = Array.from(criteriaMap.values())
|
||||
if (criteria.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Get all evaluations
|
||||
const evaluations = await ctx.prisma.evaluation.findMany({
|
||||
where: {
|
||||
assignment: { roundId: input.roundId },
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
where: evalWhere(input, { status: 'SUBMITTED' }),
|
||||
select: { criterionScoresJson: true },
|
||||
})
|
||||
|
||||
@@ -441,13 +469,10 @@ export const analyticsRouter = router({
|
||||
* Get juror consistency metrics for a round
|
||||
*/
|
||||
getJurorConsistency: observerProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.input(editionOrRoundInput)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const evaluations = await ctx.prisma.evaluation.findMany({
|
||||
where: {
|
||||
assignment: { roundId: input.roundId },
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
where: evalWhere(input, { status: 'SUBMITTED' }),
|
||||
include: {
|
||||
assignment: {
|
||||
include: {
|
||||
@@ -513,10 +538,10 @@ export const analyticsRouter = router({
|
||||
* Get diversity metrics for projects in a round
|
||||
*/
|
||||
getDiversityMetrics: observerProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.input(editionOrRoundInput)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: { roundId: input.roundId },
|
||||
where: projectWhere(input),
|
||||
select: {
|
||||
country: true,
|
||||
competitionCategory: true,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
|
||||
@@ -87,18 +87,44 @@ export const roundRouter = router({
|
||||
},
|
||||
})
|
||||
|
||||
// Get evaluation stats
|
||||
const evaluationStats = await ctx.prisma.evaluation.groupBy({
|
||||
by: ['status'],
|
||||
where: {
|
||||
assignment: { roundId: input.id },
|
||||
// Get evaluation stats + progress in parallel (avoids duplicate groupBy in getProgress)
|
||||
const [evaluationStats, totalAssignments, completedAssignments] =
|
||||
await Promise.all([
|
||||
ctx.prisma.evaluation.groupBy({
|
||||
by: ['status'],
|
||||
where: {
|
||||
assignment: { roundId: input.id },
|
||||
},
|
||||
_count: true,
|
||||
}),
|
||||
ctx.prisma.assignment.count({ where: { roundId: input.id } }),
|
||||
ctx.prisma.assignment.count({
|
||||
where: { roundId: input.id, isCompleted: true },
|
||||
}),
|
||||
])
|
||||
|
||||
const evaluationsByStatus = evaluationStats.reduce(
|
||||
(acc, curr) => {
|
||||
acc[curr.status] = curr._count
|
||||
return acc
|
||||
},
|
||||
_count: true,
|
||||
})
|
||||
{} as Record<string, number>
|
||||
)
|
||||
|
||||
return {
|
||||
...round,
|
||||
evaluationStats,
|
||||
// Inline progress data (eliminates need for separate getProgress call)
|
||||
progress: {
|
||||
totalProjects: round._count.projects,
|
||||
totalAssignments,
|
||||
completedAssignments,
|
||||
completionPercentage:
|
||||
totalAssignments > 0
|
||||
? Math.round((completedAssignments / totalAssignments) * 100)
|
||||
: 0,
|
||||
evaluationsByStatus,
|
||||
},
|
||||
}
|
||||
}),
|
||||
|
||||
|
||||
@@ -525,32 +525,31 @@ export const specialAwardRouter = router({
|
||||
})
|
||||
}
|
||||
|
||||
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
|
||||
where: { id: input.awardId },
|
||||
})
|
||||
|
||||
// Get eligible projects
|
||||
const eligibleProjects = await ctx.prisma.awardEligibility.findMany({
|
||||
where: { awardId: input.awardId, eligible: true },
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
teamName: true,
|
||||
description: true,
|
||||
competitionCategory: true,
|
||||
country: true,
|
||||
tags: true,
|
||||
// Fetch award, eligible projects, and votes in parallel
|
||||
const [award, eligibleProjects, myVotes] = await Promise.all([
|
||||
ctx.prisma.specialAward.findUniqueOrThrow({
|
||||
where: { id: input.awardId },
|
||||
}),
|
||||
ctx.prisma.awardEligibility.findMany({
|
||||
where: { awardId: input.awardId, eligible: true },
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
teamName: true,
|
||||
description: true,
|
||||
competitionCategory: true,
|
||||
country: true,
|
||||
tags: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Get user's existing votes
|
||||
const myVotes = await ctx.prisma.awardVote.findMany({
|
||||
where: { awardId: input.awardId, userId: ctx.user.id },
|
||||
})
|
||||
}),
|
||||
ctx.prisma.awardVote.findMany({
|
||||
where: { awardId: input.awardId, userId: ctx.user.id },
|
||||
}),
|
||||
])
|
||||
|
||||
return {
|
||||
award,
|
||||
@@ -646,25 +645,25 @@ export const specialAwardRouter = router({
|
||||
getVoteResults: adminProcedure
|
||||
.input(z.object({ awardId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
|
||||
where: { id: input.awardId },
|
||||
})
|
||||
|
||||
const votes = await ctx.prisma.awardVote.findMany({
|
||||
where: { awardId: input.awardId },
|
||||
include: {
|
||||
project: {
|
||||
select: { id: true, title: true, teamName: true },
|
||||
const [award, votes, jurorCount] = await Promise.all([
|
||||
ctx.prisma.specialAward.findUniqueOrThrow({
|
||||
where: { id: input.awardId },
|
||||
}),
|
||||
ctx.prisma.awardVote.findMany({
|
||||
where: { awardId: input.awardId },
|
||||
include: {
|
||||
project: {
|
||||
select: { id: true, title: true, teamName: true },
|
||||
},
|
||||
user: {
|
||||
select: { id: true, name: true, email: true },
|
||||
},
|
||||
},
|
||||
user: {
|
||||
select: { id: true, name: true, email: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const jurorCount = await ctx.prisma.awardJuror.count({
|
||||
where: { awardId: input.awardId },
|
||||
})
|
||||
}),
|
||||
ctx.prisma.awardJuror.count({
|
||||
where: { awardId: input.awardId },
|
||||
}),
|
||||
])
|
||||
|
||||
const votedJurorCount = new Set(votes.map((v) => v.userId)).size
|
||||
|
||||
|
||||
@@ -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