diff --git a/package.json b/package.json index 20668f0..22a166c 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-tabs": "^1.1.1", "@types/nodemailer": "^6.4.17", + "@vercel/analytics": "^1.4.1", "@vercel/postgres": "^0.10.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", @@ -32,7 +33,7 @@ "dotenv": "^16.4.5", "drizzle-orm": "^0.36.3", "lucide-react": "^0.454.0", - "next": "15.0.2", + "next": "15.0.3", "ngrok": "5.0.0-beta.2", "nodemailer": "^6.9.16", "react": "^18.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index edb5a08..2c33c20 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,7 +10,7 @@ importers: dependencies: '@clerk/nextjs': specifier: ^6.4.0 - version: 6.4.0(next@15.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 6.4.0(next@15.0.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-alert-dialog': specifier: ^1.1.2 version: 1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -35,6 +35,9 @@ importers: '@types/nodemailer': specifier: ^6.4.17 version: 6.4.17 + '@vercel/analytics': + specifier: ^1.4.1 + version: 1.4.1(next@15.0.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) '@vercel/postgres': specifier: ^0.10.0 version: 0.10.0 @@ -57,8 +60,8 @@ importers: specifier: ^0.454.0 version: 0.454.0(react@18.3.1) next: - specifier: 15.0.2 - version: 15.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: 15.0.3 + version: 15.0.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) ngrok: specifier: 5.0.0-beta.2 version: 5.0.0-beta.2 @@ -780,56 +783,56 @@ packages: '@neondatabase/serverless@0.9.5': resolution: {integrity: sha512-siFas6gItqv6wD/pZnvdu34wEqgG3nSE6zWZdq5j2DEsa+VvX8i/5HXJOo06qrw5axPXn+lGCxeR+NLaSPIXug==} - '@next/env@15.0.2': - resolution: {integrity: sha512-c0Zr0ModK5OX7D4ZV8Jt/wqoXtitLNPwUfG9zElCZztdaZyNVnN40rDXVZ/+FGuR4CcNV5AEfM6N8f+Ener7Dg==} + '@next/env@15.0.3': + resolution: {integrity: sha512-t9Xy32pjNOvVn2AS+Utt6VmyrshbpfUMhIjFO60gI58deSo/KgLOp31XZ4O+kY/Is8WAGYwA5gR7kOb1eORDBA==} '@next/eslint-plugin-next@15.0.2': resolution: {integrity: sha512-R9Jc7T6Ge0txjmqpPwqD8vx6onQjynO9JT73ArCYiYPvSrwYXepH/UY/WdKDY8JPWJl72sAE4iGMHPeQ5xdEWg==} - '@next/swc-darwin-arm64@15.0.2': - resolution: {integrity: sha512-GK+8w88z+AFlmt+ondytZo2xpwlfAR8U6CRwXancHImh6EdGfHMIrTSCcx5sOSBei00GyLVL0ioo1JLKTfprgg==} + '@next/swc-darwin-arm64@15.0.3': + resolution: {integrity: sha512-s3Q/NOorCsLYdCKvQlWU+a+GeAd3C8Rb3L1YnetsgwXzhc3UTWrtQpB/3eCjFOdGUj5QmXfRak12uocd1ZiiQw==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@15.0.2': - resolution: {integrity: sha512-KUpBVxIbjzFiUZhiLIpJiBoelqzQtVZbdNNsehhUn36e2YzKHphnK8eTUW1s/4aPy5kH/UTid8IuVbaOpedhpw==} + '@next/swc-darwin-x64@15.0.3': + resolution: {integrity: sha512-Zxl/TwyXVZPCFSf0u2BNj5sE0F2uR6iSKxWpq4Wlk/Sv9Ob6YCKByQTkV2y6BCic+fkabp9190hyrDdPA/dNrw==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@15.0.2': - resolution: {integrity: sha512-9J7TPEcHNAZvwxXRzOtiUvwtTD+fmuY0l7RErf8Yyc7kMpE47MIQakl+3jecmkhOoIyi/Rp+ddq7j4wG6JDskQ==} + '@next/swc-linux-arm64-gnu@15.0.3': + resolution: {integrity: sha512-T5+gg2EwpsY3OoaLxUIofmMb7ohAUlcNZW0fPQ6YAutaWJaxt1Z1h+8zdl4FRIOr5ABAAhXtBcpkZNwUcKI2fw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@15.0.2': - resolution: {integrity: sha512-BjH4ZSzJIoTTZRh6rG+a/Ry4SW0HlizcPorqNBixBWc3wtQtj4Sn9FnRZe22QqrPnzoaW0ctvSz4FaH4eGKMww==} + '@next/swc-linux-arm64-musl@15.0.3': + resolution: {integrity: sha512-WkAk6R60mwDjH4lG/JBpb2xHl2/0Vj0ZRu1TIzWuOYfQ9tt9NFsIinI1Epma77JVgy81F32X/AeD+B2cBu/YQA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-x64-gnu@15.0.2': - resolution: {integrity: sha512-i3U2TcHgo26sIhcwX/Rshz6avM6nizrZPvrDVDY1bXcLH1ndjbO8zuC7RoHp0NSK7wjJMPYzm7NYL1ksSKFreA==} + '@next/swc-linux-x64-gnu@15.0.3': + resolution: {integrity: sha512-gWL/Cta1aPVqIGgDb6nxkqy06DkwJ9gAnKORdHWX1QBbSZZB+biFYPFti8aKIQL7otCE1pjyPaXpFzGeG2OS2w==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@15.0.2': - resolution: {integrity: sha512-AMfZfSVOIR8fa+TXlAooByEF4OB00wqnms1sJ1v+iu8ivwvtPvnkwdzzFMpsK5jA2S9oNeeQ04egIWVb4QWmtQ==} + '@next/swc-linux-x64-musl@15.0.3': + resolution: {integrity: sha512-QQEMwFd8r7C0GxQS62Zcdy6GKx999I/rTO2ubdXEe+MlZk9ZiinsrjwoiBL5/57tfyjikgh6GOU2WRQVUej3UA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-win32-arm64-msvc@15.0.2': - resolution: {integrity: sha512-JkXysDT0/hEY47O+Hvs8PbZAeiCQVxKfGtr4GUpNAhlG2E0Mkjibuo8ryGD29Qb5a3IOnKYNoZlh/MyKd2Nbww==} + '@next/swc-win32-arm64-msvc@15.0.3': + resolution: {integrity: sha512-9TEp47AAd/ms9fPNgtgnT7F3M1Hf7koIYYWCMQ9neOwjbVWJsHZxrFbI3iEDJ8rf1TDGpmHbKxXf2IFpAvheIQ==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@15.0.2': - resolution: {integrity: sha512-foaUL0NqJY/dX0Pi/UcZm5zsmSk5MtP/gxx3xOPyREkMFN+CTjctPfu3QaqrQHinaKdPnMWPJDKt4VjDfTBe/Q==} + '@next/swc-win32-x64-msvc@15.0.3': + resolution: {integrity: sha512-VNAz+HN4OGgvZs6MOoVfnn41kBzT+M+tB+OK4cww6DNyWS6wKaDpaAm/qLeOUbnMh0oVx1+mg0uoYARF69dJyA==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -1316,6 +1319,32 @@ packages: resolution: {integrity: sha512-h8vYOulWec9LhpwfAdZf2bjr8xIp0KNKnpgqSz0qqYYKAW/QZKw3ktRndbiAtUz4acH4QLQavwZBYCc0wulA/Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@vercel/analytics@1.4.1': + resolution: {integrity: sha512-ekpL4ReX2TH3LnrRZTUKjHHNpNy9S1I7QmS+g/RQXoSUQ8ienzosuX7T9djZ/s8zPhBx1mpHP/Rw5875N+zQIQ==} + peerDependencies: + '@remix-run/react': ^2 + '@sveltejs/kit': ^1 || ^2 + next: '>= 13' + react: ^18 || ^19 || ^19.0.0-rc + svelte: '>= 4' + vue: ^3 + vue-router: ^4 + peerDependenciesMeta: + '@remix-run/react': + optional: true + '@sveltejs/kit': + optional: true + next: + optional: true + react: + optional: true + svelte: + optional: true + vue: + optional: true + vue-router: + optional: true + '@vercel/postgres@0.10.0': resolution: {integrity: sha512-fSD23DxGND40IzSkXjcFcxr53t3Tiym59Is0jSYIFpG4/0f0KO9SGtcp1sXiebvPaGe7N/tU05cH4yt2S6/IPg==} engines: {node: '>=18.14'} @@ -2352,16 +2381,16 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - next@15.0.2: - resolution: {integrity: sha512-rxIWHcAu4gGSDmwsELXacqAPUk+j8dV/A9cDF5fsiCMpkBDYkO2AEaL1dfD+nNmDiU6QMCFN8Q30VEKapT9UHQ==} - engines: {node: '>=18.18.0'} + next@15.0.3: + resolution: {integrity: sha512-ontCbCRKJUIoivAdGB34yCaOcPgYXr9AAkV/IwqFfWWTXEPUgLYkSkqBhIk9KK7gGmgjc64B+RdoeIDM13Irnw==} + engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} hasBin: true peerDependencies: '@opentelemetry/api': ^1.1.0 '@playwright/test': ^1.41.2 babel-plugin-react-compiler: '*' - react: ^18.2.0 || 19.0.0-rc-02c0e824-20241028 - react-dom: ^18.2.0 || 19.0.0-rc-02c0e824-20241028 + react: ^18.2.0 || 19.0.0-rc-66855b96-20241106 + react-dom: ^18.2.0 || 19.0.0-rc-66855b96-20241106 sass: ^1.3.0 peerDependenciesMeta: '@opentelemetry/api': @@ -3079,15 +3108,15 @@ snapshots: react-dom: 18.3.1(react@18.3.1) tslib: 2.4.1 - '@clerk/nextjs@6.4.0(next@15.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@clerk/nextjs@6.4.0(next@15.0.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@clerk/backend': 1.17.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@clerk/clerk-react': 5.16.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@clerk/shared': 2.15.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@clerk/types': 4.34.0 crypto-js: 4.2.0 - ezheaders: 0.1.0(next@15.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) - next: 15.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + ezheaders: 0.1.0(next@15.0.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) + next: 15.0.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) server-only: 0.0.1 @@ -3507,34 +3536,34 @@ snapshots: dependencies: '@types/pg': 8.11.6 - '@next/env@15.0.2': {} + '@next/env@15.0.3': {} '@next/eslint-plugin-next@15.0.2': dependencies: fast-glob: 3.3.1 - '@next/swc-darwin-arm64@15.0.2': + '@next/swc-darwin-arm64@15.0.3': optional: true - '@next/swc-darwin-x64@15.0.2': + '@next/swc-darwin-x64@15.0.3': optional: true - '@next/swc-linux-arm64-gnu@15.0.2': + '@next/swc-linux-arm64-gnu@15.0.3': optional: true - '@next/swc-linux-arm64-musl@15.0.2': + '@next/swc-linux-arm64-musl@15.0.3': optional: true - '@next/swc-linux-x64-gnu@15.0.2': + '@next/swc-linux-x64-gnu@15.0.3': optional: true - '@next/swc-linux-x64-musl@15.0.2': + '@next/swc-linux-x64-musl@15.0.3': optional: true - '@next/swc-win32-arm64-msvc@15.0.2': + '@next/swc-win32-arm64-msvc@15.0.3': optional: true - '@next/swc-win32-x64-msvc@15.0.2': + '@next/swc-win32-x64-msvc@15.0.3': optional: true '@nodelib/fs.scandir@2.1.5': @@ -4015,6 +4044,11 @@ snapshots: '@typescript-eslint/types': 8.15.0 eslint-visitor-keys: 4.2.0 + '@vercel/analytics@1.4.1(next@15.0.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': + optionalDependencies: + next: 15.0.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + '@vercel/postgres@0.10.0': dependencies: '@neondatabase/serverless': 0.9.5 @@ -4758,9 +4792,9 @@ snapshots: transitivePeerDependencies: - supports-color - ezheaders@0.1.0(next@15.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)): + ezheaders@0.1.0(next@15.0.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)): dependencies: - next: 15.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + next: 15.0.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) fast-deep-equal@3.1.3: {} @@ -5188,9 +5222,9 @@ snapshots: natural-compare@1.4.0: {} - next@15.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + next@15.0.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@next/env': 15.0.2 + '@next/env': 15.0.3 '@swc/counter': 0.1.3 '@swc/helpers': 0.5.13 busboy: 1.6.0 @@ -5200,14 +5234,14 @@ snapshots: react-dom: 18.3.1(react@18.3.1) styled-jsx: 5.1.6(react@18.3.1) optionalDependencies: - '@next/swc-darwin-arm64': 15.0.2 - '@next/swc-darwin-x64': 15.0.2 - '@next/swc-linux-arm64-gnu': 15.0.2 - '@next/swc-linux-arm64-musl': 15.0.2 - '@next/swc-linux-x64-gnu': 15.0.2 - '@next/swc-linux-x64-musl': 15.0.2 - '@next/swc-win32-arm64-msvc': 15.0.2 - '@next/swc-win32-x64-msvc': 15.0.2 + '@next/swc-darwin-arm64': 15.0.3 + '@next/swc-darwin-x64': 15.0.3 + '@next/swc-linux-arm64-gnu': 15.0.3 + '@next/swc-linux-arm64-musl': 15.0.3 + '@next/swc-linux-x64-gnu': 15.0.3 + '@next/swc-linux-x64-musl': 15.0.3 + '@next/swc-win32-arm64-msvc': 15.0.3 + '@next/swc-win32-x64-msvc': 15.0.3 sharp: 0.33.5 transitivePeerDependencies: - '@babel/core' diff --git a/src/app/api/invitations/[id]/route.ts b/src/app/api/invitations/[id]/route.ts index 6804901..a109d60 100644 --- a/src/app/api/invitations/[id]/route.ts +++ b/src/app/api/invitations/[id]/route.ts @@ -3,46 +3,47 @@ /* tslint:disable */ import { eq } from "drizzle-orm"; -import { NextRequest, NextResponse } from "next/server"; -import { auth } from "@clerk/nextjs/server"; +import { NextRequest } from "next/server"; import { db } from "~/db"; import { invitationsTable } from "~/db/schema"; +import { PERMISSIONS, checkPermissions } from "~/lib/permissions-server"; +import { ApiError, createApiResponse } from "~/lib/api-utils"; -// @ts-ignore export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) { - const { userId } = await auth(); - const { id } = params; - - if (!userId) { - return NextResponse.json( - { error: "Unauthorized" }, - { status: 401 } - ); - } - try { + const { id } = params; const invitationId = parseInt(id, 10); if (isNaN(invitationId)) { - return NextResponse.json( - { error: "Invalid invitation ID" }, - { status: 400 } - ); + return ApiError.BadRequest("Invalid invitation ID"); + } + + // Get the invitation to check the study ID + const invitation = await db + .select() + .from(invitationsTable) + .where(eq(invitationsTable.id, invitationId)) + .limit(1); + + if (!invitation[0]) { + return ApiError.NotFound("Invitation"); + } + + const permissionCheck = await checkPermissions({ + studyId: invitation[0].studyId, + permission: PERMISSIONS.MANAGE_ROLES + }); + + if (permissionCheck.error) { + return permissionCheck.error; } await db .delete(invitationsTable) .where(eq(invitationsTable.id, invitationId)); - return NextResponse.json( - { message: "Invitation deleted successfully" }, - { status: 200 } - ); + return createApiResponse({ message: "Invitation deleted successfully" }); } catch (error) { - console.error("Error deleting invitation:", error); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 } - ); + return ApiError.ServerError(error); } } \ No newline at end of file diff --git a/src/app/api/invitations/accept/[token]/route.ts b/src/app/api/invitations/accept/[token]/route.ts index 4ffc793..7b019fb 100644 --- a/src/app/api/invitations/accept/[token]/route.ts +++ b/src/app/api/invitations/accept/[token]/route.ts @@ -1,18 +1,16 @@ import { eq, and } from "drizzle-orm"; -import { NextRequest, NextResponse } from "next/server"; +import { NextRequest } from "next/server"; import { auth } from "@clerk/nextjs/server"; import { db } from "~/db"; -import { invitationsTable } from "~/db/schema"; +import { invitationsTable, userRolesTable } from "~/db/schema"; +import { ApiError, createApiResponse } from "~/lib/api-utils"; export async function POST(req: NextRequest, { params }: { params: { token: string } }) { const { userId } = await auth(); const { token } = params; if (!userId) { - return NextResponse.json( - { error: "Unauthorized" }, - { status: 401 } - ); + return ApiError.Unauthorized(); } try { @@ -29,30 +27,37 @@ export async function POST(req: NextRequest, { params }: { params: { token: stri .limit(1); if (!invitation) { - return NextResponse.json( - { error: "Invalid or expired invitation" }, - { status: 404 } - ); + return ApiError.NotFound("Invitation"); } - // Update the invitation - await db - .update(invitationsTable) - .set({ - accepted: true, - acceptedByUserId: userId, - }) - .where(eq(invitationsTable.id, invitation.id)); + // Check if invitation has expired + if (new Date() > invitation.expiresAt) { + return ApiError.BadRequest("Invitation has expired"); + } - return NextResponse.json( - { message: "Invitation accepted successfully" }, - { status: 200 } - ); + // Assign role and mark invitation as accepted in a transaction + await db.transaction(async (tx) => { + // Assign role + await tx + .insert(userRolesTable) + .values({ + userId: userId, + roleId: invitation.roleId, + studyId: invitation.studyId, + }); + + // Mark invitation as accepted + await tx + .update(invitationsTable) + .set({ + accepted: true, + acceptedByUserId: userId, + }) + .where(eq(invitationsTable.id, invitation.id)); + }); + + return createApiResponse({ message: "Invitation accepted successfully" }); } catch (error) { - console.error("Error accepting invitation:", error); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 } - ); + return ApiError.ServerError(error); } } \ No newline at end of file diff --git a/src/app/api/invitations/route.ts b/src/app/api/invitations/route.ts index a02adc2..6658cfd 100644 --- a/src/app/api/invitations/route.ts +++ b/src/app/api/invitations/route.ts @@ -2,10 +2,11 @@ import { NextResponse } from "next/server"; import { auth } from "@clerk/nextjs/server"; import { db } from "~/db"; import { invitationsTable, studyTable, rolesTable } from "~/db/schema"; -import { eq, and } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { randomBytes } from "crypto"; import { sendInvitationEmail } from "~/lib/email"; -import { hasPermission, hasStudyAccess, PERMISSIONS } from "~/lib/permissions"; +import { PERMISSIONS, checkPermissions } from "~/lib/permissions-server"; +import { ApiError, createApiResponse } from "~/lib/api-utils"; // Helper to generate a secure random token function generateToken(): string { @@ -13,24 +14,21 @@ function generateToken(): string { } export async function GET(request: Request) { - const { userId } = await auth(); - - if (!userId) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - try { const url = new URL(request.url); const studyId = url.searchParams.get("studyId"); if (!studyId) { - return NextResponse.json({ error: "Study ID is required" }, { status: 400 }); + return ApiError.BadRequest("Study ID is required"); } - // First check if user has access to the study - const hasAccess = await hasStudyAccess(userId, parseInt(studyId)); - if (!hasAccess) { - return NextResponse.json({ error: "Study not found" }, { status: 404 }); + const permissionCheck = await checkPermissions({ + studyId: parseInt(studyId), + permission: PERMISSIONS.MANAGE_ROLES + }); + + if (permissionCheck.error) { + return permissionCheck.error; } // Get all invitations for the study, including role names @@ -47,37 +45,26 @@ export async function GET(request: Request) { .innerJoin(rolesTable, eq(invitationsTable.roleId, rolesTable.id)) .where(eq(invitationsTable.studyId, parseInt(studyId))); - return NextResponse.json(invitations); + return createApiResponse(invitations); } catch (error) { - console.error("Error fetching invitations:", error); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 } - ); + return ApiError.ServerError(error); } } export async function POST(request: Request) { - const { userId } = await auth(); - - if (!userId) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - try { const { email, studyId, roleId } = await request.json(); - // First check if user has access to the study - const hasAccess = await hasStudyAccess(userId, studyId); - if (!hasAccess) { - return NextResponse.json({ error: "Study not found" }, { status: 404 }); + const permissionCheck = await checkPermissions({ + studyId, + permission: PERMISSIONS.MANAGE_ROLES + }); + + if (permissionCheck.error) { + return permissionCheck.error; } - // Then check if user has permission to invite users - const canInvite = await hasPermission(userId, PERMISSIONS.MANAGE_ROLES, studyId); - if (!canInvite) { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } + const { userId } = permissionCheck; // Get study details const study = await db @@ -87,7 +74,7 @@ export async function POST(request: Request) { .limit(1); if (!study[0]) { - return NextResponse.json({ error: "Study not found" }, { status: 404 }); + return ApiError.NotFound("Study"); } // Verify the role exists @@ -98,7 +85,7 @@ export async function POST(request: Request) { .limit(1); if (!role[0]) { - return NextResponse.json({ error: "Invalid role" }, { status: 400 }); + return ApiError.BadRequest("Invalid role"); } // Generate invitation token @@ -128,12 +115,8 @@ export async function POST(request: Request) { token, }); - return NextResponse.json(invitation); + return createApiResponse(invitation); } catch (error) { - console.error("Error creating invitation:", error); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 } - ); + return ApiError.ServerError(error); } } \ No newline at end of file diff --git a/src/app/api/permissions/route.ts b/src/app/api/permissions/route.ts index c14011c..77dc39a 100644 --- a/src/app/api/permissions/route.ts +++ b/src/app/api/permissions/route.ts @@ -1,19 +1,29 @@ import { NextResponse } from "next/server"; import { auth } from "@clerk/nextjs/server"; -import { getUserPermissions } from "~/lib/permissions"; +import { ApiError, createApiResponse } from "~/lib/api-utils"; +import { db } from "~/db"; +import { userRolesTable, rolePermissionsTable, permissionsTable } from "~/db/schema"; +import { eq, and } from "drizzle-orm"; export async function GET() { const { userId } = await auth(); if (!userId) { - return new NextResponse("Unauthorized", { status: 401 }); + return ApiError.Unauthorized(); } try { - const permissions = await getUserPermissions(userId); - return NextResponse.json(permissions); + const permissions = await db + .selectDistinct({ + code: permissionsTable.code, + }) + .from(userRolesTable) + .innerJoin(rolePermissionsTable, eq(rolePermissionsTable.roleId, userRolesTable.roleId)) + .innerJoin(permissionsTable, eq(permissionsTable.id, rolePermissionsTable.permissionId)) + .where(eq(userRolesTable.userId, userId)); + + return createApiResponse(permissions.map(p => p.code)); } catch (error) { - console.error("Error fetching permissions:", error); - return new NextResponse("Internal Server Error", { status: 500 }); + return ApiError.ServerError(error); } } diff --git a/src/app/api/studies/[id]/participants/route.ts b/src/app/api/studies/[id]/participants/route.ts new file mode 100644 index 0000000..c24dc8b --- /dev/null +++ b/src/app/api/studies/[id]/participants/route.ts @@ -0,0 +1,129 @@ +import { eq } from "drizzle-orm"; +import { db } from "~/db"; +import { participantsTable } from "~/db/schema"; +import { PERMISSIONS, checkPermissions } from "~/lib/permissions-server"; +import { ApiError, createApiResponse } from "~/lib/api-utils"; +import { auth } from "@clerk/nextjs/server"; + +export async function GET( + request: Request, + { params }: { params: { id: string } } +) { + const { userId } = await auth(); + + if (!userId) { + return ApiError.Unauthorized(); + } + + try { + const studyId = parseInt(params.id); + + if (isNaN(studyId)) { + return ApiError.BadRequest("Invalid study ID"); + } + + const permissionCheck = await checkPermissions({ + studyId, + permission: PERMISSIONS.VIEW_PARTICIPANT_NAMES, + }); + + if (permissionCheck.error) { + return permissionCheck.error; + } + + const participants = await db + .select() + .from(participantsTable) + .where(eq(participantsTable.studyId, studyId)); + + return createApiResponse(participants); + } catch (error) { + return ApiError.ServerError(error); + } +} + +export async function POST( + request: Request, + { params }: { params: { id: string } } +) { + const { userId } = await auth(); + + if (!userId) { + return ApiError.Unauthorized(); + } + + try { + const studyId = parseInt(params.id); + const { name } = await request.json(); + + if (isNaN(studyId)) { + return ApiError.BadRequest("Invalid study ID"); + } + + if (!name || typeof name !== "string") { + return ApiError.BadRequest("Name is required"); + } + + const permissionCheck = await checkPermissions({ + studyId, + permission: PERMISSIONS.CREATE_PARTICIPANT, + }); + + if (permissionCheck.error) { + return permissionCheck.error; + } + + const participant = await db + .insert(participantsTable) + .values({ + name, + studyId, + }) + .returning(); + + return createApiResponse(participant[0]); + } catch (error) { + return ApiError.ServerError(error); + } +} + +export async function DELETE( + request: Request, + { params }: { params: { id: string } } +) { + const { userId } = await auth(); + + if (!userId) { + return ApiError.Unauthorized(); + } + + try { + const studyId = parseInt(params.id); + const { participantId } = await request.json(); + + if (isNaN(studyId)) { + return ApiError.BadRequest("Invalid study ID"); + } + + if (!participantId || typeof participantId !== "number") { + return ApiError.BadRequest("Participant ID is required"); + } + + const permissionCheck = await checkPermissions({ + studyId, + permission: PERMISSIONS.DELETE_PARTICIPANT, + }); + + if (permissionCheck.error) { + return permissionCheck.error; + } + + await db + .delete(participantsTable) + .where(eq(participantsTable.id, participantId)); + + return createApiResponse({ success: true }); + } catch (error) { + return ApiError.ServerError(error); + } +} \ No newline at end of file diff --git a/src/app/api/studies/[id]/route.ts b/src/app/api/studies/[id]/route.ts index f05518e..3b69424 100644 --- a/src/app/api/studies/[id]/route.ts +++ b/src/app/api/studies/[id]/route.ts @@ -1,45 +1,58 @@ -import { eq } from "drizzle-orm"; -import { NextResponse } from "next/server"; -import { auth } from "@clerk/nextjs/server"; +import { eq, and } from "drizzle-orm"; import { db } from "~/db"; -import { studyTable } from "~/db/schema"; -import { hasStudyAccess } from "~/lib/permissions"; - -export async function GET( - request: Request, - context: { params: { id: string } } -) { - const { userId } = await auth(); - - if (!userId) { - return new NextResponse("Unauthorized", { status: 401 }); - } +import { studyTable, userRolesTable, rolePermissionsTable, permissionsTable } from "~/db/schema"; +import { PERMISSIONS, checkPermissions } from "~/lib/permissions-server"; +import { ApiError, createApiResponse } from "~/lib/api-utils"; +export async function GET(request: Request, { params }: { params: { id: string } }) { try { - // Properly await and destructure params - const { id } = await context.params; + const { id } = params; const studyId = parseInt(id); - - // Check if user has access to this study - const hasAccess = await hasStudyAccess(userId, studyId); - if (!hasAccess) { - return new NextResponse("Forbidden", { status: 403 }); + + if (isNaN(studyId)) { + return ApiError.BadRequest("Invalid study ID"); } - // Get study details - const study = await db - .select() + const permissionCheck = await checkPermissions({ + studyId, + permission: PERMISSIONS.VIEW_STUDY + }); + + if (permissionCheck.error) { + return permissionCheck.error; + } + + // Get study with permissions + const studyWithPermissions = await db + .selectDistinct({ + id: studyTable.id, + title: studyTable.title, + description: studyTable.description, + createdAt: studyTable.createdAt, + updatedAt: studyTable.updatedAt, + userId: studyTable.userId, + permissionCode: permissionsTable.code, + }) .from(studyTable) - .where(eq(studyTable.id, studyId)) - .limit(1); + .leftJoin(userRolesTable, eq(userRolesTable.studyId, studyTable.id)) + .leftJoin(rolePermissionsTable, eq(rolePermissionsTable.roleId, userRolesTable.roleId)) + .leftJoin(permissionsTable, eq(permissionsTable.id, rolePermissionsTable.permissionId)) + .where(eq(studyTable.id, studyId)); - if (!study[0]) { - return new NextResponse("Study not found", { status: 404 }); + if (!studyWithPermissions.length) { + return ApiError.NotFound("Study"); } - return NextResponse.json(study[0]); + // Group permissions + const study = { + ...studyWithPermissions[0], + permissions: studyWithPermissions + .map(s => s.permissionCode) + .filter((code): code is string => code !== null) + }; + + return createApiResponse(study); } catch (error) { - console.error("Error fetching study:", error); - return new NextResponse("Internal Server Error", { status: 500 }); + return ApiError.ServerError(error); } } \ No newline at end of file diff --git a/src/app/api/studies/route.ts b/src/app/api/studies/route.ts index f9ed0a8..bb3062c 100644 --- a/src/app/api/studies/route.ts +++ b/src/app/api/studies/route.ts @@ -1,78 +1,146 @@ -import { eq, or } from "drizzle-orm"; -import { NextResponse } from "next/server"; -import { auth } from "@clerk/nextjs/server"; +import { eq, and, or } from "drizzle-orm"; import { db } from "~/db"; -import { studyTable, usersTable, userRolesTable } from "~/db/schema"; +import { studyTable, userRolesTable, rolePermissionsTable, permissionsTable, rolesTable } from "~/db/schema"; +import { PERMISSIONS, checkPermissions } from "~/lib/permissions-server"; +import { ApiError, createApiResponse, getEnvironment } from "~/lib/api-utils"; +import { auth } from "@clerk/nextjs/server"; export async function GET() { const { userId } = await auth(); if (!userId) { - return new NextResponse("Unauthorized", { status: 401 }); + return ApiError.Unauthorized(); } - // Get all studies where user is either the owner or has a role - const studies = await db - .select({ - id: studyTable.id, - title: studyTable.title, - description: studyTable.description, - createdAt: studyTable.createdAt, - updatedAt: studyTable.updatedAt, - userId: studyTable.userId, - }) - .from(studyTable) - .leftJoin(userRolesTable, eq(userRolesTable.studyId, studyTable.id)) - .where( - or( - eq(studyTable.userId, userId), - eq(userRolesTable.userId, userId) - ) - ) - .groupBy(studyTable.id); + try { + const currentEnvironment = getEnvironment(); - return NextResponse.json(studies); + // Get all studies where user has any role + const studiesWithPermissions = await db + .selectDistinct({ + id: studyTable.id, + title: studyTable.title, + description: studyTable.description, + createdAt: studyTable.createdAt, + updatedAt: studyTable.updatedAt, + userId: studyTable.userId, + permissionCode: permissionsTable.code, + roleName: rolesTable.name, + }) + .from(studyTable) + .innerJoin( + userRolesTable, + and( + eq(userRolesTable.studyId, studyTable.id), + eq(userRolesTable.userId, userId) + ) + ) + .innerJoin(rolesTable, eq(rolesTable.id, userRolesTable.roleId)) + .leftJoin(rolePermissionsTable, eq(rolePermissionsTable.roleId, userRolesTable.roleId)) + .leftJoin(permissionsTable, eq(permissionsTable.id, rolePermissionsTable.permissionId)) + .where(eq(studyTable.environment, currentEnvironment)); + + // Group permissions and roles by study + const studies = studiesWithPermissions.reduce((acc, curr) => { + const existingStudy = acc.find(s => s.id === curr.id); + if (!existingStudy) { + acc.push({ + id: curr.id, + title: curr.title, + description: curr.description, + createdAt: curr.createdAt, + updatedAt: curr.updatedAt, + userId: curr.userId, + permissions: curr.permissionCode ? [curr.permissionCode] : [], + roles: curr.roleName ? [curr.roleName] : [] + }); + } else { + if (curr.permissionCode && !existingStudy.permissions.includes(curr.permissionCode)) { + existingStudy.permissions.push(curr.permissionCode); + } + if (curr.roleName && !existingStudy.roles.includes(curr.roleName)) { + existingStudy.roles.push(curr.roleName); + } + } + return acc; + }, [] as Array<{ + id: number; + title: string; + description: string | null; + createdAt: Date; + updatedAt: Date | null; + userId: string; + permissions: string[]; + roles: string[]; + }>); + + return createApiResponse(studies); + } catch (error) { + return ApiError.ServerError(error); + } } export async function POST(request: Request) { const { userId } = await auth(); if (!userId) { - return new Response(JSON.stringify({ error: "Unauthorized" }), { - status: 401, - headers: { 'Content-Type': 'application/json' } - }); + return ApiError.Unauthorized(); } try { - // Verify user exists first - const existingUser = await db - .select() - .from(usersTable) - .where(eq(usersTable.id, userId)); - const { title, description } = await request.json(); + const currentEnvironment = getEnvironment(); - const study = await db - .insert(studyTable) - .values({ - title, - description, - userId: userId, - }) - .returning(); + // Create study and assign admin role in a transaction + const result = await db.transaction(async (tx) => { + // Create the study + const [study] = await tx + .insert(studyTable) + .values({ + title, + description, + userId: userId, + environment: currentEnvironment, + }) + .returning(); - return new Response(JSON.stringify(study[0]), { - status: 200, - headers: { 'Content-Type': 'application/json' } + // Look up the ADMIN role + const [adminRole] = await tx + .select() + .from(rolesTable) + .where(eq(rolesTable.name, 'admin')) + .limit(1); + + if (!adminRole) { + throw new Error('Admin role not found'); + } + + // Assign admin role + await tx + .insert(userRolesTable) + .values({ + userId: userId, + roleId: adminRole.id, + studyId: study.id, + }); + + // Get all permissions for this role + const permissions = await tx + .select({ + permissionCode: permissionsTable.code + }) + .from(rolePermissionsTable) + .innerJoin(permissionsTable, eq(permissionsTable.id, rolePermissionsTable.permissionId)) + .where(eq(rolePermissionsTable.roleId, adminRole.id)); + + return { + ...study, + permissions: permissions.map(p => p.permissionCode) + }; }); + + return createApiResponse(result); } catch (error) { - return new Response(JSON.stringify({ - error: "Failed to create study", - details: error instanceof Error ? error.message : 'Unknown error' - }), { - status: 500, - headers: { 'Content-Type': 'application/json' } - }); + return ApiError.ServerError(error); } } diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 5937afc..03b9255 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -72,7 +72,7 @@ export default function Dashboard() { -
{stats.studyCount}
+
{stats.studyCount ? stats.studyCount : 0}

Active research studies

diff --git a/src/app/dashboard/participants/page.tsx b/src/app/dashboard/participants/page.tsx deleted file mode 100644 index 78e9634..0000000 --- a/src/app/dashboard/participants/page.tsx +++ /dev/null @@ -1,264 +0,0 @@ -'use client'; - -import { PlusIcon, Trash2Icon } from "lucide-react"; -import { useEffect, useState } from "react"; -import { Button } from "~/components/ui/button"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card"; -import { Input } from "~/components/ui/input"; -import { Label } from "~/components/ui/label"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select"; -import { useToast } from "~/hooks/use-toast"; - -interface Study { - id: number; - title: string; -} - -interface Participant { - id: number; - name: string; - studyId: number; -} - -export default function Participants() { - const [studies, setStudies] = useState([]); - const [participants, setParticipants] = useState([]); - const [selectedStudyId, setSelectedStudyId] = useState(null); - const [participantName, setParticipantName] = useState(""); - const [loading, setLoading] = useState(true); - const { toast } = useToast(); - - useEffect(() => { - fetchStudies(); - }, []); - - const fetchStudies = async () => { - try { - const response = await fetch('/api/studies'); - const data = await response.json(); - setStudies(data); - } catch (error) { - console.error('Error fetching studies:', error); - toast({ - title: "Error", - description: "Failed to load studies", - variant: "destructive", - }); - } finally { - setLoading(false); - } - }; - - const fetchParticipants = async (studyId: number) => { - try { - const response = await fetch(`/api/participants?studyId=${studyId}`); - - if (!response.ok) { - throw new Error(`Failed to fetch participants`); - } - - const data = await response.json(); - setParticipants(data); - } catch (error) { - console.error('Error fetching participants:', error); - toast({ - title: "Error", - description: "Failed to load participants", - variant: "destructive", - }); - } - }; - - const handleStudyChange = (studyId: string) => { - const id = parseInt(studyId); - setSelectedStudyId(id); - fetchParticipants(id); - }; - - const addParticipant = async (e: React.FormEvent) => { - e.preventDefault(); - if (!selectedStudyId) return; - - try { - const response = await fetch(`/api/participants`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - name: participantName, - studyId: selectedStudyId, - }), - }); - - if (!response.ok) { - throw new Error('Failed to add participant'); - } - - const newParticipant = await response.json(); - setParticipants([...participants, newParticipant]); - setParticipantName(""); - - toast({ - title: "Success", - description: "Participant added successfully", - }); - } catch (error) { - console.error('Error adding participant:', error); - toast({ - title: "Error", - description: "Failed to add participant", - variant: "destructive", - }); - } - }; - - const deleteParticipant = async (id: number) => { - try { - const response = await fetch(`/api/participants/${id}`, { - method: 'DELETE', - }); - - if (!response.ok) { - throw new Error('Failed to delete participant'); - } - - setParticipants(participants.filter(participant => participant.id !== id)); - toast({ - title: "Success", - description: "Participant deleted successfully", - }); - } catch (error) { - console.error('Error deleting participant:', error); - toast({ - title: "Error", - description: "Failed to delete participant", - variant: "destructive", - }); - } - }; - - if (loading) { - return ( -
-
-
- ); - } - - return ( -
-
-

Participants

-

Manage study participants

-
- - - - Study Selection - - Select a study to manage its participants - - - -
- - -
-
-
- - {selectedStudyId && ( - - - Add New Participant - - Add a new participant to the selected study - - - -
-
- - setParticipantName(e.target.value)} - placeholder="Enter participant name" - required - /> -
- -
-
-
- )} - - {selectedStudyId && participants.length > 0 && ( - - - Participants List - - Manage existing participants - - - -
- {participants.map((participant) => ( -
- {participant.name} - -
- ))} -
-
-
- )} - - {selectedStudyId && participants.length === 0 && ( - - -

- No participants added yet. Add your first participant above. -

-
-
- )} - - {!selectedStudyId && ( - - -

- Please select a study to view its participants. -

-
-
- )} -
- ); -} \ No newline at end of file diff --git a/src/app/dashboard/studies/[id]/settings/page.tsx b/src/app/dashboard/studies/[id]/settings/page.tsx index 2956a77..c71421d 100644 --- a/src/app/dashboard/studies/[id]/settings/page.tsx +++ b/src/app/dashboard/studies/[id]/settings/page.tsx @@ -1,201 +1,79 @@ 'use client'; -import { useEffect, useState } from "react"; -import { useParams } from "next/navigation"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card"; +import { useState } from "react"; +import { useParams, useRouter, useSearchParams } from "next/navigation"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; -import { InviteUserDialog } from "~/components/invite-user-dialog"; -import { Button } from "~/components/ui/button"; -import { Loader2 } from "lucide-react"; -import { useToast } from "~/hooks/use-toast"; +import { SettingsTab } from "~/components/studies/settings-tab"; +import { ParticipantsTab } from "~/components/studies/participants-tab"; +import { useEffect } from "react"; interface Study { id: number; title: string; - description: string; + description: string | null; + permissions: string[]; } -interface Invitation { - id: string; - email: string; - roleName: string; - accepted: boolean; - expiresAt: string; -} - -export default function StudySettingsPage() { - const params = useParams(); +export default function StudySettings() { const [study, setStudy] = useState(null); - const [invitations, setInvitations] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - const { toast } = useToast(); - - const fetchStudyData = async () => { - try { - const response = await fetch(`/api/studies/${params.id}`); - if (!response.ok) throw new Error("Failed to fetch study"); - const data = await response.json(); - setStudy(data); - } catch (error) { - setError("Failed to load study details"); - console.error("Error fetching study:", error); - toast({ - title: "Error", - description: "Failed to load study details", - variant: "destructive", - }); - } - }; - - const fetchInvitations = async () => { - try { - const response = await fetch(`/api/invitations?studyId=${params.id}`); - if (!response.ok) throw new Error("Failed to fetch invitations"); - const data = await response.json(); - setInvitations(data); - } catch (error) { - setError("Failed to load invitations"); - console.error("Error fetching invitations:", error); - toast({ - title: "Error", - description: "Failed to load invitations", - variant: "destructive", - }); - } finally { - setIsLoading(false); - } - }; + const { id } = useParams(); + const router = useRouter(); + const searchParams = useSearchParams(); + const tab = searchParams.get('tab') || 'settings'; useEffect(() => { - const loadData = async () => { - await Promise.all([fetchStudyData(), fetchInvitations()]); - }; - loadData(); - }, [params.id]); // eslint-disable-line react-hooks/exhaustive-deps - - const handleDeleteInvitation = async (invitationId: string) => { - try { - const response = await fetch(`/api/invitations/${invitationId}`, { - method: "DELETE", - }); - - if (!response.ok) { - throw new Error("Failed to delete invitation"); + const fetchStudy = async () => { + try { + const response = await fetch(`/api/studies/${id}`); + if (!response.ok) throw new Error("Failed to fetch study"); + const data = await response.json(); + setStudy(data.data); + } catch (error) { + console.error("Error fetching study:", error); + setError(error instanceof Error ? error.message : "Failed to load study"); + } finally { + setIsLoading(false); } + }; - // Update local state - setInvitations(invitations.filter(inv => inv.id !== invitationId)); - - toast({ - title: "Success", - description: "Invitation deleted successfully", - }); - } catch (error) { - console.error("Error deleting invitation:", error); - toast({ - title: "Error", - description: "Failed to delete invitation", - variant: "destructive", - }); - } + fetchStudy(); + }, [id]); + + const handleTabChange = (value: string) => { + router.push(`/dashboard/studies/${id}/settings?tab=${value}`); }; if (isLoading) { - return ( -
- -
- ); + return
Loading...
; } - if (error) { - return ( -
-

{error}

-
- ); - } - - if (!study) { - return ( -
-

Study not found

-
- ); + if (error || !study) { + return
{error || "Study not found"}
; } return (
-
-

{study.title}

-

{study.description}

+
+

{study.title}

+

+ Manage study settings and participants +

- + - Invites Settings + Participants - - - - - Manage Invitations - - Invite researchers and participants to collaborate on “{study.title}” - - - -
- -
- {invitations.length > 0 ? ( -
- {invitations.map((invitation) => ( -
-
-

{invitation.email}

-

- Role: {invitation.roleName} - {invitation.accepted ? " • Accepted" : " • Pending"} -

-
- {!invitation.accepted && ( - - )} -
- ))} -
- ) : ( -

No invitations sent yet.

- )} -
-
+ + - - - - - Study Settings - - Configure study settings and permissions - - - -

Settings coming soon...

-
-
+ + +
diff --git a/src/app/dashboard/studies/page.tsx b/src/app/dashboard/studies/page.tsx index 29954b5..865953e 100644 --- a/src/app/dashboard/studies/page.tsx +++ b/src/app/dashboard/studies/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { useRouter } from "next/navigation"; import { PlusIcon, Trash2Icon, Settings2 } from "lucide-react"; import { Button } from "~/components/ui/button"; @@ -9,73 +9,86 @@ import { Input } from "~/components/ui/input"; import { Label } from "~/components/ui/label"; import { Textarea } from "~/components/ui/textarea"; import { useToast } from "~/hooks/use-toast"; +import { PERMISSIONS, hasPermission } from "~/lib/permissions-client"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, + AlertDialogFooter +} from "~/components/ui/alert-dialog"; +import { ROLES } from "~/lib/roles"; interface Study { id: number; title: string; - description: string; + description: string | null; createdAt: string; + updatedAt: string | null; + userId: string; + permissions: string[]; + roles: string[]; +} + +// Helper function to format role name +function formatRoleName(role: string): string { + return role + .split('_') + .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(' '); } export default function Studies() { const [studies, setStudies] = useState([]); - const [newStudyTitle, setNewStudyTitle] = useState(""); - const [newStudyDescription, setNewStudyDescription] = useState(""); - const [loading, setLoading] = useState(true); + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); const router = useRouter(); const { toast } = useToast(); - useEffect(() => { - fetchStudies(); - }, []); - const fetchStudies = async () => { try { - const response = await fetch('/api/studies'); + const response = await fetch("/api/studies"); + if (!response.ok) throw new Error("Failed to fetch studies"); const data = await response.json(); - setStudies(data); + setStudies(data.data || []); } catch (error) { - console.error('Error fetching studies:', error); + console.error("Error fetching studies:", error); toast({ title: "Error", description: "Failed to load studies", variant: "destructive", }); - } finally { - setLoading(false); } }; const createStudy = async (e: React.FormEvent) => { e.preventDefault(); - try { - const response = await fetch('/api/studies', { - method: 'POST', + const response = await fetch("/api/studies", { + method: "POST", headers: { - 'Content-Type': 'application/json', + "Content-Type": "application/json", }, - body: JSON.stringify({ - title: newStudyTitle, - description: newStudyDescription, - }), + body: JSON.stringify({ title, description }), }); if (!response.ok) { - throw new Error('Failed to create study'); + throw new Error("Failed to create study"); } - const newStudy = await response.json(); - setStudies([...studies, newStudy]); - setNewStudyTitle(""); - setNewStudyDescription(""); - + const data = await response.json(); + setStudies([...studies, data.data]); + setTitle(""); + setDescription(""); toast({ title: "Success", description: "Study created successfully", }); } catch (error) { - console.error('Error creating study:', error); + console.error("Error creating study:", error); toast({ title: "Error", description: "Failed to create study", @@ -87,11 +100,11 @@ export default function Studies() { const deleteStudy = async (id: number) => { try { const response = await fetch(`/api/studies/${id}`, { - method: 'DELETE', + method: "DELETE", }); if (!response.ok) { - throw new Error('Failed to delete study'); + throw new Error("Failed to delete study"); } setStudies(studies.filter(study => study.id !== id)); @@ -100,7 +113,7 @@ export default function Studies() { description: "Study deleted successfully", }); } catch (error) { - console.error('Error deleting study:', error); + console.error("Error deleting study:", error); toast({ title: "Error", description: "Failed to delete study", @@ -109,85 +122,128 @@ export default function Studies() { } }; - if (loading) { - return ( -
-
-
- ); - } + // Fetch studies on mount + useState(() => { + fetchStudies(); + }); return (
-
-

Studies

-

Manage your research studies

+
+

Studies

+

+ Manage your research studies and experiments +

- - - Create New Study - - Add a new research study to your collection - - - -
-
- - setNewStudyTitle(e.target.value)} - placeholder="Enter study title" - required - /> -
-
- -