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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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() }))
|
||||
|
||||
398
src/server/routers/onboarding.ts
Normal file
398
src/server/routers/onboarding.ts
Normal 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,
|
||||
}
|
||||
}),
|
||||
})
|
||||
Reference in New Issue
Block a user