import { z } from 'zod' import { TRPCError } from '@trpc/server' import { Prisma } from '@prisma/client' import { router, publicProcedure, adminProcedure } from '../trpc' import { getPresignedUrl } from '@/lib/minio' // Bucket for form submission files export const SUBMISSIONS_BUCKET = 'mopc-submissions' // Field type enum matching Prisma const fieldTypeEnum = z.enum([ 'TEXT', 'TEXTAREA', 'NUMBER', 'EMAIL', 'PHONE', 'URL', 'DATE', 'DATETIME', 'SELECT', 'MULTI_SELECT', 'RADIO', 'CHECKBOX', 'CHECKBOX_GROUP', 'FILE', 'FILE_MULTIPLE', 'SECTION', '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, name: z.string().min(1).max(100), label: z.string().min(1).max(255), description: z.string().optional(), placeholder: z.string().optional(), required: z.boolean().default(false), minLength: z.number().int().optional(), maxLength: z.number().int().optional(), minValue: z.number().optional(), maxValue: z.number().optional(), optionsJson: z .array(z.object({ value: z.string(), label: z.string() })) .optional(), conditionJson: z .object({ fieldId: z.string(), operator: z.enum(['equals', 'not_equals', 'contains', 'not_empty']), value: z.string().optional(), }) .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({ /** * List all forms (admin view) */ list: adminProcedure .input( z.object({ programId: z.string().optional(), status: z.enum(['DRAFT', 'PUBLISHED', 'CLOSED']).optional(), page: z.number().int().min(1).default(1), perPage: z.number().int().min(1).max(100).default(20), }) ) .query(async ({ ctx, input }) => { const where: Record = {} if (input.programId !== undefined) { where.programId = input.programId } if (input.status) { where.status = input.status } const [data, total] = await Promise.all([ ctx.prisma.applicationForm.findMany({ where, include: { program: { select: { id: true, name: true, year: true } }, _count: { select: { submissions: true, fields: true } }, }, orderBy: { createdAt: 'desc' }, skip: (input.page - 1) * input.perPage, take: input.perPage, }), ctx.prisma.applicationForm.count({ where }), ]) return { data, total, page: input.page, perPage: input.perPage, totalPages: Math.ceil(total / input.perPage), } }), /** * Get a single form by ID (admin view with all fields and steps) */ get: 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 } }, fields: { orderBy: { sortOrder: 'asc' } }, steps: { orderBy: { sortOrder: 'asc' }, include: { fields: { orderBy: { sortOrder: 'asc' } } }, }, _count: { select: { submissions: true } }, }, }) }), /** * Get a public form by slug (for form submission) */ getBySlug: publicProcedure .input(z.object({ slug: z.string() })) .query(async ({ ctx, input }) => { const form = await ctx.prisma.applicationForm.findUnique({ where: { publicSlug: input.slug }, include: { fields: { orderBy: { sortOrder: 'asc' } }, }, }) if (!form) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Form not found', }) } // Check if form is available if (!form.isPublic || form.status !== 'PUBLISHED') { throw new TRPCError({ code: 'FORBIDDEN', message: 'This form is not currently accepting submissions', }) } // Check submission window const now = new Date() if (form.opensAt && now < form.opensAt) { throw new TRPCError({ code: 'FORBIDDEN', message: 'This form is not yet open for submissions', }) } if (form.closesAt && now > form.closesAt) { throw new TRPCError({ code: 'FORBIDDEN', message: 'This form has 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 }), /** * Create a new form (admin only) */ create: adminProcedure .input( z.object({ programId: z.string().nullable(), name: z.string().min(1).max(255), description: z.string().optional(), publicSlug: z.string().min(1).max(100).optional(), submissionLimit: z.number().int().optional(), opensAt: z.string().datetime().optional(), closesAt: z.string().datetime().optional(), confirmationMessage: z.string().optional(), }) ) .mutation(async ({ ctx, input }) => { // Check slug uniqueness if (input.publicSlug) { const existing = await ctx.prisma.applicationForm.findUnique({ where: { publicSlug: input.publicSlug }, }) if (existing) { throw new TRPCError({ code: 'CONFLICT', message: 'This URL slug is already in use', }) } } const form = await ctx.prisma.applicationForm.create({ data: { ...input, opensAt: input.opensAt ? new Date(input.opensAt) : null, closesAt: input.closesAt ? new Date(input.closesAt) : null, }, }) // Audit log await ctx.prisma.auditLog.create({ data: { userId: ctx.user.id, action: 'CREATE', entityType: 'ApplicationForm', entityId: form.id, detailsJson: { name: input.name }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }, }) return form }), /** * Update a form (admin only) */ update: adminProcedure .input( z.object({ id: z.string(), name: z.string().min(1).max(255).optional(), description: z.string().optional().nullable(), status: z.enum(['DRAFT', 'PUBLISHED', 'CLOSED']).optional(), isPublic: z.boolean().optional(), publicSlug: z.string().min(1).max(100).optional().nullable(), submissionLimit: z.number().int().optional().nullable(), opensAt: z.string().datetime().optional().nullable(), closesAt: z.string().datetime().optional().nullable(), confirmationMessage: z.string().optional().nullable(), }) ) .mutation(async ({ ctx, input }) => { const { id, opensAt, closesAt, ...data } = input // Check slug uniqueness if changing if (data.publicSlug) { const existing = await ctx.prisma.applicationForm.findFirst({ where: { publicSlug: data.publicSlug, NOT: { id } }, }) if (existing) { throw new TRPCError({ code: 'CONFLICT', message: 'This URL slug is already in use', }) } } const form = await ctx.prisma.applicationForm.update({ where: { id }, data: { ...data, opensAt: opensAt ? new Date(opensAt) : opensAt === null ? null : undefined, closesAt: closesAt ? new Date(closesAt) : closesAt === null ? null : undefined, }, }) // Audit log await ctx.prisma.auditLog.create({ data: { userId: ctx.user.id, action: 'UPDATE', entityType: 'ApplicationForm', entityId: id, detailsJson: data, ipAddress: ctx.ip, userAgent: ctx.userAgent, }, }) return form }), /** * Delete a form (admin only) */ delete: adminProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const form = await ctx.prisma.applicationForm.delete({ where: { id: input.id }, }) // Audit log await ctx.prisma.auditLog.create({ data: { userId: ctx.user.id, action: 'DELETE', entityType: 'ApplicationForm', entityId: input.id, detailsJson: { name: form.name }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }, }) return form }), /** * Add a field to a form (or step) */ addField: adminProcedure .input( z.object({ formId: z.string(), field: fieldInputSchema, }) ) .mutation(async ({ ctx, input }) => { // 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: whereClause, _max: { sortOrder: true }, }) const { stepId, projectMapping, specialType, ...restField } = input.field const field = await ctx.prisma.applicationFormField.create({ data: { formId: input.formId, ...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, }, }) return field }), /** * Update a field */ updateField: adminProcedure .input( z.object({ id: z.string(), field: fieldInputSchema.partial(), }) ) .mutation(async ({ ctx, input }) => { const { stepId, projectMapping, specialType, ...restField } = input.field const field = await ctx.prisma.applicationFormField.update({ where: { id: input.id }, data: { ...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, }, }) return field }), /** * Delete a field */ deleteField: adminProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { return ctx.prisma.applicationFormField.delete({ where: { id: input.id }, }) }), /** * Reorder fields */ reorderFields: adminProcedure .input( z.object({ items: z.array( z.object({ id: z.string(), sortOrder: z.number().int(), }) ), }) ) .mutation(async ({ ctx, input }) => { await ctx.prisma.$transaction( input.items.map((item) => ctx.prisma.applicationFormField.update({ where: { id: item.id }, data: { sortOrder: item.sortOrder }, }) ) ) return { success: true } }), /** * Submit a form (public endpoint) */ submit: publicProcedure .input( z.object({ formId: z.string(), data: z.record(z.unknown()), email: z.string().email().optional(), name: z.string().optional(), }) ) .mutation(async ({ ctx, input }) => { // Get form with fields const form = await ctx.prisma.applicationForm.findUniqueOrThrow({ where: { id: input.formId }, include: { fields: 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 submission window const now = new Date() if (form.opensAt && now < form.opensAt) { throw new TRPCError({ code: 'FORBIDDEN', message: 'This form is not yet open', }) } if (form.closesAt && now > form.closesAt) { throw new TRPCError({ code: 'FORBIDDEN', message: 'This form has 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', }) } } // Validate required fields for (const field of form.fields) { if (field.required && field.fieldType !== 'SECTION' && field.fieldType !== 'INSTRUCTIONS') { const value = input.data[field.name] if (value === undefined || value === null || value === '') { throw new TRPCError({ code: 'BAD_REQUEST', message: `${field.label} is required`, }) } } } // Create submission const submission = await ctx.prisma.applicationFormSubmission.create({ data: { formId: input.formId, email: input.email, name: input.name, dataJson: input.data as Prisma.InputJsonValue, }, }) return { success: true, submissionId: submission.id, confirmationMessage: form.confirmationMessage, } }), /** * Get upload URL for a submission file */ getSubmissionUploadUrl: publicProcedure .input( z.object({ formId: z.string(), fieldName: z.string(), fileName: z.string(), mimeType: z.string(), }) ) .mutation(async ({ ctx, input }) => { // Verify form exists and is accepting submissions const form = await ctx.prisma.applicationForm.findUniqueOrThrow({ where: { id: input.formId }, }) if (!form.isPublic || form.status !== 'PUBLISHED') { throw new TRPCError({ code: 'FORBIDDEN', message: 'This form is not accepting submissions', }) } const timestamp = Date.now() const sanitizedName = input.fileName.replace(/[^a-zA-Z0-9.-]/g, '_') const objectKey = `forms/${input.formId}/${timestamp}-${sanitizedName}` const url = await getPresignedUrl(SUBMISSIONS_BUCKET, objectKey, 'PUT', 3600) return { url, bucket: SUBMISSIONS_BUCKET, objectKey, } }), /** * List submissions for a form (admin only) */ listSubmissions: adminProcedure .input( z.object({ formId: z.string(), status: z.enum(['SUBMITTED', 'REVIEWED', 'APPROVED', 'REJECTED']).optional(), search: z.string().optional(), page: z.number().int().min(1).default(1), perPage: z.number().int().min(1).max(100).default(20), }) ) .query(async ({ ctx, input }) => { const where: Record = { formId: input.formId, } if (input.status) { where.status = input.status } if (input.search) { where.OR = [ { email: { contains: input.search, mode: 'insensitive' } }, { name: { contains: input.search, mode: 'insensitive' } }, ] } const [data, total] = await Promise.all([ ctx.prisma.applicationFormSubmission.findMany({ where, include: { files: true, }, orderBy: { createdAt: 'desc' }, skip: (input.page - 1) * input.perPage, take: input.perPage, }), ctx.prisma.applicationFormSubmission.count({ where }), ]) return { data, total, page: input.page, perPage: input.perPage, totalPages: Math.ceil(total / input.perPage), } }), /** * Get a single submission */ getSubmission: adminProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { return ctx.prisma.applicationFormSubmission.findUniqueOrThrow({ where: { id: input.id }, include: { form: { include: { fields: { orderBy: { sortOrder: 'asc' } } }, }, files: true, }, }) }), /** * Update submission status */ updateSubmissionStatus: adminProcedure .input( z.object({ id: z.string(), status: z.enum(['SUBMITTED', 'REVIEWED', 'APPROVED', 'REJECTED']), }) ) .mutation(async ({ ctx, input }) => { const submission = await ctx.prisma.applicationFormSubmission.update({ where: { id: input.id }, data: { status: input.status }, }) // Audit log await ctx.prisma.auditLog.create({ data: { userId: ctx.user.id, action: 'UPDATE_STATUS', entityType: 'ApplicationFormSubmission', entityId: input.id, detailsJson: { status: input.status }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }, }) return submission }), /** * Delete a submission */ deleteSubmission: adminProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const submission = await ctx.prisma.applicationFormSubmission.delete({ where: { id: input.id }, }) // Audit log await ctx.prisma.auditLog.create({ data: { userId: ctx.user.id, action: 'DELETE', entityType: 'ApplicationFormSubmission', entityId: input.id, ipAddress: ctx.ip, userAgent: ctx.userAgent, }, }) return submission }), /** * Get download URL for a submission file */ getSubmissionFileUrl: adminProcedure .input(z.object({ fileId: z.string() })) .query(async ({ ctx, input }) => { const file = await ctx.prisma.submissionFile.findUniqueOrThrow({ where: { id: input.fileId }, }) const url = await getPresignedUrl(file.bucket, file.objectKey, 'GET', 900) return { url, fileName: file.fileName } }), /** * Duplicate a form */ duplicate: adminProcedure .input( z.object({ id: z.string(), name: z.string().min(1).max(255), }) ) .mutation(async ({ ctx, input }) => { // Get original form with fields const original = await ctx.prisma.applicationForm.findUniqueOrThrow({ where: { id: input.id }, include: { fields: true }, }) // Create new form const newForm = await ctx.prisma.applicationForm.create({ data: { programId: original.programId, name: input.name, description: original.description, status: 'DRAFT', isPublic: false, confirmationMessage: original.confirmationMessage, }, }) // Copy fields await ctx.prisma.applicationFormField.createMany({ data: original.fields.map((field) => ({ formId: newForm.id, fieldType: field.fieldType, name: field.name, label: field.label, description: field.description, placeholder: field.placeholder, required: field.required, minLength: field.minLength, maxLength: field.maxLength, minValue: field.minValue, maxValue: field.maxValue, optionsJson: field.optionsJson as Prisma.InputJsonValue ?? undefined, conditionJson: field.conditionJson as Prisma.InputJsonValue ?? undefined, sortOrder: field.sortOrder, width: field.width, })), }) // Audit log await ctx.prisma.auditLog.create({ data: { userId: ctx.user.id, action: 'DUPLICATE', entityType: 'ApplicationForm', entityId: newForm.id, detailsJson: { originalId: input.id, name: input.name }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }, }) 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 */ getStats: adminProcedure .input(z.object({ formId: z.string() })) .query(async ({ ctx, input }) => { const [total, byStatus, recentSubmissions] = await Promise.all([ ctx.prisma.applicationFormSubmission.count({ where: { formId: input.formId }, }), ctx.prisma.applicationFormSubmission.groupBy({ by: ['status'], where: { formId: input.formId }, _count: true, }), ctx.prisma.applicationFormSubmission.findMany({ where: { formId: input.formId }, select: { createdAt: true }, orderBy: { createdAt: 'desc' }, take: 30, }), ]) // Calculate submissions per day for last 30 days const thirtyDaysAgo = new Date() thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30) const submissionsPerDay = new Map() for (const sub of recentSubmissions) { const date = sub.createdAt.toISOString().split('T')[0] submissionsPerDay.set(date, (submissionsPerDay.get(date) || 0) + 1) } return { total, byStatus: Object.fromEntries( byStatus.map((r) => [r.status, r._count]) ), submissionsPerDay: Object.fromEntries(submissionsPerDay), } }), })