From 6584a48f279be9873d5b038e26636812d68cd050 Mon Sep 17 00:00:00 2001 From: Sean O'Connor Date: Thu, 26 Sep 2024 16:04:57 -0400 Subject: [PATCH] Form implementation, api routes --- .env | 4 +- Dockerfile | 16 ++- drizzle.config.ts | 2 +- next.config.js | 37 +++++- package.json | 2 + src/app/api/content/[...path]/route.ts | 26 ++++ src/app/api/forms/[id]/route.ts | 58 +++++++++ src/app/api/forms/route.ts | 104 ++++++++++++++++ src/app/api/informed-consent/route.ts | 67 ----------- src/app/forms/page.tsx | 15 +++ src/app/sign-in/[[...sign-in]]/page.tsx | 62 ++++++++-- src/app/sign-up/[[...sign-up]]/page.tsx | 2 +- src/components/forms/FormCard.tsx | 48 ++++++++ src/components/forms/FormsGrid.tsx | 74 ++++++++++++ src/components/forms/UploadFormButton.tsx | 113 ++++++++++++++++++ .../participant/ParticipantCard.tsx | 30 +++++ src/components/participant/Participants.tsx | 22 +--- .../participant/UploadConsentForm.tsx | 89 -------------- src/components/sidebar.tsx | 2 + src/components/ui/badge.tsx | 36 ++++++ src/lib/fileStorage.ts | 35 +++++- src/server/db/init.ts | 3 +- src/server/db/schema.ts | 30 +++-- 23 files changed, 663 insertions(+), 214 deletions(-) create mode 100644 src/app/api/content/[...path]/route.ts create mode 100644 src/app/api/forms/[id]/route.ts create mode 100644 src/app/api/forms/route.ts delete mode 100644 src/app/api/informed-consent/route.ts create mode 100644 src/app/forms/page.tsx create mode 100644 src/components/forms/FormCard.tsx create mode 100644 src/components/forms/FormsGrid.tsx create mode 100644 src/components/forms/UploadFormButton.tsx create mode 100644 src/components/participant/ParticipantCard.tsx delete mode 100644 src/components/participant/UploadConsentForm.tsx create mode 100644 src/components/ui/badge.tsx diff --git a/.env b/.env index 87b0984..1603226 100644 --- a/.env +++ b/.env @@ -5,8 +5,8 @@ DATABASE_URL="postgresql://postgres:jusxah-jufrew-niwjY5@db:5432/hristudio" # Clerk -NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_YWxsb3dlZC1zYWxtb24tNjMuY2xlcmsuYWNjb3VudHMuZGV2JA -CLERK_SECRET_KEY=sk_test_nUKl0GTM5ibgUH12WbTH6pNVHAyRshlSFi64IrEeWD +NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_cmVmaW5lZC1kcnVtLTIzLmNsZXJrLmFjY291bnRzLmRldiQ +CLERK_SECRET_KEY=sk_test_3qESERGxZqHpROHzFe7nYxjfqfVhpHWS1UVDQt86v8 NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up diff --git a/Dockerfile b/Dockerfile index 72366a4..23ac3d7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,8 @@ # Use the Node.js 18 Alpine Linux image as the base image -FROM node:22-alpine +FROM node:22-alpine + +# Install GraphicsMagick +RUN apk add --no-cache graphicsmagick ghostscript # Set the working directory inside the container to /app WORKDIR /app @@ -14,8 +17,15 @@ RUN pnpm install # Copy all the files from the local directory to the working directory in the container COPY . . -# Push database schema to database -RUN pnpm drizzle-kit push +# # Clear previous build artifacts +# RUN rm -rf .next + +# # Build the application +# RUN pnpm build + +# # Ensure correct permissions +# RUN chown -R node:node . +# USER node # Run the application in development mode CMD ["pnpm", "run", "dev"] \ No newline at end of file diff --git a/drizzle.config.ts b/drizzle.config.ts index b4164b7..33b6656 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -8,5 +8,5 @@ export default { dbCredentials: { url: env.DATABASE_URL, }, - tablesFilter: ["hristudio_*"], + // tablesFilter: ["hristudio_*"], } satisfies Config; diff --git a/next.config.js b/next.config.js index 9bfe4a0..1c81fee 100644 --- a/next.config.js +++ b/next.config.js @@ -4,7 +4,38 @@ */ await import("./src/env.js"); -/** @type {import("next").NextConfig} */ -const config = {}; +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, + experimental: { + serverActions: { + bodySizeLimit: '2mb', + }, + }, + webpack: (config) => { + config.externals.push({ + "utf-8-validate": "commonjs utf-8-validate", + bufferutil: "commonjs bufferutil", + }); + return config; + }, + // Add this section to disable linting during build + eslint: { + ignoreDuringBuilds: true, + }, + // Add this section to disable type checking during build + typescript: { + ignoreBuildErrors: true, + }, + // Add this section + async rewrites() { + return [ + { + source: '/content/:path*', + destination: '/api/content/:path*', + }, + ]; + }, +}; -export default config; +export default nextConfig; diff --git a/package.json b/package.json index ddfc4cb..976b641 100644 --- a/package.json +++ b/package.json @@ -34,11 +34,13 @@ "lucide-react": "^0.441.0", "next": "^14.2.12", "next-themes": "^0.3.0", + "pdf2pic": "^3.1.3", "postgres": "^3.4.4", "radix-ui": "^1.0.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-icons": "^5.3.0", + "spawn-sync": "^2.0.0", "tailwind-merge": "^2.5.2", "tailwindcss-animate": "^1.0.7", "zod": "^3.23.8" diff --git a/src/app/api/content/[...path]/route.ts b/src/app/api/content/[...path]/route.ts new file mode 100644 index 0000000..08bb858 --- /dev/null +++ b/src/app/api/content/[...path]/route.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from 'next/server'; +import path from 'path'; +import fs from 'fs/promises'; +import { auth } from "@clerk/nextjs/server"; + +export async function GET( + request: NextRequest, + { params }: { params: { path: string[] } } +) { + const { userId } = auth(); + if (!userId) { + return new NextResponse('Unauthorized', { status: 401 }); + } + + const filePath = path.join(process.cwd(), 'content', ...params.path); + + try { + const file = await fs.readFile(filePath); + const response = new NextResponse(file); + response.headers.set('Content-Type', 'application/pdf'); + return response; + } catch (error) { + console.error('Error reading file:', error); + return new NextResponse('File not found', { status: 404 }); + } +} \ No newline at end of file diff --git a/src/app/api/forms/[id]/route.ts b/src/app/api/forms/[id]/route.ts new file mode 100644 index 0000000..cefe0f4 --- /dev/null +++ b/src/app/api/forms/[id]/route.ts @@ -0,0 +1,58 @@ +import { NextResponse } from "next/server"; +import { db } from "~/server/db"; +import { informedConsentForms, contents } from "~/server/db/schema"; +import { auth } from "@clerk/nextjs/server"; +import { eq } from "drizzle-orm"; +import fs from 'fs/promises'; +import path from 'path'; + +export async function DELETE( + request: Request, + { params }: { params: { id: string } } +) { + const { userId } = auth(); + if (!userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const id = parseInt(params.id); + if (isNaN(id)) { + return NextResponse.json({ error: 'Invalid ID' }, { status: 400 }); + } + + try { + // First, get the content associated with this form + const [form] = await db + .select({ + contentId: informedConsentForms.contentId, + location: contents.location, + }) + .from(informedConsentForms) + .innerJoin(contents, eq(informedConsentForms.contentId, contents.id)) + .where(eq(informedConsentForms.id, id)); + + if (!form) { + return NextResponse.json({ error: 'Form not found' }, { status: 404 }); + } + + // Delete the file from the file system + const fullPath = path.join(process.cwd(), form.location); + try { + await fs.access(fullPath); + await fs.unlink(fullPath); + } catch (error) { + console.warn(`File not found or couldn't be deleted: ${fullPath}`); + } + + // Delete the form and content from the database + await db.transaction(async (tx) => { + await tx.delete(informedConsentForms).where(eq(informedConsentForms.id, id)); + await tx.delete(contents).where(eq(contents.id, form.contentId)); + }); + + return NextResponse.json({ message: "Form deleted successfully" }); + } catch (error) { + console.error('Error deleting form:', error); + return NextResponse.json({ error: 'Failed to delete form' }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/forms/route.ts b/src/app/api/forms/route.ts new file mode 100644 index 0000000..6a778ce --- /dev/null +++ b/src/app/api/forms/route.ts @@ -0,0 +1,104 @@ +import { NextResponse } from "next/server"; +import { db } from "~/server/db"; +import { contents, informedConsentForms, contentTypes } from "~/server/db/schema"; +import { auth } from "@clerk/nextjs/server"; +import { eq } from "drizzle-orm"; +import { saveFile } from "~/lib/fileStorage"; +import fs from 'fs/promises'; + +// Function to generate a random string +const generateRandomString = (length: number) => { + const characters = 'abcdefghijklmnopqrstuvwxyz0123456789'; + let result = ''; + for (let i = 0; i < length; i++) { + result += characters.charAt(Math.floor(Math.random() * characters.length)); + } + return result; +}; + +export async function GET(request: Request) { + const { userId } = auth(); + if (!userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const forms = await db.select({ + id: informedConsentForms.id, + title: contents.title, + location: contents.location, + previewLocation: contents.previewLocation, + studyId: informedConsentForms.studyId, + participantId: informedConsentForms.participantId, + contentId: informedConsentForms.contentId, + }).from(informedConsentForms) + .innerJoin(contents, eq(informedConsentForms.contentId, contents.id)); + + return NextResponse.json(forms); +} + +export async function POST(request: Request) { + const { userId } = auth(); + if (!userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const formData = await request.formData(); + const file = formData.get('file') as File; + const title = formData.get('title') as string; + const studyId = formData.get('studyId') as string; + const participantId = formData.get('participantId') as string; + + if (!file || !title || !studyId || !participantId) { + return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); + } + + try { + const [formContentType] = await db + .select() + .from(contentTypes) + .where(eq(contentTypes.name, "Informed Consent Form")); + + const [previewContentType] = await db + .select() + .from(contentTypes) + .where(eq(contentTypes.name, "Preview Image")); + + if (!formContentType || !previewContentType) { + return NextResponse.json({ error: 'Content type not found' }, { status: 500 }); + } + + // Generate a random filename with the same extension + const fileExtension = file.name.split('.').pop(); // Get the file extension + const randomFileName = `${generateRandomString(12)}.${fileExtension}`; // Generate random filename with 12 characters + const { pdfPath, previewPath } = await saveFile(file, `${formContentType.id}/${randomFileName}`, previewContentType.id); + + const [content] = await db + .insert(contents) + .values({ + contentTypeId: formContentType.id, + uploader: userId, + location: pdfPath, + previewLocation: previewPath, + title: title, + }) + .returning(); + + if (!content) { + throw new Error("Content not found"); + } + + const [form] = await db + .insert(informedConsentForms) + .values({ + studyId: parseInt(studyId), + participantId: parseInt(participantId), + contentId: content.id, + }) + .returning(); + + return NextResponse.json(form); + } catch (error) { + console.error('Error uploading form:', error); + return NextResponse.json({ error: 'Failed to upload form' }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/informed-consent/route.ts b/src/app/api/informed-consent/route.ts deleted file mode 100644 index a0ef527..0000000 --- a/src/app/api/informed-consent/route.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { NextResponse } from "next/server"; -import { db } from "~/server/db"; -import { contents, informedConsentForms, contentTypes } from "~/server/db/schema"; -import { auth } from "@clerk/nextjs/server"; -import { eq } from "drizzle-orm"; -import { saveFile } from '~/lib/fileStorage'; - -export async function POST(request: Request) { - const { userId } = auth(); - if (!userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const formData = await request.formData(); - const file = formData.get('file') as File; - const studyId = formData.get('studyId') as string; - const participantId = formData.get('participantId') as string; - - if (!file || !studyId || !participantId) { - return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); - } - - try { - const [contentType] = await db - .select() - .from(contentTypes) - .where(eq(contentTypes.name, "Informed Consent Form")); - - if (!contentType) { - return NextResponse.json({ error: 'Content type not found' }, { status: 500 }); - } - - const [content] = await db - .insert(contents) - .values({ - contentTypeId: contentType.id, - uploader: userId, - location: '', // We'll update this after saving the file - }) - .returning(); - - if (!content) { - throw new Error("Content not found"); - } - - const fileLocation = await saveFile(file, content.id); - - await db - .update(contents) - .set({ location: fileLocation }) - .where(eq(contents.id, content.id)); - - const [form] = await db - .insert(informedConsentForms) - .values({ - studyId: parseInt(studyId), - participantId: parseInt(participantId), - contentId: content.id, - }) - .returning(); - - return NextResponse.json(form); - } catch (error) { - console.error('Error uploading informed consent form:', error); - return NextResponse.json({ error: 'Failed to upload form' }, { status: 500 }); - } -} \ No newline at end of file diff --git a/src/app/forms/page.tsx b/src/app/forms/page.tsx new file mode 100644 index 0000000..fc28550 --- /dev/null +++ b/src/app/forms/page.tsx @@ -0,0 +1,15 @@ +import Layout from "~/components/layout"; +import { FormsGrid } from "~/components/forms/FormsGrid"; +import { UploadFormButton } from "~/components/forms/UploadFormButton"; + +export default function FormsPage() { + return ( + +
+

Forms

+ +
+ +
+ ); +} \ No newline at end of file diff --git a/src/app/sign-in/[[...sign-in]]/page.tsx b/src/app/sign-in/[[...sign-in]]/page.tsx index 6ee827b..4f6f89b 100644 --- a/src/app/sign-in/[[...sign-in]]/page.tsx +++ b/src/app/sign-in/[[...sign-in]]/page.tsx @@ -1,7 +1,7 @@ "use client" import { useState } from "react" -import { useSignIn } from "@clerk/nextjs" +import { useSignIn, useSignUp } from "@clerk/nextjs" import { useRouter } from "next/navigation" import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "~/components/ui/card" import { Input } from "~/components/ui/input" @@ -12,28 +12,66 @@ import { FcGoogle } from "react-icons/fc" import { FaApple } from "react-icons/fa" export default function SignInPage() { - const { isLoaded, signIn, setActive } = useSignIn() - const [emailAddress, setEmailAddress] = useState("") - const [password, setPassword] = useState("") - const router = useRouter() + const { isLoaded, signIn, setActive } = useSignIn(); + const { signUp } = useSignUp(); + const [emailAddress, setEmailAddress] = useState(""); + const [password, setPassword] = useState(""); + const router = useRouter(); const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - if (!isLoaded) return + e.preventDefault(); + if (!isLoaded) return; try { const result = await signIn.create({ identifier: emailAddress, password, - }) + }); if (result.status === "complete") { - await setActive({ session: result.createdSessionId }) - router.push("/dash") + await setActive({ session: result.createdSessionId }); + router.push("/dash"); } } catch (err) { const error = err as { errors?: { message: string }[] }; - console.error("Error:", error.errors?.[0]?.message ?? "Unknown error") + console.error("Error:", error.errors?.[0]?.message ?? "Unknown error"); + + // If the error indicates the user does not exist, trigger sign-up + if (error.errors?.[0]?.message.includes("not found")) { + if (!signUp) { + console.error("Sign-up functionality is not available."); + return; + } + + try { + const signUpResult = await signUp.create({ + emailAddress, + password, + }); + + if (signUpResult.status === "complete") { + await setActive({ session: signUpResult.createdSessionId }); + + // Create a user entry in the database + const response = await fetch('/api/users', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: emailAddress }), + }); + + if (!response.ok) { + const errorData = await response.json(); + console.error("Error creating user in database:", errorData.error); + return; // Optionally handle the error (e.g., show a message to the user) + } + + router.push("/dash"); + } + } catch (signUpErr) { + const signUpError = signUpErr as { errors?: { message: string }[] }; + console.error("Sign-up Error:", signUpError.errors?.[0]?.message ?? "Unknown error"); + } + } } } @@ -52,7 +90,7 @@ export default function SignInPage() {
- Sign In to HRIStudio + Sign in to HRIStudio Enter your email and password to sign in diff --git a/src/app/sign-up/[[...sign-up]]/page.tsx b/src/app/sign-up/[[...sign-up]]/page.tsx index 8b3b523..af36baa 100644 --- a/src/app/sign-up/[[...sign-up]]/page.tsx +++ b/src/app/sign-up/[[...sign-up]]/page.tsx @@ -66,7 +66,7 @@ export default function SignUpPage() {
- Sign Up for HRIStudio + Sign up for HRIStudio Create an account to get started diff --git a/src/components/forms/FormCard.tsx b/src/components/forms/FormCard.tsx new file mode 100644 index 0000000..1fd694c --- /dev/null +++ b/src/components/forms/FormCard.tsx @@ -0,0 +1,48 @@ +import { Card, CardContent, CardFooter } from "~/components/ui/card"; +import { Badge } from "~/components/ui/badge"; +import { Button } from "~/components/ui/button"; +import { Trash2 } from "lucide-react"; + +interface FormCardProps { + form: { + id: number; + title: string; + location: string; + studyId: number; + studyTitle: string; + participantId: number; + participantName: string; + previewLocation: string; // Added this property + }; + onDelete: (formId: number) => void; +} + +export function FormCard({ form, onDelete }: FormCardProps) { + return ( + + + {form.title} + + +

{form.title}

+
+ {form.studyTitle} + {form.participantName} +
+ +
+
+ ); +} \ No newline at end of file diff --git a/src/components/forms/FormsGrid.tsx b/src/components/forms/FormsGrid.tsx new file mode 100644 index 0000000..8b5134a --- /dev/null +++ b/src/components/forms/FormsGrid.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { FormCard } from "~/components/forms/FormCard"; +import { useToast } from "~/hooks/use-toast"; + +interface Form { + id: number; + title: string; + location: string; + studyId: number; + studyTitle: string; + participantId: number; + participantName: string; + previewLocation: string; +} + +export function FormsGrid() { + const [forms, setForms] = useState([]); + const { toast } = useToast(); + + useEffect(() => { + fetchForms(); + }, []); + + const fetchForms = async () => { + try { + const response = await fetch("/api/forms"); + if (!response.ok) { + throw new Error("Failed to fetch forms"); + } + const data = await response.json(); + setForms(data); + } catch (error) { + console.error("Error fetching forms:", error); + toast({ + title: "Error", + description: "Failed to load forms. Please try again.", + variant: "destructive", + }); + } + }; + + const handleDelete = async (formId: number) => { + try { + const response = await fetch(`/api/forms/${formId}`, { + method: "DELETE", + }); + if (!response.ok) { + throw new Error("Failed to delete form"); + } + setForms(forms.filter((form) => form.id !== formId)); + toast({ + title: "Success", + description: "Form deleted successfully", + }); + } catch (error) { + console.error("Error deleting form:", error); + toast({ + title: "Error", + description: "Failed to delete form. Please try again.", + variant: "destructive", + }); + } + }; + + return ( +
+ {forms.map((form) => ( + + ))} +
+ ); +} \ No newline at end of file diff --git a/src/components/forms/UploadFormButton.tsx b/src/components/forms/UploadFormButton.tsx new file mode 100644 index 0000000..93801c7 --- /dev/null +++ b/src/components/forms/UploadFormButton.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; +import { Label } from "~/components/ui/label"; +import { useToast } from "~/hooks/use-toast"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "~/components/ui/dialog"; +import { useStudyContext } from "~/context/StudyContext"; + +export function UploadFormButton() { + const [isOpen, setIsOpen] = useState(false); + const [file, setFile] = useState(null); + const [title, setTitle] = useState(""); + const [participantId, setParticipantId] = useState(""); + const { toast } = useToast(); + const { selectedStudy } = useStudyContext(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!file || !title || !participantId || !selectedStudy) { + toast({ + title: "Error", + description: "Please fill in all fields and select a file.", + variant: "destructive", + }); + return; + } + + const formData = new FormData(); + formData.append("file", file); + formData.append("title", title); + formData.append("studyId", selectedStudy.id.toString()); + formData.append("participantId", participantId); + + try { + const response = await fetch("/api/forms", { + method: "POST", + body: formData, + }); + + if (!response.ok) { + throw new Error("Failed to upload form"); + } + + toast({ + title: "Success", + description: "Form uploaded successfully", + }); + setIsOpen(false); + setFile(null); + setTitle(""); + setParticipantId(""); + } catch (error) { + console.error("Error uploading form:", error); + toast({ + title: "Error", + description: "Failed to upload form. Please try again.", + variant: "destructive", + }); + } + }; + + return ( + + + + + + + Upload New Form + +
+
+ + setTitle(e.target.value)} + required + /> +
+
+ + setParticipantId(e.target.value)} + required + /> +
+
+ + setFile(e.target.files?.[0] || null)} + required + /> +
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/participant/ParticipantCard.tsx b/src/components/participant/ParticipantCard.tsx new file mode 100644 index 0000000..9bff544 --- /dev/null +++ b/src/components/participant/ParticipantCard.tsx @@ -0,0 +1,30 @@ +import { Card, CardContent, CardFooter } from "~/components/ui/card"; +import { Button } from "~/components/ui/button"; +import { Trash2 } from "lucide-react"; +import { Participant } from "../../types/Participant"; + +interface ParticipantCardProps { + participant: Participant; + onDelete: (participantId: number) => void; +} + +export function ParticipantCard({ participant, onDelete }: ParticipantCardProps) { + return ( + + +

{participant.name}

+
+ + + +
+ ); +} \ No newline at end of file diff --git a/src/components/participant/Participants.tsx b/src/components/participant/Participants.tsx index 7a23cf2..4639b84 100644 --- a/src/components/participant/Participants.tsx +++ b/src/components/participant/Participants.tsx @@ -6,9 +6,8 @@ import { Button } from "~/components/ui/button"; import { useStudyContext } from '../../context/StudyContext'; import { Participant } from '../../types/Participant'; import { CreateParticipantDialog } from './CreateParticipantDialog'; -import { Trash2 } from 'lucide-react'; import { useToast } from '~/hooks/use-toast'; -import { UploadConsentForm } from './UploadConsentForm'; +import { ParticipantCard } from './ParticipantCard'; export function Participants() { const [participants, setParticipants] = useState([]); @@ -89,24 +88,11 @@ export function Participants() { {participants.length > 0 ? ( -
    +
    {participants.map(participant => ( -
  • -
    - {participant.name} - -
    - -
  • + ))} -
+
) : (

No participants added yet.

)} diff --git a/src/components/participant/UploadConsentForm.tsx b/src/components/participant/UploadConsentForm.tsx deleted file mode 100644 index 918ee3a..0000000 --- a/src/components/participant/UploadConsentForm.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { Button } from "~/components/ui/button"; -import { Input } from "~/components/ui/input"; -import { useToast } from "~/hooks/use-toast"; - -interface UploadConsentFormProps { - studyId: number; - participantId: number; -} - -export function UploadConsentForm({ studyId, participantId }: UploadConsentFormProps) { - const [file, setFile] = useState(null); - const [isUploading, setIsUploading] = useState(false); - const { toast } = useToast(); - - useEffect(() => { - toast({ - title: "Test Toast", - description: "This is a test toast message", - }); - }, []); - - const handleFileChange = (e: React.ChangeEvent) => { - if (e.target.files && e.target.files[0]) { - setFile(e.target.files[0]); - } - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (!file) { - toast({ - title: "Error", - description: "Please select a file to upload", - variant: "destructive", - }); - return; - } - - setIsUploading(true); - - const formData = new FormData(); - formData.append('file', file); - formData.append('studyId', studyId.toString()); - formData.append('participantId', participantId.toString()); - - try { - const response = await fetch('/api/informed-consent', { - method: 'POST', - body: formData, - }); - - if (!response.ok) { - throw new Error('Failed to upload form'); - } - - toast({ - title: "Success", - description: "Informed consent form uploaded successfully", - }); - setFile(null); - } catch (error) { - console.error('Error uploading form:', error); - toast({ - title: "Error", - description: "Failed to upload informed consent form", - variant: "destructive", - }); - } finally { - setIsUploading(false); - } - }; - - return ( -
-
- -
- -
- ); -} \ No newline at end of file diff --git a/src/components/sidebar.tsx b/src/components/sidebar.tsx index 3e12ad8..979b3a0 100644 --- a/src/components/sidebar.tsx +++ b/src/components/sidebar.tsx @@ -6,6 +6,7 @@ import { BeakerIcon, BotIcon, FolderIcon, + FileTextIcon, LayoutDashboard, Menu, Settings @@ -22,6 +23,7 @@ const navItems = [ { name: "Dashboard", href: "/dash", icon: LayoutDashboard }, { name: "Studies", href: "/studies", icon: FolderIcon }, { name: "Participants", href: "/participants", icon: BeakerIcon }, + { name: "Forms", href: "/forms", icon: FileTextIcon }, { name: "Data Analysis", href: "/analysis", icon: BarChartIcon }, { name: "Settings", href: "/settings", icon: Settings }, ]; diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..5e2b7ac --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "~/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/src/lib/fileStorage.ts b/src/lib/fileStorage.ts index d3edab7..62380b5 100644 --- a/src/lib/fileStorage.ts +++ b/src/lib/fileStorage.ts @@ -1,15 +1,38 @@ import fs from 'fs/promises'; import path from 'path'; +import { fromBuffer } from 'pdf2pic'; const CONTENT_DIR = path.join(process.cwd(), 'content'); -export async function saveFile(file: File, contentId: number): Promise { - const contentFolder = path.join(CONTENT_DIR, contentId.toString()); - await fs.mkdir(contentFolder, { recursive: true }); +export async function saveFile(file: File, filePath: string, previewContentTypeId: number): Promise<{ pdfPath: string; previewPath: string }> { + const fullPath = path.join(CONTENT_DIR, filePath); + const dir = path.dirname(fullPath); + await fs.mkdir(dir, { recursive: true }); const buffer = Buffer.from(await file.arrayBuffer()); - const filePath = path.join(contentFolder, file.name); - await fs.writeFile(filePath, buffer); + await fs.writeFile(fullPath, buffer); - return filePath; + // Generate preview image + const previewFileName = path.basename(filePath, '.pdf') + '_preview.png'; + const previewDir = path.join(CONTENT_DIR, previewContentTypeId.toString()); + await fs.mkdir(previewDir, { recursive: true }); + const previewPath = path.join(previewDir, previewFileName); + + const options = { + density: 100, + saveFilename: path.basename(previewPath, '.png'), + savePath: previewDir, + format: "png", + width: 600, + height: 800 + }; + + const convert = fromBuffer(buffer, options); + await convert(1); + + // Return relative paths that can be used in URLs + return { + pdfPath: `/content/${filePath}`, + previewPath: `/content/${previewContentTypeId}/${previewFileName}` + }; } \ No newline at end of file diff --git a/src/server/db/init.ts b/src/server/db/init.ts index 6f68695..c45a269 100644 --- a/src/server/db/init.ts +++ b/src/server/db/init.ts @@ -7,10 +7,9 @@ export async function initializeContentTypes() { if (existingTypes.length === 0) { await db.insert(contentTypes).values([ { name: "Informed Consent Form" }, + { name: "Preview Image" }, // New content type // Add other content types as needed ]); console.log("Content types initialized"); - } else { - console.log("Content types already exist"); } } diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index ef87b82..5c4969e 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -1,13 +1,12 @@ // Example model schema from the Drizzle docs // https://orm.drizzle.team/docs/sql-schema-declaration -import { pgTableCreator } from "drizzle-orm/pg-core"; +import { pgTable } from "drizzle-orm/pg-core"; import { serial, varchar, timestamp, - integer, - pgTable + integer } from "drizzle-orm/pg-core"; import { sql } from "drizzle-orm"; @@ -17,9 +16,7 @@ import { sql } from "drizzle-orm"; * * @see https://orm.drizzle.team/docs/goodies#multi-project-schema */ -export const createTable = pgTableCreator((name) => `hristudio_${name}`); - -export const studies = createTable( +export const studies = pgTable( "study", { id: serial("id").primaryKey(), @@ -35,7 +32,7 @@ export const studies = createTable( } ); -export const participants = createTable( +export const participants = pgTable( "participant", { id: serial("id").primaryKey(), @@ -47,7 +44,7 @@ export const participants = createTable( } ); -export const contentTypes = createTable( +export const contentTypes = pgTable( "content_type", { id: serial("id").primaryKey(), @@ -55,20 +52,22 @@ export const contentTypes = createTable( } ); -export const contents = createTable( +export const contents = pgTable( "content", { id: serial("id").primaryKey(), contentTypeId: integer("content_type_id").references(() => contentTypes.id).notNull(), uploader: varchar("uploader", { length: 256 }).notNull(), location: varchar("location", { length: 1000 }).notNull(), + previewLocation: varchar("preview_location", { length: 1000 }), + title: varchar("title", { length: 256 }).notNull(), createdAt: timestamp("created_at", { withTimezone: true }) .default(sql`CURRENT_TIMESTAMP`) .notNull(), } ); -export const informedConsentForms = createTable( +export const informedConsentForms = pgTable( "informed_consent_form", { id: serial("id").primaryKey(), @@ -79,4 +78,15 @@ export const informedConsentForms = createTable( .default(sql`CURRENT_TIMESTAMP`) .notNull(), } +); + +export const users = pgTable( + "user", + { + id: serial("id").primaryKey(), + email: varchar("email", { length: 256 }).notNull().unique(), + createdAt: timestamp("created_at", { withTimezone: true }) + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + } ); \ No newline at end of file