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)
|
||||
}),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user