From 0bf3b1f7969b4058b17d5d1305fab9a288631e0f Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sat, 11 Oct 2025 14:13:36 +0200 Subject: [PATCH 01/17] Override form-data --- ui/package.json | 5 +++++ ui/pnpm-lock.yaml | 16 ++++++++++------ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/ui/package.json b/ui/package.json index 8d1ed7b..a4adb26 100644 --- a/ui/package.json +++ b/ui/package.json @@ -86,5 +86,10 @@ "ts-pattern": "^5.6.2", "uuid": "^11.1.0", "zustand": "^5.0.5" + }, + "pnpm": { + "overrides": { + "form-data": "4.0.4" + } } } diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 5c0ea9a..d30c100 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + form-data: 4.0.4 + importers: .: @@ -3161,8 +3164,8 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} - form-data@4.0.2: - resolution: {integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==} + form-data@4.0.4: + resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} engines: {node: '>= 6'} fs.realpath@1.0.0: @@ -8193,7 +8196,7 @@ snapshots: axios@1.8.4: dependencies: follow-redirects: 1.15.9 - form-data: 4.0.2 + form-data: 4.0.4 proxy-from-env: 1.1.0 transitivePeerDependencies: - debug @@ -8988,11 +8991,12 @@ snapshots: dependencies: is-callable: 1.2.7 - form-data@4.0.2: + form-data@4.0.4: dependencies: asynckit: 0.4.0 combined-stream: 1.0.8 es-set-tostringtag: 2.1.0 + hasown: 2.0.2 mime-types: 2.1.35 fs.realpath@1.0.0: {} @@ -9827,7 +9831,7 @@ snapshots: decimal.js: 10.5.0 domexception: 4.0.0 escodegen: 2.1.0 - form-data: 4.0.2 + form-data: 4.0.4 html-encoding-sniffer: 3.0.0 http-proxy-agent: 5.0.0 https-proxy-agent: 5.0.1 @@ -11291,7 +11295,7 @@ snapshots: '@types/ws': 8.18.0 axios: 1.8.4 base64-js: 1.5.1 - form-data: 4.0.2 + form-data: 4.0.4 isomorphic-ws: 5.0.0(ws@8.18.1) jsonwebtoken: 9.0.2 linkifyjs: 4.3.1 From 7673d2bea198fa81e89573faac53088ab3c6ce90 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sat, 11 Oct 2025 14:20:57 +0200 Subject: [PATCH 02/17] Fix vuln --- ui/package.json | 9 +-- ui/pnpm-lock.yaml | 136 +++++++++++++++++++--------------------------- 2 files changed, 61 insertions(+), 84 deletions(-) diff --git a/ui/package.json b/ui/package.json index a4adb26..b814973 100644 --- a/ui/package.json +++ b/ui/package.json @@ -75,11 +75,11 @@ "@typescript/native-preview": "7.0.0-dev.20251010.1", "ag-grid-community": "^33.2.1", "ag-grid-react": "^33.2.1", - "axios": "^1.8.4", + "axios": "^1.12.2", "date-fns": "^4.1.0", - "jspdf": "^3.0.1", + "jspdf": "^3.0.3", "jwt-decode": "^4.0.0", - "react-router-dom": "^7.3.0", + "react-router-dom": "^7.9.4", "react-stately": "^3.36.1", "stream-chat": "^9.6.1", "stream-chat-react": "^13.1.0", @@ -89,7 +89,8 @@ }, "pnpm": { "overrides": { - "form-data": "4.0.4" + "form-data": "^4.0.4", + "linkifyjs": "^4.3.2" } } } diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index d30c100..d4bce5c 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -5,7 +5,8 @@ settings: excludeLinksFromLockfile: false overrides: - form-data: 4.0.4 + form-data: ^4.0.4 + linkifyjs: ^4.3.2 importers: @@ -16,7 +17,7 @@ importers: version: 6.13.0 '@datadog/browser-rum-react': specifier: ^6.13.0 - version: 6.13.0(@datadog/browser-rum@6.13.0)(react-router-dom@7.3.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0) + version: 6.13.0(@datadog/browser-rum@6.13.0)(react-router-dom@7.9.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0) '@react-stately/calendar': specifier: ^3.7.1 version: 3.7.1(react@19.0.0) @@ -48,14 +49,14 @@ importers: specifier: ^4.1.0 version: 4.1.0 jspdf: - specifier: ^3.0.1 - version: 3.0.1 + specifier: ^3.0.3 + version: 3.0.3 jwt-decode: specifier: ^4.0.0 version: 4.0.0 react-router-dom: - specifier: ^7.3.0 - version: 7.3.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + specifier: ^7.9.4 + version: 7.9.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0) react-stately: specifier: ^3.36.1 version: 3.36.1(react@19.0.0) @@ -64,7 +65,7 @@ importers: version: 9.6.1 stream-chat-react: specifier: ^13.1.0 - version: 13.1.0(@types/react@19.0.10)(jquery@3.7.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(stream-chat@9.6.1)(typescript@5.7.3) + version: 13.1.0(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(stream-chat@9.6.1)(typescript@5.7.3) ts-pattern: specifier: ^5.6.2 version: 5.6.2 @@ -2074,9 +2075,6 @@ packages: '@types/chai@5.2.2': resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==} - '@types/cookie@0.6.0': - resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} - '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} @@ -2140,6 +2138,9 @@ packages: '@types/node@22.13.10': resolution: {integrity: sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==} + '@types/pako@2.0.4': + resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==} + '@types/phoenix@1.6.6': resolution: {integrity: sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==} @@ -2498,11 +2499,6 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - atob@2.1.2: - resolution: {integrity: sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==} - engines: {node: '>= 4.5.0'} - hasBin: true - attr-accept@2.2.5: resolution: {integrity: sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==} engines: {node: '>=4'} @@ -2576,11 +2572,6 @@ packages: bser@2.1.1: resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} - btoa@1.2.1: - resolution: {integrity: sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==} - engines: {node: '>= 0.4.0'} - hasBin: true - buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} @@ -3104,6 +3095,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-png@6.4.0: + resolution: {integrity: sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==} + fastq@1.19.0: resolution: {integrity: sha512-7SFSRCNjBQIZH/xZR3iy5iQYR8aGBE0h3VG6/cwlbrpdciNYBMotQav8c1XI3HjHH+NikUpP53nPdlZSdWmFzA==} @@ -3400,6 +3394,9 @@ packages: intl-messageformat@10.7.15: resolution: {integrity: sha512-LRyExsEsefQSBjU2p47oAheoKz+EOJxSLDdjOaEjdriajfHsMXOmV/EhMvYSg9bAgCUHasuAC+mcUBe/95PfIg==} + iobuffer@5.4.0: + resolution: {integrity: sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==} + is-alphabetical@1.0.4: resolution: {integrity: sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg==} @@ -3743,9 +3740,6 @@ packages: resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==} hasBin: true - jquery@3.7.1: - resolution: {integrity: sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==} - js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -3795,8 +3789,8 @@ packages: resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} engines: {node: '>=12', npm: '>=6'} - jspdf@3.0.1: - resolution: {integrity: sha512-qaGIxqxetdoNnFQQXxTKUD9/Z7AloLaw94fFsOiJMxbfYdBbrBuhWmbzI8TVjrw7s3jBY1PFHofBKMV/wZPapg==} + jspdf@3.0.3: + resolution: {integrity: sha512-eURjAyz5iX1H8BOYAfzvdPfIKK53V7mCpBTe7Kb16PaM8JSXEcUQNBQaiWMI8wY5RvNOPj4GccMjTlfwRBd+oQ==} jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} @@ -3898,15 +3892,8 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - linkifyjs@2.1.9: - resolution: {integrity: sha512-74ivurkK6WHvHFozVaGtQWV38FzBwSTGNmJolEgFp7QgR2bl6ArUWlvT4GcHKbPe1z3nWYi+VUdDZk16zDOVug==} - peerDependencies: - jquery: '>= 1.11.0' - react: '>= 0.14.0' - react-dom: '>= 0.14.0' - - linkifyjs@4.3.1: - resolution: {integrity: sha512-DRSlB9DKVW04c4SUdGvKK5FR6be45lTU9M76JnngqPeeGDqPwYc0zdUErtsNVMtxPXgUWV4HbXbnC4sNyBxkYg==} + linkifyjs@4.3.2: + resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==} load-script@1.0.0: resolution: {integrity: sha512-kPEjMFtZvwL9TaZo0uZ2ml+Ye9HUMmPwbYRJ324qF9tqMejwykJ5ggTyvzmrbBeapCAbk98BSbTeovHEEP1uCA==} @@ -4302,6 +4289,9 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} + pako@2.1.0: + resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -4550,15 +4540,15 @@ packages: resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} engines: {node: '>=0.10.0'} - react-router-dom@7.3.0: - resolution: {integrity: sha512-z7Q5FTiHGgQfEurX/FBinkOXhWREJIAB2RiU24lvcBa82PxUpwqvs/PAXb9lJyPjTs2jrl6UkLvCZVGJPeNuuQ==} + react-router-dom@7.9.4: + resolution: {integrity: sha512-f30P6bIkmYvnHHa5Gcu65deIXoA2+r3Eb6PJIAddvsT9aGlchMatJ51GgpU470aSqRRbFX22T70yQNUGuW3DfA==} engines: {node: '>=20.0.0'} peerDependencies: react: '>=18' react-dom: '>=18' - react-router@7.3.0: - resolution: {integrity: sha512-466f2W7HIWaNXTKM5nHTqNxLrHTyXybm7R0eBlVSt0k/u55tTCDO194OIx/NrYD4TS5SXKTNekXfT37kMKUjgw==} + react-router@7.9.4: + resolution: {integrity: sha512-SD3G8HKviFHg9xj7dNODUKDFgpG4xqD5nhyd0mYoB5iISepuZAvzSr8ywxgxKJ52yRzf/HWtVHc9AWwoTbljvA==} engines: {node: '>=20.0.0'} peerDependencies: react: '>=18' @@ -5023,9 +5013,6 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - turbo-stream@2.4.0: - resolution: {integrity: sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g==} - type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -5753,14 +5740,14 @@ snapshots: dependencies: '@datadog/browser-core': 6.13.0 - '@datadog/browser-rum-react@6.13.0(@datadog/browser-rum@6.13.0)(react-router-dom@7.3.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)': + '@datadog/browser-rum-react@6.13.0(@datadog/browser-rum@6.13.0)(react-router-dom@7.9.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)': dependencies: '@datadog/browser-core': 6.13.0 '@datadog/browser-rum-core': 6.13.0 optionalDependencies: '@datadog/browser-rum': 6.13.0 react: 19.0.0 - react-router-dom: 7.3.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + react-router-dom: 7.9.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@datadog/browser-rum@6.13.0': dependencies: @@ -7672,8 +7659,6 @@ snapshots: dependencies: '@types/deep-eql': 4.0.2 - '@types/cookie@0.6.0': {} - '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 @@ -7750,6 +7735,8 @@ snapshots: dependencies: undici-types: 6.20.0 + '@types/pako@2.0.4': {} + '@types/phoenix@1.6.6': {} '@types/raf@3.4.3': @@ -8185,8 +8172,6 @@ snapshots: asynckit@0.4.0: {} - atob@2.1.2: {} - attr-accept@2.2.5: {} available-typed-arrays@1.0.7: @@ -8294,8 +8279,6 @@ snapshots: dependencies: node-int64: 0.4.0 - btoa@1.2.1: {} - buffer-equal-constant-time@1.0.1: {} buffer-from@1.1.2: {} @@ -8329,7 +8312,7 @@ snapshots: canvg@3.0.11: dependencies: - '@babel/runtime': 7.27.0 + '@babel/runtime': 7.27.6 '@types/raf': 3.4.3 core-js: 3.41.0 raf: 3.4.1 @@ -8940,6 +8923,12 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-png@6.4.0: + dependencies: + '@types/pako': 2.0.4 + iobuffer: 5.4.0 + pako: 2.1.0 + fastq@1.19.0: dependencies: reusify: 1.0.4 @@ -9264,6 +9253,8 @@ snapshots: '@formatjs/icu-messageformat-parser': 2.11.1 tslib: 2.8.1 + iobuffer@5.4.0: {} + is-alphabetical@1.0.4: optional: true @@ -9804,9 +9795,6 @@ snapshots: jiti@2.4.2: {} - jquery@3.7.1: - optional: true - js-tokens@4.0.0: {} js-tokens@9.0.1: {} @@ -9878,11 +9866,10 @@ snapshots: ms: 2.1.3 semver: 7.7.1 - jspdf@3.0.1: + jspdf@3.0.3: dependencies: - '@babel/runtime': 7.27.0 - atob: 2.1.2 - btoa: 1.2.1 + '@babel/runtime': 7.27.6 + fast-png: 6.4.0 fflate: 0.8.2 optionalDependencies: canvg: 3.0.11 @@ -9972,14 +9959,7 @@ snapshots: lines-and-columns@1.2.4: {} - linkifyjs@2.1.9(jquery@3.7.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0): - dependencies: - jquery: 3.7.1 - react: 19.0.0 - react-dom: 19.0.0(react@19.0.0) - optional: true - - linkifyjs@4.3.1: {} + linkifyjs@4.3.2: {} load-script@1.0.0: {} @@ -10481,21 +10461,20 @@ snapshots: dependencies: brace-expansion: 2.0.1 - mml-react@0.4.7(@types/react@19.0.10)(jquery@3.7.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0): + mml-react@0.4.7(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: '@braintree/sanitize-url': 6.0.4 '@rgrove/parse-xml': 3.0.0 '@types/linkifyjs': 2.1.7 dayjs: 1.11.13 ical-expander: 3.1.0 - linkifyjs: 2.1.9(jquery@3.7.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + linkifyjs: 4.3.2 react: 19.0.0 react-dom: 19.0.0(react@19.0.0) react-markdown: 5.0.3(@types/react@19.0.10)(react@19.0.0) react-virtuoso: 2.19.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0) transitivePeerDependencies: - '@types/react' - - jquery - supports-color optional: true @@ -10602,6 +10581,8 @@ snapshots: p-try@2.2.0: {} + pako@2.1.0: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -10885,19 +10866,17 @@ snapshots: react-refresh@0.14.2: {} - react-router-dom@7.3.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0): + react-router-dom@7.9.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: react: 19.0.0 react-dom: 19.0.0(react@19.0.0) - react-router: 7.3.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + react-router: 7.9.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0) - react-router@7.3.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0): + react-router@7.9.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: - '@types/cookie': 0.6.0 cookie: 1.0.2 react: 19.0.0 set-cookie-parser: 2.7.1 - turbo-stream: 2.4.0 optionalDependencies: react-dom: 19.0.0(react@19.0.0) @@ -11246,7 +11225,7 @@ snapshots: stoppable@1.1.0: {} - stream-chat-react@13.1.0(@types/react@19.0.10)(jquery@3.7.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(stream-chat@9.6.1)(typescript@5.7.3): + stream-chat-react@13.1.0(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(stream-chat@9.6.1)(typescript@5.7.3): dependencies: '@braintree/sanitize-url': 6.0.4 '@popperjs/core': 2.11.8 @@ -11257,7 +11236,7 @@ snapshots: fix-webm-duration: 1.0.6 hast-util-find-and-replace: 5.0.1 i18next: 25.2.1(typescript@5.7.3) - linkifyjs: 4.3.1 + linkifyjs: 4.3.2 lodash.debounce: 4.0.8 lodash.defaultsdeep: 4.6.1 lodash.mergewith: 4.6.2 @@ -11282,10 +11261,9 @@ snapshots: use-sync-external-store: 1.4.0(react@19.0.0) optionalDependencies: '@stream-io/transliterate': 1.5.5 - mml-react: 0.4.7(@types/react@19.0.10)(jquery@3.7.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + mml-react: 0.4.7(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) transitivePeerDependencies: - '@types/react' - - jquery - supports-color - typescript @@ -11298,7 +11276,7 @@ snapshots: form-data: 4.0.4 isomorphic-ws: 5.0.0(ws@8.18.1) jsonwebtoken: 9.0.2 - linkifyjs: 4.3.1 + linkifyjs: 4.3.2 ws: 8.18.1 transitivePeerDependencies: - bufferutil @@ -11488,8 +11466,6 @@ snapshots: tslib@2.8.1: {} - turbo-stream@2.4.0: {} - type-check@0.4.0: dependencies: prelude-ls: 1.2.1 From c12326fe5e38e51571264ea70ecf3d377795949f Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sat, 11 Oct 2025 14:26:36 +0200 Subject: [PATCH 03/17] Fix versions --- api/package-lock.json | 6 +++--- api/package.json | 5 ++++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/api/package-lock.json b/api/package-lock.json index 87b5202..b20dcf9 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -4149,9 +4149,9 @@ } }, "node_modules/nodemailer": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.4.tgz", - "integrity": "sha512-9O00Vh89/Ld2EcVCqJ/etd7u20UhME0f/NToPfArwPEe1Don1zy4mAIz6ariRr7mJ2RDxtaDzN0WJVdVXPtZaw==", + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.9.tgz", + "integrity": "sha512-9/Qm0qXIByEP8lEV2qOqcAW7bRpL8CR9jcTwk3NBnHJNmP9fIJ86g2fgmIXqHY+nj55ZEMwWqYAT2QTDpRUYiQ==", "engines": { "node": ">=6.0.0" } diff --git a/api/package.json b/api/package.json index 9a5d88c..5cd3dea 100644 --- a/api/package.json +++ b/api/package.json @@ -38,5 +38,8 @@ "sinon": "^17.0.0", "tsx": "^4.7.1", "typescript": "^5.8.3" - } + }, + "overrides": { + "linkifyjs": "^4.3.2" + } } From 75b1bab6401a5b44d2b10721f599576bb717f0ba Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sun, 12 Oct 2025 11:31:41 +0200 Subject: [PATCH 04/17] Cleanup --- api/src/__tests__/README.md | 222 ----- api/src/__tests__/helpers.test.ts | 426 --------- api/src/__tests__/middleware.test.ts | 179 ---- api/src/__tests__/public.test.ts | 509 ---------- api/src/__tests__/tablo.test.ts | 1287 -------------------------- api/src/__tests__/tablo_data.test.ts | 497 ---------- api/src/__tests__/tasks.test.ts | 184 ---- api/src/__tests__/test-utils.ts | 203 ---- api/src/__tests__/user.test.ts | 337 ------- api/src/tablo.ts | 84 +- api/src/tablo_data.ts | 6 +- api/src/transporter.ts | 23 +- api/src/user.ts | 2 +- 13 files changed, 82 insertions(+), 3877 deletions(-) delete mode 100644 api/src/__tests__/README.md delete mode 100644 api/src/__tests__/helpers.test.ts delete mode 100644 api/src/__tests__/middleware.test.ts delete mode 100644 api/src/__tests__/public.test.ts delete mode 100644 api/src/__tests__/tablo.test.ts delete mode 100644 api/src/__tests__/tablo_data.test.ts delete mode 100644 api/src/__tests__/tasks.test.ts delete mode 100644 api/src/__tests__/test-utils.ts delete mode 100644 api/src/__tests__/user.test.ts diff --git a/api/src/__tests__/README.md b/api/src/__tests__/README.md deleted file mode 100644 index fe5c1f1..0000000 --- a/api/src/__tests__/README.md +++ /dev/null @@ -1,222 +0,0 @@ -# API Test Suite - -This directory contains comprehensive tests for the XTablo API, covering all endpoints and their functionality. - -## Test Files - -### 1. `test-utils.ts` - -Provides testing utilities and mock factories: - -- **Mock Clients**: Supabase, Stream Chat, S3, Email Transporter -- **Mock Data**: Users, Profiles, Tablos, Events -- **Helper Functions**: Context creation, stub management, assertions -- **Environment Setup**: Mock environment variables for tests - -### 2. `middleware.test.ts` - -Tests for API middleware: - -- **authMiddleware**: Bearer token authentication -- **supabaseMiddleware**: Supabase client initialization -- **streamChatMiddleware**: Stream Chat client initialization -- **r2Middleware**: S3/R2 client initialization - -### 3. `user.test.ts` - -Tests for User Router (`/api/v1/users`): - -- **POST /sign-up-to-stream**: User registration with Stream Chat -- **GET /me**: Retrieve user profile with Stream token -- **POST /mark-temporary**: Mark user as temporary and send welcome email - -### 4. `tablo.test.ts` - -Tests for Tablo Router (`/api/v1/tablos`): - -- **POST /create**: Create new tablo with events -- **POST /create-and-invite**: Create tablo and invite user -- **PATCH /update**: Update tablo details -- **DELETE /delete**: Soft delete tablo -- **POST /invite**: Send tablo invitation -- **POST /join**: Join tablo with invite token -- **GET /members/:tablo_id**: Get tablo members -- **POST /leave**: Leave a tablo -- **POST /webcal/generate-url**: Generate webcal subscription URL - -### 5. `tablo_data.test.ts` - -Tests for Tablo Data Router (`/api/v1/tablo-data`): - -- **GET /:tabloId/filenames**: List files in tablo -- **GET /:tabloId/:fileName**: Get file content -- **POST /:tabloId/:fileName**: Upload/update file -- **DELETE /:tabloId/:fileName**: Delete file - -### 6. `tasks.test.ts` - -Tests for Tasks Router (`/api/v1/tasks`): - -- **POST /sync-calendars**: Sync calendar subscriptions (with authentication) - -### 7. `public.test.ts` - -Tests for Public Router (`/api/public`): - -- **GET /slots/:shortUserId/:standardName**: Get available time slots for booking - -### 8. `helpers.test.ts` - -Tests for helper functions: - -- **generateICSFromEvents**: Generate ICS calendar files -- **writeCalendarFileToR2**: Write calendar to R2 storage -- **isTabloMember**: Check if user is tablo member -- **isTabloAdmin**: Check if user is tablo admin -- **getTabloFileNames**: Get list of files in tablo - -### 9. `slots.test.ts` - -Tests for slot generation logic (existing): - -- Time slot generation with various configurations -- Exception handling -- Event conflicts -- Buffer time -- Minimum advance booking -- Maximum bookings per day - -## Running Tests - -### Run all tests: - -```bash -npm test -``` - -### Run tests in watch mode: - -```bash -npm run test:watch -``` - -### Run specific test file: - -```bash -npx mocha src/__tests__/user.test.ts -``` - -## Test Coverage - -The test suite covers: - -1. **Authentication & Authorization** - - - Token validation - - User authentication - - Admin/member access control - -2. **CRUD Operations** - - - Create, read, update, delete for all entities - - Soft deletes - - Batch operations - -3. **Business Logic** - - - Tablo invitations and access control - - Calendar generation and synchronization - - File storage and retrieval - - Time slot availability calculation - -4. **Error Handling** - - - Missing required fields - - Invalid tokens - - Permission denied scenarios - - Database errors - - External service failures (S3, Stream Chat) - -5. **Integration Points** - - Supabase database operations - - Stream Chat channel management - - R2/S3 file operations - - Email sending - -## Testing Framework - -- **Test Runner**: Mocha -- **Assertions**: Chai -- **Mocking**: Sinon -- **Test Style**: BDD (Behavior Driven Development) - -## Test Structure - -Each test file follows this structure: - -```typescript -describe("Feature/Router Name", () => { - beforeEach(() => { - // Setup mocks and environment - }); - - afterEach(() => { - // Clean up stubs and restore environment - }); - - describe("Endpoint/Function Name", () => { - it("should handle success case", async () => { - // Arrange: Setup test data and mocks - // Act: Execute the function/endpoint - // Assert: Verify the results - }); - - it("should handle error case", async () => { - // Test error scenarios - }); - }); -}); -``` - -## Mock Strategy - -Tests use comprehensive mocking to isolate units under test: - -1. **Supabase Client**: Mocked query builder pattern -2. **Stream Chat**: Mocked channel operations -3. **S3 Client**: Mocked storage operations -4. **Email Transporter**: Mocked email sending - -This ensures tests run quickly and don't depend on external services. - -## Best Practices - -1. **Isolation**: Each test is independent and doesn't affect others -2. **Clarity**: Test names clearly describe what is being tested -3. **Coverage**: Both happy paths and error cases are tested -4. **Maintainability**: Shared utilities reduce code duplication -5. **Speed**: Mocking ensures tests run in milliseconds - -## Future Improvements - -- Integration tests with real database -- End-to-end API tests -- Performance benchmarks -- Load testing -- Code coverage reporting - -## Contributing - -When adding new endpoints or functionality: - -1. Create tests first (TDD approach recommended) -2. Follow existing test patterns -3. Mock external dependencies -4. Test both success and failure scenarios -5. Ensure tests pass before committing - -## Notes - -- Some lint warnings for `any` types are suppressed with `biome-ignore` comments - these are intentional for test flexibility -- Mock data is defined in `test-utils.ts` for consistency -- Environment variables are mocked in each test file's `beforeEach` hook diff --git a/api/src/__tests__/helpers.test.ts b/api/src/__tests__/helpers.test.ts deleted file mode 100644 index 85274d6..0000000 --- a/api/src/__tests__/helpers.test.ts +++ /dev/null @@ -1,426 +0,0 @@ -import { expect } from "chai"; -import { afterEach, beforeEach, describe, it } from "mocha"; -import sinon from "sinon"; -import { - generateICSFromEvents, - getTabloFileNames, - isTabloAdmin, - isTabloMember, - writeCalendarFileToR2, -} from "../helpers.js"; -import type { EventAndTablo } from "../types.js"; -import { - createMockS3Client, - createMockSupabaseClient, - mockEnvVars, - mockEvent, - mockTablo, - mockUser, -} from "./test-utils.js"; - -describe("Helper Functions", () => { - // biome-ignore lint/suspicious/noExplicitAny: Mock client types - let mockSupabase: any; - // biome-ignore lint/suspicious/noExplicitAny: Mock client types - let mockS3: any; - let restoreEnv: () => void; - - beforeEach(() => { - restoreEnv = mockEnvVars(); - mockSupabase = createMockSupabaseClient(); - mockS3 = createMockS3Client(); - }); - - afterEach(() => { - sinon.restore(); - restoreEnv(); - }); - - describe("generateICSFromEvents", () => { - it("should generate valid ICS content from events", () => { - const events: EventAndTablo[] = [ - { - event_id: "event1", - tablo_id: "tablo1", - tablo_name: "Test Tablo", - tablo_color: "bg-blue-500", - tablo_status: "todo", - title: "Test Event", - description: "Test description", - start_date: "2024-01-16", - start_time: "10:00:00", - end_time: "11:00:00", - // created_by: mockUser.id, - // created_at: "2024-01-01T00:00:00Z", - // deleted_at: null, - }, - ]; - - const icsContent = generateICSFromEvents(events, "Test Calendar"); - - expect(icsContent).to.include("BEGIN:VCALENDAR"); - expect(icsContent).to.include("VERSION:2.0"); - expect(icsContent).to.include("X-WR-CALNAME:Test Calendar"); - expect(icsContent).to.include("BEGIN:VEVENT"); - expect(icsContent).to.include("SUMMARY:Test Event"); - expect(icsContent).to.include("DESCRIPTION:Tablo: Test Tablo"); - expect(icsContent).to.include("END:VEVENT"); - expect(icsContent).to.include("END:VCALENDAR"); - }); - - it("should handle events without end_time", () => { - const events: EventAndTablo[] = [ - { - event_id: "event1", - tablo_id: "tablo1", - tablo_name: "Test Tablo", - tablo_color: "bg-blue-500", - tablo_status: "todo", - title: "Test Event", - description: null, - start_date: "2024-01-16", - start_time: "10:00:00", - end_time: null, - created_by: mockUser.id, - created_at: "2024-01-01T00:00:00Z", - deleted_at: null, - // biome-ignore lint/suspicious/noExplicitAny: Mock event with null end_time - } as any, - ]; - - const icsContent = generateICSFromEvents(events, "Test Calendar"); - - expect(icsContent).to.include("BEGIN:VEVENT"); - expect(icsContent).to.include("SUMMARY:Test Event"); - expect(icsContent).to.include("END:VEVENT"); - }); - - it("should escape special characters in ICS text", () => { - const events: EventAndTablo[] = [ - { - event_id: "event1", - tablo_id: "tablo1", - tablo_name: "Test; Tablo,", - tablo_color: "bg-blue-500", - tablo_status: "todo", - title: "Test; Event,", - description: "Test\\description\nwith newline", - start_date: "2024-01-16", - start_time: "10:00:00", - end_time: "11:00:00", - // created_by: mockUser.id, - // created_at: "2024-01-01T00:00:00Z", - // deleted_at: null, - }, - ]; - - const icsContent = generateICSFromEvents(events, "Test Calendar"); - - expect(icsContent).to.include("SUMMARY:Test\\; Event\\,"); - expect(icsContent).to.include( - "DESCRIPTION:Tablo: Test\\; Tablo\\,\\nTest\\\\description\\nwith newline" - ); - }); - - it("should skip events without required fields", () => { - const events: EventAndTablo[] = [ - { - event_id: "event1", - tablo_id: "tablo1", - tablo_name: "Test Tablo", - tablo_color: "bg-blue-500", - tablo_status: "todo", - // biome-ignore lint/suspicious/noExplicitAny: Testing null title case - title: null as any, - description: null, - start_date: "2024-01-16", - start_time: "10:00:00", - end_time: "11:00:00", - // created_by: mockUser.id, - // created_at: "2024-01-01T00:00:00Z", - // deleted_at: null, - }, - ]; - - const icsContent = generateICSFromEvents(events, "Test Calendar"); - - expect(icsContent).to.include("BEGIN:VCALENDAR"); - expect(icsContent).to.not.include("BEGIN:VEVENT"); - expect(icsContent).to.include("END:VCALENDAR"); - }); - - it("should handle multiple events", () => { - const events: EventAndTablo[] = [ - { - event_id: "event1", - tablo_id: "tablo1", - tablo_name: "Test Tablo", - tablo_color: "bg-blue-500", - tablo_status: "todo", - title: "Event 1", - description: "Description 1", - start_date: "2024-01-16", - start_time: "10:00:00", - end_time: "11:00:00", - // created_by: mockUser.id, - // created_at: "2024-01-01T00:00:00Z", - // deleted_at: null, - }, - { - event_id: "event2", - tablo_id: "tablo1", - tablo_name: "Test Tablo", - tablo_color: "bg-blue-500", - tablo_status: "todo", - title: "Event 2", - description: "Description 2", - start_date: "2024-01-17", - start_time: "14:00:00", - end_time: "15:00:00", - // created_by: mockUser.id, - // created_at: "2024-01-01T00:00:00Z", - // deleted_at: null, - }, - ]; - - const icsContent = generateICSFromEvents(events, "Test Calendar"); - - const eventCount = (icsContent.match(/BEGIN:VEVENT/g) || []).length; - expect(eventCount).to.equal(2); - expect(icsContent).to.include("SUMMARY:Event 1"); - expect(icsContent).to.include("SUMMARY:Event 2"); - }); - }); - - describe("writeCalendarFileToR2", () => { - it("should write calendar file to R2 successfully", async () => { - const events: EventAndTablo[] = [ - { - event_id: "event1", - tablo_id: mockTablo.id, - tablo_name: "Test Tablo", - tablo_color: "bg-blue-500", - tablo_status: "todo", - title: "Test Event", - description: "Test description", - start_date: "2024-01-16", - start_time: "10:00:00", - end_time: "11:00:00", - // created_by: mockUser.id, - // created_at: "2024-01-01T00:00:00Z", - // deleted_at: null, - }, - ]; - - const eventsBuilder = { - select: sinon.stub().returnsThis(), - eq: sinon.stub().resolves({ data: events, error: null }), - }; - - mockSupabase.from.withArgs("events_and_tablos").returns(eventsBuilder); - - mockS3.send.resolves({}); - - await writeCalendarFileToR2(mockS3, mockSupabase, { - token: "test-token", - tabloName: "Test Tablo", - tablo_id: mockTablo.id, - }); - - expect(mockS3.send.calledOnce).to.be.true; - }); - - it("should throw error if events fetch fails", async () => { - const eventsBuilder = { - select: sinon.stub().returnsThis(), - eq: sinon - .stub() - .resolves({ data: null, error: { message: "Database error" } }), - }; - - mockSupabase.from.withArgs("events_and_tablos").returns(eventsBuilder); - - try { - await writeCalendarFileToR2(mockS3, mockSupabase, { - token: "test-token", - tabloName: "Test Tablo", - tablo_id: mockTablo.id, - }); - expect.fail("Should have thrown an error"); - // biome-ignore lint/suspicious/noExplicitAny: Catching error to check message - } catch (error: any) { - expect(error.message).to.equal("Failed to generate events"); - } - }); - }); - - describe("isTabloMember", () => { - it("should return true if user is a member", async () => { - const accessBuilder = { - select: sinon.stub().returnsThis(), - eq: sinon.stub().returnsThis(), - }; - // The last eq() call should resolve with data - accessBuilder.eq.onCall(2).resolves({ - data: [{ tablo_id: mockTablo.id, user_id: mockUser.id }], - error: null, - }); - - mockSupabase.from.withArgs("tablo_access").returns(accessBuilder); - - const isMember = await isTabloMember( - mockSupabase, - mockTablo.id, - mockUser.id - ); - - expect(isMember).to.be.true; - }); - - it("should return false if user is not a member", async () => { - const accessBuilder = { - select: sinon.stub().returnsThis(), - eq: sinon.stub().returnsThis(), - }; - // The last eq() call should resolve with empty data - accessBuilder.eq.onCall(2).resolves({ data: [], error: null }); - - mockSupabase.from.withArgs("tablo_access").returns(accessBuilder); - - const isMember = await isTabloMember( - mockSupabase, - mockTablo.id, - mockUser.id - ); - - expect(isMember).to.be.false; - }); - - it("should return false if database error occurs", async () => { - const accessBuilder = { - select: sinon.stub().returnsThis(), - eq: sinon.stub().returnsThis(), - }; - // The last eq() call should resolve with error - accessBuilder.eq - .onCall(2) - .resolves({ data: null, error: { message: "Database error" } }); - - mockSupabase.from.withArgs("tablo_access").returns(accessBuilder); - - const isMember = await isTabloMember( - mockSupabase, - mockTablo.id, - mockUser.id - ); - - expect(isMember).to.be.false; - }); - }); - - describe("isTabloAdmin", () => { - it("should return true if user is an admin", async () => { - const accessBuilder = { - select: sinon.stub().returnsThis(), - eq: sinon.stub().returnsThis(), - }; - // The last eq() call (4th call - onCall(3)) should resolve with data - accessBuilder.eq.onCall(3).resolves({ - data: [ - { tablo_id: mockTablo.id, user_id: mockUser.id, is_admin: true }, - ], - error: null, - }); - - mockSupabase.from.withArgs("tablo_access").returns(accessBuilder); - - const isAdmin = await isTabloAdmin( - mockSupabase, - mockTablo.id, - mockUser.id - ); - - expect(isAdmin).to.be.true; - }); - - it("should return false if user is not an admin", async () => { - const accessBuilder = { - select: sinon.stub().returnsThis(), - eq: sinon.stub().returnsThis(), - }; - // The last eq() call should resolve with empty data - accessBuilder.eq.onCall(3).resolves({ data: [], error: null }); - - mockSupabase.from.withArgs("tablo_access").returns(accessBuilder); - - const isAdmin = await isTabloAdmin( - mockSupabase, - mockTablo.id, - mockUser.id - ); - - expect(isAdmin).to.be.false; - }); - - it("should return false if database error occurs", async () => { - const accessBuilder = { - select: sinon.stub().returnsThis(), - eq: sinon.stub().returnsThis(), - }; - // The last eq() call should resolve with error - accessBuilder.eq - .onCall(3) - .resolves({ data: null, error: { message: "Database error" } }); - - mockSupabase.from.withArgs("tablo_access").returns(accessBuilder); - - const isAdmin = await isTabloAdmin( - mockSupabase, - mockTablo.id, - mockUser.id - ); - - expect(isAdmin).to.be.false; - }); - }); - - describe("getTabloFileNames", () => { - it("should return list of file names", async () => { - mockS3.send.resolves({ - Contents: [ - { Key: `${mockTablo.id}/file1.txt` }, - { Key: `${mockTablo.id}/file2.pdf` }, - { Key: `${mockTablo.id}/file3.jpg` }, - ], - }); - - const fileNames = await getTabloFileNames(mockS3, mockTablo.id); - - expect(fileNames).to.deep.equal(["file1.txt", "file2.pdf", "file3.jpg"]); - }); - - it("should return empty array if no files exist", async () => { - mockS3.send.resolves({ - Contents: [], - }); - - const fileNames = await getTabloFileNames(mockS3, mockTablo.id); - - expect(fileNames).to.deep.equal([]); - }); - - it("should filter out invalid file names", async () => { - mockS3.send.resolves({ - Contents: [ - { Key: `${mockTablo.id}/file1.txt` }, - { Key: `${mockTablo.id}/` }, // Empty file name - { Key: `${mockTablo.id}` }, // No file name - ], - }); - - const fileNames = await getTabloFileNames(mockS3, mockTablo.id); - - expect(fileNames).to.deep.equal(["file1.txt"]); - }); - }); -}); diff --git a/api/src/__tests__/middleware.test.ts b/api/src/__tests__/middleware.test.ts deleted file mode 100644 index ba4bb43..0000000 --- a/api/src/__tests__/middleware.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { expect } from "chai"; -import { afterEach, beforeEach, describe, it } from "mocha"; -import sinon from "sinon"; -import { - authMiddleware, - r2Middleware, - streamChatMiddleware, - supabaseMiddleware, -} from "../middleware.js"; -import { - createMockContext, - createMockNext, - createMockSupabaseClient, - mockEnvVars, - mockUser, -} from "./test-utils.js"; - -describe("Middleware", () => { - let restoreEnv: () => void; - - beforeEach(() => { - restoreEnv = mockEnvVars(); - }); - - afterEach(() => { - sinon.restore(); - restoreEnv(); - }); - - describe("authMiddleware", () => { - it("should authenticate valid Bearer token", async () => { - const mockSupabase = createMockSupabaseClient(); - const mockContext = createMockContext(); - const mockNext = createMockNext(); - - mockContext.get.withArgs("supabase").returns(mockSupabase); - mockContext.req.header.withArgs("Authorization").returns("Bearer valid-token"); - - // Mock successful auth - mockSupabase.auth.getUser.resolves({ - data: { user: mockUser }, - error: null, - }); - - await authMiddleware(mockContext, mockNext); - - expect(mockSupabase.auth.getUser.calledWith("valid-token")).to.be.true; - expect(mockContext.set.calledWith("user", mockUser)).to.be.true; - expect(mockNext.calledOnce).to.be.true; - }); - - it("should return 401 for missing Authorization header", async () => { - const mockSupabase = createMockSupabaseClient(); - const mockContext = createMockContext(); - const mockNext = createMockNext(); - - mockContext.get.withArgs("supabase").returns(mockSupabase); - mockContext.req.header.withArgs("Authorization").returns(undefined); - mockContext.json.returns({ - error: "Missing or invalid authorization header", - }); - - const result = await authMiddleware(mockContext, mockNext); - - expect(mockNext.called).to.be.false; - expect(result).to.deep.equal({ - error: "Missing or invalid authorization header", - }); - }); - - it("should return 401 for invalid Bearer token format", async () => { - const mockSupabase = createMockSupabaseClient(); - const mockContext = createMockContext(); - const mockNext = createMockNext(); - - mockContext.get.withArgs("supabase").returns(mockSupabase); - mockContext.req.header.withArgs("Authorization").returns("InvalidFormat"); - mockContext.json.returns({ - error: "Missing or invalid authorization header", - }); - - const result = await authMiddleware(mockContext, mockNext); - - expect(mockNext.called).to.be.false; - expect(result).to.deep.equal({ - error: "Missing or invalid authorization header", - }); - }); - - it("should return 401 for invalid or expired token", async () => { - const mockSupabase = createMockSupabaseClient(); - const mockContext = createMockContext(); - const mockNext = createMockNext(); - - mockContext.get.withArgs("supabase").returns(mockSupabase); - mockContext.req.header.withArgs("Authorization").returns("Bearer invalid-token"); - - // Mock auth failure - mockSupabase.auth.getUser.resolves({ - data: { user: null }, - error: { message: "Invalid token" }, - }); - - mockContext.json.returns({ error: "Invalid or expired token" }); - - const result = await authMiddleware(mockContext, mockNext); - - expect(mockNext.called).to.be.false; - expect(result).to.deep.equal({ error: "Invalid or expired token" }); - }); - - it("should return 401 when user is null", async () => { - const mockSupabase = createMockSupabaseClient(); - const mockContext = createMockContext(); - const mockNext = createMockNext(); - - mockContext.get.withArgs("supabase").returns(mockSupabase); - mockContext.req.header.withArgs("Authorization").returns("Bearer valid-token"); - - // Mock auth with null user - mockSupabase.auth.getUser.resolves({ - data: { user: null }, - error: null, - }); - - mockContext.json.returns({ error: "Invalid or expired token" }); - - const result = await authMiddleware(mockContext, mockNext); - - expect(mockNext.called).to.be.false; - expect(result).to.deep.equal({ error: "Invalid or expired token" }); - }); - }); - - describe("supabaseMiddleware", () => { - it("should create and set Supabase client in context", async () => { - const mockContext = createMockContext(); - const mockNext = createMockNext(); - - await supabaseMiddleware(mockContext, mockNext); - - expect(mockContext.set.calledOnce).to.be.true; - const setCall = mockContext.set.getCall(0); - expect(setCall.args[0]).to.equal("supabase"); - expect(setCall.args[1]).to.be.an("object"); - expect(mockNext.calledOnce).to.be.true; - }); - }); - - describe("streamChatMiddleware", () => { - it("should create and set Stream Chat client in context", async () => { - const mockContext = createMockContext(); - const mockNext = createMockNext(); - - await streamChatMiddleware(mockContext, mockNext); - - expect(mockContext.set.calledOnce).to.be.true; - const setCall = mockContext.set.getCall(0); - expect(setCall.args[0]).to.equal("streamServerClient"); - expect(setCall.args[1]).to.be.an("object"); - expect(mockNext.calledOnce).to.be.true; - }); - }); - - describe("r2Middleware", () => { - it("should create and set S3 client in context", async () => { - const mockContext = createMockContext(); - const mockNext = createMockNext(); - - await r2Middleware(mockContext, mockNext); - - expect(mockContext.set.calledOnce).to.be.true; - const setCall = mockContext.set.getCall(0); - expect(setCall.args[0]).to.equal("s3_client"); - expect(setCall.args[1]).to.be.an("object"); - expect(mockNext.calledOnce).to.be.true; - }); - }); -}); diff --git a/api/src/__tests__/public.test.ts b/api/src/__tests__/public.test.ts deleted file mode 100644 index 0264e11..0000000 --- a/api/src/__tests__/public.test.ts +++ /dev/null @@ -1,509 +0,0 @@ -import { expect } from "chai"; -import { afterEach, beforeEach, describe, it } from "mocha"; -import sinon from "sinon"; -import { - createMockContext, - createMockSupabaseClient, - mockEnvVars, - mockEvent, - mockProfile, -} from "./test-utils.js"; - -describe("Public Router", () => { - // biome-ignore lint/suspicious/noExplicitAny: Mock client types - let mockSupabase: any; - let restoreEnv: () => void; - - beforeEach(() => { - restoreEnv = mockEnvVars(); - mockSupabase = createMockSupabaseClient(); - }); - - afterEach(() => { - sinon.restore(); - restoreEnv(); - }); - - describe("GET /slots/:shortUserId/:standardName", () => { - it("should return available slots for valid user and event type", async () => { - const mockContext = createMockContext(); - mockContext.req.param.withArgs("shortUserId").returns("testuser"); - mockContext.req.param.withArgs("standardName").returns("meeting-30min"); - mockContext.get.withArgs("supabase").returns(mockSupabase); - - const eventType = { - id: "event-type-id", - user_id: mockProfile.id, - standard_name: "meeting-30min", - config: { - name: "30 Minute Meeting", - description: "Standard meeting", - duration: 30, - requiresApproval: false, - }, - created_at: "2024-01-01T00:00:00Z", - deleted_at: null, - }; - - const availability = { - id: "availability-id", - user_id: mockProfile.id, - availability_data: { - 0: { enabled: true, timeRanges: [{ start: "09:00", end: "17:00" }] }, - 1: { enabled: true, timeRanges: [{ start: "09:00", end: "17:00" }] }, - 2: { enabled: true, timeRanges: [{ start: "09:00", end: "17:00" }] }, - 3: { enabled: true, timeRanges: [{ start: "09:00", end: "17:00" }] }, - 4: { enabled: true, timeRanges: [{ start: "09:00", end: "17:00" }] }, - 5: { enabled: false, timeRanges: [] }, - 6: { enabled: false, timeRanges: [] }, - }, - exceptions: [], - }; - - // Mock user lookup - const userBuilder = { - select: sinon.stub().returnsThis(), - eq: sinon.stub().returnsThis(), - single: sinon.stub().resolves({ data: mockProfile, error: null }), - }; - - // Mock event type lookup - const eventTypeBuilder = { - select: sinon.stub().returnsThis(), - eq: sinon.stub().returnsThis(), - is: sinon.stub().returnsThis(), - single: sinon.stub().resolves({ data: eventType, error: null }), - }; - - // Mock availabilities lookup - const availabilityBuilder = { - select: sinon.stub().returnsThis(), - eq: sinon.stub().returnsThis(), - single: sinon.stub().resolves({ data: availability, error: null }), - }; - - // Mock events lookup - const eventsBuilder = { - select: sinon.stub().returnsThis(), - eq: sinon.stub().returnsThis(), - gte: sinon.stub().returnsThis(), - lte: sinon.stub().returnsThis(), - is: sinon.stub().resolves({ data: [], error: null }), - }; - - mockSupabase.from.callsFake((table: string) => { - if (table === "profiles") return userBuilder; - if (table === "event_types") return eventTypeBuilder; - if (table === "availabilities") return availabilityBuilder; - if (table === "events") return eventsBuilder; - return mockSupabase.from(); - }); - - // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context - const handler = async (c: any) => { - const supabase = c.get("supabase"); - const shortUserId = c.req.param("shortUserId"); - const standardName = c.req.param("standardName"); - - // Get user - const { data: userData, error: userError } = await supabase - .from("profiles") - .select("*") - .eq("short_user_id", shortUserId) - .single(); - - if (userError || !userData) { - return c.json({ error: "User not found" }, 404); - } - - // Get event type - const { data: eventTypeData, error: eventTypeError } = await supabase - .from("event_types") - .select("*") - .eq("user_id", userData.id) - .eq("standard_name", standardName) - .is("deleted_at", null) - .single(); - - if (eventTypeError || !eventTypeData) { - return c.json({ error: "Event type not found" }, 404); - } - - // Get availabilities - const { error: availabilitiesError } = await supabase - .from("availabilities") - .select("*") - .eq("user_id", userData.id) - .single(); - - if (availabilitiesError) { - return c.json({ error: "Availabilities not found" }, 404); - } - - // Get existing events - const { error: eventsError } = await supabase - .from("events") - .select("*") - .eq("created_by", userData.id) - .gte("start_date", "2024-01-01") - .lte("start_date", "2024-12-31") - .is("deleted_at", null); - - if (eventsError) { - return c.json({ error: "Failed to fetch events" }, 500); - } - - return c.json({ - user: { name: userData.name }, - eventType: eventTypeData.config, - slots: {}, - availableSlots: [], - }); - }; - - const result = await handler(mockContext); - - expect(result.user.name).to.equal(mockProfile.name); - expect(result.eventType.name).to.equal("30 Minute Meeting"); - }); - - it("should return 404 if user not found", async () => { - const mockContext = createMockContext(); - mockContext.req.param.withArgs("shortUserId").returns("nonexistent"); - mockContext.req.param.withArgs("standardName").returns("meeting-30min"); - mockContext.get.withArgs("supabase").returns(mockSupabase); - - // Mock user lookup with no data - const userBuilder = { - select: sinon.stub().returnsThis(), - eq: sinon.stub().returnsThis(), - single: sinon - .stub() - .resolves({ data: null, error: { message: "Not found" } }), - }; - - mockSupabase.from.withArgs("profiles").returns(userBuilder); - - // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context - const handler = async (c: any) => { - const supabase = c.get("supabase"); - const shortUserId = c.req.param("shortUserId"); - - const { data: userData, error: userError } = await supabase - .from("profiles") - .select("*") - .eq("short_user_id", shortUserId) - .single(); - - if (userError || !userData) { - return c.json({ error: "User not found" }, 404); - } - - return c.json({ message: "Success" }); - }; - - const result = await handler(mockContext); - - expect(result).to.deep.equal({ error: "User not found" }); - }); - - it("should return 404 if event type not found", async () => { - const mockContext = createMockContext(); - mockContext.req.param.withArgs("shortUserId").returns("testuser"); - mockContext.req.param.withArgs("standardName").returns("nonexistent"); - mockContext.get.withArgs("supabase").returns(mockSupabase); - - // Mock user lookup - const userBuilder = { - select: sinon.stub().returnsThis(), - eq: sinon.stub().returnsThis(), - single: sinon.stub().resolves({ data: mockProfile, error: null }), - }; - - // Mock event type lookup with no data - const eventTypeBuilder = { - select: sinon.stub().returnsThis(), - eq: sinon.stub().returnsThis(), - is: sinon.stub().returnsThis(), - single: sinon - .stub() - .resolves({ data: null, error: { message: "Not found" } }), - }; - - mockSupabase.from.callsFake((table: string) => { - if (table === "profiles") return userBuilder; - if (table === "event_types") return eventTypeBuilder; - return mockSupabase.from(); - }); - - // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context - const handler = async (c: any) => { - const supabase = c.get("supabase"); - const shortUserId = c.req.param("shortUserId"); - const standardName = c.req.param("standardName"); - - // Get user - const { data: userData, error: userError } = await supabase - .from("profiles") - .select("*") - .eq("short_user_id", shortUserId) - .single(); - - if (userError || !userData) { - return c.json({ error: "User not found" }, 404); - } - - // Get event type - const { data: eventTypeData, error: eventTypeError } = await supabase - .from("event_types") - .select("*") - .eq("user_id", userData.id) - .eq("standard_name", standardName) - .is("deleted_at", null) - .single(); - - if (eventTypeError || !eventTypeData) { - return c.json({ error: "Event type not found" }, 404); - } - - return c.json({ message: "Success" }); - }; - - const result = await handler(mockContext); - - expect(result).to.deep.equal({ error: "Event type not found" }); - }); - - it("should return 404 if availabilities not found", async () => { - const mockContext = createMockContext(); - mockContext.req.param.withArgs("shortUserId").returns("testuser"); - mockContext.req.param.withArgs("standardName").returns("meeting-30min"); - mockContext.get.withArgs("supabase").returns(mockSupabase); - - const eventType = { - id: "event-type-id", - user_id: mockProfile.id, - standard_name: "meeting-30min", - config: { - name: "30 Minute Meeting", - description: "Standard meeting", - duration: 30, - requiresApproval: false, - }, - created_at: "2024-01-01T00:00:00Z", - deleted_at: null, - }; - - // Mock user lookup - const userBuilder = { - select: sinon.stub().returnsThis(), - eq: sinon.stub().returnsThis(), - single: sinon.stub().resolves({ data: mockProfile, error: null }), - }; - - // Mock event type lookup - const eventTypeBuilder = { - select: sinon.stub().returnsThis(), - eq: sinon.stub().returnsThis(), - is: sinon.stub().returnsThis(), - single: sinon.stub().resolves({ data: eventType, error: null }), - }; - - // Mock availabilities lookup with error - const availabilityBuilder = { - select: sinon.stub().returnsThis(), - eq: sinon.stub().returnsThis(), - single: sinon - .stub() - .resolves({ data: null, error: { message: "Not found" } }), - }; - - mockSupabase.from.callsFake((table: string) => { - if (table === "profiles") return userBuilder; - if (table === "event_types") return eventTypeBuilder; - if (table === "availabilities") return availabilityBuilder; - return mockSupabase.from(); - }); - - // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context - const handler = async (c: any) => { - const supabase = c.get("supabase"); - const shortUserId = c.req.param("shortUserId"); - const standardName = c.req.param("standardName"); - - // Get user - const { data: userData, error: userError } = await supabase - .from("profiles") - .select("*") - .eq("short_user_id", shortUserId) - .single(); - - if (userError || !userData) { - return c.json({ error: "User not found" }, 404); - } - - // Get event type - const { data: eventTypeData, error: eventTypeError } = await supabase - .from("event_types") - .select("*") - .eq("user_id", userData.id) - .eq("standard_name", standardName) - .is("deleted_at", null) - .single(); - - if (eventTypeError || !eventTypeData) { - return c.json({ error: "Event type not found" }, 404); - } - - // Get availabilities - const { error: availabilitiesError } = await supabase - .from("availabilities") - .select("*") - .eq("user_id", userData.id) - .single(); - - if (availabilitiesError) { - return c.json({ error: "Availabilities not found" }, 404); - } - - return c.json({ message: "Success" }); - }; - - const result = await handler(mockContext); - - expect(result).to.deep.equal({ error: "Availabilities not found" }); - }); - - it("should return 500 if events query fails", async () => { - const mockContext = createMockContext(); - mockContext.req.param.withArgs("shortUserId").returns("testuser"); - mockContext.req.param.withArgs("standardName").returns("meeting-30min"); - mockContext.get.withArgs("supabase").returns(mockSupabase); - - const eventType = { - id: "event-type-id", - user_id: mockProfile.id, - standard_name: "meeting-30min", - config: { - name: "30 Minute Meeting", - description: "Standard meeting", - duration: 30, - requiresApproval: false, - }, - created_at: "2024-01-01T00:00:00Z", - deleted_at: null, - }; - - const availability = { - id: "availability-id", - user_id: mockProfile.id, - availability_data: { - 0: { enabled: true, timeRanges: [{ start: "09:00", end: "17:00" }] }, - }, - exceptions: [], - }; - - // Mock user lookup - const userBuilder = { - select: sinon.stub().returnsThis(), - eq: sinon.stub().returnsThis(), - single: sinon.stub().resolves({ data: mockProfile, error: null }), - }; - - // Mock event type lookup - const eventTypeBuilder = { - select: sinon.stub().returnsThis(), - eq: sinon.stub().returnsThis(), - is: sinon.stub().returnsThis(), - single: sinon.stub().resolves({ data: eventType, error: null }), - }; - - // Mock availabilities lookup - const availabilityBuilder = { - select: sinon.stub().returnsThis(), - eq: sinon.stub().returnsThis(), - single: sinon.stub().resolves({ data: availability, error: null }), - }; - - // Mock events lookup with error - const eventsBuilder = { - select: sinon.stub().returnsThis(), - eq: sinon.stub().returnsThis(), - gte: sinon.stub().returnsThis(), - lte: sinon.stub().returnsThis(), - is: sinon - .stub() - .resolves({ data: null, error: { message: "Database error" } }), - }; - - mockSupabase.from.callsFake((table: string) => { - if (table === "profiles") return userBuilder; - if (table === "event_types") return eventTypeBuilder; - if (table === "availabilities") return availabilityBuilder; - if (table === "events") return eventsBuilder; - return mockSupabase.from(); - }); - - // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context - const handler = async (c: any) => { - const supabase = c.get("supabase"); - const shortUserId = c.req.param("shortUserId"); - const standardName = c.req.param("standardName"); - - // Get user - const { data: userData, error: userError } = await supabase - .from("profiles") - .select("*") - .eq("short_user_id", shortUserId) - .single(); - - if (userError || !userData) { - return c.json({ error: "User not found" }, 404); - } - - // Get event type - const { data: eventTypeData, error: eventTypeError } = await supabase - .from("event_types") - .select("*") - .eq("user_id", userData.id) - .eq("standard_name", standardName) - .is("deleted_at", null) - .single(); - - if (eventTypeError || !eventTypeData) { - return c.json({ error: "Event type not found" }, 404); - } - - // Get availabilities - const { error: availabilitiesError } = await supabase - .from("availabilities") - .select("*") - .eq("user_id", userData.id) - .single(); - - if (availabilitiesError) { - return c.json({ error: "Availabilities not found" }, 404); - } - - // Get existing events - const { error: eventsError } = await supabase - .from("events") - .select("*") - .eq("created_by", userData.id) - .gte("start_date", "2024-01-01") - .lte("start_date", "2024-12-31") - .is("deleted_at", null); - - if (eventsError) { - return c.json({ error: "Failed to fetch events" }, 500); - } - - return c.json({ message: "Success" }); - }; - - const result = await handler(mockContext); - - expect(result).to.deep.equal({ error: "Failed to fetch events" }); - }); - }); -}); diff --git a/api/src/__tests__/tablo.test.ts b/api/src/__tests__/tablo.test.ts deleted file mode 100644 index 71e94c3..0000000 --- a/api/src/__tests__/tablo.test.ts +++ /dev/null @@ -1,1287 +0,0 @@ -import { expect } from "chai"; -import { afterEach, beforeEach, describe, it } from "mocha"; -import sinon from "sinon"; -import { - createMockContext, - createMockS3Client, - createMockStreamChatClient, - createMockSupabaseClient, - createMockTransporter, - mockEnvVars, - mockEvent, - mockProfile, - mockTablo, - mockUser, -} from "./test-utils.js"; - -describe("Tablo Router", () => { - // biome-ignore lint/suspicious/noExplicitAny: Mock client types - let mockSupabase: any; - // biome-ignore lint/suspicious/noExplicitAny: Mock client types - let mockStreamChat: any; - // biome-ignore lint/suspicious/noExplicitAny: Mock client types - let mockChannel: any; - // biome-ignore lint/suspicious/noExplicitAny: Mock client types - let mockS3: any; - let restoreEnv: () => void; - - beforeEach(() => { - restoreEnv = mockEnvVars(); - mockSupabase = createMockSupabaseClient(); - const streamMocks = createMockStreamChatClient(); - mockStreamChat = streamMocks.mockStreamChat; - mockChannel = streamMocks.mockChannel; - mockS3 = createMockS3Client(); - }); - - afterEach(() => { - sinon.restore(); - restoreEnv(); - }); - - describe("POST /create", () => { - it("should create a new tablo with events", async () => { - const mockContext = createMockContext(); - const payload = { - name: "New Tablo", - color: "bg-blue-500", - status: "todo", - events: [ - { - title: "Event 1", - description: "Test event", - start_date: "2024-01-16", - start_time: "10:00", - end_time: "11:00", - }, - ], - }; - - mockContext.req.json.resolves(payload); - mockContext.get.withArgs("user").returns(mockUser); - mockContext.get.withArgs("supabase").returns(mockSupabase); - mockContext.get.withArgs("streamServerClient").returns(mockStreamChat); - - // Mock Supabase insert - mockSupabase - .from() - .insert() - .select() - .single.resolves({ data: mockTablo, error: null }); - - // Mock events insert - const eventsBuilder = { - insert: sinon.stub().resolves({ data: [], error: null }), - }; - mockSupabase.from.withArgs("events").returns(eventsBuilder); - - // Create test handler - // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context - const handler = async (c: any) => { - const user = c.get("user"); - const supabase = c.get("supabase"); - const data = await c.req.json(); - - const { data: insertedTablo, error } = await supabase - .from("tablos") - .insert({ - ...data, - owner_id: user.id, - events: undefined, - }) - .select() - .single(); - - if (error || !insertedTablo) { - return c.json( - { error: error?.message || "Failed to create tablo" }, - 500 - ); - } - - const streamServerClient = c.get("streamServerClient"); - const channel = streamServerClient.channel( - "messaging", - insertedTablo.id, - { - name: insertedTablo.name, - created_by_id: user.id, - members: [user.id], - } - ); - await channel.create(); - - if (data.events) { - // biome-ignore lint/suspicious/noExplicitAny: Event type varies - const eventsToInsert = data.events.map((event: any) => ({ - ...event, - tablo_id: insertedTablo.id, - created_by: user.id, - })); - - await supabase.from("events").insert(eventsToInsert); - } - - return c.json({ message: "Tablo created successfully" }); - }; - - const result = await handler(mockContext); - - expect(mockStreamChat.channel.calledOnce).to.be.true; - expect(mockChannel.create.calledOnce).to.be.true; - expect(result).to.deep.equal({ message: "Tablo created successfully" }); - }); - - it("should return 500 if tablo creation fails", async () => { - const mockContext = createMockContext(); - const payload = { - name: "New Tablo", - color: "bg-blue-500", - status: "todo", - }; - - mockContext.req.json.resolves(payload); - mockContext.get.withArgs("user").returns(mockUser); - mockContext.get.withArgs("supabase").returns(mockSupabase); - - // Mock Supabase error - mockSupabase - .from() - .insert() - .select() - .single.resolves({ data: null, error: { message: "Insert failed" } }); - - // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context - const handler = async (c: any) => { - const user = c.get("user"); - const supabase = c.get("supabase"); - const data = await c.req.json(); - - const { error } = await supabase - .from("tablos") - .insert({ - ...data, - owner_id: user.id, - events: undefined, - }) - .select() - .single(); - - if (error) { - return c.json({ error: error.message }, 500); - } - - return c.json({ message: "Tablo created successfully" }); - }; - - const result = await handler(mockContext); - - expect(result).to.deep.equal({ error: "Insert failed" }); - }); - }); - - describe("POST /create-and-invite", () => { - it("should create tablo and grant access to invited user", async () => { - const mockContext = createMockContext(); - const ownerProfile = { - ...mockProfile, - id: "owner-id", - short_user_id: "owner123", - }; - const invitedProfile = { ...mockProfile, id: "invited-id" }; - - const payload = { - owner_short_id: "owner123", - event: { - title: "Meeting", - description: "Test meeting", - start_date: "2024-01-16", - start_time: "10:00", - end_time: "11:00", - }, - }; - - mockContext.req.json.resolves(payload); - mockContext.get - .withArgs("user") - .returns({ ...mockUser, id: "invited-id" }); - mockContext.get.withArgs("supabase").returns(mockSupabase); - mockContext.get.withArgs("streamServerClient").returns(mockStreamChat); - - // Mock owner lookup - const ownerBuilder = { - select: sinon.stub().returnsThis(), - eq: sinon.stub().returnsThis(), - single: sinon.stub().resolves({ data: ownerProfile, error: null }), - }; - - // Mock invited user lookup - const invitedBuilder = { - select: sinon.stub().returnsThis(), - eq: sinon.stub().returnsThis(), - single: sinon.stub().resolves({ data: invitedProfile, error: null }), - }; - - // Mock existing tablo check - const existingTabloBuilder = { - select: sinon.stub().returnsThis(), - eq: sinon.stub().returnsThis(), - is: sinon.stub().returnsThis(), - limit: sinon.stub().resolves({ data: [], error: null }), - }; - - // Mock tablo creation - const createTabloBuilder = { - insert: sinon.stub().returnsThis(), - select: sinon.stub().returnsThis(), - single: sinon.stub().resolves({ data: mockTablo, error: null }), - }; - - // Mock tablo access insert - const accessBuilder = { - insert: sinon.stub().resolves({ error: null }), - }; - - // Mock event insert - const eventBuilder = { - insert: sinon.stub().resolves({ error: null }), - }; - - let callCount = 0; - mockSupabase.from.callsFake((table: string) => { - callCount++; - if (table === "profiles" && callCount === 1) return ownerBuilder; - if (table === "profiles" && callCount === 2) return invitedBuilder; - if (table === "tablos" && callCount === 3) return existingTabloBuilder; - if (table === "tablos" && callCount === 4) return createTabloBuilder; - if (table === "tablo_access") return accessBuilder; - if (table === "events") return eventBuilder; - return createTabloBuilder; - }); - - // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context - const handler = async (c: any) => { - const user = c.get("user"); - const supabase = c.get("supabase"); - const streamServerClient = c.get("streamServerClient"); - const data = await c.req.json(); - - if (!data.owner_short_id) { - return c.json({ error: "owner_id is required" }, 400); - } - - if (!data.event) { - return c.json({ error: "event is required" }, 400); - } - - const { data: ownerData, error: ownerError } = await supabase - .from("profiles") - .select("id, name, email") - .eq("short_user_id", data.owner_short_id) - .single(); - - const { data: invitedUser, error: invitedUserError } = await supabase - .from("profiles") - .select("id, name, email") - .eq("id", user.id) - .single(); - - if (ownerError || !ownerData || invitedUserError || !invitedUser) { - return c.json( - { error: "owner_id or invited_user_id is incorrect" }, - 400 - ); - } - - const ownerId = ownerData.id; - - const { data: existingTablo, error: existingTabloError } = - await supabase - .from("tablos") - .select( - ` - id, - name, - owner_id, - tablo_access!inner(user_id) - ` - ) - .eq("owner_id", ownerId) - .eq("tablo_access.user_id", user.id) - .is("deleted_at", null) - .limit(1); - - if (existingTabloError) { - return c.json({ error: existingTabloError.message }, 500); - } - - let tabloData: typeof mockTablo; - - if (!existingTablo.length) { - const { data: insertedTablo, error } = await supabase - .from("tablos") - .insert({ - name: `${invitedUser.name || "Invité"} / ${ - ownerData.name || "Propriétaire" - }`, - color: "bg-blue-500", - status: "todo", - owner_id: ownerId, - }) - .select() - .single(); - - if (error || !insertedTablo) { - return c.json( - { error: error?.message || "Failed to create tablo" }, - 500 - ); - } - - tabloData = insertedTablo; - } else { - tabloData = existingTablo[0]; - } - - const { error: tabloAccessError } = await supabase - .from("tablo_access") - .insert({ - tablo_id: tabloData.id, - user_id: user.id, - is_admin: false, - is_active: true, - granted_by: ownerId, - }); - - if (tabloAccessError) { - return c.json({ error: tabloAccessError.message }, 500); - } - - const channel = streamServerClient.channel("messaging", tabloData.id, { - name: tabloData.name, - created_by_id: ownerId, - members: [ownerId, user.id], - }); - await channel.create(); - - await channel.sendMessage({ - text: `🎉 Bienvenue dans votre nouveau tablo "${tabloData.name}" !`, - user_id: ownerId, - }); - - await supabase.from("events").insert({ - ...data.event, - tablo_id: tabloData.id, - created_by: ownerId, - }); - - return c.json({ id: tabloData.id }); - }; - - const result = await handler(mockContext); - - expect(result).to.deep.equal({ id: mockTablo.id }); - expect(mockChannel.create.calledOnce).to.be.true; - expect(mockChannel.sendMessage.calledOnce).to.be.true; - }); - - it("should return 400 if owner_short_id is missing", async () => { - const mockContext = createMockContext(); - mockContext.req.json.resolves({ event: {} }); - - // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context - const handler = async (c: any) => { - const data = await c.req.json(); - - if (!data.owner_short_id) { - return c.json({ error: "owner_id is required" }, 400); - } - - return c.json({ message: "Success" }); - }; - - const result = await handler(mockContext); - - expect(result).to.deep.equal({ error: "owner_id is required" }); - }); - - it("should return 400 if event is missing", async () => { - const mockContext = createMockContext(); - mockContext.req.json.resolves({ owner_short_id: "owner123" }); - - // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context - const handler = async (c: any) => { - const data = await c.req.json(); - - if (!data.owner_short_id) { - return c.json({ error: "owner_id is required" }, 400); - } - - if (!data.event) { - return c.json({ error: "event is required" }, 400); - } - - return c.json({ message: "Success" }); - }; - - const result = await handler(mockContext); - - expect(result).to.deep.equal({ error: "event is required" }); - }); - }); - - describe("PATCH /update", () => { - it("should update tablo successfully", async () => { - const mockContext = createMockContext(); - const updateData = { - id: mockTablo.id, - name: "Updated Tablo Name", - }; - - mockContext.req.json.resolves(updateData); - mockContext.get.withArgs("user").returns(mockUser); - mockContext.get.withArgs("supabase").returns(mockSupabase); - mockContext.get.withArgs("streamServerClient").returns(mockStreamChat); - - const updatedTablo = { ...mockTablo, name: "Updated Tablo Name" }; - - mockSupabase - .from() - .update() - .eq() - .select() - .single.resolves({ data: updatedTablo, error: null }); - - // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context - const handler = async (c: any) => { - const user = c.get("user"); - const supabase = c.get("supabase"); - const streamServerClient = c.get("streamServerClient"); - const data = await c.req.json(); - - const { id, ...tablo } = data; - - const { data: update, error } = await supabase - .from("tablos") - .update(tablo) - .eq("id", id) - .eq("owner_id", user.id) - .select() - .single(); - - if (error || !update) { - return c.json({ error: error?.message || "Failed to update" }, 500); - } - - const isUpdatingName = - tablo.name !== undefined && tablo.name !== update.name; - - if (isUpdatingName) { - const channel = streamServerClient.channel("messaging", update.id); - try { - await channel.update({ - name: update.name, - }); - } catch (error) { - console.error("error updating channel", error); - } - } - - return c.json({ message: "Tablo updated successfully" }); - }; - - const result = await handler(mockContext); - - expect(result).to.deep.equal({ message: "Tablo updated successfully" }); - }); - - it("should return 500 if update fails", async () => { - const mockContext = createMockContext(); - const updateData = { - id: mockTablo.id, - name: "Updated Name", - }; - - mockContext.req.json.resolves(updateData); - mockContext.get.withArgs("user").returns(mockUser); - mockContext.get.withArgs("supabase").returns(mockSupabase); - - mockSupabase - .from() - .update() - .eq() - .select() - .single.resolves({ data: null, error: { message: "Update failed" } }); - - // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context - const handler = async (c: any) => { - const user = c.get("user"); - const supabase = c.get("supabase"); - const data = await c.req.json(); - - const { id, ...tablo } = data; - - const { error } = await supabase - .from("tablos") - .update(tablo) - .eq("id", id) - .eq("owner_id", user.id) - .select() - .single(); - - if (error) { - return c.json({ error: error.message }, 500); - } - - return c.json({ message: "Tablo updated successfully" }); - }; - - const result = await handler(mockContext); - - expect(result).to.deep.equal({ error: "Update failed" }); - }); - }); - - describe("DELETE /delete", () => { - it("should soft delete tablo successfully", async () => { - const mockContext = createMockContext(); - mockContext.req.json.resolves({ id: mockTablo.id }); - mockContext.get.withArgs("user").returns(mockUser); - mockContext.get.withArgs("supabase").returns(mockSupabase); - mockContext.get.withArgs("streamServerClient").returns(mockStreamChat); - - const updateBuilder = { - update: sinon.stub().returnsThis(), - eq: sinon.stub().returnsThis(), - }; - updateBuilder.eq.onCall(1).resolves({ error: null }); - - mockSupabase.from.withArgs("tablos").returns(updateBuilder); - - // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context - const handler = async (c: any) => { - const user = c.get("user"); - const supabase = c.get("supabase"); - const streamServerClient = c.get("streamServerClient"); - const data = await c.req.json(); - - const { id } = data; - - const { error } = await supabase - .from("tablos") - .update({ deleted_at: new Date().toISOString() }) - .eq("id", id) - .eq("owner_id", user.id); - - if (error) { - return c.json({ error: error.message }, 500); - } - - const channel = streamServerClient.channel("messaging", id); - try { - await channel.delete(); - } catch (error) { - console.error("error deleting channel", error); - } - - return c.json({ message: "Tablo deleted successfully" }); - }; - - const result = await handler(mockContext); - - expect(result).to.deep.equal({ message: "Tablo deleted successfully" }); - expect(mockChannel.delete.calledOnce).to.be.true; - }); - - it("should return 500 if delete fails", async () => { - const mockContext = createMockContext(); - mockContext.req.json.resolves({ id: mockTablo.id }); - mockContext.get.withArgs("user").returns(mockUser); - mockContext.get.withArgs("supabase").returns(mockSupabase); - - const updateBuilder = { - update: sinon.stub().returnsThis(), - eq: sinon.stub().returnsThis(), - }; - updateBuilder.eq - .onCall(1) - .resolves({ error: { message: "Delete failed" } }); - - mockSupabase.from.withArgs("tablos").returns(updateBuilder); - - // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context - const handler = async (c: any) => { - const user = c.get("user"); - const supabase = c.get("supabase"); - const data = await c.req.json(); - - const { id } = data; - - const { error } = await supabase - .from("tablos") - .update({ deleted_at: new Date().toISOString() }) - .eq("id", id) - .eq("owner_id", user.id); - - if (error) { - return c.json({ error: error.message }, 500); - } - - return c.json({ message: "Tablo deleted successfully" }); - }; - - const result = await handler(mockContext); - - expect(result).to.deep.equal({ error: "Delete failed" }); - }); - }); - - describe("POST /invite", () => { - it("should send invite successfully", async () => { - const mockContext = createMockContext(); - mockContext.req.json.resolves({ - email: "invitee@example.com", - tablo_id: mockTablo.id, - }); - mockContext.get.withArgs("user").returns(mockUser); - mockContext.get.withArgs("supabase").returns(mockSupabase); - - // Mock tablo lookup - mockSupabase - .from() - .select() - .eq() - .single.resolves({ data: mockTablo, error: null }); - - // Mock invite insert - const inviteBuilder = { - insert: sinon.stub().resolves({ error: null }), - }; - mockSupabase.from.withArgs("tablo_invites").returns(inviteBuilder); - - // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context - const handler = async (c: any) => { - const sender = c.get("user"); - const supabase = c.get("supabase"); - const { tablo_id, email } = await c.req.json(); - - const { data, error: tabloError } = await supabase - .from("tablos") - .select("*") - .eq("id", tablo_id) - .single(); - - if (tabloError) { - return c.json({ error: tabloError.message }, 500); - } - - if (!data) { - return c.json({ error: "Tablo not found" }, 404); - } - - if (data.owner_id !== sender.id) { - return c.json( - { error: "You are not allowed to invite users to this tablo" }, - 400 - ); - } - - const { error } = await supabase.from("tablo_invites").insert({ - invited_email: email, - tablo_id: tablo_id, - invited_by: sender.id, - invite_token: "mock-token", - }); - - if (error) { - return c.json({ error: error.message }, 500); - } - - return c.json({ - message: "Invite sent successfully", - }); - }; - - const result = await handler(mockContext); - - expect(result).to.deep.equal({ message: "Invite sent successfully" }); - }); - - it("should return 404 if tablo not found", async () => { - const mockContext = createMockContext(); - mockContext.req.json.resolves({ - email: "invitee@example.com", - tablo_id: "non-existent", - }); - mockContext.get.withArgs("user").returns(mockUser); - mockContext.get.withArgs("supabase").returns(mockSupabase); - - mockSupabase - .from() - .select() - .eq() - .single.resolves({ data: null, error: null }); - - // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context - const handler = async (c: any) => { - const _sender = c.get("user"); - const supabase = c.get("supabase"); - const { tablo_id } = await c.req.json(); - - const { data, error: tabloError } = await supabase - .from("tablos") - .select("*") - .eq("id", tablo_id) - .single(); - - if (tabloError) { - return c.json({ error: tabloError.message }, 500); - } - - if (!data) { - return c.json({ error: "Tablo not found" }, 404); - } - - return c.json({ message: "Success" }); - }; - - const result = await handler(mockContext); - - expect(result).to.deep.equal({ error: "Tablo not found" }); - }); - - it("should return 400 if user is not owner", async () => { - const mockContext = createMockContext(); - mockContext.req.json.resolves({ - email: "invitee@example.com", - tablo_id: mockTablo.id, - }); - mockContext.get - .withArgs("user") - .returns({ ...mockUser, id: "different-user" }); - mockContext.get.withArgs("supabase").returns(mockSupabase); - - mockSupabase - .from() - .select() - .eq() - .single.resolves({ data: mockTablo, error: null }); - - // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context - const handler = async (c: any) => { - const sender = c.get("user"); - const supabase = c.get("supabase"); - const { tablo_id } = await c.req.json(); - - const { data, error: tabloError } = await supabase - .from("tablos") - .select("*") - .eq("id", tablo_id) - .single(); - - if (tabloError) { - return c.json({ error: tabloError.message }, 500); - } - - if (!data) { - return c.json({ error: "Tablo not found" }, 404); - } - - if (data.owner_id !== sender.id) { - return c.json( - { error: "You are not allowed to invite users to this tablo" }, - 400 - ); - } - - return c.json({ message: "Success" }); - }; - - const result = await handler(mockContext); - - expect(result).to.deep.equal({ - error: "You are not allowed to invite users to this tablo", - }); - }); - }); - - describe("POST /join", () => { - it("should join tablo successfully with valid token", async () => { - const mockContext = createMockContext(); - mockContext.req.json.resolves({ token: "valid-token" }); - mockContext.get.withArgs("user").returns(mockUser); - mockContext.get.withArgs("supabase").returns(mockSupabase); - mockContext.get.withArgs("streamServerClient").returns(mockStreamChat); - - const inviteData = { - id: "invite-id", - tablo_id: mockTablo.id, - invited_by: "inviter-id", - }; - - // Mock invite lookup - const inviteSelectBuilder = { - select: sinon.stub().returnsThis(), - eq: sinon.stub().returnsThis(), - single: sinon.stub().resolves({ data: inviteData, error: null }), - }; - - // Mock tablo access insert - const accessBuilder = { - insert: sinon.stub().resolves({ error: null }), - }; - - // Mock invite delete - const deleteBuilder = { - delete: sinon.stub().returnsThis(), - eq: sinon.stub().resolves({ error: null }), - }; - - let callCount = 0; - mockSupabase.from.callsFake((table: string) => { - callCount++; - if (table === "tablo_invites" && callCount === 1) { - return inviteSelectBuilder; - } - if (table === "tablo_access") return accessBuilder; - if (table === "tablo_invites" && callCount > 1) return deleteBuilder; - return mockSupabase.from(); - }); - - // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context - const handler = async (c: any) => { - const { token } = await c.req.json(); - - const joiner = c.get("user"); - const supabase = c.get("supabase"); - const streamServerClient = c.get("streamServerClient"); - - const { data: inviteData, error } = await supabase - .from("tablo_invites") - .select("id, tablo_id, invited_by") - .eq("invite_token", token) - .eq("invited_email", joiner.email) - .single(); - - if (error) { - return c.json({ error: error.message }, 500); - } - - if (!inviteData) { - return c.json({ error: "Invalid token or email" }, 400); - } - - const { id: invite_id, tablo_id, invited_by } = inviteData; - - const { error: tabloAccessError } = await supabase - .from("tablo_access") - .insert({ - tablo_id, - user_id: joiner.id, - is_admin: false, - is_active: true, - granted_by: invited_by, - }); - - if (tabloAccessError) { - return c.json({ error: tabloAccessError.message }, 500); - } - - await supabase.from("tablo_invites").delete().eq("id", invite_id); - - const channel = streamServerClient.channel("messaging", tablo_id); - await channel.addMembers([joiner.id]); - - return c.json({ message: "Tablo joined successfully" }); - }; - - const result = await handler(mockContext); - - expect(result).to.deep.equal({ message: "Tablo joined successfully" }); - expect(mockChannel.addMembers.calledOnce).to.be.true; - }); - - it("should return 400 for invalid token", async () => { - const mockContext = createMockContext(); - mockContext.req.json.resolves({ token: "invalid-token" }); - mockContext.get.withArgs("user").returns(mockUser); - mockContext.get.withArgs("supabase").returns(mockSupabase); - - mockSupabase - .from() - .select() - .eq() - .single.resolves({ data: null, error: null }); - - // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context - const handler = async (c: any) => { - const { token } = await c.req.json(); - const joiner = c.get("user"); - const supabase = c.get("supabase"); - - const { data: inviteData, error } = await supabase - .from("tablo_invites") - .select("id, tablo_id, invited_by") - .eq("invite_token", token) - .eq("invited_email", joiner.email) - .single(); - - if (error) { - return c.json({ error: error.message }, 500); - } - - if (!inviteData) { - return c.json({ error: "Invalid token or email" }, 400); - } - - return c.json({ message: "Success" }); - }; - - const result = await handler(mockContext); - - expect(result).to.deep.equal({ error: "Invalid token or email" }); - }); - }); - - describe("GET /members/:tablo_id", () => { - it("should return tablo members", async () => { - const mockContext = createMockContext(); - mockContext.req.param.withArgs("tablo_id").returns(mockTablo.id); - mockContext.get.withArgs("user").returns(mockUser); - mockContext.get.withArgs("supabase").returns(mockSupabase); - - const members = [ - { is_admin: true, profiles: { id: "user1", name: "User 1" } }, - { is_admin: false, profiles: { id: "user2", name: "User 2" } }, - ]; - - // Mock user_tablos check - const userTablosBuilder = { - select: sinon.stub().returnsThis(), - eq: sinon.stub().returnsThis(), - }; - // The second eq() call should resolve - userTablosBuilder.eq - .onCall(1) - .resolves({ data: [mockTablo], error: null }); - - // Mock tablo_access query - const accessBuilder = { - select: sinon.stub().returnsThis(), - eq: sinon.stub().returnsThis(), - }; - // The second eq() call should resolve - accessBuilder.eq.onCall(1).resolves({ data: members, error: null }); - - mockSupabase.from.callsFake((table: string) => { - if (table === "user_tablos") return userTablosBuilder; - if (table === "tablo_access") return accessBuilder; - return mockSupabase.from(); - }); - - // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context - const handler = async (c: any) => { - const user = c.get("user"); - const supabase = c.get("supabase"); - const tablo_id = c.req.param("tablo_id"); - - const { data: tabloData, error: tabloError } = await supabase - .from("user_tablos") - .select("*") - .eq("id", tablo_id) - .eq("user_id", user.id); - - if (!tabloData || tabloData.length === 0) { - return c.json({ error: "You are not a member of this tablo" }, 403); - } - - if (tabloError) { - return c.json({ error: "Internal server error" }, 500); - } - - const { data, error } = await supabase - .from("tablo_access") - .select("is_admin, profiles(id, name)") - .eq("tablo_id", tablo_id) - .eq("is_active", true); - - if (error) { - return c.json({ error: error.message }, 500); - } - - return c.json({ - // biome-ignore lint/suspicious/noExplicitAny: Member type from DB - members: data.map((member: any) => ({ - ...member.profiles, - is_admin: member.is_admin, - })), - }); - }; - - const result = await handler(mockContext); - - expect(result.members).to.have.length(2); - expect(result.members[0]).to.deep.equal({ - id: "user1", - name: "User 1", - is_admin: true, - }); - }); - - it("should return 403 if user is not a member", async () => { - const mockContext = createMockContext(); - mockContext.req.param.withArgs("tablo_id").returns(mockTablo.id); - mockContext.get.withArgs("user").returns(mockUser); - mockContext.get.withArgs("supabase").returns(mockSupabase); - - const userTablosBuilder = { - select: sinon.stub().returnsThis(), - eq: sinon.stub().returnsThis(), - single: sinon.stub().resolves({ data: [], error: null }), - }; - - mockSupabase.from.withArgs("user_tablos").returns(userTablosBuilder); - - // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context - const handler = async (c: any) => { - const user = c.get("user"); - const supabase = c.get("supabase"); - const tablo_id = c.req.param("tablo_id"); - - const { data: tabloData } = await supabase - .from("user_tablos") - .select("*") - .eq("id", tablo_id) - .eq("user_id", user.id); - - if (!tabloData || tabloData.length === 0) { - return c.json({ error: "You are not a member of this tablo" }, 403); - } - - return c.json({ message: "Success" }); - }; - - const result = await handler(mockContext); - - expect(result).to.deep.equal({ - error: "You are not a member of this tablo", - }); - }); - }); - - describe("POST /leave", () => { - it("should leave tablo successfully", async () => { - const mockContext = createMockContext(); - mockContext.req.json.resolves({ tablo_id: mockTablo.id }); - mockContext.get.withArgs("user").returns(mockUser); - mockContext.get.withArgs("supabase").returns(mockSupabase); - mockContext.get.withArgs("streamServerClient").returns(mockStreamChat); - - const updateBuilder = { - update: sinon.stub().returnsThis(), - eq: sinon.stub().returnsThis(), - }; - // The second eq() call should resolve - updateBuilder.eq.onCall(1).resolves({ error: null }); - - mockSupabase.from.withArgs("tablo_access").returns(updateBuilder); - - // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context - const handler = async (c: any) => { - const user = c.get("user"); - const supabase = c.get("supabase"); - const streamServerClient = c.get("streamServerClient"); - const { tablo_id } = await c.req.json(); - - const channel = streamServerClient.channel("messaging", tablo_id); - await channel.removeMembers([user.id]); - - const { error } = await supabase - .from("tablo_access") - .update({ is_active: false }) - .eq("tablo_id", tablo_id) - .eq("user_id", user.id); - - if (error) { - return c.json({ error: error.message }, 500); - } - - return c.json({ message: "Tablo left successfully" }); - }; - - const result = await handler(mockContext); - - expect(result).to.deep.equal({ message: "Tablo left successfully" }); - expect(mockChannel.removeMembers.calledOnce).to.be.true; - }); - }); - - describe("POST /webcal/generate-url", () => { - it("should generate webcal URL for tablo", async () => { - const mockContext = createMockContext(); - mockContext.req.json.resolves({ tablo_id: mockTablo.id }); - mockContext.get.withArgs("user").returns(mockUser); - mockContext.get.withArgs("supabase").returns(mockSupabase); - mockContext.get.withArgs("s3_client").returns(mockS3); - - // Mock tablo lookup - const tabloBuilder = { - select: sinon.stub().returnsThis(), - eq: sinon.stub().returnsThis(), - single: sinon.stub().resolves({ data: mockTablo, error: null }), - }; - - // Mock access check - const accessBuilder = { - select: sinon.stub().returnsThis(), - eq: sinon.stub().returnsThis(), - single: sinon - .stub() - .resolves({ data: { id: mockTablo.id }, error: null }), - }; - - // Mock subscription check (no existing subscription) - const subscriptionBuilder = { - select: sinon.stub().returnsThis(), - eq: sinon.stub().returnsThis(), - single: sinon.stub().resolves({ data: null, error: null }), - }; - - // Mock subscription insert - const insertBuilder = { - insert: sinon.stub().resolves({ error: null }), - }; - - let callCount = 0; - mockSupabase.from.callsFake((table: string) => { - callCount++; - if (table === "tablos") return tabloBuilder; - if (table === "user_tablos") return accessBuilder; - if (table === "calendar_subscriptions" && callCount === 3) - return subscriptionBuilder; - if (table === "calendar_subscriptions" && callCount === 4) - return insertBuilder; - if (table === "events_and_tablos") { - return { - select: sinon.stub().returnsThis(), - eq: sinon.stub().resolves({ data: [], error: null }), - }; - } - return mockSupabase.from(); - }); - - mockS3.send.resolves({}); - - // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context - const handler = async (c: any) => { - const user = c.get("user"); - const supabase = c.get("supabase"); - - const { tablo_id } = await c.req.json(); - - if (tablo_id === null) { - return c.json({ error: "All tablos are not supported" }, 400); - } - - const { data: tabloData, error: tabloError } = await supabase - .from("tablos") - .select("name") - .eq("id", tablo_id) - .single(); - - if (tabloError || !tabloData) { - return c.json({ error: "Tablo not found" }, 404); - } - - const { data: accessData, error: accessError } = await supabase - .from("user_tablos") - .select("id") - .eq("id", tablo_id) - .eq("user_id", user.id) - .single(); - - if (accessError || !accessData) { - return c.json({ error: "Access denied to this tablo" }, 403); - } - - const { data: subscriptionData } = await supabase - .from("calendar_subscriptions") - .select("*") - .eq("tablo_id", tablo_id) - .single(); - - if (subscriptionData) { - const token = subscriptionData.token; - const tabloName = tabloData.name.replace(/ /g, "_"); - const httpUrl = `https://calendar.xtablo.com/${token}/${tabloName}.ics`; - - return c.json({ - webcal_url: null, - http_url: httpUrl, - }); - } - - const token = "mock-token"; - - const { error } = await supabase.from("calendar_subscriptions").insert({ - tablo_id: tablo_id, - token: token, - }); - - if (error) { - return c.json({ error: "Failed to generate token" }, 500); - } - - const tabloName = tabloData.name.replace(/ /g, "_"); - const httpUrl = `https://calendar.xtablo.com/${token}/${tabloName}.ics`; - - return c.json({ - webcal_url: null, - http_url: httpUrl, - }); - }; - - const result = await handler(mockContext); - - expect(result.http_url).to.include("https://calendar.xtablo.com/"); - expect(result.http_url).to.include(".ics"); - }); - - it("should return 404 if tablo not found", async () => { - const mockContext = createMockContext(); - mockContext.req.json.resolves({ tablo_id: "non-existent" }); - mockContext.get.withArgs("user").returns(mockUser); - mockContext.get.withArgs("supabase").returns(mockSupabase); - - mockSupabase - .from() - .select() - .eq() - .single.resolves({ data: null, error: { message: "Not found" } }); - - // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context - const handler = async (c: any) => { - const _user = c.get("user"); - const supabase = c.get("supabase"); - - const { tablo_id } = await c.req.json(); - - const { data: tabloData, error: tabloError } = await supabase - .from("tablos") - .select("name") - .eq("id", tablo_id) - .single(); - - if (tabloError || !tabloData) { - return c.json({ error: "Tablo not found" }, 404); - } - - return c.json({ message: "Success" }); - }; - - const result = await handler(mockContext); - - expect(result).to.deep.equal({ error: "Tablo not found" }); - }); - }); -}); diff --git a/api/src/__tests__/tablo_data.test.ts b/api/src/__tests__/tablo_data.test.ts deleted file mode 100644 index 26f3ae0..0000000 --- a/api/src/__tests__/tablo_data.test.ts +++ /dev/null @@ -1,497 +0,0 @@ -import { expect } from "chai"; -import { afterEach, beforeEach, describe, it } from "mocha"; -import sinon from "sinon"; -import { - createMockContext, - createMockS3Client, - createMockSupabaseClient, - mockEnvVars, - mockTablo, - mockUser, -} from "./test-utils.js"; - -describe("Tablo Data Router", () => { - // biome-ignore lint/suspicious/noExplicitAny: Mock client types - let mockSupabase: any; - // biome-ignore lint/suspicious/noExplicitAny: Mock client types - let mockS3: any; - let restoreEnv: () => void; - - beforeEach(() => { - restoreEnv = mockEnvVars(); - mockSupabase = createMockSupabaseClient(); - mockS3 = createMockS3Client(); - }); - - afterEach(() => { - sinon.restore(); - restoreEnv(); - }); - - describe("GET /:tabloId/filenames", () => { - it("should return list of filenames for tablo member", async () => { - const mockContext = createMockContext(); - mockContext.req.param.withArgs("tabloId").returns(mockTablo.id); - mockContext.get.withArgs("user").returns(mockUser); - mockContext.get.withArgs("supabase").returns(mockSupabase); - mockContext.get.withArgs("s3_client").returns(mockS3); - - // Mock tablo access check - mockSupabase - .from() - .select() - .eq() - .single.resolves({ data: [{ tablo_id: mockTablo.id }], error: null }); - - // Mock S3 list objects - mockS3.send.resolves({ - Contents: [ - { Key: `${mockTablo.id}/file1.txt` }, - { Key: `${mockTablo.id}/file2.pdf` }, - { Key: `${mockTablo.id}/file3.jpg` }, - ], - }); - - // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context - const handler = async (c: any) => { - const _tabloId = c.req.param("tabloId"); - const s3_client = c.get("s3_client"); - - try { - const result = await s3_client.send({}); - const fileNames = result.Contents?.map( - // biome-ignore lint/suspicious/noExplicitAny: S3 Contents type is complex - (content: any) => content.Key?.split("/")[1] - // biome-ignore lint/suspicious/noExplicitAny: S3 Contents type is complex - ).filter((content: any) => content?.length && content.length > 0); - return c.json({ fileNames: fileNames || [] }); - } catch { - return c.json({ error: "Failed to fetch tablo files" }, 500); - } - }; - - const result = await handler(mockContext); - - expect(result.fileNames).to.deep.equal([ - "file1.txt", - "file2.pdf", - "file3.jpg", - ]); - }); - - it("should return empty array if no files exist", async () => { - const mockContext = createMockContext(); - mockContext.req.param.withArgs("tabloId").returns(mockTablo.id); - mockContext.get.withArgs("user").returns(mockUser); - mockContext.get.withArgs("supabase").returns(mockSupabase); - mockContext.get.withArgs("s3_client").returns(mockS3); - - // Mock S3 list objects with no contents - mockS3.send.resolves({ - Contents: [], - }); - - // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context - const handler = async (c: any) => { - const _tabloId = c.req.param("tabloId"); - const s3_client = c.get("s3_client"); - - try { - const result = await s3_client.send({}); - const fileNames = result.Contents?.map( - // biome-ignore lint/suspicious/noExplicitAny: S3 Contents type is complex - (content: any) => content.Key?.split("/")[1] - // biome-ignore lint/suspicious/noExplicitAny: S3 Contents type is complex - ).filter((content: any) => content?.length && content.length > 0); - return c.json({ fileNames: fileNames || [] }); - } catch { - return c.json({ error: "Failed to fetch tablo files" }, 500); - } - }; - - const result = await handler(mockContext); - - expect(result.fileNames).to.deep.equal([]); - }); - - it("should return 500 if S3 operation fails", async () => { - const mockContext = createMockContext(); - mockContext.req.param.withArgs("tabloId").returns(mockTablo.id); - mockContext.get.withArgs("user").returns(mockUser); - mockContext.get.withArgs("supabase").returns(mockSupabase); - mockContext.get.withArgs("s3_client").returns(mockS3); - - // Mock S3 error - mockS3.send.rejects(new Error("S3 error")); - - // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context - const handler = async (c: any) => { - const _tabloId = c.req.param("tabloId"); - const s3_client = c.get("s3_client"); - - try { - const result = await s3_client.send({}); - const fileNames = result.Contents?.map( - // biome-ignore lint/suspicious/noExplicitAny: S3 Contents type is complex - (content: any) => content.Key?.split("/")[1] - // biome-ignore lint/suspicious/noExplicitAny: S3 Contents type is complex - ).filter((content: any) => content?.length && content.length > 0); - return c.json({ fileNames: fileNames || [] }); - } catch { - return c.json({ error: "Failed to fetch tablo files" }, 500); - } - }; - - const result = await handler(mockContext); - - expect(result).to.deep.equal({ error: "Failed to fetch tablo files" }); - }); - }); - - describe("GET /:tabloId/:fileName", () => { - it("should return file content for tablo member", async () => { - const mockContext = createMockContext(); - mockContext.req.param.withArgs("tabloId").returns(mockTablo.id); - mockContext.req.param.withArgs("fileName").returns("test.txt"); - mockContext.get.withArgs("user").returns(mockUser); - mockContext.get.withArgs("supabase").returns(mockSupabase); - mockContext.get.withArgs("s3_client").returns(mockS3); - - const fileContent = "Hello, World!"; - const mockBody = { - transformToString: sinon.stub().resolves(fileContent), - }; - - // Mock S3 get object - mockS3.send.resolves({ - Body: mockBody, - ContentType: "text/plain", - LastModified: new Date("2024-01-01"), - }); - - // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context - const handler = async (c: any) => { - const _tabloId = c.req.param("tabloId"); - const fileName = c.req.param("fileName"); - const s3_client = c.get("s3_client"); - - try { - const response = await s3_client.send({}); - - if (!response.Body) { - return c.json({ error: "File not found" }, 404); - } - - const content = await response.Body.transformToString(); - - return c.json({ - fileName, - content, - contentType: response.ContentType, - lastModified: response.LastModified, - }); - } catch { - return c.json({ error: "Failed to fetch file" }, 500); - } - }; - - const result = await handler(mockContext); - - expect(result.fileName).to.equal("test.txt"); - expect(result.content).to.equal(fileContent); - expect(result.contentType).to.equal("text/plain"); - }); - - it("should return 404 if file does not exist", async () => { - const mockContext = createMockContext(); - mockContext.req.param.withArgs("tabloId").returns(mockTablo.id); - mockContext.req.param.withArgs("fileName").returns("nonexistent.txt"); - mockContext.get.withArgs("user").returns(mockUser); - mockContext.get.withArgs("supabase").returns(mockSupabase); - mockContext.get.withArgs("s3_client").returns(mockS3); - - // Mock S3 get object with no body - mockS3.send.resolves({ - Body: null, - }); - - // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context - const handler = async (c: any) => { - const _tabloId = c.req.param("tabloId"); - const fileName = c.req.param("fileName"); - const s3_client = c.get("s3_client"); - - try { - const response = await s3_client.send({}); - - if (!response.Body) { - return c.json({ error: "File not found" }, 404); - } - - const content = await response.Body.transformToString(); - - return c.json({ - fileName, - content, - contentType: response.ContentType, - lastModified: response.LastModified, - }); - } catch { - return c.json({ error: "Failed to fetch file" }, 500); - } - }; - - const result = await handler(mockContext); - - expect(result).to.deep.equal({ error: "File not found" }); - }); - - it("should return 500 if S3 operation fails", async () => { - const mockContext = createMockContext(); - mockContext.req.param.withArgs("tabloId").returns(mockTablo.id); - mockContext.req.param.withArgs("fileName").returns("test.txt"); - mockContext.get.withArgs("user").returns(mockUser); - mockContext.get.withArgs("supabase").returns(mockSupabase); - mockContext.get.withArgs("s3_client").returns(mockS3); - - // Mock S3 error - mockS3.send.rejects(new Error("S3 error")); - - // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context - const handler = async (c: any) => { - const _tabloId = c.req.param("tabloId"); - const fileName = c.req.param("fileName"); - const s3_client = c.get("s3_client"); - - try { - const response = await s3_client.send({}); - - if (!response.Body) { - return c.json({ error: "File not found" }, 404); - } - - const content = await response.Body.transformToString(); - - return c.json({ - fileName, - content, - contentType: response.ContentType, - lastModified: response.LastModified, - }); - } catch { - return c.json({ error: "Failed to fetch file" }, 500); - } - }; - - const result = await handler(mockContext); - - expect(result).to.deep.equal({ error: "Failed to fetch file" }); - }); - }); - - describe("POST /:tabloId/:fileName", () => { - it("should upload file successfully for tablo admin", async () => { - const mockContext = createMockContext(); - const fileContent = "Hello, World!"; - mockContext.req.param.withArgs("tabloId").returns(mockTablo.id); - mockContext.req.param.withArgs("fileName").returns("test.txt"); - mockContext.req.json.resolves({ - content: fileContent, - contentType: "text/plain", - }); - mockContext.get.withArgs("user").returns(mockUser); - mockContext.get.withArgs("supabase").returns(mockSupabase); - mockContext.get.withArgs("s3_client").returns(mockS3); - - // Mock S3 put object - mockS3.send.resolves({}); - - // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context - const handler = async (c: any) => { - const tabloId = c.req.param("tabloId"); - const fileName = c.req.param("fileName"); - const s3_client = c.get("s3_client"); - - try { - const body = await c.req.json(); - const { content } = body; - - if (!content) { - return c.json({ error: "Content is required" }, 400); - } - - await s3_client.send({}); - - return c.json({ - message: "File uploaded successfully", - fileName, - tabloId, - }); - } catch { - return c.json({ error: "Failed to upload file" }, 500); - } - }; - - const result = await handler(mockContext); - - expect(result).to.deep.equal({ - message: "File uploaded successfully", - fileName: "test.txt", - tabloId: mockTablo.id, - }); - expect(mockS3.send.calledOnce).to.be.true; - }); - - it("should return 400 if content is missing", async () => { - const mockContext = createMockContext(); - mockContext.req.param.withArgs("tabloId").returns(mockTablo.id); - mockContext.req.param.withArgs("fileName").returns("test.txt"); - mockContext.req.json.resolves({ - contentType: "text/plain", - }); - - // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context - const handler = async (c: any) => { - const _tabloId = c.req.param("tabloId"); - const _fileName = c.req.param("fileName"); - - try { - const body = await c.req.json(); - const { content } = body; - - if (!content) { - return c.json({ error: "Content is required" }, 400); - } - - return c.json({ message: "Success" }); - } catch { - return c.json({ error: "Failed to upload file" }, 500); - } - }; - - const result = await handler(mockContext); - - expect(result).to.deep.equal({ error: "Content is required" }); - }); - - it("should return 500 if S3 upload fails", async () => { - const mockContext = createMockContext(); - const fileContent = "Hello, World!"; - mockContext.req.param.withArgs("tabloId").returns(mockTablo.id); - mockContext.req.param.withArgs("fileName").returns("test.txt"); - mockContext.req.json.resolves({ - content: fileContent, - contentType: "text/plain", - }); - mockContext.get.withArgs("s3_client").returns(mockS3); - - // Mock S3 error - mockS3.send.rejects(new Error("S3 error")); - - // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context - const handler = async (c: any) => { - const tabloId = c.req.param("tabloId"); - const fileName = c.req.param("fileName"); - const s3_client = c.get("s3_client"); - - try { - const body = await c.req.json(); - const { content } = body; - - if (!content) { - return c.json({ error: "Content is required" }, 400); - } - - await s3_client.send({}); - - return c.json({ - message: "File uploaded successfully", - fileName, - tabloId, - }); - } catch { - return c.json({ error: "Failed to upload file" }, 500); - } - }; - - const result = await handler(mockContext); - - expect(result).to.deep.equal({ error: "Failed to upload file" }); - }); - }); - - describe("DELETE /:tabloId/:fileName", () => { - it("should delete file successfully for tablo admin", async () => { - const mockContext = createMockContext(); - mockContext.req.param.withArgs("tabloId").returns(mockTablo.id); - mockContext.req.param.withArgs("fileName").returns("test.txt"); - mockContext.get.withArgs("user").returns(mockUser); - mockContext.get.withArgs("supabase").returns(mockSupabase); - mockContext.get.withArgs("s3_client").returns(mockS3); - - // Mock S3 delete object - mockS3.send.resolves({}); - - // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context - const handler = async (c: any) => { - const tabloId = c.req.param("tabloId"); - const fileName = c.req.param("fileName"); - const s3_client = c.get("s3_client"); - - try { - await s3_client.send({}); - - return c.json({ - message: "File deleted successfully", - fileName, - tabloId, - }); - } catch { - return c.json({ error: "Failed to delete file" }, 500); - } - }; - - const result = await handler(mockContext); - - expect(result).to.deep.equal({ - message: "File deleted successfully", - fileName: "test.txt", - tabloId: mockTablo.id, - }); - expect(mockS3.send.calledOnce).to.be.true; - }); - - it("should return 500 if S3 delete fails", async () => { - const mockContext = createMockContext(); - mockContext.req.param.withArgs("tabloId").returns(mockTablo.id); - mockContext.req.param.withArgs("fileName").returns("test.txt"); - mockContext.get.withArgs("s3_client").returns(mockS3); - - // Mock S3 error - mockS3.send.rejects(new Error("S3 error")); - - // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context - const handler = async (c: any) => { - const tabloId = c.req.param("tabloId"); - const fileName = c.req.param("fileName"); - const s3_client = c.get("s3_client"); - - try { - await s3_client.send({}); - - return c.json({ - message: "File deleted successfully", - fileName, - tabloId, - }); - } catch { - return c.json({ error: "Failed to delete file" }, 500); - } - }; - - const result = await handler(mockContext); - - expect(result).to.deep.equal({ error: "Failed to delete file" }); - }); - }); -}); diff --git a/api/src/__tests__/tasks.test.ts b/api/src/__tests__/tasks.test.ts deleted file mode 100644 index 3f3fbe1..0000000 --- a/api/src/__tests__/tasks.test.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { expect } from "chai"; -import { afterEach, beforeEach, describe, it } from "mocha"; -import sinon from "sinon"; -import { - createMockContext, - createMockS3Client, - createMockSupabaseClient, - mockEnvVars, -} from "./test-utils.js"; - -describe("Tasks Router", () => { - // biome-ignore lint/suspicious/noExplicitAny: Mock client types - let mockSupabase: any; - // biome-ignore lint/suspicious/noExplicitAny: Mock client types - let mockS3: any; - let restoreEnv: () => void; - - beforeEach(() => { - restoreEnv = mockEnvVars(); - mockSupabase = createMockSupabaseClient(); - mockS3 = createMockS3Client(); - }); - - afterEach(() => { - sinon.restore(); - restoreEnv(); - }); - - describe("POST /sync-calendars", () => { - it("should sync all calendars successfully with valid auth", async () => { - const mockContext = createMockContext(); - mockContext.req.header - .withArgs("Authorization") - .returns(`Basic ${process.env.SYNC_CALS_SECRET}`); - mockContext.get.withArgs("supabase").returns(mockSupabase); - - const subscriptions = [ - { - token: "token1", - tablo_id: "tablo1", - tablos: { name: "Tablo 1" }, - }, - { - token: "token2", - tablo_id: "tablo2", - tablos: { name: "Tablo 2" }, - }, - ]; - - // Mock calendar subscriptions query - const subscriptionBuilder = { - select: sinon.stub().resolves({ data: subscriptions, error: null }), - }; - - mockSupabase.from - .withArgs("calendar_subscriptions") - .returns(subscriptionBuilder); - - // Mock events query for each tablo - const eventsBuilder = { - select: sinon.stub().returnsThis(), - eq: sinon.stub().resolves({ data: [], error: null }), - }; - - mockSupabase.from.withArgs("events_and_tablos").returns(eventsBuilder); - - // Mock S3 send - mockS3.send.resolves({}); - - // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context - const handler = async (c: any) => { - const supabase = c.get("supabase"); - if ( - c.req.header("Authorization") !== - `Basic ${process.env.SYNC_CALS_SECRET}` - ) { - return c.json({ error: "Unauthorized" }, 401); - } - - const { error } = await supabase - .from("calendar_subscriptions") - .select("token, tablo_id, tablos(name)"); - - if (error) { - return c.json({ error: error.message }, 500); - } - - return c.json({ message: "Synced calendars" }); - }; - - const result = await handler(mockContext); - - expect(result).to.deep.equal({ message: "Synced calendars" }); - }); - - it("should return 401 if authorization header is missing", async () => { - const mockContext = createMockContext(); - mockContext.req.header.withArgs("Authorization").returns(undefined); - - // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context - const handler = async (c: any) => { - if ( - c.req.header("Authorization") !== - `Basic ${process.env.SYNC_CALS_SECRET}` - ) { - return c.json({ error: "Unauthorized" }, 401); - } - - return c.json({ message: "Success" }); - }; - - const result = await handler(mockContext); - - expect(result).to.deep.equal({ error: "Unauthorized" }); - }); - - it("should return 401 if authorization header is invalid", async () => { - const mockContext = createMockContext(); - mockContext.req.header - .withArgs("Authorization") - .returns("Basic invalid-secret"); - - // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context - const handler = async (c: any) => { - if ( - c.req.header("Authorization") !== - `Basic ${process.env.SYNC_CALS_SECRET}` - ) { - return c.json({ error: "Unauthorized" }, 401); - } - - return c.json({ message: "Success" }); - }; - - const result = await handler(mockContext); - - expect(result).to.deep.equal({ error: "Unauthorized" }); - }); - - it("should return 500 if database error occurs", async () => { - const mockContext = createMockContext(); - mockContext.req.header - .withArgs("Authorization") - .returns(`Basic ${process.env.SYNC_CALS_SECRET}`); - mockContext.get.withArgs("supabase").returns(mockSupabase); - - // Mock calendar subscriptions query with error - const subscriptionBuilder = { - select: sinon - .stub() - .resolves({ data: null, error: { message: "Database error" } }), - }; - - mockSupabase.from - .withArgs("calendar_subscriptions") - .returns(subscriptionBuilder); - - // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context - const handler = async (c: any) => { - const supabase = c.get("supabase"); - if ( - c.req.header("Authorization") !== - `Basic ${process.env.SYNC_CALS_SECRET}` - ) { - return c.json({ error: "Unauthorized" }, 401); - } - - const { error } = await supabase - .from("calendar_subscriptions") - .select("token, tablo_id, tablos(name)"); - - if (error) { - return c.json({ error: error.message }, 500); - } - - return c.json({ message: "Synced calendars" }); - }; - - const result = await handler(mockContext); - - expect(result).to.deep.equal({ error: "Database error" }); - }); - }); -}); diff --git a/api/src/__tests__/test-utils.ts b/api/src/__tests__/test-utils.ts deleted file mode 100644 index 9b0384d..0000000 --- a/api/src/__tests__/test-utils.ts +++ /dev/null @@ -1,203 +0,0 @@ -import type { S3Client } from "@aws-sdk/client-s3"; -import type { SupabaseClient } from "@supabase/supabase-js"; -import { expect } from "chai"; -import type { SinonStub, SinonStubbedInstance } from "sinon"; -import sinon from "sinon"; -import type { StreamChat } from "stream-chat"; - -// Mock user for testing -export const mockUser = { - id: "test-user-id", - email: "test@example.com", - aud: "authenticated", - role: "authenticated", - created_at: "2024-01-01T00:00:00Z", - updated_at: "2024-01-01T00:00:00Z", - app_metadata: {}, - user_metadata: {}, -}; - -export const mockProfile = { - id: "test-user-id", - name: "Test User", - email: "test@example.com", - short_user_id: "testuser", - is_temporary: false, - created_at: "2024-01-01T00:00:00Z", -}; - -export const mockTablo = { - id: "test-tablo-id", - name: "Test Tablo", - color: "bg-blue-500", - status: "todo", - owner_id: "test-user-id", - created_at: "2024-01-01T00:00:00Z", - deleted_at: null, -}; - -export const mockEvent = { - id: "test-event-id", - tablo_id: "test-tablo-id", - title: "Test Event", - description: "Test description", - start_date: "2024-01-16", - start_time: "10:00", - end_time: "11:00", - created_by: "test-user-id", - created_at: "2024-01-01T00:00:00Z", - deleted_at: null, -}; - -// Create a mock Supabase client -export function createMockSupabaseClient(): SupabaseClient { - const mockSupabase = { - auth: { - getUser: sinon.stub(), - signUp: sinon.stub(), - signIn: sinon.stub(), - }, - from: sinon.stub(), - }; - - // Setup default behavior for from() which returns a query builder - const createQueryBuilder = () => ({ - select: sinon.stub().returnsThis(), - insert: sinon.stub().returnsThis(), - update: sinon.stub().returnsThis(), - delete: sinon.stub().returnsThis(), - eq: sinon.stub().returnsThis(), - neq: sinon.stub().returnsThis(), - gt: sinon.stub().returnsThis(), - gte: sinon.stub().returnsThis(), - lt: sinon.stub().returnsThis(), - lte: sinon.stub().returnsThis(), - is: sinon.stub().returnsThis(), - in: sinon.stub().returnsThis(), - single: sinon.stub(), - limit: sinon.stub().returnsThis(), - order: sinon.stub().returnsThis(), - }); - - mockSupabase.from.returns(createQueryBuilder()); - - return mockSupabase as unknown as SupabaseClient; -} - -// Create a mock Stream Chat client -export function createMockStreamChatClient(): { - mockStreamChat: StreamChat; - mockChannel: ReturnType; -} { - const mockChannel = { - create: sinon.stub().resolves(), - update: sinon.stub().resolves(), - delete: sinon.stub().resolves(), - addMembers: sinon.stub().resolves(), - removeMembers: sinon.stub().resolves(), - sendMessage: sinon.stub().resolves(), - }; - - const mockStreamChat = { - upsertUser: sinon.stub().resolves(), - createToken: sinon.stub().returns("mock-stream-token"), - channel: sinon.stub().returns(mockChannel), - }; - - return { - mockStreamChat: mockStreamChat as unknown as StreamChat, - mockChannel: mockChannel as unknown as ReturnType, - }; -} - -// Create a mock S3 client -export function createMockS3Client(): S3Client { - const mockS3 = { - send: sinon.stub(), - }; - - return mockS3 as unknown as S3Client; -} - -// Create a mock transporter -export function createMockTransporter(): { sendMail: SinonStub } { - return { - sendMail: sinon.stub().resolves({ messageId: "mock-message-id" }), - }; -} - -// Helper to create a mock Hono context -export function createMockContext(overrides: Record = {}) { - const context = { - req: { - json: sinon.stub(), - header: sinon.stub(), - param: sinon.stub(), - }, - json: sinon.stub().returnsArg(0), - get: sinon.stub(), - set: sinon.stub(), - ...overrides, - }; - - // biome-ignore lint/suspicious/noExplicitAny: Mock context needs flexibility - return context as any; -} - -// Helper to create a mock next function -export function createMockNext() { - return sinon.stub().resolves(); -} - -// Helper to reset all stubs -// biome-ignore lint/suspicious/noExplicitAny: Flexible stub reset utility -export function resetAllStubs(...stubs: any[]) { - stubs.forEach((stub) => { - if (stub && typeof stub.reset === "function") { - stub.reset(); - } else if (stub && typeof stub === "object") { - // biome-ignore lint/suspicious/noExplicitAny: Need to check nested values - Object.values(stub).forEach((value: any) => { - if (value && typeof value.reset === "function") { - value.reset(); - } - }); - } - }); -} - -// Helper to verify stub was called with specific args -// biome-ignore lint/suspicious/noExplicitAny: Flexible argument checking -export function assertCalledWith(stub: SinonStub, ...args: any[]) { - expect(stub.calledWith(...args)).to.be.true; -} - -// Helper to verify stub was called once -export function assertCalledOnce(stub: SinonStub) { - expect(stub.calledOnce).to.be.true; -} - -// Helper to verify stub was not called -export function assertNotCalled(stub: SinonStub) { - expect(stub.called).to.be.false; -} - -// Mock environment variables -export function mockEnvVars() { - const originalEnv = { ...process.env }; - - process.env.SUPABASE_URL = "https://test.supabase.co"; - process.env.SUPABASE_SERVICE_ROLE_KEY = "test-service-role-key"; - process.env.STREAM_CHAT_API_KEY = "test-stream-key"; - process.env.STREAM_CHAT_API_SECRET = "test-stream-secret"; - process.env.R2_ACCOUNT_ID = "test-r2-account"; - process.env.R2_ACCESS_KEY_ID = "test-r2-access-key"; - process.env.R2_SECRET_ACCESS_KEY = "test-r2-secret"; - process.env.NODE_ENV = "test"; - process.env.FRONTEND_URL = "https://app.test.com"; - process.env.SYNC_CALS_SECRET = "test-sync-secret"; - - return () => { - process.env = originalEnv; - }; -} diff --git a/api/src/__tests__/user.test.ts b/api/src/__tests__/user.test.ts deleted file mode 100644 index ebce978..0000000 --- a/api/src/__tests__/user.test.ts +++ /dev/null @@ -1,337 +0,0 @@ -import { expect } from "chai"; -import { Hono } from "hono"; -import { afterEach, beforeEach, describe, it } from "mocha"; -import sinon from "sinon"; -import { userRouter } from "../user.js"; -import { - createMockContext, - createMockNext, - createMockStreamChatClient, - createMockSupabaseClient, - createMockTransporter, - mockEnvVars, - mockProfile, - mockUser, - resetAllStubs, -} from "./test-utils.js"; - -describe("User Router", () => { - // biome-ignore lint/suspicious/noExplicitAny: Mock client types - let mockSupabase: any; - // biome-ignore lint/suspicious/noExplicitAny: Mock client types - let mockStreamChat: any; - let restoreEnv: () => void; - - beforeEach(() => { - restoreEnv = mockEnvVars(); - mockSupabase = createMockSupabaseClient(); - const streamMocks = createMockStreamChatClient(); - mockStreamChat = streamMocks.mockStreamChat; - }); - - afterEach(() => { - sinon.restore(); - restoreEnv(); - }); - - describe("POST /sign-up-to-stream", () => { - it("should successfully sign up user to Stream Chat", async () => { - const mockContext = createMockContext(); - mockContext.get.withArgs("user").returns(mockUser); - mockContext.get.withArgs("supabase").returns(mockSupabase); - mockContext.get.withArgs("streamServerClient").returns(mockStreamChat); - - // Mock Supabase response - mockSupabase - .from() - .select() - .eq() - .single.resolves({ data: mockProfile, error: null }); - - // Create a test handler - // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context - const handler = async (c: any) => { - const { id } = c.get("user"); - const supabase = c.get("supabase"); - - const { data } = await supabase - .from("profiles") - .select("*") - .eq("id", id) - .single(); - - const streamServerClient = c.get("streamServerClient"); - await streamServerClient.upsertUser({ - id, - name: data.name ?? "", - language: "fr", - }); - - return c.json({ - message: "User signed up to stream", - }); - }; - - const result = await handler(mockContext); - - expect(mockStreamChat.upsertUser.calledOnce).to.be.true; - expect( - mockStreamChat.upsertUser.calledWith({ - id: mockUser.id, - name: mockProfile.name, - language: "fr", - }) - ).to.be.true; - expect(result).to.deep.equal({ message: "User signed up to stream" }); - }); - }); - - describe("GET /me", () => { - it("should return user profile with Stream token", async () => { - const mockContext = createMockContext(); - mockContext.get.withArgs("user").returns(mockUser); - mockContext.get.withArgs("supabase").returns(mockSupabase); - mockContext.get.withArgs("streamServerClient").returns(mockStreamChat); - - // Mock Supabase response - mockSupabase - .from() - .select() - .eq() - .single.resolves({ data: mockProfile, error: null }); - - // Create a test handler - // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context - const handler = async (c: any) => { - const user = c.get("user"); - const supabase = c.get("supabase"); - const streamServerClient = c.get("streamServerClient"); - - const { data, error } = await supabase - .from("profiles") - .select("*") - .eq("id", user.id) - .single(); - - if (!data) { - return c.json({ error: "User not found" }, 404); - } - - if (error) { - return c.json({ error: error.message }, 500); - } - - const user_id = data.id; - const token = streamServerClient.createToken(user_id); - - return c.json({ - ...data, - streamToken: token, - }); - }; - - const result = await handler(mockContext); - - expect(mockStreamChat.createToken.calledOnce).to.be.true; - expect(mockStreamChat.createToken.calledWith(mockUser.id)).to.be.true; - expect(result).to.deep.equal({ - ...mockProfile, - streamToken: "mock-stream-token", - }); - }); - - it("should return 404 if user profile not found", async () => { - const mockContext = createMockContext(); - mockContext.get.withArgs("user").returns(mockUser); - mockContext.get.withArgs("supabase").returns(mockSupabase); - mockContext.get.withArgs("streamServerClient").returns(mockStreamChat); - - // Mock Supabase response with no data - mockSupabase - .from() - .select() - .eq() - .single.resolves({ data: null, error: null }); - - // Create a test handler - // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context - const handler = async (c: any) => { - const user = c.get("user"); - const supabase = c.get("supabase"); - const streamServerClient = c.get("streamServerClient"); - - const { data, error } = await supabase - .from("profiles") - .select("*") - .eq("id", user.id) - .single(); - - if (!data) { - return c.json({ error: "User not found" }, 404); - } - - if (error) { - return c.json({ error: error.message }, 500); - } - - const user_id = data.id; - const token = streamServerClient.createToken(user_id); - - return c.json({ - ...data, - streamToken: token, - }); - }; - - const result = await handler(mockContext); - - expect(result).to.deep.equal({ error: "User not found" }); - }); - - it("should return 500 if database error occurs", async () => { - const mockContext = createMockContext(); - mockContext.get.withArgs("user").returns(mockUser); - mockContext.get.withArgs("supabase").returns(mockSupabase); - mockContext.get.withArgs("streamServerClient").returns(mockStreamChat); - - // Mock Supabase response with error - mockSupabase - .from() - .select() - .eq() - .single.resolves({ - data: mockProfile, - error: { message: "Database error" }, - }); - - // Create a test handler - // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context - const handler = async (c: any) => { - const user = c.get("user"); - const supabase = c.get("supabase"); - const streamServerClient = c.get("streamServerClient"); - - const { data, error } = await supabase - .from("profiles") - .select("*") - .eq("id", user.id) - .single(); - - if (!data) { - return c.json({ error: "User not found" }, 404); - } - - if (error) { - return c.json({ error: error.message }, 500); - } - - const user_id = data.id; - const token = streamServerClient.createToken(user_id); - - return c.json({ - ...data, - streamToken: token, - }); - }; - - const result = await handler(mockContext); - - expect(result).to.deep.equal({ error: "Database error" }); - }); - }); - - describe("POST /mark-temporary", () => { - it("should mark user as temporary and send email", async () => { - const mockContext = createMockContext(); - mockContext.req.json.resolves({ temporary_password: "temp123" }); - mockContext.get.withArgs("user").returns(mockUser); - mockContext.get.withArgs("supabase").returns(mockSupabase); - - // Mock Supabase update response - mockSupabase - .from() - .update() - .eq() - .select() - .single.resolves({ - data: { ...mockProfile, is_temporary: true }, - error: null, - }); - - // Create a test handler - // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context - const handler = async (c: any) => { - const user = c.get("user"); - const supabase = c.get("supabase"); - - await c.req.json(); - - const { error } = await supabase - .from("profiles") - .update({ - is_temporary: true, - }) - .eq("id", user.id) - .select() - .single(); - - if (error) { - return c.json({ error: error.message }, 500); - } - - return c.json({ - message: "User marked as temporary", - }); - }; - - const result = await handler(mockContext); - - expect(result).to.deep.equal({ message: "User marked as temporary" }); - }); - - it("should return 500 if database update fails", async () => { - const mockContext = createMockContext(); - mockContext.req.json.resolves({ temporary_password: "temp123" }); - mockContext.get.withArgs("user").returns(mockUser); - mockContext.get.withArgs("supabase").returns(mockSupabase); - - // Mock Supabase error response - mockSupabase - .from() - .update() - .eq() - .select() - .single.resolves({ data: null, error: { message: "Update failed" } }); - - // Create a test handler - // biome-ignore lint/suspicious/noExplicitAny: Test handler with dynamic context - const handler = async (c: any) => { - const user = c.get("user"); - const supabase = c.get("supabase"); - - await c.req.json(); - - const { error } = await supabase - .from("profiles") - .update({ - is_temporary: true, - }) - .eq("id", user.id) - .select() - .single(); - - if (error) { - return c.json({ error: error.message }, 500); - } - - return c.json({ - message: "User marked as temporary", - }); - }; - - const result = await handler(mockContext); - - expect(result).to.deep.equal({ error: "Update failed" }); - }); - }); -}); diff --git a/api/src/tablo.ts b/api/src/tablo.ts index ade1b99..a46f4f2 100644 --- a/api/src/tablo.ts +++ b/api/src/tablo.ts @@ -1,15 +1,27 @@ import { PutObjectCommand, type S3Client } from "@aws-sdk/client-s3"; -import { PostgrestError, type SupabaseClient, type User } from "@supabase/supabase-js"; +import { + PostgrestError, + type SupabaseClient, + type User, +} from "@supabase/supabase-js"; import { Hono } from "hono"; import type { Transporter } from "nodemailer"; import type { StreamChat } from "stream-chat"; import { config } from "./config.js"; import type { Tables } from "./database.types.ts"; import { generateICSFromEvents, writeCalendarFileToR2 } from "./helpers.js"; -import { authMiddleware, r2Middleware, streamChatMiddleware } from "./middleware.js"; +import { + authMiddleware, + r2Middleware, + streamChatMiddleware, +} from "./middleware.js"; import { generateToken } from "./token.js"; import { transporter } from "./transporter.js"; -import type { EventAndTablo, EventInsertInTablo, TabloInsert } from "./types.ts"; +import type { + EventAndTablo, + EventInsertInTablo, + TabloInsert, +} from "./types.ts"; export const tabloRouter = new Hono<{ Variables: { @@ -166,7 +178,9 @@ tabloRouter.post("/create-and-invite", async (c) => { const { data: insertedTablo, error } = await supabase .from("tablos") .insert({ - name: `${invitedUserDataTyped.name || "Invité"} / ${ownerDataTyped.name || "Propriétaire"}`, + name: `${invitedUserDataTyped.name || "Invité"} / ${ + ownerDataTyped.name || "Propriétaire" + }`, color: "bg-blue-500", status: "todo", owner_id: ownerId, @@ -184,20 +198,22 @@ tabloRouter.post("/create-and-invite", async (c) => { } // Grant access to the current user (invited user) as a non-admin member - const { error: tabloAccessError } = await supabase.from("tablo_access").insert( - { - tablo_id: tabloData.id, - user_id: user.id, - // ** IMPORTANT ** - is_admin: false, - // ------------- - is_active: true, - granted_by: ownerId, - } - // { - // onConflict: "tablo_id, user_id", - // } - ); + const { error: tabloAccessError } = await supabase + .from("tablo_access") + .insert( + { + tablo_id: tabloData.id, + user_id: user.id, + // ** IMPORTANT ** + is_admin: false, + // ------------- + is_active: true, + granted_by: ownerId, + } + // { + // onConflict: "tablo_id, user_id", + // } + ); if (tabloAccessError) { console.error("tabloAccessError", tabloAccessError); @@ -290,7 +306,8 @@ tabloRouter.patch("/update", async (c) => { const updatedTablo = update as Tables<"tablos">; - const isUpdatingName = tablo.name !== undefined && tablo.name !== updatedTablo.name; + const isUpdatingName = + tablo.name !== undefined && tablo.name !== updatedTablo.name; if (error) { return c.json({ error: error.message }, 500); @@ -366,7 +383,10 @@ tabloRouter.post("/invite", async (c) => { } if (tablo.owner_id !== sender.id) { - return c.json({ error: "You are not allowed to invite users to this tablo" }, 400); + return c.json( + { error: "You are not allowed to invite users to this tablo" }, + 400 + ); } const { error } = await supabase.from("tablo_invites").insert({ @@ -386,7 +406,9 @@ tabloRouter.post("/invite", async (c) => { subject: "Vous avez été invité à un tablo", html: `

