Compare commits

...
2 Commits
Author SHA1 Message Date
WilandClaude Sonnet 5 3c19455bdd feat: add LiveKit join flow with waiting room and host admission
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-29 12:44:33 -05:00
WilandClaude Sonnet 5 e26cfe624d feat: add meeting creation and dashboard with join links
Adds the management/CRUD layer for meetings: a signed JWT join-link
token service (jose), instant/scheduled meeting creation endpoints,
and a protected /dashboard page listing the host's active, upcoming,
and past meetings with a "Nueva reunión" action area (instant button +
scheduling dialog). Also fixes a Base UI console warning (missing
nativeButton={false}) on Button instances rendered as Link.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-29 11:57:42 -05:00
28 changed files with 2082 additions and 1 deletions

No files matched your search

+51
View File
@@ -7,11 +7,16 @@
"dependencies": { "dependencies": {
"@base-ui/react": "^1.9.0", "@base-ui/react": "^1.9.0",
"@hookform/resolvers": "^6.0.2", "@hookform/resolvers": "^6.0.2",
"@livekit/components-react": "^2.9.24",
"@livekit/components-styles": "^1.2.0",
"@prisma/adapter-mariadb": "^7.10.1", "@prisma/adapter-mariadb": "^7.10.1",
"@prisma/client": "^7.10.1", "@prisma/client": "^7.10.1",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"cn": "^0.5.0", "cn": "^0.5.0",
"jose": "^6.2.14",
"livekit-client": "^2.23.0",
"livekit-server-sdk": "^2.19.2",
"lucide-react": "^1.64.0", "lucide-react": "^1.64.0",
"next": "16.4.3", "next": "16.4.3",
"next-auth": "^5.0.0-beta.32", "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=="], "@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/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=="], "@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=="], "@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/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=="], "@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/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/estree": ["@types/estree@1.0.10", "", {}, "sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g=="],
"@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="], "@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=="], "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=="], "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=="], "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=="], "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=="], "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": ["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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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-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=="], "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=="], "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=="], "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=="], "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-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": ["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=="], "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=="], "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=="], "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"valibot": ["valibot@1.2.0", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg=="], "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=="], "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": ["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=="], "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=="], "@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/@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=="], "@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=="], "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/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=="], "log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="],
+5
View File
@@ -11,11 +11,16 @@
"dependencies": { "dependencies": {
"@base-ui/react": "^1.9.0", "@base-ui/react": "^1.9.0",
"@hookform/resolvers": "^6.0.2", "@hookform/resolvers": "^6.0.2",
"@livekit/components-react": "^2.9.24",
"@livekit/components-styles": "^1.2.0",
"@prisma/adapter-mariadb": "^7.10.1", "@prisma/adapter-mariadb": "^7.10.1",
"@prisma/client": "^7.10.1", "@prisma/client": "^7.10.1",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"cn": "^0.5.0", "cn": "^0.5.0",
"jose": "^6.2.14",
"livekit-client": "^2.23.0",
"livekit-server-sdk": "^2.19.2",
"lucide-react": "^1.64.0", "lucide-react": "^1.64.0",
"next": "16.4.3", "next": "16.4.3",
"next-auth": "^5.0.0-beta.32", "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(),
})),
})
}
+42
View File
@@ -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 }
)
}
+63
View File
@@ -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 }
)
}
+84
View File
@@ -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}
/>
)
}
+12
View File
@@ -14,6 +14,12 @@
--color-meet-bg-mint-start: var(--meet-bg-mint-start); --color-meet-bg-mint-start: var(--meet-bg-mint-start);
--color-meet-bg-mint-end: var(--meet-bg-mint-end); --color-meet-bg-mint-end: var(--meet-bg-mint-end);
--color-meet-focus-ring: var(--meet-focus-ring); --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-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
--font-mono: var(--font-geist-mono); --font-mono: var(--font-geist-mono);
--font-heading: var(--font-sans); --font-heading: var(--font-sans);
@@ -63,6 +69,12 @@
--meet-bg-mint-start: #EAF7F4; --meet-bg-mint-start: #EAF7F4;
--meet-bg-mint-end: #F2FBF0; --meet-bg-mint-end: #F2FBF0;
--meet-focus-ring: #2BA8C4; --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: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0); --card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0); --popover: oklch(1 0 0);
+217
View File
@@ -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>
)
}
+5 -1
View File
@@ -97,7 +97,11 @@ export default function RegisterPage() {
</a> </a>
</p> </p>
) : null} ) : null}
<Button className="w-full" render={<Link href="/login" />}> <Button
className="w-full"
nativeButton={false}
render={<Link href="/login" />}
>
Ir a iniciar sesión Ir a iniciar sesión
</Button> </Button>
</div> </div>
+118
View File
@@ -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>
)
}
+58
View File
@@ -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>
)
}
+13
View File
@@ -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
}
+207
View File
@@ -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>
)
}
+153
View File
@@ -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>
)
}
+107
View File
@@ -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>
)
}
+87
View File
@@ -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
}
}
+61
View File
@@ -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()
}
+34
View File
@@ -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")
)
}
+20
View File
@@ -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)",
},
}
+58
View File
@@ -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
}
+11
View File
@@ -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>
+19
View File
@@ -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>