chore(deps): Update dependencies and enhance API error handling

- Added '@vercel/analytics' to package.json for improved analytics tracking.
- Updated 'next' version from 15.0.2 to 15.0.3 to incorporate the latest features and fixes.
- Refactored API routes for invitations and studies to improve error handling and response structure.
- Enhanced permission checks in the invitations and studies API to ensure proper access control.
- Removed the participants dashboard page as part of a restructuring effort.
- Updated the database schema to include environment settings for users and studies.
- Improved the dashboard components to handle loading states and display statistics more effectively.
This commit is contained in:
2024-12-04 12:32:54 -05:00
parent cb4c0f9c87
commit 95b106d9e9
22 changed files with 1160 additions and 771 deletions

View File

@@ -25,6 +25,7 @@
"@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.1", "@radix-ui/react-tabs": "^1.1.1",
"@types/nodemailer": "^6.4.17", "@types/nodemailer": "^6.4.17",
"@vercel/analytics": "^1.4.1",
"@vercel/postgres": "^0.10.0", "@vercel/postgres": "^0.10.0",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -32,7 +33,7 @@
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"drizzle-orm": "^0.36.3", "drizzle-orm": "^0.36.3",
"lucide-react": "^0.454.0", "lucide-react": "^0.454.0",
"next": "15.0.2", "next": "15.0.3",
"ngrok": "5.0.0-beta.2", "ngrok": "5.0.0-beta.2",
"nodemailer": "^6.9.16", "nodemailer": "^6.9.16",
"react": "^18.3.1", "react": "^18.3.1",

134
pnpm-lock.yaml generated
View File

