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

@@ -0,0 +1,506 @@
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useSession } from "~/lib/auth-client";
import { notFound } from "next/navigation";
import Link from "next/link";
import {
FileText,
ArrowLeft,
Plus,
Trash2,
GripVertical,
FileSignature,
ClipboardList,
FileQuestion,
Save,
Eye,
Edit2,
Users,
CheckCircle,
} from "lucide-react";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Textarea } from "~/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { Badge } from "~/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { api } from "~/trpc/react";
import { toast } from "sonner";
interface Field {
id: string;
type: string;
label: string;
required: boolean;
options?: string[];
settings?: Record<string, any>;
}
const fieldTypes = [
{ value: "text", label: "Text (short)", icon: "📝" },
{ value: "textarea", label: "Text (long)", icon: "📄" },
{ value: "multiple_choice", label: "Multiple Choice", icon: "☑️" },
{ value: "checkbox", label: "Checkbox", icon: "✅" },
{ value: "rating", label: "Rating Scale", icon: "⭐" },
{ value: "yes_no", label: "Yes/No", icon: "✔️" },
{ value: "date", label: "Date", icon: "📅" },
{ value: "signature", label: "Signature", icon: "✍️" },
];
const formTypeIcons = {
consent: FileSignature,
survey: ClipboardList,
questionnaire: FileQuestion,
};
const statusColors = {
pending: "bg-yellow-100 text-yellow-700",
completed: "bg-green-100 text-green-700",
rejected: "bg-red-100 text-red-700",
};
interface FormViewPageProps {
params: Promise<{
id: string;
formId: string;
}>;
}
export default function FormViewPage({ params }: FormViewPageProps) {
const { data: session } = useSession();
const router = useRouter();
const utils = api.useUtils();
const [resolvedParams, setResolvedParams] = useState<{ id: string; formId: string } | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [fields, setFields] = useState<Field[]>([]);
useEffect(() => {
const resolveParams = async () => {
const resolved = await params;
setResolvedParams(resolved);
};
void resolveParams();
}, [params]);
const { data: study } = api.studies.get.useQuery(
{ id: resolvedParams?.id ?? "" },
{ enabled: !!resolvedParams?.id },
);
const { data: form, isLoading } = api.forms.get.useQuery(
{ id: resolvedParams?.formId ?? "" },
{ enabled: !!resolvedParams?.formId },
);
const { data: responsesData } = api.forms.getResponses.useQuery(
{ formId: resolvedParams?.formId ?? "", limit: 50 },
{ enabled: !!resolvedParams?.formId },
);
const userRole = (study as any)?.userRole;
const canManage = userRole === "owner" || userRole === "researcher";
const updateForm = api.forms.update.useMutation({
onSuccess: () => {
toast.success("Form updated successfully!");
setIsEditing(false);
void utils.forms.get.invalidate({ id: resolvedParams?.formId });
},
onError: (error) => {
toast.error("Failed to update form", { description: error.message });
},
});
useEffect(() => {
if (form) {
setTitle(form.title);
setDescription(form.description || "");
setFields((form.fields as Field[]) || []);
}
}, [form]);
useBreadcrumbsEffect([
{ label: "Dashboard", href: "/dashboard" },
{ label: "Studies", href: "/studies" },
{ label: study?.name ?? "Study", href: `/studies/${resolvedParams?.id}` },
{ label: "Forms", href: `/studies/${resolvedParams?.id}/forms` },
{ label: form?.title ?? "Form" },
]);
if (!session?.user) {
return notFound();
}
if (isLoading || !form) return <div>Loading...</div>;
const TypeIcon = formTypeIcons[form.type as keyof typeof formTypeIcons] || FileText;
const responses = responsesData?.responses ?? [];
const addField = (type: string) => {
const newField: Field = {
id: crypto.randomUUID(),
type,
label: `New ${fieldTypes.find(f => f.value === type)?.label || "Field"}`,
required: false,
options: type === "multiple_choice" ? ["Option 1", "Option 2"] : undefined,
};
setFields([...fields, newField]);
};
const removeField = (id: string) => {
setFields(fields.filter(f => f.id !== id));
};
const updateField = (id: string, updates: Partial<Field>) => {
setFields(fields.map(f => f.id === id ? { ...f, ...updates } : f));
};
const handleSave = () => {
updateForm.mutate({
id: form.id,
title,
description,
fields,
settings: form.settings as Record<string, any>,
});
};
return (
<div className="container mx-auto max-w-4xl space-y-6 py-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" asChild>
<Link href={`/studies/${resolvedParams?.id}/forms`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Link>
</Button>
<div>
<div className="flex items-center gap-2">
<TypeIcon className="h-5 w-5 text-muted-foreground" />
<h1 className="text-2xl font-bold">{form.title}</h1>
{form.active && (
<Badge variant="default" className="text-xs">Active</Badge>
)}
</div>
<p className="text-muted-foreground text-sm capitalize">
{form.type} Version {form.version}
</p>
</div>
</div>
{canManage && (
<div className="flex gap-2">
{isEditing ? (
<>
<Button variant="outline" onClick={() => setIsEditing(false)}>
Cancel
</Button>
<Button onClick={handleSave} disabled={updateForm.isPending}>
<Save className="mr-2 h-4 w-4" />
Save Changes
</Button>
</>
) : (
<Button onClick={() => setIsEditing(true)}>
<Edit2 className="mr-2 h-4 w-4" />
Edit Form
</Button>
)}
</div>
)}
</div>
<Tabs defaultValue="fields" className="space-y-4">
<TabsList>
<TabsTrigger value="fields">Fields</TabsTrigger>
<TabsTrigger value="preview">Preview</TabsTrigger>
<TabsTrigger value="responses">
Responses ({responses.length})
</TabsTrigger>
</TabsList>
<TabsContent value="fields">
{isEditing ? (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Form Fields</CardTitle>
<Select onValueChange={addField}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Add field..." />
</SelectTrigger>
<SelectContent>
{fieldTypes.map((type) => (
<SelectItem key={type.value} value={type.value}>
<span className="mr-2">{type.icon}</span>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
</CardHeader>
<CardContent>
{fields.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center text-muted-foreground">
<FileText className="mb-2 h-8 w-8" />
<p>No fields added yet</p>
</div>
) : (
<div className="space-y-4">
{fields.map((field) => (
<div
key={field.id}
className="flex items-start gap-3 rounded-lg border p-4"
>
<div className="flex cursor-grab items-center text-muted-foreground">
<GripVertical className="h-5 w-5" />
</div>
<div className="flex-1 space-y-3">
<div className="flex items-center gap-3">
<Badge variant="outline" className="text-xs">
{fieldTypes.find(f => f.value === field.type)?.icon}{" "}
{fieldTypes.find(f => f.value === field.type)?.label}
</Badge>
<Input
value={field.label}
onChange={(e) => updateField(field.id, { label: e.target.value })}
placeholder="Field label"
className="flex-1"
/>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={field.required}
onChange={(e) => updateField(field.id, { required: e.target.checked })}
className="rounded border-gray-300"
/>
Required
</label>
</div>
{field.type === "multiple_choice" && (
<div className="space-y-2">
<Label className="text-xs">Options</Label>
{field.options?.map((opt, i) => (
<div key={i} className="flex items-center gap-2">
<Input
value={opt}
onChange={(e) => {
const newOptions = [...(field.options || [])];
newOptions[i] = e.target.value;
updateField(field.id, { options: newOptions });
}}
placeholder={`Option ${i + 1}`}
className="flex-1"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => {
const newOptions = field.options?.filter((_, idx) => idx !== i);
updateField(field.id, { options: newOptions });
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
const newOptions = [...(field.options || []), `Option ${(field.options?.length || 0) + 1}`];
updateField(field.id, { options: newOptions });
}}
>
<Plus className="mr-1 h-4 w-4" />
Add Option
</Button>
</div>
)}
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeField(field.id)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
) : (
<Card>
<CardHeader>
<CardTitle>Form Fields</CardTitle>
</CardHeader>
<CardContent>
{fields.length === 0 ? (
<p className="text-muted-foreground">No fields defined</p>
) : (
<div className="space-y-3">
{fields.map((field, index) => (
<div key={field.id} className="flex items-center gap-3 rounded-lg border p-3">
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-muted text-xs">
{index + 1}
</span>
<div className="flex-1">
<p className="font-medium">{field.label}</p>
<p className="text-muted-foreground text-xs">
{fieldTypes.find(f => f.value === field.type)?.label}
{field.required && " • Required"}
{field.type === "multiple_choice" && `${field.options?.length} options`}
</p>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
)}
</TabsContent>
<TabsContent value="preview">
<Card>
<CardHeader>
<CardTitle>Form Preview</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<h2 className="text-xl font-semibold">{title}</h2>
{description && <p className="text-muted-foreground">{description}</p>}
</div>
{fields.length === 0 ? (
<p className="text-muted-foreground">No fields to preview</p>
) : (
<div className="space-y-4">
{fields.map((field, index) => (
<div key={field.id} className="space-y-2">
<Label>
{index + 1}. {field.label}
{field.required && <span className="text-destructive"> *</span>}
</Label>
{field.type === "text" && (
<Input placeholder="Enter your response..." disabled />
)}
{field.type === "textarea" && (
<Textarea placeholder="Enter your response..." disabled />
)}
{field.type === "multiple_choice" && (
<div className="space-y-2">
{field.options?.map((opt, i) => (
<label key={i} className="flex items-center gap-2">
<input type="radio" disabled /> {opt}
</label>
))}
</div>
)}
{field.type === "checkbox" && (
<label className="flex items-center gap-2">
<input type="checkbox" disabled /> Yes
</label>
)}
{field.type === "yes_no" && (
<div className="flex gap-4">
<label className="flex items-center gap-2">
<input type="radio" disabled /> Yes
</label>
<label className="flex items-center gap-2">
<input type="radio" disabled /> No
</label>
</div>
)}
{field.type === "rating" && (
<div className="flex gap-2">
{Array.from({ length: field.settings?.scale || 5 }, (_, i) => (
<button key={i} type="button" className="h-8 w-8 rounded border disabled" disabled>
{i + 1}
</button>
))}
</div>
)}
{field.type === "date" && (
<Input type="date" disabled />
)}
{field.type === "signature" && (
<div className="h-24 rounded border bg-muted/50 flex items-center justify-center text-muted-foreground">
Signature pad (disabled in preview)
</div>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="responses">
<Card>
<CardHeader>
<CardTitle>Form Responses</CardTitle>
</CardHeader>
<CardContent>
{responses.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center text-muted-foreground">
<Users className="mb-2 h-8 w-8" />
<p>No responses yet</p>
</div>
) : (
<div className="space-y-4">
{responses.map((response) => (
<div key={response.id} className="rounded-lg border p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">
{response.participant?.name || response.participant?.participantCode || "Unknown"}
</span>
</div>
<Badge className={`text-xs ${statusColors[response.status as keyof typeof statusColors]}`}>
{response.status}
</Badge>
</div>
<div className="space-y-2 text-sm">
{Object.entries(response.responses as Record<string, any>).map(([key, value]) => (
<div key={key} className="flex gap-2">
<span className="text-muted-foreground">{key}:</span>
<span>{String(value)}</span>
</div>
))}
</div>
{response.signedAt && (
<div className="mt-2 pt-2 border-t text-xs text-muted-foreground">
Signed: {new Date(response.signedAt).toLocaleString()}
</div>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,354 @@
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useSession } from "~/lib/auth-client";
import { notFound } from "next/navigation";
import Link from "next/link";
import {
FileText,
ArrowLeft,
Plus,
Trash2,
GripVertical,
FileSignature,
ClipboardList,
FileQuestion,
Save,
} from "lucide-react";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Textarea } from "~/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { Badge } from "~/components/ui/badge";
import { api } from "~/trpc/react";
import { toast } from "sonner";
interface Field {
id: string;
type: string;
label: string;
required: boolean;
options?: string[];
settings?: Record<string, any>;
}
const fieldTypes = [
{ value: "text", label: "Text (short)", icon: "📝" },
{ value: "textarea", label: "Text (long)", icon: "📄" },
{ value: "multiple_choice", label: "Multiple Choice", icon: "☑️" },
{ value: "checkbox", label: "Checkbox", icon: "✅" },
{ value: "rating", label: "Rating Scale", icon: "⭐" },
{ value: "yes_no", label: "Yes/No", icon: "✔️" },
{ value: "date", label: "Date", icon: "📅" },
{ value: "signature", label: "Signature", icon: "✍️" },
];
const formTypes = [
{ value: "consent", label: "Consent Form", icon: FileSignature, description: "Legal/IRB consent documents" },
{ value: "survey", label: "Survey", icon: ClipboardList, description: "Multi-question questionnaires" },
{ value: "questionnaire", label: "Questionnaire", icon: FileQuestion, description: "Custom data collection forms" },
];
export default function NewFormPage() {
const params = useParams();
const router = useRouter();
const { data: session } = useSession();
const utils = api.useUtils();
const studyId = typeof params.id === "string" ? params.id : "";
const [formType, setFormType] = useState<string>("");
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [fields, setFields] = useState<Field[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const { data: study } = api.studies.get.useQuery(
{ id: studyId },
{ enabled: !!studyId },
);
const createForm = api.forms.create.useMutation({
onSuccess: (data) => {
toast.success("Form created successfully!");
router.push(`/studies/${studyId}/forms/${data.id}`);
},
onError: (error) => {
toast.error("Failed to create form", { description: error.message });
setIsSubmitting(false);
},
});
useBreadcrumbsEffect([
{ label: "Dashboard", href: "/dashboard" },
{ label: "Studies", href: "/studies" },
{ label: study?.name ?? "Study", href: `/studies/${studyId}` },
{ label: "Forms", href: `/studies/${studyId}/forms` },
{ label: "Create Form" },
]);
if (!session?.user) {
return notFound();
}
const addField = (type: string) => {
const newField: Field = {
id: crypto.randomUUID(),
type,
label: `New ${fieldTypes.find(f => f.value === type)?.label || "Field"}`,
required: false,
options: type === "multiple_choice" ? ["Option 1", "Option 2"] : undefined,
};
setFields([...fields, newField]);
};
const removeField = (id: string) => {
setFields(fields.filter(f => f.id !== id));
};
const updateField = (id: string, updates: Partial<Field>) => {
setFields(fields.map(f => f.id === id ? { ...f, ...updates } : f));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!formType || !title) {
toast.error("Please select a form type and enter a title");
return;
}
setIsSubmitting(true);
createForm.mutate({
studyId,
type: formType as "consent" | "survey" | "questionnaire",
title,
description,
fields,
settings: {},
});
};
return (
<div className="container mx-auto max-w-4xl space-y-6 py-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" asChild>
<Link href={`/studies/${studyId}/forms`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Link>
</Button>
</div>
<div>
<h1 className="text-2xl font-bold">Create New Form</h1>
<p className="text-muted-foreground">Design a consent form, survey, or questionnaire</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Form Details</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Form Type</Label>
<div className="grid gap-3 sm:grid-cols-3">
{formTypes.map((type) => (
<button
key={type.value}
type="button"
onClick={() => setFormType(type.value)}
className={`flex flex-col items-start rounded-lg border p-4 text-left transition-all hover:bg-muted/50 ${
formType === type.value
? "border-primary bg-primary/5 ring-1 ring-primary"
: "border-border"
}`}
>
<type.icon className={`mb-2 h-5 w-5 ${formType === type.value ? "text-primary" : "text-muted-foreground"}`} />
<span className="font-medium">{type.label}</span>
<span className="text-muted-foreground text-xs">{type.description}</span>
</button>
))}
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="title">Title</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter form title"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description (optional)</Label>
<Input
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Brief description"
/>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Form Fields</CardTitle>
<Select onValueChange={addField}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Add field..." />
</SelectTrigger>
<SelectContent>
{fieldTypes.map((type) => (
<SelectItem key={type.value} value={type.value}>
<span className="mr-2">{type.icon}</span>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
</CardHeader>
<CardContent>
{fields.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center text-muted-foreground">
<FileText className="mb-2 h-8 w-8" />
<p>No fields added yet</p>
<p className="text-sm">Use the dropdown above to add fields</p>
</div>
) : (
<div className="space-y-4">
{fields.map((field, index) => (
<div
key={field.id}
className="flex items-start gap-3 rounded-lg border p-4"
>
<div className="flex cursor-grab items-center text-muted-foreground">
<GripVertical className="h-5 w-5" />
</div>
<div className="flex-1 space-y-3">
<div className="flex items-center gap-3">
<Badge variant="outline" className="text-xs">
{fieldTypes.find(f => f.value === field.type)?.icon}{" "}
{fieldTypes.find(f => f.value === field.type)?.label}
</Badge>
<Input
value={field.label}
onChange={(e) => updateField(field.id, { label: e.target.value })}
placeholder="Field label"
className="flex-1"
/>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={field.required}
onChange={(e) => updateField(field.id, { required: e.target.checked })}
className="rounded border-gray-300"
/>
Required
</label>
</div>
{field.type === "multiple_choice" && (
<div className="space-y-2">
<Label className="text-xs">Options</Label>
{field.options?.map((opt, i) => (
<div key={i} className="flex items-center gap-2">
<Input
value={opt}
onChange={(e) => {
const newOptions = [...(field.options || [])];
newOptions[i] = e.target.value;
updateField(field.id, { options: newOptions });
}}
placeholder={`Option ${i + 1}`}
className="flex-1"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => {
const newOptions = field.options?.filter((_, idx) => idx !== i);
updateField(field.id, { options: newOptions });
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
const newOptions = [...(field.options || []), `Option ${(field.options?.length || 0) + 1}`];
updateField(field.id, { options: newOptions });
}}
>
<Plus className="mr-1 h-4 w-4" />
Add Option
</Button>
</div>
)}
{field.type === "rating" && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>Scale:</span>
<Select
value={field.settings?.scale?.toString() || "5"}
onValueChange={(val) => updateField(field.id, { settings: { scale: parseInt(val) } })}
>
<SelectTrigger className="w-[100px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">1-5</SelectItem>
<SelectItem value="7">1-7</SelectItem>
<SelectItem value="10">1-10</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeField(field.id)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
<div className="flex justify-end gap-3">
<Button variant="outline" asChild>
<Link href={`/studies/${studyId}/forms`}>Cancel</Link>
</Button>
<Button type="submit" disabled={isSubmitting || !formType || !title}>
<Save className="mr-2 h-4 w-4" />
{isSubmitting ? "Creating..." : "Create Form"}
</Button>
</div>
</form>
</div>
);
}

View File

@@ -1,131 +1,74 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useSession } from "~/lib/auth-client"; import { useSession } from "~/lib/auth-client";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import Link from "next/link";
import { import {
FileText, FileText,
Loader2,
Plus, Plus,
Download, Search,
Edit2, ClipboardList,
FileQuestion,
FileSignature,
MoreHorizontal,
Pencil,
Trash2,
Eye, Eye,
Save, Copy,
CheckCircle,
XCircle,
Clock,
} from "lucide-react"; } from "lucide-react";
import { import {
EntityView, EntityView,
EntityViewHeader,
EntityViewSection, EntityViewSection,
EmptyState, EmptyState,
} from "~/components/ui/entity-view"; } from "~/components/ui/entity-view";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider"; import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { toast } from "sonner"; import { toast } from "sonner";
import { PageHeader } from "~/components/ui/page-header"; import { PageHeader } from "~/components/ui/page-header";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { Markdown } from "tiptap-markdown";
import { Table } from "@tiptap/extension-table";
import { TableRow } from "@tiptap/extension-table-row";
import { TableCell } from "@tiptap/extension-table-cell";
import { TableHeader } from "@tiptap/extension-table-header";
import {
Bold,
Italic,
List,
ListOrdered,
Heading1,
Heading2,
Quote,
Table as TableIcon,
} from "lucide-react";
import { downloadPdfFromHtml } from "~/lib/pdf-generator";
const Toolbar = ({ editor }: { editor: any }) => { const formTypeIcons = {
if (!editor) { consent: FileSignature,
return null; survey: ClipboardList,
} questionnaire: FileQuestion,
};
return ( const formTypeColors = {
<div className="border-input flex flex-wrap items-center gap-1 rounded-tl-md rounded-tr-md border bg-transparent p-1"> consent: "bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400",
<Button survey: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
variant="ghost" questionnaire: "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400",
size="sm" };
onClick={() => editor.chain().focus().toggleBold().run()}
disabled={!editor.can().chain().focus().toggleBold().run()} const statusConfig = {
className={editor.isActive("bold") ? "bg-muted" : ""} active: {
> label: "Active",
<Bold className="h-4 w-4" /> variant: "default" as const,
</Button> icon: CheckCircle,
<Button },
variant="ghost" draft: {
size="sm" label: "Draft",
onClick={() => editor.chain().focus().toggleItalic().run()} variant: "secondary" as const,
disabled={!editor.can().chain().focus().toggleItalic().run()} icon: Clock,
className={editor.isActive("italic") ? "bg-muted" : ""} },
> deprecated: {
<Italic className="h-4 w-4" /> label: "Deprecated",
</Button> variant: "destructive" as const,
<div className="bg-border mx-1 h-6 w-[1px]" /> icon: XCircle,
<Button },
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
className={editor.isActive("heading", { level: 1 }) ? "bg-muted" : ""}
>
<Heading1 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
className={editor.isActive("heading", { level: 2 }) ? "bg-muted" : ""}
>
<Heading2 className="h-4 w-4" />
</Button>
<div className="bg-border mx-1 h-6 w-[1px]" />
<Button
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleBulletList().run()}
className={editor.isActive("bulletList") ? "bg-muted" : ""}
>
<List className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleOrderedList().run()}
className={editor.isActive("orderedList") ? "bg-muted" : ""}
>
<ListOrdered className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleBlockquote().run()}
className={editor.isActive("blockquote") ? "bg-muted" : ""}
>
<Quote className="h-4 w-4" />
</Button>
<div className="bg-border mx-1 h-6 w-[1px]" />
<Button
variant="ghost"
size="sm"
onClick={() =>
editor
.chain()
.focus()
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
.run()
}
>
<TableIcon className="h-4 w-4" />
</Button>
</div>
);
}; };
interface StudyFormsPageProps { interface StudyFormsPageProps {
@@ -136,11 +79,10 @@ interface StudyFormsPageProps {
export default function StudyFormsPage({ params }: StudyFormsPageProps) { export default function StudyFormsPage({ params }: StudyFormsPageProps) {
const { data: session } = useSession(); const { data: session } = useSession();
const router = useRouter();
const utils = api.useUtils(); const utils = api.useUtils();
const [resolvedParams, setResolvedParams] = useState<{ id: string } | null>( const [resolvedParams, setResolvedParams] = useState<{ id: string } | null>(null);
null, const [search, setSearch] = useState("");
);
const [editorTarget, setEditorTarget] = useState<string>("");
useEffect(() => { useEffect(() => {
const resolveParams = async () => { const resolveParams = async () => {
@@ -155,91 +97,33 @@ export default function StudyFormsPage({ params }: StudyFormsPageProps) {
{ enabled: !!resolvedParams?.id }, { enabled: !!resolvedParams?.id },
); );
const { data: activeConsentForm, refetch: refetchConsentForm } = const { data: formsData, isLoading } = api.forms.list.useQuery(
api.studies.getActiveConsentForm.useQuery( { studyId: resolvedParams?.id ?? "", search: search || undefined },
{ studyId: resolvedParams?.id ?? "" }, { enabled: !!resolvedParams?.id },
{ enabled: !!resolvedParams?.id }, );
);
// Only sync once when form loads to avoid resetting user edits const userRole = (study as any)?.userRole;
useEffect(() => { const canManage = userRole === "owner" || userRole === "researcher";
if (activeConsentForm && !editorTarget) {
setEditorTarget(activeConsentForm.content);
}
}, [activeConsentForm, editorTarget]);
const editor = useEditor({ const deleteMutation = api.forms.delete.useMutation({
extensions: [
StarterKit,
Table.configure({
resizable: true,
}),
TableRow,
TableHeader,
TableCell,
Markdown.configure({
transformPastedText: true,
}),
],
content: editorTarget || "",
immediatelyRender: false,
onUpdate: ({ editor }) => {
// @ts-ignore
setEditorTarget(editor.storage.markdown.getMarkdown());
},
});
// Sync Tiptap when editorTarget is set (e.g., from DB) but make sure not to overwrite active edits
useEffect(() => {
if (editor && editorTarget && editor.isEmpty) {
editor.commands.setContent(editorTarget);
}
}, [editorTarget, editor]);
const generateConsentMutation = api.studies.generateConsentForm.useMutation({
onSuccess: (data) => {
toast.success("Default Consent Form Generated!");
setEditorTarget(data.content);
editor?.commands.setContent(data.content);
void refetchConsentForm();
void utils.studies.getActivity.invalidate({
studyId: resolvedParams?.id ?? "",
});
},
onError: (error) => {
toast.error("Error generating consent form", {
description: error.message,
});
},
});
const updateConsentMutation = api.studies.updateConsentForm.useMutation({
onSuccess: () => { onSuccess: () => {
toast.success("Consent Form Saved Successfully!"); toast.success("Form deleted successfully");
void refetchConsentForm(); void utils.forms.list.invalidate({ studyId: resolvedParams?.id });
void utils.studies.getActivity.invalidate({
studyId: resolvedParams?.id ?? "",
});
}, },
onError: (error) => { onError: (error) => {
toast.error("Error saving consent form", { description: error.message }); toast.error("Failed to delete form", { description: error.message });
}, },
}); });
const handleDownloadConsent = async () => { const setActiveMutation = api.forms.setActive.useMutation({
if (!activeConsentForm || !study || !editor) return; onSuccess: () => {
toast.success("Form set as active");
try { void utils.forms.list.invalidate({ studyId: resolvedParams?.id });
toast.loading("Generating Document...", { id: "pdf-gen" }); },
await downloadPdfFromHtml(editor.getHTML(), { onError: (error) => {
filename: `Consent_Form_${study.name.replace(/\s+/g, "_")}_v${activeConsentForm.version}.pdf`, toast.error("Failed to set active", { description: error.message });
}); },
toast.success("Document Downloaded Successfully!", { id: "pdf-gen" }); });
} catch (error) {
toast.error("Error generating PDF", { id: "pdf-gen" });
console.error(error);
}
};
useBreadcrumbsEffect([ useBreadcrumbsEffect([
{ label: "Dashboard", href: "/dashboard" }, { label: "Dashboard", href: "/dashboard" },
@@ -254,121 +138,145 @@ export default function StudyFormsPage({ params }: StudyFormsPageProps) {
if (!study) return <div>Loading...</div>; if (!study) return <div>Loading...</div>;
const userRole = (study as any)?.userRole; const forms = formsData?.forms ?? [];
const canManage = userRole === "owner" || userRole === "researcher";
return ( return (
<EntityView> <EntityView>
<PageHeader <PageHeader
title="Study Forms" title="Forms"
description="Manage consent forms and future questionnaires for this study" description="Manage consent forms, surveys, and questionnaires for this study"
icon={FileText} icon={FileText}
actions={
canManage && (
<Button asChild>
<Link href={`/studies/${resolvedParams?.id}/forms/new`}>
<Plus className="mr-2 h-4 w-4" />
Create Form
</Link>
</Button>
)
}
/> />
<div className="grid grid-cols-1 gap-8"> {forms.length === 0 && !isLoading ? (
<EntityViewSection <EmptyState
title="Consent Document"
icon="FileText" icon="FileText"
description="Design and manage the consent form that participants must sign before participating in your trials." title="No Forms Yet"
actions={ description="Create consent forms, surveys, or questionnaires to collect data from participants"
action={
canManage ? ( canManage ? (
<div className="flex gap-2"> <Button asChild>
<Button <Link href={`/studies/${resolvedParams?.id}/forms/new`}>
variant="outline" <Plus className="mr-2 h-4 w-4" />
size="sm" Create Your First Form
onClick={() => </Link>
generateConsentMutation.mutate({ studyId: study.id }) </Button>
}
disabled={
generateConsentMutation.isPending ||
updateConsentMutation.isPending
}
>
{generateConsentMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Plus className="mr-2 h-4 w-4" />
)}
Generate Default Template
</Button>
{activeConsentForm && (
<Button
size="sm"
onClick={() =>
updateConsentMutation.mutate({
studyId: study.id,
content: editorTarget,
})
}
disabled={
updateConsentMutation.isPending ||
editorTarget === activeConsentForm.content
}
>
{updateConsentMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save Changes
</Button>
)}
</div>
) : null ) : null
} }
> />
{activeConsentForm ? ( ) : (
<div className="space-y-4"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center gap-4">
<div className="space-y-1"> <div className="relative flex-1 max-w-sm">
<p className="text-sm leading-none font-medium"> <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
{activeConsentForm.title} <Input
</p> placeholder="Search forms..."
<p className="text-muted-foreground text-sm"> value={search}
v{activeConsentForm.version} Status: Active onChange={(e) => setSearch(e.target.value)}
</p> className="pl-10"
</div> />
<div className="flex items-center gap-3">
<Button
size="sm"
variant="ghost"
onClick={handleDownloadConsent}
>
<Download className="mr-2 h-4 w-4" />
Download PDF
</Button>
<Badge
variant="outline"
className="bg-green-50 text-green-700 hover:bg-green-50"
>
Active
</Badge>
</div>
</div>
<div className="bg-muted/30 border-border flex w-full justify-center overflow-hidden rounded-md border p-8">
<div className="dark:bg-card ring-border flex w-full max-w-4xl flex-col rounded-sm bg-white shadow-xl ring-1">
<div className="border-border bg-muted/50 dark:bg-muted/10 border-b">
<Toolbar editor={editor} />
</div>
<div className="editor-container dark:bg-card min-h-[850px] bg-white px-16 py-20 text-sm">
<EditorContent
editor={editor}
className="prose prose-sm dark:prose-invert h-full max-w-none outline-none focus:outline-none focus-visible:outline-none"
/>
</div>
</div>
</div>
</div> </div>
) : ( </div>
<EmptyState
icon="FileText" <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
title="No Consent Form" {forms.map((form) => {
description="Generate a boilerplate consent form for this study to download and collect signatures." const TypeIcon = formTypeIcons[form.type as keyof typeof formTypeIcons] || FileText;
/> const typeColor = formTypeColors[form.type as keyof typeof formTypeColors] || "bg-gray-100";
)} const isActive = form.active;
</EntityViewSection>
</div> return (
<Card key={form.id} className="overflow-hidden">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<div className={`rounded-md p-2 ${typeColor}`}>
<TypeIcon className="h-4 w-4" />
</div>
<div>
<CardTitle className="text-base">{form.title}</CardTitle>
<p className="text-muted-foreground text-xs capitalize">
{form.type}
</p>
</div>
</div>
{isActive && (
<Badge variant="default" className="text-xs">
Active
</Badge>
)}
</div>
</CardHeader>
<CardContent className="pb-3">
{form.description && (
<p className="text-muted-foreground text-sm line-clamp-2 mb-3">
{form.description}
</p>
)}
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>v{form.version}</span>
<span>{(form as any)._count?.responses ?? 0} responses</span>
</div>
</CardContent>
<div className="flex items-center justify-between border-t bg-muted/30 px-4 py-2">
<Button asChild variant="ghost" size="sm">
<Link href={`/studies/${resolvedParams?.id}/forms/${form.id}`}>
<Eye className="mr-1 h-3 w-3" />
View
</Link>
</Button>
{canManage && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/studies/${resolvedParams?.id}/forms/${form.id}/edit`}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</Link>
</DropdownMenuItem>
{!isActive && (
<DropdownMenuItem
onClick={() => setActiveMutation.mutate({ id: form.id })}
>
<CheckCircle className="mr-2 h-4 w-4" />
Set Active
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => {
if (confirm("Are you sure you want to delete this form?")) {
deleteMutation.mutate({ id: form.id });
}
}}
className="text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</Card>
);
})}
</div>
</div>
)}
</EntityView> </EntityView>
); );
} }

View File

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

View File

@@ -68,6 +68,29 @@ export const stepTypeEnum = pgEnum("step_type", [
"conditional", "conditional",
]); ]);
export const formTypeEnum = pgEnum("form_type", [
"consent",
"survey",
"questionnaire",
]);
export const formFieldTypeEnum = pgEnum("form_field_type", [
"text",
"textarea",
"multiple_choice",
"checkbox",
"rating",
"yes_no",
"date",
"signature",
]);
export const formResponseStatusEnum = pgEnum("form_response_status", [
"pending",
"completed",
"rejected",
]);
export const communicationProtocolEnum = pgEnum("communication_protocol", [ export const communicationProtocolEnum = pgEnum("communication_protocol", [
"rest", "rest",
"ros2", "ros2",
@@ -594,6 +617,64 @@ export const consentForms = createTable(
}), }),
); );
// New unified forms table
export const forms = createTable(
"form",
{
id: uuid("id").notNull().primaryKey().defaultRandom(),
studyId: uuid("study_id")
.notNull()
.references(() => studies.id, { onDelete: "cascade" }),
type: formTypeEnum("type").notNull(),
title: varchar("title", { length: 255 }).notNull(),
description: text("description"),
version: integer("version").default(1).notNull(),
active: boolean("active").default(true).notNull(),
fields: jsonb("fields").notNull().default([]),
settings: jsonb("settings").default({}),
createdBy: text("created_by")
.notNull()
.references(() => users.id),
createdAt: timestamp("created_at", { withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
},
(table) => ({
studyVersionUnique: unique().on(table.studyId, table.version),
}),
);
// Form responses/submissions
export const formResponses = createTable(
"form_response",
{
id: uuid("id").notNull().primaryKey().defaultRandom(),
formId: uuid("form_id")
.notNull()
.references(() => forms.id, { onDelete: "cascade" }),
participantId: uuid("participant_id")
.notNull()
.references(() => participants.id, { onDelete: "cascade" }),
responses: jsonb("responses").notNull().default({}),
status: formResponseStatusEnum("status").default("pending"),
signatureData: text("signature_data"),
signedAt: timestamp("signed_at", { withTimezone: true }),
ipAddress: inet("ip_address"),
submittedAt: timestamp("submitted_at", { withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
},
(table) => ({
formParticipantUnique: unique().on(table.formId, table.participantId),
}),
);
export const participantConsents = createTable( export const participantConsents = createTable(
"participant_consent", "participant_consent",
{ {
@@ -1118,6 +1199,29 @@ export const participantConsentsRelations = relations(
}), }),
); );
export const formsRelations = relations(forms, ({ one, many }) => ({
study: one(studies, {
fields: [forms.studyId],
references: [studies.id],
}),
createdBy: one(users, {
fields: [forms.createdBy],
references: [users.id],
}),
responses: many(formResponses),
}));
export const formResponsesRelations = relations(formResponses, ({ one }) => ({
form: one(forms, {
fields: [formResponses.formId],
references: [forms.id],
}),
participant: one(participants, {
fields: [formResponses.participantId],
references: [participants.id],
}),
}));
export const robotsRelations = relations(robots, ({ many }) => ({ export const robotsRelations = relations(robots, ({ many }) => ({
experiments: many(experiments), experiments: many(experiments),
plugins: many(plugins), plugins: many(plugins),