Add background filtering jobs, improved date picker, AI reasoning display

- Implement background job system for AI filtering to avoid HTTP timeouts
- Add FilteringJob model to track progress of long-running filtering operations
- Add real-time progress polling for filtering operations on round details page
- Create custom DateTimePicker component with calendar popup (no year picker hassle)
- Fix round date persistence bug (refetchOnWindowFocus was resetting form state)
- Integrate filtering controls into round details page for filtering rounds
- Display AI reasoning for flagged/filtered projects in results table
- Add onboarding system scaffolding (schema, routes, basic UI)
- Allow setting round dates in the past for manual overrides

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-03 19:48:41 +01:00
parent 8be740a4fb
commit e2782b2b19
24 changed files with 3692 additions and 443 deletions

View File

@@ -16,6 +16,7 @@ import { partnerRouter } from './partner'
import { notionImportRouter } from './notion-import'
import { typeformImportRouter } from './typeform-import'
import { applicationFormRouter } from './applicationForm'
import { onboardingRouter } from './onboarding'
// Phase 2B routers
import { tagRouter } from './tag'
import { applicantRouter } from './applicant'
@@ -51,6 +52,7 @@ export const appRouter = router({
notionImport: notionImportRouter,
typeformImport: typeformImportRouter,
applicationForm: applicationFormRouter,
onboarding: onboardingRouter,
// Phase 2B routers
tag: tagRouter,
applicant: applicantRouter,

View File

@@ -28,6 +28,16 @@ const fieldTypeEnum = z.enum([
'INSTRUCTIONS',
])
// Special field type enum
const specialFieldTypeEnum = z.enum([
'TEAM_MEMBERS',
'COMPETITION_CATEGORY',
'OCEAN_ISSUE',
'FILE_UPLOAD',
'GDPR_CONSENT',
'COUNTRY_SELECT',
])
// Field input schema
const fieldInputSchema = z.object({
fieldType: fieldTypeEnum,
@@ -52,6 +62,25 @@ const fieldInputSchema = z.object({
.optional(),
sortOrder: z.number().int().default(0),
width: z.enum(['full', 'half']).default('full'),
// Onboarding-specific fields
stepId: z.string().optional(),
projectMapping: z.string().optional(),
specialType: specialFieldTypeEnum.optional(),
})
// Step input schema
const stepInputSchema = z.object({
name: z.string().min(1).max(100),
title: z.string().min(1).max(255),
description: z.string().optional(),
isOptional: z.boolean().default(false),
conditionJson: z
.object({
fieldId: z.string(),
operator: z.enum(['equals', 'not_equals', 'contains', 'not_empty']),
value: z.string().optional(),
})
.optional(),
})
export const applicationFormRouter = router({
@@ -101,7 +130,7 @@ export const applicationFormRouter = router({
}),
/**
* Get a single form by ID (admin view with all fields)
* Get a single form by ID (admin view with all fields and steps)
*/
get: adminProcedure
.input(z.object({ id: z.string() }))
@@ -110,7 +139,12 @@ export const applicationFormRouter = router({
where: { id: input.id },
include: {
program: { select: { id: true, name: true, year: true } },
round: { select: { id: true, name: true, slug: true } },
fields: { orderBy: { sortOrder: 'asc' } },
steps: {
orderBy: { sortOrder: 'asc' },
include: { fields: { orderBy: { sortOrder: 'asc' } } },
},
_count: { select: { submissions: true } },
},
})
@@ -315,7 +349,7 @@ export const applicationFormRouter = router({
}),
/**
* Add a field to a form
* Add a field to a form (or step)
*/
addField: adminProcedure
.input(
@@ -325,19 +359,28 @@ export const applicationFormRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
// Get max sort order
// Get max sort order (within the step if specified, otherwise form-wide)
const whereClause = input.field.stepId
? { stepId: input.field.stepId }
: { formId: input.formId, stepId: null }
const maxOrder = await ctx.prisma.applicationFormField.aggregate({
where: { formId: input.formId },
where: whereClause,
_max: { sortOrder: true },
})
const { stepId, projectMapping, specialType, ...restField } = input.field
const field = await ctx.prisma.applicationFormField.create({
data: {
formId: input.formId,
...input.field,
sortOrder: input.field.sortOrder ?? (maxOrder._max.sortOrder ?? 0) + 1,
optionsJson: input.field.optionsJson ?? undefined,
conditionJson: input.field.conditionJson ?? undefined,
...restField,
sortOrder: restField.sortOrder ?? (maxOrder._max.sortOrder ?? 0) + 1,
optionsJson: restField.optionsJson ?? undefined,
conditionJson: restField.conditionJson ?? undefined,
stepId: stepId ?? undefined,
projectMapping: projectMapping ?? undefined,
specialType: specialType ?? undefined,
},
})
@@ -355,12 +398,18 @@ export const applicationFormRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
const { stepId, projectMapping, specialType, ...restField } = input.field
const field = await ctx.prisma.applicationFormField.update({
where: { id: input.id },
data: {
...input.field,
optionsJson: input.field.optionsJson ?? undefined,
conditionJson: input.field.conditionJson ?? undefined,
...restField,
optionsJson: restField.optionsJson ?? undefined,
conditionJson: restField.conditionJson ?? undefined,
// Handle nullable fields explicitly
stepId: stepId === undefined ? undefined : stepId,
projectMapping: projectMapping === undefined ? undefined : projectMapping,
specialType: specialType === undefined ? undefined : specialType,
},
})
@@ -733,6 +782,248 @@ export const applicationFormRouter = router({
return newForm
}),
// ===========================================================================
// ONBOARDING STEP MANAGEMENT
// ===========================================================================
/**
* Create a new step in a form
*/
createStep: adminProcedure
.input(
z.object({
formId: z.string(),
step: stepInputSchema,
})
)
.mutation(async ({ ctx, input }) => {
// Get max sort order
const maxOrder = await ctx.prisma.onboardingStep.aggregate({
where: { formId: input.formId },
_max: { sortOrder: true },
})
const step = await ctx.prisma.onboardingStep.create({
data: {
formId: input.formId,
...input.step,
sortOrder: (maxOrder._max.sortOrder ?? -1) + 1,
conditionJson: input.step.conditionJson ?? undefined,
},
})
return step
}),
/**
* Update a step
*/
updateStep: adminProcedure
.input(
z.object({
id: z.string(),
step: stepInputSchema.partial(),
})
)
.mutation(async ({ ctx, input }) => {
const step = await ctx.prisma.onboardingStep.update({
where: { id: input.id },
data: {
...input.step,
conditionJson: input.step.conditionJson ?? undefined,
},
})
return step
}),
/**
* Delete a step (fields will have stepId set to null)
*/
deleteStep: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
return ctx.prisma.onboardingStep.delete({
where: { id: input.id },
})
}),
/**
* Reorder steps
*/
reorderSteps: adminProcedure
.input(
z.object({
formId: z.string(),
stepIds: z.array(z.string()),
})
)
.mutation(async ({ ctx, input }) => {
await ctx.prisma.$transaction(
input.stepIds.map((id, index) =>
ctx.prisma.onboardingStep.update({
where: { id },
data: { sortOrder: index },
})
)
)
return { success: true }
}),
/**
* Move a field to a different step
*/
moveFieldToStep: adminProcedure
.input(
z.object({
fieldId: z.string(),
stepId: z.string().nullable(),
})
)
.mutation(async ({ ctx, input }) => {
const field = await ctx.prisma.applicationFormField.update({
where: { id: input.fieldId },
data: { stepId: input.stepId },
})
return field
}),
/**
* Update email settings for a form
*/
updateEmailSettings: adminProcedure
.input(
z.object({
formId: z.string(),
sendConfirmationEmail: z.boolean().optional(),
sendTeamInviteEmails: z.boolean().optional(),
confirmationEmailSubject: z.string().optional().nullable(),
confirmationEmailBody: z.string().optional().nullable(),
})
)
.mutation(async ({ ctx, input }) => {
const { formId, ...data } = input
const form = await ctx.prisma.applicationForm.update({
where: { id: formId },
data,
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'UPDATE_EMAIL_SETTINGS',
entityType: 'ApplicationForm',
entityId: formId,
detailsJson: data,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return form
}),
/**
* Link a form to a round (for onboarding forms that create projects)
*/
linkToRound: adminProcedure
.input(
z.object({
formId: z.string(),
roundId: z.string().nullable(),
})
)
.mutation(async ({ ctx, input }) => {
// Check if another form is already linked to this round
if (input.roundId) {
const existing = await ctx.prisma.applicationForm.findFirst({
where: {
roundId: input.roundId,
NOT: { id: input.formId },
},
})
if (existing) {
throw new TRPCError({
code: 'CONFLICT',
message: 'Another form is already linked to this round',
})
}
}
const form = await ctx.prisma.applicationForm.update({
where: { id: input.formId },
data: { roundId: input.roundId },
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'LINK_TO_ROUND',
entityType: 'ApplicationForm',
entityId: input.formId,
detailsJson: { roundId: input.roundId },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return form
}),
/**
* Get form with steps for onboarding builder
*/
getForBuilder: adminProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.applicationForm.findUniqueOrThrow({
where: { id: input.id },
include: {
program: { select: { id: true, name: true, year: true } },
round: { select: { id: true, name: true, slug: true, programId: true } },
steps: {
orderBy: { sortOrder: 'asc' },
include: {
fields: { orderBy: { sortOrder: 'asc' } },
},
},
fields: {
where: { stepId: null },
orderBy: { sortOrder: 'asc' },
},
_count: { select: { submissions: true } },
},
})
}),
/**
* Get available rounds for linking
*/
getAvailableRounds: adminProcedure
.input(z.object({ programId: z.string().optional() }))
.query(async ({ ctx, input }) => {
// Get rounds that don't have a linked form yet
return ctx.prisma.round.findMany({
where: {
...(input.programId ? { programId: input.programId } : {}),
applicationForm: null,
},
select: {
id: true,
name: true,
slug: true,
status: true,
program: { select: { id: true, name: true, year: true } },
},
orderBy: [{ program: { year: 'desc' } }, { sortOrder: 'asc' }],
})
}),
/**
* Get form statistics
*/

View File

@@ -1,10 +1,140 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { Prisma } from '@prisma/client'
import { Prisma, PrismaClient } from '@prisma/client'
import { router, adminProcedure, protectedProcedure } from '../trpc'
import { executeFilteringRules } from '../services/ai-filtering'
import { executeFilteringRules, type ProgressCallback } from '../services/ai-filtering'
import { logAudit } from '../utils/audit'
import { isOpenAIConfigured, testOpenAIConnection } from '@/lib/openai'
import { prisma } from '@/lib/prisma'
// Background job execution function
async function runFilteringJob(jobId: string, roundId: string, userId: string) {
try {
// Update job to running
await prisma.filteringJob.update({
where: { id: jobId },
data: { status: 'RUNNING', startedAt: new Date() },
})
// Get rules
const rules = await prisma.filteringRule.findMany({
where: { roundId },
orderBy: { priority: 'asc' },
})
// Get projects
const roundProjectEntries = await prisma.roundProject.findMany({
where: { roundId },
include: {
project: {
include: {
files: {
select: { id: true, fileName: true, fileType: true },
},
},
},
},
})
const projects = roundProjectEntries.map((rp) => rp.project)
// Calculate batch info
const BATCH_SIZE = 20
const totalBatches = Math.ceil(projects.length / BATCH_SIZE)
await prisma.filteringJob.update({
where: { id: jobId },
data: { totalProjects: projects.length, totalBatches },
})
// Progress callback
const onProgress: ProgressCallback = async (progress) => {
await prisma.filteringJob.update({
where: { id: jobId },
data: {
currentBatch: progress.currentBatch,
processedCount: progress.processedCount,
},
})
}
// Execute rules
const results = await executeFilteringRules(rules, projects, userId, roundId, onProgress)
// Count outcomes
const passedCount = results.filter((r) => r.outcome === 'PASSED').length
const filteredCount = results.filter((r) => r.outcome === 'FILTERED_OUT').length
const flaggedCount = results.filter((r) => r.outcome === 'FLAGGED').length
// Upsert results
await prisma.$transaction(
results.map((r) =>
prisma.filteringResult.upsert({
where: {
roundId_projectId: {
roundId,
projectId: r.projectId,
},
},
create: {
roundId,
projectId: r.projectId,
outcome: r.outcome,
ruleResultsJson: r.ruleResults as unknown as Prisma.InputJsonValue,
aiScreeningJson: (r.aiScreeningJson ?? Prisma.JsonNull) as Prisma.InputJsonValue,
},
update: {
outcome: r.outcome,
ruleResultsJson: r.ruleResults as unknown as Prisma.InputJsonValue,
aiScreeningJson: (r.aiScreeningJson ?? Prisma.JsonNull) as Prisma.InputJsonValue,
overriddenBy: null,
overriddenAt: null,
overrideReason: null,
finalOutcome: null,
},
})
)
)
// Mark job as completed
await prisma.filteringJob.update({
where: { id: jobId },
data: {
status: 'COMPLETED',
completedAt: new Date(),
processedCount: projects.length,
passedCount,
filteredCount,
flaggedCount,
},
})
// Audit log
await logAudit({
userId,
action: 'UPDATE',
entityType: 'Round',
entityId: roundId,
detailsJson: {
action: 'EXECUTE_FILTERING',
jobId,
projectCount: projects.length,
passed: passedCount,
filteredOut: filteredCount,
flagged: flaggedCount,
},
})
} catch (error) {
console.error('[Filtering Job] Error:', error)
await prisma.filteringJob.update({
where: { id: jobId },
data: {
status: 'FAILED',
completedAt: new Date(),
errorMessage: error instanceof Error ? error.message : 'Unknown error',
},
})
}
}
export const filteringRouter = router({
/**
@@ -168,7 +298,112 @@ export const filteringRouter = router({
}),
/**
* Execute all filtering rules against projects in a round
* Start a filtering job (runs in background)
*/
startJob: adminProcedure
.input(z.object({ roundId: z.string() }))
.mutation(async ({ ctx, input }) => {
// Check if there's already a running job
const existingJob = await ctx.prisma.filteringJob.findFirst({
where: { roundId: input.roundId, status: 'RUNNING' },
})
if (existingJob) {
throw new TRPCError({
code: 'CONFLICT',
message: 'A filtering job is already running for this round',
})
}
// Get rules
const rules = await ctx.prisma.filteringRule.findMany({
where: { roundId: input.roundId },
orderBy: { priority: 'asc' },
})
if (rules.length === 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'No filtering rules configured for this round',
})
}
// Check AI config if needed
const hasAIRules = rules.some((r) => r.ruleType === 'AI_SCREENING' && r.isActive)
if (hasAIRules) {
const aiConfigured = await isOpenAIConfigured()
if (!aiConfigured) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message:
'AI screening rules require OpenAI to be configured. Go to Settings → AI to configure your API key.',
})
}
const testResult = await testOpenAIConnection()
if (!testResult.success) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: `AI configuration error: ${testResult.error}. Go to Settings → AI to fix.`,
})
}
}
// Count projects
const projectCount = await ctx.prisma.roundProject.count({
where: { roundId: input.roundId },
})
if (projectCount === 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'No projects found in this round',
})
}
// Create job
const job = await ctx.prisma.filteringJob.create({
data: {
roundId: input.roundId,
status: 'PENDING',
totalProjects: projectCount,
},
})
// Start background execution (non-blocking)
setImmediate(() => {
runFilteringJob(job.id, input.roundId, ctx.user.id).catch(console.error)
})
return { jobId: job.id, message: 'Filtering job started' }
}),
/**
* Get current job status
*/
getJobStatus: protectedProcedure
.input(z.object({ jobId: z.string() }))
.query(async ({ ctx, input }) => {
const job = await ctx.prisma.filteringJob.findUnique({
where: { id: input.jobId },
})
if (!job) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Job not found' })
}
return job
}),
/**
* Get latest job for a round
*/
getLatestJob: protectedProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.filteringJob.findFirst({
where: { roundId: input.roundId },
orderBy: { createdAt: 'desc' },
})
}),
/**
* Execute all filtering rules against projects in a round (synchronous, legacy)
*/
executeRules: adminProcedure
.input(z.object({ roundId: z.string() }))

