mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-03-23 19:27:51 -04:00
Compare commits
21 Commits
f8e6fccae3
...
20d6d3de1a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20d6d3de1a | ||
| 4bed537943 | |||
| 73f70f6550 | |||
| 3fafd61553 | |||
| 3491bf4463 | |||
| cc58593891 | |||
| bbbe397ba8 | |||
| bbc34921b5 | |||
| 8e647c958e | |||
| 4e86546311 | |||
| e84c794962 | |||
| 70064f487e | |||
| 91d03a789d | |||
| 31d2173703 | |||
| 4a9abf4ff1 | |||
| 487f97c5c2 | |||
| db147f2294 | |||
| a705c720fb | |||
| e460c1b029 | |||
| eb0d86f570 | |||
| e40c37cfd0 |
67
bun.lock
67
bun.lock
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"lockfileVersion": 1,
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 0,
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"name": "hristudio",
|
"name": "hristudio",
|
||||||
@@ -7,6 +8,7 @@
|
|||||||
"@auth/drizzle-adapter": "^1.11.1",
|
"@auth/drizzle-adapter": "^1.11.1",
|
||||||
"@aws-sdk/client-s3": "^3.989.0",
|
"@aws-sdk/client-s3": "^3.989.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.989.0",
|
"@aws-sdk/s3-request-presigner": "^3.989.0",
|
||||||
|
"@better-auth/drizzle-adapter": "^1.5.5",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
@@ -48,6 +50,7 @@
|
|||||||
"@types/js-cookie": "^3.0.6",
|
"@types/js-cookie": "^3.0.6",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
|
"better-auth": "^1.5.5",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
@@ -207,6 +210,24 @@
|
|||||||
|
|
||||||
"@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="],
|
"@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="],
|
||||||
|
|
||||||
|
"@better-auth/core": ["@better-auth/core@1.5.5", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21", "@cloudflare/workers-types": ">=4", "better-call": "1.3.2", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types"] }, "sha512-1oR/2jAp821Dcf67kQYHUoyNcdc1TcShfw4QMK0YTVntuRES5mUOyvEJql5T6eIuLfaqaN4LOF78l0FtF66HXA=="],
|
||||||
|
|
||||||
|
"@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.5.5", "", { "peerDependencies": { "@better-auth/core": "1.5.5", "@better-auth/utils": "^0.3.0", "drizzle-orm": ">=0.41.0" }, "optionalPeers": ["drizzle-orm"] }, "sha512-HAi9xAP40oDt48QZeYBFTcmg3vt1Jik90GwoRIfangd7VGbxesIIDBJSnvwMbZ52GBIc6+V4FRw9lasNiNrPfw=="],
|
||||||
|
|
||||||
|
"@better-auth/kysely-adapter": ["@better-auth/kysely-adapter@1.5.5", "", { "peerDependencies": { "@better-auth/core": "1.5.5", "@better-auth/utils": "^0.3.0", "kysely": "^0.27.0 || ^0.28.0" } }, "sha512-LmHffIVnqbfsxcxckMOoE8MwibWrbVFch+kwPKJ5OFDFv6lin75ufN7ZZ7twH0IMPLT/FcgzaRjP8jRrXRef9g=="],
|
||||||
|
|
||||||
|
"@better-auth/memory-adapter": ["@better-auth/memory-adapter@1.5.5", "", { "peerDependencies": { "@better-auth/core": "1.5.5", "@better-auth/utils": "^0.3.0" } }, "sha512-4X0j1/2L+nsgmObjmy9xEGUFWUv38Qjthp558fwS3DAp6ueWWyCaxaD6VJZ7m5qPNMrsBStO5WGP8CmJTEWm7g=="],
|
||||||
|
|
||||||
|
"@better-auth/mongo-adapter": ["@better-auth/mongo-adapter@1.5.5", "", { "peerDependencies": { "@better-auth/core": "1.5.5", "@better-auth/utils": "^0.3.0", "mongodb": "^6.0.0 || ^7.0.0" } }, "sha512-P1J9ljL5X5k740I8Rx1esPWNgWYPdJR5hf2CY7BwDSrQFPUHuzeCg0YhtEEP55niNateTXhBqGAcy0fVOeamZg=="],
|
||||||
|
|
||||||
|
"@better-auth/prisma-adapter": ["@better-auth/prisma-adapter@1.5.5", "", { "peerDependencies": { "@better-auth/core": "1.5.5", "@better-auth/utils": "^0.3.0", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@prisma/client", "prisma"] }, "sha512-CliDd78CXHzzwQIXhCdwGr5Ml53i6JdCHWV7PYwTIJz9EAm6qb2RVBdpP3nqEfNjINGM22A6gfleCgCdZkTIZg=="],
|
||||||
|
|
||||||
|
"@better-auth/telemetry": ["@better-auth/telemetry@1.5.5", "", { "dependencies": { "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21" }, "peerDependencies": { "@better-auth/core": "1.5.5" } }, "sha512-1+lklxArn4IMHuU503RcPdXrSG2tlXt4jnGG3omolmspQ7tktg/Y9XO/yAkYDurtvMn1xJ8X1Ov01Ji/r5s9BQ=="],
|
||||||
|
|
||||||
|
"@better-auth/utils": ["@better-auth/utils@0.3.1", "", {}, "sha512-+CGp4UmZSUrHHnpHhLPYu6cV+wSUSvVbZbNykxhUDocpVNTo9uFFxw/NqJlh1iC4wQ9HKKWGCKuZ5wUgS0v6Kg=="],
|
||||||
|
|
||||||
|
"@better-fetch/fetch": ["@better-fetch/fetch@1.1.21", "", {}, "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="],
|
||||||
|
|
||||||
"@date-fns/tz": ["@date-fns/tz@1.4.1", "", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="],
|
"@date-fns/tz": ["@date-fns/tz@1.4.1", "", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="],
|
||||||
|
|
||||||
"@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="],
|
"@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="],
|
||||||
@@ -377,6 +398,8 @@
|
|||||||
|
|
||||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.29", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ=="],
|
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.29", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ=="],
|
||||||
|
|
||||||
|
"@mongodb-js/saslprep": ["@mongodb-js/saslprep@1.4.6", "", { "dependencies": { "sparse-bitfield": "^3.0.3" } }, "sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g=="],
|
||||||
|
|
||||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
|
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
|
||||||
|
|
||||||
"@next/env": ["@next/env@16.1.6", "", {}, "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ=="],
|
"@next/env": ["@next/env@16.1.6", "", {}, "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ=="],
|
||||||
@@ -399,6 +422,10 @@
|
|||||||
|
|
||||||
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A=="],
|
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A=="],
|
||||||
|
|
||||||
|
"@noble/ciphers": ["@noble/ciphers@2.1.1", "", {}, "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw=="],
|
||||||
|
|
||||||
|
"@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="],
|
||||||
|
|
||||||
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
||||||
|
|
||||||
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
|
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
|
||||||
@@ -855,6 +882,10 @@
|
|||||||
|
|
||||||
"@types/uuid": ["@types/uuid@11.0.0", "", { "dependencies": { "uuid": "*" } }, "sha512-HVyk8nj2m+jcFRNazzqyVKiZezyhDKrGUA3jlEcg/nZ6Ms+qHwocba1Y/AaVaznJTAM9xpdFSh+ptbNrhOGvZA=="],
|
"@types/uuid": ["@types/uuid@11.0.0", "", { "dependencies": { "uuid": "*" } }, "sha512-HVyk8nj2m+jcFRNazzqyVKiZezyhDKrGUA3jlEcg/nZ6Ms+qHwocba1Y/AaVaznJTAM9xpdFSh+ptbNrhOGvZA=="],
|
||||||
|
|
||||||
|
"@types/webidl-conversions": ["@types/webidl-conversions@7.0.3", "", {}, "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA=="],
|
||||||
|
|
||||||
|
"@types/whatwg-url": ["@types/whatwg-url@13.0.0", "", { "dependencies": { "@types/webidl-conversions": "*" } }, "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q=="],
|
||||||
|
|
||||||
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
||||||
|
|
||||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.55.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.55.0", "@typescript-eslint/type-utils": "8.55.0", "@typescript-eslint/utils": "8.55.0", "@typescript-eslint/visitor-keys": "8.55.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.55.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ=="],
|
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.55.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.55.0", "@typescript-eslint/type-utils": "8.55.0", "@typescript-eslint/utils": "8.55.0", "@typescript-eslint/visitor-keys": "8.55.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.55.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ=="],
|
||||||
@@ -987,6 +1018,10 @@
|
|||||||
|
|
||||||
"bcryptjs": ["bcryptjs@3.0.3", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g=="],
|
"bcryptjs": ["bcryptjs@3.0.3", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g=="],
|
||||||
|
|
||||||
|
"better-auth": ["better-auth@1.5.5", "", { "dependencies": { "@better-auth/core": "1.5.5", "@better-auth/drizzle-adapter": "1.5.5", "@better-auth/kysely-adapter": "1.5.5", "@better-auth/memory-adapter": "1.5.5", "@better-auth/mongo-adapter": "1.5.5", "@better-auth/prisma-adapter": "1.5.5", "@better-auth/telemetry": "1.5.5", "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.1.1", "@noble/hashes": "^2.0.1", "better-call": "1.3.2", "defu": "^6.1.4", "jose": "^6.1.3", "kysely": "^0.28.11", "nanostores": "^1.1.1", "zod": "^4.3.6" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": ">=0.41.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-GpVPaV1eqr3mOovKfghJXXk6QvlcVeFbS3z+n+FPDid5rK/2PchnDtiaVCzWyXA9jH2KkirOfl+JhAUvnja0Eg=="],
|
||||||
|
|
||||||
|
"better-call": ["better-call@1.3.2", "", { "dependencies": { "@better-auth/utils": "^0.3.1", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-4cZIfrerDsNTn3cm+MhLbUePN0gdwkhSXEuG7r/zuQ8c/H7iU0/jSK5TD3FW7U0MgKHce/8jGpPYNO4Ve+4NBw=="],
|
||||||
|
|
||||||
"bl": ["bl@5.1.0", "", { "dependencies": { "buffer": "^6.0.3", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-tv1ZJHLfTDnXE6tMHv73YgSJaWR2AFuPwMntBe7XL/GBFHnT0CLnsHMogfk5+GzCDC5ZWarSCYaIGATZt9dNsQ=="],
|
"bl": ["bl@5.1.0", "", { "dependencies": { "buffer": "^6.0.3", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-tv1ZJHLfTDnXE6tMHv73YgSJaWR2AFuPwMntBe7XL/GBFHnT0CLnsHMogfk5+GzCDC5ZWarSCYaIGATZt9dNsQ=="],
|
||||||
|
|
||||||
"block-stream2": ["block-stream2@2.1.0", "", { "dependencies": { "readable-stream": "^3.4.0" } }, "sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg=="],
|
"block-stream2": ["block-stream2@2.1.0", "", { "dependencies": { "readable-stream": "^3.4.0" } }, "sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg=="],
|
||||||
@@ -999,6 +1034,8 @@
|
|||||||
|
|
||||||
"browser-or-node": ["browser-or-node@2.1.1", "", {}, "sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg=="],
|
"browser-or-node": ["browser-or-node@2.1.1", "", {}, "sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg=="],
|
||||||
|
|
||||||
|
"bson": ["bson@7.2.0", "", {}, "sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ=="],
|
||||||
|
|
||||||
"buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
|
"buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
|
||||||
|
|
||||||
"buffer-crc32": ["buffer-crc32@1.0.0", "", {}, "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w=="],
|
"buffer-crc32": ["buffer-crc32@1.0.0", "", {}, "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w=="],
|
||||||
@@ -1085,6 +1122,8 @@
|
|||||||
|
|
||||||
"define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="],
|
"define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="],
|
||||||
|
|
||||||
|
"defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],
|
||||||
|
|
||||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||||
|
|
||||||
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
|
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
|
||||||
@@ -1353,7 +1392,7 @@
|
|||||||
|
|
||||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||||
|
|
||||||
"jose": ["jose@6.0.12", "", {}, "sha512-T8xypXs8CpmiIi78k0E+Lk7T2zlK4zDyg+o1CZ4AkOHgDg98ogdP2BeZ61lTFKFyoEwJ9RgAgN+SdM3iPgNonQ=="],
|
"jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="],
|
||||||
|
|
||||||
"js-cookie": ["js-cookie@3.0.5", "", {}, "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw=="],
|
"js-cookie": ["js-cookie@3.0.5", "", {}, "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw=="],
|
||||||
|
|
||||||
@@ -1379,6 +1418,8 @@
|
|||||||
|
|
||||||
"kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
|
"kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
|
||||||
|
|
||||||
|
"kysely": ["kysely@0.28.14", "", {}, "sha512-SU3lgh0rPvq7upc6vvdVrCsSMUG1h3ChvHVOY7wJ2fw4C9QEB7X3d5eyYEyULUX7UQtxZJtZXGuT6U2US72UYA=="],
|
||||||
|
|
||||||
"language-subtag-registry": ["language-subtag-registry@0.3.23", "", {}, "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ=="],
|
"language-subtag-registry": ["language-subtag-registry@0.3.23", "", {}, "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ=="],
|
||||||
|
|
||||||
"language-tags": ["language-tags@1.0.9", "", { "dependencies": { "language-subtag-registry": "^0.3.20" } }, "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA=="],
|
"language-tags": ["language-tags@1.0.9", "", { "dependencies": { "language-subtag-registry": "^0.3.20" } }, "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA=="],
|
||||||
@@ -1435,6 +1476,8 @@
|
|||||||
|
|
||||||
"mdurl": ["mdurl@2.0.0", "", {}, "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="],
|
"mdurl": ["mdurl@2.0.0", "", {}, "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="],
|
||||||
|
|
||||||
|
"memory-pager": ["memory-pager@1.5.0", "", {}, "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg=="],
|
||||||
|
|
||||||
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
|
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
|
||||||
|
|
||||||
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
|
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
|
||||||
@@ -1453,10 +1496,16 @@
|
|||||||
|
|
||||||
"minio": ["minio@8.0.6", "", { "dependencies": { "async": "^3.2.4", "block-stream2": "^2.1.0", "browser-or-node": "^2.1.1", "buffer-crc32": "^1.0.0", "eventemitter3": "^5.0.1", "fast-xml-parser": "^4.4.1", "ipaddr.js": "^2.0.1", "lodash": "^4.17.21", "mime-types": "^2.1.35", "query-string": "^7.1.3", "stream-json": "^1.8.0", "through2": "^4.0.2", "web-encoding": "^1.1.5", "xml2js": "^0.5.0 || ^0.6.2" } }, "sha512-sOeh2/b/XprRmEtYsnNRFtOqNRTPDvYtMWh+spWlfsuCV/+IdxNeKVUMKLqI7b5Dr07ZqCPuaRGU/rB9pZYVdQ=="],
|
"minio": ["minio@8.0.6", "", { "dependencies": { "async": "^3.2.4", "block-stream2": "^2.1.0", "browser-or-node": "^2.1.1", "buffer-crc32": "^1.0.0", "eventemitter3": "^5.0.1", "fast-xml-parser": "^4.4.1", "ipaddr.js": "^2.0.1", "lodash": "^4.17.21", "mime-types": "^2.1.35", "query-string": "^7.1.3", "stream-json": "^1.8.0", "through2": "^4.0.2", "web-encoding": "^1.1.5", "xml2js": "^0.5.0 || ^0.6.2" } }, "sha512-sOeh2/b/XprRmEtYsnNRFtOqNRTPDvYtMWh+spWlfsuCV/+IdxNeKVUMKLqI7b5Dr07ZqCPuaRGU/rB9pZYVdQ=="],
|
||||||
|
|
||||||
|
"mongodb": ["mongodb@7.1.0", "", { "dependencies": { "@mongodb-js/saslprep": "^1.3.0", "bson": "^7.1.1", "mongodb-connection-string-url": "^7.0.0" }, "peerDependencies": { "@aws-sdk/credential-providers": "^3.806.0", "@mongodb-js/zstd": "^7.0.0", "gcp-metadata": "^7.0.1", "kerberos": "^7.0.0", "mongodb-client-encryption": ">=7.0.0 <7.1.0", "snappy": "^7.3.2", "socks": "^2.8.6" }, "optionalPeers": ["@aws-sdk/credential-providers", "@mongodb-js/zstd", "gcp-metadata", "kerberos", "mongodb-client-encryption", "snappy", "socks"] }, "sha512-kMfnKunbolQYwCIyrkxNJFB4Ypy91pYqua5NargS/f8ODNSJxT03ZU3n1JqL4mCzbSih8tvmMEMLpKTT7x5gCg=="],
|
||||||
|
|
||||||
|
"mongodb-connection-string-url": ["mongodb-connection-string-url@7.0.1", "", { "dependencies": { "@types/whatwg-url": "^13.0.0", "whatwg-url": "^14.1.0" } }, "sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ=="],
|
||||||
|
|
||||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||||
|
|
||||||
|
"nanostores": ["nanostores@1.2.0", "", {}, "sha512-F0wCzbsH80G7XXo0Jd9/AVQC7ouWY6idUCTnMwW5t/Rv9W8qmO6endavDwg7TNp5GbugwSukFMVZqzPSrSMndg=="],
|
||||||
|
|
||||||
"napi-postinstall": ["napi-postinstall@0.3.2", "", { "bin": { "napi-postinstall": "lib/cli.js" } }, "sha512-tWVJxJHmBWLy69PvO96TZMZDrzmw5KeiZBz3RHmiM2XZ9grBJ2WgMAFVVg25nqp3ZjTFUs2Ftw1JhscL3Teliw=="],
|
"napi-postinstall": ["napi-postinstall@0.3.2", "", { "bin": { "napi-postinstall": "lib/cli.js" } }, "sha512-tWVJxJHmBWLy69PvO96TZMZDrzmw5KeiZBz3RHmiM2XZ9grBJ2WgMAFVVg25nqp3ZjTFUs2Ftw1JhscL3Teliw=="],
|
||||||
|
|
||||||
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
||||||
@@ -1641,6 +1690,8 @@
|
|||||||
|
|
||||||
"rope-sequence": ["rope-sequence@1.3.4", "", {}, "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ=="],
|
"rope-sequence": ["rope-sequence@1.3.4", "", {}, "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ=="],
|
||||||
|
|
||||||
|
"rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="],
|
||||||
|
|
||||||
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
|
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
|
||||||
|
|
||||||
"safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="],
|
"safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="],
|
||||||
@@ -1659,6 +1710,8 @@
|
|||||||
|
|
||||||
"server-only": ["server-only@0.0.1", "", {}, "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA=="],
|
"server-only": ["server-only@0.0.1", "", {}, "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA=="],
|
||||||
|
|
||||||
|
"set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="],
|
||||||
|
|
||||||
"set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="],
|
"set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="],
|
||||||
|
|
||||||
"set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="],
|
"set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="],
|
||||||
@@ -1697,6 +1750,8 @@
|
|||||||
|
|
||||||
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
|
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
|
||||||
|
|
||||||
|
"sparse-bitfield": ["sparse-bitfield@3.0.3", "", { "dependencies": { "memory-pager": "^1.0.2" } }, "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ=="],
|
||||||
|
|
||||||
"split-on-first": ["split-on-first@1.1.0", "", {}, "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw=="],
|
"split-on-first": ["split-on-first@1.1.0", "", {}, "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw=="],
|
||||||
|
|
||||||
"stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="],
|
"stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="],
|
||||||
@@ -1773,6 +1828,8 @@
|
|||||||
|
|
||||||
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||||
|
|
||||||
|
"tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="],
|
||||||
|
|
||||||
"trim-canvas": ["trim-canvas@0.1.2", "", {}, "sha512-nd4Ga3iLFV94mdhW9JFMLpQbHUyCQuhFOD71PEAt1NjtMD5wbZctzhX8c3agHNybMR5zXD1XTGoIEWk995E6pQ=="],
|
"trim-canvas": ["trim-canvas@0.1.2", "", {}, "sha512-nd4Ga3iLFV94mdhW9JFMLpQbHUyCQuhFOD71PEAt1NjtMD5wbZctzhX8c3agHNybMR5zXD1XTGoIEWk995E6pQ=="],
|
||||||
|
|
||||||
"ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="],
|
"ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="],
|
||||||
@@ -1837,6 +1894,10 @@
|
|||||||
|
|
||||||
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
|
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
|
||||||
|
|
||||||
|
"webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="],
|
||||||
|
|
||||||
|
"whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="],
|
||||||
|
|
||||||
"which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
|
"which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
|
||||||
|
|
||||||
"which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
|
"which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
|
||||||
@@ -1863,6 +1924,8 @@
|
|||||||
|
|
||||||
"zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="],
|
"zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="],
|
||||||
|
|
||||||
|
"@auth/core/jose": ["jose@6.0.12", "", {}, "sha512-T8xypXs8CpmiIi78k0E+Lk7T2zlK4zDyg+o1CZ4AkOHgDg98ogdP2BeZ61lTFKFyoEwJ9RgAgN+SdM3iPgNonQ=="],
|
||||||
|
|
||||||
"@aws-crypto/crc32/@aws-sdk/types": ["@aws-sdk/types@3.840.0", "", { "dependencies": { "@smithy/types": "^4.3.1", "tslib": "^2.6.2" } }, "sha512-xliuHaUFZxEx1NSXeLLZ9Dyu6+EJVQKEoD+yM+zqUo3YDZ7medKJWY6fIOKiPX/N7XbLdBYwajb15Q7IL8KkeA=="],
|
"@aws-crypto/crc32/@aws-sdk/types": ["@aws-sdk/types@3.840.0", "", { "dependencies": { "@smithy/types": "^4.3.1", "tslib": "^2.6.2" } }, "sha512-xliuHaUFZxEx1NSXeLLZ9Dyu6+EJVQKEoD+yM+zqUo3YDZ7medKJWY6fIOKiPX/N7XbLdBYwajb15Q7IL8KkeA=="],
|
||||||
|
|
||||||
"@aws-crypto/crc32c/@aws-sdk/types": ["@aws-sdk/types@3.840.0", "", { "dependencies": { "@smithy/types": "^4.3.1", "tslib": "^2.6.2" } }, "sha512-xliuHaUFZxEx1NSXeLLZ9Dyu6+EJVQKEoD+yM+zqUo3YDZ7medKJWY6fIOKiPX/N7XbLdBYwajb15Q7IL8KkeA=="],
|
"@aws-crypto/crc32c/@aws-sdk/types": ["@aws-sdk/types@3.840.0", "", { "dependencies": { "@smithy/types": "^4.3.1", "tslib": "^2.6.2" } }, "sha512-xliuHaUFZxEx1NSXeLLZ9Dyu6+EJVQKEoD+yM+zqUo3YDZ7medKJWY6fIOKiPX/N7XbLdBYwajb15Q7IL8KkeA=="],
|
||||||
@@ -2097,6 +2160,8 @@
|
|||||||
|
|
||||||
"eslint-import-resolver-typescript/tinyglobby/fdir": ["fdir@6.4.6", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w=="],
|
"eslint-import-resolver-typescript/tinyglobby/fdir": ["fdir@6.4.6", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w=="],
|
||||||
|
|
||||||
|
"next-auth/@auth/core/jose": ["jose@6.0.12", "", {}, "sha512-T8xypXs8CpmiIi78k0E+Lk7T2zlK4zDyg+o1CZ4AkOHgDg98ogdP2BeZ61lTFKFyoEwJ9RgAgN+SdM3iPgNonQ=="],
|
||||||
|
|
||||||
"prosemirror-markdown/@types/markdown-it/@types/linkify-it": ["@types/linkify-it@5.0.0", "", {}, "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q=="],
|
"prosemirror-markdown/@types/markdown-it/@types/linkify-it": ["@types/linkify-it@5.0.0", "", {}, "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q=="],
|
||||||
|
|
||||||
"prosemirror-markdown/@types/markdown-it/@types/mdurl": ["@types/mdurl@2.0.0", "", {}, "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="],
|
"prosemirror-markdown/@types/markdown-it/@types/mdurl": ["@types/mdurl@2.0.0", "", {}, "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="],
|
||||||
|
|||||||
159
docs/MARCH-2026-SESSION.md
Normal file
159
docs/MARCH-2026-SESSION.md
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
# HRIStudio - March 2026 Development Summary
|
||||||
|
|
||||||
|
## What We Did This Session
|
||||||
|
|
||||||
|
### 1. Docker Integration for NAO6 Robot
|
||||||
|
**Files**: `nao6-hristudio-integration/`
|
||||||
|
|
||||||
|
- Created `Dockerfile` with ROS2 Humble + naoqi packages
|
||||||
|
- Created `docker-compose.yaml` with 3 services: `nao_driver`, `ros_bridge`, `ros_api`
|
||||||
|
- Created `scripts/init_robot.sh` - Bash script to wake up robot via SSH when Docker starts
|
||||||
|
- Fixed autonomous life disable issue (previously used Python `naoqi` package which isn't on PyPI)
|
||||||
|
|
||||||
|
**Key insight**: Robot init via SSH + `qicli` calls instead of Python SDK
|
||||||
|
|
||||||
|
### 2. Plugin System Fixes
|
||||||
|
**Files**: `robot-plugins/plugins/nao6-ros2.json`, `src/lib/ros/wizard-ros-service.ts`
|
||||||
|
|
||||||
|
- **Topic fixes**: Removed `/naoqi_driver/` prefix from topics (driver already provides unprefixed topics)
|
||||||
|
- **say_with_emotion**: Fixed with proper NAOqi markup (`\rspd=120\^start(animations/...)`)
|
||||||
|
- **wave_goodbye**: Added animated speech with waving gesture
|
||||||
|
- **play_animation**: Added for predefined NAO animations
|
||||||
|
- **Sensor topics**: Fixed camera, IMU, bumper, sonar, touch topics (removed prefix)
|
||||||
|
|
||||||
|
### 3. Database Schema - Plugin Identifier
|
||||||
|
**Files**: `src/server/db/schema.ts`, `src/server/services/trial-execution.ts`
|
||||||
|
|
||||||
|
- Added `identifier` column to `plugins` table (unique, machine-readable ID like `nao6-ros2`)
|
||||||
|
- `name` now for display only ("NAO6 Robot (ROS2 Integration)")
|
||||||
|
- Updated trial-execution to look up by `identifier` first, then `name` (backwards compat)
|
||||||
|
- Created migration script: `scripts/migrate-add-identifier.ts`
|
||||||
|
|
||||||
|
### 4. Seed Script Improvements
|
||||||
|
**Files**: `scripts/seed-dev.ts`
|
||||||
|
|
||||||
|
- Fixed to use local plugin file (not remote `repo.hristudio.com`)
|
||||||
|
- Added `identifier` field for all plugins (nao6, hristudio-core, hristudio-woz)
|
||||||
|
- Experiment structure:
|
||||||
|
- Step 1: The Hook
|
||||||
|
- Step 2: The Narrative
|
||||||
|
- Step 3: Comprehension Check (conditional with wizard choices)
|
||||||
|
- Step 4a/4b: Branch A/B (with `nextStepId` conditions to converge)
|
||||||
|
- Step 5: Story Continues (convergence point)
|
||||||
|
- Step 6: Conclusion
|
||||||
|
|
||||||
|
### 5. Robot Action Timing Fix
|
||||||
|
**Files**: `src/lib/ros/wizard-ros-service.ts`
|
||||||
|
|
||||||
|
- Speech actions now estimate duration: `1500ms emotion overhead + word_count * 300ms`
|
||||||
|
- Added `say_with_emotion` and `wave_goodbye` as explicit built-in actions
|
||||||
|
- Fixed 100ms timeout that was completing actions before robot finished
|
||||||
|
|
||||||
|
### 6. Branching Logic Fixes (Critical!)
|
||||||
|
**Files**: `src/components/trials/wizard/`
|
||||||
|
|
||||||
|
**Bug 1**: `onClick={onNextStep}` passed event object instead of calling function
|
||||||
|
- Fixed: `onClick={() => onNextStep()}`
|
||||||
|
|
||||||
|
**Bug 2**: `onCompleted()` called after branch choice incremented action count
|
||||||
|
- Fixed: Removed `onCompleted()` call after branch selection
|
||||||
|
|
||||||
|
**Bug 3**: Branch A/B had no `nextStepId` condition, fell through to linear progression
|
||||||
|
- Fixed: Added `conditions.nextStepId: step5.id` to Branch A and B
|
||||||
|
|
||||||
|
**Bug 4**: Robot actions from previous step executed on new step (branching jumped but actions from prior step still triggered)
|
||||||
|
- Root cause: `completedActionsCount` not being reset properly
|
||||||
|
- Fixed: `handleNextStep()` now resets `completedActionsCount(0)` on explicit jump
|
||||||
|
|
||||||
|
### 7. Auth.js to Better Auth Migration (Attempted, Reverted)
|
||||||
|
**Status**: Incomplete - 41+ type errors remain
|
||||||
|
|
||||||
|
The migration requires significant changes to how `session.user.roles` is accessed since Better Auth doesn't include roles in session by default. Would need to fetch roles from database on each request.
|
||||||
|
|
||||||
|
**Recommendation**: Defer until more development time available.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Architecture
|
||||||
|
|
||||||
|
### Plugin Identifier System
|
||||||
|
```
|
||||||
|
plugins table:
|
||||||
|
- id: UUID (primary key)
|
||||||
|
- identifier: varchar (unique, e.g. "nao6-ros2")
|
||||||
|
- name: varchar (display, e.g. "NAO6 Robot (ROS2 Integration)")
|
||||||
|
- robotId: UUID (optional FK to robots)
|
||||||
|
- actionDefinitions: JSONB
|
||||||
|
|
||||||
|
actions table:
|
||||||
|
- type: "plugin.action" (e.g., "nao6-ros2.say_with_emotion")
|
||||||
|
- pluginId: varchar (references plugins.identifier)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Branching Flow
|
||||||
|
```
|
||||||
|
Step 3 (Comprehension Check)
|
||||||
|
└── wizard_wait_for_response action
|
||||||
|
├── Click "Correct" → setLastResponse("Correct") → nextStepId=step4a.id
|
||||||
|
└── Click "Incorrect" → setLastResponse("Incorrect") → nextStepId=step4b.id
|
||||||
|
|
||||||
|
Step 4a/4b (Branches)
|
||||||
|
└── conditions.nextStepId: step5.id → jump to Story Continues
|
||||||
|
|
||||||
|
Step 5 (Story Continues)
|
||||||
|
└── Linear progression to Step 6
|
||||||
|
|
||||||
|
Step 6 (Conclusion)
|
||||||
|
└── Trial complete
|
||||||
|
```
|
||||||
|
|
||||||
|
### ROS Topics (NAO6)
|
||||||
|
```
|
||||||
|
/speech - Text-to-speech
|
||||||
|
/cmd_vel - Velocity commands
|
||||||
|
/joint_angles - Joint position commands
|
||||||
|
/camera/front/image_raw
|
||||||
|
/camera/bottom/image_raw
|
||||||
|
/imu/torso
|
||||||
|
/bumper
|
||||||
|
/{hand,head}_touch
|
||||||
|
/sonar/{left,right}
|
||||||
|
/info
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known Issues / Remaining Work
|
||||||
|
|
||||||
|
1. **Auth.js to Better Auth Migration** - Deferred, requires significant refactoring
|
||||||
|
2. **robots.executeSystemAction** - Procedure not found error (fallback works but should investigate)
|
||||||
|
3. **say_with_emotion via WebSocket** - May need proper plugin config to avoid fallback
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd nao6-hristudio-integration
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Robot init runs automatically on startup (via `init_robot.sh`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
- [x] Docker builds and starts
|
||||||
|
- [x] Robot wakes up (autonomous life disabled)
|
||||||
|
- [x] Seed script runs successfully
|
||||||
|
- [x] Trial executes with proper branching
|
||||||
|
- [x] Branch A → Story Continues (not Branch B)
|
||||||
|
- [x] Robot speaks with emotion (say_with_emotion)
|
||||||
|
- [x] Wave gesture works
|
||||||
|
- [ ] Robot movement (walk, turn) tested
|
||||||
|
- [ ] All NAO6 actions verified
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Last Updated: March 21, 2026*
|
||||||
@@ -2,88 +2,92 @@
|
|||||||
|
|
||||||
Essential commands for using NAO6 robots with HRIStudio.
|
Essential commands for using NAO6 robots with HRIStudio.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start (Docker)
|
||||||
|
|
||||||
### 1. Start NAO Integration
|
### 1. Start Docker Integration
|
||||||
```bash
|
```bash
|
||||||
cd ~/naoqi_ros2_ws
|
cd ~/Documents/Projects/nao6-hristudio-integration
|
||||||
source install/setup.bash
|
docker compose up -d
|
||||||
ros2 launch nao_launch nao6_hristudio.launch.py nao_ip:=nao.local password:=robolab
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Wake Robot
|
The robot will automatically wake up and autonomous life will be disabled on startup.
|
||||||
Press chest button for 3 seconds, or use:
|
|
||||||
```bash
|
|
||||||
# Via SSH (institution-specific password)
|
|
||||||
ssh nao@nao.local
|
|
||||||
# Then run wake-up command (see integration repo docs)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Start HRIStudio
|
### 2. Start HRIStudio
|
||||||
```bash
|
```bash
|
||||||
cd ~/Documents/Projects/hristudio
|
cd ~/Documents/Projects/hristudio
|
||||||
bun dev
|
bun dev
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Test Connection
|
### 3. Verify Connection
|
||||||
- Open: `http://localhost:3000/nao-test`
|
- Open: `http://localhost:3000`
|
||||||
- Click "Connect"
|
- Navigate to trial wizard
|
||||||
- Test robot commands
|
- WebSocket should connect automatically
|
||||||
|
|
||||||
## Essential Commands
|
## Docker Services
|
||||||
|
|
||||||
### Test Connectivity
|
| Service | Port | Description |
|
||||||
```bash
|
|---------|------|-------------|
|
||||||
ping nao.local # Test network
|
| nao_driver | - | NAOqi driver node |
|
||||||
ros2 topic list | grep naoqi # Check ROS topics
|
| ros_bridge | 9090 | WebSocket bridge |
|
||||||
```
|
| ros_api | - | ROS API services |
|
||||||
|
|
||||||
### Manual Control
|
|
||||||
```bash
|
|
||||||
# Speech
|
|
||||||
ros2 topic pub --once /speech std_msgs/String "data: 'Hello world'"
|
|
||||||
|
|
||||||
# Movement (robot must be awake!)
|
|
||||||
ros2 topic pub --once /cmd_vel geometry_msgs/msg/Twist '{linear: {x: 0.1}}'
|
|
||||||
|
|
||||||
# Stop
|
|
||||||
ros2 topic pub --once /cmd_vel geometry_msgs/msg/Twist '{linear: {x: 0.0}}'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Monitor Status
|
|
||||||
```bash
|
|
||||||
ros2 topic echo /naoqi_driver/battery # Battery level
|
|
||||||
ros2 topic echo /naoqi_driver/joint_states # Joint positions
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
**Robot not moving:** Press chest button for 3 seconds to wake up
|
|
||||||
|
|
||||||
**WebSocket fails:** Check rosbridge is running on port 9090
|
|
||||||
```bash
|
|
||||||
ss -an | grep 9090
|
|
||||||
```
|
|
||||||
|
|
||||||
**Connection lost:** Restart rosbridge
|
|
||||||
```bash
|
|
||||||
pkill -f rosbridge
|
|
||||||
ros2 run rosbridge_server rosbridge_websocket
|
|
||||||
```
|
|
||||||
|
|
||||||
## ROS Topics
|
## ROS Topics
|
||||||
|
|
||||||
**Commands (Input):**
|
**Commands (Publish to these):**
|
||||||
- `/speech` - Text-to-speech
|
```
|
||||||
- `/cmd_vel` - Movement
|
/speech - Text-to-speech
|
||||||
- `/joint_angles` - Joint control
|
/cmd_vel - Velocity commands (movement)
|
||||||
|
/joint_angles - Joint position commands
|
||||||
|
```
|
||||||
|
|
||||||
**Sensors (Output):**
|
**Sensors (Subscribe to these):**
|
||||||
- `/naoqi_driver/joint_states` - Joint data
|
```
|
||||||
- `/naoqi_driver/battery` - Battery level
|
/camera/front/image_raw - Front camera
|
||||||
- `/naoqi_driver/bumper` - Foot sensors
|
/camera/bottom/image_raw - Bottom camera
|
||||||
- `/naoqi_driver/sonar/*` - Distance sensors
|
/joint_states - Joint positions
|
||||||
- `/naoqi_driver/camera/*` - Camera feeds
|
/imu/torso - IMU data
|
||||||
|
/bumper - Foot bumpers
|
||||||
|
/{hand,head}_touch - Touch sensors
|
||||||
|
/sonar/{left,right} - Ultrasonic sensors
|
||||||
|
/info - Robot info
|
||||||
|
```
|
||||||
|
|
||||||
|
## Manual Control
|
||||||
|
|
||||||
|
### Test Connectivity
|
||||||
|
```bash
|
||||||
|
# Network
|
||||||
|
ping 10.0.0.42
|
||||||
|
|
||||||
|
# ROS topics (inside Docker)
|
||||||
|
docker exec -it nao6-hristudio-integration-nao_driver-1 ros2 topic list
|
||||||
|
```
|
||||||
|
|
||||||
|
### Direct Commands (inside Docker)
|
||||||
|
```bash
|
||||||
|
# Speech
|
||||||
|
docker exec -it nao6-hristudio-integration-nao_driver-1 \
|
||||||
|
ros2 topic pub --once /speech std_msgs/String "{data: 'Hello'}"
|
||||||
|
|
||||||
|
# Movement (robot must be awake!)
|
||||||
|
docker exec -it nao6-hristudio-integration-nao_driver-1 \
|
||||||
|
ros2 topic pub --once /cmd_vel geometry_msgs/Twist "{linear: {x: 0.1, y: 0.0, z: 0.0}}"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Robot Control via SSH
|
||||||
|
```bash
|
||||||
|
# SSH to robot
|
||||||
|
sshpass -p "nao" ssh nao@10.0.0.42
|
||||||
|
|
||||||
|
# Wake up
|
||||||
|
qicli call ALMotion.wakeUp
|
||||||
|
|
||||||
|
# Disable autonomous life
|
||||||
|
qicli call ALAutonomousLife.setState disabled
|
||||||
|
|
||||||
|
# Go to stand
|
||||||
|
qicli call ALRobotPosture.goToPosture Stand 0.5
|
||||||
|
```
|
||||||
|
|
||||||
## WebSocket
|
## WebSocket
|
||||||
|
|
||||||
@@ -99,79 +103,76 @@ ros2 run rosbridge_server rosbridge_websocket
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## More Information
|
## Troubleshooting
|
||||||
|
|
||||||
See **[nao6-hristudio-integration](../../nao6-hristudio-integration/)** repository for:
|
**Robot not moving:**
|
||||||
- Complete installation guide
|
- Check robot is awake: `qicli call ALMotion.isWakeUp` → returns `true`
|
||||||
- Detailed usage instructions
|
- If not: `qicli call ALMotion.wakeUp`
|
||||||
- Full troubleshooting guide
|
|
||||||
- Plugin definitions
|
|
||||||
- Launch file configurations
|
|
||||||
|
|
||||||
## Common Use Cases
|
**WebSocket fails:**
|
||||||
|
|
||||||
### Make Robot Speak
|
|
||||||
```bash
|
```bash
|
||||||
ros2 topic pub --once /speech std_msgs/String "data: 'Welcome to the experiment'"
|
# Check rosbridge is running
|
||||||
|
docker compose ps
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
docker compose logs ros_bridge
|
||||||
```
|
```
|
||||||
|
|
||||||
### Walk Forward 3 Steps
|
**Connection issues:**
|
||||||
```bash
|
```bash
|
||||||
ros2 topic pub --times 3 /cmd_vel geometry_msgs/msg/Twist '{linear: {x: 0.1, y: 0.0, z: 0.0}, angular: {x: 0.0, y: 0.0, z: 0.0}}'
|
# Restart Docker
|
||||||
|
docker compose down && docker compose up -d
|
||||||
|
|
||||||
|
# Check robot IP in .env
|
||||||
|
cat nao6-hristudio-integration/.env
|
||||||
```
|
```
|
||||||
|
|
||||||
### Turn Head Left
|
## Environment Variables
|
||||||
```bash
|
|
||||||
ros2 topic pub --once /joint_angles naoqi_bridge_msgs/msg/JointAnglesWithSpeed '{joint_names: ["HeadYaw"], joint_angles: [0.8], speed: 0.2}'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Emergency Stop
|
Create `nao6-hristudio-integration/.env`:
|
||||||
```bash
|
```
|
||||||
ros2 topic pub --once /cmd_vel geometry_msgs/msg/Twist '{linear: {x: 0.0, y: 0.0, z: 0.0}, angular: {x: 0.0, y: 0.0, z: 0.0}}'
|
NAO_IP=10.0.0.42
|
||||||
|
NAO_USERNAME=nao
|
||||||
|
NAO_PASSWORD=nao
|
||||||
|
BRIDGE_PORT=9090
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🚨 Safety Notes
|
## 🚨 Safety Notes
|
||||||
|
|
||||||
- **Always wake up robot before movement commands**
|
- **Always verify robot is awake before movement commands**
|
||||||
- **Keep emergency stop accessible**
|
- **Keep emergency stop accessible** (`qicli call ALMotion.rest()`)
|
||||||
- **Start with small movements (0.05 m/s)**
|
- **Start with small movements (0.05 m/s)**
|
||||||
- **Monitor battery level during experiments**
|
- **Monitor battery level**
|
||||||
- **Ensure clear space around robot**
|
- **Ensure clear space around robot**
|
||||||
|
|
||||||
## 📝 Credentials
|
## Credentials
|
||||||
|
|
||||||
**Default NAO Login:**
|
**NAO Robot:**
|
||||||
|
- IP: `10.0.0.42` (configurable)
|
||||||
- Username: `nao`
|
- Username: `nao`
|
||||||
- Password: `robolab` (institution-specific)
|
- Password: `nao`
|
||||||
|
|
||||||
**HRIStudio Login:**
|
**HRIStudio:**
|
||||||
- Email: `sean@soconnor.dev`
|
- Email: `sean@soconnor.dev`
|
||||||
- Password: `password123`
|
- Password: `password123`
|
||||||
|
|
||||||
## 🔄 Complete Restart Procedure
|
## Complete Restart
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Kill all processes
|
# 1. Restart Docker integration
|
||||||
sudo fuser -k 9090/tcp
|
cd ~/Documents/Projects/nao6-hristudio-integration
|
||||||
pkill -f "rosbridge\|naoqi\|ros2"
|
docker compose down
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
# 2. Restart database
|
# 2. Verify robot is awake (check logs)
|
||||||
sudo docker compose down && sudo docker compose up -d
|
docker compose logs nao_driver | grep -i "wake\|autonomous"
|
||||||
|
|
||||||
# 3. Start ROS integration
|
# 3. Start HRIStudio
|
||||||
cd ~/naoqi_ros2_ws && source install/setup.bash
|
cd ~/Documents/Projects/hristudio
|
||||||
ros2 launch install/nao_launch/share/nao_launch/launch/nao6_hristudio.launch.py nao_ip:=nao.local password:=robolab
|
bun dev
|
||||||
|
|
||||||
# 4. Wake up robot (in another terminal)
|
|
||||||
sshpass -p "robolab" ssh nao@nao.local "python2 -c \"import sys; sys.path.append('/opt/aldebaran/lib/python2.7/site-packages'); import naoqi; naoqi.ALProxy('ALMotion', '127.0.0.1', 9559).wakeUp()\""
|
|
||||||
|
|
||||||
# 5. Start HRIStudio (in another terminal)
|
|
||||||
cd /home/robolab/Documents/Projects/hristudio && bun dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**📖 For detailed setup instructions, see:** [NAO6 Complete Integration Guide](./nao6-integration-complete-guide.md)
|
|
||||||
|
|
||||||
**✅ Integration Status:** Production Ready
|
**✅ Integration Status:** Production Ready
|
||||||
**🤖 Tested With:** NAO V6.0 / NAOqi 2.8.7.4 / ROS2 Humble
|
**🤖 Tested With:** NAO V6 / ROS2 Humble / Docker
|
||||||
|
|||||||
@@ -111,6 +111,39 @@ http://localhost:3000/api/trpc/
|
|||||||
- **`dashboard`**: Overview stats, recent activity, study progress
|
- **`dashboard`**: Overview stats, recent activity, study progress
|
||||||
- **`admin`**: Repository management, system settings
|
- **`admin`**: Repository management, system settings
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🤖 NAO6 Docker Integration
|
||||||
|
|
||||||
|
### Quick Start
|
||||||
|
```bash
|
||||||
|
cd ~/Documents/Projects/nao6-hristudio-integration
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Robot automatically wakes up and disables autonomous life on startup.
|
||||||
|
|
||||||
|
### ROS Topics
|
||||||
|
```
|
||||||
|
/speech - Text-to-speech
|
||||||
|
/cmd_vel - Movement commands
|
||||||
|
/joint_angles - Joint position control
|
||||||
|
/camera/front/image_raw
|
||||||
|
/camera/bottom/image_raw
|
||||||
|
/imu/torso
|
||||||
|
/bumper
|
||||||
|
/{hand,head}_touch
|
||||||
|
/sonar/{left,right}
|
||||||
|
/info
|
||||||
|
```
|
||||||
|
|
||||||
|
### Plugin System
|
||||||
|
- Plugin identifier: `nao6-ros2`
|
||||||
|
- Plugin name: `NAO6 Robot (ROS2 Integration)`
|
||||||
|
- Action types: `nao6-ros2.say_with_emotion`, `nao6-ros2.move_arm`, etc.
|
||||||
|
|
||||||
|
See [nao6-quick-reference.md](./nao6-quick-reference.md) for full details.
|
||||||
|
|
||||||
### Example Usage
|
### Example Usage
|
||||||
```typescript
|
```typescript
|
||||||
// Get user's studies
|
// Get user's studies
|
||||||
|
|||||||
3908
drizzle/meta/0000_snapshot.json
Normal file
3908
drizzle/meta/0000_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
13
drizzle/meta/_journal.json
Normal file
13
drizzle/meta/_journal.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1774137504617,
|
||||||
|
"tag": "0000_old_tattoo",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,55 +1,27 @@
|
|||||||
import type { Session } from "next-auth";
|
|
||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { auth } from "./src/server/auth";
|
|
||||||
|
|
||||||
export default auth((req: NextRequest & { auth: Session | null }) => {
|
export default async function middleware(request: NextRequest) {
|
||||||
const { nextUrl } = req;
|
const { nextUrl } = request;
|
||||||
const isLoggedIn = !!req.auth;
|
|
||||||
|
|
||||||
// Define route patterns
|
// Skip session checks for now to debug the auth issue
|
||||||
const isApiAuthRoute = nextUrl.pathname.startsWith("/api/auth");
|
const isApiRoute = nextUrl.pathname.startsWith("/api");
|
||||||
const isPublicRoute = ["/", "/auth/signin", "/auth/signup"].includes(
|
|
||||||
nextUrl.pathname,
|
|
||||||
);
|
|
||||||
const isAuthRoute = nextUrl.pathname.startsWith("/auth");
|
const isAuthRoute = nextUrl.pathname.startsWith("/auth");
|
||||||
|
|
||||||
// Allow API auth routes to pass through
|
if (isApiRoute) {
|
||||||
if (isApiAuthRoute) {
|
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
// If user is on auth pages and already logged in, redirect to dashboard
|
// Allow auth routes through for now
|
||||||
if (isAuthRoute && isLoggedIn) {
|
if (isAuthRoute) {
|
||||||
return NextResponse.redirect(new URL("/", nextUrl));
|
return NextResponse.next();
|
||||||
}
|
|
||||||
|
|
||||||
// If user is not logged in and trying to access protected routes
|
|
||||||
if (!isLoggedIn && !isPublicRoute && !isAuthRoute) {
|
|
||||||
let callbackUrl = nextUrl.pathname;
|
|
||||||
if (nextUrl.search) {
|
|
||||||
callbackUrl += nextUrl.search;
|
|
||||||
}
|
|
||||||
|
|
||||||
const encodedCallbackUrl = encodeURIComponent(callbackUrl);
|
|
||||||
return NextResponse.redirect(
|
|
||||||
new URL(`/auth/signin?callbackUrl=${encodedCallbackUrl}`, nextUrl),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
});
|
}
|
||||||
|
|
||||||
// Configure which routes the middleware should run on
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
matcher: [
|
||||||
/*
|
|
||||||
* Match all request paths except for the ones starting with:
|
|
||||||
* - _next/static (static files)
|
|
||||||
* - _next/image (image optimization files)
|
|
||||||
* - favicon.ico (favicon file)
|
|
||||||
* - public files (images, etc.)
|
|
||||||
*/
|
|
||||||
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
|
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"@auth/drizzle-adapter": "^1.11.1",
|
"@auth/drizzle-adapter": "^1.11.1",
|
||||||
"@aws-sdk/client-s3": "^3.989.0",
|
"@aws-sdk/client-s3": "^3.989.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.989.0",
|
"@aws-sdk/s3-request-presigner": "^3.989.0",
|
||||||
|
"@better-auth/drizzle-adapter": "^1.5.5",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
@@ -67,6 +68,7 @@
|
|||||||
"@types/js-cookie": "^3.0.6",
|
"@types/js-cookie": "^3.0.6",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
|
"better-auth": "^1.5.5",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
|||||||
Submodule robot-plugins updated: 31beaffc5b...ff48567918
@@ -564,6 +564,7 @@ async function seedNAO6Plugin() {
|
|||||||
|
|
||||||
const pluginData: InsertPlugin = {
|
const pluginData: InsertPlugin = {
|
||||||
robotId: robotId,
|
robotId: robotId,
|
||||||
|
identifier: "nao6-ros2",
|
||||||
name: "NAO6 Robot (Enhanced ROS2 Integration)",
|
name: "NAO6 Robot (Enhanced ROS2 Integration)",
|
||||||
version: "2.0.0",
|
version: "2.0.0",
|
||||||
description:
|
description:
|
||||||
|
|||||||
274
scripts/archive/seed-story-red-rock.ts
Normal file
274
scripts/archive/seed-story-red-rock.ts
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
import { drizzle } from "drizzle-orm/postgres-js";
|
||||||
|
import postgres from "postgres";
|
||||||
|
import * as schema from "../../src/server/db/schema";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
|
||||||
|
// Database connection
|
||||||
|
const connectionString = process.env.DATABASE_URL!;
|
||||||
|
const connection = postgres(connectionString);
|
||||||
|
const db = drizzle(connection, { schema });
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log("🌱 Seeding 'Story: Red Rock' experiment...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Find Admin User & Study
|
||||||
|
const user = await db.query.users.findFirst({
|
||||||
|
where: (users, { eq }) => eq(users.email, "sean@soconnor.dev"),
|
||||||
|
});
|
||||||
|
if (!user) throw new Error("Admin user 'sean@soconnor.dev' not found.");
|
||||||
|
|
||||||
|
const study = await db.query.studies.findFirst({
|
||||||
|
where: (studies, { eq }) => eq(studies.name, "Comparative WoZ Study"),
|
||||||
|
});
|
||||||
|
if (!study) throw new Error("Study 'Comparative WoZ Study' not found.");
|
||||||
|
|
||||||
|
const robot = await db.query.robots.findFirst({
|
||||||
|
where: (robots, { eq }) => eq(robots.name, "NAO6"),
|
||||||
|
});
|
||||||
|
if (!robot) throw new Error("Robot 'NAO6' not found.");
|
||||||
|
|
||||||
|
// 2. Create Experiment
|
||||||
|
const [experiment] = await db
|
||||||
|
.insert(schema.experiments)
|
||||||
|
.values({
|
||||||
|
studyId: study.id,
|
||||||
|
name: "Story: Red Rock",
|
||||||
|
description:
|
||||||
|
"A story about a red rock on Mars with comprehension check and branching.",
|
||||||
|
version: 1,
|
||||||
|
status: "draft",
|
||||||
|
robotId: robot.id,
|
||||||
|
createdBy: user.id,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!experiment) throw new Error("Failed to create experiment");
|
||||||
|
console.log(`✅ Created Experiment: ${experiment.id}`);
|
||||||
|
|
||||||
|
// 3. Create Steps (in reverse for ID references if needed, but we'll use uuid placeholders)
|
||||||
|
const conclusionId = uuidv4();
|
||||||
|
const branchAId = uuidv4();
|
||||||
|
const branchBId = uuidv4();
|
||||||
|
const checkId = uuidv4();
|
||||||
|
|
||||||
|
// Step 1: The Hook
|
||||||
|
const [step1] = await db
|
||||||
|
.insert(schema.steps)
|
||||||
|
.values({
|
||||||
|
experimentId: experiment.id,
|
||||||
|
name: "The Hook",
|
||||||
|
type: "wizard",
|
||||||
|
orderIndex: 0,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// Step 2: The Narrative
|
||||||
|
const [step2] = await db
|
||||||
|
.insert(schema.steps)
|
||||||
|
.values({
|
||||||
|
experimentId: experiment.id,
|
||||||
|
name: "The Narrative",
|
||||||
|
type: "wizard",
|
||||||
|
orderIndex: 1,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// Step 3: Comprehension Check (Conditional)
|
||||||
|
const [step3] = await db
|
||||||
|
.insert(schema.steps)
|
||||||
|
.values({
|
||||||
|
id: checkId,
|
||||||
|
experimentId: experiment.id,
|
||||||
|
name: "Comprehension Check",
|
||||||
|
type: "conditional",
|
||||||
|
orderIndex: 2,
|
||||||
|
conditions: {
|
||||||
|
variable: "last_wizard_response",
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: "Answer: Red (Correct)",
|
||||||
|
value: "Red",
|
||||||
|
variant: "default",
|
||||||
|
nextStepId: branchAId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Answer: Other (Incorrect)",
|
||||||
|
value: "Incorrect",
|
||||||
|
variant: "destructive",
|
||||||
|
nextStepId: branchBId,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// Step 4: Branch A (Correct)
|
||||||
|
const [step4] = await db
|
||||||
|
.insert(schema.steps)
|
||||||
|
.values({
|
||||||
|
id: branchAId,
|
||||||
|
experimentId: experiment.id,
|
||||||
|
name: "Branch A: Correct Response",
|
||||||
|
type: "wizard",
|
||||||
|
orderIndex: 3,
|
||||||
|
conditions: { nextStepId: conclusionId }, // SKIP BRANCH B
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// Step 5: Branch B (Incorrect)
|
||||||
|
const [step5] = await db
|
||||||
|
.insert(schema.steps)
|
||||||
|
.values({
|
||||||
|
id: branchBId,
|
||||||
|
experimentId: experiment.id,
|
||||||
|
name: "Branch B: Incorrect Response",
|
||||||
|
type: "wizard",
|
||||||
|
orderIndex: 4,
|
||||||
|
conditions: { nextStepId: conclusionId },
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// Step 6: Conclusion
|
||||||
|
const [step6] = await db
|
||||||
|
.insert(schema.steps)
|
||||||
|
.values({
|
||||||
|
id: conclusionId,
|
||||||
|
experimentId: experiment.id,
|
||||||
|
name: "Conclusion",
|
||||||
|
type: "wizard",
|
||||||
|
orderIndex: 5,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// 4. Create Actions
|
||||||
|
|
||||||
|
// The Hook
|
||||||
|
await db.insert(schema.actions).values([
|
||||||
|
{
|
||||||
|
stepId: step1!.id,
|
||||||
|
name: "Say Hello",
|
||||||
|
type: "nao6-ros2.say_text",
|
||||||
|
orderIndex: 0,
|
||||||
|
parameters: { text: "Hello! Are you ready for a story?" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stepId: step1!.id,
|
||||||
|
name: "Wave",
|
||||||
|
type: "nao6-ros2.move_arm",
|
||||||
|
orderIndex: 1,
|
||||||
|
parameters: { arm: "right", shoulder_pitch: 0.5 },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// The Narrative
|
||||||
|
await db.insert(schema.actions).values([
|
||||||
|
{
|
||||||
|
stepId: step2!.id,
|
||||||
|
name: "The Story",
|
||||||
|
type: "nao6-ros2.say_text",
|
||||||
|
orderIndex: 0,
|
||||||
|
parameters: {
|
||||||
|
text: "Once, a traveler went to Mars. He found a bright red rock that glowed.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stepId: step2!.id,
|
||||||
|
name: "Look Left",
|
||||||
|
type: "nao6-ros2.turn_head",
|
||||||
|
orderIndex: 1,
|
||||||
|
parameters: { yaw: 0.5, speed: 0.3 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stepId: step2!.id,
|
||||||
|
name: "Look Right",
|
||||||
|
type: "nao6-ros2.turn_head",
|
||||||
|
orderIndex: 2,
|
||||||
|
parameters: { yaw: -0.5, speed: 0.3 },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Comprehension Check
|
||||||
|
await db.insert(schema.actions).values([
|
||||||
|
{
|
||||||
|
stepId: step3!.id,
|
||||||
|
name: "Ask Color",
|
||||||
|
type: "nao6-ros2.say_text",
|
||||||
|
orderIndex: 0,
|
||||||
|
parameters: { text: "What color was the rock I found on Mars?" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stepId: step3!.id,
|
||||||
|
name: "Wait for Color",
|
||||||
|
type: "wizard_wait_for_response",
|
||||||
|
orderIndex: 1,
|
||||||
|
parameters: {
|
||||||
|
options: ["Red", "Blue", "Green", "Incorrect"],
|
||||||
|
prompt_text: "What color did the participant say?",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Branch A (Using say_with_emotion)
|
||||||
|
await db
|
||||||
|
.insert(schema.actions)
|
||||||
|
.values([
|
||||||
|
{
|
||||||
|
stepId: step4!.id,
|
||||||
|
name: "Happy Response",
|
||||||
|
type: "nao6-ros2.say_with_emotion",
|
||||||
|
orderIndex: 0,
|
||||||
|
parameters: {
|
||||||
|
text: "Exacty! It was a glowing red rock.",
|
||||||
|
emotion: "happy",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Branch B
|
||||||
|
await db.insert(schema.actions).values([
|
||||||
|
{
|
||||||
|
stepId: step5!.id,
|
||||||
|
name: "Correct them",
|
||||||
|
type: "nao6-ros2.say_text",
|
||||||
|
orderIndex: 0,
|
||||||
|
parameters: { text: "Actually, it was red." },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stepId: step5!.id,
|
||||||
|
name: "Shake Head",
|
||||||
|
type: "nao6-ros2.turn_head",
|
||||||
|
orderIndex: 1,
|
||||||
|
parameters: { yaw: 0.3, speed: 0.5 },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Conclusion
|
||||||
|
await db.insert(schema.actions).values([
|
||||||
|
{
|
||||||
|
stepId: step6!.id,
|
||||||
|
name: "Final Goodbye",
|
||||||
|
type: "nao6-ros2.say_text",
|
||||||
|
orderIndex: 0,
|
||||||
|
parameters: { text: "That is all for today. Goodbye!" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stepId: step6!.id,
|
||||||
|
name: "Rest",
|
||||||
|
type: "nao6-ros2.move_arm",
|
||||||
|
orderIndex: 1,
|
||||||
|
parameters: { shoulder_pitch: 1.5 },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
console.log("✅ Seed completed successfully!");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("❌ Seed failed:", err);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
await connection.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
37
scripts/migrate-add-identifier.ts
Normal file
37
scripts/migrate-add-identifier.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { db } from "~/server/db";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
|
||||||
|
async function migrate() {
|
||||||
|
console.log("Adding identifier column to hs_plugin...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.execute(
|
||||||
|
sql`ALTER TABLE hs_plugin ADD COLUMN identifier varchar(100)`,
|
||||||
|
);
|
||||||
|
console.log("✓ Added identifier column");
|
||||||
|
} catch (e: any) {
|
||||||
|
console.log("Column may already exist:", e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.execute(
|
||||||
|
sql`UPDATE hs_plugin SET identifier = name WHERE identifier IS NULL`,
|
||||||
|
);
|
||||||
|
console.log("✓ Copied name to identifier");
|
||||||
|
} catch (e: any) {
|
||||||
|
console.log("Error copying:", e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.execute(
|
||||||
|
sql`ALTER TABLE hs_plugin ADD CONSTRAINT hs_plugin_identifier_unique UNIQUE (identifier)`,
|
||||||
|
);
|
||||||
|
console.log("✓ Added unique constraint");
|
||||||
|
} catch (e: any) {
|
||||||
|
console.log("Constraint may already exist:", e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Migration complete!");
|
||||||
|
}
|
||||||
|
|
||||||
|
migrate().catch(console.error);
|
||||||
@@ -14,31 +14,31 @@ const db = drizzle(connection, { schema });
|
|||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
|
|
||||||
// Function to load plugin definition (Remote -> Local Fallback)
|
// Function to load plugin definition (Local first -> Remote fallback)
|
||||||
async function loadNaoPluginDef() {
|
async function loadNaoPluginDef() {
|
||||||
const REMOTE_URL = "https://repo.hristudio.com/plugins/nao6-ros2.json";
|
|
||||||
const LOCAL_PATH = path.join(
|
const LOCAL_PATH = path.join(
|
||||||
__dirname,
|
__dirname,
|
||||||
"../robot-plugins/plugins/nao6-ros2.json",
|
"../robot-plugins/plugins/nao6-ros2.json",
|
||||||
);
|
);
|
||||||
|
const REMOTE_URL = "https://repo.hristudio.com/plugins/nao6-ros2.json";
|
||||||
|
|
||||||
|
// Always load from local file first (has latest fixes)
|
||||||
try {
|
try {
|
||||||
console.log(
|
console.log(`📁 Loading plugin definition from local file...`);
|
||||||
`🌐 Attempting to fetch plugin definition from ${REMOTE_URL}...`,
|
const rawPlugin = fs.readFileSync(LOCAL_PATH, "utf-8");
|
||||||
|
console.log("✅ Successfully loaded local plugin definition.");
|
||||||
|
return JSON.parse(rawPlugin);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(
|
||||||
|
`⚠️ Local file load failed. Falling back to remote: ${REMOTE_URL}`,
|
||||||
);
|
);
|
||||||
const response = await fetch(REMOTE_URL, {
|
const response = await fetch(REMOTE_URL, {
|
||||||
signal: AbortSignal.timeout(3000),
|
signal: AbortSignal.timeout(5000),
|
||||||
}); // 3s timeout
|
});
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
console.log("✅ Successfully fetched plugin definition from remote.");
|
console.log("✅ Successfully fetched plugin definition from remote.");
|
||||||
return data;
|
return data;
|
||||||
} catch (err) {
|
|
||||||
console.warn(
|
|
||||||
`⚠️ Remote fetch failed (${err instanceof Error ? err.message : String(err)}). Falling back to local file.`,
|
|
||||||
);
|
|
||||||
const rawPlugin = fs.readFileSync(LOCAL_PATH, "utf-8");
|
|
||||||
return JSON.parse(rawPlugin);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +76,9 @@ async function main() {
|
|||||||
|
|
||||||
// 1. Clean existing data (Full Wipe)
|
// 1. Clean existing data (Full Wipe)
|
||||||
console.log("🧹 Cleaning existing data...");
|
console.log("🧹 Cleaning existing data...");
|
||||||
|
await db.delete(schema.sessions).where(sql`1=1`);
|
||||||
|
await db.delete(schema.accounts).where(sql`1=1`);
|
||||||
|
await db.delete(schema.verificationTokens).where(sql`1=1`);
|
||||||
await db.delete(schema.mediaCaptures).where(sql`1=1`);
|
await db.delete(schema.mediaCaptures).where(sql`1=1`);
|
||||||
await db.delete(schema.trialEvents).where(sql`1=1`);
|
await db.delete(schema.trialEvents).where(sql`1=1`);
|
||||||
await db.delete(schema.trials).where(sql`1=1`);
|
await db.delete(schema.trials).where(sql`1=1`);
|
||||||
@@ -93,20 +96,24 @@ async function main() {
|
|||||||
await db.delete(schema.users).where(sql`1=1`);
|
await db.delete(schema.users).where(sql`1=1`);
|
||||||
await db.delete(schema.robots).where(sql`1=1`);
|
await db.delete(schema.robots).where(sql`1=1`);
|
||||||
|
|
||||||
// 2. Create Users
|
// 2. Create Users (Better Auth manages credentials)
|
||||||
console.log("👥 Creating users...");
|
console.log("👥 Creating users...");
|
||||||
const hashedPassword = await bcrypt.hash("password123", 12);
|
const hashedPassword = await bcrypt.hash("password123", 12);
|
||||||
|
|
||||||
const gravatarUrl = (email: string) =>
|
const gravatarUrl = (email: string) =>
|
||||||
`https://www.gravatar.com/avatar/${createHash("md5").update(email.toLowerCase().trim()).digest("hex")}?d=identicon`;
|
`https://www.gravatar.com/avatar/${createHash("md5").update(email.toLowerCase().trim()).digest("hex")}?d=identicon`;
|
||||||
|
|
||||||
|
// Generate text IDs (Better Auth uses text-based IDs)
|
||||||
|
const adminId = `admin_${randomUUID()}`;
|
||||||
|
const researcherId = `researcher_${randomUUID()}`;
|
||||||
|
|
||||||
const [adminUser] = await db
|
const [adminUser] = await db
|
||||||
.insert(schema.users)
|
.insert(schema.users)
|
||||||
.values({
|
.values({
|
||||||
|
id: adminId,
|
||||||
name: "Sean O'Connor",
|
name: "Sean O'Connor",
|
||||||
email: "sean@soconnor.dev",
|
email: "sean@soconnor.dev",
|
||||||
password: hashedPassword,
|
emailVerified: true,
|
||||||
emailVerified: new Date(),
|
|
||||||
image: gravatarUrl("sean@soconnor.dev"),
|
image: gravatarUrl("sean@soconnor.dev"),
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
@@ -114,16 +121,39 @@ async function main() {
|
|||||||
const [researcherUser] = await db
|
const [researcherUser] = await db
|
||||||
.insert(schema.users)
|
.insert(schema.users)
|
||||||
.values({
|
.values({
|
||||||
|
id: researcherId,
|
||||||
name: "Dr. Felipe Perrone",
|
name: "Dr. Felipe Perrone",
|
||||||
email: "felipe.perrone@bucknell.edu",
|
email: "felipe.perrone@bucknell.edu",
|
||||||
password: hashedPassword,
|
emailVerified: true,
|
||||||
emailVerified: new Date(),
|
|
||||||
image: "https://api.dicebear.com/7.x/avataaars/svg?seed=Felipe",
|
image: "https://api.dicebear.com/7.x/avataaars/svg?seed=Felipe",
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
if (!adminUser) throw new Error("Failed to create admin user");
|
if (!adminUser) throw new Error("Failed to create admin user");
|
||||||
|
|
||||||
|
// Create credential accounts for Better Auth (accountId = userId for credential provider)
|
||||||
|
await db.insert(schema.accounts).values({
|
||||||
|
id: `acc_${randomUUID()}`,
|
||||||
|
userId: adminUser.id,
|
||||||
|
providerId: "credential",
|
||||||
|
accountId: adminUser.id,
|
||||||
|
password: hashedPassword,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (researcherUser) {
|
||||||
|
await db.insert(schema.accounts).values({
|
||||||
|
id: `acc_${randomUUID()}`,
|
||||||
|
userId: researcherUser.id,
|
||||||
|
providerId: "credential",
|
||||||
|
accountId: researcherUser.id,
|
||||||
|
password: hashedPassword,
|
||||||
|
});
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insert(schema.userSystemRoles)
|
||||||
|
.values({ userId: researcherUser.id, role: "researcher" });
|
||||||
|
}
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.insert(schema.userSystemRoles)
|
.insert(schema.userSystemRoles)
|
||||||
.values({ userId: adminUser.id, role: "administrator" });
|
.values({ userId: adminUser.id, role: "administrator" });
|
||||||
@@ -159,6 +189,7 @@ async function main() {
|
|||||||
.insert(schema.plugins)
|
.insert(schema.plugins)
|
||||||
.values({
|
.values({
|
||||||
robotId: naoRobot!.id,
|
robotId: naoRobot!.id,
|
||||||
|
identifier: NAO_PLUGIN_DEF.robotId,
|
||||||
name: NAO_PLUGIN_DEF.name,
|
name: NAO_PLUGIN_DEF.name,
|
||||||
version: NAO_PLUGIN_DEF.version,
|
version: NAO_PLUGIN_DEF.version,
|
||||||
description: NAO_PLUGIN_DEF.description,
|
description: NAO_PLUGIN_DEF.description,
|
||||||
@@ -196,6 +227,7 @@ async function main() {
|
|||||||
const [corePlugin] = await db
|
const [corePlugin] = await db
|
||||||
.insert(schema.plugins)
|
.insert(schema.plugins)
|
||||||
.values({
|
.values({
|
||||||
|
identifier: CORE_PLUGIN_DEF.id,
|
||||||
name: CORE_PLUGIN_DEF.name,
|
name: CORE_PLUGIN_DEF.name,
|
||||||
version: CORE_PLUGIN_DEF.version,
|
version: CORE_PLUGIN_DEF.version,
|
||||||
description: CORE_PLUGIN_DEF.description,
|
description: CORE_PLUGIN_DEF.description,
|
||||||
@@ -211,6 +243,7 @@ async function main() {
|
|||||||
const [wozPlugin] = await db
|
const [wozPlugin] = await db
|
||||||
.insert(schema.plugins)
|
.insert(schema.plugins)
|
||||||
.values({
|
.values({
|
||||||
|
identifier: WOZ_PLUGIN_DEF.id,
|
||||||
name: WOZ_PLUGIN_DEF.name,
|
name: WOZ_PLUGIN_DEF.name,
|
||||||
version: WOZ_PLUGIN_DEF.version,
|
version: WOZ_PLUGIN_DEF.version,
|
||||||
description: WOZ_PLUGIN_DEF.description,
|
description: WOZ_PLUGIN_DEF.description,
|
||||||
@@ -262,6 +295,35 @@ async function main() {
|
|||||||
// 5. Create Steps & Actions (The Interactive Storyteller Protocol)
|
// 5. Create Steps & Actions (The Interactive Storyteller Protocol)
|
||||||
console.log("🎬 Creating experiment steps (Interactive Storyteller)...");
|
console.log("🎬 Creating experiment steps (Interactive Storyteller)...");
|
||||||
|
|
||||||
|
// Pre-create steps that will be referenced before they're defined
|
||||||
|
// --- Step 5: Story Continues (Convergence point for both branches) ---
|
||||||
|
const [step5] = await db
|
||||||
|
.insert(schema.steps)
|
||||||
|
.values({
|
||||||
|
experimentId: experiment!.id,
|
||||||
|
name: "Story Continues",
|
||||||
|
description: "Both branches converge here",
|
||||||
|
type: "robot",
|
||||||
|
orderIndex: 5,
|
||||||
|
required: true,
|
||||||
|
durationEstimate: 15,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// --- Step 6: Conclusion ---
|
||||||
|
const [step6] = await db
|
||||||
|
.insert(schema.steps)
|
||||||
|
.values({
|
||||||
|
experimentId: experiment!.id,
|
||||||
|
name: "Conclusion",
|
||||||
|
description: "End the story and thank participant",
|
||||||
|
type: "robot",
|
||||||
|
orderIndex: 6,
|
||||||
|
required: true,
|
||||||
|
durationEstimate: 25,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
// --- Step 1: The Hook ---
|
// --- Step 1: The Hook ---
|
||||||
const [step1] = await db
|
const [step1] = await db
|
||||||
.insert(schema.steps)
|
.insert(schema.steps)
|
||||||
@@ -363,10 +425,6 @@ async function main() {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// --- Step 3: Comprehension Check (Wizard Decision Point) ---
|
|
||||||
// Note: Wizard will choose to proceed to Step 4a (Correct) or 4b (Incorrect)
|
|
||||||
// --- Step 3: Comprehension Check (Wizard Decision Point) ---
|
|
||||||
// Note: Wizard will choose to proceed to Step 4a (Correct) or 4b (Incorrect)
|
|
||||||
// --- Step 4a: Correct Response Branch ---
|
// --- Step 4a: Correct Response Branch ---
|
||||||
const [step4a] = await db
|
const [step4a] = await db
|
||||||
.insert(schema.steps)
|
.insert(schema.steps)
|
||||||
@@ -378,6 +436,9 @@ async function main() {
|
|||||||
orderIndex: 3,
|
orderIndex: 3,
|
||||||
required: false,
|
required: false,
|
||||||
durationEstimate: 20,
|
durationEstimate: 20,
|
||||||
|
conditions: {
|
||||||
|
nextStepId: step5!.id, // Jump to Story Continues after completing
|
||||||
|
},
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
@@ -392,11 +453,13 @@ async function main() {
|
|||||||
orderIndex: 4,
|
orderIndex: 4,
|
||||||
required: false,
|
required: false,
|
||||||
durationEstimate: 20,
|
durationEstimate: 20,
|
||||||
|
conditions: {
|
||||||
|
nextStepId: step5!.id, // Jump to Story Continues after completing
|
||||||
|
},
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
// --- Step 3: Comprehension Check (Wizard Decision Point) ---
|
// --- Step 3: Comprehension Check (Wizard Decision Point) ---
|
||||||
// Note: Wizard will choose to proceed to Step 4a (Correct) or 4b (Incorrect)
|
|
||||||
const [step3] = await db
|
const [step3] = await db
|
||||||
.insert(schema.steps)
|
.insert(schema.steps)
|
||||||
.values({
|
.values({
|
||||||
@@ -445,10 +508,12 @@ async function main() {
|
|||||||
name: "Wait for Choice",
|
name: "Wait for Choice",
|
||||||
type: "wizard_wait_for_response",
|
type: "wizard_wait_for_response",
|
||||||
orderIndex: 1,
|
orderIndex: 1,
|
||||||
// Define the options that will be presented to the Wizard
|
|
||||||
parameters: {
|
parameters: {
|
||||||
prompt_text: "Did participant answer 'Red' correctly?",
|
prompt_text: "Did participant answer 'Red' correctly?",
|
||||||
options: ["Correct", "Incorrect"],
|
options: [
|
||||||
|
{ label: "Correct", value: "Correct", nextStepId: step4a!.id },
|
||||||
|
{ label: "Incorrect", value: "Incorrect", nextStepId: step4b!.id },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
sourceKind: "core",
|
sourceKind: "core",
|
||||||
pluginId: "hristudio-woz", // Explicit link
|
pluginId: "hristudio-woz", // Explicit link
|
||||||
@@ -553,23 +618,42 @@ async function main() {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// --- Step 5: Conclusion ---
|
// --- Step 5 actions: Story Continues ---
|
||||||
const [step5] = await db
|
|
||||||
.insert(schema.steps)
|
|
||||||
.values({
|
|
||||||
experimentId: experiment!.id,
|
|
||||||
name: "Conclusion",
|
|
||||||
description: "End the story and thank participant",
|
|
||||||
type: "robot",
|
|
||||||
orderIndex: 5,
|
|
||||||
required: true,
|
|
||||||
durationEstimate: 25,
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
await db.insert(schema.actions).values([
|
await db.insert(schema.actions).values([
|
||||||
{
|
{
|
||||||
stepId: step5!.id,
|
stepId: step5!.id,
|
||||||
|
name: "Excited Continuation",
|
||||||
|
type: "nao6-ros2.say_with_emotion",
|
||||||
|
orderIndex: 0,
|
||||||
|
parameters: {
|
||||||
|
text: "And so the adventure continues! The traveler kept the glowing rock as a precious treasure.",
|
||||||
|
emotion: "excited",
|
||||||
|
speed: 1.1,
|
||||||
|
},
|
||||||
|
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
||||||
|
pluginVersion: "2.2.0",
|
||||||
|
category: "interaction",
|
||||||
|
retryable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stepId: step5!.id,
|
||||||
|
name: "Wave Goodbye",
|
||||||
|
type: "nao6-ros2.wave_goodbye",
|
||||||
|
orderIndex: 1,
|
||||||
|
parameters: {
|
||||||
|
text: "See you later!",
|
||||||
|
},
|
||||||
|
pluginId: NAO_PLUGIN_DEF.robotId || "nao6-ros2",
|
||||||
|
pluginVersion: "2.2.0",
|
||||||
|
category: "interaction",
|
||||||
|
retryable: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// --- Step 6 actions: Conclusion ---
|
||||||
|
await db.insert(schema.actions).values([
|
||||||
|
{
|
||||||
|
stepId: step6!.id,
|
||||||
name: "End Story",
|
name: "End Story",
|
||||||
type: "nao6-ros2.say_text",
|
type: "nao6-ros2.say_text",
|
||||||
orderIndex: 0,
|
orderIndex: 0,
|
||||||
@@ -580,7 +664,7 @@ async function main() {
|
|||||||
retryable: true,
|
retryable: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
stepId: step5!.id,
|
stepId: step6!.id,
|
||||||
name: "Bow Gesture",
|
name: "Bow Gesture",
|
||||||
type: "nao6-ros2.move_arm",
|
type: "nao6-ros2.move_arm",
|
||||||
orderIndex: 1,
|
orderIndex: 1,
|
||||||
@@ -843,6 +927,22 @@ async function main() {
|
|||||||
.values(participants)
|
.values(participants)
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
// 7. Pre-create a pending trial for immediate testing
|
||||||
|
console.log("🧪 Creating a pre-seeded pending trial for testing...");
|
||||||
|
const p001 = insertedParticipants.find((p) => p.participantCode === "P101");
|
||||||
|
|
||||||
|
const [pendingTrial] = await db
|
||||||
|
.insert(schema.trials)
|
||||||
|
.values({
|
||||||
|
experimentId: experiment!.id,
|
||||||
|
participantId: p001?.id,
|
||||||
|
status: "scheduled",
|
||||||
|
scheduledAt: new Date(),
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
console.log(` Created pending trial: ${pendingTrial?.id}`);
|
||||||
|
|
||||||
console.log("\n✅ Database seeded successfully!");
|
console.log("\n✅ Database seeded successfully!");
|
||||||
console.log(`Summary:`);
|
console.log(`Summary:`);
|
||||||
console.log(`- 1 Admin User (sean@soconnor.dev)`);
|
console.log(`- 1 Admin User (sean@soconnor.dev)`);
|
||||||
@@ -1024,7 +1124,7 @@ async function main() {
|
|||||||
trialId: analyticsTrial!.id,
|
trialId: analyticsTrial!.id,
|
||||||
eventType: "step_changed",
|
eventType: "step_changed",
|
||||||
timestamp: new Date(currentTime),
|
timestamp: new Date(currentTime),
|
||||||
data: { stepId: step5!.id, stepName: "Conclusion" },
|
data: { stepId: step6!.id, stepName: "Conclusion" },
|
||||||
});
|
});
|
||||||
|
|
||||||
advance(2);
|
advance(2);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { cookies } from "next/headers";
|
import { cookies, headers } from "next/headers";
|
||||||
import {
|
import {
|
||||||
SidebarInset,
|
SidebarInset,
|
||||||
SidebarProvider,
|
SidebarProvider,
|
||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
} from "~/components/ui/sidebar";
|
} from "~/components/ui/sidebar";
|
||||||
import { Separator } from "~/components/ui/separator";
|
import { Separator } from "~/components/ui/separator";
|
||||||
import { AppSidebar } from "~/components/dashboard/app-sidebar";
|
import { AppSidebar } from "~/components/dashboard/app-sidebar";
|
||||||
import { auth } from "~/server/auth";
|
import { auth } from "~/lib/auth";
|
||||||
import {
|
import {
|
||||||
BreadcrumbProvider,
|
BreadcrumbProvider,
|
||||||
BreadcrumbDisplay,
|
BreadcrumbDisplay,
|
||||||
@@ -22,16 +22,15 @@ interface DashboardLayoutProps {
|
|||||||
export default async function DashboardLayout({
|
export default async function DashboardLayout({
|
||||||
children,
|
children,
|
||||||
}: DashboardLayoutProps) {
|
}: DashboardLayoutProps) {
|
||||||
const session = await auth();
|
const session = await auth.api.getSession({
|
||||||
|
headers: await headers(),
|
||||||
|
});
|
||||||
|
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
redirect("/auth/signin");
|
redirect("/auth/signin");
|
||||||
}
|
}
|
||||||
|
|
||||||
const userRole =
|
const userRole = "researcher"; // Default role for dashboard access
|
||||||
typeof session.user.roles?.[0] === "string"
|
|
||||||
? session.user.roles[0]
|
|
||||||
: (session.user.roles?.[0]?.role ?? "observer");
|
|
||||||
|
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const defaultOpen = cookieStore.get("sidebar_state")?.value === "true";
|
const defaultOpen = cookieStore.get("sidebar_state")?.value === "true";
|
||||||
|
|||||||
@@ -16,19 +16,10 @@ import { Separator } from "~/components/ui/separator";
|
|||||||
import { PageHeader } from "~/components/ui/page-header";
|
import { PageHeader } from "~/components/ui/page-header";
|
||||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||||
import { formatRole, getRoleDescription } from "~/lib/auth-client";
|
import { formatRole, getRoleDescription } from "~/lib/auth-client";
|
||||||
import {
|
import { User, Shield, Download, Trash2, Lock, UserCog } from "lucide-react";
|
||||||
User,
|
import { useSession } from "~/lib/auth-client";
|
||||||
Shield,
|
|
||||||
Download,
|
|
||||||
Trash2,
|
|
||||||
ExternalLink,
|
|
||||||
Lock,
|
|
||||||
UserCog,
|
|
||||||
Mail,
|
|
||||||
Fingerprint,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useSession } from "next-auth/react";
|
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
|
||||||
interface ProfileUser {
|
interface ProfileUser {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -37,7 +28,8 @@ interface ProfileUser {
|
|||||||
image: string | null;
|
image: string | null;
|
||||||
roles?: Array<{
|
roles?: Array<{
|
||||||
role: "administrator" | "researcher" | "wizard" | "observer";
|
role: "administrator" | "researcher" | "wizard" | "observer";
|
||||||
grantedAt: string | Date;
|
grantedAt: Date;
|
||||||
|
grantedBy: string | null;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,14 +205,20 @@ function ProfileContent({ user }: { user: ProfileUser }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
const { data: session, status } = useSession();
|
const { data: session, isPending } = useSession();
|
||||||
|
const { data: userData, isPending: isUserPending } = api.auth.me.useQuery(
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
enabled: !!session?.user,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
useBreadcrumbsEffect([
|
useBreadcrumbsEffect([
|
||||||
{ label: "Dashboard", href: "/dashboard" },
|
{ label: "Dashboard", href: "/dashboard" },
|
||||||
{ label: "Profile" },
|
{ label: "Profile" },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (status === "loading") {
|
if (isPending || isUserPending) {
|
||||||
return (
|
return (
|
||||||
<div className="text-muted-foreground animate-pulse p-8">
|
<div className="text-muted-foreground animate-pulse p-8">
|
||||||
Loading profile...
|
Loading profile...
|
||||||
@@ -232,7 +230,13 @@ export default function ProfilePage() {
|
|||||||
redirect("/auth/signin");
|
redirect("/auth/signin");
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = session.user;
|
const user: ProfileUser = {
|
||||||
|
id: session.user.id,
|
||||||
|
name: userData?.name ?? session.user.name ?? null,
|
||||||
|
email: userData?.email ?? session.user.email,
|
||||||
|
image: userData?.image ?? session.user.image ?? null,
|
||||||
|
roles: userData?.systemRoles as ProfileUser["roles"],
|
||||||
|
};
|
||||||
|
|
||||||
return <ProfileContent user={user} />;
|
return <ProfileContent user={user} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import {
|
|||||||
} from "~/components/ui/entity-view";
|
} from "~/components/ui/entity-view";
|
||||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "~/lib/auth-client";
|
||||||
import { useStudyManagement } from "~/hooks/useStudyManagement";
|
import { useStudyManagement } from "~/hooks/useStudyManagement";
|
||||||
|
|
||||||
interface ExperimentDetailPageProps {
|
interface ExperimentDetailPageProps {
|
||||||
@@ -99,6 +99,9 @@ export default function ExperimentDetailPage({
|
|||||||
params,
|
params,
|
||||||
}: ExperimentDetailPageProps) {
|
}: ExperimentDetailPageProps) {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
|
const { data: userData } = api.auth.me.useQuery(undefined, {
|
||||||
|
enabled: !!session?.user,
|
||||||
|
});
|
||||||
const [experiment, setExperiment] = useState<Experiment | null>(null);
|
const [experiment, setExperiment] = useState<Experiment | null>(null);
|
||||||
const [trials, setTrials] = useState<Trial[]>([]);
|
const [trials, setTrials] = useState<Trial[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -181,7 +184,7 @@ export default function ExperimentDetailPage({
|
|||||||
const description = experiment.description;
|
const description = experiment.description;
|
||||||
|
|
||||||
// Check if user can edit this experiment
|
// Check if user can edit this experiment
|
||||||
const userRoles = session?.user?.roles?.map((r) => r.role) ?? [];
|
const userRoles = userData?.roles ?? [];
|
||||||
const canEdit =
|
const canEdit =
|
||||||
userRoles.includes("administrator") || userRoles.includes("researcher");
|
userRoles.includes("administrator") || userRoles.includes("researcher");
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "~/lib/auth-client";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { FileText, Loader2, Plus, Download, Edit2, Eye, Save } from "lucide-react";
|
|
||||||
import {
|
import {
|
||||||
EntityView,
|
FileText,
|
||||||
EntityViewHeader,
|
Loader2,
|
||||||
EntityViewSection,
|
Plus,
|
||||||
EmptyState,
|
Download,
|
||||||
|
Edit2,
|
||||||
|
Eye,
|
||||||
|
Save,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
EntityView,
|
||||||
|
EntityViewHeader,
|
||||||
|
EntityViewSection,
|
||||||
|
EmptyState,
|
||||||
} from "~/components/ui/entity-view";
|
} from "~/components/ui/entity-view";
|
||||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
@@ -16,302 +24,346 @@ import { Badge } from "~/components/ui/badge";
|
|||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { PageHeader } from "~/components/ui/page-header";
|
import { PageHeader } from "~/components/ui/page-header";
|
||||||
import { useEditor, EditorContent } from '@tiptap/react';
|
import { useEditor, EditorContent } from "@tiptap/react";
|
||||||
import StarterKit from '@tiptap/starter-kit';
|
import StarterKit from "@tiptap/starter-kit";
|
||||||
import { Markdown } from 'tiptap-markdown';
|
import { Markdown } from "tiptap-markdown";
|
||||||
import { Table } from '@tiptap/extension-table';
|
import { Table } from "@tiptap/extension-table";
|
||||||
import { TableRow } from '@tiptap/extension-table-row';
|
import { TableRow } from "@tiptap/extension-table-row";
|
||||||
import { TableCell } from '@tiptap/extension-table-cell';
|
import { TableCell } from "@tiptap/extension-table-cell";
|
||||||
import { TableHeader } from '@tiptap/extension-table-header';
|
import { TableHeader } from "@tiptap/extension-table-header";
|
||||||
import { Bold, Italic, List, ListOrdered, Heading1, Heading2, Quote, Table as TableIcon } from "lucide-react";
|
import {
|
||||||
|
Bold,
|
||||||
|
Italic,
|
||||||
|
List,
|
||||||
|
ListOrdered,
|
||||||
|
Heading1,
|
||||||
|
Heading2,
|
||||||
|
Quote,
|
||||||
|
Table as TableIcon,
|
||||||
|
} from "lucide-react";
|
||||||
import { downloadPdfFromHtml } from "~/lib/pdf-generator";
|
import { downloadPdfFromHtml } from "~/lib/pdf-generator";
|
||||||
|
|
||||||
const Toolbar = ({ editor }: { editor: any }) => {
|
const Toolbar = ({ editor }: { editor: any }) => {
|
||||||
if (!editor) {
|
if (!editor) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border border-input bg-transparent rounded-tr-md rounded-tl-md p-1 flex items-center gap-1 flex-wrap">
|
<div className="border-input flex flex-wrap items-center gap-1 rounded-tl-md rounded-tr-md border bg-transparent p-1">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||||
disabled={!editor.can().chain().focus().toggleBold().run()}
|
disabled={!editor.can().chain().focus().toggleBold().run()}
|
||||||
className={editor.isActive('bold') ? 'bg-muted' : ''}
|
className={editor.isActive("bold") ? "bg-muted" : ""}
|
||||||
>
|
>
|
||||||
<Bold className="h-4 w-4" />
|
<Bold className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||||
disabled={!editor.can().chain().focus().toggleItalic().run()}
|
disabled={!editor.can().chain().focus().toggleItalic().run()}
|
||||||
className={editor.isActive('italic') ? 'bg-muted' : ''}
|
className={editor.isActive("italic") ? "bg-muted" : ""}
|
||||||
>
|
>
|
||||||
<Italic className="h-4 w-4" />
|
<Italic className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div className="w-[1px] h-6 bg-border mx-1" />
|
<div className="bg-border mx-1 h-6 w-[1px]" />
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||||
className={editor.isActive('heading', { level: 1 }) ? 'bg-muted' : ''}
|
className={editor.isActive("heading", { level: 1 }) ? "bg-muted" : ""}
|
||||||
>
|
>
|
||||||
<Heading1 className="h-4 w-4" />
|
<Heading1 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||||
className={editor.isActive('heading', { level: 2 }) ? 'bg-muted' : ''}
|
className={editor.isActive("heading", { level: 2 }) ? "bg-muted" : ""}
|
||||||
>
|
>
|
||||||
<Heading2 className="h-4 w-4" />
|
<Heading2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div className="w-[1px] h-6 bg-border mx-1" />
|
<div className="bg-border mx-1 h-6 w-[1px]" />
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||||
className={editor.isActive('bulletList') ? 'bg-muted' : ''}
|
className={editor.isActive("bulletList") ? "bg-muted" : ""}
|
||||||
>
|
>
|
||||||
<List className="h-4 w-4" />
|
<List className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||||
className={editor.isActive('orderedList') ? 'bg-muted' : ''}
|
className={editor.isActive("orderedList") ? "bg-muted" : ""}
|
||||||
>
|
>
|
||||||
<ListOrdered className="h-4 w-4" />
|
<ListOrdered className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||||
className={editor.isActive('blockquote') ? 'bg-muted' : ''}
|
className={editor.isActive("blockquote") ? "bg-muted" : ""}
|
||||||
>
|
>
|
||||||
<Quote className="h-4 w-4" />
|
<Quote className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div className="w-[1px] h-6 bg-border mx-1" />
|
<div className="bg-border mx-1 h-6 w-[1px]" />
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()}
|
onClick={() =>
|
||||||
>
|
editor
|
||||||
<TableIcon className="h-4 w-4" />
|
.chain()
|
||||||
</Button>
|
.focus()
|
||||||
</div>
|
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
|
||||||
);
|
.run()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TableIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface StudyFormsPageProps {
|
interface StudyFormsPageProps {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
id: string;
|
id: string;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function StudyFormsPage({ params }: StudyFormsPageProps) {
|
export default function StudyFormsPage({ params }: StudyFormsPageProps) {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const [resolvedParams, setResolvedParams] = useState<{ id: string } | null>(null);
|
const [resolvedParams, setResolvedParams] = useState<{ id: string } | null>(
|
||||||
const [editorTarget, setEditorTarget] = useState<string>("");
|
null,
|
||||||
|
);
|
||||||
|
const [editorTarget, setEditorTarget] = useState<string>("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const resolveParams = async () => {
|
const resolveParams = async () => {
|
||||||
const resolved = await params;
|
const resolved = await params;
|
||||||
setResolvedParams(resolved);
|
setResolvedParams(resolved);
|
||||||
};
|
|
||||||
void resolveParams();
|
|
||||||
}, [params]);
|
|
||||||
|
|
||||||
const { data: study } = api.studies.get.useQuery(
|
|
||||||
{ id: resolvedParams?.id ?? "" },
|
|
||||||
{ enabled: !!resolvedParams?.id },
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data: activeConsentForm, refetch: refetchConsentForm } =
|
|
||||||
api.studies.getActiveConsentForm.useQuery(
|
|
||||||
{ studyId: resolvedParams?.id ?? "" },
|
|
||||||
{ enabled: !!resolvedParams?.id },
|
|
||||||
);
|
|
||||||
|
|
||||||
// Only sync once when form loads to avoid resetting user edits
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeConsentForm && !editorTarget) {
|
|
||||||
setEditorTarget(activeConsentForm.content);
|
|
||||||
}
|
|
||||||
}, [activeConsentForm, editorTarget]);
|
|
||||||
|
|
||||||
const editor = useEditor({
|
|
||||||
extensions: [
|
|
||||||
StarterKit,
|
|
||||||
Table.configure({
|
|
||||||
resizable: true,
|
|
||||||
}),
|
|
||||||
TableRow,
|
|
||||||
TableHeader,
|
|
||||||
TableCell,
|
|
||||||
Markdown.configure({
|
|
||||||
transformPastedText: true,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
content: editorTarget || '',
|
|
||||||
immediatelyRender: false,
|
|
||||||
onUpdate: ({ editor }) => {
|
|
||||||
// @ts-ignore
|
|
||||||
setEditorTarget(editor.storage.markdown.getMarkdown());
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sync Tiptap when editorTarget is set (e.g., from DB) but make sure not to overwrite active edits
|
|
||||||
useEffect(() => {
|
|
||||||
if (editor && editorTarget && editor.isEmpty) {
|
|
||||||
editor.commands.setContent(editorTarget);
|
|
||||||
}
|
|
||||||
}, [editorTarget, editor]);
|
|
||||||
|
|
||||||
const generateConsentMutation = api.studies.generateConsentForm.useMutation({
|
|
||||||
onSuccess: (data) => {
|
|
||||||
toast.success("Default Consent Form Generated!");
|
|
||||||
setEditorTarget(data.content);
|
|
||||||
editor?.commands.setContent(data.content);
|
|
||||||
void refetchConsentForm();
|
|
||||||
void utils.studies.getActivity.invalidate({ studyId: resolvedParams?.id ?? "" });
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
toast.error("Error generating consent form", { description: error.message });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateConsentMutation = api.studies.updateConsentForm.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("Consent Form Saved Successfully!");
|
|
||||||
void refetchConsentForm();
|
|
||||||
void utils.studies.getActivity.invalidate({ studyId: resolvedParams?.id ?? "" });
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
toast.error("Error saving consent form", { description: error.message });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleDownloadConsent = async () => {
|
|
||||||
if (!activeConsentForm || !study || !editor) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
toast.loading("Generating Document...", { id: "pdf-gen" });
|
|
||||||
await downloadPdfFromHtml(editor.getHTML(), {
|
|
||||||
filename: `Consent_Form_${study.name.replace(/\s+/g, "_")}_v${activeConsentForm.version}.pdf`
|
|
||||||
});
|
|
||||||
toast.success("Document Downloaded Successfully!", { id: "pdf-gen" });
|
|
||||||
} catch (error) {
|
|
||||||
toast.error("Error generating PDF", { id: "pdf-gen" });
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
void resolveParams();
|
||||||
|
}, [params]);
|
||||||
|
|
||||||
useBreadcrumbsEffect([
|
const { data: study } = api.studies.get.useQuery(
|
||||||
{ label: "Dashboard", href: "/dashboard" },
|
{ id: resolvedParams?.id ?? "" },
|
||||||
{ label: "Studies", href: "/studies" },
|
{ enabled: !!resolvedParams?.id },
|
||||||
{ label: study?.name ?? "Study", href: `/studies/${resolvedParams?.id}` },
|
);
|
||||||
{ label: "Forms" },
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!session?.user) {
|
const { data: activeConsentForm, refetch: refetchConsentForm } =
|
||||||
return notFound();
|
api.studies.getActiveConsentForm.useQuery(
|
||||||
}
|
{ studyId: resolvedParams?.id ?? "" },
|
||||||
|
{ enabled: !!resolvedParams?.id },
|
||||||
if (!study) return <div>Loading...</div>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<EntityView>
|
|
||||||
<PageHeader
|
|
||||||
title="Study Forms"
|
|
||||||
description="Manage consent forms and future questionnaires for this study"
|
|
||||||
icon={FileText}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-8">
|
|
||||||
<EntityViewSection
|
|
||||||
title="Consent Document"
|
|
||||||
icon="FileText"
|
|
||||||
description="Design and manage the consent form that participants must sign before participating in your trials."
|
|
||||||
actions={
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => generateConsentMutation.mutate({ studyId: study.id })}
|
|
||||||
disabled={generateConsentMutation.isPending || updateConsentMutation.isPending}
|
|
||||||
>
|
|
||||||
{generateConsentMutation.isPending ? (
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
)}
|
|
||||||
Generate Default Template
|
|
||||||
</Button>
|
|
||||||
{activeConsentForm && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={() => updateConsentMutation.mutate({ studyId: study.id, content: editorTarget })}
|
|
||||||
disabled={updateConsentMutation.isPending || editorTarget === activeConsentForm.content}
|
|
||||||
>
|
|
||||||
{updateConsentMutation.isPending ? (
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Save className="mr-2 h-4 w-4" />
|
|
||||||
)}
|
|
||||||
Save Changes
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{activeConsentForm ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-sm font-medium leading-none">
|
|
||||||
{activeConsentForm.title}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
v{activeConsentForm.version} • Status: Active
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={handleDownloadConsent}
|
|
||||||
>
|
|
||||||
<Download className="mr-2 h-4 w-4" />
|
|
||||||
Download PDF
|
|
||||||
</Button>
|
|
||||||
<Badge variant="outline" className="bg-green-50 text-green-700 hover:bg-green-50">Active</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-full flex justify-center bg-muted/30 p-8 rounded-md border border-border overflow-hidden">
|
|
||||||
<div className="max-w-4xl w-full bg-white dark:bg-card shadow-xl ring-1 ring-border rounded-sm flex flex-col">
|
|
||||||
<div className="border-b border-border bg-muted/50 dark:bg-muted/10">
|
|
||||||
<Toolbar editor={editor} />
|
|
||||||
</div>
|
|
||||||
<div className="min-h-[850px] px-16 py-20 text-sm editor-container bg-white dark:bg-card">
|
|
||||||
<EditorContent editor={editor} className="prose prose-sm dark:prose-invert max-w-none h-full outline-none focus:outline-none focus-visible:outline-none" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<EmptyState
|
|
||||||
icon="FileText"
|
|
||||||
title="No Consent Form"
|
|
||||||
description="Generate a boilerplate consent form for this study to download and collect signatures."
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</EntityViewSection>
|
|
||||||
</div>
|
|
||||||
</EntityView>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Only sync once when form loads to avoid resetting user edits
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeConsentForm && !editorTarget) {
|
||||||
|
setEditorTarget(activeConsentForm.content);
|
||||||
|
}
|
||||||
|
}, [activeConsentForm, editorTarget]);
|
||||||
|
|
||||||
|
const editor = useEditor({
|
||||||
|
extensions: [
|
||||||
|
StarterKit,
|
||||||
|
Table.configure({
|
||||||
|
resizable: true,
|
||||||
|
}),
|
||||||
|
TableRow,
|
||||||
|
TableHeader,
|
||||||
|
TableCell,
|
||||||
|
Markdown.configure({
|
||||||
|
transformPastedText: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
content: editorTarget || "",
|
||||||
|
immediatelyRender: false,
|
||||||
|
onUpdate: ({ editor }) => {
|
||||||
|
// @ts-ignore
|
||||||
|
setEditorTarget(editor.storage.markdown.getMarkdown());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync Tiptap when editorTarget is set (e.g., from DB) but make sure not to overwrite active edits
|
||||||
|
useEffect(() => {
|
||||||
|
if (editor && editorTarget && editor.isEmpty) {
|
||||||
|
editor.commands.setContent(editorTarget);
|
||||||
|
}
|
||||||
|
}, [editorTarget, editor]);
|
||||||
|
|
||||||
|
const generateConsentMutation = api.studies.generateConsentForm.useMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
toast.success("Default Consent Form Generated!");
|
||||||
|
setEditorTarget(data.content);
|
||||||
|
editor?.commands.setContent(data.content);
|
||||||
|
void refetchConsentForm();
|
||||||
|
void utils.studies.getActivity.invalidate({
|
||||||
|
studyId: resolvedParams?.id ?? "",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error("Error generating consent form", {
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateConsentMutation = api.studies.updateConsentForm.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Consent Form Saved Successfully!");
|
||||||
|
void refetchConsentForm();
|
||||||
|
void utils.studies.getActivity.invalidate({
|
||||||
|
studyId: resolvedParams?.id ?? "",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error("Error saving consent form", { description: error.message });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDownloadConsent = async () => {
|
||||||
|
if (!activeConsentForm || !study || !editor) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
toast.loading("Generating Document...", { id: "pdf-gen" });
|
||||||
|
await downloadPdfFromHtml(editor.getHTML(), {
|
||||||
|
filename: `Consent_Form_${study.name.replace(/\s+/g, "_")}_v${activeConsentForm.version}.pdf`,
|
||||||
|
});
|
||||||
|
toast.success("Document Downloaded Successfully!", { id: "pdf-gen" });
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Error generating PDF", { id: "pdf-gen" });
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useBreadcrumbsEffect([
|
||||||
|
{ label: "Dashboard", href: "/dashboard" },
|
||||||
|
{ label: "Studies", href: "/studies" },
|
||||||
|
{ label: study?.name ?? "Study", href: `/studies/${resolvedParams?.id}` },
|
||||||
|
{ label: "Forms" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!study) return <div>Loading...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EntityView>
|
||||||
|
<PageHeader
|
||||||
|
title="Study Forms"
|
||||||
|
description="Manage consent forms and future questionnaires for this study"
|
||||||
|
icon={FileText}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-8">
|
||||||
|
<EntityViewSection
|
||||||
|
title="Consent Document"
|
||||||
|
icon="FileText"
|
||||||
|
description="Design and manage the consent form that participants must sign before participating in your trials."
|
||||||
|
actions={
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
generateConsentMutation.mutate({ studyId: study.id })
|
||||||
|
}
|
||||||
|
disabled={
|
||||||
|
generateConsentMutation.isPending ||
|
||||||
|
updateConsentMutation.isPending
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{generateConsentMutation.isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Generate Default Template
|
||||||
|
</Button>
|
||||||
|
{activeConsentForm && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
updateConsentMutation.mutate({
|
||||||
|
studyId: study.id,
|
||||||
|
content: editorTarget,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={
|
||||||
|
updateConsentMutation.isPending ||
|
||||||
|
editorTarget === activeConsentForm.content
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{updateConsentMutation.isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{activeConsentForm ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm leading-none font-medium">
|
||||||
|
{activeConsentForm.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
v{activeConsentForm.version} • Status: Active
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleDownloadConsent}
|
||||||
|
>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
Download PDF
|
||||||
|
</Button>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="bg-green-50 text-green-700 hover:bg-green-50"
|
||||||
|
>
|
||||||
|
Active
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-muted/30 border-border flex w-full justify-center overflow-hidden rounded-md border p-8">
|
||||||
|
<div className="dark:bg-card ring-border flex w-full max-w-4xl flex-col rounded-sm bg-white shadow-xl ring-1">
|
||||||
|
<div className="border-border bg-muted/50 dark:bg-muted/10 border-b">
|
||||||
|
<Toolbar editor={editor} />
|
||||||
|
</div>
|
||||||
|
<div className="editor-container dark:bg-card min-h-[850px] bg-white px-16 py-20 text-sm">
|
||||||
|
<EditorContent
|
||||||
|
editor={editor}
|
||||||
|
className="prose prose-sm dark:prose-invert h-full max-w-none outline-none focus:outline-none focus-visible:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
icon="FileText"
|
||||||
|
title="No Consent Form"
|
||||||
|
description="Generate a boilerplate consent form for this study to download and collect signatures."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</EntityViewSection>
|
||||||
|
</div>
|
||||||
|
</EntityView>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
} from "~/components/ui/entity-view";
|
} from "~/components/ui/entity-view";
|
||||||
import { PageHeader } from "~/components/ui/page-header";
|
import { PageHeader } from "~/components/ui/page-header";
|
||||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "~/lib/auth-client";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
|
|
||||||
interface StudyDetailPageProps {
|
interface StudyDetailPageProps {
|
||||||
@@ -273,12 +273,13 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
|
|||||||
</Link>
|
</Link>
|
||||||
</h4>
|
</h4>
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${experiment.status === "draft"
|
className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${
|
||||||
? "bg-gray-100 text-gray-800"
|
experiment.status === "draft"
|
||||||
: experiment.status === "ready"
|
? "bg-gray-100 text-gray-800"
|
||||||
? "bg-green-100 text-green-800"
|
: experiment.status === "ready"
|
||||||
: "bg-blue-100 text-blue-800"
|
? "bg-green-100 text-green-800"
|
||||||
}`}
|
: "bg-blue-100 text-blue-800"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{experiment.status}
|
{experiment.status}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { WizardView } from "~/components/trials/views/WizardView";
|
|||||||
import { ObserverView } from "~/components/trials/views/ObserverView";
|
import { ObserverView } from "~/components/trials/views/ObserverView";
|
||||||
import { ParticipantView } from "~/components/trials/views/ParticipantView";
|
import { ParticipantView } from "~/components/trials/views/ParticipantView";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "~/lib/auth-client";
|
||||||
|
|
||||||
function WizardPageContent() {
|
function WizardPageContent() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@@ -25,6 +25,11 @@ function WizardPageContent() {
|
|||||||
const { study } = useSelectedStudyDetails();
|
const { study } = useSelectedStudyDetails();
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
|
|
||||||
|
// Get user roles
|
||||||
|
const { data: userData } = api.auth.me.useQuery(undefined, {
|
||||||
|
enabled: !!session?.user,
|
||||||
|
});
|
||||||
|
|
||||||
// Get trial data
|
// Get trial data
|
||||||
const {
|
const {
|
||||||
data: trial,
|
data: trial,
|
||||||
@@ -67,7 +72,7 @@ function WizardPageContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Default role logic based on user
|
// Default role logic based on user
|
||||||
const userRole = session.user.roles?.[0]?.role ?? "observer";
|
const userRole = userData?.roles?.[0] ?? "observer";
|
||||||
if (userRole === "administrator" || userRole === "researcher") {
|
if (userRole === "administrator" || userRole === "researcher") {
|
||||||
return "wizard";
|
return "wizard";
|
||||||
}
|
}
|
||||||
@@ -188,6 +193,7 @@ function WizardPageContent() {
|
|||||||
name: trial.experiment.name,
|
name: trial.experiment.name,
|
||||||
description: trial.experiment.description,
|
description: trial.experiment.description,
|
||||||
studyId: trial.experiment.studyId,
|
studyId: trial.experiment.studyId,
|
||||||
|
robotId: trial.experiment.robotId,
|
||||||
},
|
},
|
||||||
participant: {
|
participant: {
|
||||||
id: trial.participant.id,
|
id: trial.participant.id,
|
||||||
|
|||||||
4
src/app/api/auth/[...all]/route.ts
Executable file
4
src/app/api/auth/[...all]/route.ts
Executable file
@@ -0,0 +1,4 @@
|
|||||||
|
import { auth } from "~/lib/auth";
|
||||||
|
import { toNextJsHandler } from "better-auth/next-js";
|
||||||
|
|
||||||
|
export const { GET, POST } = toNextJsHandler(auth);
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { handlers } from "~/server/auth";
|
|
||||||
|
|
||||||
export const { GET, POST } = handlers;
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { NextResponse, type NextRequest } from "next/server";
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
|
import { headers } from "next/headers";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
generateFileKey,
|
generateFileKey,
|
||||||
@@ -7,9 +8,14 @@ import {
|
|||||||
uploadFile,
|
uploadFile,
|
||||||
validateFile,
|
validateFile,
|
||||||
} from "~/lib/storage/minio";
|
} from "~/lib/storage/minio";
|
||||||
import { auth } from "~/server/auth";
|
import { auth } from "~/lib/auth";
|
||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
import { experiments, mediaCaptures, studyMembers, trials } from "~/server/db/schema";
|
import {
|
||||||
|
experiments,
|
||||||
|
mediaCaptures,
|
||||||
|
studyMembers,
|
||||||
|
trials,
|
||||||
|
} from "~/server/db/schema";
|
||||||
|
|
||||||
const uploadSchema = z.object({
|
const uploadSchema = z.object({
|
||||||
trialId: z.string().optional(),
|
trialId: z.string().optional(),
|
||||||
@@ -23,7 +29,9 @@ const uploadSchema = z.object({
|
|||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
// Check authentication
|
// Check authentication
|
||||||
const session = await auth();
|
const session = await auth.api.getSession({
|
||||||
|
headers: await headers(),
|
||||||
|
});
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
@@ -91,15 +99,15 @@ export async function POST(request: NextRequest) {
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(studyMembers.studyId, trial[0].studyId),
|
eq(studyMembers.studyId, trial[0].studyId),
|
||||||
eq(studyMembers.userId, session.user.id)
|
eq(studyMembers.userId, session.user.id),
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!membership.length) {
|
if (!membership.length) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Insufficient permissions to upload to this trial" },
|
{ error: "Insufficient permissions to upload to this trial" },
|
||||||
{ status: 403 }
|
{ status: 403 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -176,7 +184,9 @@ export async function POST(request: NextRequest) {
|
|||||||
// Generate presigned upload URL for direct client uploads
|
// Generate presigned upload URL for direct client uploads
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await auth.api.getSession({
|
||||||
|
headers: await headers(),
|
||||||
|
});
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { signIn } from "next-auth/react";
|
import { signIn } from "~/lib/auth-client";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
@@ -37,22 +37,21 @@ export default function SignInPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await signIn("credentials", {
|
const result = await signIn.email({
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
redirect: false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result?.error) {
|
if (result.error) {
|
||||||
setError("Invalid email or password");
|
setError(result.error.message || "Invalid email or password");
|
||||||
} else {
|
} else {
|
||||||
router.push("/");
|
router.push("/");
|
||||||
router.refresh();
|
router.refresh();
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (err: unknown) {
|
||||||
setError(
|
setError(
|
||||||
error instanceof Error
|
err instanceof Error
|
||||||
? error.message
|
? err.message
|
||||||
: "An error occurred. Please try again.",
|
: "An error occurred. Please try again.",
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { signOut, useSession } from "next-auth/react";
|
import { signOut, useSession } from "~/lib/auth-client";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
@@ -14,33 +14,29 @@ import {
|
|||||||
} from "~/components/ui/card";
|
} from "~/components/ui/card";
|
||||||
|
|
||||||
export default function SignOutPage() {
|
export default function SignOutPage() {
|
||||||
const { data: session, status } = useSession();
|
const { data: session, isPending } = useSession();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isSigningOut, setIsSigningOut] = useState(false);
|
const [isSigningOut, setIsSigningOut] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// If user is not logged in, redirect to home
|
if (!isPending && !session) {
|
||||||
if (status === "loading") return; // Still loading
|
|
||||||
if (!session) {
|
|
||||||
router.push("/");
|
router.push("/");
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}, [session, status, router]);
|
}, [session, isPending, router]);
|
||||||
|
|
||||||
const handleSignOut = async () => {
|
const handleSignOut = async () => {
|
||||||
setIsSigningOut(true);
|
setIsSigningOut(true);
|
||||||
try {
|
try {
|
||||||
await signOut({
|
await signOut();
|
||||||
callbackUrl: "/",
|
router.push("/");
|
||||||
redirect: true,
|
router.refresh();
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error signing out:", error);
|
console.error("Error signing out:", error);
|
||||||
setIsSigningOut(false);
|
setIsSigningOut(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (status === "loading") {
|
if (isPending) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100">
|
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
@@ -52,7 +48,7 @@ export default function SignOutPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return null; // Will redirect via useEffect
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -80,7 +76,7 @@ export default function SignOutPage() {
|
|||||||
<div className="rounded-md bg-blue-50 p-3 text-sm text-blue-700">
|
<div className="rounded-md bg-blue-50 p-3 text-sm text-blue-700">
|
||||||
<p className="font-medium">
|
<p className="font-medium">
|
||||||
Currently signed in as:{" "}
|
Currently signed in as:{" "}
|
||||||
{session.user.name ?? session.user.email}
|
{session.user?.name ?? session.user?.email}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -103,7 +99,8 @@ export default function SignOutPage() {
|
|||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="mt-8 text-center text-xs text-slate-500">
|
<div className="mt-8 text-center text-xs text-slate-500">
|
||||||
<p>
|
<p>
|
||||||
© 2024 HRIStudio. A platform for Human-Robot Interaction research.
|
© {new Date().getFullYear()} HRIStudio. A platform for Human-Robot
|
||||||
|
Interaction research.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ import { Badge } from "~/components/ui/badge";
|
|||||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
import { useTour } from "~/components/onboarding/TourProvider";
|
import { useTour } from "~/components/onboarding/TourProvider";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "~/lib/auth-client";
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const { startTour } = useTour();
|
const { startTour } = useTour();
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import "~/styles/globals.css";
|
|||||||
import { type Metadata } from "next";
|
import { type Metadata } from "next";
|
||||||
import { Inter } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
|
|
||||||
import { SessionProvider } from "next-auth/react";
|
|
||||||
import { TRPCReactProvider } from "~/trpc/react";
|
import { TRPCReactProvider } from "~/trpc/react";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -24,9 +23,7 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="en" className={`${inter.variable}`}>
|
<html lang="en" className={`${inter.variable}`}>
|
||||||
<body>
|
<body>
|
||||||
<SessionProvider>
|
<TRPCReactProvider>{children}</TRPCReactProvider>
|
||||||
<TRPCReactProvider>{children}</TRPCReactProvider>
|
|
||||||
</SessionProvider>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { headers } from "next/headers";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { Logo } from "~/components/ui/logo";
|
import { Logo } from "~/components/ui/logo";
|
||||||
import { auth } from "~/server/auth";
|
import { auth } from "~/lib/auth";
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Beaker,
|
Beaker,
|
||||||
@@ -20,7 +21,9 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
export default async function Home() {
|
export default async function Home() {
|
||||||
const session = await auth();
|
const session = await auth.api.getSession({
|
||||||
|
headers: await headers(),
|
||||||
|
});
|
||||||
|
|
||||||
if (session?.user) {
|
if (session?.user) {
|
||||||
redirect("/dashboard");
|
redirect("/dashboard");
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { headers } from "next/headers";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -7,10 +8,12 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "~/components/ui/card";
|
} from "~/components/ui/card";
|
||||||
import { auth } from "~/server/auth";
|
import { auth } from "~/lib/auth";
|
||||||
|
|
||||||
export default async function UnauthorizedPage() {
|
export default async function UnauthorizedPage() {
|
||||||
const session = await auth();
|
const session = await auth.api.getSession({
|
||||||
|
headers: await headers(),
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 px-4">
|
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 px-4">
|
||||||
@@ -60,13 +63,6 @@ export default async function UnauthorizedPage() {
|
|||||||
<div className="rounded-md bg-blue-50 p-3 text-sm text-blue-700">
|
<div className="rounded-md bg-blue-50 p-3 text-sm text-blue-700">
|
||||||
<p className="font-medium">Current User:</p>
|
<p className="font-medium">Current User:</p>
|
||||||
<p>{session.user.name ?? session.user.email}</p>
|
<p>{session.user.name ?? session.user.email}</p>
|
||||||
{session.user.roles && session.user.roles.length > 0 ? (
|
|
||||||
<p className="mt-1">
|
|
||||||
Roles: {session.user.roles.map((r) => r.role).join(", ")}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<p className="mt-1">No roles assigned</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import React, { useEffect, useRef } from "react";
|
import React, { useEffect, useRef } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { signOut, useSession } from "next-auth/react";
|
import { signOut, useSession } from "~/lib/auth-client";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
BarChart3,
|
BarChart3,
|
||||||
@@ -197,13 +197,14 @@ export function AppSidebar({
|
|||||||
// Build study work items with proper URLs when study is selected
|
// Build study work items with proper URLs when study is selected
|
||||||
const studyWorkItemsWithUrls = selectedStudyId
|
const studyWorkItemsWithUrls = selectedStudyId
|
||||||
? studyWorkItems.map((item) => ({
|
? studyWorkItems.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
url: `/studies/${selectedStudyId}${item.url}`,
|
url: `/studies/${selectedStudyId}${item.url}`,
|
||||||
}))
|
}))
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const handleSignOut = async () => {
|
const handleSignOut = async () => {
|
||||||
await signOut({ callbackUrl: "/" });
|
await signOut();
|
||||||
|
window.location.href = "/";
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStudySelect = async (studyId: string) => {
|
const handleStudySelect = async (studyId: string) => {
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import { useRef, useState } from "react";
|
|||||||
import SignatureCanvas from "react-signature-canvas";
|
import SignatureCanvas from "react-signature-canvas";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "~/components/ui/dialog";
|
} from "~/components/ui/dialog";
|
||||||
import { PenBox, Eraser, Loader2, CheckCircle } from "lucide-react";
|
import { PenBox, Eraser, Loader2, CheckCircle } from "lucide-react";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
@@ -25,211 +25,250 @@ import TableHeader from "@tiptap/extension-table-header";
|
|||||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||||
|
|
||||||
interface DigitalSignatureModalProps {
|
interface DigitalSignatureModalProps {
|
||||||
studyId: string;
|
studyId: string;
|
||||||
participantId: string;
|
participantId: string;
|
||||||
participantName?: string | null;
|
participantName?: string | null;
|
||||||
participantCode: string;
|
participantCode: string;
|
||||||
activeForm: { id: string; content: string; version: number };
|
activeForm: { id: string; content: string; version: number };
|
||||||
onSuccess: () => void;
|
onSuccess: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DigitalSignatureModal({
|
export function DigitalSignatureModal({
|
||||||
studyId,
|
studyId,
|
||||||
participantId,
|
participantId,
|
||||||
participantName,
|
participantName,
|
||||||
participantCode,
|
participantCode,
|
||||||
activeForm,
|
activeForm,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
}: DigitalSignatureModalProps) {
|
}: DigitalSignatureModalProps) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const sigCanvas = useRef<any>(null);
|
const sigCanvas = useRef<any>(null);
|
||||||
|
|
||||||
// Mutations
|
// Mutations
|
||||||
const getUploadUrlMutation = api.participants.getConsentUploadUrl.useMutation();
|
const getUploadUrlMutation =
|
||||||
const recordConsentMutation = api.participants.recordConsent.useMutation();
|
api.participants.getConsentUploadUrl.useMutation();
|
||||||
|
const recordConsentMutation = api.participants.recordConsent.useMutation();
|
||||||
|
|
||||||
// Create a preview version of the text
|
// Create a preview version of the text
|
||||||
let previewMd = activeForm.content;
|
let previewMd = activeForm.content;
|
||||||
previewMd = previewMd.replace(/{{PARTICIPANT_NAME}}/g, participantName ?? "_________________");
|
previewMd = previewMd.replace(
|
||||||
previewMd = previewMd.replace(/{{PARTICIPANT_CODE}}/g, participantCode);
|
/{{PARTICIPANT_NAME}}/g,
|
||||||
const today = new Date().toLocaleDateString();
|
participantName ?? "_________________",
|
||||||
previewMd = previewMd.replace(/{{DATE}}/g, today);
|
);
|
||||||
previewMd = previewMd.replace(/{{SIGNATURE_IMAGE}}/g, "_[Signature Here]_");
|
previewMd = previewMd.replace(/{{PARTICIPANT_CODE}}/g, participantCode);
|
||||||
|
const today = new Date().toLocaleDateString();
|
||||||
|
previewMd = previewMd.replace(/{{DATE}}/g, today);
|
||||||
|
previewMd = previewMd.replace(/{{SIGNATURE_IMAGE}}/g, "_[Signature Here]_");
|
||||||
|
|
||||||
const previewEditor = useEditor({
|
const previewEditor = useEditor({
|
||||||
extensions: [StarterKit, Table, TableRow, TableHeader, TableCell, Markdown],
|
extensions: [StarterKit, Table, TableRow, TableHeader, TableCell, Markdown],
|
||||||
content: previewMd,
|
content: previewMd,
|
||||||
editable: false,
|
editable: false,
|
||||||
immediatelyRender: false,
|
immediatelyRender: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleClear = () => {
|
const handleClear = () => {
|
||||||
sigCanvas.current?.clear();
|
sigCanvas.current?.clear();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (sigCanvas.current?.isEmpty()) {
|
if (sigCanvas.current?.isEmpty()) {
|
||||||
toast.error("Signature required", { description: "Please sign the document before submitting." });
|
toast.error("Signature required", {
|
||||||
return;
|
description: "Please sign the document before submitting.",
|
||||||
}
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
toast.loading("Generating Signed Document...", { id: "sig-upload" });
|
toast.loading("Generating Signed Document...", { id: "sig-upload" });
|
||||||
|
|
||||||
// 1. Get Signature Image Data URL
|
// 1. Get Signature Image Data URL
|
||||||
const signatureDataUrl = sigCanvas.current.getTrimmedCanvas().toDataURL("image/png");
|
const signatureDataUrl = sigCanvas.current
|
||||||
|
.getTrimmedCanvas()
|
||||||
|
.toDataURL("image/png");
|
||||||
|
|
||||||
// 2. Prepare final Markdown and HTML
|
// 2. Prepare final Markdown and HTML
|
||||||
let finalMd = activeForm.content;
|
let finalMd = activeForm.content;
|
||||||
finalMd = finalMd.replace(/{{PARTICIPANT_NAME}}/g, participantName ?? "_________________");
|
finalMd = finalMd.replace(
|
||||||
finalMd = finalMd.replace(/{{PARTICIPANT_CODE}}/g, participantCode);
|
/{{PARTICIPANT_NAME}}/g,
|
||||||
finalMd = finalMd.replace(/{{DATE}}/g, today);
|
participantName ?? "_________________",
|
||||||
finalMd = finalMd.replace(/{{SIGNATURE_IMAGE}}/g, `<img src="${signatureDataUrl}" style="height: 60px; max-width: 250px;" />`);
|
);
|
||||||
|
finalMd = finalMd.replace(/{{PARTICIPANT_CODE}}/g, participantCode);
|
||||||
|
finalMd = finalMd.replace(/{{DATE}}/g, today);
|
||||||
|
finalMd = finalMd.replace(
|
||||||
|
/{{SIGNATURE_IMAGE}}/g,
|
||||||
|
`<img src="${signatureDataUrl}" style="height: 60px; max-width: 250px;" />`,
|
||||||
|
);
|
||||||
|
|
||||||
const headlessEditor = new Editor({
|
const headlessEditor = new Editor({
|
||||||
extensions: [StarterKit, Table, TableRow, TableHeader, TableCell, Markdown],
|
extensions: [
|
||||||
content: finalMd,
|
StarterKit,
|
||||||
});
|
Table,
|
||||||
const htmlContent = headlessEditor.getHTML();
|
TableRow,
|
||||||
headlessEditor.destroy();
|
TableHeader,
|
||||||
|
TableCell,
|
||||||
|
Markdown,
|
||||||
|
],
|
||||||
|
content: finalMd,
|
||||||
|
});
|
||||||
|
const htmlContent = headlessEditor.getHTML();
|
||||||
|
headlessEditor.destroy();
|
||||||
|
|
||||||
// 3. Generate PDF Blob
|
// 3. Generate PDF Blob
|
||||||
const filename = `Signed_Consent_${participantCode}_v${activeForm.version}.pdf`;
|
const filename = `Signed_Consent_${participantCode}_v${activeForm.version}.pdf`;
|
||||||
const pdfBlob = await generatePdfBlobFromHtml(htmlContent, { filename });
|
const pdfBlob = await generatePdfBlobFromHtml(htmlContent, { filename });
|
||||||
const file = new File([pdfBlob], filename, { type: "application/pdf" });
|
const file = new File([pdfBlob], filename, { type: "application/pdf" });
|
||||||
|
|
||||||
// 4. Get Presigned URL
|
// 4. Get Presigned URL
|
||||||
toast.loading("Uploading Document...", { id: "sig-upload" });
|
toast.loading("Uploading Document...", { id: "sig-upload" });
|
||||||
const { url, key } = await getUploadUrlMutation.mutateAsync({
|
const { url, key } = await getUploadUrlMutation.mutateAsync({
|
||||||
studyId,
|
studyId,
|
||||||
participantId,
|
participantId,
|
||||||
filename: file.name,
|
filename: file.name,
|
||||||
contentType: file.type,
|
contentType: file.type,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 5. Upload to MinIO
|
// 5. Upload to MinIO
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
xhr.open("PUT", url, true);
|
xhr.open("PUT", url, true);
|
||||||
xhr.setRequestHeader("Content-Type", file.type);
|
xhr.setRequestHeader("Content-Type", file.type);
|
||||||
xhr.onload = () => {
|
xhr.onload = () => {
|
||||||
if (xhr.status >= 200 && xhr.status < 300) resolve();
|
if (xhr.status >= 200 && xhr.status < 300) resolve();
|
||||||
else reject(new Error(`Upload failed with status ${xhr.status}`));
|
else reject(new Error(`Upload failed with status ${xhr.status}`));
|
||||||
};
|
};
|
||||||
xhr.onerror = () => reject(new Error("Network error during upload"));
|
xhr.onerror = () => reject(new Error("Network error during upload"));
|
||||||
xhr.send(file);
|
xhr.send(file);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 6. Record Consent in DB
|
// 6. Record Consent in DB
|
||||||
toast.loading("Finalizing Consent...", { id: "sig-upload" });
|
toast.loading("Finalizing Consent...", { id: "sig-upload" });
|
||||||
await recordConsentMutation.mutateAsync({
|
await recordConsentMutation.mutateAsync({
|
||||||
participantId,
|
participantId,
|
||||||
consentFormId: activeForm.id,
|
consentFormId: activeForm.id,
|
||||||
storagePath: key,
|
storagePath: key,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.success("Consent Successfully Recorded!", { id: "sig-upload" });
|
toast.success("Consent Successfully Recorded!", { id: "sig-upload" });
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
onSuccess();
|
onSuccess();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
toast.error("Failed to submit digital signature", {
|
toast.error("Failed to submit digital signature", {
|
||||||
id: "sig-upload",
|
id: "sig-upload",
|
||||||
description: error instanceof Error ? error.message : "Unknown error",
|
description: error instanceof Error ? error.message : "Unknown error",
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="default" size="sm" className="bg-primary/90 hover:bg-primary">
|
<Button
|
||||||
<PenBox className="mr-2 h-4 w-4" />
|
variant="default"
|
||||||
Sign Digitally
|
size="sm"
|
||||||
</Button>
|
className="bg-primary/90 hover:bg-primary"
|
||||||
</DialogTrigger>
|
>
|
||||||
<DialogContent className="max-w-4xl h-[90vh] flex flex-col p-6">
|
<PenBox className="mr-2 h-4 w-4" />
|
||||||
<DialogHeader>
|
Sign Digitally
|
||||||
<DialogTitle>Digital Consent Signature</DialogTitle>
|
</Button>
|
||||||
<DialogDescription>
|
</DialogTrigger>
|
||||||
Please review the document below and provide your digital signature to consent to this study.
|
<DialogContent className="flex h-[90vh] max-w-4xl flex-col p-6">
|
||||||
</DialogDescription>
|
<DialogHeader>
|
||||||
</DialogHeader>
|
<DialogTitle>Digital Consent Signature</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Please review the document below and provide your digital signature
|
||||||
|
to consent to this study.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex-1 min-h-0 grid grid-cols-1 md:grid-cols-2 gap-6 mt-4">
|
<div className="mt-4 grid min-h-0 flex-1 grid-cols-1 gap-6 md:grid-cols-2">
|
||||||
{/* Document Preview (Left) */}
|
{/* Document Preview (Left) */}
|
||||||
<div className="flex flex-col border rounded-md overflow-hidden bg-muted/20">
|
<div className="bg-muted/20 flex flex-col overflow-hidden rounded-md border">
|
||||||
<div className="bg-muted px-4 py-2 border-b text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
<div className="bg-muted text-muted-foreground border-b px-4 py-2 text-xs font-semibold tracking-wider uppercase">
|
||||||
Document Preview
|
Document Preview
|
||||||
</div>
|
</div>
|
||||||
<ScrollArea className="flex-1 w-full bg-white p-6 shadow-inner">
|
<ScrollArea className="w-full flex-1 bg-white p-6 shadow-inner">
|
||||||
<div className="prose prose-sm max-w-none text-black">
|
<div className="prose prose-sm max-w-none text-black">
|
||||||
<EditorContent editor={previewEditor} />
|
<EditorContent editor={previewEditor} />
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Signature Panel (Right) */}
|
{/* Signature Panel (Right) */}
|
||||||
<div className="flex flex-col space-y-4">
|
<div className="flex flex-col space-y-4">
|
||||||
<div className="border rounded-md overflow-hidden bg-white shadow-sm flex flex-col">
|
<div className="flex flex-col overflow-hidden rounded-md border bg-white shadow-sm">
|
||||||
<div className="bg-muted px-4 py-2 border-b text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
<div className="bg-muted text-muted-foreground border-b px-4 py-2 text-xs font-semibold tracking-wider uppercase">
|
||||||
Digital Signature Pad
|
Digital Signature Pad
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 bg-muted/10 relative">
|
<div className="bg-muted/10 relative p-4">
|
||||||
<div className="absolute top-4 right-4">
|
<div className="absolute top-4 right-4">
|
||||||
<Button variant="ghost" size="sm" onClick={handleClear} disabled={isSubmitting}>
|
<Button
|
||||||
<Eraser className="h-4 w-4 mr-2" />
|
variant="ghost"
|
||||||
Clear
|
size="sm"
|
||||||
</Button>
|
onClick={handleClear}
|
||||||
</div>
|
disabled={isSubmitting}
|
||||||
<div className="border-2 border-dashed border-input rounded-md bg-white mt-10" style={{ height: "250px" }}>
|
>
|
||||||
<SignatureCanvas
|
<Eraser className="mr-2 h-4 w-4" />
|
||||||
ref={sigCanvas}
|
Clear
|
||||||
penColor="black"
|
</Button>
|
||||||
canvasProps={{ className: "w-full h-full cursor-crosshair rounded-md" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="text-center text-xs text-muted-foreground mt-2">
|
|
||||||
Draw your signature using your mouse or touch screen inside the box above.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1" />
|
|
||||||
|
|
||||||
{/* Submission Actions */}
|
|
||||||
<div className="flex flex-col space-y-3 p-4 bg-primary/5 rounded-lg border border-primary/20">
|
|
||||||
<h4 className="flex items-center text-sm font-semibold text-primary">
|
|
||||||
<CheckCircle className="h-4 w-4 mr-2" />
|
|
||||||
Agreement
|
|
||||||
</h4>
|
|
||||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
|
||||||
By clicking "Submit Signed Document", you confirm that you have read and understood the information provided in the document preview, and you voluntarily agree to participate in this study.
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
className="w-full mt-2"
|
|
||||||
size="lg"
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
{isSubmitting ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
Processing...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"Submit Signed Document"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
<div
|
||||||
</Dialog>
|
className="border-input mt-10 rounded-md border-2 border-dashed bg-white"
|
||||||
);
|
style={{ height: "250px" }}
|
||||||
|
>
|
||||||
|
<SignatureCanvas
|
||||||
|
ref={sigCanvas}
|
||||||
|
penColor="black"
|
||||||
|
canvasProps={{
|
||||||
|
className: "w-full h-full cursor-crosshair rounded-md",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground mt-2 text-center text-xs">
|
||||||
|
Draw your signature using your mouse or touch screen inside
|
||||||
|
the box above.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
{/* Submission Actions */}
|
||||||
|
<div className="bg-primary/5 border-primary/20 flex flex-col space-y-3 rounded-lg border p-4">
|
||||||
|
<h4 className="text-primary flex items-center text-sm font-semibold">
|
||||||
|
<CheckCircle className="mr-2 h-4 w-4" />
|
||||||
|
Agreement
|
||||||
|
</h4>
|
||||||
|
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||||
|
By clicking "Submit Signed Document", you confirm that you have
|
||||||
|
read and understood the information provided in the document
|
||||||
|
preview, and you voluntarily agree to participate in this study.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
className="mt-2 w-full"
|
||||||
|
size="lg"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Processing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Submit Signed Document"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,10 @@ export function ParticipantConsentManager({
|
|||||||
existingConsent,
|
existingConsent,
|
||||||
participantName,
|
participantName,
|
||||||
participantCode,
|
participantCode,
|
||||||
}: ParticipantConsentManagerProps & { participantName?: string | null; participantCode: string }) {
|
}: ParticipantConsentManagerProps & {
|
||||||
|
participantName?: string | null;
|
||||||
|
participantCode: string;
|
||||||
|
}) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
|
||||||
@@ -99,14 +102,24 @@ export function ParticipantConsentManager({
|
|||||||
|
|
||||||
// Substitute placeholders in markdown
|
// Substitute placeholders in markdown
|
||||||
let customMd = activeForm.content;
|
let customMd = activeForm.content;
|
||||||
customMd = customMd.replace(/{{PARTICIPANT_NAME}}/g, participantName ?? "_________________");
|
customMd = customMd.replace(
|
||||||
|
/{{PARTICIPANT_NAME}}/g,
|
||||||
|
participantName ?? "_________________",
|
||||||
|
);
|
||||||
customMd = customMd.replace(/{{PARTICIPANT_CODE}}/g, participantCode);
|
customMd = customMd.replace(/{{PARTICIPANT_CODE}}/g, participantCode);
|
||||||
customMd = customMd.replace(/{{DATE}}/g, "_________________");
|
customMd = customMd.replace(/{{DATE}}/g, "_________________");
|
||||||
customMd = customMd.replace(/{{SIGNATURE_IMAGE}}/g, ""); // Blank ready for physical signature
|
customMd = customMd.replace(/{{SIGNATURE_IMAGE}}/g, ""); // Blank ready for physical signature
|
||||||
|
|
||||||
// Use headless Tiptap to parse MD to HTML via same extensions
|
// Use headless Tiptap to parse MD to HTML via same extensions
|
||||||
const editor = new Editor({
|
const editor = new Editor({
|
||||||
extensions: [StarterKit, Table, TableRow, TableHeader, TableCell, Markdown],
|
extensions: [
|
||||||
|
StarterKit,
|
||||||
|
Table,
|
||||||
|
TableRow,
|
||||||
|
TableHeader,
|
||||||
|
TableCell,
|
||||||
|
Markdown,
|
||||||
|
],
|
||||||
content: customMd,
|
content: customMd,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -195,7 +208,11 @@ export function ParticipantConsentManager({
|
|||||||
activeForm={activeForm}
|
activeForm={activeForm}
|
||||||
onSuccess={handleSuccess}
|
onSuccess={handleSuccess}
|
||||||
/>
|
/>
|
||||||
<Button variant="outline" size="sm" onClick={handleDownloadUnsigned}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleDownloadUnsigned}
|
||||||
|
>
|
||||||
<Download className="mr-2 h-4 w-4" />
|
<Download className="mr-2 h-4 w-4" />
|
||||||
Print Empty Form
|
Print Empty Form
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -119,39 +119,39 @@ export function ParticipantForm({
|
|||||||
{ label: "Studies", href: "/studies" },
|
{ label: "Studies", href: "/studies" },
|
||||||
...(contextStudyId
|
...(contextStudyId
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
label: participant?.study?.name ?? "Study",
|
label: participant?.study?.name ?? "Study",
|
||||||
href: `/studies/${contextStudyId}`,
|
href: `/studies/${contextStudyId}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Participants",
|
label: "Participants",
|
||||||
href: `/studies/${contextStudyId}/participants`,
|
href: `/studies/${contextStudyId}/participants`,
|
||||||
},
|
},
|
||||||
...(mode === "edit" && participant
|
...(mode === "edit" && participant
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
label: participant.name ?? participant.participantCode,
|
label: participant.name ?? participant.participantCode,
|
||||||
href: `/studies/${contextStudyId}/participants/${participant.id}`,
|
href: `/studies/${contextStudyId}/participants/${participant.id}`,
|
||||||
},
|
},
|
||||||
{ label: "Edit" },
|
{ label: "Edit" },
|
||||||
]
|
]
|
||||||
: [{ label: "New Participant" }]),
|
: [{ label: "New Participant" }]),
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
{
|
{
|
||||||
label: "Participants",
|
label: "Participants",
|
||||||
href: `/studies/${contextStudyId}/participants`,
|
href: `/studies/${contextStudyId}/participants`,
|
||||||
},
|
},
|
||||||
...(mode === "edit" && participant
|
...(mode === "edit" && participant
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
label: participant.name ?? participant.participantCode,
|
label: participant.name ?? participant.participantCode,
|
||||||
href: `/studies/${contextStudyId}/participants/${participant.id}`,
|
href: `/studies/${contextStudyId}/participants/${participant.id}`,
|
||||||
},
|
},
|
||||||
{ label: "Edit" },
|
{ label: "Edit" },
|
||||||
]
|
]
|
||||||
: [{ label: "New Participant" }]),
|
: [{ label: "New Participant" }]),
|
||||||
]),
|
]),
|
||||||
];
|
];
|
||||||
|
|
||||||
useBreadcrumbsEffect(breadcrumbs);
|
useBreadcrumbsEffect(breadcrumbs);
|
||||||
@@ -291,7 +291,7 @@ export function ParticipantForm({
|
|||||||
readOnly={true}
|
readOnly={true}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-muted text-muted-foreground",
|
"bg-muted text-muted-foreground",
|
||||||
form.formState.errors.participantCode ? "border-red-500" : ""
|
form.formState.errors.participantCode ? "border-red-500" : "",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{form.formState.errors.participantCode && (
|
{form.formState.errors.participantCode && (
|
||||||
@@ -338,7 +338,11 @@ export function ParticipantForm({
|
|||||||
|
|
||||||
<FormSection
|
<FormSection
|
||||||
title={contextStudyId ? "Demographics" : "Demographics & Study"}
|
title={contextStudyId ? "Demographics" : "Demographics & Study"}
|
||||||
description={contextStudyId ? "Participant demographic details." : "Study association and demographic details."}
|
description={
|
||||||
|
contextStudyId
|
||||||
|
? "Participant demographic details."
|
||||||
|
: "Study association and demographic details."
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||||
{!contextStudyId && (
|
{!contextStudyId && (
|
||||||
@@ -358,7 +362,9 @@ export function ParticipantForm({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectValue
|
<SelectValue
|
||||||
placeholder={studiesLoading ? "Loading..." : "Select study"}
|
placeholder={
|
||||||
|
studiesLoading ? "Loading..." : "Select study"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -404,11 +410,11 @@ export function ParticipantForm({
|
|||||||
form.setValue(
|
form.setValue(
|
||||||
"gender",
|
"gender",
|
||||||
value as
|
value as
|
||||||
| "male"
|
| "male"
|
||||||
| "female"
|
| "female"
|
||||||
| "non_binary"
|
| "non_binary"
|
||||||
| "prefer_not_to_say"
|
| "prefer_not_to_say"
|
||||||
| "other",
|
| "other",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Robot initialization mutation (for startup routine)
|
// Robot initialization mutation (for startup routine)
|
||||||
const initializeRobotMutation = api.robots.initialize.useMutation({
|
const initializeRobotMutation = api.robots.plugins.initialize.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Robot initialized", {
|
toast.success("Robot initialized", {
|
||||||
description: "Autonomous Life disabled and robot awake.",
|
description: "Autonomous Life disabled and robot awake.",
|
||||||
@@ -187,7 +187,8 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const executeSystemActionMutation = api.robots.executeSystemAction.useMutation();
|
const executeSystemActionMutation =
|
||||||
|
api.robots.plugins.executeSystemAction.useMutation();
|
||||||
const [isCompleting, setIsCompleting] = useState(false);
|
const [isCompleting, setIsCompleting] = useState(false);
|
||||||
|
|
||||||
// Map database step types to component step types
|
// Map database step types to component step types
|
||||||
@@ -578,11 +579,20 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleNextStep = (targetIndex?: number) => {
|
const handleNextStep = (targetIndex?: number) => {
|
||||||
|
console.log(
|
||||||
|
`[DEBUG] handleNextStep called: targetIndex=${targetIndex}, currentStepIndex=${currentStepIndex}`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`[DEBUG] Steps: ${steps.map((s, i) => `${i}:${s.name}`).join(" | ")}`,
|
||||||
|
);
|
||||||
|
|
||||||
// If explicit target provided (from branching choice), use it
|
// If explicit target provided (from branching choice), use it
|
||||||
if (typeof targetIndex === "number") {
|
if (typeof targetIndex === "number") {
|
||||||
// Find step by index to ensure safety
|
// Find step by index to ensure safety
|
||||||
if (targetIndex >= 0 && targetIndex < steps.length) {
|
if (targetIndex >= 0 && targetIndex < steps.length) {
|
||||||
console.log(`[WizardInterface] Manual jump to step ${targetIndex}`);
|
console.log(
|
||||||
|
`[WizardInterface] Manual jump to step ${targetIndex} (${steps[targetIndex]?.name})`,
|
||||||
|
);
|
||||||
|
|
||||||
// Log manual jump
|
// Log manual jump
|
||||||
logEventMutation.mutate({
|
logEventMutation.mutate({
|
||||||
@@ -600,7 +610,18 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
setCompletedActionsCount(0);
|
setCompletedActionsCount(0);
|
||||||
setCurrentStepIndex(targetIndex);
|
setCurrentStepIndex(targetIndex);
|
||||||
setLastResponse(null);
|
setLastResponse(null);
|
||||||
|
|
||||||
|
// Mark source step as completed so it won't be revisited
|
||||||
|
setCompletedSteps((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.add(currentStepIndex);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
`[DEBUG] Invalid targetIndex: ${targetIndex}, steps.length=${steps.length}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -613,33 +634,51 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
currentStep.conditions?.options &&
|
currentStep.conditions?.options &&
|
||||||
lastResponse
|
lastResponse
|
||||||
) {
|
) {
|
||||||
const matchedOption = currentStep.conditions.options.find(
|
// Handle both string options and object options
|
||||||
(opt) => opt.value === lastResponse,
|
const matchedOption = currentStep.conditions.options.find((opt) => {
|
||||||
);
|
// If opt is a string, compare directly with lastResponse
|
||||||
if (matchedOption && matchedOption.nextStepId) {
|
if (typeof opt === "string") {
|
||||||
// Find index of the target step
|
return opt === lastResponse;
|
||||||
const targetIndex = steps.findIndex(
|
}
|
||||||
(s) => s.id === matchedOption.nextStepId,
|
// If opt is an object, check .value property
|
||||||
);
|
return opt.value === lastResponse;
|
||||||
if (targetIndex !== -1) {
|
});
|
||||||
console.log(
|
|
||||||
`[WizardInterface] Branching to step ${targetIndex} (${matchedOption.label})`,
|
|
||||||
);
|
|
||||||
|
|
||||||
logEventMutation.mutate({
|
if (matchedOption) {
|
||||||
trialId: trial.id,
|
// Handle both string options and object options for nextStepId
|
||||||
type: "step_branched",
|
const nextStepId =
|
||||||
data: {
|
typeof matchedOption === "string"
|
||||||
fromIndex: currentStepIndex,
|
? null // String options don't have nextStepId
|
||||||
toIndex: targetIndex,
|
: matchedOption.nextStepId;
|
||||||
condition: matchedOption.label,
|
|
||||||
value: lastResponse,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
setCurrentStepIndex(targetIndex);
|
if (nextStepId) {
|
||||||
setLastResponse(null); // Reset after consuming
|
// Find index of the target step
|
||||||
return;
|
const targetIndex = steps.findIndex((s) => s.id === nextStepId);
|
||||||
|
if (targetIndex !== -1) {
|
||||||
|
const label =
|
||||||
|
typeof matchedOption === "string"
|
||||||
|
? matchedOption
|
||||||
|
: matchedOption.label;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[WizardInterface] Branching to step ${targetIndex} (${label})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
logEventMutation.mutate({
|
||||||
|
trialId: trial.id,
|
||||||
|
type: "step_branched",
|
||||||
|
data: {
|
||||||
|
fromIndex: currentStepIndex,
|
||||||
|
toIndex: targetIndex,
|
||||||
|
condition: label,
|
||||||
|
value: lastResponse,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
setCurrentStepIndex(targetIndex);
|
||||||
|
setLastResponse(null); // Reset after consuming
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -837,13 +876,25 @@ export const WizardInterface = React.memo(function WizardInterface({
|
|||||||
if (parameters.nextStepId) {
|
if (parameters.nextStepId) {
|
||||||
const nextId = String(parameters.nextStepId);
|
const nextId = String(parameters.nextStepId);
|
||||||
const targetIndex = steps.findIndex((s) => s.id === nextId);
|
const targetIndex = steps.findIndex((s) => s.id === nextId);
|
||||||
|
console.log(
|
||||||
|
`[DEBUG] Branch choice: value=${parameters.value}, label=${parameters.label}`,
|
||||||
|
);
|
||||||
|
console.log(`[DEBUG] Target step ID: ${nextId}`);
|
||||||
|
console.log(`[DEBUG] Target index in steps array: ${targetIndex}`);
|
||||||
|
console.log(
|
||||||
|
`[DEBUG] Available step IDs: ${steps.map((s) => s.id).join(", ")}`,
|
||||||
|
);
|
||||||
if (targetIndex !== -1) {
|
if (targetIndex !== -1) {
|
||||||
console.log(
|
console.log(
|
||||||
`[WizardInterface] Choice-based jump to step ${targetIndex} (${nextId})`,
|
`[WizardInterface] Choice-based jump to step ${targetIndex} (${nextId})`,
|
||||||
);
|
);
|
||||||
handleNextStep(targetIndex);
|
handleNextStep(targetIndex);
|
||||||
return; // Exit after jump
|
return; // Exit after jump
|
||||||
|
} else {
|
||||||
|
console.warn(`[DEBUG] Target step not found! nextStepId=${nextId}`);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
console.warn(`[DEBUG] No nextStepId in parameters!`, parameters);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ export function TrialControlPanel({
|
|||||||
Pause
|
Pause
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={onNextStep}
|
onClick={() => onNextStep()}
|
||||||
disabled={currentStepIndex >= steps.length - 1}
|
disabled={currentStepIndex >= steps.length - 1}
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -535,8 +535,10 @@ export function WizardActionItem({
|
|||||||
className="hover:border-primary hover:bg-primary/5 h-auto justify-start px-4 py-3 text-left"
|
className="hover:border-primary hover:bg-primary/5 h-auto justify-start px-4 py-3 text-left"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
console.log(`[DEBUG WizardActionItem] Choice clicked: actionId=${action.id}, value=${value}, label=${label}, nextStepId=${nextStepId}`);
|
||||||
onExecute(action.id, { value, label, nextStepId });
|
onExecute(action.id, { value, label, nextStepId });
|
||||||
onCompleted();
|
// Don't call onCompleted() here - the branching logic in handleWizardResponse
|
||||||
|
// will handle the jump and reset completedActionsCount
|
||||||
}}
|
}}
|
||||||
disabled={readOnly || isExecuting}
|
disabled={readOnly || isExecuting}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -403,8 +403,8 @@ export function WizardExecutionPanel({
|
|||||||
size="lg"
|
size="lg"
|
||||||
onClick={
|
onClick={
|
||||||
currentStepIndex === steps.length - 1
|
currentStepIndex === steps.length - 1
|
||||||
? onCompleteTrial
|
? (onCompleteTrial ?? (() => {}))
|
||||||
: onNextStep
|
: () => onNextStep?.()
|
||||||
}
|
}
|
||||||
className={`w-full max-w-sm text-white shadow-lg transition-all hover:scale-[1.02] ${
|
className={`w-full max-w-sm text-white shadow-lg transition-all hover:scale-[1.02] ${
|
||||||
currentStepIndex === steps.length - 1
|
currentStepIndex === steps.length - 1
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
/* eslint-disable react-hooks/exhaustive-deps */
|
/* eslint-disable react-hooks/exhaustive-deps */
|
||||||
|
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "~/lib/auth-client";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
export type TrialStatus =
|
export type TrialStatus =
|
||||||
|
|||||||
@@ -345,7 +345,8 @@ export function useWizardRos(
|
|||||||
...execution,
|
...execution,
|
||||||
status: "failed",
|
status: "failed",
|
||||||
endTime: new Date(),
|
endTime: new Date(),
|
||||||
error: error instanceof Error ? error.message : "System action failed",
|
error:
|
||||||
|
error instanceof Error ? error.message : "System action failed",
|
||||||
};
|
};
|
||||||
service.emit("action_failed", failedExecution);
|
service.emit("action_failed", failedExecution);
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -1,68 +1,15 @@
|
|||||||
// Client-side role utilities without database imports
|
import { createAuthClient } from "better-auth/react";
|
||||||
import type { Session } from "next-auth";
|
|
||||||
|
export const authClient = createAuthClient({
|
||||||
|
baseURL: process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const { signIn, signOut, useSession } = authClient;
|
||||||
|
|
||||||
// Role types from schema
|
// Role types from schema
|
||||||
export type SystemRole = "administrator" | "researcher" | "wizard" | "observer";
|
export type SystemRole = "administrator" | "researcher" | "wizard" | "observer";
|
||||||
export type StudyRole = "owner" | "researcher" | "wizard" | "observer";
|
export type StudyRole = "owner" | "researcher" | "wizard" | "observer";
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the current user has a specific system role
|
|
||||||
*/
|
|
||||||
export function hasRole(session: Session | null, role: SystemRole): boolean {
|
|
||||||
if (!session?.user?.roles) return false;
|
|
||||||
return session.user.roles.some((userRole) => userRole.role === role);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the current user is an administrator
|
|
||||||
*/
|
|
||||||
export function isAdmin(session: Session | null): boolean {
|
|
||||||
return hasRole(session, "administrator");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the current user is a researcher or admin
|
|
||||||
*/
|
|
||||||
export function isResearcher(session: Session | null): boolean {
|
|
||||||
return hasRole(session, "researcher") || isAdmin(session);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the current user is a wizard or admin
|
|
||||||
*/
|
|
||||||
export function isWizard(session: Session | null): boolean {
|
|
||||||
return hasRole(session, "wizard") || isAdmin(session);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the current user has any of the specified roles
|
|
||||||
*/
|
|
||||||
export function hasAnyRole(
|
|
||||||
session: Session | null,
|
|
||||||
roles: SystemRole[],
|
|
||||||
): boolean {
|
|
||||||
if (!session?.user?.roles) return false;
|
|
||||||
return session.user.roles.some((userRole) => roles.includes(userRole.role));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a user owns or has admin access to a resource
|
|
||||||
*/
|
|
||||||
export function canAccessResource(
|
|
||||||
session: Session | null,
|
|
||||||
resourceOwnerId: string,
|
|
||||||
): boolean {
|
|
||||||
if (!session?.user) return false;
|
|
||||||
|
|
||||||
// Admin can access anything
|
|
||||||
if (isAdmin(session)) return true;
|
|
||||||
|
|
||||||
// Owner can access their own resources
|
|
||||||
if (session.user.id === resourceOwnerId) return true;
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format role for display
|
* Format role for display
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { signOut } from "next-auth/react";
|
import { signOut } from "~/lib/auth-client";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { TRPCClientError } from "@trpc/client";
|
import { TRPCClientError } from "@trpc/client";
|
||||||
|
|
||||||
@@ -104,10 +104,8 @@ export async function handleAuthError(
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
await signOut({
|
await signOut();
|
||||||
callbackUrl: "/",
|
window.location.href = "/";
|
||||||
redirect: true,
|
|
||||||
});
|
|
||||||
} catch (signOutError) {
|
} catch (signOutError) {
|
||||||
console.error("Error during sign out:", signOutError);
|
console.error("Error during sign out:", signOutError);
|
||||||
// Force redirect if signOut fails
|
// Force redirect if signOut fails
|
||||||
|
|||||||
79
src/lib/auth.ts
Normal file
79
src/lib/auth.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { betterAuth } from "better-auth";
|
||||||
|
import { drizzleAdapter } from "@better-auth/drizzle-adapter";
|
||||||
|
import { nextCookies } from "better-auth/next-js";
|
||||||
|
import { db } from "~/server/db";
|
||||||
|
import {
|
||||||
|
users,
|
||||||
|
accounts,
|
||||||
|
sessions,
|
||||||
|
verificationTokens,
|
||||||
|
} from "~/server/db/schema";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
|
||||||
|
const baseURL =
|
||||||
|
process.env.NEXTAUTH_URL ||
|
||||||
|
process.env.BETTER_AUTH_URL ||
|
||||||
|
"http://localhost:3000";
|
||||||
|
|
||||||
|
export const auth = betterAuth({
|
||||||
|
baseURL,
|
||||||
|
database: drizzleAdapter(db, {
|
||||||
|
provider: "pg",
|
||||||
|
schema: {
|
||||||
|
user: users,
|
||||||
|
account: accounts,
|
||||||
|
session: sessions,
|
||||||
|
verification: verificationTokens,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
emailAndPassword: {
|
||||||
|
enabled: true,
|
||||||
|
password: {
|
||||||
|
hash: async (password: string) => {
|
||||||
|
return bcrypt.hash(password, 12);
|
||||||
|
},
|
||||||
|
verify: async ({
|
||||||
|
hash,
|
||||||
|
password,
|
||||||
|
}: {
|
||||||
|
hash: string;
|
||||||
|
password: string;
|
||||||
|
}) => {
|
||||||
|
return bcrypt.compare(password, hash);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
session: {
|
||||||
|
expiresIn: 60 * 60 * 24 * 30,
|
||||||
|
updateAge: 60 * 60 * 24,
|
||||||
|
modelName: "session",
|
||||||
|
fields: {
|
||||||
|
id: "id",
|
||||||
|
token: "token",
|
||||||
|
userId: "userId",
|
||||||
|
expiresAt: "expiresAt",
|
||||||
|
ipAddress: "ipAddress",
|
||||||
|
userAgent: "userAgent",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
account: {
|
||||||
|
modelName: "account",
|
||||||
|
fields: {
|
||||||
|
id: "id",
|
||||||
|
providerId: "providerId",
|
||||||
|
accountId: "accountId",
|
||||||
|
userId: "userId",
|
||||||
|
accessToken: "accessToken",
|
||||||
|
refreshToken: "refreshToken",
|
||||||
|
expiresAt: "expiresAt",
|
||||||
|
scope: "scope",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pages: {
|
||||||
|
signIn: "/auth/signin",
|
||||||
|
error: "/auth/error",
|
||||||
|
},
|
||||||
|
plugins: [nextCookies()],
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Session = typeof auth.$Infer.Session;
|
||||||
@@ -1,61 +1,76 @@
|
|||||||
export interface PdfOptions {
|
export interface PdfOptions {
|
||||||
filename?: string;
|
filename?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getHtml2PdfOptions = (filename?: string) => ({
|
const getHtml2PdfOptions = (filename?: string) => ({
|
||||||
margin: 0.5,
|
margin: 0.5,
|
||||||
filename: filename ?? 'document.pdf',
|
filename: filename ?? "document.pdf",
|
||||||
image: { type: 'jpeg' as const, quality: 0.98 },
|
image: { type: "jpeg" as const, quality: 0.98 },
|
||||||
html2canvas: { scale: 2, useCORS: true, backgroundColor: "#ffffff", windowWidth: 800 },
|
html2canvas: {
|
||||||
jsPDF: { unit: 'in', format: 'letter' as const, orientation: 'portrait' as const }
|
scale: 2,
|
||||||
|
useCORS: true,
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
windowWidth: 800,
|
||||||
|
},
|
||||||
|
jsPDF: {
|
||||||
|
unit: "in",
|
||||||
|
format: "letter" as const,
|
||||||
|
orientation: "portrait" as const,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const createPrintWrapper = (htmlContent: string) => {
|
const createPrintWrapper = (htmlContent: string) => {
|
||||||
const printWrapper = document.createElement("div");
|
const printWrapper = document.createElement("div");
|
||||||
printWrapper.style.position = "absolute";
|
printWrapper.style.position = "absolute";
|
||||||
printWrapper.style.left = "-9999px";
|
printWrapper.style.left = "-9999px";
|
||||||
printWrapper.style.top = "0px";
|
printWrapper.style.top = "0px";
|
||||||
printWrapper.className = "light"; // Prevent dark mode variables from bleeding into the physical PDF
|
printWrapper.className = "light"; // Prevent dark mode variables from bleeding into the physical PDF
|
||||||
|
|
||||||
const element = document.createElement("div");
|
const element = document.createElement("div");
|
||||||
element.innerHTML = htmlContent;
|
element.innerHTML = htmlContent;
|
||||||
// Assign standard prose layout and explicitly white/black print colors
|
// Assign standard prose layout and explicitly white/black print colors
|
||||||
element.className = "prose prose-sm max-w-none p-12 bg-white text-black";
|
element.className = "prose prose-sm max-w-none p-12 bg-white text-black";
|
||||||
element.style.width = "800px";
|
element.style.width = "800px";
|
||||||
element.style.backgroundColor = "white";
|
element.style.backgroundColor = "white";
|
||||||
element.style.color = "black";
|
element.style.color = "black";
|
||||||
|
|
||||||
printWrapper.appendChild(element);
|
printWrapper.appendChild(element);
|
||||||
document.body.appendChild(printWrapper);
|
document.body.appendChild(printWrapper);
|
||||||
|
|
||||||
return { printWrapper, element };
|
return { printWrapper, element };
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function downloadPdfFromHtml(htmlContent: string, options: PdfOptions = {}): Promise<void> {
|
export async function downloadPdfFromHtml(
|
||||||
// @ts-ignore - Dynamic import to prevent SSR issues with window/document
|
htmlContent: string,
|
||||||
const html2pdf = (await import('html2pdf.js')).default;
|
options: PdfOptions = {},
|
||||||
|
): Promise<void> {
|
||||||
|
// @ts-ignore - Dynamic import to prevent SSR issues with window/document
|
||||||
|
const html2pdf = (await import("html2pdf.js")).default;
|
||||||
|
|
||||||
const { printWrapper, element } = createPrintWrapper(htmlContent);
|
const { printWrapper, element } = createPrintWrapper(htmlContent);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const opt = getHtml2PdfOptions(options.filename);
|
const opt = getHtml2PdfOptions(options.filename);
|
||||||
await html2pdf().set(opt).from(element).save();
|
await html2pdf().set(opt).from(element).save();
|
||||||
} finally {
|
} finally {
|
||||||
document.body.removeChild(printWrapper);
|
document.body.removeChild(printWrapper);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generatePdfBlobFromHtml(htmlContent: string, options: PdfOptions = {}): Promise<Blob> {
|
export async function generatePdfBlobFromHtml(
|
||||||
// @ts-ignore - Dynamic import to prevent SSR issues with window/document
|
htmlContent: string,
|
||||||
const html2pdf = (await import('html2pdf.js')).default;
|
options: PdfOptions = {},
|
||||||
|
): Promise<Blob> {
|
||||||
|
// @ts-ignore - Dynamic import to prevent SSR issues with window/document
|
||||||
|
const html2pdf = (await import("html2pdf.js")).default;
|
||||||
|
|
||||||
const { printWrapper, element } = createPrintWrapper(htmlContent);
|
const { printWrapper, element } = createPrintWrapper(htmlContent);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const opt = getHtml2PdfOptions(options.filename);
|
const opt = getHtml2PdfOptions(options.filename);
|
||||||
const pdfBlob = await html2pdf().set(opt).from(element).output('blob');
|
const pdfBlob = await html2pdf().set(opt).from(element).output("blob");
|
||||||
return pdfBlob;
|
return pdfBlob;
|
||||||
} finally {
|
} finally {
|
||||||
document.body.removeChild(printWrapper);
|
document.body.removeChild(printWrapper);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -298,6 +298,7 @@ export class WizardRosService extends EventEmitter {
|
|||||||
messageType: string,
|
messageType: string,
|
||||||
msg: Record<string, unknown>,
|
msg: Record<string, unknown>,
|
||||||
): void {
|
): void {
|
||||||
|
console.log(`[WizardROS] Publishing to ${topic}:`, msg);
|
||||||
const message: RosMessage = {
|
const message: RosMessage = {
|
||||||
op: "publish",
|
op: "publish",
|
||||||
topic,
|
topic,
|
||||||
@@ -437,12 +438,30 @@ export class WizardRosService extends EventEmitter {
|
|||||||
|
|
||||||
this.publish(config.topic, config.messageType, msg);
|
this.publish(config.topic, config.messageType, msg);
|
||||||
|
|
||||||
// Wait for action completion (simple delay for now)
|
// Wait for action completion based on topic type
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
if (config.topic === "/speech") {
|
||||||
|
// Estimate speech duration based on text content
|
||||||
|
const text =
|
||||||
|
typeof msg === "object" && msg !== null && "data" in msg
|
||||||
|
? String((msg as any).data || "")
|
||||||
|
: JSON.stringify(msg);
|
||||||
|
const wordCount = text.split(/\s+/).filter(Boolean).length;
|
||||||
|
// Emotion markup adds overhead: ~200ms per word base + emotion animation time
|
||||||
|
const emotionOverhead = 1500; // Animation prep time
|
||||||
|
const duration = emotionOverhead + Math.max(1000, wordCount * 300);
|
||||||
|
console.log(
|
||||||
|
`[WizardROS] Speech action estimated duration: ${duration}ms (${wordCount} words)`,
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, duration));
|
||||||
|
} else {
|
||||||
|
// Short delay for non-speech actions
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute built-in robot actions
|
* Execute built-in robot actions (robot-agnostic defaults)
|
||||||
|
* These are generic actions that work with standard ROS topics
|
||||||
*/
|
*/
|
||||||
private async executeBuiltinAction(
|
private async executeBuiltinAction(
|
||||||
actionId: string,
|
actionId: string,
|
||||||
@@ -450,36 +469,97 @@ export class WizardRosService extends EventEmitter {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
switch (actionId) {
|
switch (actionId) {
|
||||||
case "say_text":
|
case "say_text":
|
||||||
|
case "say_with_emotion":
|
||||||
const text = String(parameters.text || "Hello");
|
const text = String(parameters.text || "Hello");
|
||||||
this.publish("/speech", "std_msgs/String", {
|
this.publish("/speech", "std_msgs/String", { data: text });
|
||||||
data: text,
|
const wordCount = text.split(/\s+/).filter(Boolean).length;
|
||||||
});
|
const emotion = String(parameters.emotion || "neutral");
|
||||||
// Estimate speech duration (roughly 150ms per word + 500ms baseline)
|
const emotionOverhead = 1500;
|
||||||
const wordCount = text.split(/\s+/).length;
|
const duration = emotionOverhead + Math.max(1000, wordCount * 300);
|
||||||
const estimatedDuration = Math.max(800, wordCount * 250 + 500);
|
console.log(
|
||||||
await new Promise((resolve) => setTimeout(resolve, estimatedDuration));
|
`[WizardROS] Speech action (${actionId}) estimated: ${duration}ms`,
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, duration));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "wave_goodbye":
|
||||||
|
const waveText = String(parameters.text || "Goodbye");
|
||||||
|
this.publish("/speech", "std_msgs/String", { data: waveText });
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "walk_forward":
|
case "walk_forward":
|
||||||
|
this.publish("/cmd_vel", "geometry_msgs/Twist", {
|
||||||
|
linear: { x: Number(parameters.speed) || 0.1, y: 0, z: 0 },
|
||||||
|
angular: { x: 0, y: 0, z: 0 },
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
break;
|
||||||
|
|
||||||
case "walk_backward":
|
case "walk_backward":
|
||||||
|
this.publish("/cmd_vel", "geometry_msgs/Twist", {
|
||||||
|
linear: { x: -(Number(parameters.speed) || 0.1), y: 0, z: 0 },
|
||||||
|
angular: { x: 0, y: 0, z: 0 },
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
break;
|
||||||
|
|
||||||
case "turn_left":
|
case "turn_left":
|
||||||
|
this.publish("/cmd_vel", "geometry_msgs/Twist", {
|
||||||
|
linear: { x: 0, y: 0, z: 0 },
|
||||||
|
angular: { x: 0, y: 0, z: -(Number(parameters.speed) || 0.3) },
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
break;
|
||||||
|
|
||||||
case "turn_right":
|
case "turn_right":
|
||||||
case "strafe_left":
|
this.publish("/cmd_vel", "geometry_msgs/Twist", {
|
||||||
case "strafe_right":
|
linear: { x: 0, y: 0, z: 0 },
|
||||||
await this.executeMovementAction(actionId, parameters);
|
angular: { x: 0, y: 0, z: Number(parameters.speed) || 0.3 },
|
||||||
// Wait for movement to start (short baseline for better UI 'loading' feel)
|
});
|
||||||
await new Promise((resolve) => setTimeout(resolve, 800));
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "move_head":
|
case "move_head":
|
||||||
case "turn_head":
|
case "turn_head":
|
||||||
await this.executeTurnHead(parameters);
|
this.publish(
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
"/joint_angles",
|
||||||
|
"naoqi_bridge_msgs/JointAnglesWithSpeed",
|
||||||
|
{
|
||||||
|
joint_names: ["HeadYaw", "HeadPitch"],
|
||||||
|
joint_angles: [
|
||||||
|
Number(parameters.yaw) || 0,
|
||||||
|
Number(parameters.pitch) || 0,
|
||||||
|
],
|
||||||
|
speed: Number(parameters.speed) || 0.3,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "move_arm":
|
case "move_arm":
|
||||||
await this.executeMoveArm(parameters);
|
const arm = String(parameters.arm || "right");
|
||||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
const prefix = arm.toLowerCase() === "left" ? "L" : "R";
|
||||||
|
this.publish(
|
||||||
|
"/joint_angles",
|
||||||
|
"naoqi_bridge_msgs/JointAnglesWithSpeed",
|
||||||
|
{
|
||||||
|
joint_names: [
|
||||||
|
`${prefix}ShoulderPitch`,
|
||||||
|
`${prefix}ShoulderRoll`,
|
||||||
|
`${prefix}ElbowYaw`,
|
||||||
|
`${prefix}ElbowRoll`,
|
||||||
|
],
|
||||||
|
joint_angles: [
|
||||||
|
Number(parameters.shoulder_pitch) || 0,
|
||||||
|
Number(parameters.shoulder_roll) || 0,
|
||||||
|
Number(parameters.elbow_yaw) || 0,
|
||||||
|
Number(parameters.elbow_roll) || 0,
|
||||||
|
],
|
||||||
|
speed: Number(parameters.speed) || 0.3,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "emergency_stop":
|
case "emergency_stop":
|
||||||
@@ -490,88 +570,12 @@ export class WizardRosService extends EventEmitter {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown action: ${actionId}`);
|
throw new Error(
|
||||||
|
`Unknown action: ${actionId}. Define this action in your robot plugin.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute movement actions
|
|
||||||
*/
|
|
||||||
private executeMovementAction(
|
|
||||||
actionId: string,
|
|
||||||
parameters: Record<string, unknown>,
|
|
||||||
): void {
|
|
||||||
let linear = { x: 0, y: 0, z: 0 };
|
|
||||||
let angular = { x: 0, y: 0, z: 0 };
|
|
||||||
|
|
||||||
const speed = Number(parameters.speed) || 0.1;
|
|
||||||
|
|
||||||
switch (actionId) {
|
|
||||||
case "walk_forward":
|
|
||||||
linear.x = speed;
|
|
||||||
break;
|
|
||||||
case "walk_backward":
|
|
||||||
linear.x = -speed;
|
|
||||||
break;
|
|
||||||
case "turn_left":
|
|
||||||
angular.z = speed;
|
|
||||||
break;
|
|
||||||
case "turn_right":
|
|
||||||
angular.z = -speed;
|
|
||||||
break;
|
|
||||||
case "strafe_left":
|
|
||||||
linear.y = speed;
|
|
||||||
break;
|
|
||||||
case "strafe_right":
|
|
||||||
linear.y = -speed;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.publish("/cmd_vel", "geometry_msgs/Twist", {
|
|
||||||
linear,
|
|
||||||
angular,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute head turn action
|
|
||||||
*/
|
|
||||||
private executeTurnHead(parameters: Record<string, unknown>): void {
|
|
||||||
const yaw = Number(parameters.yaw) || 0;
|
|
||||||
const pitch = Number(parameters.pitch) || 0;
|
|
||||||
const speed = Number(parameters.speed) || 0.3;
|
|
||||||
|
|
||||||
this.publish("/joint_angles", "naoqi_bridge_msgs/JointAnglesWithSpeed", {
|
|
||||||
joint_names: ["HeadYaw", "HeadPitch"],
|
|
||||||
joint_angles: [yaw, pitch],
|
|
||||||
speed: speed,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute arm movement
|
|
||||||
*/
|
|
||||||
private executeMoveArm(parameters: Record<string, unknown>): void {
|
|
||||||
const arm = String(parameters.arm || "Right");
|
|
||||||
const roll = Number(parameters.roll) || 0;
|
|
||||||
const pitch = Number(parameters.pitch) || 0;
|
|
||||||
const speed = Number(parameters.speed) || 0.2;
|
|
||||||
|
|
||||||
const prefix = arm === "Left" ? "L" : "R";
|
|
||||||
const jointNames = [`${prefix}ShoulderPitch`, `${prefix}ShoulderRoll`];
|
|
||||||
const jointAngles = [pitch, roll];
|
|
||||||
|
|
||||||
this.publish(
|
|
||||||
"/joint_angles",
|
|
||||||
"naoqi_bridge_msgs/JointAnglesWithSpeed",
|
|
||||||
{
|
|
||||||
joint_names: jointNames,
|
|
||||||
joint_angles: jointAngles,
|
|
||||||
speed: speed,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Call a ROS service
|
* Call a ROS service
|
||||||
*/
|
*/
|
||||||
@@ -776,12 +780,74 @@ export class WizardRosService extends EventEmitter {
|
|||||||
speed: Number(parameters.speed) || 0.2,
|
speed: Number(parameters.speed) || 0.2,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
case "transformToEmotionalSpeech":
|
||||||
|
return this.transformToEmotionalSpeech(parameters);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
console.warn(`Unknown transform function: ${transformFn}`);
|
console.warn(`Unknown transform function: ${transformFn}`);
|
||||||
return parameters;
|
return parameters;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform parameters for emotional speech
|
||||||
|
* NAOqi markup: \rspd=<speed>\<text>
|
||||||
|
* For animated speech: ^start(animations/Stand/Gestures/...)
|
||||||
|
*/
|
||||||
|
private transformToEmotionalSpeech(parameters: Record<string, unknown>): {
|
||||||
|
data: string;
|
||||||
|
} {
|
||||||
|
const text = String(parameters.text || "Hello");
|
||||||
|
const emotion = String(parameters.emotion || "neutral");
|
||||||
|
const speed = Number(parameters.speed || 1.0);
|
||||||
|
const speedPercent = Math.round(speed * 100);
|
||||||
|
|
||||||
|
let markedText = text;
|
||||||
|
|
||||||
|
switch (emotion) {
|
||||||
|
case "happy":
|
||||||
|
markedText = `\\rspd=120\\^start(animations/Stand/Gestures/Happy_4) ${text}`;
|
||||||
|
break;
|
||||||
|
case "excited":
|
||||||
|
markedText = `\\rspd=140\\^start(animations/Stand/Gestures/Enthusiastic_1) ${text}`;
|
||||||
|
break;
|
||||||
|
case "sad":
|
||||||
|
markedText = `\\rspd=80\\vct=80\\${text}`;
|
||||||
|
break;
|
||||||
|
case "calm":
|
||||||
|
markedText = `\\rspd=90\\${text}`;
|
||||||
|
break;
|
||||||
|
case "neutral":
|
||||||
|
default:
|
||||||
|
markedText = `\\rspd=${speedPercent}\\${text}`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { data: markedText };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform for wave goodbye - animated speech with waving
|
||||||
|
*/
|
||||||
|
private transformToWaveGoodbye(parameters: Record<string, unknown>): {
|
||||||
|
data: string;
|
||||||
|
} {
|
||||||
|
const text = String(parameters.text || "Goodbye!");
|
||||||
|
const markedText = `\\rspd=110\\^start(animations/Stand/Gestures/Hey_1) ${text} ^start(animations/Stand/Gestures/Hey_1)`;
|
||||||
|
return { data: markedText };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform for playing animations
|
||||||
|
*/
|
||||||
|
private transformToAnimation(parameters: Record<string, unknown>): {
|
||||||
|
data: string;
|
||||||
|
} {
|
||||||
|
const animation = String(parameters.animation || "Hey_1");
|
||||||
|
const markedText = `^start(animations/Stand/Gestures/${animation})`;
|
||||||
|
return { data: markedText };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schedule reconnection attempt
|
* Schedule reconnection attempt
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -927,11 +927,17 @@ export const adminRouter = createTRPCRouter({
|
|||||||
|
|
||||||
if (existingPlugin.length === 0) {
|
if (existingPlugin.length === 0) {
|
||||||
// Create new plugin
|
// Create new plugin
|
||||||
|
const pluginName = pluginData.name ?? "unknown";
|
||||||
|
const slugIdentifier = pluginName
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-|-$/g, "");
|
||||||
const newPlugin = await db
|
const newPlugin = await db
|
||||||
.insert(plugins)
|
.insert(plugins)
|
||||||
.values({
|
.values({
|
||||||
|
identifier: slugIdentifier,
|
||||||
robotId,
|
robotId,
|
||||||
name: pluginData.name ?? "",
|
name: pluginName,
|
||||||
version: pluginData.version ?? "",
|
version: pluginData.version ?? "",
|
||||||
description: pluginData.description,
|
description: pluginData.description,
|
||||||
author:
|
author:
|
||||||
|
|||||||
@@ -38,12 +38,15 @@ export const authRouter = createTRPCRouter({
|
|||||||
const hashedPassword = await bcrypt.hash(password, 12);
|
const hashedPassword = await bcrypt.hash(password, 12);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create user
|
// Create user with text ID
|
||||||
|
const userId = `user_${crypto.randomUUID()}`;
|
||||||
const newUsers = await ctx.db
|
const newUsers = await ctx.db
|
||||||
.insert(users)
|
.insert(users)
|
||||||
.values({
|
.values({
|
||||||
|
id: userId,
|
||||||
name,
|
name,
|
||||||
email,
|
email,
|
||||||
|
emailVerified: false,
|
||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
})
|
})
|
||||||
.returning({
|
.returning({
|
||||||
|
|||||||
@@ -428,7 +428,7 @@ export const dashboardRouter = createTRPCRouter({
|
|||||||
session: {
|
session: {
|
||||||
userId: ctx.session.user.id,
|
userId: ctx.session.user.id,
|
||||||
userEmail: ctx.session.user.email,
|
userEmail: ctx.session.user.email,
|
||||||
userRole: ctx.session.user.roles?.[0]?.role ?? null,
|
userRole: systemRoles[0]?.role ?? null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -499,7 +499,8 @@ export const robotsRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const robotIp = process.env.NAO_ROBOT_IP || process.env.NAO_IP || "134.82.159.168";
|
const robotIp =
|
||||||
|
process.env.NAO_ROBOT_IP || process.env.NAO_IP || "134.82.159.168";
|
||||||
const password = process.env.NAO_PASSWORD || "robolab";
|
const password = process.env.NAO_PASSWORD || "robolab";
|
||||||
|
|
||||||
console.log(`[Robots] Initializing robot ${input.id} at ${robotIp}`);
|
console.log(`[Robots] Initializing robot ${input.id} at ${robotIp}`);
|
||||||
@@ -514,7 +515,10 @@ export const robotsRouter = createTRPCRouter({
|
|||||||
// Execute commands sequentially
|
// Execute commands sequentially
|
||||||
console.log("[Robots] Executing AL disable...");
|
console.log("[Robots] Executing AL disable...");
|
||||||
await execAsync(disableAlCmd).catch((e) =>
|
await execAsync(disableAlCmd).catch((e) =>
|
||||||
console.warn("AL disable failed (non-critical/already disabled):", e),
|
console.warn(
|
||||||
|
"AL disable failed (non-critical/already disabled):",
|
||||||
|
e,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log("[Robots] Executing Wake Up...");
|
console.log("[Robots] Executing Wake Up...");
|
||||||
@@ -538,7 +542,8 @@ export const robotsRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const robotIp = process.env.NAO_ROBOT_IP || process.env.NAO_IP || "134.82.159.168";
|
const robotIp =
|
||||||
|
process.env.NAO_ROBOT_IP || process.env.NAO_IP || "134.82.159.168";
|
||||||
const password = process.env.NAO_PASSWORD || "robolab";
|
const password = process.env.NAO_PASSWORD || "robolab";
|
||||||
|
|
||||||
console.log(`[Robots] Executing system action ${input.id}`);
|
console.log(`[Robots] Executing system action ${input.id}`);
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import superjson from "superjson";
|
|||||||
import { ZodError } from "zod";
|
import { ZodError } from "zod";
|
||||||
|
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { auth } from "~/server/auth";
|
import { headers } from "next/headers";
|
||||||
|
import { auth } from "~/lib/auth";
|
||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
import { userSystemRoles } from "~/server/db/schema";
|
import { userSystemRoles } from "~/server/db/schema";
|
||||||
|
|
||||||
@@ -29,7 +30,9 @@ import { userSystemRoles } from "~/server/db/schema";
|
|||||||
* @see https://trpc.io/docs/server/context
|
* @see https://trpc.io/docs/server/context
|
||||||
*/
|
*/
|
||||||
export const createTRPCContext = async (opts: { headers: Headers }) => {
|
export const createTRPCContext = async (opts: { headers: Headers }) => {
|
||||||
const session = await auth();
|
const session = await auth.api.getSession({
|
||||||
|
headers: await headers(),
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
db,
|
db,
|
||||||
|
|||||||
@@ -1,134 +0,0 @@
|
|||||||
import bcrypt from "bcryptjs";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { type DefaultSession, type NextAuthConfig } from "next-auth";
|
|
||||||
import Credentials from "next-auth/providers/credentials";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
import { db } from "~/server/db";
|
|
||||||
import { users } from "~/server/db/schema";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Module augmentation for `next-auth` types. Allows us to add custom properties to the `session`
|
|
||||||
* object and keep type safety.
|
|
||||||
*
|
|
||||||
* @see https://next-auth.js.org/getting-started/typescript#module-augmentation
|
|
||||||
*/
|
|
||||||
declare module "next-auth" {
|
|
||||||
interface Session extends DefaultSession {
|
|
||||||
user: {
|
|
||||||
id: string;
|
|
||||||
roles: Array<{
|
|
||||||
role: "administrator" | "researcher" | "wizard" | "observer";
|
|
||||||
grantedAt: Date;
|
|
||||||
grantedBy: string | null;
|
|
||||||
}>;
|
|
||||||
} & DefaultSession["user"];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface User {
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
name: string | null;
|
|
||||||
image: string | null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Options for NextAuth.js used to configure adapters, providers, callbacks, etc.
|
|
||||||
*
|
|
||||||
* @see https://next-auth.js.org/configuration/options
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const authConfig: NextAuthConfig = {
|
|
||||||
session: {
|
|
||||||
strategy: "jwt" as const,
|
|
||||||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
|
||||||
},
|
|
||||||
pages: {
|
|
||||||
signIn: "/auth/signin",
|
|
||||||
error: "/auth/error",
|
|
||||||
},
|
|
||||||
providers: [
|
|
||||||
Credentials({
|
|
||||||
name: "credentials",
|
|
||||||
credentials: {
|
|
||||||
email: { label: "Email", type: "email" },
|
|
||||||
password: { label: "Password", type: "password" },
|
|
||||||
},
|
|
||||||
async authorize(credentials) {
|
|
||||||
const parsed = z
|
|
||||||
.object({
|
|
||||||
email: z.string().email(),
|
|
||||||
password: z.string().min(6),
|
|
||||||
})
|
|
||||||
.safeParse(credentials);
|
|
||||||
|
|
||||||
if (!parsed.success) return null;
|
|
||||||
|
|
||||||
const user = await db.query.users.findFirst({
|
|
||||||
where: eq(users.email, parsed.data.email),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user?.password) return null;
|
|
||||||
|
|
||||||
const isValidPassword = await bcrypt.compare(
|
|
||||||
parsed.data.password,
|
|
||||||
user.password,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isValidPassword) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: user.id,
|
|
||||||
email: user.email,
|
|
||||||
name: user.name,
|
|
||||||
image: user.image,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
callbacks: {
|
|
||||||
jwt: async ({ token, user }) => {
|
|
||||||
if (user) {
|
|
||||||
token.id = user.id;
|
|
||||||
}
|
|
||||||
return token;
|
|
||||||
},
|
|
||||||
session: async ({ session, token }) => {
|
|
||||||
if (token.id && typeof token.id === "string") {
|
|
||||||
// Fetch user roles from database
|
|
||||||
const userWithRoles = await db.query.users.findFirst({
|
|
||||||
where: eq(users.id, token.id),
|
|
||||||
with: {
|
|
||||||
systemRoles: {
|
|
||||||
with: {
|
|
||||||
grantedByUser: {
|
|
||||||
columns: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
email: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
...session,
|
|
||||||
user: {
|
|
||||||
...session.user,
|
|
||||||
id: token.id,
|
|
||||||
roles:
|
|
||||||
userWithRoles?.systemRoles?.map((sr) => ({
|
|
||||||
role: sr.role,
|
|
||||||
grantedAt: sr.grantedAt,
|
|
||||||
grantedBy: sr.grantedBy,
|
|
||||||
})) ?? [],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return session;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import NextAuth from "next-auth";
|
|
||||||
import { cache } from "react";
|
|
||||||
|
|
||||||
import { authConfig } from "./config";
|
|
||||||
|
|
||||||
const { auth: uncachedAuth, handlers, signIn, signOut } = NextAuth(authConfig);
|
|
||||||
|
|
||||||
const auth = cache(uncachedAuth);
|
|
||||||
|
|
||||||
export { auth, handlers, signIn, signOut };
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
import { and, eq } from "drizzle-orm";
|
|
||||||
import type { Session } from "next-auth";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { db } from "~/server/db";
|
|
||||||
import { users, userSystemRoles } from "~/server/db/schema";
|
|
||||||
import { auth } from "./index";
|
|
||||||
|
|
||||||
// Role types from schema
|
|
||||||
export type SystemRole = "administrator" | "researcher" | "wizard" | "observer";
|
|
||||||
export type StudyRole = "owner" | "researcher" | "wizard" | "observer";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current session or redirect to login
|
|
||||||
*/
|
|
||||||
export async function requireAuth() {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user) {
|
|
||||||
redirect("/auth/signin");
|
|
||||||
}
|
|
||||||
return session;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current session without redirecting
|
|
||||||
*/
|
|
||||||
export async function getSession() {
|
|
||||||
return await auth();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the current user has a specific system role
|
|
||||||
*/
|
|
||||||
export function hasRole(session: Session | null, role: SystemRole): boolean {
|
|
||||||
if (!session?.user?.roles) return false;
|
|
||||||
return session.user.roles.some((userRole) => userRole.role === role);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the current user is an administrator
|
|
||||||
*/
|
|
||||||
export function isAdmin(session: Session | null): boolean {
|
|
||||||
return hasRole(session, "administrator");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the current user is a researcher or admin
|
|
||||||
*/
|
|
||||||
export function isResearcher(session: Session | null): boolean {
|
|
||||||
return hasRole(session, "researcher") || isAdmin(session);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the current user is a wizard or admin
|
|
||||||
*/
|
|
||||||
export function isWizard(session: Session | null): boolean {
|
|
||||||
return hasRole(session, "wizard") || isAdmin(session);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the current user has any of the specified roles
|
|
||||||
*/
|
|
||||||
export function hasAnyRole(
|
|
||||||
session: Session | null,
|
|
||||||
roles: SystemRole[],
|
|
||||||
): boolean {
|
|
||||||
if (!session?.user?.roles) return false;
|
|
||||||
return session.user.roles.some((userRole) => roles.includes(userRole.role));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Require admin role or redirect
|
|
||||||
*/
|
|
||||||
export async function requireAdmin() {
|
|
||||||
const session = await requireAuth();
|
|
||||||
if (!isAdmin(session)) {
|
|
||||||
redirect("/unauthorized");
|
|
||||||
}
|
|
||||||
return session;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Require researcher role or redirect
|
|
||||||
*/
|
|
||||||
export async function requireResearcher() {
|
|
||||||
const session = await requireAuth();
|
|
||||||
if (!isResearcher(session)) {
|
|
||||||
redirect("/unauthorized");
|
|
||||||
}
|
|
||||||
return session;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get user roles from database
|
|
||||||
*/
|
|
||||||
export async function getUserRoles(userId: string) {
|
|
||||||
const userWithRoles = await db.query.users.findFirst({
|
|
||||||
where: eq(users.id, userId),
|
|
||||||
with: {
|
|
||||||
systemRoles: {
|
|
||||||
with: {
|
|
||||||
grantedByUser: {
|
|
||||||
columns: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
email: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return userWithRoles?.systemRoles ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Grant a system role to a user
|
|
||||||
*/
|
|
||||||
export async function grantRole(
|
|
||||||
userId: string,
|
|
||||||
role: SystemRole,
|
|
||||||
grantedBy: string,
|
|
||||||
) {
|
|
||||||
// Check if user already has this role
|
|
||||||
const existingRole = await db.query.userSystemRoles.findFirst({
|
|
||||||
where: and(
|
|
||||||
eq(userSystemRoles.userId, userId),
|
|
||||||
eq(userSystemRoles.role, role),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existingRole) {
|
|
||||||
throw new Error(`User already has role: ${role}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Grant the role
|
|
||||||
const newRole = await db
|
|
||||||
.insert(userSystemRoles)
|
|
||||||
.values({
|
|
||||||
userId,
|
|
||||||
role,
|
|
||||||
grantedBy,
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
return newRole[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Revoke a system role from a user
|
|
||||||
*/
|
|
||||||
export async function revokeRole(userId: string, role: SystemRole) {
|
|
||||||
const deletedRole = await db
|
|
||||||
.delete(userSystemRoles)
|
|
||||||
.where(
|
|
||||||
and(eq(userSystemRoles.userId, userId), eq(userSystemRoles.role, role)),
|
|
||||||
)
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
if (deletedRole.length === 0) {
|
|
||||||
throw new Error(`User does not have role: ${role}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return deletedRole[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a user owns or has admin access to a resource
|
|
||||||
*/
|
|
||||||
export function canAccessResource(
|
|
||||||
session: Session | null,
|
|
||||||
resourceOwnerId: string,
|
|
||||||
): boolean {
|
|
||||||
if (!session?.user) return false;
|
|
||||||
|
|
||||||
// Admin can access anything
|
|
||||||
if (isAdmin(session)) return true;
|
|
||||||
|
|
||||||
// Owner can access their own resources
|
|
||||||
if (session.user.id === resourceOwnerId) return true;
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format role for display
|
|
||||||
*/
|
|
||||||
export function formatRole(role: SystemRole): string {
|
|
||||||
const roleMap: Record<SystemRole, string> = {
|
|
||||||
administrator: "Administrator",
|
|
||||||
researcher: "Researcher",
|
|
||||||
wizard: "Wizard",
|
|
||||||
observer: "Observer",
|
|
||||||
};
|
|
||||||
|
|
||||||
return roleMap[role] || role;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get role description
|
|
||||||
*/
|
|
||||||
export function getRoleDescription(role: SystemRole): string {
|
|
||||||
const descriptions: Record<SystemRole, string> = {
|
|
||||||
administrator: "Full system access and user management",
|
|
||||||
researcher: "Can create and manage studies and experiments",
|
|
||||||
wizard: "Can control robots during trials and experiments",
|
|
||||||
observer: "Read-only access to studies and trial data",
|
|
||||||
};
|
|
||||||
|
|
||||||
return descriptions[role] || "Unknown role";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get available roles for assignment
|
|
||||||
*/
|
|
||||||
export function getAvailableRoles(): Array<{
|
|
||||||
value: SystemRole;
|
|
||||||
label: string;
|
|
||||||
description: string;
|
|
||||||
}> {
|
|
||||||
const roles: SystemRole[] = [
|
|
||||||
"administrator",
|
|
||||||
"researcher",
|
|
||||||
"wizard",
|
|
||||||
"observer",
|
|
||||||
];
|
|
||||||
|
|
||||||
return roles.map((role) => ({
|
|
||||||
value: role,
|
|
||||||
label: formatRole(role),
|
|
||||||
description: getRoleDescription(role),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
uuid,
|
uuid,
|
||||||
varchar,
|
varchar,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
import { type AdapterAccount } from "next-auth/adapters";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
|
* This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
|
||||||
@@ -114,15 +113,12 @@ export const exportStatusEnum = pgEnum("export_status", [
|
|||||||
"failed",
|
"failed",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Users and Authentication
|
// Users and Authentication (Better Auth compatible)
|
||||||
export const users = createTable("user", {
|
export const users = createTable("user", {
|
||||||
id: uuid("id").notNull().primaryKey().defaultRandom(),
|
id: text("id").notNull().primaryKey(),
|
||||||
email: varchar("email", { length: 255 }).notNull().unique(),
|
|
||||||
emailVerified: timestamp("email_verified", {
|
|
||||||
mode: "date",
|
|
||||||
withTimezone: true,
|
|
||||||
}),
|
|
||||||
name: varchar("name", { length: 255 }),
|
name: varchar("name", { length: 255 }),
|
||||||
|
email: varchar("email", { length: 255 }).notNull().unique(),
|
||||||
|
emailVerified: boolean("email_verified").default(false).notNull(),
|
||||||
image: text("image"),
|
image: text("image"),
|
||||||
password: varchar("password", { length: 255 }),
|
password: varchar("password", { length: 255 }),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
@@ -137,23 +133,20 @@ export const users = createTable("user", {
|
|||||||
export const accounts = createTable(
|
export const accounts = createTable(
|
||||||
"account",
|
"account",
|
||||||
{
|
{
|
||||||
userId: uuid("user_id")
|
id: text("id").notNull().primaryKey(),
|
||||||
|
userId: text("user_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id, { onDelete: "cascade" }),
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
type: varchar("type", { length: 255 })
|
providerId: varchar("provider_id", { length: 255 }).notNull(),
|
||||||
.$type<AdapterAccount["type"]>()
|
accountId: varchar("account_id", { length: 255 }).notNull(),
|
||||||
.notNull(),
|
|
||||||
provider: varchar("provider", { length: 255 }).notNull(),
|
|
||||||
providerAccountId: varchar("provider_account_id", {
|
|
||||||
length: 255,
|
|
||||||
}).notNull(),
|
|
||||||
refreshToken: text("refresh_token"),
|
refreshToken: text("refresh_token"),
|
||||||
accessToken: text("access_token"),
|
accessToken: text("access_token"),
|
||||||
expiresAt: integer("expires_at"),
|
expiresAt: timestamp("expires_at", {
|
||||||
tokenType: varchar("token_type", { length: 255 }),
|
mode: "date",
|
||||||
|
withTimezone: true,
|
||||||
|
}),
|
||||||
scope: varchar("scope", { length: 255 }),
|
scope: varchar("scope", { length: 255 }),
|
||||||
idToken: text("id_token"),
|
password: text("password"),
|
||||||
sessionState: varchar("session_state", { length: 255 }),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
.default(sql`CURRENT_TIMESTAMP`)
|
.default(sql`CURRENT_TIMESTAMP`)
|
||||||
.notNull(),
|
.notNull(),
|
||||||
@@ -162,25 +155,25 @@ export const accounts = createTable(
|
|||||||
.notNull(),
|
.notNull(),
|
||||||
},
|
},
|
||||||
(table) => ({
|
(table) => ({
|
||||||
compoundKey: primaryKey({
|
|
||||||
columns: [table.provider, table.providerAccountId],
|
|
||||||
}),
|
|
||||||
userIdIdx: index("account_user_id_idx").on(table.userId),
|
userIdIdx: index("account_user_id_idx").on(table.userId),
|
||||||
|
providerAccountIdx: unique().on(table.providerId, table.accountId),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const sessions = createTable(
|
export const sessions = createTable(
|
||||||
"session",
|
"session",
|
||||||
{
|
{
|
||||||
id: uuid("id").notNull().primaryKey().defaultRandom(),
|
id: text("id").notNull().primaryKey(),
|
||||||
sessionToken: varchar("session_token", { length: 255 }).notNull().unique(),
|
token: varchar("token", { length: 255 }).notNull().unique(),
|
||||||
userId: uuid("user_id")
|
userId: text("user_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id, { onDelete: "cascade" }),
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
expires: timestamp("expires", {
|
expiresAt: timestamp("expires_at", {
|
||||||
mode: "date",
|
mode: "date",
|
||||||
withTimezone: true,
|
withTimezone: true,
|
||||||
}).notNull(),
|
}).notNull(),
|
||||||
|
ipAddress: text("ip_address"),
|
||||||
|
userAgent: text("user_agent"),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
.default(sql`CURRENT_TIMESTAMP`)
|
.default(sql`CURRENT_TIMESTAMP`)
|
||||||
.notNull(),
|
.notNull(),
|
||||||
@@ -196,18 +189,25 @@ export const sessions = createTable(
|
|||||||
export const verificationTokens = createTable(
|
export const verificationTokens = createTable(
|
||||||
"verification_token",
|
"verification_token",
|
||||||
{
|
{
|
||||||
|
id: text("id").notNull().primaryKey(),
|
||||||
identifier: varchar("identifier", { length: 255 }).notNull(),
|
identifier: varchar("identifier", { length: 255 }).notNull(),
|
||||||
token: varchar("token", { length: 255 }).notNull().unique(),
|
value: varchar("value", { length: 255 }).notNull().unique(),
|
||||||
expires: timestamp("expires", {
|
expiresAt: timestamp("expires_at", {
|
||||||
mode: "date",
|
mode: "date",
|
||||||
withTimezone: true,
|
withTimezone: true,
|
||||||
}).notNull(),
|
}).notNull(),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
.default(sql`CURRENT_TIMESTAMP`)
|
.default(sql`CURRENT_TIMESTAMP`)
|
||||||
.notNull(),
|
.notNull(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`)
|
||||||
|
.notNull(),
|
||||||
},
|
},
|
||||||
(table) => ({
|
(table) => ({
|
||||||
compoundKey: primaryKey({ columns: [table.identifier, table.token] }),
|
identifierIdx: index("verification_token_identifier_idx").on(
|
||||||
|
table.identifier,
|
||||||
|
),
|
||||||
|
valueIdx: index("verification_token_value_idx").on(table.value),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -216,14 +216,14 @@ export const userSystemRoles = createTable(
|
|||||||
"user_system_role",
|
"user_system_role",
|
||||||
{
|
{
|
||||||
id: uuid("id").notNull().primaryKey().defaultRandom(),
|
id: uuid("id").notNull().primaryKey().defaultRandom(),
|
||||||
userId: uuid("user_id")
|
userId: text("user_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id, { onDelete: "cascade" }),
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
role: systemRoleEnum("role").notNull(),
|
role: systemRoleEnum("role").notNull(),
|
||||||
grantedAt: timestamp("granted_at", { withTimezone: true })
|
grantedAt: timestamp("granted_at", { withTimezone: true })
|
||||||
.default(sql`CURRENT_TIMESTAMP`)
|
.default(sql`CURRENT_TIMESTAMP`)
|
||||||
.notNull(),
|
.notNull(),
|
||||||
grantedBy: uuid("granted_by").references(() => users.id),
|
grantedBy: text("granted_by").references(() => users.id),
|
||||||
},
|
},
|
||||||
(table) => ({
|
(table) => ({
|
||||||
userRoleUnique: unique().on(table.userId, table.role),
|
userRoleUnique: unique().on(table.userId, table.role),
|
||||||
@@ -263,7 +263,7 @@ export const studies = createTable("study", {
|
|||||||
institution: varchar("institution", { length: 255 }),
|
institution: varchar("institution", { length: 255 }),
|
||||||
irbProtocol: varchar("irb_protocol", { length: 100 }),
|
irbProtocol: varchar("irb_protocol", { length: 100 }),
|
||||||
status: studyStatusEnum("status").default("draft").notNull(),
|
status: studyStatusEnum("status").default("draft").notNull(),
|
||||||
createdBy: uuid("created_by")
|
createdBy: text("created_by")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id),
|
.references(() => users.id),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
@@ -284,7 +284,7 @@ export const studyMembers = createTable(
|
|||||||
studyId: uuid("study_id")
|
studyId: uuid("study_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => studies.id, { onDelete: "cascade" }),
|
.references(() => studies.id, { onDelete: "cascade" }),
|
||||||
userId: uuid("user_id")
|
userId: text("user_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id, { onDelete: "cascade" }),
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
role: studyMemberRoleEnum("role").notNull(),
|
role: studyMemberRoleEnum("role").notNull(),
|
||||||
@@ -292,7 +292,7 @@ export const studyMembers = createTable(
|
|||||||
joinedAt: timestamp("joined_at", { withTimezone: true })
|
joinedAt: timestamp("joined_at", { withTimezone: true })
|
||||||
.default(sql`CURRENT_TIMESTAMP`)
|
.default(sql`CURRENT_TIMESTAMP`)
|
||||||
.notNull(),
|
.notNull(),
|
||||||
invitedBy: uuid("invited_by").references(() => users.id),
|
invitedBy: text("invited_by").references(() => users.id),
|
||||||
},
|
},
|
||||||
(table) => ({
|
(table) => ({
|
||||||
studyUserUnique: unique().on(table.studyId, table.userId),
|
studyUserUnique: unique().on(table.studyId, table.userId),
|
||||||
@@ -380,7 +380,7 @@ export const experiments = createTable(
|
|||||||
robotId: uuid("robot_id").references(() => robots.id),
|
robotId: uuid("robot_id").references(() => robots.id),
|
||||||
status: experimentStatusEnum("status").default("draft").notNull(),
|
status: experimentStatusEnum("status").default("draft").notNull(),
|
||||||
estimatedDuration: integer("estimated_duration"), // in minutes
|
estimatedDuration: integer("estimated_duration"), // in minutes
|
||||||
createdBy: uuid("created_by")
|
createdBy: text("created_by")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id),
|
.references(() => users.id),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
@@ -449,7 +449,7 @@ export const participantDocuments = createTable(
|
|||||||
type: varchar("type", { length: 100 }), // MIME type or custom category
|
type: varchar("type", { length: 100 }), // MIME type or custom category
|
||||||
storagePath: text("storage_path").notNull(),
|
storagePath: text("storage_path").notNull(),
|
||||||
fileSize: integer("file_size"),
|
fileSize: integer("file_size"),
|
||||||
uploadedBy: uuid("uploaded_by").references(() => users.id),
|
uploadedBy: text("uploaded_by").references(() => users.id),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
.default(sql`CURRENT_TIMESTAMP`)
|
.default(sql`CURRENT_TIMESTAMP`)
|
||||||
.notNull(),
|
.notNull(),
|
||||||
@@ -467,7 +467,7 @@ export const trials = createTable("trial", {
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => experiments.id),
|
.references(() => experiments.id),
|
||||||
participantId: uuid("participant_id").references(() => participants.id),
|
participantId: uuid("participant_id").references(() => participants.id),
|
||||||
wizardId: uuid("wizard_id").references(() => users.id),
|
wizardId: text("wizard_id").references(() => users.id),
|
||||||
sessionNumber: integer("session_number").default(1).notNull(),
|
sessionNumber: integer("session_number").default(1).notNull(),
|
||||||
status: trialStatusEnum("status").default("scheduled").notNull(),
|
status: trialStatusEnum("status").default("scheduled").notNull(),
|
||||||
scheduledAt: timestamp("scheduled_at", { withTimezone: true }),
|
scheduledAt: timestamp("scheduled_at", { withTimezone: true }),
|
||||||
@@ -562,7 +562,7 @@ export const consentForms = createTable(
|
|||||||
title: varchar("title", { length: 255 }).notNull(),
|
title: varchar("title", { length: 255 }).notNull(),
|
||||||
content: text("content").notNull(),
|
content: text("content").notNull(),
|
||||||
active: boolean("active").default(true).notNull(),
|
active: boolean("active").default(true).notNull(),
|
||||||
createdBy: uuid("created_by")
|
createdBy: text("created_by")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id),
|
.references(() => users.id),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
@@ -608,6 +608,7 @@ export const plugins = createTable(
|
|||||||
robotId: uuid("robot_id").references(() => robots.id, {
|
robotId: uuid("robot_id").references(() => robots.id, {
|
||||||
onDelete: "cascade",
|
onDelete: "cascade",
|
||||||
}),
|
}),
|
||||||
|
identifier: varchar("identifier", { length: 100 }).notNull().unique(),
|
||||||
name: varchar("name", { length: 255 }).notNull(),
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
version: varchar("version", { length: 50 }).notNull(),
|
version: varchar("version", { length: 50 }).notNull(),
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
@@ -644,7 +645,7 @@ export const studyPlugins = createTable(
|
|||||||
installedAt: timestamp("installed_at", { withTimezone: true })
|
installedAt: timestamp("installed_at", { withTimezone: true })
|
||||||
.default(sql`CURRENT_TIMESTAMP`)
|
.default(sql`CURRENT_TIMESTAMP`)
|
||||||
.notNull(),
|
.notNull(),
|
||||||
installedBy: uuid("installed_by")
|
installedBy: text("installed_by")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id),
|
.references(() => users.id),
|
||||||
},
|
},
|
||||||
@@ -673,7 +674,7 @@ export const pluginRepositories = createTable(
|
|||||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||||
.default(sql`CURRENT_TIMESTAMP`)
|
.default(sql`CURRENT_TIMESTAMP`)
|
||||||
.notNull(),
|
.notNull(),
|
||||||
createdBy: uuid("created_by")
|
createdBy: text("created_by")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id),
|
.references(() => users.id),
|
||||||
},
|
},
|
||||||
@@ -696,7 +697,7 @@ export const trialEvents = createTable(
|
|||||||
.default(sql`CURRENT_TIMESTAMP`)
|
.default(sql`CURRENT_TIMESTAMP`)
|
||||||
.notNull(),
|
.notNull(),
|
||||||
data: jsonb("data").default({}),
|
data: jsonb("data").default({}),
|
||||||
createdBy: uuid("created_by").references(() => users.id), // NULL for system events
|
createdBy: text("created_by").references(() => users.id), // NULL for system events
|
||||||
},
|
},
|
||||||
(table) => ({
|
(table) => ({
|
||||||
trialTimestampIdx: index("trial_events_trial_timestamp_idx").on(
|
trialTimestampIdx: index("trial_events_trial_timestamp_idx").on(
|
||||||
@@ -711,7 +712,7 @@ export const wizardInterventions = createTable("wizard_intervention", {
|
|||||||
trialId: uuid("trial_id")
|
trialId: uuid("trial_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => trials.id, { onDelete: "cascade" }),
|
.references(() => trials.id, { onDelete: "cascade" }),
|
||||||
wizardId: uuid("wizard_id")
|
wizardId: text("wizard_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id),
|
.references(() => users.id),
|
||||||
interventionType: varchar("intervention_type", { length: 100 }).notNull(),
|
interventionType: varchar("intervention_type", { length: 100 }).notNull(),
|
||||||
@@ -770,7 +771,7 @@ export const annotations = createTable("annotation", {
|
|||||||
trialId: uuid("trial_id")
|
trialId: uuid("trial_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => trials.id, { onDelete: "cascade" }),
|
.references(() => trials.id, { onDelete: "cascade" }),
|
||||||
annotatorId: uuid("annotator_id")
|
annotatorId: text("annotator_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id),
|
.references(() => users.id),
|
||||||
timestampStart: timestamp("timestamp_start", {
|
timestampStart: timestamp("timestamp_start", {
|
||||||
@@ -798,7 +799,7 @@ export const activityLogs = createTable(
|
|||||||
studyId: uuid("study_id").references(() => studies.id, {
|
studyId: uuid("study_id").references(() => studies.id, {
|
||||||
onDelete: "cascade",
|
onDelete: "cascade",
|
||||||
}),
|
}),
|
||||||
userId: uuid("user_id").references(() => users.id),
|
userId: text("user_id").references(() => users.id),
|
||||||
action: varchar("action", { length: 100 }).notNull(),
|
action: varchar("action", { length: 100 }).notNull(),
|
||||||
resourceType: varchar("resource_type", { length: 50 }),
|
resourceType: varchar("resource_type", { length: 50 }),
|
||||||
resourceId: uuid("resource_id"),
|
resourceId: uuid("resource_id"),
|
||||||
@@ -823,7 +824,7 @@ export const comments = createTable("comment", {
|
|||||||
parentId: uuid("parent_id"),
|
parentId: uuid("parent_id"),
|
||||||
resourceType: varchar("resource_type", { length: 50 }).notNull(), // 'experiment', 'trial', 'annotation'
|
resourceType: varchar("resource_type", { length: 50 }).notNull(), // 'experiment', 'trial', 'annotation'
|
||||||
resourceId: uuid("resource_id").notNull(),
|
resourceId: uuid("resource_id").notNull(),
|
||||||
authorId: uuid("author_id")
|
authorId: text("author_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id),
|
.references(() => users.id),
|
||||||
content: text("content").notNull(),
|
content: text("content").notNull(),
|
||||||
@@ -845,7 +846,7 @@ export const attachments = createTable("attachment", {
|
|||||||
filePath: text("file_path").notNull(),
|
filePath: text("file_path").notNull(),
|
||||||
contentType: varchar("content_type", { length: 100 }),
|
contentType: varchar("content_type", { length: 100 }),
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
uploadedBy: uuid("uploaded_by")
|
uploadedBy: text("uploaded_by")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id),
|
.references(() => users.id),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
@@ -859,7 +860,7 @@ export const exportJobs = createTable("export_job", {
|
|||||||
studyId: uuid("study_id")
|
studyId: uuid("study_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => studies.id, { onDelete: "cascade" }),
|
.references(() => studies.id, { onDelete: "cascade" }),
|
||||||
requestedBy: uuid("requested_by")
|
requestedBy: text("requested_by")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id),
|
.references(() => users.id),
|
||||||
exportType: varchar("export_type", { length: 50 }).notNull(), // 'full', 'trials', 'analysis', 'media'
|
exportType: varchar("export_type", { length: 50 }).notNull(), // 'full', 'trials', 'analysis', 'media'
|
||||||
@@ -882,7 +883,7 @@ export const sharedResources = createTable("shared_resource", {
|
|||||||
.references(() => studies.id, { onDelete: "cascade" }),
|
.references(() => studies.id, { onDelete: "cascade" }),
|
||||||
resourceType: varchar("resource_type", { length: 50 }).notNull(),
|
resourceType: varchar("resource_type", { length: 50 }).notNull(),
|
||||||
resourceId: uuid("resource_id").notNull(),
|
resourceId: uuid("resource_id").notNull(),
|
||||||
sharedBy: uuid("shared_by")
|
sharedBy: text("shared_by")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id),
|
.references(() => users.id),
|
||||||
shareToken: varchar("share_token", { length: 255 }).unique(),
|
shareToken: varchar("share_token", { length: 255 }).unique(),
|
||||||
@@ -900,7 +901,7 @@ export const systemSettings = createTable("system_setting", {
|
|||||||
key: varchar("key", { length: 100 }).notNull().unique(),
|
key: varchar("key", { length: 100 }).notNull().unique(),
|
||||||
value: jsonb("value").notNull(),
|
value: jsonb("value").notNull(),
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
updatedBy: uuid("updated_by").references(() => users.id),
|
updatedBy: text("updated_by").references(() => users.id),
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||||
.default(sql`CURRENT_TIMESTAMP`)
|
.default(sql`CURRENT_TIMESTAMP`)
|
||||||
.notNull(),
|
.notNull(),
|
||||||
@@ -910,7 +911,7 @@ export const auditLogs = createTable(
|
|||||||
"audit_log",
|
"audit_log",
|
||||||
{
|
{
|
||||||
id: uuid("id").notNull().primaryKey().defaultRandom(),
|
id: uuid("id").notNull().primaryKey().defaultRandom(),
|
||||||
userId: uuid("user_id").references(() => users.id),
|
userId: text("user_id").references(() => users.id),
|
||||||
action: varchar("action", { length: 100 }).notNull(),
|
action: varchar("action", { length: 100 }).notNull(),
|
||||||
resourceType: varchar("resource_type", { length: 50 }),
|
resourceType: varchar("resource_type", { length: 50 }),
|
||||||
resourceId: uuid("resource_id"),
|
resourceId: uuid("resource_id"),
|
||||||
|
|||||||
@@ -653,20 +653,37 @@ export class TrialExecutionEngine {
|
|||||||
pluginName,
|
pluginName,
|
||||||
);
|
);
|
||||||
|
|
||||||
const query = isUuid
|
let plugin;
|
||||||
? eq(plugins.id, pluginName)
|
if (isUuid) {
|
||||||
: eq(plugins.name, pluginName);
|
const [result] = await this.db
|
||||||
|
.select()
|
||||||
|
.from(plugins)
|
||||||
|
.where(eq(plugins.id, pluginName))
|
||||||
|
.limit(1);
|
||||||
|
plugin = result;
|
||||||
|
} else {
|
||||||
|
// Look up by identifier first (e.g., "nao6-ros2"), then fall back to name
|
||||||
|
const [byIdentifier] = await this.db
|
||||||
|
.select()
|
||||||
|
.from(plugins)
|
||||||
|
.where(eq(plugins.identifier, pluginName))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
const [plugin] = await this.db
|
if (byIdentifier) {
|
||||||
.select()
|
plugin = byIdentifier;
|
||||||
.from(plugins)
|
} else {
|
||||||
.where(query)
|
const [byName] = await this.db
|
||||||
.limit(1);
|
.select()
|
||||||
|
.from(plugins)
|
||||||
|
.where(eq(plugins.name, pluginName))
|
||||||
|
.limit(1);
|
||||||
|
plugin = byName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (plugin) {
|
if (plugin) {
|
||||||
// Cache the plugin definition
|
// Cache the plugin definition
|
||||||
// Use the actual name for cache key if we looked up by ID
|
const cacheKey = isUuid ? plugin.id : plugin.identifier;
|
||||||
const cacheKey = isUuid ? plugin.name : pluginName;
|
|
||||||
|
|
||||||
const pluginData = {
|
const pluginData = {
|
||||||
...plugin,
|
...plugin,
|
||||||
@@ -676,10 +693,13 @@ export class TrialExecutionEngine {
|
|||||||
};
|
};
|
||||||
|
|
||||||
this.pluginCache.set(cacheKey, pluginData);
|
this.pluginCache.set(cacheKey, pluginData);
|
||||||
// Also cache by ID if accessible
|
// Also cache by ID and identifier
|
||||||
if (plugin.id) {
|
if (plugin.id) {
|
||||||
this.pluginCache.set(plugin.id, pluginData);
|
this.pluginCache.set(plugin.id, pluginData);
|
||||||
}
|
}
|
||||||
|
if (plugin.identifier) {
|
||||||
|
this.pluginCache.set(plugin.identifier, pluginData);
|
||||||
|
}
|
||||||
|
|
||||||
return pluginData;
|
return pluginData;
|
||||||
}
|
}
|
||||||
|
|||||||
27
src/trpc/query-client.js
Normal file
27
src/trpc/query-client.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.createQueryClient = void 0;
|
||||||
|
var react_query_1 = require("@tanstack/react-query");
|
||||||
|
var superjson_1 = require("superjson");
|
||||||
|
var createQueryClient = function () {
|
||||||
|
return new react_query_1.QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
// With SSR, we usually want to set some default staleTime
|
||||||
|
// above 0 to avoid refetching immediately on the client
|
||||||
|
staleTime: 30 * 1000,
|
||||||
|
},
|
||||||
|
dehydrate: {
|
||||||
|
serializeData: superjson_1.default.serialize,
|
||||||
|
shouldDehydrateQuery: function (query) {
|
||||||
|
return (0, react_query_1.defaultShouldDehydrateQuery)(query) ||
|
||||||
|
query.state.status === "pending";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
hydrate: {
|
||||||
|
deserializeData: superjson_1.default.deserialize,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
exports.createQueryClient = createQueryClient;
|
||||||
59
src/trpc/react.js
vendored
Normal file
59
src/trpc/react.js
vendored
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
"use client";
|
||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.api = void 0;
|
||||||
|
exports.TRPCReactProvider = TRPCReactProvider;
|
||||||
|
var react_query_1 = require("@tanstack/react-query");
|
||||||
|
var client_1 = require("@trpc/client");
|
||||||
|
var react_query_2 = require("@trpc/react-query");
|
||||||
|
var react_1 = require("react");
|
||||||
|
var superjson_1 = require("superjson");
|
||||||
|
var query_client_1 = require("./query-client");
|
||||||
|
var clientQueryClientSingleton = undefined;
|
||||||
|
var getQueryClient = function () {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
// Server: always make a new query client
|
||||||
|
return (0, query_client_1.createQueryClient)();
|
||||||
|
}
|
||||||
|
// Browser: use singleton pattern to keep the same query client
|
||||||
|
clientQueryClientSingleton !== null && clientQueryClientSingleton !== void 0 ? clientQueryClientSingleton : (clientQueryClientSingleton = (0, query_client_1.createQueryClient)());
|
||||||
|
return clientQueryClientSingleton;
|
||||||
|
};
|
||||||
|
exports.api = (0, react_query_2.createTRPCReact)();
|
||||||
|
function TRPCReactProvider(props) {
|
||||||
|
var queryClient = getQueryClient();
|
||||||
|
var trpcClient = (0, react_1.useState)(function () {
|
||||||
|
return exports.api.createClient({
|
||||||
|
links: [
|
||||||
|
(0, client_1.loggerLink)({
|
||||||
|
enabled: function (op) {
|
||||||
|
return process.env.NODE_ENV === "development" ||
|
||||||
|
(op.direction === "down" && op.result instanceof Error);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
(0, client_1.httpBatchStreamLink)({
|
||||||
|
transformer: superjson_1.default,
|
||||||
|
url: getBaseUrl() + "/api/trpc",
|
||||||
|
headers: function () {
|
||||||
|
var headers = new Headers();
|
||||||
|
headers.set("x-trpc-source", "nextjs-react");
|
||||||
|
return headers;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
})[0];
|
||||||
|
return (<react_query_1.QueryClientProvider client={queryClient}>
|
||||||
|
<exports.api.Provider client={trpcClient} queryClient={queryClient}>
|
||||||
|
{props.children}
|
||||||
|
</exports.api.Provider>
|
||||||
|
</react_query_1.QueryClientProvider>);
|
||||||
|
}
|
||||||
|
function getBaseUrl() {
|
||||||
|
var _a;
|
||||||
|
if (typeof window !== "undefined")
|
||||||
|
return window.location.origin;
|
||||||
|
if (process.env.VERCEL_URL)
|
||||||
|
return "https://".concat(process.env.VERCEL_URL);
|
||||||
|
return "http://localhost:".concat((_a = process.env.PORT) !== null && _a !== void 0 ? _a : 3000);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user