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