View File

@@ -0,0 +1,398 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { Prisma } from '@prisma/client'
import { router, publicProcedure } from '../trpc'
import { sendApplicationConfirmationEmail, sendTeamMemberInviteEmail } from '@/lib/email'
import { nanoid } from 'nanoid'
// Team member input for submission
const teamMemberInputSchema = z.object({
name: z.string().min(1),
email: z.string().email().optional(),
title: z.string().optional(),
role: z.enum(['LEAD', 'MEMBER', 'ADVISOR']).default('MEMBER'),
})
export const onboardingRouter = router({
/**
* Get onboarding form configuration for public wizard
* Returns form + steps + fields + program info
*/
getConfig: publicProcedure
.input(
z.object({
slug: z.string(), // Round slug or form publicSlug
})
)
.query(async ({ ctx, input }) => {
// Try to find by round slug first
let form = await ctx.prisma.applicationForm.findFirst({
where: {
round: { slug: input.slug },
status: 'PUBLISHED',
isPublic: true,
},
include: {
program: { select: { id: true, name: true, year: true } },
round: {
select: {
id: true,
name: true,
slug: true,
submissionStartDate: true,
submissionEndDate: true,
submissionDeadline: true,
},
},
steps: {
orderBy: { sortOrder: 'asc' },
include: {
fields: { orderBy: { sortOrder: 'asc' } },
},
},
fields: {
where: { stepId: null },
orderBy: { sortOrder: 'asc' },
},
},
})
// If not found by round slug, try form publicSlug
if (!form) {
form = await ctx.prisma.applicationForm.findFirst({
where: {
publicSlug: input.slug,
status: 'PUBLISHED',
isPublic: true,
},
include: {
program: { select: { id: true, name: true, year: true } },
round: {
select: {
id: true,
name: true,
slug: true,
submissionStartDate: true,
submissionEndDate: true,
submissionDeadline: true,
},
},
steps: {
orderBy: { sortOrder: 'asc' },
include: {
fields: { orderBy: { sortOrder: 'asc' } },
},
},
fields: {
where: { stepId: null },
orderBy: { sortOrder: 'asc' },
},
},
})
}
if (!form) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Application form not found or not accepting submissions',
})
}
// Check submission window
const now = new Date()
const startDate = form.round?.submissionStartDate || form.opensAt
const endDate = form.round?.submissionEndDate || form.round?.submissionDeadline || form.closesAt
if (startDate && now < startDate) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Applications are not yet open',
})
}
if (endDate && now > endDate) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Applications have closed',
})
}
// Check submission limit
if (form.submissionLimit) {
const count = await ctx.prisma.applicationFormSubmission.count({
where: { formId: form.id },
})
if (count >= form.submissionLimit) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'This form has reached its submission limit',
})
}
}
return {
form: {
id: form.id,
name: form.name,
description: form.description,
confirmationMessage: form.confirmationMessage,
},
program: form.program,
round: form.round,
steps: form.steps,
orphanFields: form.fields, // Fields not assigned to any step
}
}),
/**
* Submit an application through the onboarding wizard
* Creates Project, TeamMembers, and sends emails
*/
submit: publicProcedure
.input(
z.object({
formId: z.string(),
// Contact info
contactName: z.string().min(1),
contactEmail: z.string().email(),
contactPhone: z.string().optional(),
// Project info
projectName: z.string().min(1),
description: z.string().optional(),
competitionCategory: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(),
oceanIssue: z
.enum([
'POLLUTION_REDUCTION',
'CLIMATE_MITIGATION',
'TECHNOLOGY_INNOVATION',
'SUSTAINABLE_SHIPPING',
'BLUE_CARBON',
'HABITAT_RESTORATION',
'COMMUNITY_CAPACITY',
'SUSTAINABLE_FISHING',
'CONSUMER_AWARENESS',
'OCEAN_ACIDIFICATION',
'OTHER',
])
.optional(),
country: z.string().optional(),
institution: z.string().optional(),
teamName: z.string().optional(),
wantsMentorship: z.boolean().optional(),
referralSource: z.string().optional(),
foundedAt: z.string().datetime().optional(),
// Team members
teamMembers: z.array(teamMemberInputSchema).optional(),
// Additional metadata (unmapped fields)
metadata: z.record(z.unknown()).optional(),
// GDPR consent
gdprConsent: z.boolean(),
})
)
.mutation(async ({ ctx, input }) => {
if (!input.gdprConsent) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'You must accept the terms and conditions to submit',
})
}
// Get form with round info
const form = await ctx.prisma.applicationForm.findUniqueOrThrow({
where: { id: input.formId },
include: {
round: true,
program: true,
},
})
// Verify form is accepting submissions
if (!form.isPublic || form.status !== 'PUBLISHED') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'This form is not accepting submissions',
})
}
// Check if we need a round/program for project creation
const programId = form.round?.programId || form.programId
if (!programId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'This form is not linked to a program',
})
}
// Create or find user for contact email
let contactUser = await ctx.prisma.user.findUnique({
where: { email: input.contactEmail },
})
if (!contactUser) {
contactUser = await ctx.prisma.user.create({
data: {
email: input.contactEmail,
name: input.contactName,
role: 'APPLICANT',
status: 'ACTIVE',
},
})
}
// Create project
const project = await ctx.prisma.project.create({
data: {
programId,
title: input.projectName,
description: input.description,
teamName: input.teamName || input.projectName,
competitionCategory: input.competitionCategory,
oceanIssue: input.oceanIssue,
country: input.country,
institution: input.institution,
wantsMentorship: input.wantsMentorship ?? false,
referralSource: input.referralSource,
foundedAt: input.foundedAt ? new Date(input.foundedAt) : null,
submissionSource: 'PUBLIC_FORM',
submittedByEmail: input.contactEmail,
submittedByUserId: contactUser.id,
submittedAt: new Date(),
metadataJson: input.metadata as Prisma.InputJsonValue ?? {},
},
})
// Create RoundProject entry if form is linked to a round
if (form.roundId) {
await ctx.prisma.roundProject.create({
data: {
roundId: form.roundId,
projectId: project.id,
status: 'SUBMITTED',
},
})
}
// Create TeamMember for contact as LEAD
await ctx.prisma.teamMember.create({
data: {
projectId: project.id,
userId: contactUser.id,
role: 'LEAD',
title: 'Team Lead',
},
})
// Process additional team members
const invitePromises: Promise<void>[] = []
if (input.teamMembers && input.teamMembers.length > 0) {
for (const member of input.teamMembers) {
// Skip if same email as contact
if (member.email === input.contactEmail) continue
let memberUser = member.email
? await ctx.prisma.user.findUnique({ where: { email: member.email } })
: null
if (member.email && !memberUser) {
// Create user with invite token
const inviteToken = nanoid(32)
const inviteTokenExpiresAt = new Date()
inviteTokenExpiresAt.setDate(inviteTokenExpiresAt.getDate() + 30) // 30 days
memberUser = await ctx.prisma.user.create({
data: {
email: member.email,
name: member.name,
role: 'APPLICANT',
status: 'INVITED',
inviteToken,
inviteTokenExpiresAt,
},
})
// Queue invite email
if (form.sendTeamInviteEmails) {
const inviteUrl = `${process.env.NEXTAUTH_URL || ''}/accept-invite?token=${inviteToken}`
invitePromises.push(
sendTeamMemberInviteEmail(
member.email,
member.name,
input.projectName,
input.contactName,
inviteUrl
).catch((err) => {
console.error(`Failed to send invite email to ${member.email}:`, err)
})
)
}
}
// Create team member if we have a user
if (memberUser) {
await ctx.prisma.teamMember.create({
data: {
projectId: project.id,
userId: memberUser.id,
role: member.role,
title: member.title,
},
})
}
}
}
// Create form submission record
await ctx.prisma.applicationFormSubmission.create({
data: {
formId: input.formId,
email: input.contactEmail,
name: input.contactName,
dataJson: input as unknown as Prisma.InputJsonValue,
status: 'SUBMITTED',
},
})
// Send confirmation email
if (form.sendConfirmationEmail) {
const programName = form.program?.name || form.round?.name || 'the program'
try {
await sendApplicationConfirmationEmail(
input.contactEmail,
input.contactName,
input.projectName,
programName,
form.confirmationEmailBody || form.confirmationMessage || undefined
)
} catch (err) {
console.error('Failed to send confirmation email:', err)
}
}
// Wait for invite emails (don't block on failure)
await Promise.allSettled(invitePromises)
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: contactUser.id,
action: 'SUBMIT_APPLICATION',
entityType: 'Project',
entityId: project.id,
detailsJson: {
formId: input.formId,
projectName: input.projectName,
teamMemberCount: (input.teamMembers?.length || 0) + 1,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return {
success: true,
projectId: project.id,
confirmationMessage: form.confirmationMessage,
}
}),
})