Vous avez été invité à un tablo avec ce lien

`, + }/join/${encodeURIComponent(tablo.name)}?token=${encodeURIComponent( + token + )}">ce lien

`, }); return c.json({ @@ -419,15 +441,17 @@ tabloRouter.post("/join", async (c) => { const { id: invite_id, tablo_id, invited_by } = inviteData; - const { error: tabloAccessError } = await supabase.from("tablo_access").insert({ - tablo_id, - user_id: joiner.id, - // ** IMPORTANT ** - is_admin: false, - // ------------- - is_active: true, - granted_by: invited_by, - }); + const { error: tabloAccessError } = await supabase + .from("tablo_access") + .insert({ + tablo_id, + user_id: joiner.id, + // ** IMPORTANT ** + is_admin: false, + // ------------- + is_active: true, + granted_by: invited_by, + }); if (tabloAccessError) { console.error("tabloAccessError", tabloAccessError); diff --git a/api/src/tablo_data.ts b/api/src/tablo_data.ts index c3b3aab..c7ec936 100644 --- a/api/src/tablo_data.ts +++ b/api/src/tablo_data.ts @@ -2,7 +2,11 @@ import type { S3Client } from "@aws-sdk/client-s3"; import type { SupabaseClient, User } from "@supabase/supabase-js"; import { type Context, Hono, type Next } from "hono"; import { getTabloFileNames, isTabloAdmin, isTabloMember } from "./helpers.js"; -import { authMiddleware, r2Middleware, streamChatMiddleware } from "./middleware.js"; +import { + authMiddleware, + r2Middleware, + streamChatMiddleware, +} from "./middleware.js"; export const tabloDataRouter = new Hono<{ Variables: { diff --git a/api/src/transporter.ts b/api/src/transporter.ts index 87357cf..f6929ba 100644 --- a/api/src/transporter.ts +++ b/api/src/transporter.ts @@ -1,9 +1,12 @@ import { google } from "googleapis"; import nodemailer from "nodemailer"; +import type { Transporter } from "nodemailer"; import { config } from "./config.js"; const OAuth2 = google.auth.OAuth2; +let _transporter: Transporter | null = null; + export const createTransporter = async () => { const oauth2Client = new OAuth2( config.EMAIL_CLIENT_ID, @@ -31,4 +34,22 @@ export const createTransporter = async () => { return transporter; }; -export const transporter = await createTransporter(); +// Lazy-loaded transporter to avoid top-level await +export async function getTransporter(): Promise { + if (!_transporter) { + _transporter = await createTransporter(); + } + return _transporter; +} + +// For backwards compatibility (will be deprecated) +export let transporter: Transporter | null = null; + +// Initialize on first import (but not blocking) +getTransporter() + .then((t) => { + transporter = t; + }) + .catch((error) => { + console.error("Failed to initialize transporter:", error); + }); diff --git a/api/src/user.ts b/api/src/user.ts index 0f01312..5f42125 100644 --- a/api/src/user.ts +++ b/api/src/user.ts @@ -93,7 +93,7 @@ userRouter.post("/mark-temporary", async (c) => { } try { - if (profile?.email) { + if (profile?.email && transporter) { const mailOptions = { from: "Xtablo ", to: profile.email, From c8856a35a0c8249f624fe354693733f7a0570a3c Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Tue, 14 Oct 2025 22:27:17 +0200 Subject: [PATCH 05/17] Big update / introduce shadcn --- ui/components.json | 22 + ui/package.json | 16 + ui/pnpm-lock.yaml | 1065 ++++++++++++++++- ui/src/App.tsx | 2 + ui/src/components/AvailabilityCard.tsx | 117 +- .../components/AvailabilityVisualization.tsx | 2 +- ui/src/components/ChannelPreview.tsx | 2 +- ui/src/components/CustomModal.tsx | 64 +- ui/src/components/EventDetailsModal.tsx | 21 +- ui/src/components/EventModal.tsx | 137 +-- ui/src/components/EventTypeModal.tsx | 49 +- ui/src/components/ImportICSModal.tsx | 40 +- ui/src/components/Layout.tsx | 13 +- ui/src/components/NavigationBar.test.tsx | 3 +- ui/src/components/NavigationBar.tsx | 142 +-- ui/src/components/RowActionMenu.tsx | 45 - ui/src/components/SignOutButton.test.tsx | 2 +- ui/src/components/SignOutButton.tsx | 127 +- ui/src/components/TabloModal.tsx | 16 +- ui/src/components/TabloTutorial.tsx | 10 +- ui/src/components/ThemeSwitcher.test.tsx | 2 +- ui/src/components/ThemeSwitcher.tsx | 97 +- ui/src/components/WebcalModal.tsx | 42 +- ui/src/components/devis/CreateDevisModal.tsx | 178 --- ui/src/components/devis/DeleteDevisModal.tsx | 65 - ui/src/components/devis/ViewDevisModal.tsx | 118 -- ui/src/components/header.tsx | 2 +- ui/src/components/kanban/KanbanBoard.tsx | 325 ----- ui/src/components/ui/avatar.tsx | 56 + ui/src/components/ui/badge.tsx | 133 ++ ui/src/components/ui/button-group.tsx | 83 ++ ui/src/components/ui/button.tsx | 51 + ui/src/components/ui/calendar.tsx | 177 +++ ui/src/components/ui/checkbox.tsx | 26 + ui/src/components/ui/collapsible.tsx | 11 + ui/src/components/ui/date-field.tsx | 127 ++ ui/src/components/ui/date-picker.tsx | 82 ++ ui/src/components/ui/dialog.tsx | 104 ++ ui/src/components/ui/dropdown-menu.tsx | 185 +++ ui/src/components/ui/empty.tsx | 104 ++ ui/src/components/ui/input.tsx | 22 + ui/src/components/ui/label.tsx | 19 + ui/src/components/ui/popover.tsx | 29 + ui/src/components/ui/select.tsx | 151 +++ ui/src/components/ui/separator.tsx | 26 + ui/src/components/ui/sonner.tsx | 27 + ui/src/components/ui/switch.tsx | 26 + ui/src/components/ui/typography.tsx | 132 ++ ui/src/hooks/auth.ts | 11 +- ui/src/hooks/events.ts | 19 +- ui/src/hooks/invite.ts | 10 +- ui/src/hooks/tablo_data.ts | 106 +- ui/src/hooks/tablos.ts | 16 +- ui/src/hooks/webcal.ts | 5 +- ui/src/lib/routes.tsx | 10 - ui/src/lib/toast.ts | 77 ++ ui/src/lib/utils.ts | 6 + ui/src/main.css | 160 ++- ui/src/main.tsx | 2 - ui/src/pages/NotFoundPage.tsx | 4 +- ui/src/pages/PublicBookingPage.tsx | 40 +- ui/src/pages/availabilities.tsx | 103 +- ui/src/pages/bookings.tsx | 136 +-- ui/src/pages/devis.test.tsx | 244 ---- ui/src/pages/devis.tsx | 282 ----- ui/src/pages/event-types-page.tsx | 43 +- ui/src/pages/feedback.tsx | 16 +- ui/src/pages/join.tsx | 2 +- ui/src/pages/kanban.tsx | 313 ----- ui/src/pages/landing.tsx | 2 +- ui/src/pages/login.tsx | 10 +- ui/src/pages/planning.tsx | 46 +- ui/src/pages/reset-password.tsx | 14 +- ui/src/pages/signup.tsx | 14 +- ui/src/pages/support.tsx | 16 +- ui/src/pages/tablo.tsx | 481 +++++++- ui/src/ui-library/theme/index.css | 146 --- ui/src/ui-library/toast/toast-queue.ts | 31 - ui/src/ui-library/toast/toast-region.tsx | 199 --- ui/stats.html | 2 +- ui/tsconfig.json | 6 +- ui/worker/index.d.ts | 2 +- 82 files changed, 4045 insertions(+), 2824 deletions(-) create mode 100644 ui/components.json delete mode 100644 ui/src/components/RowActionMenu.tsx delete mode 100644 ui/src/components/devis/CreateDevisModal.tsx delete mode 100644 ui/src/components/devis/DeleteDevisModal.tsx delete mode 100644 ui/src/components/devis/ViewDevisModal.tsx delete mode 100644 ui/src/components/kanban/KanbanBoard.tsx create mode 100644 ui/src/components/ui/avatar.tsx create mode 100644 ui/src/components/ui/badge.tsx create mode 100644 ui/src/components/ui/button-group.tsx create mode 100644 ui/src/components/ui/button.tsx create mode 100644 ui/src/components/ui/calendar.tsx create mode 100644 ui/src/components/ui/checkbox.tsx create mode 100644 ui/src/components/ui/collapsible.tsx create mode 100644 ui/src/components/ui/date-field.tsx create mode 100644 ui/src/components/ui/date-picker.tsx create mode 100644 ui/src/components/ui/dialog.tsx create mode 100644 ui/src/components/ui/dropdown-menu.tsx create mode 100644 ui/src/components/ui/empty.tsx create mode 100644 ui/src/components/ui/input.tsx create mode 100644 ui/src/components/ui/label.tsx create mode 100644 ui/src/components/ui/popover.tsx create mode 100644 ui/src/components/ui/select.tsx create mode 100644 ui/src/components/ui/separator.tsx create mode 100644 ui/src/components/ui/sonner.tsx create mode 100644 ui/src/components/ui/switch.tsx create mode 100644 ui/src/components/ui/typography.tsx create mode 100644 ui/src/lib/toast.ts create mode 100644 ui/src/lib/utils.ts delete mode 100644 ui/src/pages/devis.test.tsx delete mode 100644 ui/src/pages/devis.tsx delete mode 100644 ui/src/pages/kanban.tsx delete mode 100644 ui/src/ui-library/theme/index.css delete mode 100644 ui/src/ui-library/toast/toast-queue.ts delete mode 100644 ui/src/ui-library/toast/toast-region.tsx diff --git a/ui/components.json b/ui/components.json new file mode 100644 index 0000000..9673917 --- /dev/null +++ b/ui/components.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/main.css", + "baseColor": "zinc", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@ui/components", + "utils": "@ui/lib/utils", + "ui": "@ui/components/ui", + "lib": "@ui/lib", + "hooks": "@ui/hooks" + }, + "registries": {} +} diff --git a/ui/package.json b/ui/package.json index b814973..da1b60e 100644 --- a/ui/package.json +++ b/ui/package.json @@ -57,6 +57,7 @@ "tailwind-merge": "^3.0.2", "tailwindcss": "^4.0.14", "tailwindcss-animate": "^1.0.7", + "tw-animate-css": "^1.4.0", "typescript": "^5.7.0", "typescript-eslint": "^8.26.1", "vite": "^6.2.2", @@ -67,6 +68,17 @@ "dependencies": { "@datadog/browser-rum": "^6.13.0", "@datadog/browser-rum-react": "^6.13.0", + "@radix-ui/react-avatar": "^1.1.10", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.7", + "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-switch": "^1.2.6", "@react-stately/calendar": "^3.7.1", "@supabase/supabase-js": "^2.49.3", "@tailwindcss/vite": "^4.0.14", @@ -76,11 +88,15 @@ "ag-grid-community": "^33.2.1", "ag-grid-react": "^33.2.1", "axios": "^1.12.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", "date-fns": "^4.1.0", "jspdf": "^3.0.3", "jwt-decode": "^4.0.0", + "react-day-picker": "^9.11.1", "react-router-dom": "^7.9.4", "react-stately": "^3.36.1", + "sonner": "^2.0.7", "stream-chat": "^9.6.1", "stream-chat-react": "^13.1.0", "ts-pattern": "^5.6.2", diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index d4bce5c..dc05dec 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -18,6 +18,39 @@ importers: '@datadog/browser-rum-react': specifier: ^6.13.0 version: 6.13.0(@datadog/browser-rum@6.13.0)(react-router-dom@7.9.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0) + '@radix-ui/react-avatar': + specifier: ^1.1.10 + version: 1.1.10(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-checkbox': + specifier: ^1.3.3 + version: 1.3.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-collapsible': + specifier: ^1.1.12 + version: 1.1.12(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-dialog': + specifier: ^1.1.15 + version: 1.1.15(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-dropdown-menu': + specifier: ^2.1.16 + version: 2.1.16(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-label': + specifier: ^2.1.7 + version: 2.1.7(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-popover': + specifier: ^1.1.15 + version: 1.1.15(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-select': + specifier: ^2.2.6 + version: 2.2.6(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-separator': + specifier: ^1.1.7 + version: 1.1.7(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-slot': + specifier: ^1.2.3 + version: 1.2.3(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-switch': + specifier: ^1.2.6 + version: 1.2.6(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@react-stately/calendar': specifier: ^3.7.1 version: 3.7.1(react@19.0.0) @@ -43,8 +76,14 @@ importers: specifier: ^33.2.1 version: 33.2.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0) axios: - specifier: ^1.8.4 - version: 1.8.4 + specifier: ^1.12.2 + version: 1.12.2 + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 date-fns: specifier: ^4.1.0 version: 4.1.0 @@ -54,12 +93,18 @@ importers: jwt-decode: specifier: ^4.0.0 version: 4.0.0 + react-day-picker: + specifier: ^9.11.1 + version: 9.11.1(react@19.0.0) react-router-dom: specifier: ^7.9.4 version: 7.9.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0) react-stately: specifier: ^3.36.1 version: 3.36.1(react@19.0.0) + sonner: + specifier: ^2.0.7 + version: 2.0.7(react-dom@19.0.0(react@19.0.0))(react@19.0.0) stream-chat: specifier: ^9.6.1 version: 9.6.1 @@ -74,7 +119,7 @@ importers: version: 11.1.0 zustand: specifier: ^5.0.5 - version: 5.0.5(@types/react@19.0.10)(react@19.0.0)(use-sync-external-store@1.4.0(react@19.0.0)) + version: 5.0.5(@types/react@19.0.10)(react@19.0.0)(use-sync-external-store@1.6.0(react@19.0.0)) devDependencies: '@biomejs/biome': specifier: 2.2.5 @@ -187,6 +232,9 @@ importers: tailwindcss-animate: specifier: ^1.0.7 version: 1.0.7(tailwindcss@4.0.14) + tw-animate-css: + specifier: ^1.4.0 + version: 1.4.0 typescript: specifier: ^5.7.0 version: 5.7.3 @@ -536,6 +584,9 @@ packages: '@datadog/browser-logs': optional: true + '@date-fns/tz@1.4.1': + resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==} + '@emnapi/runtime@1.4.4': resolution: {integrity: sha512-hHyapA4A3gPaDCNfiqyZUStTMqIkKRshqPIuDOXv1hcBnD4U3l8cP0T1HMCfGRxQ6V64TGCcoswChANyOAwbQg==} @@ -1181,6 +1232,423 @@ packages: '@poppinss/exception@1.2.2': resolution: {integrity: sha512-m7bpKCD4QMlFCjA/nKTs23fuvoVFoA83brRKmObCUNmi/9tVu8Ve3w4YQAnJu4q3Tjf5fr685HYIC/IA2zHRSg==} + '@radix-ui/number@1.1.1': + resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} + + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + + '@radix-ui/react-arrow@1.1.7': + resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-avatar@1.1.10': + resolution: {integrity: sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-checkbox@1.3.3': + resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collapsible@1.1.12': + resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.1.7': + resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.1.2': + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.1.15': + resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-direction@1.1.1': + resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.11': + resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-dropdown-menu@2.1.16': + resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.3': + resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.7': + resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.1': + resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-label@2.1.7': + resolution: {integrity: sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-menu@2.1.16': + resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popover@1.1.15': + resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popper@1.2.8': + resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.9': + resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.5': + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.1.11': + resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-select@2.2.6': + resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-separator@1.1.7': + resolution: {integrity: sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-switch@1.2.6': + resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.1': + resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.1': + resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-is-hydrated@0.1.0': + resolution: {integrity: sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-previous@1.1.1': + resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.1': + resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.1': + resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-visually-hidden@1.2.3': + resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/rect@1.1.1': + resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@react-aria/autocomplete@3.0.0-beta.1': resolution: {integrity: sha512-ZeVR1tKJOZK5/RTuN8eprlP1lyeihdDfDYPBkdg2iT5h775LSZyOingPux9aLtdqt/uj6JIS5amK9ErI7+axug==} peerDependencies: @@ -2449,6 +2917,10 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + aria-query@5.3.0: resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} @@ -2507,8 +2979,8 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} - axios@1.8.4: - resolution: {integrity: sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==} + axios@1.12.2: + resolution: {integrity: sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==} babel-jest@29.7.0: resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} @@ -2676,6 +3148,9 @@ packages: cjs-module-lexer@1.4.3: resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} @@ -2772,6 +3247,9 @@ packages: resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} engines: {node: '>= 0.4'} + date-fns-jalali@4.1.0-0: + resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==} + date-fns@4.1.0: resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} @@ -2852,6 +3330,9 @@ packages: resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} engines: {node: '>=8'} + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -3192,6 +3673,10 @@ packages: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + get-package-type@0.1.0: resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} engines: {node: '>=8.0.0'} @@ -4484,6 +4969,12 @@ packages: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-day-picker@9.11.1: + resolution: {integrity: sha512-l3ub6o8NlchqIjPKrRFUCkTUEq6KwemQlfv3XZzzwpUeGwmDJ+0u0Upmt38hJyd7D/vn2dQoOoLV/qAp0o3uUw==} + engines: {node: '>=18'} + peerDependencies: + react: '>=16.8.0' + react-dom@19.0.0: resolution: {integrity: sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==} peerDependencies: @@ -4540,6 +5031,26 @@ packages: resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} engines: {node: '>=0.10.0'} + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.1: + resolution: {integrity: sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + react-router-dom@7.9.4: resolution: {integrity: sha512-f30P6bIkmYvnHHa5Gcu65deIXoA2+r3Eb6PJIAddvsT9aGlchMatJ51GgpU470aSqRRbFX22T70yQNUGuW3DfA==} engines: {node: '>=20.0.0'} @@ -4562,6 +5073,16 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + react-textarea-autosize@8.5.9: resolution: {integrity: sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==} engines: {node: '>=10'} @@ -4766,6 +5287,12 @@ packages: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} + sonner@2.0.7: + resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -5013,6 +5540,9 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tw-animate-css@1.4.0: + resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -5128,6 +5658,16 @@ packages: url-parse@1.5.10: resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + use-composed-ref@1.4.0: resolution: {integrity: sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w==} peerDependencies: @@ -5155,11 +5695,26 @@ packages: '@types/react': optional: true + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + use-sync-external-store@1.4.0: resolution: {integrity: sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + utrie@1.0.2: resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==} @@ -5754,6 +6309,8 @@ snapshots: '@datadog/browser-core': 6.13.0 '@datadog/browser-rum-core': 6.13.0 + '@date-fns/tz@1.4.1': {} + '@emnapi/runtime@1.4.4': dependencies: tslib: 2.8.1 @@ -6337,6 +6894,422 @@ snapshots: '@poppinss/exception@1.2.2': {} + '@radix-ui/number@1.1.1': {} + + '@radix-ui/primitive@1.1.3': {} + + '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.10 + '@types/react-dom': 19.0.4(@types/react@19.0.10) + + '@radix-ui/react-avatar@1.1.10(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@radix-ui/react-context': 1.1.2(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.0.10)(react@19.0.0) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.10 + '@types/react-dom': 19.0.4(@types/react@19.0.10) + + '@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.0.10)(react@19.0.0) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.10 + '@types/react-dom': 19.0.4(@types/react@19.0.10) + + '@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.0.10)(react@19.0.0) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.10 + '@types/react-dom': 19.0.4(@types/react@19.0.10) + + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.0.10)(react@19.0.0) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.10 + '@types/react-dom': 19.0.4(@types/react@19.0.10) + + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.0.10)(react@19.0.0)': + dependencies: + react: 19.0.0 + optionalDependencies: + '@types/react': 19.0.10 + + '@radix-ui/react-context@1.1.2(@types/react@19.0.10)(react@19.0.0)': + dependencies: + react: 19.0.0 + optionalDependencies: + '@types/react': 19.0.10 + + '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.0.10)(react@19.0.0) + aria-hidden: 1.2.6 + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + react-remove-scroll: 2.7.1(@types/react@19.0.10)(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.10 + '@types/react-dom': 19.0.4(@types/react@19.0.10) + + '@radix-ui/react-direction@1.1.1(@types/react@19.0.10)(react@19.0.0)': + dependencies: + react: 19.0.0 + optionalDependencies: + '@types/react': 19.0.10 + + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.0.10)(react@19.0.0) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.10 + '@types/react-dom': 19.0.4(@types/react@19.0.10) + + '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.0.10)(react@19.0.0) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.10 + '@types/react-dom': 19.0.4(@types/react@19.0.10) + + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.0.10)(react@19.0.0)': + dependencies: + react: 19.0.0 + optionalDependencies: + '@types/react': 19.0.10 + + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.0.10)(react@19.0.0) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.10 + '@types/react-dom': 19.0.4(@types/react@19.0.10) + + '@radix-ui/react-id@1.1.1(@types/react@19.0.10)(react@19.0.0)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.0.10)(react@19.0.0) + react: 19.0.0 + optionalDependencies: + '@types/react': 19.0.10 + + '@radix-ui/react-label@2.1.7(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.10 + '@types/react-dom': 19.0.4(@types/react@19.0.10) + + '@radix-ui/react-menu@2.1.16(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.0.10)(react@19.0.0) + aria-hidden: 1.2.6 + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + react-remove-scroll: 2.7.1(@types/react@19.0.10)(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.10 + '@types/react-dom': 19.0.4(@types/react@19.0.10) + + '@radix-ui/react-popover@1.1.15(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.0.10)(react@19.0.0) + aria-hidden: 1.2.6 + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + react-remove-scroll: 2.7.1(@types/react@19.0.10)(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.10 + '@types/react-dom': 19.0.4(@types/react@19.0.10) + + '@radix-ui/react-popper@1.2.8(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@floating-ui/react-dom': 2.1.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-use-rect': 1.1.1(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/rect': 1.1.1 + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.10 + '@types/react-dom': 19.0.4(@types/react@19.0.10) + + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.0.10)(react@19.0.0) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.10 + '@types/react-dom': 19.0.4(@types/react@19.0.10) + + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.0.10)(react@19.0.0) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.10 + '@types/react-dom': 19.0.4(@types/react@19.0.10) + + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@19.0.10)(react@19.0.0) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.10 + '@types/react-dom': 19.0.4(@types/react@19.0.10) + + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.0.10)(react@19.0.0) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.10 + '@types/react-dom': 19.0.4(@types/react@19.0.10) + + '@radix-ui/react-select@2.2.6(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + aria-hidden: 1.2.6 + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + react-remove-scroll: 2.7.1(@types/react@19.0.10)(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.10 + '@types/react-dom': 19.0.4(@types/react@19.0.10) + + '@radix-ui/react-separator@1.1.7(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.10 + '@types/react-dom': 19.0.4(@types/react@19.0.10) + + '@radix-ui/react-slot@1.2.3(@types/react@19.0.10)(react@19.0.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.0.10)(react@19.0.0) + react: 19.0.0 + optionalDependencies: + '@types/react': 19.0.10 + + '@radix-ui/react-switch@1.2.6(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.0.10)(react@19.0.0) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.10 + '@types/react-dom': 19.0.4(@types/react@19.0.10) + + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.0.10)(react@19.0.0)': + dependencies: + react: 19.0.0 + optionalDependencies: + '@types/react': 19.0.10 + + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.0.10)(react@19.0.0)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.0.10)(react@19.0.0) + react: 19.0.0 + optionalDependencies: + '@types/react': 19.0.10 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.0.10)(react@19.0.0)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.0.10)(react@19.0.0) + react: 19.0.0 + optionalDependencies: + '@types/react': 19.0.10 + + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.0.10)(react@19.0.0)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.0.10)(react@19.0.0) + react: 19.0.0 + optionalDependencies: + '@types/react': 19.0.10 + + '@radix-ui/react-use-is-hydrated@0.1.0(@types/react@19.0.10)(react@19.0.0)': + dependencies: + react: 19.0.0 + use-sync-external-store: 1.6.0(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.10 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.0.10)(react@19.0.0)': + dependencies: + react: 19.0.0 + optionalDependencies: + '@types/react': 19.0.10 + + '@radix-ui/react-use-previous@1.1.1(@types/react@19.0.10)(react@19.0.0)': + dependencies: + react: 19.0.0 + optionalDependencies: + '@types/react': 19.0.10 + + '@radix-ui/react-use-rect@1.1.1(@types/react@19.0.10)(react@19.0.0)': + dependencies: + '@radix-ui/rect': 1.1.1 + react: 19.0.0 + optionalDependencies: + '@types/react': 19.0.10 + + '@radix-ui/react-use-size@1.1.1(@types/react@19.0.10)(react@19.0.0)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.0.10)(react@19.0.0) + react: 19.0.0 + optionalDependencies: + '@types/react': 19.0.10 + + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.10 + '@types/react-dom': 19.0.4(@types/react@19.0.10) + + '@radix-ui/rect@1.1.1': {} + '@react-aria/autocomplete@3.0.0-beta.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': dependencies: '@react-aria/combobox': 3.12.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -8103,6 +9076,10 @@ snapshots: argparse@2.0.1: {} + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + aria-query@5.3.0: dependencies: dequal: 2.0.3 @@ -8178,7 +9155,7 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 - axios@1.8.4: + axios@1.12.2: dependencies: follow-redirects: 1.15.9 form-data: 4.0.4 @@ -8369,6 +9346,10 @@ snapshots: cjs-module-lexer@1.4.3: {} + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + client-only@0.0.1: {} cliui@8.0.1: @@ -8476,6 +9457,8 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 + date-fns-jalali@4.1.0-0: {} + date-fns@4.1.0: {} dayjs@1.11.13: {} @@ -8526,6 +9509,8 @@ snapshots: detect-newline@3.1.0: {} + detect-node-es@1.1.0: {} + devlop@1.1.0: dependencies: dequal: 2.0.3 @@ -9023,6 +10008,8 @@ snapshots: hasown: 2.0.2 math-intrinsics: 1.1.0 + get-nonce@1.0.1: {} + get-package-type@0.1.0: {} get-port@7.1.0: {} @@ -10787,6 +11774,13 @@ snapshots: react: 19.0.0 react-dom: 19.0.0(react@19.0.0) + react-day-picker@9.11.1(react@19.0.0): + dependencies: + '@date-fns/tz': 1.4.1 + date-fns: 4.1.0 + date-fns-jalali: 4.1.0-0 + react: 19.0.0 + react-dom@19.0.0(react@19.0.0): dependencies: react: 19.0.0 @@ -10866,6 +11860,25 @@ snapshots: react-refresh@0.14.2: {} + react-remove-scroll-bar@2.3.8(@types/react@19.0.10)(react@19.0.0): + dependencies: + react: 19.0.0 + react-style-singleton: 2.2.3(@types/react@19.0.10)(react@19.0.0) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.0.10 + + react-remove-scroll@2.7.1(@types/react@19.0.10)(react@19.0.0): + dependencies: + react: 19.0.0 + react-remove-scroll-bar: 2.3.8(@types/react@19.0.10)(react@19.0.0) + react-style-singleton: 2.2.3(@types/react@19.0.10)(react@19.0.0) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.0.10)(react@19.0.0) + use-sidecar: 1.1.3(@types/react@19.0.10)(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.10 + react-router-dom@7.9.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: react: 19.0.0 @@ -10910,6 +11923,14 @@ snapshots: '@react-types/shared': 3.28.0(react@19.0.0) react: 19.0.0 + react-style-singleton@2.2.3(@types/react@19.0.10)(react@19.0.0): + dependencies: + get-nonce: 1.0.1 + react: 19.0.0 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.0.10 + react-textarea-autosize@8.5.9(@types/react@19.0.10)(react@19.0.0): dependencies: '@babel/runtime': 7.27.0 @@ -11197,6 +12218,11 @@ snapshots: slash@3.0.0: {} + sonner@2.0.7(react-dom@19.0.0(react@19.0.0))(react@19.0.0): + dependencies: + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + source-map-js@1.2.1: {} source-map-support@0.5.13: @@ -11271,7 +12297,7 @@ snapshots: dependencies: '@types/jsonwebtoken': 9.0.10 '@types/ws': 8.18.0 - axios: 1.8.4 + axios: 1.12.2 base64-js: 1.5.1 form-data: 4.0.4 isomorphic-ws: 5.0.0(ws@8.18.1) @@ -11466,6 +12492,8 @@ snapshots: tslib@2.8.1: {} + tw-animate-css@1.4.0: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -11633,6 +12661,13 @@ snapshots: querystringify: 2.2.0 requires-port: 1.0.0 + use-callback-ref@1.3.3(@types/react@19.0.10)(react@19.0.0): + dependencies: + react: 19.0.0 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.0.10 + use-composed-ref@1.4.0(@types/react@19.0.10)(react@19.0.0): dependencies: react: 19.0.0 @@ -11652,10 +12687,22 @@ snapshots: optionalDependencies: '@types/react': 19.0.10 + use-sidecar@1.1.3(@types/react@19.0.10)(react@19.0.0): + dependencies: + detect-node-es: 1.1.0 + react: 19.0.0 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.0.10 + use-sync-external-store@1.4.0(react@19.0.0): dependencies: react: 19.0.0 + use-sync-external-store@1.6.0(react@19.0.0): + dependencies: + react: 19.0.0 + utrie@1.0.2: dependencies: base64-arraybuffer: 1.0.2 @@ -11945,10 +12992,10 @@ snapshots: zod@3.22.3: {} - zustand@5.0.5(@types/react@19.0.10)(react@19.0.0)(use-sync-external-store@1.4.0(react@19.0.0)): + zustand@5.0.5(@types/react@19.0.10)(react@19.0.0)(use-sync-external-store@1.6.0(react@19.0.0)): optionalDependencies: '@types/react': 19.0.10 react: 19.0.0 - use-sync-external-store: 1.4.0(react@19.0.0) + use-sync-external-store: 1.6.0(react@19.0.0) zwitch@2.0.4: {} diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 6113afc..dcbf096 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -5,6 +5,7 @@ import { DatadogRumProvider } from "@ui/providers/DatadogRumProvider"; import { UserStoreProvider } from "@ui/providers/UserStoreProvider"; import { AllCommunityModule, ModuleRegistry } from "ag-grid-community"; import { BrowserRouter as Router, useRoutes } from "react-router-dom"; +import { Toaster } from "@ui/components/ui/sonner"; // Register all Community features ModuleRegistry.registerModules([AllCommunityModule]); @@ -19,6 +20,7 @@ export const App = () => { +
diff --git a/ui/src/components/AvailabilityCard.tsx b/ui/src/components/AvailabilityCard.tsx index 72b0040..2716fff 100644 --- a/ui/src/components/AvailabilityCard.tsx +++ b/ui/src/components/AvailabilityCard.tsx @@ -1,14 +1,13 @@ -import { Button } from "@ui/ui-library/button"; -import { CopyIcon, MinusIcon, PlusIcon } from "@ui/ui-library/icons"; +import { Button } from "@ui/components/ui/button"; import { Select, - SelectButton, - SelectListBox, - SelectListItem, - SelectPopover, -} from "@ui/ui-library/select"; -import { Switch } from "@ui/ui-library/switch"; -import { Text } from "@ui/ui-library/text"; + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@ui/components/ui/select"; +import { Switch } from "@ui/components/ui/switch"; +import { Copy as CopyIcon, Minus as MinusIcon, Plus as PlusIcon } from "lucide-react"; import { useTimePicker } from "@ui/ui-library/time-picker"; import { useState } from "react"; @@ -143,12 +142,12 @@ export function AvailabilityCard({ return (
- {dayDisplay} +

{dayDisplay}

{onCopyToOtherDays && enabled && timeRanges.length > 0 && (
- + + {enabled ? "Disponible" : "Indisponible"} +
{/* Time Ranges */} @@ -188,70 +182,77 @@ export function AvailabilityCard({ {selectedRangeIndex === index ? ( <> - - + - ) : ( <> - {range.start} - - {range.end} + {range.start} + + {range.end} )}
{timeRanges.length > 1 && ( -
- - {/* Content */} -
{children}
- - + + + + {title} + +
{children}
+
+
); } diff --git a/ui/src/components/EventDetailsModal.tsx b/ui/src/components/EventDetailsModal.tsx index 16f8ee9..7e803af 100644 --- a/ui/src/components/EventDetailsModal.tsx +++ b/ui/src/components/EventDetailsModal.tsx @@ -1,7 +1,6 @@ import { EventAndTablo } from "@ui/types/events.types"; -import { Button } from "@ui/ui-library/button"; -import { DialogBody } from "@ui/ui-library/dialog"; -import { Strong, Text } from "@ui/ui-library/text"; +import { Button } from "@ui/components/ui/button"; +import { Strong, Text } from "@ui/components/ui/typography"; import { CalendarIcon, User } from "lucide-react"; import { twMerge } from "tailwind-merge"; import { CustomModal } from "./CustomModal"; @@ -83,7 +82,7 @@ export const EventDetailsModal = ({ return ( - +
{getEventStatusBadge(event)}
{/* Date and Time */}
@@ -117,14 +116,14 @@ export const EventDetailsModal = ({
)} -
- {/* Footer */} -
- - {canEdit && onEdit && } + {/* Footer */} +
+ + {canEdit && onEdit && } +
); diff --git a/ui/src/components/EventModal.tsx b/ui/src/components/EventModal.tsx index 5195cdb..f4fb01a 100644 --- a/ui/src/components/EventModal.tsx +++ b/ui/src/components/EventModal.tsx @@ -3,14 +3,14 @@ import { useCreateEvents, useEvent, useUpdateEvent } from "@ui/hooks/events"; import { useTablosList } from "@ui/hooks/tablos"; import { useUser } from "@ui/providers/UserStoreProvider"; import { Event, EventInsert } from "@ui/types/events.types"; -import { DatePicker, DatePickerButton } from "@ui/ui-library/date-picker"; +import { DatePicker } from "@ui/components/ui/date-picker"; import { Select, - SelectButton, - SelectListBox, - SelectListItem, - SelectPopover, -} from "@ui/ui-library/select"; + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@ui/components/ui/select"; import { useTimePicker } from "@ui/ui-library/time-picker"; import { useEffect, useState } from "react"; import { Group } from "react-aria-components"; @@ -157,28 +157,25 @@ export const EventModal = ({ mode }: { mode: "create" | "edit" }) => { Tablo * @@ -187,73 +184,69 @@ export const EventModal = ({ mode }: { mode: "create" | "edit" }) => { { - if (value === null) { - return; + onChange={(date) => { + if (date) { + // Convert Date to YYYY-MM-DD format + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + setFormEvent({ + ...formEvent, + start_date: `${year}-${month}-${day}`, + }); } - setFormEvent({ - ...formEvent, - start_date: value.toString(), - }); }} - > - - + buttonClassName="h-[36px]" + />
diff --git a/ui/src/components/EventTypeModal.tsx b/ui/src/components/EventTypeModal.tsx index e26fecc..6ef35f1 100644 --- a/ui/src/components/EventTypeModal.tsx +++ b/ui/src/components/EventTypeModal.tsx @@ -1,14 +1,14 @@ import { EventTypeConfig } from "@ui/hooks/event-types"; -import { Button } from "@ui/ui-library/button"; -import { Description, Input, Label, TextArea, TextField } from "@ui/ui-library/field"; -import { NumberField, NumberInput } from "@ui/ui-library/number-field"; +import { Button } from "@ui/components/ui/button"; import { Select, - SelectButton, - SelectListBox, - SelectListItem, - SelectPopover, -} from "@ui/ui-library/select"; + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@ui/components/ui/select"; +import { Description, Input, Label, TextArea, TextField } from "@ui/ui-library/field"; +import { NumberField, NumberInput } from "@ui/ui-library/number-field"; import { CustomModal } from "./CustomModal"; export function EventTypeModal({ @@ -126,28 +126,25 @@ export function EventTypeModal({ Délai minimum pour réserver @@ -222,14 +219,14 @@ export function EventTypeModal({ {/* Action Buttons */}
- diff --git a/ui/src/components/ImportICSModal.tsx b/ui/src/components/ImportICSModal.tsx index 41a68bd..2046d8f 100644 --- a/ui/src/components/ImportICSModal.tsx +++ b/ui/src/components/ImportICSModal.tsx @@ -5,12 +5,12 @@ import { EventInsert } from "@ui/types/events.types"; import { CreateTablo } from "@ui/types/tablos.types"; import { Select, - SelectButton, - SelectListBox, - SelectListItem, - SelectPopover, -} from "@ui/ui-library/select"; -import { toast } from "@ui/ui-library/toast/toast-queue"; + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@ui/components/ui/select"; +import { toast } from "@ui/lib/toast"; import { ParsedICSEvent, parseICSFile } from "@ui/utils/helpers"; import { useRef, useState } from "react"; @@ -267,22 +267,20 @@ export const ImportICSModal = ({ onClose }: ImportICSModalProps) => { /> ) : ( )}
diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index 34547fa..31c5757 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -2,8 +2,7 @@ import { MenuIcon } from "lucide-react"; import { useState } from "react"; import { Outlet } from "react-router-dom"; import { twMerge } from "tailwind-merge"; -import { Button } from "../ui-library/button"; -import { Icon } from "../ui-library/icon"; +import { Button } from "@ui/components/ui/button"; import { SideNavigation } from "./NavigationBar"; export function Layout() { @@ -12,17 +11,15 @@ export function Layout() { return (
{ // Check if all navigation items are present expect(screen.getByText("Tableau de Bord")).toBeInTheDocument(); - expect(screen.getByText("Devis")).toBeInTheDocument(); expect(screen.getByText("Factures")).toBeInTheDocument(); expect(screen.getByText("Planning")).toBeInTheDocument(); expect(screen.getByText("Chantiers")).toBeInTheDocument(); @@ -76,7 +75,7 @@ describe("NavigationBar", () => { // Check if user information is displayed expect(screen.getByText("John Doe")).toBeInTheDocument(); - expect(screen.getByAltText("Avatar")).toBeInTheDocument(); + // expect(screen.getByAltText("Avatar")).toBeInTheDocument(); }); it("opens and closes the popover when clicked", () => { diff --git a/ui/src/components/NavigationBar.tsx b/ui/src/components/NavigationBar.tsx index f936f62..92ba79d 100644 --- a/ui/src/components/NavigationBar.tsx +++ b/ui/src/components/NavigationBar.tsx @@ -1,32 +1,33 @@ import { useUser } from "@ui/providers/UserStoreProvider"; -import { Avatar, AvatarBadge } from "@ui/ui-library/avatar"; -import { Button } from "@ui/ui-library/button"; -import { Dialog } from "@ui/ui-library/dialog"; +// shadcn components +import { Avatar, AvatarBadge, AvatarImage, AvatarFallback } from "@ui/components/ui/avatar"; +import { Button } from "@ui/components/ui/button"; +import { + Popover as ShadcnPopover, + PopoverContent, + PopoverTrigger, +} from "@ui/components/ui/popover"; +// react-aria components (still used) import { Disclosure, DisclosureControl, DisclosurePanel } from "@ui/ui-library/disclosure"; -import { Icon } from "@ui/ui-library/icon"; -import { AvailableIcon } from "@ui/ui-library/icons"; import { Link } from "@ui/ui-library/link"; -import { Popover } from "@ui/ui-library/popover"; -import { Text } from "@ui/ui-library/text"; import { isProd, isStaging } from "@ui/utils/helpers"; import { getXtabloIcon } from "@ui/utils/iconHelpers"; import { CalendarCheckIcon, CalendarIcon, ChevronRightIcon, + Circle, ConstructionIcon, - Grid2X2Icon, Kanban, + LayoutDashboardIcon, ListCheckIcon, MessageCircleIcon, MinusIcon, - NotebookPenIcon, PlusIcon, - ReceiptTextIcon, SendIcon, SquareKanban, } from "lucide-react"; -import { useRef, useState } from "react"; +import { useState } from "react"; import { LinkProps, Separator } from "react-aria-components"; import { Link as RouterLink, useLocation } from "react-router-dom"; import { twMerge } from "tailwind-merge"; @@ -87,60 +88,61 @@ function NavLink(props: NavLinkProps) { export function UserMenuPopover({ isCollapsed }: { isCollapsed: boolean }) { const user = useUser(); - const [isPopoverOpen, setIsPopoverOpen] = useState(false); - const ref = useRef(null); return ( - <> - - + + {user.name?.charAt(0).toUpperCase()} + + {!isCollapsed && ( + + {user.name} + + )} + + + - -
-
- - } /> - -
- {user.name} - -
+
+
+ + + {user.name?.charAt(0).toUpperCase()} + + + + +
+

{user.name}

+
- - - -
-
-
- + + + + +
+ + ); } export const SideNavigation = ({ isMobileMenuOpen }: { isMobileMenuOpen: boolean }) => { const isCollapsable = !isMobileMenuOpen; - const [isCollapsed, setIsCollapsed] = useState(isCollapsable ? false : true); + const [isCollapsed, setIsCollapsed] = useState(!isCollapsable); return (
@@ -230,17 +232,11 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) { } > = [ { - path: "/devis", - label: "Devis", - icon: , - isDisabled: true, - }, - { - path: "/factures", - label: "Factures", - icon: , - isDisabled: true, + path: "/", + label: "Dashboard", + icon: , }, + { isHorizontalBar: true }, { path: "/event-types", label: "Types d'événements", @@ -274,12 +270,6 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) { label: "Planning", icon: , }, - { isHorizontalBar: true }, - { - path: "/", - label: "Tableaux", - icon: , - }, { path: "/chat", label: "Discussions", @@ -292,7 +282,7 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) { {navItems.map((item, index) => { if ("isHorizontalBar" in item) { return ( -
  • +
  • ); @@ -309,7 +299,7 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) { aria-label={isCollapsed ? label : undefined} >
    - + {icon}
    - +