Compare commits
2
Commits
6facce3d22
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c19455bdd | ||
|
|
e26cfe624d |
No files matched your search
@@ -7,11 +7,16 @@
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.9.0",
|
||||
"@hookform/resolvers": "^6.0.2",
|
||||
"@livekit/components-react": "^2.9.24",
|
||||
"@livekit/components-styles": "^1.2.0",
|
||||
"@prisma/adapter-mariadb": "^7.10.1",
|
||||
"@prisma/client": "^7.10.1",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"cn": "^0.5.0",
|
||||
"jose": "^6.2.14",
|
||||
"livekit-client": "^2.23.0",
|
||||
"livekit-server-sdk": "^2.19.2",
|
||||
"lucide-react": "^1.64.0",
|
||||
"next": "16.4.3",
|
||||
"next-auth": "^5.0.0-beta.32",
|
||||
@@ -109,6 +114,8 @@
|
||||
|
||||
"@base-ui/utils": ["@base-ui/utils@0.5.0", "", { "dependencies": { "@babel/runtime": "^7.29.7 || ^8.0.0", "@floating-ui/utils": "^0.2.12", "reselect": "^5.3.0", "use-sync-external-store": "^1.7.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-dYGGcEttjkYiMoa5nHm8vLaJ2UcuTWxo3BFAwOYXeRg/q7UVVvA6cacEIXEs3FgBfK1CGtW1ptElqs7wsx+t9A=="],
|
||||
|
||||
"@bufbuild/protobuf": ["@bufbuild/protobuf@1.10.1", "", {}, "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ=="],
|
||||
|
||||
"@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.75.1", "", { "dependencies": { "@dotenvx/primitives": "^0.8.0", "commander": "^11.1.0", "conf": "^10.2.0", "dotenv": "^17.2.1", "enquirer": "^2.4.1", "env-paths": "^2.2.1", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "open": "^8.4.2", "picomatch": "^4.0.4", "systeminformation": "^5.22.11", "undici": "^7.11.0", "which": "^4.0.0", "yocto-spinner": "^1.1.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-/BITOC9dmS/edY2zQwZNicQ059O6RKabtQfyEafV0nGtfYRNHYy1DIPiYVcov40+tob9hfmBnbR963dS+EQ1DQ=="],
|
||||
|
||||
"@dotenvx/primitives": ["@dotenvx/primitives@0.8.0", "", {}, "sha512-VYJy0uhFm9zTJ1TxBaW/pA8bjbOM/OttaNMwZ1RHG4JKyRG7DhSdiqD1ipQoAyoD22olUtxbP78W9xY3Wd11bg=="],
|
||||
@@ -227,6 +234,16 @@
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@livekit/components-core": ["@livekit/components-core@0.12.15", "", { "dependencies": { "@floating-ui/dom": "1.7.6", "loglevel": "1.9.1", "rxjs": "7.8.2" }, "peerDependencies": { "livekit-client": "^2.20.1", "tslib": "^2.6.2" } }, "sha512-bDkK+jkPMgyT4a5lZmdcuc/hZ8fZUrXW+qQPNHVeSpXCBMzk5q9QWzx+HPtTsPJ8hnncWGfoT81RnXHBsy+Z8g=="],
|
||||
|
||||
"@livekit/components-react": ["@livekit/components-react@2.9.24", "", { "dependencies": { "@livekit/components-core": "0.12.15", "clsx": "2.1.1", "events": "^3.3.0", "jose": "^6.0.12", "usehooks-ts": "3.1.1" }, "peerDependencies": { "@livekit/krisp-noise-filter": "^0.2.12 || ^0.3.0 || ^0.4.0", "livekit-client": "^2.20.1", "react": ">=18", "react-dom": ">=18", "tslib": "^2.6.2" }, "optionalPeers": ["@livekit/krisp-noise-filter"] }, "sha512-qw5Oy1EfPg1f/xrKYrFielSrmtxY8BZ9mDt6299G5aikotQIztmldBfTNosrVS7VSNit7qeYfOFSyKaML20W6Q=="],
|
||||
|
||||
"@livekit/components-styles": ["@livekit/components-styles@1.2.0", "", {}, "sha512-74/rt0lDh6aHmOPmWAeDE9C4OrNW9RIdmhX/YRbovQBVNGNVWojRjl3FgQZ5LPFXO6l1maKB4JhXcBFENVxVvw=="],
|
||||
|
||||
"@livekit/mutex": ["@livekit/mutex@1.1.1", "", {}, "sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw=="],
|
||||
|
||||
"@livekit/protocol": ["@livekit/protocol@1.52.1", "", { "dependencies": { "@bufbuild/protobuf": "^1.10.0" } }, "sha512-X8DxKlNbzk6Lh+v630+mjdFHSotdAFwIyzxSRSUql2DbTxCnlpa7JsvFGc6Na3mXiex2OFh0iiQtWwOBP0rIYA=="],
|
||||
|
||||
"@modelcontextprotocol/core": ["@modelcontextprotocol/core@2.4.0", "", { "dependencies": { "zod": "^4.2.0" } }, "sha512-z8QBKqdfXLPzkLQuG4xp8hrVEMm1PZ1IgxMN2sMbJiFiAKOWXnAzogXn2W4aqIBlP1ea6U5yiqVquvUNM1Cj6Q=="],
|
||||
|
||||
"@modelcontextprotocol/server": ["@modelcontextprotocol/server@2.4.0", "", { "dependencies": { "@modelcontextprotocol/core": "2.4.0", "zod": "^4.2.0" } }, "sha512-rgR9yxSaAJ+bcsi/XroT/S2AfPiIWloXAnrdfYSCms9ivVju0ishu/wW0J21Ho6+ZOR1r7RyeRbjiRuF87ix5Q=="],
|
||||
@@ -377,6 +394,8 @@
|
||||
|
||||
"@types/d3-time-format": ["@types/d3-time-format@2.1.0", "", {}, "sha512-/myT3I7EwlukNOX2xVdMzb8FRgNzRMpsZddwst9Ld/VFe6LyJyRp0s32l/V9XoUzk+Gqu56F/oGk6507+8BxrA=="],
|
||||
|
||||
"@types/dom-mediacapture-record": ["@types/dom-mediacapture-record@1.0.22", "", {}, "sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.10", "", {}, "sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g=="],
|
||||
|
||||
"@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="],
|
||||
@@ -743,6 +762,8 @@
|
||||
|
||||
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
||||
|
||||
"events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="],
|
||||
|
||||
"execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="],
|
||||
|
||||
"exsolve": ["exsolve@1.2.0", "", {}, "sha512-h1pQPR9XoeSMdntRNCjyTznaume4UpfXrULBnlkZ2CUM/G5eSa+pBByGldO5uR8dxbPC+OuJXsPPNf+JZ81ADg=="],
|
||||
@@ -1007,14 +1028,22 @@
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
|
||||
|
||||
"livekit-client": ["livekit-client@2.23.0", "", { "dependencies": { "@livekit/mutex": "1.1.1", "@livekit/protocol": "1.52.1", "events": "^3.3.0", "jose": "^6.1.0", "loglevel": "^1.9.2", "machina": "^7.0.0", "sdp-transform": "^2.15.0", "tslib": "2.8.1", "typed-emitter": "^2.1.0", "webrtc-adapter": "9.0.6" }, "peerDependencies": { "@types/dom-mediacapture-record": "^1" } }, "sha512-j9Kg86nJjBjNFUD+ukp2jwMCGa0k+YD5gU//RzAM+ZY3pbG631QCte7fQjwqWMmtBzhDJL9nKYuOLMv/9IbAxg=="],
|
||||
|
||||
"livekit-server-sdk": ["livekit-server-sdk@2.19.2", "", { "dependencies": { "@bufbuild/protobuf": "^1.10.1", "@livekit/protocol": "1.51.0", "jose": "^5.1.2" } }, "sha512-aNb7iZOdMYWjg3X227GO9i/jWsqR6uiUa2le3T0JOaK+FwE3bMx+bEhHVoSioMFL4LQ4xk4ztz1H8TKxIPjq1w=="],
|
||||
|
||||
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||
|
||||
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
|
||||
|
||||
"lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="],
|
||||
|
||||
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
|
||||
|
||||
"log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="],
|
||||
|
||||
"loglevel": ["loglevel@1.9.2", "", {}, "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg=="],
|
||||
|
||||
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
|
||||
|
||||
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
|
||||
@@ -1025,6 +1054,8 @@
|
||||
|
||||
"lucide-react": ["lucide-react@1.64.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-v626F/bcLNvFom6vzztHILtG9tUgXXbgA5AGkd82YkV401+Ko/NHyrWafbPwGofa+6p/EgDJcvEkIwsMH9098w=="],
|
||||
|
||||
"machina": ["machina@7.0.1", "", {}, "sha512-0Xkgs8NeBvO2hW6bjJT8ewLWrMfh1haMPstaghpASWjXkLjRhtDiaJG1Kb7mslaOHxZYxpH7PIjpmirRVYbMMA=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"magicast": ["magicast@0.5.5", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "source-map-js": "^1.2.1" } }, "sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA=="],
|
||||
@@ -1211,6 +1242,8 @@
|
||||
|
||||
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
|
||||
|
||||
"rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="],
|
||||
|
||||
"safe-array-concat": ["safe-array-concat@1.1.4", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "get-intrinsic": "^1.3.0", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg=="],
|
||||
|
||||
"safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="],
|
||||
@@ -1223,6 +1256,10 @@
|
||||
|
||||
"scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="],
|
||||
|
||||
"sdp": ["sdp@3.2.2", "", {}, "sha512-xZocWwfyp4hkbN4hLWxMjmv2Q8aNa9MhmOZ7L9aCZPT+dZsgRr6wZRrSYE3HTdyk/2pZKPSgqI7ns7Een1xMSA=="],
|
||||
|
||||
"sdp-transform": ["sdp-transform@2.15.0", "", { "bin": { "sdp-verify": "checker.js" } }, "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw=="],
|
||||
|
||||
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"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=="],
|
||||
@@ -1337,6 +1374,8 @@
|
||||
|
||||
"typed-array-length": ["typed-array-length@1.0.8", "", { "dependencies": { "call-bind": "^1.0.9", "for-each": "^0.3.5", "gopd": "^1.2.0", "is-typed-array": "^1.1.15", "possible-typed-array-names": "^1.1.0", "reflect.getprototypeof": "^1.0.10" } }, "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g=="],
|
||||
|
||||
"typed-emitter": ["typed-emitter@2.1.0", "", { "optionalDependencies": { "rxjs": "*" } }, "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"typescript-eslint": ["typescript-eslint@8.72.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.72.1", "@typescript-eslint/parser": "8.72.1", "@typescript-eslint/typescript-estree": "8.72.1", "@typescript-eslint/utils": "8.72.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-3Li9FfPmQp45nxEtFD1ZCrYFI3cYK5kpyNtmm1H7lMagm8u9sy6PTjy3ytwQZs7KNukOyKFHAZ6z6GjYFeaeXw=="],
|
||||
@@ -1359,12 +1398,16 @@
|
||||
|
||||
"use-sync-external-store": ["use-sync-external-store@1.7.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A=="],
|
||||
|
||||
"usehooks-ts": ["usehooks-ts@3.1.1", "", { "dependencies": { "lodash.debounce": "^4.0.8" }, "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA=="],
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
"valibot": ["valibot@1.2.0", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg=="],
|
||||
|
||||
"validate-npm-package-name": ["validate-npm-package-name@7.0.2", "", {}, "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A=="],
|
||||
|
||||
"webrtc-adapter": ["webrtc-adapter@9.0.6", "", { "dependencies": { "sdp": "^3.2.0" } }, "sha512-CHbl2ZQbxx164IgWRgzJno4hWtM4tFbRam1QfI3Yxhs3w/DvqluVxVWeXs3oL5/fbGkSNLKo0Ty5MgUWceNhog=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"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=="],
|
||||
@@ -1409,6 +1452,10 @@
|
||||
|
||||
"@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
|
||||
|
||||
"@livekit/components-core/@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="],
|
||||
|
||||
"@livekit/components-core/loglevel": ["loglevel@1.9.1", "", {}, "sha512-hP3I3kCrDIMuRwAwHltphhDM1r8i55H33GgqjXbrisuJhF4kRhW1dNuxsRklp4bXl8DSdLaNLuiL4A/LWRfxvg=="],
|
||||
|
||||
"@next/eslint-plugin-next/@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
|
||||
|
||||
"@next/eslint-plugin-next/fast-glob": ["fast-glob@3.3.1", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="],
|
||||
@@ -1479,6 +1526,10 @@
|
||||
|
||||
"is-inside-container/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
|
||||
|
||||
"livekit-server-sdk/@livekit/protocol": ["@livekit/protocol@1.51.0", "", { "dependencies": { "@bufbuild/protobuf": "^1.10.0" } }, "sha512-kEDEDaFvIcp5VlYBromdSKMJMEJKfPvXPfEaJPdEz1Qgo84gZAoLF2g58CcMlVYGsjrwE5XYgQxRS5B7KuEj3w=="],
|
||||
|
||||
"livekit-server-sdk/jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="],
|
||||
|
||||
"log-symbols/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||
|
||||
"log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="],
|
||||
|
||||
@@ -11,11 +11,16 @@
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.9.0",
|
||||
"@hookform/resolvers": "^6.0.2",
|
||||
"@livekit/components-react": "^2.9.24",
|
||||
"@livekit/components-styles": "^1.2.0",
|
||||
"@prisma/adapter-mariadb": "^7.10.1",
|
||||
"@prisma/client": "^7.10.1",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"cn": "^0.5.0",
|
||||
"jose": "^6.2.14",
|
||||
"livekit-client": "^2.23.0",
|
||||
"livekit-server-sdk": "^2.19.2",
|
||||
"lucide-react": "^1.64.0",
|
||||
"next": "16.4.3",
|
||||
"next-auth": "^5.0.0-beta.32",
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import crypto from "node:crypto"
|
||||
|
||||
import { NextResponse } from "next/server"
|
||||
|
||||
import { auth } from "@/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { verifyJoinLinkToken } from "@/lib/join-link"
|
||||
import { guestNameSchema } from "@/lib/validations/join"
|
||||
import {
|
||||
guestIdentityCookieName,
|
||||
GUEST_IDENTITY_COOKIE_MAX_AGE_SECONDS,
|
||||
} from "@/lib/participant-identity"
|
||||
|
||||
// Creates (or reuses) the current viewer's MeetingParticipant row, landing
|
||||
// them in `pending` status unless they're the host. Called from the
|
||||
// /join/[meetingUuid] name-entry / confirm step, not automatically — so a
|
||||
// participant row is only created once the visitor explicitly asks to
|
||||
// join.
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ meetingUuid: string }> }
|
||||
) {
|
||||
const { meetingUuid } = await params
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "Cuerpo de solicitud inválido" },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { token, guestName } = (body ?? {}) as {
|
||||
token?: string
|
||||
guestName?: string
|
||||
}
|
||||
|
||||
if (typeof token !== "string" || !token) {
|
||||
return NextResponse.json(
|
||||
{ error: "Falta el token de invitación" },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const verified = await verifyJoinLinkToken(token)
|
||||
if (!verified || verified.meetingUuid !== meetingUuid) {
|
||||
return NextResponse.json(
|
||||
{ error: "El enlace de invitación no es válido o ha expirado" },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const meeting = await prisma.meeting.findUnique({
|
||||
where: { uuid: meetingUuid },
|
||||
})
|
||||
|
||||
if (!meeting) {
|
||||
return NextResponse.json(
|
||||
{ error: "Reunión no encontrada" },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
if (meeting.status === "ended") {
|
||||
return NextResponse.json(
|
||||
{ error: "Esta reunión ha finalizado" },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
const session = await auth()
|
||||
|
||||
if (session?.user?.id) {
|
||||
const userId = Number(session.user.id)
|
||||
const identity = `user-${userId}`
|
||||
const isHost = meeting.hostUserId === userId
|
||||
|
||||
const participant = await prisma.meetingParticipant.upsert({
|
||||
where: { meetingId_identity: { meetingId: meeting.id, identity } },
|
||||
update: {},
|
||||
create: {
|
||||
meetingId: meeting.id,
|
||||
userId,
|
||||
identity,
|
||||
role: isHost ? "host" : "participant",
|
||||
status: isHost ? "admitted" : "pending",
|
||||
admittedAt: isHost ? new Date() : null,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
participantId: participant.id,
|
||||
status: participant.status,
|
||||
})
|
||||
}
|
||||
|
||||
// Anonymous guest: validate the display name and mint a fresh identity.
|
||||
const parsed = guestNameSchema.safeParse({ guestName })
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Datos inválidos",
|
||||
issues: parsed.error.flatten().fieldErrors,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const identity = `guest-${crypto.randomUUID()}`
|
||||
|
||||
const participant = await prisma.meetingParticipant.create({
|
||||
data: {
|
||||
meetingId: meeting.id,
|
||||
guestName: parsed.data.guestName,
|
||||
identity,
|
||||
role: "participant",
|
||||
status: "pending",
|
||||
},
|
||||
})
|
||||
|
||||
const response = NextResponse.json({
|
||||
participantId: participant.id,
|
||||
status: participant.status,
|
||||
})
|
||||
|
||||
response.cookies.set(guestIdentityCookieName(meetingUuid), identity, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
maxAge: GUEST_IDENTITY_COOKIE_MAX_AGE_SECONDS,
|
||||
path: "/",
|
||||
})
|
||||
|
||||
return response
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { NextResponse } from "next/server"
|
||||
|
||||
import { auth } from "@/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { isHostOrCoHost } from "@/lib/meeting-auth"
|
||||
|
||||
export async function POST(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ meetingUuid: string; participantId: string }> }
|
||||
) {
|
||||
const { meetingUuid, participantId } = await params
|
||||
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "No autenticado" }, { status: 401 })
|
||||
}
|
||||
|
||||
const meeting = await prisma.meeting.findUnique({
|
||||
where: { uuid: meetingUuid },
|
||||
})
|
||||
|
||||
if (!meeting) {
|
||||
return NextResponse.json(
|
||||
{ error: "Reunión no encontrada" },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const userId = Number(session.user.id)
|
||||
if (!(await isHostOrCoHost(meeting, userId))) {
|
||||
return NextResponse.json(
|
||||
{ error: "No tienes permiso para admitir participantes" },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const participant = await prisma.meetingParticipant.findUnique({
|
||||
where: { id: Number(participantId) },
|
||||
})
|
||||
|
||||
if (!participant || participant.meetingId !== meeting.id) {
|
||||
return NextResponse.json(
|
||||
{ error: "Participante no encontrado" },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const updated = await prisma.meetingParticipant.update({
|
||||
where: { id: participant.id },
|
||||
data: { status: "admitted", admittedAt: new Date() },
|
||||
})
|
||||
|
||||
return NextResponse.json({ id: updated.id, status: updated.status })
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { NextResponse } from "next/server"
|
||||
|
||||
import { auth } from "@/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { isHostOrCoHost } from "@/lib/meeting-auth"
|
||||
|
||||
export async function POST(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ meetingUuid: string; participantId: string }> }
|
||||
) {
|
||||
const { meetingUuid, participantId } = await params
|
||||
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "No autenticado" }, { status: 401 })
|
||||
}
|
||||
|
||||
const meeting = await prisma.meeting.findUnique({
|
||||
where: { uuid: meetingUuid },
|
||||
})
|
||||
|
||||
if (!meeting) {
|
||||
return NextResponse.json(
|
||||
{ error: "Reunión no encontrada" },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const userId = Number(session.user.id)
|
||||
if (!(await isHostOrCoHost(meeting, userId))) {
|
||||
return NextResponse.json(
|
||||
{ error: "No tienes permiso para denegar participantes" },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const participant = await prisma.meetingParticipant.findUnique({
|
||||
where: { id: Number(participantId) },
|
||||
})
|
||||
|
||||
if (!participant || participant.meetingId !== meeting.id) {
|
||||
return NextResponse.json(
|
||||
{ error: "Participante no encontrado" },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const updated = await prisma.meetingParticipant.update({
|
||||
where: { id: participant.id },
|
||||
data: { status: "denied" },
|
||||
})
|
||||
|
||||
return NextResponse.json({ id: updated.id, status: updated.status })
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { NextResponse } from "next/server"
|
||||
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { resolveViewerIdentity } from "@/lib/participant-identity"
|
||||
|
||||
// Polled by the waiting-room screen every ~3s until status flips to
|
||||
// `admitted` or `denied`. Identity is resolved server-side from the
|
||||
// session or guest cookie, never from a client-supplied value.
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ meetingUuid: string }> }
|
||||
) {
|
||||
const { meetingUuid } = await params
|
||||
|
||||
const meeting = await prisma.meeting.findUnique({
|
||||
where: { uuid: meetingUuid },
|
||||
})
|
||||
|
||||
if (!meeting) {
|
||||
return NextResponse.json(
|
||||
{ error: "Reunión no encontrada" },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const viewer = await resolveViewerIdentity(meetingUuid)
|
||||
|
||||
if (!viewer) {
|
||||
return NextResponse.json({ status: null }, { status: 200 })
|
||||
}
|
||||
|
||||
const participant = await prisma.meetingParticipant.findUnique({
|
||||
where: {
|
||||
meetingId_identity: { meetingId: meeting.id, identity: viewer.identity },
|
||||
},
|
||||
})
|
||||
|
||||
if (!participant) {
|
||||
return NextResponse.json({ status: null }, { status: 200 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
status: participant.status,
|
||||
role: participant.role,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { NextResponse } from "next/server"
|
||||
|
||||
import { auth } from "@/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { isHostOrCoHost } from "@/lib/meeting-auth"
|
||||
|
||||
// Lists participants for the host/co-host admission panel — polled from
|
||||
// /room/[meetingUuid] to surface pending join requests.
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ meetingUuid: string }> }
|
||||
) {
|
||||
const { meetingUuid } = await params
|
||||
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "No autenticado" }, { status: 401 })
|
||||
}
|
||||
|
||||
const meeting = await prisma.meeting.findUnique({
|
||||
where: { uuid: meetingUuid },
|
||||
})
|
||||
|
||||
if (!meeting) {
|
||||
return NextResponse.json(
|
||||
{ error: "Reunión no encontrada" },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const userId = Number(session.user.id)
|
||||
if (!(await isHostOrCoHost(meeting, userId))) {
|
||||
return NextResponse.json(
|
||||
{ error: "No tienes permiso para ver esta lista" },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const participants = await prisma.meetingParticipant.findMany({
|
||||
where: { meetingId: meeting.id },
|
||||
orderBy: { requestedAt: "asc" },
|
||||
include: { user: { select: { displayName: true, email: true } } },
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
participants: participants.map((participant) => ({
|
||||
id: participant.id,
|
||||
identity: participant.identity,
|
||||
displayName:
|
||||
participant.user?.displayName ?? participant.guestName ?? "Invitado",
|
||||
role: participant.role,
|
||||
status: participant.status,
|
||||
requestedAt: participant.requestedAt.toISOString(),
|
||||
})),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import crypto from "node:crypto"
|
||||
|
||||
import { NextResponse } from "next/server"
|
||||
|
||||
import { auth } from "@/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { createJoinLinkToken } from "@/lib/join-link"
|
||||
|
||||
export async function POST() {
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "No autenticado" }, { status: 401 })
|
||||
}
|
||||
|
||||
const hostUserId = Number(session.user.id)
|
||||
const now = new Date()
|
||||
|
||||
// The creator is about to join immediately, so the meeting starts
|
||||
// `active` right away rather than sitting in `scheduled` until someone
|
||||
// joins.
|
||||
const meeting = await prisma.meeting.create({
|
||||
data: {
|
||||
uuid: crypto.randomUUID(),
|
||||
title: "Reunión instantánea",
|
||||
type: "instant",
|
||||
status: "active",
|
||||
hostUserId,
|
||||
startedAt: now,
|
||||
},
|
||||
})
|
||||
|
||||
const token = await createJoinLinkToken(meeting.uuid)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
meeting: { id: meeting.id, uuid: meeting.uuid },
|
||||
joinUrl: `/join/${meeting.uuid}?token=${token}`,
|
||||
},
|
||||
{ status: 201 }
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import crypto from "node:crypto"
|
||||
|
||||
import { NextResponse } from "next/server"
|
||||
|
||||
import { auth } from "@/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { createJoinLinkToken } from "@/lib/join-link"
|
||||
import { scheduledMeetingSchema } from "@/lib/validations/meeting"
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "No autenticado" }, { status: 401 })
|
||||
}
|
||||
|
||||
let body: unknown
|
||||
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "Cuerpo de solicitud inválido" },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const parsed = scheduledMeetingSchema.safeParse(body)
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Datos inválidos",
|
||||
issues: parsed.error.flatten().fieldErrors,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const hostUserId = Number(session.user.id)
|
||||
const scheduledAt = new Date(parsed.data.scheduledAt)
|
||||
|
||||
const meeting = await prisma.meeting.create({
|
||||
data: {
|
||||
uuid: crypto.randomUUID(),
|
||||
title: parsed.data.title,
|
||||
type: "scheduled",
|
||||
status: "scheduled",
|
||||
hostUserId,
|
||||
scheduledAt,
|
||||
},
|
||||
})
|
||||
|
||||
const token = await createJoinLinkToken(meeting.uuid, { scheduledAt })
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
meeting: { id: meeting.id, uuid: meeting.uuid },
|
||||
joinUrl: `/join/${meeting.uuid}?token=${token}`,
|
||||
},
|
||||
{ status: 201 }
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
import { auth } from "@/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { createJoinLinkToken } from "@/lib/join-link"
|
||||
import { DashboardContent } from "@/components/dashboard/dashboard-content"
|
||||
import type { MeetingDTO } from "@/components/dashboard/types"
|
||||
|
||||
// This route reads the session (cookies) and queries the DB for the
|
||||
// current user's meetings on every request, so it can't be part of the
|
||||
// static shell under Cache Components. Opting out of instant-navigation
|
||||
// validation here is the documented migration path (see
|
||||
// node_modules/next/dist/docs/01-app/02-guides/authentication-with-cache-components.md)
|
||||
// for a route that should always render fresh data rather than a cached
|
||||
// shell — dashboard data (active/upcoming meetings) must never be stale.
|
||||
export const instant = false
|
||||
|
||||
const PAST_MEETINGS_LIMIT = 10
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
redirect("/login?callbackUrl=/dashboard")
|
||||
}
|
||||
|
||||
const hostUserId = Number(session.user.id)
|
||||
|
||||
const [activeMeetings, upcomingMeetings, pastMeetings] = await Promise.all([
|
||||
prisma.meeting.findMany({
|
||||
where: { hostUserId, status: "active" },
|
||||
orderBy: { startedAt: "desc" },
|
||||
}),
|
||||
prisma.meeting.findMany({
|
||||
where: {
|
||||
hostUserId,
|
||||
status: "scheduled",
|
||||
scheduledAt: { gte: new Date() },
|
||||
},
|
||||
orderBy: { scheduledAt: "asc" },
|
||||
}),
|
||||
prisma.meeting.findMany({
|
||||
where: { hostUserId, status: "ended" },
|
||||
orderBy: { endedAt: "desc" },
|
||||
take: PAST_MEETINGS_LIMIT,
|
||||
}),
|
||||
])
|
||||
|
||||
async function toDTO(
|
||||
meeting: (typeof activeMeetings)[number],
|
||||
withJoinLink: boolean
|
||||
): Promise<MeetingDTO> {
|
||||
return {
|
||||
id: meeting.id,
|
||||
uuid: meeting.uuid,
|
||||
title: meeting.title,
|
||||
type: meeting.type,
|
||||
status: meeting.status,
|
||||
scheduledAt: meeting.scheduledAt?.toISOString() ?? null,
|
||||
startedAt: meeting.startedAt?.toISOString() ?? null,
|
||||
endedAt: meeting.endedAt?.toISOString() ?? null,
|
||||
joinUrl: withJoinLink
|
||||
? `/join/${meeting.uuid}?token=${await createJoinLinkToken(meeting.uuid, {
|
||||
scheduledAt: meeting.scheduledAt,
|
||||
})}`
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
const [active, upcoming, past] = await Promise.all([
|
||||
Promise.all(activeMeetings.map((meeting) => toDTO(meeting, true))),
|
||||
Promise.all(upcomingMeetings.map((meeting) => toDTO(meeting, true))),
|
||||
Promise.all(pastMeetings.map((meeting) => toDTO(meeting, false))),
|
||||
])
|
||||
|
||||
return (
|
||||
<DashboardContent
|
||||
displayName={session.user.displayName}
|
||||
active={active}
|
||||
upcoming={upcoming}
|
||||
past={past}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -14,6 +14,12 @@
|
||||
--color-meet-bg-mint-start: var(--meet-bg-mint-start);
|
||||
--color-meet-bg-mint-end: var(--meet-bg-mint-end);
|
||||
--color-meet-focus-ring: var(--meet-focus-ring);
|
||||
--color-meet-status-active-bg: var(--meet-status-active-bg);
|
||||
--color-meet-status-active-fg: var(--meet-status-active-fg);
|
||||
--color-meet-status-scheduled-bg: var(--meet-status-scheduled-bg);
|
||||
--color-meet-status-scheduled-fg: var(--meet-status-scheduled-fg);
|
||||
--color-meet-status-ended-bg: var(--meet-status-ended-bg);
|
||||
--color-meet-status-ended-fg: var(--meet-status-ended-fg);
|
||||
--font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--font-heading: var(--font-sans);
|
||||
@@ -63,6 +69,12 @@
|
||||
--meet-bg-mint-start: #EAF7F4;
|
||||
--meet-bg-mint-end: #F2FBF0;
|
||||
--meet-focus-ring: #2BA8C4;
|
||||
--meet-status-active-bg: #E3F6EA;
|
||||
--meet-status-active-fg: #1F9D55;
|
||||
--meet-status-scheduled-bg: #E5F1FB;
|
||||
--meet-status-scheduled-fg: #1C6FB0;
|
||||
--meet-status-ended-bg: #F1F1F1;
|
||||
--meet-status-ended-fg: #6B7280;
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { cookies } from "next/headers"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
import { auth } from "@/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { verifyJoinLinkToken } from "@/lib/join-link"
|
||||
import { guestIdentityCookieName } from "@/lib/participant-identity"
|
||||
import { AppShell } from "@/components/shell/app-shell"
|
||||
import {
|
||||
Card,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import { JoinFlow } from "@/components/join/join-flow"
|
||||
|
||||
// Reads the session/cookies and queries the DB on every request to
|
||||
// resolve "who is this visitor and what's their access state" — same
|
||||
// reasoning as the dashboard page (see its own comment) for opting out
|
||||
// of Cache Components' static-shell validation.
|
||||
export const instant = false
|
||||
|
||||
function JoinShell({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title?: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<AppShell className="flex min-h-[80vh] items-center justify-center">
|
||||
<div className="w-full max-w-md">
|
||||
{title ? (
|
||||
<h1 className="mb-4 text-center text-lg font-semibold text-foreground">
|
||||
{title}
|
||||
</h1>
|
||||
) : null}
|
||||
{children}
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
function JoinMessage({
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
}) {
|
||||
return (
|
||||
<JoinShell>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</JoinShell>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function JoinPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ meetingUuid: string }>
|
||||
searchParams: Promise<{ token?: string }>
|
||||
}) {
|
||||
const { meetingUuid } = await params
|
||||
const { token } = await searchParams
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<JoinMessage
|
||||
title="Enlace inválido"
|
||||
description="Falta el token de invitación en el enlace. Pide al anfitrión que te comparta el enlace completo."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const verified = await verifyJoinLinkToken(token)
|
||||
if (!verified || verified.meetingUuid !== meetingUuid) {
|
||||
return (
|
||||
<JoinMessage
|
||||
title="Enlace inválido o expirado"
|
||||
description="Este enlace de invitación ya no es válido. Pide al anfitrión un nuevo enlace."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const meeting = await prisma.meeting.findUnique({
|
||||
where: { uuid: meetingUuid },
|
||||
})
|
||||
|
||||
if (!meeting) {
|
||||
return (
|
||||
<JoinMessage
|
||||
title="Reunión no encontrada"
|
||||
description="No encontramos esta reunión. Verifica el enlace."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (meeting.status === "ended") {
|
||||
return (
|
||||
<JoinMessage
|
||||
title="Esta reunión ha finalizado"
|
||||
description="El anfitrión ya terminó esta reunión. No es posible unirse."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const session = await auth()
|
||||
|
||||
if (session?.user?.id) {
|
||||
const userId = Number(session.user.id)
|
||||
const identity = `user-${userId}`
|
||||
|
||||
if (meeting.hostUserId === userId) {
|
||||
await prisma.meetingParticipant.upsert({
|
||||
where: {
|
||||
meetingId_identity: { meetingId: meeting.id, identity },
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
meetingId: meeting.id,
|
||||
userId,
|
||||
identity,
|
||||
role: "host",
|
||||
status: "admitted",
|
||||
admittedAt: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
redirect(`/room/${meetingUuid}`)
|
||||
}
|
||||
|
||||
const participant = await prisma.meetingParticipant.findUnique({
|
||||
where: {
|
||||
meetingId_identity: { meetingId: meeting.id, identity },
|
||||
},
|
||||
})
|
||||
|
||||
if (participant) {
|
||||
if (participant.status === "admitted") {
|
||||
redirect(`/room/${meetingUuid}`)
|
||||
}
|
||||
|
||||
if (participant.status === "denied") {
|
||||
return (
|
||||
<JoinShell>
|
||||
<JoinFlow meetingUuid={meetingUuid} token={token} mode="denied" />
|
||||
</JoinShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<JoinShell>
|
||||
<JoinFlow meetingUuid={meetingUuid} token={token} mode="waiting" />
|
||||
</JoinShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<JoinShell>
|
||||
<JoinFlow
|
||||
meetingUuid={meetingUuid}
|
||||
token={token}
|
||||
mode="confirm"
|
||||
displayName={session.user.displayName}
|
||||
/>
|
||||
</JoinShell>
|
||||
)
|
||||
}
|
||||
|
||||
// Anonymous visitor: recognize a returning guest via cookie before
|
||||
// falling back to the name-entry form.
|
||||
const cookieStore = await cookies()
|
||||
const guestIdentity = cookieStore.get(
|
||||
guestIdentityCookieName(meetingUuid)
|
||||
)?.value
|
||||
|
||||
if (guestIdentity) {
|
||||
const participant = await prisma.meetingParticipant.findUnique({
|
||||
where: {
|
||||
meetingId_identity: { meetingId: meeting.id, identity: guestIdentity },
|
||||
},
|
||||
})
|
||||
|
||||
if (participant) {
|
||||
if (participant.status === "admitted") {
|
||||
redirect(`/room/${meetingUuid}`)
|
||||
}
|
||||
|
||||
if (participant.status === "denied") {
|
||||
return (
|
||||
<JoinShell>
|
||||
<JoinFlow meetingUuid={meetingUuid} token={token} mode="denied" />
|
||||
</JoinShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<JoinShell>
|
||||
<JoinFlow meetingUuid={meetingUuid} token={token} mode="waiting" />
|
||||
</JoinShell>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<JoinShell>
|
||||
<JoinFlow meetingUuid={meetingUuid} token={token} mode="name-entry" />
|
||||
</JoinShell>
|
||||
)
|
||||
}
|
||||
@@ -97,7 +97,11 @@ export default function RegisterPage() {
|
||||
</a>
|
||||
</p>
|
||||
) : null}
|
||||
<Button className="w-full" render={<Link href="/login" />}>
|
||||
<Button
|
||||
className="w-full"
|
||||
nativeButton={false}
|
||||
render={<Link href="/login" />}
|
||||
>
|
||||
Ir a iniciar sesión
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import Link from "next/link"
|
||||
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { resolveViewerIdentity } from "@/lib/participant-identity"
|
||||
import { mintAccessToken, LIVEKIT_URL } from "@/lib/livekit"
|
||||
import { AppShell } from "@/components/shell/app-shell"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { RoomClient } from "@/components/room/room-client"
|
||||
|
||||
// Reads the session/guest cookie and queries the DB on every request to
|
||||
// resolve admission state and mint a fresh LiveKit token — see the
|
||||
// dashboard/join pages' own comments for why this opts out of Cache
|
||||
// Components' static-shell validation.
|
||||
export const instant = false
|
||||
|
||||
function NoAccess() {
|
||||
return (
|
||||
<AppShell className="flex min-h-[80vh] items-center justify-center">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>No tienes acceso a esta sala</CardTitle>
|
||||
<CardDescription>
|
||||
Usa el enlace de invitación que te compartió el anfitrión para
|
||||
unirte a esta reunión.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button render={<Link href="/dashboard" />} nativeButton={false}>
|
||||
Ir al panel
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function RoomPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ meetingUuid: string }>
|
||||
}) {
|
||||
const { meetingUuid } = await params
|
||||
|
||||
const meeting = await prisma.meeting.findUnique({
|
||||
where: { uuid: meetingUuid },
|
||||
})
|
||||
|
||||
if (!meeting) {
|
||||
return <NoAccess />
|
||||
}
|
||||
|
||||
if (meeting.status === "ended") {
|
||||
return (
|
||||
<AppShell className="flex min-h-[80vh] items-center justify-center">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>Esta reunión ha finalizado</CardTitle>
|
||||
<CardDescription>
|
||||
El anfitrión ya terminó esta reunión.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button render={<Link href="/dashboard" />} nativeButton={false}>
|
||||
Ir al panel
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
const viewer = await resolveViewerIdentity(meetingUuid)
|
||||
if (!viewer) {
|
||||
return <NoAccess />
|
||||
}
|
||||
|
||||
const participant = await prisma.meetingParticipant.findUnique({
|
||||
where: {
|
||||
meetingId_identity: { meetingId: meeting.id, identity: viewer.identity },
|
||||
},
|
||||
include: { user: { select: { displayName: true } } },
|
||||
})
|
||||
|
||||
if (!participant || participant.status !== "admitted") {
|
||||
return <NoAccess />
|
||||
}
|
||||
|
||||
const isHost = participant.role === "host" || participant.role === "co_host"
|
||||
const displayName =
|
||||
participant.user?.displayName ?? participant.guestName ?? "Invitado"
|
||||
|
||||
const token = await mintAccessToken({
|
||||
roomName: meeting.uuid,
|
||||
identity: viewer.identity,
|
||||
displayName,
|
||||
canPublish: true,
|
||||
canSubscribe: true,
|
||||
isHost,
|
||||
})
|
||||
|
||||
return (
|
||||
<RoomClient
|
||||
serverUrl={LIVEKIT_URL}
|
||||
token={token}
|
||||
identity={viewer.identity}
|
||||
roomName={meeting.uuid}
|
||||
meetingUuid={meetingUuid}
|
||||
isHost={isHost}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { signOut } from "next-auth/react"
|
||||
import { CalendarClock, History, LogOut, Radio, Video } from "lucide-react"
|
||||
|
||||
import { AppShell } from "@/components/shell/app-shell"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { MeetingCard } from "@/components/dashboard/meeting-card"
|
||||
import { ScheduleMeetingDialog } from "@/components/dashboard/schedule-meeting-dialog"
|
||||
import type { MeetingDTO } from "@/components/dashboard/types"
|
||||
|
||||
function EmptyState({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed p-5 text-center text-sm text-muted-foreground">
|
||||
{message}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DashboardContent({
|
||||
displayName,
|
||||
active,
|
||||
upcoming,
|
||||
past,
|
||||
}: {
|
||||
displayName: string
|
||||
active: MeetingDTO[]
|
||||
upcoming: MeetingDTO[]
|
||||
past: MeetingDTO[]
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const [creatingInstant, setCreatingInstant] = useState(false)
|
||||
const [instantError, setInstantError] = useState<string | null>(null)
|
||||
|
||||
async function handleInstantMeeting() {
|
||||
setInstantError(null)
|
||||
setCreatingInstant(true)
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/meetings/instant", {
|
||||
method: "POST",
|
||||
})
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
setInstantError(data?.error ?? "No se pudo crear la reunión")
|
||||
setCreatingInstant(false)
|
||||
return
|
||||
}
|
||||
|
||||
router.push(data.joinUrl)
|
||||
} catch {
|
||||
setInstantError(
|
||||
"No se pudo conectar con el servidor. Intenta de nuevo."
|
||||
)
|
||||
setCreatingInstant(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="font-heading text-xl font-semibold text-(--meet-heading-blue)">
|
||||
Hola, {displayName}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Gestiona tus reuniones de Appisoft Meet.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => signOut({ callbackUrl: "/login" })}
|
||||
>
|
||||
<LogOut />
|
||||
Cerrar sesión
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Nueva reunión</CardTitle>
|
||||
<CardDescription>
|
||||
Inicia una reunión instantánea o programa una para más tarde.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap items-center gap-2">
|
||||
{instantError ? (
|
||||
<p className="w-full rounded-lg bg-destructive/10 p-2.5 text-sm text-destructive">
|
||||
{instantError}
|
||||
</p>
|
||||
) : null}
|
||||
<Button onClick={handleInstantMeeting} disabled={creatingInstant}>
|
||||
<Video />
|
||||
{creatingInstant ? "Creando..." : "Reunión instantánea"}
|
||||
</Button>
|
||||
<ScheduleMeetingDialog />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Tabs defaultValue="active">
|
||||
<TabsList>
|
||||
<TabsTrigger value="active">
|
||||
<Radio />
|
||||
Activas{active.length > 0 ? ` (${active.length})` : ""}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="upcoming">
|
||||
<CalendarClock />
|
||||
Próximas{upcoming.length > 0 ? ` (${upcoming.length})` : ""}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="past">
|
||||
<History />
|
||||
Pasadas
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="active" className="space-y-2 pt-1">
|
||||
{active.length === 0 ? (
|
||||
<EmptyState message="No tienes reuniones activas." />
|
||||
) : (
|
||||
active.map((meeting) => (
|
||||
<MeetingCard key={meeting.id} meeting={meeting} />
|
||||
))
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="upcoming" className="space-y-2 pt-1">
|
||||
{upcoming.length === 0 ? (
|
||||
<EmptyState message="No tienes reuniones próximas." />
|
||||
) : (
|
||||
upcoming.map((meeting) => (
|
||||
<MeetingCard key={meeting.id} meeting={meeting} />
|
||||
))
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="past" className="space-y-2 pt-1">
|
||||
{past.length === 0 ? (
|
||||
<EmptyState message="No tienes reuniones pasadas." />
|
||||
) : (
|
||||
past.map((meeting) => (
|
||||
<MeetingCard key={meeting.id} meeting={meeting} />
|
||||
))
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import Link from "next/link"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { MEETING_STATUS_BADGE } from "@/lib/meeting-status"
|
||||
import type { MeetingDTO } from "@/components/dashboard/types"
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat("es-CO", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
})
|
||||
|
||||
function formatMeetingDateTime(meeting: MeetingDTO): string {
|
||||
const value =
|
||||
meeting.status === "ended"
|
||||
? meeting.endedAt
|
||||
: meeting.status === "active"
|
||||
? meeting.startedAt
|
||||
: meeting.scheduledAt
|
||||
|
||||
if (!value) {
|
||||
return "Sin fecha"
|
||||
}
|
||||
|
||||
return dateFormatter.format(new Date(value))
|
||||
}
|
||||
|
||||
export function MeetingCard({ meeting }: { meeting: MeetingDTO }) {
|
||||
const badge = MEETING_STATUS_BADGE[meeting.status]
|
||||
|
||||
return (
|
||||
<Card size="sm">
|
||||
<CardContent className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="truncate text-sm font-medium">{meeting.title}</p>
|
||||
<Badge className={badge.className}>{badge.label}</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMeetingDateTime(meeting)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{meeting.joinUrl ? (
|
||||
<Button
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
nativeButton={false}
|
||||
render={<Link href={meeting.joinUrl} />}
|
||||
>
|
||||
Unirse
|
||||
</Button>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { CalendarPlus, Loader2 } from "lucide-react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
scheduledMeetingSchema,
|
||||
type ScheduledMeetingInput,
|
||||
} from "@/lib/validations/meeting"
|
||||
|
||||
export function ScheduleMeetingDialog() {
|
||||
const router = useRouter()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [serverError, setServerError] = useState<string | null>(null)
|
||||
|
||||
const form = useForm<ScheduledMeetingInput>({
|
||||
resolver: zodResolver(scheduledMeetingSchema),
|
||||
defaultValues: { title: "", scheduledAt: "" },
|
||||
})
|
||||
|
||||
async function onSubmit(values: ScheduledMeetingInput) {
|
||||
setServerError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/meetings/scheduled", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(values),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
setServerError(data?.error ?? "No se pudo programar la reunión")
|
||||
return
|
||||
}
|
||||
|
||||
setOpen(false)
|
||||
form.reset()
|
||||
router.refresh()
|
||||
} catch {
|
||||
setServerError("No se pudo conectar con el servidor. Intenta de nuevo.")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
setOpen(nextOpen)
|
||||
if (!nextOpen) {
|
||||
setServerError(null)
|
||||
form.reset()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTrigger render={<Button variant="outline" />}>
|
||||
<CalendarPlus />
|
||||
Programar reunión
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Programar reunión</DialogTitle>
|
||||
<DialogDescription>
|
||||
Define un título y la fecha y hora en que iniciará.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-3">
|
||||
{serverError ? (
|
||||
<p className="rounded-lg bg-destructive/10 p-2.5 text-sm text-destructive">
|
||||
{serverError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Título</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Reunión de equipo" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="scheduledAt"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Fecha y hora</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="datetime-local" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose render={<Button type="button" variant="outline" />}>
|
||||
Cancelar
|
||||
</DialogClose>
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" />
|
||||
Programando...
|
||||
</>
|
||||
) : (
|
||||
"Programar"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { MeetingStatus, MeetingType } from "@prisma/client"
|
||||
|
||||
export type MeetingDTO = {
|
||||
id: number
|
||||
uuid: string
|
||||
title: string
|
||||
type: MeetingType
|
||||
status: MeetingStatus
|
||||
scheduledAt: string | null
|
||||
startedAt: string | null
|
||||
endedAt: string | null
|
||||
joinUrl: string | null
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Loader2 } from "lucide-react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
type Step = "name-entry" | "confirm" | "waiting" | "denied"
|
||||
|
||||
const POLL_INTERVAL_MS = 3000
|
||||
|
||||
/**
|
||||
* Client-side driver for the /join/[meetingUuid] page's non-host paths:
|
||||
* anonymous name entry, authenticated-non-host confirmation, and the
|
||||
* waiting-room polling loop. The server component decides which `mode`
|
||||
* to start in based on session/cookie/participant-row state; this
|
||||
* component only handles what happens next in the browser.
|
||||
*/
|
||||
export function JoinFlow({
|
||||
meetingUuid,
|
||||
token,
|
||||
mode,
|
||||
displayName,
|
||||
}: {
|
||||
meetingUuid: string
|
||||
token: string
|
||||
mode: Step
|
||||
displayName?: string
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const [step, setStep] = useState<Step>(mode)
|
||||
const [guestName, setGuestName] = useState("")
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
const stopPolling = useCallback(() => {
|
||||
if (pollRef.current) {
|
||||
clearInterval(pollRef.current)
|
||||
pollRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (step !== "waiting") {
|
||||
return
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/meetings/${meetingUuid}/participants/me`
|
||||
)
|
||||
if (!response.ok) return
|
||||
const data = (await response.json()) as { status: string | null }
|
||||
|
||||
if (data.status === "admitted") {
|
||||
stopPolling()
|
||||
router.push(`/room/${meetingUuid}`)
|
||||
} else if (data.status === "denied") {
|
||||
stopPolling()
|
||||
setStep("denied")
|
||||
}
|
||||
} catch {
|
||||
// Transient network error: next poll tick will retry.
|
||||
}
|
||||
}
|
||||
|
||||
poll()
|
||||
pollRef.current = setInterval(poll, POLL_INTERVAL_MS)
|
||||
return stopPolling
|
||||
}, [step, meetingUuid, router, stopPolling])
|
||||
|
||||
async function submitJoinRequest(body: { guestName?: string }) {
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/meetings/${meetingUuid}/join-request`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token, ...body }),
|
||||
}
|
||||
)
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
setError(data?.error ?? "No se pudo enviar la solicitud")
|
||||
return
|
||||
}
|
||||
|
||||
if (data.status === "admitted") {
|
||||
router.push(`/room/${meetingUuid}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (data.status === "denied") {
|
||||
setStep("denied")
|
||||
return
|
||||
}
|
||||
|
||||
setStep("waiting")
|
||||
} catch {
|
||||
setError("No se pudo conectar con el servidor")
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (step === "name-entry") {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Unirse a la reunión</CardTitle>
|
||||
<CardDescription>
|
||||
Ingresa tu nombre para que el anfitrión pueda identificarte.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<Label htmlFor="guestName">Tu nombre</Label>
|
||||
<Input
|
||||
id="guestName"
|
||||
value={guestName}
|
||||
onChange={(event) => setGuestName(event.target.value)}
|
||||
placeholder="Ej. Ana Pérez"
|
||||
maxLength={80}
|
||||
disabled={submitting}
|
||||
/>
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
</CardContent>
|
||||
<CardFooter className="justify-end">
|
||||
<Button
|
||||
disabled={submitting || guestName.trim().length === 0}
|
||||
onClick={() =>
|
||||
submitJoinRequest({ guestName: guestName.trim() })
|
||||
}
|
||||
>
|
||||
{submitting ? <Loader2 className="animate-spin" /> : null}
|
||||
Unirse
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (step === "confirm") {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Unirse a la reunión</CardTitle>
|
||||
<CardDescription>
|
||||
Vas a unirte como <strong>{displayName}</strong>.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
</CardContent>
|
||||
<CardFooter className="justify-end">
|
||||
<Button disabled={submitting} onClick={() => submitJoinRequest({})}>
|
||||
{submitting ? <Loader2 className="animate-spin" /> : null}
|
||||
Unirse
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (step === "denied") {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Solicitud rechazada</CardTitle>
|
||||
<CardDescription>
|
||||
El anfitrión rechazó tu solicitud para unirte a la reunión.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// step === "waiting"
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Sala de espera
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Esperando a que el anfitrión te admita...
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { Loader2 } from "lucide-react"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
|
||||
type ParticipantRow = {
|
||||
id: number
|
||||
displayName: string
|
||||
role: "host" | "co_host" | "participant"
|
||||
status: "pending" | "admitted" | "denied" | "left"
|
||||
requestedAt: string
|
||||
}
|
||||
|
||||
const POLL_INTERVAL_MS = 3000
|
||||
|
||||
const STATUS_BADGE: Record<ParticipantRow["status"], string> = {
|
||||
pending: "bg-amber-100 text-amber-700",
|
||||
admitted: "bg-(--meet-status-active-bg) text-(--meet-status-active-fg)",
|
||||
denied: "bg-destructive/10 text-destructive",
|
||||
left: "bg-muted text-muted-foreground",
|
||||
}
|
||||
|
||||
/**
|
||||
* Host/co-host admission panel: polls the participants list and shows
|
||||
* Admit/Deny buttons on pending requests. Minimal but functional, per
|
||||
* this task's scope — a polished version lands with the full in-call UI.
|
||||
*/
|
||||
export function AdmissionPanel({ meetingUuid }: { meetingUuid: string }) {
|
||||
const [participants, setParticipants] = useState<ParticipantRow[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [actioningId, setActioningId] = useState<number | null>(null)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/meetings/${meetingUuid}/participants`
|
||||
)
|
||||
if (!response.ok) return
|
||||
const data = (await response.json()) as {
|
||||
participants: ParticipantRow[]
|
||||
}
|
||||
setParticipants(data.participants)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [meetingUuid])
|
||||
|
||||
useEffect(() => {
|
||||
refresh()
|
||||
const interval = setInterval(refresh, POLL_INTERVAL_MS)
|
||||
return () => clearInterval(interval)
|
||||
}, [refresh])
|
||||
|
||||
async function act(participantId: number, action: "admit" | "deny") {
|
||||
setActioningId(participantId)
|
||||
try {
|
||||
await fetch(
|
||||
`/api/meetings/${meetingUuid}/participants/${participantId}/${action}`,
|
||||
{ method: "POST" }
|
||||
)
|
||||
await refresh()
|
||||
} finally {
|
||||
setActioningId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const pending = participants.filter((p) => p.status === "pending")
|
||||
const others = participants.filter((p) => p.status !== "pending")
|
||||
|
||||
return (
|
||||
<div className="space-y-3 p-3">
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Solicitudes de ingreso</CardTitle>
|
||||
<CardDescription>
|
||||
{loading
|
||||
? "Cargando..."
|
||||
: pending.length === 0
|
||||
? "No hay solicitudes pendientes"
|
||||
: `${pending.length} esperando admisión`}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
{pending.length > 0 ? (
|
||||
<CardContent className="space-y-2">
|
||||
{pending.map((participant) => (
|
||||
<div
|
||||
key={participant.id}
|
||||
className="flex items-center justify-between gap-2 rounded-lg border p-2"
|
||||
>
|
||||
<span className="truncate text-sm font-medium">
|
||||
{participant.displayName}
|
||||
</span>
|
||||
<div className="flex shrink-0 gap-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={actioningId === participant.id}
|
||||
onClick={() => act(participant.id, "admit")}
|
||||
>
|
||||
{actioningId === participant.id ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : null}
|
||||
Admitir
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={actioningId === participant.id}
|
||||
onClick={() => act(participant.id, "deny")}
|
||||
>
|
||||
Rechazar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
{others.length > 0 ? (
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Participantes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{others.map((participant) => (
|
||||
<div
|
||||
key={participant.id}
|
||||
className="flex items-center justify-between gap-2"
|
||||
>
|
||||
<span className="truncate text-sm">
|
||||
{participant.displayName}
|
||||
</span>
|
||||
<Badge className={STATUS_BADGE[participant.status]}>
|
||||
{participant.status}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import {
|
||||
LiveKitRoom,
|
||||
useConnectionState,
|
||||
} from "@livekit/components-react"
|
||||
import "@livekit/components-styles"
|
||||
|
||||
import { CallShell } from "@/components/shell/call-shell"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { AdmissionPanel } from "@/components/room/admission-panel"
|
||||
|
||||
/**
|
||||
* Placeholder room screen: proves a minted LiveKit access token actually
|
||||
* connects (or reports why it didn't), plus a minimal host admission
|
||||
* panel. The real video-grid/chat/notes/recording UI is a separate,
|
||||
* already-planned follow-up task — this intentionally stays simple.
|
||||
*/
|
||||
export function RoomClient({
|
||||
serverUrl,
|
||||
token,
|
||||
identity,
|
||||
roomName,
|
||||
meetingUuid,
|
||||
isHost,
|
||||
}: {
|
||||
serverUrl: string
|
||||
token: string
|
||||
identity: string
|
||||
roomName: string
|
||||
meetingUuid: string
|
||||
isHost: boolean
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const [sidePanelOpen, setSidePanelOpen] = useState(isHost)
|
||||
const [connectError, setConnectError] = useState<string | null>(null)
|
||||
|
||||
return (
|
||||
<LiveKitRoom
|
||||
serverUrl={serverUrl}
|
||||
token={token}
|
||||
connect
|
||||
audio={false}
|
||||
video={false}
|
||||
onError={(error) => setConnectError(error.message)}
|
||||
onDisconnected={() => router.push("/dashboard")}
|
||||
>
|
||||
<CallShell
|
||||
sidePanelOpen={sidePanelOpen}
|
||||
onSidePanelClose={() => setSidePanelOpen(false)}
|
||||
sidePanel={
|
||||
isHost ? <AdmissionPanel meetingUuid={meetingUuid} /> : null
|
||||
}
|
||||
controlBar={
|
||||
<>
|
||||
<Button variant="destructive" onClick={() => router.push("/dashboard")}>
|
||||
Salir
|
||||
</Button>
|
||||
{isHost ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setSidePanelOpen((open) => !open)}
|
||||
>
|
||||
Solicitudes
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<RoomStatus
|
||||
identity={identity}
|
||||
roomName={roomName}
|
||||
connectError={connectError}
|
||||
/>
|
||||
</CallShell>
|
||||
</LiveKitRoom>
|
||||
)
|
||||
}
|
||||
|
||||
function RoomStatus({
|
||||
identity,
|
||||
roomName,
|
||||
connectError,
|
||||
}: {
|
||||
identity: string
|
||||
roomName: string
|
||||
connectError: string | null
|
||||
}) {
|
||||
const connectionState = useConnectionState()
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 text-center">
|
||||
<p className="text-lg font-medium">Conectado como {identity}</p>
|
||||
<p className="text-sm text-slate-400">Sala: {roomName}</p>
|
||||
<p className="text-sm text-slate-400">
|
||||
Estado de conexión: {connectionState}
|
||||
</p>
|
||||
{connectError ? (
|
||||
<p className="max-w-md text-sm text-red-400">
|
||||
Error de conexión: {connectError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import "server-only"
|
||||
import { SignJWT, jwtVerify, errors as joseErrors } from "jose"
|
||||
|
||||
// Signed, time-limited join-link tokens: mirror the original Laravel
|
||||
// version's approach of encoding meeting access in a token rather than
|
||||
// relying on a guessable/incrementing URL. The token carries just enough
|
||||
// info (the meeting's UUID) for the future /join/[meetingUuid] page to
|
||||
// resolve the meeting and admit the bearer.
|
||||
|
||||
const secret = process.env.JOIN_LINK_SECRET
|
||||
|
||||
if (!secret) {
|
||||
throw new Error(
|
||||
"JOIN_LINK_SECRET environment variable is not set. Add it to .env (see .env.example)."
|
||||
)
|
||||
}
|
||||
|
||||
const encodedSecret = new TextEncoder().encode(secret)
|
||||
|
||||
// Grace period a join link stays valid for, counted either from issuance
|
||||
// (instant meetings) or from the scheduled start time (scheduled
|
||||
// meetings), whichever is further in the future.
|
||||
const GRACE_PERIOD_MS = 6 * 60 * 60 * 1000
|
||||
|
||||
interface JoinLinkTokenPayload {
|
||||
meetingUuid: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a signed JWT encoding access to a meeting by UUID.
|
||||
*
|
||||
* For instant meetings, pass no `scheduledAt` and the token expires 6 hours
|
||||
* from now. For scheduled meetings, pass the meeting's `scheduledAt` so the
|
||||
* token stays valid through the grace period that follows the scheduled
|
||||
* start time, even if that's further out than 6 hours from now.
|
||||
*/
|
||||
export async function createJoinLinkToken(
|
||||
meetingUuid: string,
|
||||
options?: { scheduledAt?: Date | null }
|
||||
): Promise<string> {
|
||||
const now = Date.now()
|
||||
let expiresAtMs = now + GRACE_PERIOD_MS
|
||||
|
||||
if (options?.scheduledAt) {
|
||||
const scheduledExpiry = options.scheduledAt.getTime() + GRACE_PERIOD_MS
|
||||
expiresAtMs = Math.max(expiresAtMs, scheduledExpiry)
|
||||
}
|
||||
|
||||
return new SignJWT({ meetingUuid } satisfies JoinLinkTokenPayload)
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(Math.floor(expiresAtMs / 1000))
|
||||
.sign(encodedSecret)
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies a join-link token. Returns `null` on any invalid/expired/
|
||||
* malformed token rather than throwing, so callers (e.g. the future
|
||||
* /join/[meetingUuid] page) can handle it gracefully (show "link expired",
|
||||
* fall back to requesting admission, etc.) instead of crashing.
|
||||
*/
|
||||
export async function verifyJoinLinkToken(
|
||||
token: string
|
||||
): Promise<{ meetingUuid: string } | null> {
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, encodedSecret, {
|
||||
algorithms: ["HS256"],
|
||||
})
|
||||
|
||||
if (typeof payload.meetingUuid !== "string" || !payload.meetingUuid) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { meetingUuid: payload.meetingUuid }
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof joseErrors.JWTExpired ||
|
||||
error instanceof joseErrors.JWSSignatureVerificationFailed ||
|
||||
error instanceof joseErrors.JWTInvalid
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Any other unexpected verification failure: still fail closed.
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import "server-only"
|
||||
|
||||
import { AccessToken } from "livekit-server-sdk"
|
||||
|
||||
// Server-side LiveKit integration: minting access tokens for participants
|
||||
// to connect to the self-hosted LiveKit SFU (see LIVEKIT_URL/API_KEY/SECRET
|
||||
// in .env). Room name convention: the meeting's `uuid` is used directly as
|
||||
// the LiveKit room name (stable, unique, matches the original Laravel
|
||||
// version's approach — no separate mapping table needed).
|
||||
|
||||
const apiKey = process.env.LIVEKIT_API_KEY
|
||||
const apiSecret = process.env.LIVEKIT_API_SECRET
|
||||
const url = process.env.LIVEKIT_URL
|
||||
|
||||
if (!apiKey || !apiSecret || !url) {
|
||||
throw new Error(
|
||||
"LIVEKIT_URL / LIVEKIT_API_KEY / LIVEKIT_API_SECRET environment variables are not set. Add them to .env (see .env.example)."
|
||||
)
|
||||
}
|
||||
|
||||
export const LIVEKIT_URL = url
|
||||
|
||||
interface MintAccessTokenOptions {
|
||||
/** LiveKit room name — use the meeting's `uuid`. */
|
||||
roomName: string
|
||||
/** Stable per-participant identity, e.g. `user-{id}` or `guest-{uuid}`. */
|
||||
identity: string
|
||||
/** Shown to other participants as `Participant.name`. */
|
||||
displayName: string
|
||||
canPublish?: boolean
|
||||
canSubscribe?: boolean
|
||||
/** Host/co-host get room-admin grants; everyone else gets standard publish/subscribe. */
|
||||
isHost?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Mints a signed LiveKit access token (JWT) authorizing a participant to
|
||||
* join a room. This only requires the token's signing key/secret to match
|
||||
* the LiveKit server's configuration — it does not itself open a network
|
||||
* connection, so minting succeeds even if the actual WebSocket handshake
|
||||
* against the server would fail due to mismatched credentials.
|
||||
*/
|
||||
export async function mintAccessToken(
|
||||
options: MintAccessTokenOptions
|
||||
): Promise<string> {
|
||||
const at = new AccessToken(apiKey, apiSecret, {
|
||||
identity: options.identity,
|
||||
name: options.displayName,
|
||||
ttl: "6h",
|
||||
})
|
||||
|
||||
at.addGrant({
|
||||
room: options.roomName,
|
||||
roomJoin: true,
|
||||
roomAdmin: options.isHost ?? false,
|
||||
canPublish: options.canPublish ?? true,
|
||||
canSubscribe: options.canSubscribe ?? true,
|
||||
})
|
||||
|
||||
return at.toJwt()
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import "server-only"
|
||||
|
||||
import type { Meeting } from "@prisma/client"
|
||||
|
||||
import { prisma } from "@/lib/prisma"
|
||||
|
||||
/**
|
||||
* True if `userId` is the meeting's host, or an admitted co-host of it.
|
||||
* Used to gate host-only actions (listing/admitting/denying participants)
|
||||
* server-side — never trust a client-side role check alone.
|
||||
*/
|
||||
export async function isHostOrCoHost(
|
||||
meeting: Meeting,
|
||||
userId: number
|
||||
): Promise<boolean> {
|
||||
if (meeting.hostUserId === userId) {
|
||||
return true
|
||||
}
|
||||
|
||||
const participant = await prisma.meetingParticipant.findUnique({
|
||||
where: {
|
||||
meetingId_identity: {
|
||||
meetingId: meeting.id,
|
||||
identity: `user-${userId}`,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
!!participant &&
|
||||
participant.status === "admitted" &&
|
||||
(participant.role === "host" || participant.role === "co_host")
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { MeetingStatus } from "@prisma/client"
|
||||
|
||||
export const MEETING_STATUS_BADGE: Record<
|
||||
MeetingStatus,
|
||||
{ label: string; className: string }
|
||||
> = {
|
||||
active: {
|
||||
label: "Activa",
|
||||
className: "bg-(--meet-status-active-bg) text-(--meet-status-active-fg)",
|
||||
},
|
||||
scheduled: {
|
||||
label: "Programada",
|
||||
className:
|
||||
"bg-(--meet-status-scheduled-bg) text-(--meet-status-scheduled-fg)",
|
||||
},
|
||||
ended: {
|
||||
label: "Finalizada",
|
||||
className: "bg-(--meet-status-ended-bg) text-(--meet-status-ended-fg)",
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import "server-only"
|
||||
|
||||
import { cookies } from "next/headers"
|
||||
|
||||
import { auth } from "@/auth"
|
||||
|
||||
// Resolves "who is looking at this meeting" without trusting anything the
|
||||
// client sends directly: an authenticated user's identity is derived from
|
||||
// their session, and an anonymous guest's identity is derived from an
|
||||
// httpOnly cookie set server-side after they submit the name-entry form
|
||||
// (see /api/meetings/[meetingUuid]/join-request). Neither path lets the
|
||||
// caller spoof another participant's identity.
|
||||
|
||||
export function guestIdentityCookieName(meetingUuid: string): string {
|
||||
return `meet-guest-${meetingUuid}`
|
||||
}
|
||||
|
||||
// Matches the join-link token grace period (src/lib/join-link.ts) so a
|
||||
// guest's identity cookie doesn't outlive the link that let them in.
|
||||
export const GUEST_IDENTITY_COOKIE_MAX_AGE_SECONDS = 6 * 60 * 60
|
||||
|
||||
export type ViewerIdentity =
|
||||
| {
|
||||
kind: "user"
|
||||
identity: string
|
||||
userId: number
|
||||
displayName: string
|
||||
}
|
||||
| {
|
||||
kind: "guest"
|
||||
identity: string
|
||||
}
|
||||
|
||||
export async function resolveViewerIdentity(
|
||||
meetingUuid: string
|
||||
): Promise<ViewerIdentity | null> {
|
||||
const session = await auth()
|
||||
|
||||
if (session?.user?.id) {
|
||||
return {
|
||||
kind: "user",
|
||||
identity: `user-${session.user.id}`,
|
||||
userId: Number(session.user.id),
|
||||
displayName: session.user.displayName ?? session.user.email ?? "Usuario",
|
||||
}
|
||||
}
|
||||
|
||||
const cookieStore = await cookies()
|
||||
const guestIdentity = cookieStore.get(
|
||||
guestIdentityCookieName(meetingUuid)
|
||||
)?.value
|
||||
|
||||
if (guestIdentity) {
|
||||
return { kind: "guest", identity: guestIdentity }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const guestNameSchema = z.object({
|
||||
guestName: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "El nombre es obligatorio")
|
||||
.max(80, "El nombre es demasiado largo"),
|
||||
})
|
||||
|
||||
export type GuestNameInput = z.infer<typeof guestNameSchema>
|
||||
@@ -0,0 +1,19 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const scheduledMeetingSchema = z.object({
|
||||
title: z
|
||||
.string()
|
||||
.min(1, "El título es obligatorio")
|
||||
.max(200, "El título es demasiado largo"),
|
||||
scheduledAt: z
|
||||
.string()
|
||||
.min(1, "La fecha y hora son obligatorias")
|
||||
.refine((value) => !Number.isNaN(new Date(value).getTime()), {
|
||||
message: "Fecha y hora inválidas",
|
||||
})
|
||||
.refine((value) => new Date(value).getTime() > Date.now(), {
|
||||
message: "La fecha debe ser en el futuro",
|
||||
}),
|
||||
})
|
||||
|
||||
export type ScheduledMeetingInput = z.infer<typeof scheduledMeetingSchema>
|
||||
Reference in new issue
Block a user