feat: complete forms system overhaul

- Add new forms table with type (consent/survey/questionnaire)
- Add formResponses table for submissions
- Add forms API router with full CRUD:
  - list, get, create, update, delete
  - setActive, createVersion
  - getResponses, submitResponse
- Add forms list page with card-based UI
- Add form builder with field types (text, textarea, multiple_choice, checkbox, rating, yes_no, date, signature)
- Add form viewer with edit mode and preview
- Add responses viewing with participant info
This commit is contained in:
2026-03-22 17:43:12 -04:00
parent 8529d0ef89
commit 49e0df016a
6 changed files with 1756 additions and 290 deletions

View File

@@ -5,6 +5,7 @@ import { collaborationRouter } from "~/server/api/routers/collaboration";
import { dashboardRouter } from "~/server/api/routers/dashboard";
import { experimentsRouter } from "~/server/api/routers/experiments";
import { filesRouter } from "~/server/api/routers/files";
import { formsRouter } from "~/server/api/routers/forms";
import { mediaRouter } from "~/server/api/routers/media";
import { participantsRouter } from "~/server/api/routers/participants";
import { pluginsRouter } from "~/server/api/routers/plugins";
@@ -34,6 +35,7 @@ export const appRouter = createTRPCRouter({
admin: adminRouter,
dashboard: dashboardRouter,
storage: storageRouter,
forms: formsRouter,
});
// export type definition of API

View File

