mirror of
https://github.com/soconnor0919/hristudio.git
synced 2025-12-11 22:54:45 -05:00
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:
@@ -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
134
pnpm-lock.yaml
generated
@@ -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'
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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) {
|
||||||
|
return ApiError.BadRequest("Invitation has expired");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
.update(invitationsTable)
|
||||||
.set({
|
.set({
|
||||||
accepted: true,
|
accepted: true,
|
||||||
acceptedByUserId: userId,
|
acceptedByUserId: userId,
|
||||||
})
|
})
|
||||||
.where(eq(invitationsTable.id, invitation.id));
|
.where(eq(invitationsTable.id, invitation.id));
|
||||||
|
});
|
||||||
|
|
||||||
return NextResponse.json(
|
return createApiResponse({ message: "Invitation accepted successfully" });
|
||||||
{ message: "Invitation accepted successfully" },
|
|
||||||
{ status: 200 }
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error accepting invitation:", error);
|
return ApiError.ServerError(error);
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Internal server error" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
129
src/app/api/studies/[id]/participants/route.ts
Normal file
129
src/app/api/studies/[id]/participants/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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({
|
|
||||||
|
// Get all studies where user has any role
|
||||||
|
const studiesWithPermissions = await db
|
||||||
|
.selectDistinct({
|
||||||
id: studyTable.id,
|
id: studyTable.id,
|
||||||
title: studyTable.title,
|
title: studyTable.title,
|
||||||
description: studyTable.description,
|
description: studyTable.description,
|
||||||
createdAt: studyTable.createdAt,
|
createdAt: studyTable.createdAt,
|
||||||
updatedAt: studyTable.updatedAt,
|
updatedAt: studyTable.updatedAt,
|
||||||
userId: studyTable.userId,
|
userId: studyTable.userId,
|
||||||
|
permissionCode: permissionsTable.code,
|
||||||
|
roleName: rolesTable.name,
|
||||||
})
|
})
|
||||||
.from(studyTable)
|
.from(studyTable)
|
||||||
.leftJoin(userRolesTable, eq(userRolesTable.studyId, studyTable.id))
|
.innerJoin(
|
||||||
.where(
|
userRolesTable,
|
||||||
or(
|
and(
|
||||||
eq(studyTable.userId, userId),
|
eq(userRolesTable.studyId, studyTable.id),
|
||||||
eq(userRolesTable.userId, userId)
|
eq(userRolesTable.userId, userId)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.groupBy(studyTable.id);
|
.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));
|
||||||
|
|
||||||
return NextResponse.json(studies);
|
// 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
|
||||||
|
const result = await db.transaction(async (tx) => {
|
||||||
|
// Create the study
|
||||||
|
const [study] = await tx
|
||||||
.insert(studyTable)
|
.insert(studyTable)
|
||||||
.values({
|
.values({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
userId: userId,
|
userId: userId,
|
||||||
|
environment: currentEnvironment,
|
||||||
})
|
})
|
||||||
.returning();
|
.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' }
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 searchParams = useSearchParams();
|
||||||
|
const tab = searchParams.get('tab') || 'settings';
|
||||||
|
|
||||||
const fetchStudyData = async () => {
|
useEffect(() => {
|
||||||
|
const fetchStudy = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/studies/${params.id}`);
|
const response = await fetch(`/api/studies/${id}`);
|
||||||
if (!response.ok) throw new Error("Failed to fetch study");
|
if (!response.ok) throw new Error("Failed to fetch study");
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setStudy(data);
|
setStudy(data.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError("Failed to load study details");
|
|
||||||
console.error("Error fetching study:", error);
|
console.error("Error fetching study:", error);
|
||||||
toast({
|
setError(error instanceof Error ? error.message : "Failed to load study");
|
||||||
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 {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
fetchStudy();
|
||||||
const loadData = async () => {
|
}, [id]);
|
||||||
await Promise.all([fetchStudyData(), fetchInvitations()]);
|
|
||||||
};
|
|
||||||
loadData();
|
|
||||||
}, [params.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
||||||
|
|
||||||
const handleDeleteInvitation = async (invitationId: string) => {
|
const handleTabChange = (value: string) => {
|
||||||
try {
|
router.push(`/dashboard/studies/${id}/settings?tab=${value}`);
|
||||||
const response = await fetch(`/api/invitations/${invitationId}`, {
|
|
||||||
method: "DELETE",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("Failed to delete invitation");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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">
|
||||||
</div>
|
Manage study settings and participants
|
||||||
|
|
||||||
<Tabs defaultValue="invites" className="space-y-4">
|
|
||||||
<TabsList>
|
|
||||||
<TabsTrigger value="invites">Invites</TabsTrigger>
|
|
||||||
<TabsTrigger value="settings">Settings</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="invites">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Manage Invitations</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Invite researchers and participants to collaborate on “{study.title}”
|
|
||||||
</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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{!invitation.accepted && (
|
|
||||||
<Button
|
<Tabs value={tab} onValueChange={handleTabChange} className="space-y-6">
|
||||||
variant="outline"
|
<TabsList>
|
||||||
size="sm"
|
<TabsTrigger value="settings">Settings</TabsTrigger>
|
||||||
onClick={() => handleDeleteInvitation(invitation.id)}
|
<TabsTrigger value="participants">Participants</TabsTrigger>
|
||||||
>
|
</TabsList>
|
||||||
Cancel
|
|
||||||
</Button>
|
<TabsContent value="settings" className="space-y-6">
|
||||||
)}
|
<SettingsTab study={study} />
|
||||||
</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>
|
||||||
|
|||||||
@@ -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,37 +122,34 @@ 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>
|
||||||
|
|
||||||
|
{hasPermission(studies[0]?.permissions || [], PERMISSIONS.CREATE_STUDY) && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Create New Study</CardTitle>
|
<CardTitle>Create New Study</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>Add a new research study to your collection</CardDescription>
|
||||||
Add a new research study to your collection
|
|
||||||
</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
|
||||||
type="text"
|
|
||||||
id="title"
|
id="title"
|
||||||
value={newStudyTitle}
|
value={title}
|
||||||
onChange={(e) => setNewStudyTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
placeholder="Enter study title"
|
placeholder="Enter study title"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@@ -148,10 +158,9 @@ export default function Studies() {
|
|||||||
<Label htmlFor="description">Description</Label>
|
<Label htmlFor="description">Description</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="description"
|
id="description"
|
||||||
value={newStudyDescription}
|
value={description}
|
||||||
onChange={(e) => setNewStudyDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
placeholder="Enter study description"
|
placeholder="Enter study description"
|
||||||
rows={3}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit">
|
<Button type="submit">
|
||||||
@@ -161,33 +170,80 @@ export default function Studies() {
|
|||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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">
|
||||||
|
{study.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{study.description || "No description provided."}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
<span className="text-muted-foreground">Your Roles: </span>
|
||||||
|
<span className="text-foreground">
|
||||||
|
{study.roles?.map(formatRoleName).join(", ")}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{(hasPermission(study.permissions, PERMISSIONS.EDIT_STUDY) ||
|
||||||
|
hasPermission(study.permissions, PERMISSIONS.MANAGE_ROLES)) && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
onClick={() => router.push(`/dashboard/studies/${study.id}/settings`)}
|
onClick={() => router.push(`/dashboard/studies/${study.id}/settings`)}
|
||||||
>
|
>
|
||||||
<Settings2 className="w-4 h-4 mr-2" />
|
<Settings2 className="w-4 h-4 mr-2" />
|
||||||
Settings
|
Settings
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
)}
|
||||||
variant="outline"
|
{hasPermission(study.permissions, PERMISSIONS.MANAGE_SYSTEM_SETTINGS) && (
|
||||||
onClick={() => deleteStudy(study.id)}
|
<AlertDialog>
|
||||||
>
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
<Trash2Icon className="w-4 h-4 mr-2" />
|
<Trash2Icon className="w-4 h-4 mr-2" />
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</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
|
||||||
|
"{study.title}" 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>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
</Card>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
233
src/components/studies/participants-tab.tsx
Normal file
233
src/components/studies/participants-tab.tsx
Normal 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
|
||||||
|
"{participant.name}" 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
src/components/studies/settings-tab.tsx
Normal file
94
src/components/studies/settings-tab.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
39
src/lib/api-utils.ts
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
24
src/lib/permissions-client.ts
Normal file
24
src/lib/permissions-client.ts
Normal 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);
|
||||||
|
}
|
||||||
75
src/lib/permissions-server.ts
Normal file
75
src/lib/permissions-server.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { PERMISSIONS } from './permissions';
|
import { PERMISSIONS } from './permissions-client';
|
||||||
|
|
||||||
export const ROLES = {
|
export const ROLES = {
|
||||||
ADMIN: 'admin',
|
ADMIN: 'admin',
|
||||||
|
|||||||
Reference in New Issue
Block a user