mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-03-24 03:37:51 -04:00
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:
@@ -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
|
||||
|
||||
592
src/server/api/routers/forms.ts
Normal file
592
src/server/api/routers/forms.ts
Normal 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;
|
||||
}),
|
||||
});
|
||||
Reference in New Issue
Block a user