feat: applicant onboarding, bulk invite, team management enhancements
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m50s

- Add nationality/institution fields to User model with migration
- Applicant onboarding wizard (name, photo, nationality, country, institution, bio, project logo, preferences)
- Project logo upload from applicant context with team membership verification
- APPLICANT redirects in set-password, onboarding, and auth layout
- Mask evaluation round names as "Evaluation Round 1/2/..." for applicants
- Extend inviteTeamMember with nationality/country/institution/sendInvite fields
- Admin getApplicants query with search/filter/pagination
- Admin bulkInviteApplicants mutation with token generation and emails
- Applicants tab on Members page with bulk select and floating invite bar

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 10:11:11 +01:00
parent 68aa393559
commit 49e706f2cf
11 changed files with 1509 additions and 8 deletions

View File

@@ -49,6 +49,8 @@ export const userRouter = router({
metadataJson: true,
phoneNumber: true,
country: true,
nationality: true,
institution: true,
bio: true,
notificationPreference: true,
profileImageKey: true,
@@ -109,6 +111,9 @@ export const userRouter = router({
name: z.string().min(1).max(255).optional(),
bio: z.string().max(1000).optional(),
phoneNumber: z.string().max(20).optional().nullable(),
nationality: z.string().max(100).optional().nullable(),
institution: z.string().max(255).optional().nullable(),
country: z.string().max(100).optional(),
notificationPreference: z.enum(['EMAIL', 'WHATSAPP', 'BOTH', 'NONE']).optional(),
expertiseTags: z.array(z.string()).max(15).optional(),
digestFrequency: z.enum(['none', 'daily', 'weekly']).optional(),
@@ -1147,6 +1152,8 @@ export const userRouter = router({
name: z.string().min(1).max(255),
phoneNumber: z.string().optional(),
country: z.string().optional(),
nationality: z.string().optional(),
institution: z.string().optional(),
bio: z.string().max(500).optional(),
expertiseTags: z.array(z.string()).optional(),
notificationPreference: z.enum(['EMAIL', 'WHATSAPP', 'BOTH', 'NONE']).optional(),
@@ -1181,6 +1188,8 @@ export const userRouter = router({
name: input.name,
phoneNumber: input.phoneNumber,
country: input.country,
nationality: input.nationality,
institution: input.institution,
bio: input.bio,
expertiseTags: mergedTags,
notificationPreference: input.notificationPreference || 'EMAIL',
@@ -1552,4 +1561,143 @@ export const userRouter = router({
data: { roles: input.roles, role: primaryRole },
})
}),
/**
* List applicant users with project info for admin bulk-invite page.
*/
getApplicants: adminProcedure
.input(
z.object({
search: z.string().optional(),
roundId: z.string().optional(),
status: z.enum(['NONE', 'INVITED', 'ACTIVE', 'SUSPENDED']).optional(),
page: z.number().int().positive().default(1),
perPage: z.number().int().positive().max(100).default(20),
})
)
.query(async ({ ctx, input }) => {
const where: Prisma.UserWhereInput = {
role: 'APPLICANT',
...(input.status && { status: input.status }),
...(input.search && {
OR: [
{ name: { contains: input.search, mode: 'insensitive' as const } },
{ email: { contains: input.search, mode: 'insensitive' as const } },
{ teamMemberships: { some: { project: { title: { contains: input.search, mode: 'insensitive' as const } } } } },
],
}),
...(input.roundId && {
teamMemberships: { some: { project: { projectRoundStates: { some: { roundId: input.roundId } } } } },
}),
}
const [users, total] = await Promise.all([
ctx.prisma.user.findMany({
where,
select: {
id: true,
email: true,
name: true,
status: true,
nationality: true,
institution: true,
lastLoginAt: true,
onboardingCompletedAt: true,
teamMemberships: {
take: 1,
select: {
role: true,
project: { select: { id: true, title: true } },
},
},
submittedProjects: {
take: 1,
select: { id: true, title: true },
},
},
orderBy: { name: 'asc' },
skip: (input.page - 1) * input.perPage,
take: input.perPage,
}),
ctx.prisma.user.count({ where }),
])
return {
users: users.map((u) => {
const project = u.submittedProjects[0] || u.teamMemberships[0]?.project || null
return {
id: u.id,
email: u.email,
name: u.name,
status: u.status,
nationality: u.nationality,
institution: u.institution,
lastLoginAt: u.lastLoginAt,
onboardingCompleted: !!u.onboardingCompletedAt,
projectName: project?.title ?? null,
projectId: project?.id ?? null,
}
}),
total,
totalPages: Math.ceil(total / input.perPage),
perPage: input.perPage,
}
}),
/**
* Bulk invite applicant users — generates tokens, sets INVITED, sends emails.
*/
bulkInviteApplicants: adminProcedure
.input(z.object({ userIds: z.array(z.string()).min(1).max(500) }))
.mutation(async ({ ctx, input }) => {
const users = await ctx.prisma.user.findMany({
where: {
id: { in: input.userIds },
role: 'APPLICANT',
status: { in: ['NONE', 'INVITED'] },
},
select: { id: true, email: true, name: true, status: true },
})
const expiryMs = await getInviteExpiryMs(ctx.prisma)
let sent = 0
let skipped = 0
const failed: string[] = []
const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'
for (const user of users) {
try {
const token = generateInviteToken()
await ctx.prisma.user.update({
where: { id: user.id },
data: {
status: 'INVITED',
inviteToken: token,
inviteTokenExpiresAt: new Date(Date.now() + expiryMs),
},
})
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
await sendInvitationEmail(user.email, user.name || 'Applicant', inviteUrl, 'APPLICANT')
sent++
} catch (error) {
failed.push(user.email)
}
}
skipped = input.userIds.length - users.length
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'BULK_INVITE_APPLICANTS',
entityType: 'User',
entityId: 'bulk',
detailsJson: { sent, skipped, failed: failed.length, total: input.userIds.length },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { sent, skipped, failed }
}),
})