@@ -0,0 +1,592 @@
import { TRPCError } from "@trpc/server";
import { and, count, desc, eq, ilike, or } from "drizzle-orm";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import {
activityLogs,
formResponses,
formTypeEnum,
forms,
formFieldTypeEnum,
participants,
studyMembers,
userSystemRoles,
} from "~/server/db/schema";
const formTypes = formTypeEnum.enumValues;
const fieldTypes = formFieldTypeEnum.enumValues;
async function checkStudyAccess(
db: typeof import("~/server/db").db,
userId: string,
studyId: string,
requiredRole?: string[],
) {
const adminRole = await db.query.userSystemRoles.findFirst({
where: and(
eq(userSystemRoles.userId, userId),
eq(userSystemRoles.role, "administrator"),
),
});
if (adminRole) {
return { role: "administrator", studyId, userId, joinedAt: new Date() };
}
const membership = await db.query.studyMembers.findFirst({
where: and(
eq(studyMembers.studyId, studyId),
eq(studyMembers.userId, userId),
),
});
if (!membership) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You don't have access to this study",
});
}
if (requiredRole && !requiredRole.includes(membership.role)) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You don't have permission to perform this action",
});
}
return membership;
}
export const formsRouter = createTRPCRouter({
list: protectedProcedure
.input(
z.object({
studyId: z.string().uuid(),
type: z.enum(formTypes).optional(),
search: z.string().optional(),
page: z.number().min(1).default(1),
limit: z.number().min(1).max(100).default(20),
}),
)
.query(async ({ ctx, input }) => {
const { studyId, type, search, page, limit } = input;
const offset = (page - 1) * limit;
await checkStudyAccess(ctx.db, ctx.session.user.id, studyId);
const conditions = [eq(forms.studyId, studyId)];
if (type) {
conditions.push(eq(forms.type, type));
}
if (search) {
conditions.push(
or(
ilike(forms.title, `%${search}%`),
ilike(forms.description, `%${search}%`),
)!,
);
}
const [formsList, totalCount] = await Promise.all([
ctx.db.query.forms.findMany({
where: and(...conditions),
with: {
createdBy: {
columns: {
id: true,
name: true,
email: true,
},
},
},
orderBy: [desc(forms.updatedAt)],
limit,
offset,
}),
ctx.db
.select({ count: count() })
.from(forms)
.where(and(...conditions)),
]);
const formsWithCounts = await Promise.all(
formsList.map(async (form) => {
const responseCount = await ctx.db
.select({ count: count() })
.from(formResponses)
.where(eq(formResponses.formId, form.id));
return { ...form, _count: { responses: responseCount[0]?.count ?? 0 } };
})
);
return {
forms: formsWithCounts,
pagination: {
page,
limit,
total: totalCount[0]?.count ?? 0,
pages: Math.ceil((totalCount[0]?.count ?? 0) / limit),
},
};
}),
get: protectedProcedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ ctx, input }) => {
const form = await ctx.db.query.forms.findFirst({
where: eq(forms.id, input.id),
with: {
createdBy: {
columns: {
id: true,
name: true,
email: true,
},
},
responses: {
with: {
participant: {
columns: {
id: true,
participantCode: true,
name: true,
},
},
},
orderBy: [desc(formResponses.submittedAt)],
},
},
});
if (!form) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Form not found",
});
}
await checkStudyAccess(ctx.db, ctx.session.user.id, form.studyId);
return form;
}),
create: protectedProcedure
.input(
z.object({
studyId: z.string().uuid(),
type: z.enum(formTypes),
title: z.string().min(1).max(255),
description: z.string().optional(),
fields: z.array(
z.object({
id: z.string(),
type: z.string(),
label: z.string(),
required: z.boolean().default(false),
options: z.array(z.string()).optional(),
settings: z.record(z.string(), z.any()).optional(),
}),
).default([]),
settings: z.record(z.string(), z.any()).optional(),
}),
)
.mutation(async ({ ctx, input }) => {
await checkStudyAccess(ctx.db, ctx.session.user.id, input.studyId, [
"owner",
"researcher",
]);
const [newForm] = await ctx.db
.insert(forms)
.values({
studyId: input.studyId,
type: input.type,
title: input.title,
description: input.description,
fields: input.fields,
settings: input.settings ?? {},
createdBy: ctx.session.user.id,
})
.returning();
if (!newForm) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to create form",
});
}
await ctx.db.insert(activityLogs).values({
studyId: input.studyId,
userId: ctx.session.user.id,
action: "form_created",
description: `Created form "${newForm.title}"`,
resourceType: "form",
resourceId: newForm.id,
});
return newForm;
}),
update: protectedProcedure
.input(
z.object({
id: z.string().uuid(),
title: z.string().min(1).max(255).optional(),
description: z.string().optional(),
fields: z.array(
z.object({
id: z.string(),
type: z.string(),
label: z.string(),
required: z.boolean().default(false),
options: z.array(z.string()).optional(),
settings: z.record(z.string(), z.any()).optional(),
}),
).optional(),
settings: z.record(z.string(), z.any()).optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const { id, ...updateData } = input;
const existingForm = await ctx.db.query.forms.findFirst({
where: eq(forms.id, id),
});
if (!existingForm) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Form not found",
});
}
await checkStudyAccess(ctx.db, ctx.session.user.id, existingForm.studyId, [
"owner",
"researcher",
]);
const [updatedForm] = await ctx.db
.update(forms)
.set({
...updateData,
updatedAt: new Date(),
})
.where(eq(forms.id, id))
.returning();
if (!updatedForm) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to update form",
});
}
await ctx.db.insert(activityLogs).values({
studyId: existingForm.studyId,
userId: ctx.session.user.id,
action: "form_updated",
description: `Updated form "${updatedForm.title}"`,
resourceType: "form",
resourceId: id,
});
return updatedForm;
}),
delete: protectedProcedure
.input(z.object({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
const form = await ctx.db.query.forms.findFirst({
where: eq(forms.id, input.id),
});
if (!form) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Form not found",
});
}
await checkStudyAccess(ctx.db, ctx.session.user.id, form.studyId, [
"owner",
"researcher",
]);
await ctx.db.delete(forms).where(eq(forms.id, input.id));
await ctx.db.insert(activityLogs).values({
studyId: form.studyId,
userId: ctx.session.user.id,
action: "form_deleted",
description: `Deleted form "${form.title}"`,
resourceType: "form",
resourceId: input.id,
});
return { success: true };
}),
setActive: protectedProcedure
.input(z.object({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
const form = await ctx.db.query.forms.findFirst({
where: eq(forms.id, input.id),
});
if (!form) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Form not found",
});
}
await checkStudyAccess(ctx.db, ctx.session.user.id, form.studyId, [
"owner",
"researcher",
]);
await ctx.db
.update(forms)
.set({ active: false })
.where(eq(forms.studyId, form.studyId));
const [updatedForm] = await ctx.db
.update(forms)
.set({ active: true })
.where(eq(forms.id, input.id))
.returning();
return updatedForm;
}),
createVersion: protectedProcedure
.input(
z.object({
id: z.string().uuid(),
title: z.string().min(1).max(255).optional(),
description: z.string().optional(),
fields: z.array(
z.object({
id: z.string(),
type: z.string(),
label: z.string(),
required: z.boolean().default(false),
options: z.array(z.string()).optional(),
settings: z.record(z.string(), z.any()).optional(),
}),
),
settings: z.record(z.string(), z.any()).optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const { id, ...updateData } = input;
const existingForm = await ctx.db.query.forms.findFirst({
where: eq(forms.id, id),
});
if (!existingForm) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Form not found",
});
}
await checkStudyAccess(ctx.db, ctx.session.user.id, existingForm.studyId, [
"owner",
"researcher",
]);
const latestForm = await ctx.db.query.forms.findFirst({
where: eq(forms.studyId, existingForm.studyId),
orderBy: [desc(forms.version)],
});
const newVersion = (latestForm?.version ?? 0) + 1;
const [newForm] = await ctx.db
.insert(forms)
.values({
studyId: existingForm.studyId,
type: existingForm.type,
title: updateData.title ?? existingForm.title,
description: updateData.description ?? existingForm.description,
fields: updateData.fields ?? existingForm.fields,
settings: updateData.settings ?? existingForm.settings,
version: newVersion,
active: false,
createdBy: ctx.session.user.id,
})
.returning();
if (!newForm) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to create form version",
});
}
await ctx.db.insert(activityLogs).values({
studyId: existingForm.studyId,
userId: ctx.session.user.id,
action: "form_version_created",
description: `Created version ${newVersion} of form "${newForm.title}"`,
resourceType: "form",
resourceId: newForm.id,
});
return newForm;
}),
getResponses: protectedProcedure
.input(
z.object({
formId: z.string().uuid(),
page: z.number().min(1).default(1),
limit: z.number().min(1).max(100).default(20),
status: z.enum(["pending", "completed", "rejected"]).optional(),
}),
)
.query(async ({ ctx, input }) => {
const { formId, page, limit, status } = input;
const offset = (page - 1) * limit;
const form = await ctx.db.query.forms.findFirst({
where: eq(forms.id, formId),
});
if (!form) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Form not found",
});
}
await checkStudyAccess(ctx.db, ctx.session.user.id, form.studyId);
const conditions = [eq(formResponses.formId, formId)];
if (status) {
conditions.push(eq(formResponses.status, status));
}
const [responses, totalCount] = await Promise.all([
ctx.db.query.formResponses.findMany({
where: and(...conditions),
with: {
participant: {
columns: {
id: true,
participantCode: true,
name: true,
email: true,
},
},
},
orderBy: [desc(formResponses.submittedAt)],
limit,
offset,
}),
ctx.db
.select({ count: count() })
.from(formResponses)
.where(and(...conditions)),
]);
return {
responses,
pagination: {
page,
limit,
total: totalCount[0]?.count ?? 0,
pages: Math.ceil((totalCount[0]?.count ?? 0) / limit),
},
};
}),
submitResponse: protectedProcedure
.input(
z.object({
formId: z.string().uuid(),
participantId: z.string().uuid(),
responses: z.record(z.string(), z.any()),
signatureData: z.string().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const { formId, participantId, responses, signatureData } = input;
const form = await ctx.db.query.forms.findFirst({
where: eq(forms.id, formId),
});
if (!form) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Form not found",
});
}
await checkStudyAccess(ctx.db, ctx.session.user.id, form.studyId);
const existingResponse = await ctx.db.query.formResponses.findFirst({
where: and(
eq(formResponses.formId, formId),
eq(formResponses.participantId, participantId),
),
});
if (existingResponse) {
throw new TRPCError({
code: "CONFLICT",
message: "Participant has already submitted this form",
});
}
const [newResponse] = await ctx.db
.insert(formResponses)
.values({
formId,
participantId,
responses,
signatureData,
status: signatureData ? "completed" : "pending",
signedAt: signatureData ? new Date() : null,
})
.returning();
return newResponse;
}),
listVersions: protectedProcedure
.input(z.object({ studyId: z.string().uuid() }))
.query(async ({ ctx, input }) => {
await checkStudyAccess(ctx.db, ctx.session.user.id, input.studyId);
const formsList = await ctx.db.query.forms.findMany({
where: eq(forms.studyId, input.studyId),
with: {
createdBy: {
columns: {
id: true,
name: true,
email: true,
},
},
},
orderBy: [desc(forms.version)],
});
const formsWithCounts = await Promise.all(
formsList.map(async (form) => {
const responseCount = await ctx.db
.select({ count: count() })
.from(formResponses)
.where(eq(formResponses.formId, form.id));
return { ...form, _count: { responses: responseCount[0]?.count ?? 0 } };
})
);
return formsWithCounts;
}),
});