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