MOPC-App/src/server/routers/applicationForm.ts

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),
}
}),
})