feat: applicant onboarding, bulk invite, team management enhancements
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m50s
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:
@@ -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 }
|
||||
}),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user