1069 lines
29 KiB
TypeScript
1069 lines
29 KiB
TypeScript
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<string, unknown> = {}
|
|
|
|
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<string, unknown> = {
|
|
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<string, number>()
|
|
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),
|
|
}
|
|
}),
|
|
})
|