@@ -10,7 +10,7 @@ importers:
dependencies: dependencies:
'@clerk/nextjs': '@clerk/nextjs':
specifier: ^6.4.0 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': '@radix-ui/react-alert-dialog':
specifier: ^1.1.2 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) 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': '@types/nodemailer':
specifier: ^6.4.17 specifier: ^6.4.17
version: 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': '@vercel/postgres':
specifier: ^0.10.0 specifier: ^0.10.0
version: 0.10.0 version: 0.10.0
@@ -57,8 +60,8 @@ importers:
specifier: ^0.454.0 specifier: ^0.454.0
version: 0.454.0(react@18.3.1) version: 0.454.0(react@18.3.1)
next: next:
specifier: 15.0.2 specifier: 15.0.3
version: 15.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 15.0.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
ngrok: ngrok:
specifier: 5.0.0-beta.2 specifier: 5.0.0-beta.2
version: 5.0.0-beta.2 version: 5.0.0-beta.2
@@ -780,56 +783,56 @@ packages:
'@neondatabase/serverless@0.9.5': '@neondatabase/serverless@0.9.5':
resolution: {integrity: sha512-siFas6gItqv6wD/pZnvdu34wEqgG3nSE6zWZdq5j2DEsa+VvX8i/5HXJOo06qrw5axPXn+lGCxeR+NLaSPIXug==} resolution: {integrity: sha512-siFas6gItqv6wD/pZnvdu34wEqgG3nSE6zWZdq5j2DEsa+VvX8i/5HXJOo06qrw5axPXn+lGCxeR+NLaSPIXug==}
'@next/env@15.0.2': '@next/env@15.0.3':
resolution: {integrity: sha512-c0Zr0ModK5OX7D4ZV8Jt/wqoXtitLNPwUfG9zElCZztdaZyNVnN40rDXVZ/+FGuR4CcNV5AEfM6N8f+Ener7Dg==} resolution: {integrity: sha512-t9Xy32pjNOvVn2AS+Utt6VmyrshbpfUMhIjFO60gI58deSo/KgLOp31XZ4O+kY/Is8WAGYwA5gR7kOb1eORDBA==}
'@next/eslint-plugin-next@15.0.2': '@next/eslint-plugin-next@15.0.2':
resolution: {integrity: sha512-R9Jc7T6Ge0txjmqpPwqD8vx6onQjynO9JT73ArCYiYPvSrwYXepH/UY/WdKDY8JPWJl72sAE4iGMHPeQ5xdEWg==} resolution: {integrity: sha512-R9Jc7T6Ge0txjmqpPwqD8vx6onQjynO9JT73ArCYiYPvSrwYXepH/UY/WdKDY8JPWJl72sAE4iGMHPeQ5xdEWg==}
'@next/swc-darwin-arm64@15.0.2': '@next/swc-darwin-arm64@15.0.3':
resolution: {integrity: sha512-GK+8w88z+AFlmt+ondytZo2xpwlfAR8U6CRwXancHImh6EdGfHMIrTSCcx5sOSBei00GyLVL0ioo1JLKTfprgg==} resolution: {integrity: sha512-s3Q/NOorCsLYdCKvQlWU+a+GeAd3C8Rb3L1YnetsgwXzhc3UTWrtQpB/3eCjFOdGUj5QmXfRak12uocd1ZiiQw==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@next/swc-darwin-x64@15.0.2': '@next/swc-darwin-x64@15.0.3':
resolution: {integrity: sha512-KUpBVxIbjzFiUZhiLIpJiBoelqzQtVZbdNNsehhUn36e2YzKHphnK8eTUW1s/4aPy5kH/UTid8IuVbaOpedhpw==} resolution: {integrity: sha512-Zxl/TwyXVZPCFSf0u2BNj5sE0F2uR6iSKxWpq4Wlk/Sv9Ob6YCKByQTkV2y6BCic+fkabp9190hyrDdPA/dNrw==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
'@next/swc-linux-arm64-gnu@15.0.2': '@next/swc-linux-arm64-gnu@15.0.3':
resolution: {integrity: sha512-9J7TPEcHNAZvwxXRzOtiUvwtTD+fmuY0l7RErf8Yyc7kMpE47MIQakl+3jecmkhOoIyi/Rp+ddq7j4wG6JDskQ==} resolution: {integrity: sha512-T5+gg2EwpsY3OoaLxUIofmMb7ohAUlcNZW0fPQ6YAutaWJaxt1Z1h+8zdl4FRIOr5ABAAhXtBcpkZNwUcKI2fw==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@next/swc-linux-arm64-musl@15.0.2': '@next/swc-linux-arm64-musl@15.0.3':
resolution: {integrity: sha512-BjH4ZSzJIoTTZRh6rG+a/Ry4SW0HlizcPorqNBixBWc3wtQtj4Sn9FnRZe22QqrPnzoaW0ctvSz4FaH4eGKMww==} resolution: {integrity: sha512-WkAk6R60mwDjH4lG/JBpb2xHl2/0Vj0ZRu1TIzWuOYfQ9tt9NFsIinI1Epma77JVgy81F32X/AeD+B2cBu/YQA==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@next/swc-linux-x64-gnu@15.0.2': '@next/swc-linux-x64-gnu@15.0.3':
resolution: {integrity: sha512-i3U2TcHgo26sIhcwX/Rshz6avM6nizrZPvrDVDY1bXcLH1ndjbO8zuC7RoHp0NSK7wjJMPYzm7NYL1ksSKFreA==} resolution: {integrity: sha512-gWL/Cta1aPVqIGgDb6nxkqy06DkwJ9gAnKORdHWX1QBbSZZB+biFYPFti8aKIQL7otCE1pjyPaXpFzGeG2OS2w==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@next/swc-linux-x64-musl@15.0.2': '@next/swc-linux-x64-musl@15.0.3':
resolution: {integrity: sha512-AMfZfSVOIR8fa+TXlAooByEF4OB00wqnms1sJ1v+iu8ivwvtPvnkwdzzFMpsK5jA2S9oNeeQ04egIWVb4QWmtQ==} resolution: {integrity: sha512-QQEMwFd8r7C0GxQS62Zcdy6GKx999I/rTO2ubdXEe+MlZk9ZiinsrjwoiBL5/57tfyjikgh6GOU2WRQVUej3UA==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@next/swc-win32-arm64-msvc@15.0.2': '@next/swc-win32-arm64-msvc@15.0.3':
resolution: {integrity: sha512-JkXysDT0/hEY47O+Hvs8PbZAeiCQVxKfGtr4GUpNAhlG2E0Mkjibuo8ryGD29Qb5a3IOnKYNoZlh/MyKd2Nbww==} resolution: {integrity: sha512-9TEp47AAd/ms9fPNgtgnT7F3M1Hf7koIYYWCMQ9neOwjbVWJsHZxrFbI3iEDJ8rf1TDGpmHbKxXf2IFpAvheIQ==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
'@next/swc-win32-x64-msvc@15.0.2': '@next/swc-win32-x64-msvc@15.0.3':
resolution: {integrity: sha512-foaUL0NqJY/dX0Pi/UcZm5zsmSk5MtP/gxx3xOPyREkMFN+CTjctPfu3QaqrQHinaKdPnMWPJDKt4VjDfTBe/Q==} resolution: {integrity: sha512-VNAz+HN4OGgvZs6MOoVfnn41kBzT+M+tB+OK4cww6DNyWS6wKaDpaAm/qLeOUbnMh0oVx1+mg0uoYARF69dJyA==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
@@ -1316,6 +1319,32 @@ packages:
resolution: {integrity: sha512-h8vYOulWec9LhpwfAdZf2bjr8xIp0KNKnpgqSz0qqYYKAW/QZKw3ktRndbiAtUz4acH4QLQavwZBYCc0wulA/Q==} resolution: {integrity: sha512-h8vYOulWec9LhpwfAdZf2bjr8xIp0KNKnpgqSz0qqYYKAW/QZKw3ktRndbiAtUz4acH4QLQavwZBYCc0wulA/Q==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 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': '@vercel/postgres@0.10.0':
resolution: {integrity: sha512-fSD23DxGND40IzSkXjcFcxr53t3Tiym59Is0jSYIFpG4/0f0KO9SGtcp1sXiebvPaGe7N/tU05cH4yt2S6/IPg==} resolution: {integrity: sha512-fSD23DxGND40IzSkXjcFcxr53t3Tiym59Is0jSYIFpG4/0f0KO9SGtcp1sXiebvPaGe7N/tU05cH4yt2S6/IPg==}
engines: {node: '>=18.14'} engines: {node: '>=18.14'}
@@ -2352,16 +2381,16 @@ packages:
natural-compare@1.4.0: natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
next@15.0.2: next@15.0.3:
resolution: {integrity: sha512-rxIWHcAu4gGSDmwsELXacqAPUk+j8dV/A9cDF5fsiCMpkBDYkO2AEaL1dfD+nNmDiU6QMCFN8Q30VEKapT9UHQ==} resolution: {integrity: sha512-ontCbCRKJUIoivAdGB34yCaOcPgYXr9AAkV/IwqFfWWTXEPUgLYkSkqBhIk9KK7gGmgjc64B+RdoeIDM13Irnw==}
engines: {node: '>=18.18.0'} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
hasBin: true hasBin: true
peerDependencies: peerDependencies:
'@opentelemetry/api': ^1.1.0 '@opentelemetry/api': ^1.1.0
'@playwright/test': ^1.41.2 '@playwright/test': ^1.41.2
babel-plugin-react-compiler: '*' babel-plugin-react-compiler: '*'
react: ^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-02c0e824-20241028 react-dom: ^18.2.0 || 19.0.0-rc-66855b96-20241106
sass: ^1.3.0 sass: ^1.3.0
peerDependenciesMeta: peerDependenciesMeta:
'@opentelemetry/api': '@opentelemetry/api':
@@ -3079,15 +3108,15 @@ snapshots:
react-dom: 18.3.1(react@18.3.1) react-dom: 18.3.1(react@18.3.1)
tslib: 2.4.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: dependencies:
'@clerk/backend': 1.17.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@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/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/shared': 2.15.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@clerk/types': 4.34.0 '@clerk/types': 4.34.0
crypto-js: 4.2.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)) ezheaders: 0.1.0(next@15.0.3(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) next: 15.0.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1 react: 18.3.1
react-dom: 18.3.1(react@18.3.1) react-dom: 18.3.1(react@18.3.1)
server-only: 0.0.1 server-only: 0.0.1
@@ -3507,34 +3536,34 @@ snapshots:
dependencies: dependencies:
'@types/pg': 8.11.6 '@types/pg': 8.11.6
'@next/env@15.0.2': {} '@next/env@15.0.3': {}
'@next/eslint-plugin-next@15.0.2': '@next/eslint-plugin-next@15.0.2':
dependencies: dependencies:
fast-glob: 3.3.1 fast-glob: 3.3.1
'@next/swc-darwin-arm64@15.0.2': '@next/swc-darwin-arm64@15.0.3':
optional: true optional: true
'@next/swc-darwin-x64@15.0.2': '@next/swc-darwin-x64@15.0.3':
optional: true optional: true
'@next/swc-linux-arm64-gnu@15.0.2': '@next/swc-linux-arm64-gnu@15.0.3':
optional: true optional: true
'@next/swc-linux-arm64-musl@15.0.2': '@next/swc-linux-arm64-musl@15.0.3':
optional: true optional: true
'@next/swc-linux-x64-gnu@15.0.2': '@next/swc-linux-x64-gnu@15.0.3':
optional: true optional: true
'@next/swc-linux-x64-musl@15.0.2': '@next/swc-linux-x64-musl@15.0.3':
optional: true optional: true
'@next/swc-win32-arm64-msvc@15.0.2': '@next/swc-win32-arm64-msvc@15.0.3':
optional: true optional: true
'@next/swc-win32-x64-msvc@15.0.2': '@next/swc-win32-x64-msvc@15.0.3':
optional: true optional: true
'@nodelib/fs.scandir@2.1.5': '@nodelib/fs.scandir@2.1.5':
@@ -4015,6 +4044,11 @@ snapshots:
'@typescript-eslint/types': 8.15.0 '@typescript-eslint/types': 8.15.0
eslint-visitor-keys: 4.2.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': '@vercel/postgres@0.10.0':
dependencies: dependencies:
'@neondatabase/serverless': 0.9.5 '@neondatabase/serverless': 0.9.5
@@ -4758,9 +4792,9 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - 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: 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: {} fast-deep-equal@3.1.3: {}
@@ -5188,9 +5222,9 @@ snapshots:
natural-compare@1.4.0: {} 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: dependencies:
'@next/env': 15.0.2 '@next/env': 15.0.3
'@swc/counter': 0.1.3 '@swc/counter': 0.1.3
'@swc/helpers': 0.5.13 '@swc/helpers': 0.5.13
busboy: 1.6.0 busboy: 1.6.0
@@ -5200,14 +5234,14 @@ snapshots:
react-dom: 18.3.1(react@18.3.1) react-dom: 18.3.1(react@18.3.1)
styled-jsx: 5.1.6(react@18.3.1) styled-jsx: 5.1.6(react@18.3.1)
optionalDependencies: optionalDependencies:
'@next/swc-darwin-arm64': 15.0.2 '@next/swc-darwin-arm64': 15.0.3
'@next/swc-darwin-x64': 15.0.2 '@next/swc-darwin-x64': 15.0.3
'@next/swc-linux-arm64-gnu': 15.0.2 '@next/swc-linux-arm64-gnu': 15.0.3
'@next/swc-linux-arm64-musl': 15.0.2 '@next/swc-linux-arm64-musl': 15.0.3
'@next/swc-linux-x64-gnu': 15.0.2 '@next/swc-linux-x64-gnu': 15.0.3
'@next/swc-linux-x64-musl': 15.0.2 '@next/swc-linux-x64-musl': 15.0.3
'@next/swc-win32-arm64-msvc': 15.0.2 '@next/swc-win32-arm64-msvc': 15.0.3
'@next/swc-win32-x64-msvc': 15.0.2 '@next/swc-win32-x64-msvc': 15.0.3
sharp: 0.33.5 sharp: 0.33.5
transitivePeerDependencies: transitivePeerDependencies:
- '@babel/core' - '@babel/core'

View File

@@ -3,46 +3,47 @@
/* tslint:disable */ /* tslint:disable */
import { eq } from "drizzle-orm"; import { eq } 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 { db } from "~/db";
import { invitationsTable } from "~/db/schema"; 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 } }) { 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 { try {
const { id } = params;
const invitationId = parseInt(id, 10); const invitationId = parseInt(id, 10);
if (isNaN(invitationId)) { if (isNaN(invitationId)) {
return NextResponse.json( return ApiError.BadRequest("Invalid invitation ID");
{ error: "Invalid invitation ID" }, }
{ status: 400 }
); // 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 await db
.delete(invitationsTable) .delete(invitationsTable)
.where(eq(invitationsTable.id, invitationId)); .where(eq(invitationsTable.id, invitationId));
return NextResponse.json( return createApiResponse({ message: "Invitation deleted successfully" });
{ message: "Invitation deleted successfully" },
{ status: 200 }
);
} catch (error) { } catch (error) {
console.error("Error deleting invitation:", error); return ApiError.ServerError(error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
} }
} }

View File

@@ -1,18 +1,16 @@
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest } from "next/server";
import { auth } from "@clerk/nextjs/server"; import { auth } from "@clerk/nextjs/server";
import { db } from "~/db"; 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 } }) { export async function POST(req: NextRequest, { params }: { params: { token: string } }) {
const { userId } = await auth(); const { userId } = await auth();
const { token } = params; const { token } = params;
if (!userId) { if (!userId) {
return NextResponse.json( return ApiError.Unauthorized();
{ error: "Unauthorized" },
{ status: 401 }
);
} }
try { try {
@@ -29,30 +27,37 @@ export async function POST(req: NextRequest, { params }: { params: { token: stri
.limit(1); .limit(1);
if (!invitation) { if (!invitation) {
return NextResponse.json( return ApiError.NotFound("Invitation");
{ error: "Invalid or expired invitation" },
{ status: 404 }
);
} }
// Update the invitation // Check if invitation has expired
await db if (new Date() > invitation.expiresAt) {
.update(invitationsTable) return ApiError.BadRequest("Invitation has expired");
.set({ }
accepted: true,
acceptedByUserId: userId,
})
.where(eq(invitationsTable.id, invitation.id));
return NextResponse.json( // Assign role and mark invitation as accepted in a transaction
{ message: "Invitation accepted successfully" }, await db.transaction(async (tx) => {
{ status: 200 } // 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) { } catch (error) {
console.error("Error accepting invitation:", error); return ApiError.ServerError(error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
} }
} }

View File

@@ -2,10 +2,11 @@ import { NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server"; import { auth } from "@clerk/nextjs/server";
import { db } from "~/db"; import { db } from "~/db";
import { invitationsTable, studyTable, rolesTable } from "~/db/schema"; import { invitationsTable, studyTable, rolesTable } from "~/db/schema";
import { eq, and } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { randomBytes } from "crypto"; import { randomBytes } from "crypto";
import { sendInvitationEmail } from "~/lib/email"; 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 // Helper to generate a secure random token
function generateToken(): string { function generateToken(): string {
@@ -13,24 +14,21 @@ function generateToken(): string {
} }
export async function GET(request: Request) { export async function GET(request: Request) {
const { userId } = await auth();
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try { try {
const url = new URL(request.url); const url = new URL(request.url);
const studyId = url.searchParams.get("studyId"); const studyId = url.searchParams.get("studyId");
if (!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 permissionCheck = await checkPermissions({
const hasAccess = await hasStudyAccess(userId, parseInt(studyId)); studyId: parseInt(studyId),
if (!hasAccess) { permission: PERMISSIONS.MANAGE_ROLES
return NextResponse.json({ error: "Study not found" }, { status: 404 }); });
if (permissionCheck.error) {
return permissionCheck.error;
} }
// Get all invitations for the study, including role names // 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)) .innerJoin(rolesTable, eq(invitationsTable.roleId, rolesTable.id))
.where(eq(invitationsTable.studyId, parseInt(studyId))); .where(eq(invitationsTable.studyId, parseInt(studyId)));
return NextResponse.json(invitations); return createApiResponse(invitations);
} catch (error) { } catch (error) {
console.error("Error fetching invitations:", error); return ApiError.ServerError(error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
} }
} }
export async function POST(request: Request) { export async function POST(request: Request) {
const { userId } = await auth();
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try { try {
const { email, studyId, roleId } = await request.json(); const { email, studyId, roleId } = await request.json();
// First check if user has access to the study const permissionCheck = await checkPermissions({
const hasAccess = await hasStudyAccess(userId, studyId); studyId,
if (!hasAccess) { permission: PERMISSIONS.MANAGE_ROLES
return NextResponse.json({ error: "Study not found" }, { status: 404 }); });
if (permissionCheck.error) {
return permissionCheck.error;
} }
// Then check if user has permission to invite users const { userId } = permissionCheck;
const canInvite = await hasPermission(userId, PERMISSIONS.MANAGE_ROLES, studyId);
if (!canInvite) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
// Get study details // Get study details
const study = await db const study = await db
@@ -87,7 +74,7 @@ export async function POST(request: Request) {
.limit(1); .limit(1);
if (!study[0]) { if (!study[0]) {
return NextResponse.json({ error: "Study not found" }, { status: 404 }); return ApiError.NotFound("Study");
} }
// Verify the role exists // Verify the role exists
@@ -98,7 +85,7 @@ export async function POST(request: Request) {
.limit(1); .limit(1);
if (!role[0]) { if (!role[0]) {
return NextResponse.json({ error: "Invalid role" }, { status: 400 }); return ApiError.BadRequest("Invalid role");
} }
// Generate invitation token // Generate invitation token
@@ -128,12 +115,8 @@ export async function POST(request: Request) {
token, token,
}); });
return NextResponse.json(invitation); return createApiResponse(invitation);
} catch (error) { } catch (error) {
console.error("Error creating invitation:", error); return ApiError.ServerError(error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
} }
} }

View File

@@ -1,19 +1,29 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/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() { export async function GET() {
const { userId } = await auth(); const { userId } = await auth();
if (!userId) { if (!userId) {
return new NextResponse("Unauthorized", { status: 401 }); return ApiError.Unauthorized();
} }
try { try {
const permissions = await getUserPermissions(userId); const permissions = await db
return NextResponse.json(permissions); .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) { } catch (error) {
console.error("Error fetching permissions:", error); return ApiError.ServerError(error);
return new NextResponse("Internal Server Error", { status: 500 });
} }
} }

View File

@@ -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);
}
}

View File

@@ -1,45 +1,58 @@
import { eq } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import { NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server";
import { db } from "~/db"; import { db } from "~/db";
import { studyTable } from "~/db/schema"; import { studyTable, userRolesTable, rolePermissionsTable, permissionsTable } from "~/db/schema";
import { hasStudyAccess } from "~/lib/permissions"; import { PERMISSIONS, checkPermissions } from "~/lib/permissions-server";
import { ApiError, createApiResponse } from "~/lib/api-utils";
export async function GET(
request: Request,
context: { params: { id: string } }
) {
const { userId } = await auth();
if (!userId) {
return new NextResponse("Unauthorized", { status: 401 });
}
export async function GET(request: Request, { params }: { params: { id: string } }) {
try { try {
// Properly await and destructure params const { id } = params;
const { id } = await context.params;
const studyId = parseInt(id); const studyId = parseInt(id);
// Check if user has access to this study if (isNaN(studyId)) {
const hasAccess = await hasStudyAccess(userId, studyId); return ApiError.BadRequest("Invalid study ID");
if (!hasAccess) {
return new NextResponse("Forbidden", { status: 403 });
} }
// Get study details const permissionCheck = await checkPermissions({
const study = await db studyId,
.select() 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) .from(studyTable)
.where(eq(studyTable.id, studyId)) .leftJoin(userRolesTable, eq(userRolesTable.studyId, studyTable.id))
.limit(1); .leftJoin(rolePermissionsTable, eq(rolePermissionsTable.roleId, userRolesTable.roleId))
.leftJoin(permissionsTable, eq(permissionsTable.id, rolePermissionsTable.permissionId))
.where(eq(studyTable.id, studyId));
if (!study[0]) { if (!studyWithPermissions.length) {
return new NextResponse("Study not found", { status: 404 }); 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) { } catch (error) {
console.error("Error fetching study:", error); return ApiError.ServerError(error);
return new NextResponse("Internal Server Error", { status: 500 });
} }
} }

View File

@@ -1,78 +1,146 @@
import { eq, or } from "drizzle-orm"; import { eq, and, or } from "drizzle-orm";
import { NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server";
import { db } from "~/db"; 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() { export async function GET() {
const { userId } = await auth(); const { userId } = await auth();
if (!userId) { if (!userId) {
return new NextResponse("Unauthorized", { status: 401 }); return ApiError.Unauthorized();
} }
// Get all studies where user is either the owner or has a role try {
const studies = await db const currentEnvironment = getEnvironment();
.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);
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) { export async function POST(request: Request) {
const { userId } = await auth(); const { userId } = await auth();
if (!userId) { if (!userId) {
return new Response(JSON.stringify({ error: "Unauthorized" }), { return ApiError.Unauthorized();
status: 401,
headers: { 'Content-Type': 'application/json' }
});
} }
try { try {
// Verify user exists first
const existingUser = await db
.select()
.from(usersTable)
.where(eq(usersTable.id, userId));
const { title, description } = await request.json(); const { title, description } = await request.json();
const currentEnvironment = getEnvironment();
const study = await db // Create study and assign admin role in a transaction
.insert(studyTable) const result = await db.transaction(async (tx) => {
.values({ // Create the study
title, const [study] = await tx
description, .insert(studyTable)
userId: userId, .values({
}) title,
.returning(); description,
userId: userId,
environment: currentEnvironment,
})
.returning();
return new Response(JSON.stringify(study[0]), { // Look up the ADMIN role
status: 200, const [adminRole] = await tx
headers: { 'Content-Type': 'application/json' } .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) { } catch (error) {
return new Response(JSON.stringify({ return ApiError.ServerError(error);
error: "Failed to create study",
details: error instanceof Error ? error.message : 'Unknown error'
}), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
} }
} }

View File

@@ -72,7 +72,7 @@ export default function Dashboard() {
<BookOpen className="h-4 w-4 text-muted-foreground" /> <BookOpen className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">{stats.studyCount}</div> <div className="text-2xl font-bold">{stats.studyCount ? stats.studyCount : 0}</div>
<p className="text-xs text-muted-foreground">Active research studies</p> <p className="text-xs text-muted-foreground">Active research studies</p>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -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<Study[]>([]);
const [participants, setParticipants] = useState<Participant[]>([]);
const [selectedStudyId, setSelectedStudyId] = useState<number | null>(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 (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full" />
</div>
);
}
return (
<div className="container py-6 space-y-6">
<div>
<h1 className="text-2xl font-bold">Participants</h1>
<p className="text-muted-foreground">Manage study participants</p>
</div>
<Card>
<CardHeader>
<CardTitle>Study Selection</CardTitle>
<CardDescription>
Select a study to manage its participants
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label htmlFor="study">Select Study</Label>
<Select onValueChange={handleStudyChange}>
<SelectTrigger>
<SelectValue placeholder="Select a study" />
</SelectTrigger>
<SelectContent>
{studies.map((study) => (
<SelectItem key={study.id} value={study.id.toString()}>
{study.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{selectedStudyId && (
<Card>
<CardHeader>
<CardTitle>Add New Participant</CardTitle>
<CardDescription>
Add a new participant to the selected study
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={addParticipant} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Participant Name</Label>
<Input
type="text"
id="name"
value={participantName}
onChange={(e) => setParticipantName(e.target.value)}
placeholder="Enter participant name"
required
/>
</div>
<Button type="submit">
<PlusIcon className="w-4 h-4 mr-2" />
Add Participant
</Button>
</form>
</CardContent>
</Card>
)}
{selectedStudyId && participants.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Participants List</CardTitle>
<CardDescription>
Manage existing participants
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{participants.map((participant) => (
<div
key={participant.id}
className="flex items-center justify-between p-4 border rounded-lg bg-card"
>
<span className="font-medium">{participant.name}</span>
<Button
variant="outline"
size="sm"
onClick={() => deleteParticipant(participant.id)}
>
<Trash2Icon className="w-4 h-4 mr-2" />
Delete
</Button>
</div>
))}
</div>
</CardContent>
</Card>
)}
{selectedStudyId && participants.length === 0 && (
<Card>
<CardContent className="py-8">
<p className="text-center text-muted-foreground">
No participants added yet. Add your first participant above.
</p>
</CardContent>
</Card>
)}
{!selectedStudyId && (
<Card>
<CardContent className="py-8">
<p className="text-center text-muted-foreground">
Please select a study to view its participants.
</p>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -1,201 +1,79 @@
'use client'; 'use client';
import { useEffect, useState } from "react"; import { useState } from "react";
import { useParams } from "next/navigation"; import { useParams, useRouter, useSearchParams } from "next/navigation";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { InviteUserDialog } from "~/components/invite-user-dialog"; import { SettingsTab } from "~/components/studies/settings-tab";
import { Button } from "~/components/ui/button"; import { ParticipantsTab } from "~/components/studies/participants-tab";
import { Loader2 } from "lucide-react"; import { useEffect } from "react";
import { useToast } from "~/hooks/use-toast";
interface Study { interface Study {
id: number; id: number;
title: string; title: string;
description: string; description: string | null;
permissions: string[];
} }
interface Invitation { export default function StudySettings() {
id: string;
email: string;
roleName: string;
accepted: boolean;
expiresAt: string;
}
export default function StudySettingsPage() {
const params = useParams();
const [study, setStudy] = useState<Study | null>(null); const [study, setStudy] = useState<Study | null>(null);
const [invitations, setInvitations] = useState<Invitation[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const { toast } = useToast(); const { id } = useParams();
const router = useRouter();
const fetchStudyData = async () => { const searchParams = useSearchParams();
try { const tab = searchParams.get('tab') || 'settings';
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);
}
};
useEffect(() => { useEffect(() => {
const loadData = async () => { const fetchStudy = async () => {
await Promise.all([fetchStudyData(), fetchInvitations()]); try {
}; const response = await fetch(`/api/studies/${id}`);
loadData(); if (!response.ok) throw new Error("Failed to fetch study");
}, [params.id]); // eslint-disable-line react-hooks/exhaustive-deps const data = await response.json();
setStudy(data.data);
const handleDeleteInvitation = async (invitationId: string) => { } catch (error) {
try { console.error("Error fetching study:", error);
const response = await fetch(`/api/invitations/${invitationId}`, { setError(error instanceof Error ? error.message : "Failed to load study");
method: "DELETE", } finally {
}); setIsLoading(false);
if (!response.ok) {
throw new Error("Failed to delete invitation");
} }
};
// Update local state fetchStudy();
setInvitations(invitations.filter(inv => inv.id !== invitationId)); }, [id]);
toast({ const handleTabChange = (value: string) => {
title: "Success", router.push(`/dashboard/studies/${id}/settings?tab=${value}`);
description: "Invitation deleted successfully",
});
} catch (error) {
console.error("Error deleting invitation:", error);
toast({
title: "Error",
description: "Failed to delete invitation",
variant: "destructive",
});
}
}; };
if (isLoading) { if (isLoading) {
return ( return <div className="container py-6">Loading...</div>;
<div className="flex items-center justify-center min-h-[400px]">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
);
} }
if (error) { if (error || !study) {
return ( return <div className="container py-6 text-destructive">{error || "Study not found"}</div>;
<div className="flex items-center justify-center min-h-[400px]">
<p className="text-red-500">{error}</p>
</div>
);
}
if (!study) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<p className="text-gray-500">Study not found</p>
</div>
);
} }
return ( return (
<div className="container py-6 space-y-6"> <div className="container py-6 space-y-6">
<div> <div className="flex flex-col gap-2">
<h1 className="text-2xl font-bold">{study.title}</h1> <h1 className="text-3xl font-bold tracking-tight">{study.title}</h1>
<p className="text-muted-foreground">{study.description}</p> <p className="text-muted-foreground">
Manage study settings and participants
</p>
</div> </div>
<Tabs defaultValue="invites" className="space-y-4"> <Tabs value={tab} onValueChange={handleTabChange} className="space-y-6">
<TabsList> <TabsList>
<TabsTrigger value="invites">Invites</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger> <TabsTrigger value="settings">Settings</TabsTrigger>
<TabsTrigger value="participants">Participants</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="invites"> <TabsContent value="settings" className="space-y-6">
<Card> <SettingsTab study={study} />
<CardHeader>
<CardTitle>Manage Invitations</CardTitle>
<CardDescription>
Invite researchers and participants to collaborate on &ldquo;{study.title}&rdquo;
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div>
<InviteUserDialog studyId={study.id} onInviteSent={fetchInvitations} />
</div>
{invitations.length > 0 ? (
<div className="space-y-4">
{invitations.map((invitation) => (
<div
key={invitation.id}
className="flex items-center justify-between p-4 border rounded-lg bg-card"
>
<div>
<p className="font-medium">{invitation.email}</p>
<p className="text-sm text-muted-foreground">
Role: {invitation.roleName}
{invitation.accepted ? " • Accepted" : " • Pending"}
</p>
</div>
{!invitation.accepted && (
<Button
variant="outline"
size="sm"
onClick={() => handleDeleteInvitation(invitation.id)}
>
Cancel
</Button>
)}
</div>
))}
</div>
) : (
<p className="text-muted-foreground">No invitations sent yet.</p>
)}
</CardContent>
</Card>
</TabsContent> </TabsContent>
<TabsContent value="settings"> <TabsContent value="participants" className="space-y-6">
<Card> <ParticipantsTab studyId={study.id} permissions={study.permissions} />
<CardHeader>
<CardTitle>Study Settings</CardTitle>
<CardDescription>
Configure study settings and permissions
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">Settings coming soon...</p>
</CardContent>
</Card>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</div> </div>

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useEffect, useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { PlusIcon, Trash2Icon, Settings2 } from "lucide-react"; import { PlusIcon, Trash2Icon, Settings2 } from "lucide-react";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
@@ -9,73 +9,86 @@ import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label"; import { Label } from "~/components/ui/label";
import { Textarea } from "~/components/ui/textarea"; import { Textarea } from "~/components/ui/textarea";
import { useToast } from "~/hooks/use-toast"; 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 { interface Study {
id: number; id: number;
title: string; title: string;
description: string; description: string | null;
createdAt: string; 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() { export default function Studies() {
const [studies, setStudies] = useState<Study[]>([]); const [studies, setStudies] = useState<Study[]>([]);
const [newStudyTitle, setNewStudyTitle] = useState(""); const [title, setTitle] = useState("");
const [newStudyDescription, setNewStudyDescription] = useState(""); const [description, setDescription] = useState("");
const [loading, setLoading] = useState(true);
const router = useRouter(); const router = useRouter();
const { toast } = useToast(); const { toast } = useToast();
useEffect(() => {
fetchStudies();
}, []);
const fetchStudies = async () => { const fetchStudies = async () => {
try { 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(); const data = await response.json();
setStudies(data); setStudies(data.data || []);
} catch (error) { } catch (error) {
console.error('Error fetching studies:', error); console.error("Error fetching studies:", error);
toast({ toast({
title: "Error", title: "Error",
description: "Failed to load studies", description: "Failed to load studies",
variant: "destructive", variant: "destructive",
}); });
} finally {
setLoading(false);
} }
}; };
const createStudy = async (e: React.FormEvent) => { const createStudy = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
try { try {
const response = await fetch('/api/studies', { const response = await fetch("/api/studies", {
method: 'POST', method: "POST",
headers: { headers: {
'Content-Type': 'application/json', "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({ title, description }),
title: newStudyTitle,
description: newStudyDescription,
}),
}); });
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to create study'); throw new Error("Failed to create study");
} }
const newStudy = await response.json(); const data = await response.json();
setStudies([...studies, newStudy]); setStudies([...studies, data.data]);
setNewStudyTitle(""); setTitle("");
setNewStudyDescription(""); setDescription("");
toast({ toast({
title: "Success", title: "Success",
description: "Study created successfully", description: "Study created successfully",
}); });
} catch (error) { } catch (error) {
console.error('Error creating study:', error); console.error("Error creating study:", error);
toast({ toast({
title: "Error", title: "Error",
description: "Failed to create study", description: "Failed to create study",
@@ -87,11 +100,11 @@ export default function Studies() {
const deleteStudy = async (id: number) => { const deleteStudy = async (id: number) => {
try { try {
const response = await fetch(`/api/studies/${id}`, { const response = await fetch(`/api/studies/${id}`, {
method: 'DELETE', method: "DELETE",
}); });
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to delete study'); throw new Error("Failed to delete study");
} }
setStudies(studies.filter(study => study.id !== id)); setStudies(studies.filter(study => study.id !== id));
@@ -100,7 +113,7 @@ export default function Studies() {
description: "Study deleted successfully", description: "Study deleted successfully",
}); });
} catch (error) { } catch (error) {
console.error('Error deleting study:', error); console.error("Error deleting study:", error);
toast({ toast({
title: "Error", title: "Error",
description: "Failed to delete study", description: "Failed to delete study",
@@ -109,85 +122,128 @@ export default function Studies() {
} }
}; };
if (loading) { // Fetch studies on mount
return ( useState(() => {
<div className="flex items-center justify-center min-h-[400px]"> fetchStudies();
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full" /> });
</div>
);
}
return ( return (
<div className="container py-6 space-y-6"> <div className="container py-6 space-y-6">
<div> <div className="flex flex-col gap-2">
<h1 className="text-2xl font-bold">Studies</h1> <h1 className="text-3xl font-bold tracking-tight">Studies</h1>
<p className="text-muted-foreground">Manage your research studies</p> <p className="text-muted-foreground">
Manage your research studies and experiments
</p>
</div> </div>
<Card> {hasPermission(studies[0]?.permissions || [], PERMISSIONS.CREATE_STUDY) && (
<CardHeader> <Card>
<CardTitle>Create New Study</CardTitle> <CardHeader>
<CardDescription> <CardTitle>Create New Study</CardTitle>
Add a new research study to your collection <CardDescription>Add a new research study to your collection</CardDescription>
</CardDescription> </CardHeader>
</CardHeader> <CardContent>
<CardContent> <form onSubmit={createStudy} className="space-y-4">
<form onSubmit={createStudy} className="space-y-4"> <div className="space-y-2">
<div className="space-y-2"> <Label htmlFor="title">Study Title</Label>
<Label htmlFor="title">Study Title</Label> <Input
<Input id="title"
type="text" value={title}
id="title" onChange={(e) => setTitle(e.target.value)}
value={newStudyTitle} placeholder="Enter study title"
onChange={(e) => setNewStudyTitle(e.target.value)} required
placeholder="Enter study title" />
required </div>
/> <div className="space-y-2">
</div> <Label htmlFor="description">Description</Label>
<div className="space-y-2"> <Textarea
<Label htmlFor="description">Description</Label> id="description"
<Textarea value={description}
id="description" onChange={(e) => setDescription(e.target.value)}
value={newStudyDescription} placeholder="Enter study description"
onChange={(e) => setNewStudyDescription(e.target.value)} />
placeholder="Enter study description" </div>
rows={3} <Button type="submit">
/> <PlusIcon className="w-4 h-4 mr-2" />
</div> Create Study
<Button type="submit"> </Button>
<PlusIcon className="w-4 h-4 mr-2" /> </form>
Create Study </CardContent>
</Button> </Card>
</form> )}
</CardContent>
</Card>
<div className="grid gap-4"> <div className="grid gap-4">
{studies.length > 0 ? ( {studies.length > 0 ? (
studies.map((study) => ( studies.map((study) => (
<Card key={study.id}> <Card key={study.id} className="overflow-hidden">
<CardHeader> <div className="p-6">
<CardTitle>{study.title}</CardTitle> <div className="flex flex-col gap-1.5">
<CardDescription>{study.description}</CardDescription> <div className="flex items-start justify-between">
</CardHeader> <div className="space-y-1">
<CardContent> <h3 className="font-semibold leading-none tracking-tight">
<div className="flex items-center gap-2"> {study.title}
<Button </h3>
variant="outline" <p className="text-sm text-muted-foreground">
onClick={() => router.push(`/dashboard/studies/${study.id}/settings`)} {study.description || "No description provided."}
> </p>
<Settings2 className="w-4 h-4 mr-2" /> <p className="text-sm">
Settings <span className="text-muted-foreground">Your Roles: </span>
</Button> <span className="text-foreground">
<Button {study.roles?.map(formatRoleName).join(", ")}
variant="outline" </span>
onClick={() => deleteStudy(study.id)} </p>
> </div>
<Trash2Icon className="w-4 h-4 mr-2" /> <div className="flex items-center gap-2">
Delete {(hasPermission(study.permissions, PERMISSIONS.EDIT_STUDY) ||
</Button> hasPermission(study.permissions, PERMISSIONS.MANAGE_ROLES)) && (
<Button
variant="outline"
size="sm"
onClick={() => router.push(`/dashboard/studies/${study.id}/settings`)}
>
<Settings2 className="w-4 h-4 mr-2" />
Settings
</Button>
)}
{hasPermission(study.permissions, PERMISSIONS.MANAGE_SYSTEM_SETTINGS) && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm">
<Trash2Icon className="w-4 h-4 mr-2" />
Delete
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<div className="text-sm text-muted-foreground">
<p className="mb-2">
This action cannot be undone. This will permanently delete the study
&quot;{study.title}&quot; and all associated data including:
</p>
<ul className="list-disc list-inside">
<li>All participant data</li>
<li>All user roles and permissions</li>
<li>All pending invitations</li>
</ul>
</div>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteStudy(study.id)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete Study
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
</div>
</div> </div>
</CardContent> </div>
</Card> </Card>
)) ))
) : ( ) : (

View File

@@ -1,6 +1,7 @@
import { import {
ClerkProvider ClerkProvider
} from '@clerk/nextjs'; } from '@clerk/nextjs';
import { Analytics } from "@vercel/analytics/react"
import { Inter } from 'next/font/google'; import { Inter } from 'next/font/google';
import './globals.css'; import './globals.css';
import { Metadata } from 'next'; import { Metadata } from 'next';
@@ -24,6 +25,7 @@ export default function RootLayout({
}) { }) {
return ( return (
<ClerkProvider> <ClerkProvider>
<Analytics />
<html lang="en"> <html lang="en">
<body className={inter.className}> <body className={inter.className}>
{children} {children}

View File

@@ -23,7 +23,6 @@ import { Logo } from "~/components/logo"
const navItems = [ const navItems = [
{ name: "Dashboard", href: "/dashboard", icon: LayoutDashboard }, { name: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
{ name: "Studies", href: "/dashboard/studies", icon: FolderIcon }, { name: "Studies", href: "/dashboard/studies", icon: FolderIcon },
{ name: "Participants", href: "/dashboard/participants", icon: UsersRoundIcon },
{ name: "Trials", href: "/dashboard/trials", icon: LandPlotIcon }, { name: "Trials", href: "/dashboard/trials", icon: LandPlotIcon },
{ name: "Forms", href: "/dashboard/forms", icon: FileTextIcon }, { name: "Forms", href: "/dashboard/forms", icon: FileTextIcon },
{ name: "Data Analysis", href: "/dashboard/analysis", icon: BarChartIcon }, { name: "Data Analysis", href: "/dashboard/analysis", icon: BarChartIcon },

View File

@@ -0,0 +1,233 @@
'use client';
import { useState, useEffect } from "react";
import { PlusIcon, Trash2Icon } from "lucide-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 { useToast } from "~/hooks/use-toast";
import { PERMISSIONS } from "~/lib/permissions-client";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
AlertDialogFooter
} from "~/components/ui/alert-dialog";
interface Participant {
id: number;
name: string;
studyId: number;
createdAt: string;
}
interface ParticipantsTabProps {
studyId: number;
permissions: string[];
}
export function ParticipantsTab({ studyId, permissions }: ParticipantsTabProps) {
const [participants, setParticipants] = useState<Participant[]>([]);
const [name, setName] = useState("");
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { toast } = useToast();
useEffect(() => {
fetchParticipants();
}, [studyId]);
const fetchParticipants = async () => {
try {
const response = await fetch(`/api/studies/${studyId}/participants`);
if (!response.ok) throw new Error("Failed to fetch participants");
const data = await response.json();
setParticipants(data.data || []);
} catch (error) {
console.error("Error fetching participants:", error);
toast({
title: "Error",
description: "Failed to load participants",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
const createParticipant = async (e: React.FormEvent) => {
e.preventDefault();
try {
const response = await fetch(`/api/studies/${studyId}/participants`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ name }),
});
if (!response.ok) {
throw new Error("Failed to create participant");
}
const data = await response.json();
setParticipants([...participants, data.data]);
setName("");
toast({
title: "Success",
description: "Participant created successfully",
});
} catch (error) {
console.error("Error creating participant:", error);
toast({
title: "Error",
description: "Failed to create participant",
variant: "destructive",
});
}
};
const deleteParticipant = async (participantId: number) => {
try {
const response = await fetch(`/api/studies/${studyId}/participants`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ participantId }),
});
if (!response.ok) {
throw new Error("Failed to delete participant");
}
setParticipants(participants.filter(p => p.id !== participantId));
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",
});
}
};
const hasPermission = (permission: string) => permissions.includes(permission);
if (isLoading) {
return (
<Card>
<CardContent className="py-8">
<p className="text-center text-muted-foreground">Loading participants...</p>
</CardContent>
</Card>
);
}
if (error) {
return (
<Card>
<CardContent className="py-8">
<p className="text-center text-destructive">{error}</p>
</CardContent>
</Card>
);
}
return (
<div className="space-y-6">
{hasPermission(PERMISSIONS.CREATE_PARTICIPANT) && (
<Card>
<CardHeader>
<CardTitle>Add New Participant</CardTitle>
<CardDescription>Add a new participant to this study</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={createParticipant} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Participant Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter participant name"
required
/>
</div>
<Button type="submit">
<PlusIcon className="w-4 h-4 mr-2" />
Add Participant
</Button>
</form>
</CardContent>
</Card>
)}
<div className="grid gap-4">
{participants.length > 0 ? (
participants.map((participant) => (
<Card key={participant.id}>
<div className="p-6">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold">
{participant.name}
</h3>
<p className="text-sm text-muted-foreground">
Added {new Date(participant.createdAt).toLocaleDateString()}
</p>
</div>
{hasPermission(PERMISSIONS.DELETE_PARTICIPANT) && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm">
<Trash2Icon className="w-4 h-4 mr-2" />
Delete
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<div className="text-sm text-muted-foreground">
This action cannot be undone. This will permanently delete the participant
&quot;{participant.name}&quot; and all associated data.
</div>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteParticipant(participant.id)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete Participant
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
</div>
</Card>
))
) : (
<Card>
<CardContent className="py-8">
<p className="text-center text-muted-foreground">
No participants added yet. Add your first participant above.
</p>
</CardContent>
</Card>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,94 @@
'use client';
import { Card, CardContent, CardDescription, 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 { Button } from "~/components/ui/button";
import { useToast } from "~/hooks/use-toast";
import { useState } from "react";
import { PERMISSIONS } from "~/lib/permissions-client";
interface SettingsTabProps {
study: {
id: number;
title: string;
description: string | null;
permissions: string[];
};
}
export function SettingsTab({ study }: SettingsTabProps) {
const [title, setTitle] = useState(study.title);
const [description, setDescription] = useState(study.description || "");
const { toast } = useToast();
const hasPermission = (permission: string) => study.permissions.includes(permission);
const canEditStudy = hasPermission(PERMISSIONS.EDIT_STUDY);
const updateStudy = async (e: React.FormEvent) => {
e.preventDefault();
try {
const response = await fetch(`/api/studies/${study.id}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ title, description }),
});
if (!response.ok) throw new Error("Failed to update study");
toast({
title: "Success",
description: "Study updated successfully",
});
} catch (error) {
console.error("Error updating study:", error);
toast({
title: "Error",
description: "Failed to update study",
variant: "destructive",
});
}
};
return (
<Card>
<CardHeader>
<CardTitle>Study Settings</CardTitle>
<CardDescription>Update your study details and configuration</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={updateStudy} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">Study Title</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter study title"
required
disabled={!canEditStudy}
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Enter study description"
disabled={!canEditStudy}
/>
</div>
{canEditStudy && (
<Button type="submit">
Save Changes
</Button>
)}
</form>
</CardContent>
</Card>
);
}

View File

@@ -1,11 +1,19 @@
import { sql, relations } from 'drizzle-orm'; import { sql, relations } from 'drizzle-orm';
import { integer, pgTable, serial, text, timestamp, varchar, primaryKey, boolean, uniqueIndex } from "drizzle-orm/pg-core"; import { integer, pgTable, serial, text, timestamp, varchar, primaryKey, boolean, uniqueIndex } from "drizzle-orm/pg-core";
export const ENVIRONMENT = {
DEVELOPMENT: 'development',
PRODUCTION: 'production',
} as const;
export type Environment = typeof ENVIRONMENT[keyof typeof ENVIRONMENT];
export const usersTable = pgTable("users", { export const usersTable = pgTable("users", {
id: varchar("id", { length: 256 }).primaryKey(), id: varchar("id", { length: 256 }).primaryKey(),
name: varchar("name", { length: 256 }), name: varchar("name", { length: 256 }),
email: varchar("email", { length: 256 }).notNull(), email: varchar("email", { length: 256 }).notNull(),
imageUrl: varchar("image_url", { length: 512 }), imageUrl: varchar("image_url", { length: 512 }),
environment: varchar("environment", { length: 20 }).notNull().default(ENVIRONMENT.DEVELOPMENT),
createdAt: timestamp("created_at").defaultNow().notNull(), createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").$onUpdate(() => new Date()), updatedAt: timestamp("updated_at").$onUpdate(() => new Date()),
}); });
@@ -17,6 +25,7 @@ export const studyTable = pgTable("study", {
userId: varchar("user_id", { length: 256 }) userId: varchar("user_id", { length: 256 })
.references(() => usersTable.id) .references(() => usersTable.id)
.notNull(), .notNull(),
environment: varchar("environment", { length: 20 }).notNull().default(ENVIRONMENT.DEVELOPMENT),
createdAt: timestamp("created_at").defaultNow().notNull(), createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").$onUpdate(() => new Date()), updatedAt: timestamp("updated_at").$onUpdate(() => new Date()),
}); });

39
src/lib/api-utils.ts Normal file
View File

@@ -0,0 +1,39 @@
import { NextResponse } from "next/server";
import { ENVIRONMENT } from "~/db/schema";
export type ApiResponse<T> = {
data?: T;
error?: string;
};
export function getEnvironment(): typeof ENVIRONMENT[keyof typeof ENVIRONMENT] {
return process.env.NODE_ENV === 'production'
? ENVIRONMENT.PRODUCTION
: ENVIRONMENT.DEVELOPMENT;
}
export function createApiResponse<T>(
data?: T,
error?: string,
status: number = error ? 400 : 200
): NextResponse<ApiResponse<T>> {
return NextResponse.json(
{ data, error },
{ status }
);
}
export const ApiError = {
Unauthorized: () => createApiResponse(undefined, "Unauthorized", 401),
Forbidden: () => createApiResponse(undefined, "Forbidden", 403),
NotFound: (resource: string) => createApiResponse(undefined, `${resource} not found`, 404),
BadRequest: (message: string) => createApiResponse(undefined, message, 400),
ServerError: (error: unknown) => {
console.error("Server error:", error);
return createApiResponse(
undefined,
"Internal server error",
500
);
}
};

View File

@@ -0,0 +1,24 @@
export const PERMISSIONS = {
CREATE_STUDY: 'create_study',
EDIT_STUDY: 'edit_study',
DELETE_STUDY: 'delete_study',
VIEW_STUDY: 'view_study',
VIEW_PARTICIPANT_NAMES: 'view_participant_names',
CREATE_PARTICIPANT: 'create_participant',
EDIT_PARTICIPANT: 'edit_participant',
DELETE_PARTICIPANT: 'delete_participant',
CONTROL_ROBOT: 'control_robot',
VIEW_ROBOT_STATUS: 'view_robot_status',
RECORD_EXPERIMENT: 'record_experiment',
VIEW_EXPERIMENT: 'view_experiment',
VIEW_EXPERIMENT_DATA: 'view_experiment_data',
EXPORT_EXPERIMENT_DATA: 'export_experiment_data',
ANNOTATE_EXPERIMENT: 'annotate_experiment',
MANAGE_ROLES: 'manage_roles',
MANAGE_USERS: 'manage_users',
MANAGE_SYSTEM_SETTINGS: 'manage_system_settings',
} as const;
export function hasPermission(permissions: string[], permission: string): boolean {
return permissions.includes(permission);
}

View File

@@ -0,0 +1,75 @@
import { eq, and, or } from "drizzle-orm";
import { db } from "~/db";
import { userRolesTable, rolePermissionsTable, permissionsTable } from "~/db/schema";
import { ApiError } from "./api-utils";
import { auth } from "@clerk/nextjs/server";
import { PERMISSIONS } from "./permissions-client";
export { PERMISSIONS };
export async function hasStudyAccess(userId: string, studyId: number): Promise<boolean> {
const userRoles = await db
.select()
.from(userRolesTable)
.where(
and(
eq(userRolesTable.userId, userId),
eq(userRolesTable.studyId, studyId)
)
);
return userRoles.length > 0;
}
export async function hasPermission(
userId: string,
permissionCode: string,
studyId: number
): Promise<boolean> {
const permissions = await db
.selectDistinct({
permissionCode: permissionsTable.code,
})
.from(userRolesTable)
.innerJoin(rolePermissionsTable, eq(rolePermissionsTable.roleId, userRolesTable.roleId))
.innerJoin(permissionsTable, eq(permissionsTable.id, rolePermissionsTable.permissionId))
.where(
and(
eq(userRolesTable.userId, userId),
eq(userRolesTable.studyId, studyId)
)
);
return permissions.some(p => p.permissionCode === permissionCode);
}
export type PermissionCheck = {
studyId: number;
permission?: string;
requireStudyAccess?: boolean;
};
export async function checkPermissions(check: PermissionCheck) {
const { userId } = await auth();
if (!userId) {
return { error: ApiError.Unauthorized() };
}
const { studyId, permission, requireStudyAccess = true } = check;
if (requireStudyAccess) {
const hasAccess = await hasStudyAccess(userId, studyId);
if (!hasAccess) {
return { error: ApiError.NotFound("Study") };
}
}
if (permission) {
const hasRequiredPermission = await hasPermission(userId, permission, studyId);
if (!hasRequiredPermission) {
return { error: ApiError.Forbidden() };
}
}
return { userId };
}

View File

@@ -1,4 +1,4 @@
import { PERMISSIONS } from './permissions'; import { PERMISSIONS } from './permissions-client';
export const ROLES = { export const ROLES = {
ADMIN: 'admin', ADMIN: 'admin',