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

@@ -3,6 +3,8 @@ import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, publicProcedure, protectedProcedure } from '../trpc'
import { getPresignedUrl, generateObjectKey } from '@/lib/minio'
import { generateLogoKey, type StorageProviderType } from '@/lib/storage'
import { getImageUploadUrl, confirmImageUpload, getImageUrl, type ImageUploadConfig } from '@/server/utils/image-upload'
import { sendStyledNotificationEmail, sendTeamMemberInviteEmail } from '@/lib/email'
import { logAudit } from '@/server/utils/audit'
import { createNotification } from '../services/in-app-notification'
@@ -757,6 +759,10 @@ export const applicantRouter = router({
name: z.string().min(1),
role: z.enum(['MEMBER', 'ADVISOR']),
title: z.string().optional(),
nationality: z.string().optional(),
country: z.string().optional(),
institution: z.string().optional(),
sendInvite: z.boolean().default(true),
})
)
.mutation(async ({ ctx, input }) => {
@@ -813,9 +819,25 @@ export const applicantRouter = router({
email: normalizedEmail,
name: input.name,
role: 'APPLICANT',
roles: ['APPLICANT'],
status: 'NONE',
nationality: input.nationality,
country: input.country,
institution: input.institution,
},
})
} else {
// Update existing user with new profile fields if provided
const profileUpdates: Record<string, string> = {}
if (input.nationality && !user.nationality) profileUpdates.nationality = input.nationality
if (input.country && !user.country) profileUpdates.country = input.country
if (input.institution && !user.institution) profileUpdates.institution = input.institution
if (Object.keys(profileUpdates).length > 0) {
user = await ctx.prisma.user.update({
where: { id: user.id },
data: profileUpdates,
})
}
}
if (user.status === 'SUSPENDED') {
@@ -829,7 +851,10 @@ export const applicantRouter = router({
const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'
const requiresAccountSetup = user.status !== 'ACTIVE'
try {
// If sendInvite is false, skip email entirely and leave user as NONE
if (!input.sendInvite) {
// No email, no status change — just create team membership below
} else try {
if (requiresAccountSetup) {
const token = generateInviteToken()
await ctx.prisma.user.update({
@@ -1607,7 +1632,8 @@ export const applicantRouter = router({
}>
}> = []
for (const round of evalRounds) {
for (let i = 0; i < evalRounds.length; i++) {
const round = evalRounds[i]
const parsed = EvaluationConfigSchema.safeParse(round.configJson)
if (!parsed.success || !parsed.data.applicantVisibility.enabled) continue
@@ -1633,9 +1659,12 @@ export const applicantRouter = router({
orderBy: { submittedAt: 'asc' },
})
// Mask round names: "Evaluation Round 1", "Evaluation Round 2", etc.
const maskedName = `Evaluation Round ${i + 1}`
results.push({
roundId: round.id,
roundName: round.name,
roundName: maskedName,
evaluationCount: evaluations.length,
evaluations: evaluations.map((ev) => ({
id: ev.id,
@@ -1752,4 +1781,155 @@ export const applicantRouter = router({
return results
}),
/**
* Get onboarding context for applicant wizard — project info, institution, logo status.
*/
getOnboardingContext: protectedProcedure.query(async ({ ctx }) => {
if (ctx.user.role !== 'APPLICANT') {
return null
}
const project = await ctx.prisma.project.findFirst({
where: {
OR: [
{ submittedByUserId: ctx.user.id },
{ teamMembers: { some: { userId: ctx.user.id } } },
],
},
select: {
id: true,
title: true,
institution: true,
logoKey: true,
},
})
if (!project) return null
return {
projectId: project.id,
projectTitle: project.title,
institution: project.institution,
hasLogo: !!project.logoKey,
}
}),
/**
* Get a pre-signed URL for uploading a project logo (applicant access).
*/
getProjectLogoUploadUrl: protectedProcedure
.input(
z.object({
projectId: z.string(),
fileName: z.string(),
contentType: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
// Verify team membership
const isMember = await ctx.prisma.project.findFirst({
where: {
id: input.projectId,
OR: [
{ submittedByUserId: ctx.user.id },
{ teamMembers: { some: { userId: ctx.user.id } } },
],
},
select: { id: true },
})
if (!isMember) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'You are not a member of this project' })
}
return getImageUploadUrl(
input.projectId,
input.fileName,
input.contentType,
generateLogoKey
)
}),
/**
* Confirm project logo upload (applicant access).
*/
confirmProjectLogo: protectedProcedure
.input(
z.object({
projectId: z.string(),
key: z.string(),
providerType: z.enum(['s3', 'local']),
})
)
.mutation(async ({ ctx, input }) => {
// Verify team membership
const isMember = await ctx.prisma.project.findFirst({
where: {
id: input.projectId,
OR: [
{ submittedByUserId: ctx.user.id },
{ teamMembers: { some: { userId: ctx.user.id } } },
],
},
select: { id: true },
})
if (!isMember) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'You are not a member of this project' })
}
const logoConfig: ImageUploadConfig<{ logoKey: string | null; logoProvider: string | null }> = {
label: 'logo',
generateKey: generateLogoKey,
findCurrent: (prisma, entityId) =>
prisma.project.findUnique({
where: { id: entityId },
select: { logoKey: true, logoProvider: true },
}),
getImageKey: (record) => record.logoKey,
getProviderType: (record) =>
(record.logoProvider as StorageProviderType) || 's3',
setImage: (prisma, entityId, key, providerType) =>
prisma.project.update({
where: { id: entityId },
data: { logoKey: key, logoProvider: providerType },
}),
clearImage: (prisma, entityId) =>
prisma.project.update({
where: { id: entityId },
data: { logoKey: null, logoProvider: null },
}),
auditEntityType: 'Project',
auditFieldName: 'logoKey',
}
await confirmImageUpload(ctx.prisma, logoConfig, input.projectId, input.key, input.providerType, {
userId: ctx.user.id,
ip: ctx.ip,
userAgent: ctx.userAgent,
})
return { success: true }
}),
/**
* Get project logo URL (applicant access).
*/
getProjectLogoUrl: protectedProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
const logoConfig = {
findCurrent: (prisma: typeof ctx.prisma, entityId: string) =>
prisma.project.findUnique({
where: { id: entityId },
select: { logoKey: true, logoProvider: true },
}),
getImageKey: (record: { logoKey: string | null }) => record.logoKey,
getProviderType: (record: { logoProvider: string | null }) =>
(record.logoProvider as StorageProviderType) || 's3' as StorageProviderType,
}
return getImageUrl(ctx.prisma, logoConfig, input.projectId)
}),
})

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 }
}),
})