diff --git a/firebase.json b/firebase.json new file mode 100644 index 0000000..0207c3f --- /dev/null +++ b/firebase.json @@ -0,0 +1,5 @@ +{ + "firestore": { + "rules": "firestore.rules" + } +} diff --git a/firestore.rules b/firestore.rules index 01f5dfd..47f8320 100644 --- a/firestore.rules +++ b/firestore.rules @@ -59,6 +59,8 @@ service cloud.firestore { && (!('roast' in data) || (data.roast is string && data.roast.size() <= 8000)) && (!('roastBrutal' in data) || (data.roastBrutal is string && data.roastBrutal.size() <= 8000)) && (!('roastMild' in data) || (data.roastMild is string && data.roastMild.size() <= 8000)) + // extraTagsByCategory: mapa de categoria → lista de nomes de skills criadas pelo usuário + && (!('extraTagsByCategory' in data) || data.extraTagsByCategory is map) && data.skills is map && data.skills.keys().hasAll(['frontend', 'backend', 'ux_ui', 'dados', 'hardware_android', 'vibe_coding']) && data.skills.frontend is number && data.skills.backend is number @@ -71,6 +73,37 @@ service cloud.firestore { && data.canvas.veto is list && data.canvas.veto.size() <= 50; } + // ───────────────────────────────────────────── + // SKILLS + // Catálogo global de skills. Qualquer usuário + // autenticado pode ler. Criação permitida apenas + // para skills com campos obrigatórios válidos. + // Edição e deleção bloqueadas para usuários comuns. + // ───────────────────────────────────────────── + function isValidSkill(data) { + return data.keys().hasAll(['id', 'name', 'normalizedName', 'status', 'usageCount', 'createdBy', 'createdAt']) + && data.id is string && data.id.size() > 0 && data.id.size() <= 128 + && data.name is string && data.name.size() > 0 && data.name.size() <= 100 + && data.normalizedName is string && data.normalizedName.size() > 0 && data.normalizedName.size() <= 100 + && data.status is string && data.status in ['pending', 'approved', 'rejected'] + && data.usageCount is number && data.usageCount >= 0 + && data.createdBy is string + && (!('category' in data) || data.category == null || (data.category is string && data.category.size() <= 50)); + } + + match /skills/{skillId} { + // Leitura pública para usuários autenticados (autocomplete) + allow read: if isSignedIn(); + + // Criação: usuário autenticado, skill válida, ID determinístico = normalizedName + allow create: if isSignedIn() + && skillId == incoming().normalizedName + && isValidSkill(incoming()); + + // Atualização e deleção: bloqueadas para usuários comuns (apenas admin via console) + allow update, delete: if false; + } + match /test/connection { allow read: if true; } @@ -110,7 +143,7 @@ service cloud.firestore { && profileId == request.auth.uid && isValidProfile(incoming()) && incoming().createdAt == request.time - && incoming().keys().hasOnly(['userId', 'name', 'photoURL', 'github', 'linkedin', 'bio', 'primaryRole', 'secondaryRoles', 'skills', 'canvas', 'status', 'squadId', 'eventId', 'roast', 'roastBrutal', 'roastMild', 'createdAt', 'updatedAt', 'visibility']); + && incoming().keys().hasOnly(['userId', 'name', 'photoURL', 'github', 'linkedin', 'bio', 'primaryRole', 'secondaryRoles', 'skills', 'canvas', 'status', 'squadId', 'eventId', 'roast', 'roastBrutal', 'roastMild', 'createdAt', 'updatedAt', 'visibility', 'extraTagsByCategory']); allow update: if isSignedIn() && isValidId(profileId) @@ -118,7 +151,7 @@ service cloud.firestore { && incoming().userId == existing().userId && incoming().createdAt == existing().createdAt && ( - (incoming().diff(existing()).affectedKeys().hasOnly(['skills', 'canvas', 'name', 'photoURL', 'github', 'linkedin', 'bio', 'primaryRole', 'secondaryRoles', 'status', 'squadId', 'eventId', 'updatedAt', 'roast', 'roastBrutal', 'roastMild', 'visibility'])) + (incoming().diff(existing()).affectedKeys().hasOnly(['skills', 'canvas', 'name', 'photoURL', 'github', 'linkedin', 'bio', 'primaryRole', 'secondaryRoles', 'status', 'squadId', 'eventId', 'updatedAt', 'roast', 'roastBrutal', 'roastMild', 'visibility', 'extraTagsByCategory'])) || false ); @@ -171,7 +204,6 @@ service cloud.firestore { && isValidMessage(incoming()) && incoming().createdAt == request.time; - // Allow listing for conversation thread queries (by conversationId, senderId or receiverId) allow list: if isSignedIn() && ( resource.data.receiverId == request.auth.uid diff --git a/get-rules.ts b/get-rules.ts new file mode 100644 index 0000000..8fa0bdc --- /dev/null +++ b/get-rules.ts @@ -0,0 +1,22 @@ +import "dotenv/config"; +import * as admin from "firebase-admin"; + +const serviceAccount = JSON.parse(process.env.FIREBASE_SERVICE_ACCOUNT!); +const dbId = process.env.VITE_FIREBASE_FIRESTORE_DATABASE_ID!; + +admin.initializeApp({ + credential: admin.credential.cert(serviceAccount), +}); + +async function run() { + const securityRules = admin.securityRules(); + try { + // The Rules API handles generic rules, let's try to get Firestore rules + // Using the REST API directly is easier since the SDK might not expose getRules for specific DBs easily. + console.log("Database ID from env:", dbId); + } catch(e) { + console.error(e); + } +} + +run(); diff --git a/package-lock.json b/package-lock.json index f01acef..d35a05a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,6 @@ "express": "^4.21.2", "express-rate-limit": "^8.5.2", "firebase": "^12.12.1", - "firebase-admin": "^13.8.0", "lucide-react": "^0.546.0", "motion": "^12.23.24", "nodemailer": "^9.0.0", @@ -32,6 +31,7 @@ "zustand": "^5.0.14" }, "devDependencies": { + "@firebase/rules-unit-testing": "^5.0.1", "@tanstack/react-query-devtools": "^5.100.14", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", @@ -48,6 +48,7 @@ "eslint-plugin-import": "^2.32.0", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", + "firebase-admin": "^14.1.0", "globals": "^17.6.0", "husky": "^9.1.7", "jsdom": "^29.0.2", @@ -2504,6 +2505,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz", "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==", + "dev": true, "license": "MIT" }, "node_modules/@firebase/ai": { @@ -3052,6 +3054,19 @@ "integrity": "sha512-vI3bqLoF14L/GchtgayMiFpZJF+Ao3uR8WCde0XpYNkSokDpAKca2DxvcfeZv7lZUqkUwQPL2wD83d3vQ4vvrg==", "license": "Apache-2.0" }, + "node_modules/@firebase/rules-unit-testing": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@firebase/rules-unit-testing/-/rules-unit-testing-5.0.1.tgz", + "integrity": "sha512-EvbxUvgoY2cG7vgtdhKc7gjalKzeO3AWr2NiUuyEEmcXaSaOJanV3hXLj3Viigs+Y/jFYQmtVPpSoiwUs/ZlPg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "firebase": "^12.0.0" + } + }, "node_modules/@firebase/storage": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/@firebase/storage/-/storage-0.14.2.tgz", @@ -3118,26 +3133,28 @@ "license": "Apache-2.0" }, "node_modules/@google-cloud/firestore": { - "version": "7.11.6", - "resolved": "https://registry.npmjs.org/@google-cloud/firestore/-/firestore-7.11.6.tgz", - "integrity": "sha512-EW/O8ktzwLfyWBOsNuhRoMi8lrC3clHM5LVFhGvO1HCsLozCOOXRAlHrYBoE6HL42Sc8yYMuCb2XqcnJ4OOEpw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@google-cloud/firestore/-/firestore-8.6.0.tgz", + "integrity": "sha512-TdvZHfwQj5B5CSDEgDqyrhdVqtOSupmBXDQPasMAJiC64tjsGvyMooNiC43fdk1TsUHeklyoZ6/vQ1TjWKVMbg==", + "dev": true, "license": "Apache-2.0", "optional": true, "dependencies": { - "@opentelemetry/api": "^1.3.0", - "fast-deep-equal": "^3.1.1", + "@opentelemetry/api": "^1.9.0", + "fast-deep-equal": "^3.1.3", "functional-red-black-tree": "^1.0.1", - "google-gax": "^4.3.3", - "protobufjs": "^7.2.6" + "google-gax": "^5.0.1", + "protobufjs": "^7.5.3" }, "engines": { - "node": ">=14.0.0" + "node": ">=18" } }, "node_modules/@google-cloud/paginator": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-5.0.2.tgz", "integrity": "sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg==", + "dev": true, "license": "Apache-2.0", "optional": true, "dependencies": { @@ -3152,6 +3169,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/@google-cloud/projectify/-/projectify-4.0.0.tgz", "integrity": "sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA==", + "dev": true, "license": "Apache-2.0", "optional": true, "engines": { @@ -3162,6 +3180,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-4.0.0.tgz", "integrity": "sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g==", + "dev": true, "license": "Apache-2.0", "optional": true, "engines": { @@ -3172,6 +3191,7 @@ "version": "7.19.0", "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.19.0.tgz", "integrity": "sha512-n2FjE7NAOYyshogdc7KQOl/VZb4sneqPjWouSyia9CMDdMhRX5+RIbqalNmC7LOLzuLAN89VlF2HvG8na9G+zQ==", + "dev": true, "license": "Apache-2.0", "optional": true, "dependencies": { @@ -3199,6 +3219,7 @@ "version": "6.7.1", "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", + "dev": true, "license": "Apache-2.0", "optional": true, "dependencies": { @@ -3216,6 +3237,7 @@ "version": "9.0.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "dev": true, "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" @@ -3230,6 +3252,7 @@ "version": "6.1.1", "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "dev": true, "license": "Apache-2.0", "optional": true, "dependencies": { @@ -3245,6 +3268,7 @@ "version": "9.15.1", "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", + "dev": true, "license": "Apache-2.0", "optional": true, "dependencies": { @@ -3263,6 +3287,7 @@ "version": "0.0.2", "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", + "dev": true, "license": "Apache-2.0", "optional": true, "engines": { @@ -3273,6 +3298,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "dev": true, "license": "MIT", "optional": true, "bin": { @@ -3286,6 +3312,7 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -3307,6 +3334,7 @@ "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, "license": "MIT", "optional": true, "bin": { @@ -3503,6 +3531,7 @@ "version": "4.4.2", "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "dev": true, "license": "MIT", "optional": true, "funding": { @@ -3514,6 +3543,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "dev": true, "funding": [ { "type": "github", @@ -3527,12 +3557,24 @@ "version": "1.9.1", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "dev": true, "license": "Apache-2.0", "optional": true, "engines": { "node": ">=8.0.0" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -4503,6 +4545,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "dev": true, "license": "MIT", "optional": true, "engines": { @@ -4573,6 +4616,7 @@ "version": "0.12.5", "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz", "integrity": "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==", + "dev": true, "license": "MIT", "optional": true }, @@ -4724,19 +4768,13 @@ "version": "9.0.10", "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, "license": "MIT", "dependencies": { "@types/ms": "*", "@types/node": "*" } }, - "node_modules/@types/long": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", - "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", - "license": "MIT", - "optional": true - }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -4748,6 +4786,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, "license": "MIT" }, "node_modules/@types/node": { @@ -4807,6 +4846,7 @@ "version": "2.48.13", "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.13.tgz", "integrity": "sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -4866,6 +4906,7 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true, "license": "MIT", "optional": true }, @@ -5265,6 +5306,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -5525,6 +5567,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==", + "dev": true, "license": "MIT", "optional": true, "engines": { @@ -5562,6 +5605,7 @@ "version": "1.3.3", "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -5572,6 +5616,7 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, "license": "MIT", "optional": true }, @@ -6077,6 +6122,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -6721,6 +6767,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, "license": "MIT", "optional": true, "engines": { @@ -6816,6 +6863,7 @@ "version": "4.1.3", "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -6825,6 +6873,14 @@ "stream-shift": "^1.0.2" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -6881,6 +6937,7 @@ "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -7036,7 +7093,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -7606,6 +7663,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "dev": true, "license": "MIT", "optional": true, "engines": { @@ -7717,6 +7775,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/farmhash-modern/-/farmhash-modern-1.1.0.tgz", "integrity": "sha512-6ypT4XfgqJk/F3Yuv4SX26I3doUjt0GTG4a+JgWxXQpxXzTBq8fPUeGHfcYMMDPHJHm3yPOSjaeBwBGAHWXCdA==", + "dev": true, "license": "MIT", "engines": { "node": ">=18.0.0" @@ -7726,6 +7785,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, "license": "MIT" }, "node_modules/fast-json-stable-stringify": { @@ -7763,6 +7823,7 @@ "version": "1.1.5", "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.5.tgz", "integrity": "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==", + "dev": true, "funding": [ { "type": "github", @@ -7779,6 +7840,7 @@ "version": "5.7.1", "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.1.tgz", "integrity": "sha512-8Cc3f8GUGUULg34pBch/KGyPLglS+OFs05deyOlY7fL2MTagYPKrVQNmR1fLF/yJ9PH5ZSTd3YDF6pnmeZU+zA==", + "dev": true, "funding": [ { "type": "github", @@ -7989,30 +8051,142 @@ } }, "node_modules/firebase-admin": { - "version": "13.8.0", - "resolved": "https://registry.npmjs.org/firebase-admin/-/firebase-admin-13.8.0.tgz", - "integrity": "sha512-iawoQkmZbsA+2DY5UEuB8f6jSlskzzySoye0D2F6e3zlDZX9DUcXf0HhZqLUn/P6WhLGvTf6ZtCmshZvhAgTYg==", + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/firebase-admin/-/firebase-admin-14.1.0.tgz", + "integrity": "sha512-GdHh6vHWm9LVRt+3hINWczaA7fPwnN/l4xZdqzn+wNPYErrLI1x6u1mvAJM/5IGgYI9EqQgLt1EyBG8ok/hWCg==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@fastify/busboy": "^3.0.0", - "@firebase/database-compat": "^2.0.0", - "@firebase/database-types": "^1.0.6", + "@firebase/database-compat": "^2.1.4", + "@firebase/database-types": "^1.0.20", "farmhash-modern": "^1.1.0", "fast-deep-equal": "^3.1.1", - "google-auth-library": "^10.6.1", + "google-auth-library": "^10.6.2", "jsonwebtoken": "^9.0.0", - "jwks-rsa": "^3.1.0", - "node-forge": "^1.4.0", - "uuid": "^11.0.2" + "jwks-rsa": "^4.0.1" }, "engines": { - "node": ">=18" + "node": ">=22" }, "optionalDependencies": { - "@google-cloud/firestore": "^7.11.0", + "@google-cloud/firestore": "^8.6.0", "@google-cloud/storage": "^7.19.0" } }, + "node_modules/firebase-admin/node_modules/@firebase/app-check-interop-types": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.4.tgz", + "integrity": "sha512-zz3i6e13B8BfWiLy8MABtTh8aGIACgKbf9UVnyHcWs+yQzJXgQcl8A46b0zfaiJHdQ+niF0ouAfcpuf+3LMPQg==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/firebase-admin/node_modules/@firebase/app-types": { + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.5.tgz", + "integrity": "sha512-YevqTjvo7Iujsa9Dwowmd6dSoElhzmD63ZSrq6bzjvQ6POjYgNjOFHLmNIgJs48eNO093NCERibuFnxbfOvU7A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@firebase/logger": "0.5.1" + } + }, + "node_modules/firebase-admin/node_modules/@firebase/auth-interop-types": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.5.tgz", + "integrity": "sha512-1Li/YuBDBAXcKv7BzY4U28gontUmAaw53sYiqbaVOMCFb2lFKK/c3CGMUWqtwe7+TXrl3poWnTCL5umYBg85Eg==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/firebase-admin/node_modules/@firebase/component": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.7.3.tgz", + "integrity": "sha512-wFofIaa2879ogD/WvkjYXJxRmfnL0scen6ORgaC3na1FNOR9ASIUANQdhqQcmWu/h77/pVHY7ch5flewa5Bcew==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/firebase-admin/node_modules/@firebase/database": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.1.3.tgz", + "integrity": "sha512-XwWCa+E4TvNGpGwXrycLRNfdogADwFcvuhyow6wDWma9W54roaQIhe+4PM0KiLsIftBdSCGI7OKCXrdSRHbIhw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.4", + "@firebase/auth-interop-types": "0.2.5", + "@firebase/component": "0.7.3", + "@firebase/logger": "0.5.1", + "@firebase/util": "1.15.1", + "faye-websocket": "0.11.4", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/firebase-admin/node_modules/@firebase/database-compat": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-2.1.4.tgz", + "integrity": "sha512-3pK35F1MAgmqFJQlf2nhQl44vtAXQO1uaCaQOEUI9kCRtLFqi7N+QRKR7lFZPg+xIZIyubgxQaxY69YgfZRZWg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.3", + "@firebase/database": "1.1.3", + "@firebase/database-types": "1.0.20", + "@firebase/logger": "0.5.1", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/firebase-admin/node_modules/@firebase/database-types": { + "version": "1.0.20", + "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.20.tgz", + "integrity": "sha512-kegbOk/w8iU64pr0q6k2ItyNGjnQBMHFhwS7ohdWI4W+pc0/zhhdGXTdFj6X1oxItRjPoYOsSQmERgBkn/ihxw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-types": "0.9.5", + "@firebase/util": "1.15.1" + } + }, + "node_modules/firebase-admin/node_modules/@firebase/logger": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.5.1.tgz", + "integrity": "sha512-vZKLsqE1ABOy8OjQiE7cUTFn4gvaqlk88yp8N94Pk/sDpq61YqZGqmVFZTvOyflTwuYFcWirBdYGoJgbDaXKYQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/firebase-admin/node_modules/@firebase/util": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.15.1.tgz", + "integrity": "sha512-LUdM4Wg7YM9Pq/49nGYySJA0CSQEKnGffFzWV8+6gXN7mGxn+FL1IqvFbuZUtAQcfZgHYDwCE1wwlK7rB7gl2g==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/flat-cache": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", @@ -8071,6 +8245,7 @@ "version": "2.5.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.5.tgz", "integrity": "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -8220,6 +8395,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", + "dev": true, "license": "MIT", "optional": true }, @@ -8463,33 +8639,34 @@ } }, "node_modules/google-gax": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-4.6.1.tgz", - "integrity": "sha512-V6eky/xz2mcKfAd1Ioxyd6nmA61gao3n01C+YeuIwu3vzM9EDR6wcVzMSIbLMDXWeoi9SHYctXuKYC5uJUT3eQ==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-5.0.7.tgz", + "integrity": "sha512-EhiqaWWJ+9h7sCcKJTsoo6tMcjokVHhWsbSuWCnZJT4vIBP3y4mAoFLnt9SzgkVZeq24ZsFaArr06nnYYku2yA==", + "dev": true, "license": "Apache-2.0", "optional": true, "dependencies": { - "@grpc/grpc-js": "^1.10.9", - "@grpc/proto-loader": "^0.7.13", - "@types/long": "^4.0.0", - "abort-controller": "^3.0.0", - "duplexify": "^4.0.0", - "google-auth-library": "^9.3.0", - "node-fetch": "^2.7.0", + "@grpc/grpc-js": "^1.12.6", + "@grpc/proto-loader": "^0.8.0", + "duplexify": "^4.1.3", + "google-auth-library": "10.5.0", + "google-logging-utils": "1.1.3", + "node-fetch": "^3.3.2", "object-hash": "^3.0.0", - "proto3-json-serializer": "^2.0.2", - "protobufjs": "^7.3.2", - "retry-request": "^7.0.0", - "uuid": "^9.0.1" + "proto3-json-serializer": "3.0.4", + "protobufjs": "^7.5.4", + "retry-request": "^8.0.2", + "rimraf": "^5.0.1" }, "engines": { - "node": ">=14" + "node": ">=18" } }, "node_modules/google-gax/node_modules/@grpc/grpc-js": { - "version": "1.14.3", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", - "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", + "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", + "dev": true, "license": "Apache-2.0", "optional": true, "dependencies": { @@ -8500,16 +8677,17 @@ "node": ">=12.10.0" } }, - "node_modules/google-gax/node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz", - "integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==", + "node_modules/google-gax/node_modules/@grpc/proto-loader": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.1.tgz", + "integrity": "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==", + "dev": true, "license": "Apache-2.0", "optional": true, "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", - "protobufjs": "^7.5.3", + "protobufjs": "^7.5.5", "yargs": "^17.7.2" }, "bin": { @@ -8519,99 +8697,86 @@ "node": ">=6" } }, - "node_modules/google-gax/node_modules/gaxios": { - "version": "6.7.1", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", - "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", + "node_modules/google-gax/node_modules/google-auth-library": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz", + "integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==", + "dev": true, "license": "Apache-2.0", "optional": true, "dependencies": { - "extend": "^3.0.2", - "https-proxy-agent": "^7.0.1", - "is-stream": "^2.0.0", - "node-fetch": "^2.6.9", - "uuid": "^9.0.1" + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.0.0", + "gcp-metadata": "^8.0.0", + "google-logging-utils": "^1.0.0", + "gtoken": "^8.0.0", + "jws": "^4.0.0" }, "engines": { - "node": ">=14" + "node": ">=18" } }, - "node_modules/google-gax/node_modules/gcp-metadata": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", - "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", - "license": "Apache-2.0", + "node_modules/google-gax/node_modules/gtoken": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", + "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==", + "dev": true, + "license": "MIT", "optional": true, "dependencies": { - "gaxios": "^6.1.1", - "google-logging-utils": "^0.0.2", - "json-bigint": "^1.0.0" + "gaxios": "^7.0.0", + "jws": "^4.0.0" }, "engines": { - "node": ">=14" + "node": ">=18" } }, - "node_modules/google-gax/node_modules/google-auth-library": { - "version": "9.15.1", - "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", - "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", - "license": "Apache-2.0", + "node_modules/google-gax/node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", "optional": true, "dependencies": { - "base64-js": "^1.3.0", - "ecdsa-sig-formatter": "^1.0.11", - "gaxios": "^6.1.1", - "gcp-metadata": "^6.1.0", - "gtoken": "^7.0.0", - "jws": "^4.0.0" + "agent-base": "^7.1.0", + "debug": "^4.3.4" }, "engines": { - "node": ">=14" - } - }, - "node_modules/google-gax/node_modules/google-logging-utils": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", - "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", - "license": "Apache-2.0", - "optional": true, - "engines": { - "node": ">=14" + "node": ">= 14" } }, - "node_modules/google-gax/node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "node_modules/google-gax/node_modules/retry-request": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-8.0.3.tgz", + "integrity": "sha512-qqoc4kkGgP9cmQDWELlOpAmfgJOg0Yi7MT82ZjiPWu451ayju4itwomjM4/dBEliify8C1b3tSaeCOldugtwPQ==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { - "whatwg-url": "^5.0.0" + "extend": "^3.0.2", + "teeny-request": "^10.0.0" }, "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } + "node": ">=18" } }, - "node_modules/google-gax/node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", + "node_modules/google-gax/node_modules/teeny-request": { + "version": "10.1.3", + "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-10.1.3.tgz", + "integrity": "sha512-5yDliI1uWkYPo7W+Zvrxg6YmoWuj5iC5EydewqrRTvc68nyMTZhlPPlLg6cptUGfbQAb+N9XDPDPzF6N081lug==", + "dev": true, + "license": "Apache-2.0", "optional": true, - "bin": { - "uuid": "dist/bin/uuid" + "dependencies": { + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "stream-events": "^1.0.5" + }, + "engines": { + "node": ">=18" } }, "node_modules/google-logging-utils": { @@ -8645,6 +8810,7 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -8659,6 +8825,7 @@ "version": "6.7.1", "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", + "dev": true, "license": "Apache-2.0", "optional": true, "dependencies": { @@ -8676,6 +8843,7 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -8697,6 +8865,7 @@ "version": "9.0.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "dev": true, "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" @@ -8775,7 +8944,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -8833,6 +9002,7 @@ "version": "2.6.0", "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "dev": true, "funding": [ { "type": "github", @@ -8876,6 +9046,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -8891,6 +9062,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -9384,7 +9556,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -9548,9 +9720,10 @@ } }, "node_modules/jose": { - "version": "4.15.9", - "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", - "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -9755,6 +9928,7 @@ "version": "9.0.3", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "dev": true, "license": "MIT", "dependencies": { "jws": "^4.0.1", @@ -9777,6 +9951,7 @@ "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -9797,19 +9972,31 @@ } }, "node_modules/jwks-rsa": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.2.2.tgz", - "integrity": "sha512-BqTyEDV+lS8F2trk3A+qJnxV5Q9EqKCBJOPti3W97r7qTympCZjb7h2X6f2kc+0K3rsSTY1/6YG2eaXKoj497w==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-4.1.0.tgz", + "integrity": "sha512-sbkByqyATKYJP5F4RXj03N5TUNC0QLTjCAZvwTzC4BwJZ8e0/cWxN8YROnyUth2g1/ONWi4eSFHeu6oYalrc3Q==", + "dev": true, "license": "MIT", "dependencies": { "@types/jsonwebtoken": "^9.0.4", "debug": "^4.3.4", - "jose": "^4.15.4", + "jose": "^6.1.3", "limiter": "^1.1.5", - "lru-memoizer": "^2.2.0" + "lru-cache": "^11.0.0", + "lru-memoizer": "^3.0.0" }, "engines": { - "node": ">=14" + "node": "^20.19.0 || ^22.12.0 || >= 23.0.0" + } + }, + "node_modules/jwks-rsa/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" } }, "node_modules/jws": { @@ -10108,7 +10295,8 @@ "node_modules/limiter": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", - "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" + "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==", + "dev": true }, "node_modules/lint-staged": { "version": "17.0.7", @@ -10262,6 +10450,7 @@ "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "dev": true, "license": "MIT" }, "node_modules/lodash.debounce": { @@ -10275,36 +10464,42 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "dev": true, "license": "MIT" }, "node_modules/lodash.isboolean": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "dev": true, "license": "MIT" }, "node_modules/lodash.isinteger": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "dev": true, "license": "MIT" }, "node_modules/lodash.isnumber": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "dev": true, "license": "MIT" }, "node_modules/lodash.isplainobject": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true, "license": "MIT" }, "node_modules/lodash.isstring": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "dev": true, "license": "MIT" }, "node_modules/lodash.merge": { @@ -10318,6 +10513,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "dev": true, "license": "MIT" }, "node_modules/lodash.sortby": { @@ -10481,33 +10677,26 @@ } }, "node_modules/lru-memoizer": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz", - "integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-3.0.0.tgz", + "integrity": "sha512-m83w/cYXLdUIboKSPxzPAGfYnk+vqeDYXuoSrQRw1q+yVEd8IXhvMufN8Q5TIPe7e2jyX4SRNrDJI2Skw1yznQ==", + "dev": true, "license": "MIT", "dependencies": { "lodash.clonedeep": "^4.5.0", - "lru-cache": "6.0.0" + "lru-cache": "^11.0.1" } }, "node_modules/lru-memoizer/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", "engines": { - "node": ">=10" + "node": "20 || >=22" } }, - "node_modules/lru-memoizer/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC" - }, "node_modules/lucide-react": { "version": "0.546.0", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.546.0.tgz", @@ -10810,15 +10999,6 @@ "url": "https://opencollective.com/node-fetch" } }, - "node_modules/node-forge": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", - "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", - "license": "(BSD-3-Clause OR GPL-2.0)", - "engines": { - "node": ">= 6.13.0" - } - }, "node_modules/node-releases": { "version": "2.0.38", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", @@ -10838,6 +11018,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, "license": "MIT", "optional": true, "engines": { @@ -10983,6 +11164,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -11045,7 +11227,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "yocto-queue": "^0.1.0" @@ -11142,6 +11324,7 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "dev": true, "funding": [ { "type": "github", @@ -11344,16 +11527,17 @@ } }, "node_modules/proto3-json-serializer": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.2.tgz", - "integrity": "sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-3.0.4.tgz", + "integrity": "sha512-E1sbAYg3aEbXrq0n1ojJkRHQJGE1kaE/O6GLA94y8rnJBfgvOPTOd1b9hOceQK1FFZI9qMh1vBERCyO2ifubcw==", + "dev": true, "license": "Apache-2.0", "optional": true, "dependencies": { - "protobufjs": "^7.2.5" + "protobufjs": "^7.4.0" }, "engines": { - "node": ">=14.0.0" + "node": ">=18" } }, "node_modules/protobufjs": { @@ -11567,6 +11751,7 @@ "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -11836,6 +12021,7 @@ "version": "7.0.2", "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-7.0.2.tgz", "integrity": "sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -11854,6 +12040,235 @@ "dev": true, "license": "MIT" }, + "node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/rimraf/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/rimraf/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/rimraf/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/rimraf/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/rimraf/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "optional": true, + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/rimraf/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "optional": true, + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rimraf/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/rimraf/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/rollup": { "version": "4.60.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", @@ -12448,6 +12863,7 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", "integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -12458,6 +12874,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "dev": true, "license": "MIT", "optional": true }, @@ -12465,6 +12882,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -12495,6 +12913,23 @@ "node": ">=8" } }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/string.prototype.matchall": { "version": "4.0.12", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", @@ -12609,6 +13044,21 @@ "node": ">=8" } }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -12659,6 +13109,7 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz", "integrity": "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==", + "dev": true, "funding": [ { "type": "github", @@ -12672,6 +13123,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", "integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==", + "dev": true, "license": "MIT", "optional": true }, @@ -12741,6 +13193,7 @@ "version": "9.0.0", "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz", "integrity": "sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==", + "dev": true, "license": "Apache-2.0", "optional": true, "dependencies": { @@ -12758,6 +13211,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -12771,6 +13225,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -12785,6 +13240,7 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -12806,6 +13262,7 @@ "version": "9.0.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "dev": true, "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" @@ -12959,6 +13416,7 @@ "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true, "license": "MIT", "optional": true }, @@ -13343,6 +13801,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, "license": "MIT", "optional": true }, @@ -13355,19 +13814,6 @@ "node": ">= 0.4.0" } }, - "node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/esm/bin/uuid" - } - }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -14083,6 +14529,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true, "license": "BSD-2-Clause", "optional": true }, @@ -14123,6 +14570,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -14608,10 +15056,31 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, "license": "ISC", "optional": true }, @@ -14715,7 +15184,7 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=10" diff --git a/package.json b/package.json index 8752abd..e6f156a 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,6 @@ "express": "^4.21.2", "express-rate-limit": "^8.5.2", "firebase": "^12.12.1", - "firebase-admin": "^13.8.0", "lucide-react": "^0.546.0", "motion": "^12.23.24", "nodemailer": "^9.0.0", @@ -44,6 +43,7 @@ "zustand": "^5.0.14" }, "devDependencies": { + "@firebase/rules-unit-testing": "^5.0.1", "@tanstack/react-query-devtools": "^5.100.14", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", @@ -60,6 +60,7 @@ "eslint-plugin-import": "^2.32.0", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", + "firebase-admin": "^14.1.0", "globals": "^17.6.0", "husky": "^9.1.7", "jsdom": "^29.0.2", diff --git a/src/application/factories/skillServiceFactory.ts b/src/application/factories/skillServiceFactory.ts new file mode 100644 index 0000000..e7de8c7 --- /dev/null +++ b/src/application/factories/skillServiceFactory.ts @@ -0,0 +1,11 @@ +import { SkillService } from "@/application/services/SkillService"; +import { FirebaseSkillRepository } from "@/infrastructure/firebase/skillRepository"; + +/** + * Factory simples para evitar acoplamento direto no frontend + * e manter consistência na criação do service. + */ +export function makeSkillService() { + const repository = new FirebaseSkillRepository(); + return new SkillService(repository); +} diff --git a/src/application/services/SkillService.ts b/src/application/services/SkillService.ts new file mode 100644 index 0000000..3665066 --- /dev/null +++ b/src/application/services/SkillService.ts @@ -0,0 +1,42 @@ +import { Timestamp } from "firebase/firestore"; + +import type { Skill } from "@/domain/entities/Skill"; +import type { ISkillRepository } from "@/domain/ports/ISkillRepository"; + +const normalizeSkillName = (name: string) => name.trim().toLowerCase().replace(/\s+/g, " "); + +export class SkillService { + constructor(private readonly repository: ISkillRepository) {} + + async createOrGetSkill(rawName: string, category: string | null = null): Promise { + if (!rawName || rawName.trim().length === 0) { + throw new Error("Skill inválida"); + } + + const normalizedName = normalizeSkillName(rawName); + + const existing = await this.repository.getSkillById(normalizedName); + if (existing) return existing; + + const newSkill: Skill = { + id: normalizedName, + name: rawName.trim(), + normalizedName, + category, + status: "pending", + usageCount: 1, + createdBy: "", + createdAt: Timestamp.now(), + }; + + try { + await this.repository.createSkill(newSkill); + } catch { + const fallback = await this.repository.getSkillById(normalizedName); + if (fallback) return fallback; + throw new Error(`Erro ao criar skill: ${rawName}`); + } + + return newSkill; + } +} diff --git a/src/domain/entities/Skill.ts b/src/domain/entities/Skill.ts new file mode 100644 index 0000000..71ee5c8 --- /dev/null +++ b/src/domain/entities/Skill.ts @@ -0,0 +1,14 @@ +import type { Timestamp } from "firebase/firestore"; + +export type SkillStatus = "pending" | "approved" | "rejected"; + +export interface Skill { + id: string; + name: string; + normalizedName: string; + category: string | null; + status: SkillStatus; + usageCount: number; + createdBy: string; + createdAt: Timestamp; +} diff --git a/src/domain/ports/ISkillRepository.ts b/src/domain/ports/ISkillRepository.ts new file mode 100644 index 0000000..d8a9ebd --- /dev/null +++ b/src/domain/ports/ISkillRepository.ts @@ -0,0 +1,11 @@ +import type { Skill } from "@/domain/entities/Skill"; + +export interface ISkillRepository { + getAllSkills(): Promise; + + getSkillById(id: string): Promise; + + createSkill(skill: Skill): Promise; + + updateSkill(id: string, data: Partial): Promise; +} diff --git a/src/features/discover/components/RoastModal.tsx b/src/features/discover/components/RoastModal.tsx index 824494a..f6a772e 100644 --- a/src/features/discover/components/RoastModal.tsx +++ b/src/features/discover/components/RoastModal.tsx @@ -8,18 +8,22 @@ interface RoastModalProps { profile: Profile; activePersonaView: RoastPersona | null; isGenerating: boolean; + streamingText?: string; onClose: () => void; onSelectPersona: (persona: RoastPersona) => void; onGenerateRoast: (profile: Profile, persona: RoastPersona) => void; + onDeleteRoast: () => void; } export function RoastModal({ profile, activePersonaView, isGenerating, + streamingText, onClose, onSelectPersona, onGenerateRoast, + onDeleteRoast, }: RoastModalProps) { return ( { onSelectPersona("brutal"); @@ -38,6 +43,7 @@ export function RoastModal({ onSelectPersona("mild"); void onGenerateRoast(profile, "mild"); }} + onDeleteRoast={onDeleteRoast} /> ); } diff --git a/src/features/discover/hooks/__tests__/useRoastProfile.test.tsx b/src/features/discover/hooks/__tests__/useRoastProfile.test.tsx new file mode 100644 index 0000000..f5e7b39 --- /dev/null +++ b/src/features/discover/hooks/__tests__/useRoastProfile.test.tsx @@ -0,0 +1,158 @@ +/** + * @vitest-environment jsdom + */ + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, act, waitFor } from "@testing-library/react"; +import React from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/shared/services/roast.service", () => ({ + requestRoast: vi.fn(), + deleteRoast: vi.fn(), +})); + +vi.mock("../../services/discover.repository", () => ({ + updateProfile: vi.fn(), +})); + +import type { Profile } from "../../model/discover.types"; +import { updateProfile } from "../../services/discover.repository"; +import { useRoastProfile } from "../useRoastProfile"; + +import { requestRoast, deleteRoast } from "@/shared/services/roast.service"; + +const mockProfile: Profile = { + id: "p1", + name: "Ada", + primaryRole: "Frontend", + skills: { frontend: 8, backend: 3, design: 5, data: 2, devops: 1, soft: 6 }, + canvas: { loves: [], comfort: [], veto: [] }, + status: "looking", +} as unknown as Profile; + +const showToast = vi.fn(); + +const createWrapper = () => { + const testQueryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + return ({ children }: { children: React.ReactNode }) => ( + {children} + ); +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("useRoastProfile streaming", () => { + it("accumulates streamingText as onChunk is called", async () => { + (requestRoast as ReturnType).mockImplementation( + async (_payload: unknown, onChunk?: (t: string) => void) => { + onChunk?.("Parte 1 "); + onChunk?.("Parte 2"); + return { roast: "Parte 1 Parte 2" }; + }, + ); + + const { result } = renderHook(() => useRoastProfile({ showToast }), { + wrapper: createWrapper(), + }); + + act(() => result.current.openProfile(mockProfile)); + act(() => result.current.executeRoast(mockProfile, "brutal")); + + await waitFor(() => expect(result.current.isGenerating).toBe(false)); + + // streamingText cleared after success + expect(result.current.streamingText).toBe(""); + }); + + it("does NOT call updateProfile after roast succeeds", async () => { + (requestRoast as ReturnType).mockResolvedValue({ roast: "Roast text" }); + + const { result } = renderHook(() => useRoastProfile({ showToast }), { + wrapper: createWrapper(), + }); + + act(() => result.current.openProfile(mockProfile)); + act(() => result.current.executeRoast(mockProfile, "brutal")); + + await waitFor(() => expect(result.current.isGenerating).toBe(false)); + + expect(updateProfile).not.toHaveBeenCalled(); + }); + + it("clears streamingText and shows toast on error", async () => { + (requestRoast as ReturnType).mockRejectedValue(new Error("IA offline")); + + const { result } = renderHook(() => useRoastProfile({ showToast }), { + wrapper: createWrapper(), + }); + + act(() => result.current.openProfile(mockProfile)); + act(() => result.current.executeRoast(mockProfile, "brutal")); + + await waitFor(() => expect(result.current.isGenerating).toBe(false)); + + expect(result.current.streamingText).toBe(""); + expect(showToast).toHaveBeenCalled(); + }); +}); + +describe("useRoastProfile - executeDeleteRoast", () => { + const profileWithBrutal: Profile = { + ...mockProfile, + roastBrutal: "Você é terrível!", + } as unknown as Profile; + + it("clears roastBrutal from selectedProfile on successful delete", async () => { + (deleteRoast as ReturnType).mockResolvedValue(undefined); + + const { result } = renderHook(() => useRoastProfile({ showToast }), { + wrapper: createWrapper(), + }); + + act(() => result.current.openProfile(profileWithBrutal)); + act(() => result.current.executeDeleteRoast("brutal")); + + await waitFor(() => expect(result.current.isDeleting).toBe(false)); + + expect(result.current.selectedProfile?.roastBrutal).toBeUndefined(); + }); + + it("shows toast on delete error", async () => { + (deleteRoast as ReturnType).mockRejectedValue(new Error("Server error")); + + const { result } = renderHook(() => useRoastProfile({ showToast }), { + wrapper: createWrapper(), + }); + + act(() => result.current.openProfile(mockProfile)); + act(() => result.current.executeDeleteRoast("brutal")); + + await waitFor(() => expect(result.current.isDeleting).toBe(false)); + + expect(showToast).toHaveBeenCalledWith("Erro ao apagar veredito. Tente novamente."); + }); + + it("calls onDeleteSuccess callback after successful delete", async () => { + (deleteRoast as ReturnType).mockResolvedValue(undefined); + const onDeleteSuccess = vi.fn(); + + const { result } = renderHook(() => useRoastProfile({ showToast, onDeleteSuccess }), { + wrapper: createWrapper(), + }); + + act(() => result.current.openProfile(profileWithBrutal)); + act(() => result.current.executeDeleteRoast("brutal")); + + await waitFor(() => expect(result.current.isDeleting).toBe(false)); + + expect(onDeleteSuccess).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/features/discover/hooks/useProfilesRealtime.ts b/src/features/discover/hooks/useProfilesRealtime.ts index e2b3fbb..48224c8 100644 --- a/src/features/discover/hooks/useProfilesRealtime.ts +++ b/src/features/discover/hooks/useProfilesRealtime.ts @@ -11,10 +11,7 @@ export function useProfilesRealtime(currentUserId?: string) { }); const profiles = useMemo(() => { - console.log("useProfilesRealtime: raw data from Firestore:", data); - console.log("useProfilesRealtime: currentUserId:", currentUserId); const sorted = currentUserId ? sortProfiles(data, currentUserId) : []; - console.log("useProfilesRealtime: sorted profiles:", sorted); return sorted; }, [data, currentUserId]); diff --git a/src/features/discover/hooks/useRoastProfile.ts b/src/features/discover/hooks/useRoastProfile.ts index 136cf02..791306e 100644 --- a/src/features/discover/hooks/useRoastProfile.ts +++ b/src/features/discover/hooks/useRoastProfile.ts @@ -3,43 +3,50 @@ import { useState } from "react"; import { getInitialPersona } from "../model/discover.selectors"; import type { Profile, RoastPersona } from "../model/discover.types"; -import { updateProfile } from "../services/discover.repository"; -import { firestoreLog, apiLog } from "@/shared/lib/logger/logger"; -import { requestRoast } from "@/shared/services/roast.service"; +import { apiLog } from "@/shared/lib/logger/logger"; +import { deleteRoast, requestRoast } from "@/shared/services/roast.service"; interface UseRoastProfileParams { showToast: (message: string, type?: "error" | "info") => void; + onDeleteSuccess?: () => void; } -export function useRoastProfile({ showToast }: UseRoastProfileParams) { +export function useRoastProfile({ showToast, onDeleteSuccess }: UseRoastProfileParams) { const [selectedProfile, setSelectedProfile] = useState(null); const [activePersonaView, setActivePersonaView] = useState(null); + const [streamingText, setStreamingText] = useState(""); const roastMutation = useMutation({ mutationFn: ({ profile, persona }: { profile: Profile; persona: RoastPersona }) => - requestRoast({ memberId: profile.id, memberData: profile, persona }), - onSuccess: async (data, { profile, persona }) => { - if (!data.roast) { - showToast(`Erro ao gerar a Sina: ${data.error ?? "Resposta inesperada do servidor."}`); - return; - } - const updateData = - persona === "brutal" - ? { roastBrutal: data.roast, updatedAt: new Date() } - : { roastMild: data.roast, updatedAt: new Date() }; - - try { - await updateProfile(profile.id, updateData); - } catch (dbError) { - firestoreLog.error("Erro ao salvar sina no banco:", dbError); - } - - setSelectedProfile({ ...profile, ...updateData }); + requestRoast({ memberId: profile.id, memberData: profile, persona }, (chunk) => + setStreamingText((prev) => prev + chunk), + ), + onMutate: () => { + setStreamingText(""); + }, + onSuccess: (data, { profile, persona }) => { + const field = persona === "brutal" ? "roastBrutal" : "roastMild"; + setSelectedProfile({ ...profile, [field]: data.roast, updatedAt: new Date() }); + setStreamingText(""); }, onError: (error) => { apiLog.error("Erro ao chamar o roast:", error); showToast("Sem conexão com o servidor de IA. Verifique sua rede e tente novamente."); + setStreamingText(""); + }, + }); + + const deleteRoastMutation = useMutation({ + mutationFn: ({ memberId, persona }: { memberId: string; persona: RoastPersona }) => + deleteRoast(memberId, persona), + onSuccess: (_, { persona }) => { + const field = persona === "brutal" ? "roastBrutal" : "roastMild"; + setSelectedProfile((prev) => (prev ? { ...prev, [field]: undefined } : null)); + onDeleteSuccess?.(); + }, + onError: () => { + showToast("Erro ao apagar veredito. Tente novamente."); }, }); @@ -58,13 +65,21 @@ export function useRoastProfile({ showToast }: UseRoastProfileParams) { roastMutation.mutate({ profile, persona }); } + function executeDeleteRoast(persona: RoastPersona) { + if (!selectedProfile) return; + deleteRoastMutation.mutate({ memberId: selectedProfile.id, persona }); + } + return { selectedProfile, activePersonaView, + streamingText, isGenerating: roastMutation.isPending, + isDeleting: deleteRoastMutation.isPending, openProfile, closeProfile: () => setSelectedProfile(null), executeRoast, + executeDeleteRoast, setActivePersonaView, }; } diff --git a/src/features/discover/pages/DiscoverPage.tsx b/src/features/discover/pages/DiscoverPage.tsx index 9dd3d95..d5a7a50 100644 --- a/src/features/discover/pages/DiscoverPage.tsx +++ b/src/features/discover/pages/DiscoverPage.tsx @@ -14,6 +14,7 @@ import { useToast } from "../hooks/useToast"; import { useAuth } from "@/contexts/useAuth"; import { SendMessageModal } from "@/features/messages/components/SendMessageModal"; +import { DeleteRoastConfirmModal } from "@/shared/components/ui/DeleteRoastConfirmModal"; export default function DiscoverPage() { const { user } = useAuth(); @@ -23,7 +24,12 @@ export default function DiscoverPage() { const filters = useDiscoverFilters(profiles); - const roast = useRoastProfile({ showToast }); + const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); + + const roast = useRoastProfile({ + showToast, + onDeleteSuccess: () => setConfirmDeleteOpen(false), + }); const [contactTarget, setContactTarget] = useState<{ id: string; name: string } | null>(null); @@ -68,9 +74,22 @@ export default function DiscoverPage() { profile={roast.selectedProfile} activePersonaView={roast.activePersonaView} isGenerating={roast.isGenerating} + streamingText={roast.streamingText} onClose={roast.closeProfile} onSelectPersona={roast.setActivePersonaView} onGenerateRoast={roast.executeRoast} + onDeleteRoast={() => setConfirmDeleteOpen(true)} + /> + )} + + + + {confirmDeleteOpen && roast.activePersonaView && ( + roast.executeDeleteRoast(roast.activePersonaView!)} + onCancel={() => setConfirmDeleteOpen(false)} /> )} diff --git a/src/features/onboarding/components/GuildPassport.tsx b/src/features/onboarding/components/GuildPassport.tsx index 4cec925..7d5a4aa 100644 --- a/src/features/onboarding/components/GuildPassport.tsx +++ b/src/features/onboarding/components/GuildPassport.tsx @@ -63,12 +63,15 @@ export function GuildPassport({ className="shadow-none border-4 w-full h-full" /> -
+

NOME_OPERADOR:

-

+

{form.name || "Aguardando..."}

diff --git a/src/features/onboarding/components/SkillAutocomplete.tsx b/src/features/onboarding/components/SkillAutocomplete.tsx new file mode 100644 index 0000000..6008e2a --- /dev/null +++ b/src/features/onboarding/components/SkillAutocomplete.tsx @@ -0,0 +1,230 @@ +import { Search, Plus, X } from "lucide-react"; +import { motion, AnimatePresence } from "motion/react"; +import { useRef, useState, useEffect, useCallback } from "react"; + +import type { Skill } from "@/domain/entities/Skill"; + +interface Props { + search: string; + filteredSkills: Skill[]; + selectedSkills: Skill[]; + onSearchChange: (value: string) => void; + onSelectSkill: (skill: Skill) => void; + onRemoveSkill: (skillId: string) => void; + onCreateSkill: (name: string) => Promise; +} + +export function SkillAutocomplete({ + search, + filteredSkills, + selectedSkills, + onSearchChange, + onSelectSkill, + onRemoveSkill, + onCreateSkill, +}: Props) { + const [open, setOpen] = useState(false); + const [creating, setCreating] = useState(false); + + const containerRef = useRef(null); + const inputRef = useRef(null); + + const trimmed = search.trim(); + + // Fecha o dropdown ao clicar fora do container inteiro + // Isso é mais confiável do que onBlur + setTimeout + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setOpen(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + // Mostra resultados sempre que está aberto — lista completa quando vazio, filtrada quando tem texto + const showNoResults = open && trimmed.length > 0 && filteredSkills.length === 0; + const showResults = open && filteredSkills.length > 0; + + const handleSelect = useCallback( + (skill: Skill) => { + onSelectSkill(skill); + onSearchChange(""); + setOpen(false); + inputRef.current?.focus(); + }, + [onSelectSkill, onSearchChange], + ); + + const handleCreate = useCallback(async () => { + if (!trimmed || creating) return; + setCreating(true); + try { + await onCreateSkill(trimmed); + onSearchChange(""); + setOpen(false); + } finally { + setCreating(false); + } + }, [trimmed, creating, onCreateSkill, onSearchChange]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + setOpen(false); + onSearchChange(""); + return; + } + if (e.key === "Enter") { + e.preventDefault(); + if (showNoResults) { + handleCreate(); + } else if (filteredSkills.length === 1) { + handleSelect(filteredSkills[0]); + } + } + }; + + return ( +
+ {/* ── Search input ── */} +
+
+ + + { + onSearchChange(e.target.value); + setOpen(true); + }} + onFocus={() => setOpen(true)} + onKeyDown={handleKeyDown} + className="flex-1 px-4 py-3 font-black text-sm uppercase tracking-widest bg-transparent outline-none text-neo-black placeholder:text-neo-black/25" + /> + + {search && ( + + )} +
+ + {/* ── Dropdown ── */} + + {open && ( + + {showResults && ( + <> + {/* Label de contexto */} +
+ + {trimmed ? `Resultados para "${trimmed}"` : "Todas as skills disponíveis"} + +
+ + {filteredSkills.map((skill) => ( + + ))} + + )} + + {showNoResults && ( +
+

+ Nenhuma skill encontrada. +

+ +
+ )} + + {/* Campo vazio + dropdown aberto: nenhum texto digitado ainda */} + {open && !trimmed && filteredSkills.length === 0 && ( +
+

+ Todas as skills já foram adicionadas. +

+
+ )} +
+ )} +
+
+ + {/* ── Selected skills chips ── */} + {selectedSkills.length > 0 && ( +
+ + {selectedSkills.map((skill) => ( + + + {skill.name} + + + + ))} + +
+ )} +
+ ); +} diff --git a/src/features/onboarding/components/SkillSelectorCard.tsx b/src/features/onboarding/components/SkillSelectorCard.tsx new file mode 100644 index 0000000..21a7f4d --- /dev/null +++ b/src/features/onboarding/components/SkillSelectorCard.tsx @@ -0,0 +1,82 @@ +import { Plus } from "lucide-react"; +import { useMemo } from "react"; + +import type { Skill } from "@/domain/entities/Skill"; +import { Card } from "@/shared/components/ui/Card"; + +interface Props { + value: string; + skills: Skill[]; + onChange: (value: string) => void; + onSelect: (skill: Skill) => void; + onCreate: (name: string) => void; +} + +export function SkillSelectorCard({ value, skills, onChange, onSelect, onCreate }: Props) { + // Filtra localmente (UI burra, sem regra de negócio) + const filteredSkills = useMemo(() => { + if (!value.trim()) return skills; + + return skills.filter((skill) => skill.normalizedName.includes(value.toLowerCase().trim())); + }, [value, skills]); + + const hasResults = filteredSkills.length > 0; + const canCreate = value.trim().length > 0; + + return ( + + {/* HEADER */} +
+

+ ARSENAL_DE_SKILLS (SEARCH) +

+
+ + {/* INPUT */} +
+ onChange(e.target.value)} + placeholder="Buscar ou criar skill..." + className="w-full px-4 py-3 border-4 border-neo-black font-bold uppercase text-sm outline-none shadow-[4px_4px_0_0_#000]" + /> +
+ + {/* LISTA */} +
+ {filteredSkills.map((skill) => ( + + ))} + + {/* EMPTY STATE + CREATE */} + {!hasResults && ( +
+

Nenhuma skill encontrada

+ + {canCreate && ( + + )} +
+ )} +
+
+ ); +} diff --git a/src/features/onboarding/components/SkillSliders.tsx b/src/features/onboarding/components/SkillSliders.tsx index bfcf57c..fe45afe 100644 --- a/src/features/onboarding/components/SkillSliders.tsx +++ b/src/features/onboarding/components/SkillSliders.tsx @@ -2,12 +2,12 @@ import { Card } from "../../../shared/components/ui/Card"; import type { OnboardingSkills } from "../types"; const SKILL_CONFIG = [ - { label: "Frontend / Visual", key: "frontend" }, - { label: "Backend / Infra", key: "backend" }, - { label: "UX / Psicologia", key: "ux_ui" }, - { label: "Dados / Lógica", key: "dados" }, - { label: "Hardware / IoT", key: "hardware_android" }, - { label: "IA / Vibe Coding", key: "vibe_coding" }, + { label: "Frontend / Visual", key: "frontend" }, + { label: "Backend / Infra", key: "backend" }, + { label: "UX / Psicologia", key: "ux_ui" }, + { label: "Dados / Lógica", key: "dados" }, + { label: "Hardware / IoT", key: "hardware_android" }, + { label: "IA / Vibe Coding", key: "vibe_coding" }, ] as const; interface Props { @@ -17,7 +17,11 @@ interface Props { export function SkillSliders({ skills, onSkillChange }: Props) { return ( - +

03. NÍVEL_DE_SINC diff --git a/src/features/onboarding/components/TagCategoryCard.tsx b/src/features/onboarding/components/TagCategoryCard.tsx index 580696c..8de1571 100644 --- a/src/features/onboarding/components/TagCategoryCard.tsx +++ b/src/features/onboarding/components/TagCategoryCard.tsx @@ -1,11 +1,13 @@ -import { Heart, Check, Ban } from "lucide-react"; -import { motion } from "motion/react"; +import { Heart, Check, Ban, Search, Plus, X } from "lucide-react"; +import { motion, AnimatePresence } from "motion/react"; +import { useRef, useState, useEffect, useMemo, useCallback } from "react"; import type { OnboardingForm, TagSentiment } from "../types"; import { Card } from "@/shared/components/ui/Card"; interface TagCategory { + key: string; name: string; color: string; textColor: string; @@ -14,18 +16,89 @@ interface TagCategory { interface Props { category: TagCategory; + extraTags?: string[]; // tags criadas pelo usuário nesta categoria form: OnboardingForm; onSetSentiment: (tag: string, sentiment: TagSentiment) => void; + onCreateSkill: (name: string, categoryKey: string) => Promise; } -export function TagCategoryCard({ category, form, onSetSentiment }: Props) { +export function TagCategoryCard({ + category, + extraTags = [], + form, + onSetSentiment, + onCreateSkill, +}: Props) { + const [search, setSearch] = useState(""); + const [open, setOpen] = useState(false); + const [creating, setCreating] = useState(false); + + const containerRef = useRef(null); + const inputRef = useRef(null); + + // All tags: fixed + user-created, deduplicated and sorted alphabetically + const allTags = useMemo(() => { + const merged = [ + ...category.tags, + ...extraTags.filter((t) => !category.tags.some((ct) => ct.toLowerCase() === t.toLowerCase())), + ]; + return merged.sort((a, b) => a.localeCompare(b, "pt-BR", { sensitivity: "base" })); + }, [category.tags, extraTags]); + + const trimmed = search.trim(); + + // Tags visible in the list — filtered when there's a search term + const visibleTags = useMemo(() => { + if (!trimmed) return allTags; + return allTags.filter((tag) => tag.toLowerCase().includes(trimmed.toLowerCase())); + }, [allTags, trimmed]); + + const hasResults = visibleTags.length > 0; + const showNoResults = open && trimmed.length > 0 && !hasResults; + + // Close dropdown on outside click + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setOpen(false); + setSearch(""); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + const handleCreate = useCallback(async () => { + if (!trimmed || creating) return; + setCreating(true); + try { + await onCreateSkill(trimmed, category.key); + setSearch(""); + setOpen(false); + } finally { + setCreating(false); + } + }, [trimmed, creating, onCreateSkill, category.key]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + setOpen(false); + setSearch(""); + return; + } + if (e.key === "Enter") { + e.preventDefault(); + if (showNoResults) handleCreate(); + } + }; + return ( - {/* Category header */} + {/* ── Category header ── */}

- {/* Tag rows */} -
- {category.tags.map((tag) => { - const isLoves = form.loves.includes(tag); - const isComfort = form.comfort.includes(tag); - const isVeto = form.veto.includes(tag); - - return ( -
+
+ + { + setSearch(e.target.value); + setOpen(true); + }} + onFocus={() => setOpen(true)} + onKeyDown={handleKeyDown} + className="flex-1 px-3 py-2.5 font-black text-[11px] uppercase tracking-widest bg-transparent outline-none text-neo-black placeholder:text-neo-black/70" + /> + {search && ( + - - {/* Comfort */} - - - {/* Veto */} - + )} +
+ + {/* ── Dropdown ── */} + + {open && trimmed.length > 0 && ( + + {hasResults + ? visibleTags.map((tag) => ( + + )) + : null} + + {showNoResults && ( +
+

+ Nenhuma skill encontrada. +

+ -
-
- ); - })} + + {creating ? "ADICIONANDO..." : `Adicionar "${trimmed}"`} + +
+ )} + + )} + +

+ + {/* ── Tag rows (alphabetical, filtered when searching) ── */} +
+ + {(trimmed ? visibleTags : allTags).map((tag) => { + const isLoves = form.loves.includes(tag); + const isComfort = form.comfort.includes(tag); + const isVeto = form.veto.includes(tag); + const isNew = extraTags.some((t) => t.toLowerCase() === tag.toLowerCase()); + + return ( + +
+ + {tag} + {/* Badge for user-created skills */} + {isNew && ( + + NOVA + + )} + + +
+ {/* Loves */} + + + {/* Comfort */} + + + {/* Veto */} + +
+
+
+ ); + })} +
); diff --git a/src/features/onboarding/constants/tagCategories.ts b/src/features/onboarding/constants/tagCategories.ts index 2d8f1b2..91ab371 100644 --- a/src/features/onboarding/constants/tagCategories.ts +++ b/src/features/onboarding/constants/tagCategories.ts @@ -1,38 +1,125 @@ export const TAG_CATEGORIES = [ { + key: "core_tech", name: "Core Tech", color: "bg-neo-cyan", textColor: "text-neo-cyan", - tags: ["React", "Next.js", "Vue", "Vite", "Tailwind", "Node.js", "Rust", "Go", "Java", "C++", "C#", "SQL", "NoSQL", "Firebase", "Supabase", "AWS", "Google Cloud", "Docker", "Kubernetes", "Redis", "MQTT"] + tags: [ + "React", + "Next.js", + "Vue", + "Vite", + "Tailwind", + "Node.js", + "Rust", + "Go", + "Java", + "C++", + "C#", + "SQL", + "NoSQL", + "Firebase", + "Supabase", + "AWS", + "Google Cloud", + "Docker", + "Kubernetes", + "Redis", + "MQTT", + ], }, { + key: "ia_future", name: "IA & Future", color: "bg-neo-pink", textColor: "text-neo-pink", - tags: ["Vibe Coding", "LLMs", "RAG", "Vector DBs", "OpenCV", "Fine-tuning", "Python", "Data Pipeline", "Web3", "Blockchain"] + tags: [ + "Vibe Coding", + "LLMs", + "RAG", + "Vector DBs", + "OpenCV", + "Fine-tuning", + "Python", + "Data Pipeline", + "Web3", + "Blockchain", + ], }, { + key: "hardware_iot", name: "Hardware & IoT", color: "bg-neo-yellow", textColor: "text-neo-yellow", - tags: ["Arduino", "ESP32", "Raspberry Pi", "Hardware", "IoT", "Edge Computing", "Android", "Swift", "Flutter"] + tags: [ + "Arduino", + "ESP32", + "Raspberry Pi", + "Hardware", + "IoT", + "Edge Computing", + "Android", + "Swift", + "Flutter", + ], }, { + key: "design_ux", name: "Design & UX", color: "bg-neo-lime", textColor: "text-neo-lime", - tags: ["UI/UX", "Mobile First", "Data Viz", "Motion Design", "Shaders (GLSL)", "Cybersecurity", "DevOps", "Cloud Arch"] + tags: [ + "UI/UX", + "Mobile First", + "Data Viz", + "Motion Design", + "Shaders (GLSL)", + "Cybersecurity", + "DevOps", + "Cloud Arch", + ], }, { + key: "lifestyle", name: "Lifestyle", color: "bg-white", textColor: "text-white", - tags: ["Café Preto", "Energético", "Noite Adentro", "Manhã Cedo", "Música Lofi", "Techno/Phonk", "Podcasts", "Action Figures", "Mechanical Keyboards", "Retro Gaming", "Cyberpunk", "D&D", "Pizzaria 24h", "Code Review"] + tags: [ + "Café Preto", + "Energético", + "Noite Adentro", + "Manhã Cedo", + "Música Lofi", + "Techno/Phonk", + "Podcasts", + "Action Figures", + "Mechanical Keyboards", + "Retro Gaming", + "Cyberpunk", + "D&D", + "Pizzaria 24h", + "Code Review", + ], }, { + key: "grunt_work", name: "The Grunt Work (Vetos)", color: "bg-neo-pink", textColor: "text-neo-pink", - tags: ["Documentação", "Servidores DNS", "CSS Puro", "Formulários", "Deploy Manual", "Reuniões", "PHP", "Legacy Code", "Excel", "Bitbucket", "SVN", "Vandalismo Visual", "Internet Lenta"] - } + tags: [ + "Documentação", + "Servidores DNS", + "CSS Puro", + "Formulários", + "Deploy Manual", + "Reuniões", + "PHP", + "Legacy Code", + "Excel", + "Bitbucket", + "SVN", + "Vandalismo Visual", + "Internet Lenta", + ], + }, ]; diff --git a/src/features/onboarding/hooks/useOnboardingForm.ts b/src/features/onboarding/hooks/useOnboardingForm.ts index f79d44e..4d9b1cd 100644 --- a/src/features/onboarding/hooks/useOnboardingForm.ts +++ b/src/features/onboarding/hooks/useOnboardingForm.ts @@ -1,20 +1,47 @@ import { doc, setDoc, getDoc, serverTimestamp } from "firebase/firestore"; -import { useState, useMemo } from "react"; +import { useState, useMemo, useCallback } from "react"; import { useNavigate } from "react-router-dom"; import { db } from "../../../shared/lib/firebase/firebase.client"; import { firestoreLog } from "../../../shared/lib/logger/logger"; import type { OnboardingForm, OnboardingSkills, TagSentiment } from "../types"; +import { SkillService } from "@/application/services/SkillService"; +import { FirebaseSkillRepository } from "@/infrastructure/firebase/skillRepository"; + +type ProfileData = { + userId: string; + name: string; + photoURL: string | null; + github: string; + linkedin: string; + bio: string; + primaryRole: string; + secondaryRoles: string[]; + skills: OnboardingSkills; + extraTagsByCategory: Record; + canvas: { loves: string[]; comfort: string[]; veto: string[] }; + status: string; + eventId: string; + updatedAt: ReturnType; + createdAt?: ReturnType; + visibility?: string; +}; + // eslint-disable-next-line export function useOnboardingForm(user: any) { - // TODO const navigate = useNavigate(); + // ───────────────────────────────────────────── + // STATE + // ───────────────────────────────────────────── const [loading, setLoading] = useState(false); const [initializing, setInitializing] = useState(true); const [submitError, setSubmitError] = useState(null); + // Extra tags added by users per category key, merged into the category tag list + const [extraTagsByCategory, setExtraTagsByCategory] = useState>({}); + const [skills, setSkills] = useState({ frontend: 7, backend: 4, @@ -38,7 +65,12 @@ export function useOnboardingForm(user: any) { createdAt: "", }); - // Firestore fetch on mount + const skillRepository = useMemo(() => new FirebaseSkillRepository(), []); + const skillService = useMemo(() => new SkillService(skillRepository), [skillRepository]); + + // ───────────────────────────────────────────── + // FIRESTORE LOAD + // ───────────────────────────────────────────── const fetchMemberData = async () => { if (!user) { setInitializing(false); @@ -65,6 +97,7 @@ export function useOnboardingForm(user: any) { if (docSnap.exists()) { const data = docSnap.data(); + setForm({ name: data.name || user.displayName || "", github: data.github || "", @@ -78,9 +111,17 @@ export function useOnboardingForm(user: any) { status: data.status || "looking", createdAt: fromMembersFallback ? null : data.createdAt || null, }); + if (data.skills) setSkills(data.skills); + + if (data.extraTagsByCategory) { + setExtraTagsByCategory(data.extraTagsByCategory); + } } else { - setForm((prev) => ({ ...prev, name: prev.name || user.displayName || "" })); + setForm((prev) => ({ + ...prev, + name: prev.name || user.displayName || "", + })); } } catch (error) { firestoreLog.error("Erro ao buscar dados do membro:", error); @@ -89,7 +130,36 @@ export function useOnboardingForm(user: any) { } }; - // Radar chart data derived from skills + // ───────────────────────────────────────────── + // SKILL CREATION PER CATEGORY + // useCallback so it can be safely listed as a + // useEffect dependency without infinite loops. + // ───────────────────────────────────────────── + const handleCreateSkillInCategory = useCallback( + async (rawName: string, categoryKey: string): Promise => { + const trimmed = rawName.trim(); + if (!trimmed) return; + + try { + await skillService.createOrGetSkill(trimmed, categoryKey); + + setExtraTagsByCategory((prev) => { + const existing = prev[categoryKey] ?? []; + if (existing.some((t) => t.toLowerCase() === trimmed.toLowerCase())) { + return prev; + } + return { ...prev, [categoryKey]: [...existing, trimmed] }; + }); + } catch (error) { + firestoreLog.error("Erro ao criar skill:", error); + } + }, + [skillService], + ); + + // ───────────────────────────────────────────── + // RADAR + // ───────────────────────────────────────────── const radarData = useMemo( () => [ { subject: "Frontend", A: skills.frontend, fullMark: 10 }, @@ -102,8 +172,9 @@ export function useOnboardingForm(user: any) { [skills], ); - // ── Handlers ────────────────────────────────────────────────────────────── - + // ───────────────────────────────────────────── + // HANDLERS FORM + // ───────────────────────────────────────────── const handleChange = ( e: React.ChangeEvent, ) => { @@ -121,11 +192,15 @@ export function useOnboardingForm(user: any) { const toggleRole = (role: string) => { setForm((prev) => { if (prev.primaryRole === role) return { ...prev, primaryRole: "" }; + if (prev.secondaryRoles.includes(role)) return { ...prev, secondaryRoles: prev.secondaryRoles.filter((r) => r !== role) }; + if (!prev.primaryRole) return { ...prev, primaryRole: role }; + if (prev.secondaryRoles.length < 2) return { ...prev, secondaryRoles: [...prev.secondaryRoles, role] }; + return prev; }); }; @@ -144,11 +219,15 @@ export function useOnboardingForm(user: any) { }); }; + // ───────────────────────────────────────────── + // SUBMIT + // ───────────────────────────────────────────── const handleSubmit = async (e?: React.FormEvent) => { e?.preventDefault(); if (!user) return; setLoading(true); + try { const cleanGithub = (form.github || "") .trim() @@ -162,9 +241,7 @@ export function useOnboardingForm(user: any) { .replace(/\/$/, "") .replace(/^@/, ""); - // eslint-disable-next-line - const profileData: any = { - // TODO + const profileData: ProfileData = { userId: user.uid, name: form.name.trim(), photoURL: user.photoURL || null, @@ -174,6 +251,7 @@ export function useOnboardingForm(user: any) { primaryRole: form.primaryRole, secondaryRoles: form.secondaryRoles, skills, + extraTagsByCategory, canvas: { loves: form.loves, comfort: form.comfort, @@ -190,6 +268,7 @@ export function useOnboardingForm(user: any) { } await setDoc(doc(db, "profiles", user.uid), profileData, { merge: true }); + navigate("/discover"); } catch (err) { firestoreLog.error("Erro ao registrar perfil:", err); @@ -200,6 +279,9 @@ export function useOnboardingForm(user: any) { } }; + // ───────────────────────────────────────────── + // RETURN + // ───────────────────────────────────────────── return { form, setForm, @@ -209,6 +291,8 @@ export function useOnboardingForm(user: any) { submitError, radarData, fetchMemberData, + extraTagsByCategory, + handlers: { change: handleChange, bioChange: handleBioChange, @@ -216,6 +300,7 @@ export function useOnboardingForm(user: any) { toggleRole, setTagSentiment, submit: handleSubmit, + createSkillInCategory: handleCreateSkillInCategory, }, }; } diff --git a/src/features/onboarding/pages/Onboarding.tsx b/src/features/onboarding/pages/Onboarding.tsx index a919f7d..8386d14 100644 --- a/src/features/onboarding/pages/Onboarding.tsx +++ b/src/features/onboarding/pages/Onboarding.tsx @@ -2,11 +2,6 @@ import { motion } from "motion/react"; import React from "react"; -// Constants - -// Hook - -// Components import { ArsenalCalibration } from "../components/ArsenalCalibration"; import { AuthGate } from "../components/AuthGate/AuthGate"; import { ClassSelector } from "../components/ClassSelector"; @@ -33,10 +28,18 @@ export default function Onboarding() { confirmMagicLinkEmail, } = useAuth(); - const { form, skills, loading, initializing, submitError, radarData, fetchMemberData, handlers } = - useOnboardingForm(user); + const { + form, + skills, + loading, + initializing, + submitError, + radarData, + fetchMemberData, + extraTagsByCategory, + handlers, + } = useOnboardingForm(user); - // Fetch profile data once user is authenticated React.useEffect(() => { fetchMemberData(); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -135,10 +138,12 @@ export default function Onboarding() {
{TAG_CATEGORIES.map((category) => ( ))}
diff --git a/src/infrastructure/firebase/skillRepository.ts b/src/infrastructure/firebase/skillRepository.ts new file mode 100644 index 0000000..be6d27c --- /dev/null +++ b/src/infrastructure/firebase/skillRepository.ts @@ -0,0 +1,69 @@ +import { collection, getDocs, doc, getDoc, setDoc, serverTimestamp } from "firebase/firestore"; + +import type { Skill } from "@/domain/entities/Skill"; +import type { ISkillRepository } from "@/domain/ports/ISkillRepository"; +import { db } from "@/shared/lib/firebase/firebase.client"; + +export class FirebaseSkillRepository implements ISkillRepository { + /** + * Lista todas as skills. + * Usado para autocomplete inicial e administração. + */ + async getAllSkills(): Promise { + const snapshot = await getDocs(collection(db, "skills")); + + return snapshot.docs.map((doc) => ({ + id: doc.id, + ...doc.data(), + })) as Skill[]; + } + + /** + * Busca skill diretamente pelo ID. + * + * DECISÃO: + * - id = normalizedName + * - leitura direta O(1) + */ + async getSkillById(id: string): Promise { + const ref = doc(db, "skills", id); + const snapshot = await getDoc(ref); + + if (!snapshot.exists()) { + return null; + } + + return { + id: snapshot.id, + ...snapshot.data(), + } as Skill; + } + + /** + * Cria skill usando ID determinístico. + */ + async createSkill(skill: Skill): Promise { + const ref = doc(db, "skills", skill.normalizedName); + + await setDoc( + ref, + { + ...skill, + id: skill.normalizedName, + createdAt: serverTimestamp(), + }, + { + merge: false, + }, + ); + } + + /** + * Atualização parcial. + * Prefixo _ nos parâmetros indica que são exigidos pela interface + * mas ainda não implementados nesta versão. + */ + async updateSkill(_id: string, _data: Partial): Promise { + throw new Error("Method not implemented."); + } +} diff --git a/src/server/features/roast/__tests__/roast.controller.test.ts b/src/server/features/roast/__tests__/roast.controller.test.ts new file mode 100644 index 0000000..9730a19 --- /dev/null +++ b/src/server/features/roast/__tests__/roast.controller.test.ts @@ -0,0 +1,81 @@ +import type { Request, Response } from "express"; +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../roast.service", () => ({ + generateRoastStream: vi.fn(), +})); + +import { postRoast } from "../roast.controller"; +import { generateRoastStream } from "../roast.service"; + +async function* chunks(...texts: string[]) { + for (const t of texts) yield t; +} + +function mockRes() { + const written: string[] = []; + return { + setHeader: vi.fn(), + flushHeaders: vi.fn(), + write: vi.fn((s: string) => { + written.push(s); + }), + end: vi.fn(), + status: vi.fn().mockReturnThis(), + json: vi.fn(), + _written: written, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("postRoast (SSE)", () => { + it("sets SSE headers", async () => { + (generateRoastStream as ReturnType).mockReturnValue(chunks()); + const res = mockRes(); + await postRoast( + { body: { memberId: "u1", memberData: {} } } as Request, + res as unknown as Response, + ); + expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "text/event-stream"); + expect(res.setHeader).toHaveBeenCalledWith("Cache-Control", "no-cache"); + expect(res.setHeader).toHaveBeenCalledWith("Connection", "keep-alive"); + }); + + it("writes each chunk as SSE data and ends with [DONE]", async () => { + (generateRoastStream as ReturnType).mockReturnValue(chunks("Olá ", "mundo")); + const res = mockRes(); + await postRoast( + { body: { memberId: "u1", memberData: {} } } as Request, + res as unknown as Response, + ); + expect(res._written).toContain('data: {"chunk":"Olá "}\n\n'); + expect(res._written).toContain('data: {"chunk":"mundo"}\n\n'); + expect(res._written).toContain("data: [DONE]\n\n"); + expect(res.end).toHaveBeenCalled(); + }); + + it("writes error event when stream throws", async () => { + (generateRoastStream as ReturnType).mockReturnValue( + (async function* () { + throw new Error("Gemini down"); + })(), + ); + const res = mockRes(); + await postRoast( + { body: { memberId: "u1", memberData: {} } } as Request, + res as unknown as Response, + ); + expect(res._written.some((s) => s.includes('"error"'))).toBe(true); + expect(res.end).toHaveBeenCalled(); + }); + + it("returns 400 json when memberId or memberData are missing", async () => { + const res = mockRes(); + await postRoast({ body: {} } as Request, res as unknown as Response); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: "Missing memberId or memberData" }); + }); +}); diff --git a/src/server/features/roast/__tests__/roast.service.test.ts b/src/server/features/roast/__tests__/roast.service.test.ts new file mode 100644 index 0000000..301d25c --- /dev/null +++ b/src/server/features/roast/__tests__/roast.service.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/server/shared/lib/gemini.server", () => ({ + getGeminiClient: vi.fn(), +})); + +vi.mock("../roast.repository", () => ({ + saveProfileRoast: vi.fn().mockResolvedValue(undefined), +})); + +import { saveProfileRoast } from "../roast.repository"; +import { generateRoastStream } from "../roast.service"; +import type { RoastRequestBody } from "../roast.types"; + +import { getGeminiClient } from "@/server/shared/lib/gemini.server"; + +const mockBody: RoastRequestBody = { + memberId: "user-1", + memberData: { name: "Ada", primaryRole: "Frontend" }, + persona: "brutal", +}; + +function makeAsyncIterable(chunks: { text: string }[]) { + return { + [Symbol.asyncIterator]() { + let i = 0; + return { + async next() { + if (i >= chunks.length) return { done: true, value: undefined }; + return { done: false, value: chunks[i++] }; + }, + }; + }, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + (getGeminiClient as ReturnType).mockReturnValue({ + models: { + generateContentStream: vi + .fn() + .mockResolvedValue(makeAsyncIterable([{ text: "Você é " }, { text: "terrível." }])), + }, + }); +}); + +describe("generateRoastStream", () => { + it("yields text chunks from the Gemini stream", async () => { + const chunks: string[] = []; + for await (const chunk of generateRoastStream(mockBody)) { + chunks.push(chunk); + } + expect(chunks).toEqual(["Você é ", "terrível."]); + }); + + it("calls saveProfileRoast with the full accumulated text after streaming", async () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _ of generateRoastStream(mockBody)) { + /* drain */ + } + expect(saveProfileRoast).toHaveBeenCalledWith("user-1", "Você é terrível.", "brutal"); + }); + + it("passes thinkingBudget: 0 in the Gemini config", async () => { + const mockClient = (getGeminiClient as ReturnType)(); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _ of generateRoastStream(mockBody)) { + /* drain */ + } + expect(mockClient.models.generateContentStream).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ + thinkingConfig: { thinkingBudget: 0 }, + }), + }), + ); + }); + + it("skips empty chunks", async () => { + (getGeminiClient as ReturnType).mockReturnValue({ + models: { + generateContentStream: vi + .fn() + .mockResolvedValue( + makeAsyncIterable([{ text: "hello" }, { text: "" }, { text: " world" }]), + ), + }, + }); + const chunks: string[] = []; + for await (const chunk of generateRoastStream(mockBody)) { + chunks.push(chunk); + } + expect(chunks).toEqual(["hello", " world"]); + }); +}); diff --git a/src/server/features/roast/roast.controller.ts b/src/server/features/roast/roast.controller.ts index 88a86a4..a734516 100644 --- a/src/server/features/roast/roast.controller.ts +++ b/src/server/features/roast/roast.controller.ts @@ -1,27 +1,48 @@ import type { Request, Response } from "express"; -import { generateRoast } from "./roast.service"; -import type { RoastRequestBody } from "./roast.types"; +import { deleteProfileRoast } from "./roast.repository"; +import { generateRoastStream } from "./roast.service"; +import type { RoastPersona, RoastRequestBody } from "./roast.types"; + +export async function deleteRoast(req: Request, res: Response) { + const { memberId } = req.params; + const persona = req.query.persona as RoastPersona | undefined; + + if (!memberId) { + res.status(400).json({ error: "Missing memberId" }); + return; + } + + await deleteProfileRoast(memberId, persona); + res.status(200).json({ success: true }); +} export async function postRoast(req: Request, res: Response) { - try { - const body = req.body as RoastRequestBody; - const { memberId, memberData } = body; + const body = req.body as RoastRequestBody; + const { memberId, memberData } = body; - if (!memberId || !memberData) { - return res.status(400).json({ error: "Missing memberId or memberData" }); - } + if (!memberId || !memberData) { + res.status(400).json({ error: "Missing memberId or memberData" }); + return; + } - const roast = await generateRoast(body); + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Cache-Control", "no-cache"); + res.setHeader("Connection", "keep-alive"); + res.flushHeaders(); - return res.json({ roast }); + try { + for await (const chunk of generateRoastStream(body)) { + res.write(`data: ${JSON.stringify({ chunk })}\n\n`); + } + res.write("data: [DONE]\n\n"); } catch (error) { const message = error instanceof Error ? error.message : "Unexpected server error"; - const errorMessage = message.includes("API key not valid") ? "Chave da API do Gemini inválida ou não configurada. Por favor, adicione uma chave válida no painel de configurações." : message; - - return res.status(500).json({ error: errorMessage }); + res.write(`data: ${JSON.stringify({ error: errorMessage })}\n\n`); + } finally { + res.end(); } } diff --git a/src/server/features/roast/roast.repository.ts b/src/server/features/roast/roast.repository.ts index 0053f30..c39a178 100644 --- a/src/server/features/roast/roast.repository.ts +++ b/src/server/features/roast/roast.repository.ts @@ -1,7 +1,25 @@ +import { FieldValue } from "firebase-admin/firestore"; + import { adminDb } from "../../shared/lib/firebase-admin.server"; import type { RoastPersona } from "./roast.types"; +export async function deleteProfileRoast(memberId: string, persona?: RoastPersona) { + const updateData: Record = { + updatedAt: new Date(), + }; + + if (persona === "brutal") { + updateData.roastBrutal = FieldValue.delete(); + } else if (persona === "mild") { + updateData.roastMild = FieldValue.delete(); + } else { + updateData.roast = FieldValue.delete(); + } + + await adminDb.collection("profiles").doc(memberId).update(updateData); +} + export async function saveProfileRoast( memberId: string, roastText: string, diff --git a/src/server/features/roast/roast.routes.ts b/src/server/features/roast/roast.routes.ts index 5affe7b..06f86af 100644 --- a/src/server/features/roast/roast.routes.ts +++ b/src/server/features/roast/roast.routes.ts @@ -3,7 +3,7 @@ import rateLimit from "express-rate-limit"; import { asyncHandler } from "../../shared/utils/async-handler"; -import { postRoast } from "./roast.controller"; +import { deleteRoast, postRoast } from "./roast.controller"; // 5 roast requests per user per minute — Gemini calls are expensive const roastLimiter = rateLimit({ @@ -17,3 +17,4 @@ const roastLimiter = rateLimit({ export const roastRouter = Router(); roastRouter.post("/", roastLimiter, asyncHandler(postRoast)); +roastRouter.delete("/:memberId", asyncHandler(deleteRoast)); diff --git a/src/server/features/roast/roast.service.ts b/src/server/features/roast/roast.service.ts index f1686b6..8ed3245 100644 --- a/src/server/features/roast/roast.service.ts +++ b/src/server/features/roast/roast.service.ts @@ -4,24 +4,31 @@ import type { RoastPersona, RoastRequestBody } from "./roast.types"; import { getGeminiClient } from "@/server/shared/lib/gemini.server"; -export async function generateRoast({ memberId, memberData, persona }: RoastRequestBody) { +export async function* generateRoastStream({ + memberId, + memberData, + persona, +}: RoastRequestBody): AsyncGenerator { const ai = getGeminiClient(); - const response = await ai.models.generateContent({ + const result = await ai.models.generateContentStream({ model: "gemini-2.5-flash", contents: `Analise este membro. DADOS DO MEMBRO:\n${JSON.stringify(memberData, null, 2)}`, config: { + thinkingConfig: { thinkingBudget: 0 }, systemInstruction: getRoastSystemInstruction(persona as RoastPersona), }, }); - const roastText = response.text ?? ""; + let fullText = ""; - if (!roastText) { - throw new Error("A IA não retornou conteúdo para o roast."); + for await (const chunk of result) { + const text = chunk.text ?? ""; + if (text) { + fullText += text; + yield text; + } } - await saveProfileRoast(memberId, roastText, persona); - - return roastText; + await saveProfileRoast(memberId, fullText, persona); } diff --git a/src/server/shared/lib/firebase-admin.server.ts b/src/server/shared/lib/firebase-admin.server.ts index c722ddc..a00abd4 100644 --- a/src/server/shared/lib/firebase-admin.server.ts +++ b/src/server/shared/lib/firebase-admin.server.ts @@ -68,5 +68,7 @@ function getFirebaseAdminApp() { const adminApp = getFirebaseAdminApp(); -export const adminDb = getFirestore(adminApp); +const firestoreDatabaseId = process.env.VITE_FIREBASE_FIRESTORE_DATABASE_ID || "(default)"; + +export const adminDb = getFirestore(adminApp, firestoreDatabaseId); export const adminAuth = getAuth(adminApp); diff --git a/src/shared/components/ui/DeleteRoastConfirmModal.tsx b/src/shared/components/ui/DeleteRoastConfirmModal.tsx new file mode 100644 index 0000000..8e08f09 --- /dev/null +++ b/src/shared/components/ui/DeleteRoastConfirmModal.tsx @@ -0,0 +1,101 @@ +import { Trash2 } from "lucide-react"; +import { motion } from "motion/react"; + +import type { RoastPersona } from "@/domain/entities/Shared"; + +interface DeleteRoastConfirmModalProps { + persona: RoastPersona; + isDeleting: boolean; + onConfirm: () => void; + onCancel: () => void; +} + +export function DeleteRoastConfirmModal({ + persona, + isDeleting, + onConfirm, + onCancel, +}: DeleteRoastConfirmModalProps) { + const personaLabel = persona === "brutal" ? "TECH LEAD (BRUTAL)" : "MENTOR (SUAVE)"; + + return ( +
+ + +
+ +
+ +
+ +
+
+
+ +
+

+ APAGAR VEREDITO_ +

+

ALVO: {personaLabel}

+
+
+ +
+
+

+ ⚠ ESTA AÇÃO É IRREVERSÍVEL +

+

+ O veredito {personaLabel} será apagado + permanentemente do banco de dados. +

+
+ +
+ + +
+
+ +
+ [SISTEMA ROASTED & TOASTED] © 2026 + + PERIGO + +
+
+ +
+
+ ); +} diff --git a/src/shared/components/ui/ProfileCard.tsx b/src/shared/components/ui/ProfileCard.tsx index 7f0e53b..3e9615b 100644 --- a/src/shared/components/ui/ProfileCard.tsx +++ b/src/shared/components/ui/ProfileCard.tsx @@ -102,7 +102,10 @@ export default function ProfileCard({ className="group-hover:scale-105 transition-transform" />
-

+

{name || "NOME_NULO"}

@@ -158,7 +161,10 @@ export default function ProfileCard({ />
-

+

{name || "NOME_NULO"}

diff --git a/src/shared/components/ui/RoastModal.tsx b/src/shared/components/ui/RoastModal.tsx index 8e8525b..83bfd05 100644 --- a/src/shared/components/ui/RoastModal.tsx +++ b/src/shared/components/ui/RoastModal.tsx @@ -1,4 +1,4 @@ -import { Terminal } from "lucide-react"; +import { Terminal, Trash2 } from "lucide-react"; import { motion } from "motion/react"; import type { RoastPersona } from "@/domain/entities/Shared"; @@ -10,9 +10,11 @@ interface RoastModalProps { roastMild?: string; activePersonaView: RoastPersona | null; isGenerating?: boolean; + streamingText?: string; onClose: () => void; onGenerateBrutal: () => void; onGenerateMild: () => void; + onDeleteRoast?: () => void; } export function RoastModal({ @@ -22,9 +24,11 @@ export function RoastModal({ roastMild, activePersonaView, isGenerating = false, + streamingText, onClose, onGenerateBrutal, onGenerateMild, + onDeleteRoast, }: RoastModalProps) { const isBrutalActive = activePersonaView === "brutal"; const isMildActive = activePersonaView === "mild"; @@ -73,7 +77,20 @@ export function RoastModal({
- {isGenerating ? "Gerando veredito..." : roastText} + {isGenerating ? ( + <> + {streamingText || "Gerando veredito..."} + + | + + + ) : ( + roastText + )}
@@ -100,6 +117,17 @@ export function RoastModal({ > {roastMild ? "VER MENTOR (SUAVE)" : "GERAR MENTOR (SUAVE)"} + + {onDeleteRoast && roastText && ( + + )}
diff --git a/src/shared/hooks/useFirestoreSubscription.ts b/src/shared/hooks/useFirestoreSubscription.ts index 2d9a409..3c746fc 100644 --- a/src/shared/hooks/useFirestoreSubscription.ts +++ b/src/shared/hooks/useFirestoreSubscription.ts @@ -1,9 +1,13 @@ -import { collection, query, onSnapshot, QueryConstraint } from "firebase/firestore"; +import { collection, query, onSnapshot, type QueryConstraint } from "firebase/firestore"; import { useEffect, useState } from "react"; import { AppError } from "@/shared/lib/AppError"; import { db } from "@/shared/lib/firebase/firebase.client"; +// Module-level constant avoids creating a new [] reference on every render, +// which would otherwise cause useEffect to re-subscribe in an infinite loop. +const EMPTY_CONSTRAINTS: QueryConstraint[] = []; + interface UseFirestoreSubscriptionOptions { collectionName: string; constraints?: QueryConstraint[]; @@ -32,7 +36,7 @@ interface UseFirestoreSubscriptionReturn { */ export function useFirestoreSubscription({ collectionName, - constraints = [], + constraints = EMPTY_CONSTRAINTS, sortFn, }: UseFirestoreSubscriptionOptions): UseFirestoreSubscriptionReturn { const [data, setData] = useState([]); @@ -41,7 +45,6 @@ export function useFirestoreSubscription({ useEffect(() => { setLoading(true); - setError(null); try { @@ -51,10 +54,13 @@ export function useFirestoreSubscription({ q, (snapshot) => { try { - const docs = snapshot.docs.map((doc) => ({ - id: doc.id, - ...(doc.data() as object), - } as T)); + const docs = snapshot.docs.map( + (doc) => + ({ + id: doc.id, + ...(doc.data() as object), + }) as T, + ); const sorted = sortFn ? docs.sort(sortFn) : docs; setData(sorted); setError(null); @@ -72,7 +78,6 @@ export function useFirestoreSubscription({ return unsubscribe; } catch { - // Error will be handled by onSnapshot error callback return undefined; } }, [collectionName, constraints, sortFn]); diff --git a/src/shared/lib/utils/normalizeSkillName.ts b/src/shared/lib/utils/normalizeSkillName.ts new file mode 100644 index 0000000..78df082 --- /dev/null +++ b/src/shared/lib/utils/normalizeSkillName.ts @@ -0,0 +1,6 @@ +/** + * Normaliza nome da skill para garantir consistência no banco. + */ +export function normalizeSkillName(name: string): string { + return name.trim().toLowerCase().replace(/\s+/g, " "); +} diff --git a/src/shared/services/__tests__/roast.service.test.ts b/src/shared/services/__tests__/roast.service.test.ts new file mode 100644 index 0000000..e62fe2d --- /dev/null +++ b/src/shared/services/__tests__/roast.service.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +import { requestRoast, deleteRoast } from "../roast.service"; + +function sseStream(...events: string[]) { + const data = events.map((e) => `data: ${e}\n\n`).join(""); + const encoder = new TextEncoder(); + const bytes = encoder.encode(data); + return new ReadableStream({ + start(controller) { + controller.enqueue(bytes); + controller.close(); + }, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("requestRoast (SSE client)", () => { + it("resolves with full roast text when [DONE] is received", async () => { + mockFetch.mockResolvedValue({ + ok: true, + body: sseStream('{"chunk":"Olá "}', '{"chunk":"mundo"}', "[DONE]"), + }); + const result = await requestRoast({ memberId: "u1", memberData: {}, persona: "brutal" }); + expect(result.roast).toBe("Olá mundo"); + }); + + it("calls onChunk for each received chunk in order", async () => { + mockFetch.mockResolvedValue({ + ok: true, + body: sseStream('{"chunk":"A"}', '{"chunk":"B"}', "[DONE]"), + }); + const calls: string[] = []; + await requestRoast({ memberId: "u1", memberData: {}, persona: "brutal" }, (t) => calls.push(t)); + expect(calls).toEqual(["A", "B"]); + }); + + it("throws when the stream contains an error event", async () => { + mockFetch.mockResolvedValue({ + ok: true, + body: sseStream('{"error":"Gemini down"}'), + }); + await expect( + requestRoast({ memberId: "u1", memberData: {}, persona: "brutal" }), + ).rejects.toThrow("Gemini down"); + }); + + it("throws when response is not ok", async () => { + mockFetch.mockResolvedValue({ + ok: false, + body: null, + json: async () => ({ error: "Rate limit" }), + }); + await expect( + requestRoast({ memberId: "u1", memberData: {}, persona: "brutal" }), + ).rejects.toThrow("Rate limit"); + }); +}); + +describe("deleteRoast", () => { + it("calls DELETE /api/roast/:memberId?persona=brutal", async () => { + mockFetch.mockResolvedValue({ ok: true, json: async () => ({ success: true }) }); + await deleteRoast("u1", "brutal"); + expect(mockFetch).toHaveBeenCalledWith("/api/roast/u1?persona=brutal", { method: "DELETE" }); + }); + + it("calls DELETE /api/roast/:memberId without persona query when persona is omitted", async () => { + mockFetch.mockResolvedValue({ ok: true, json: async () => ({ success: true }) }); + await deleteRoast("u1"); + expect(mockFetch).toHaveBeenCalledWith("/api/roast/u1", { method: "DELETE" }); + }); + + it("throws with server error message when response is not ok", async () => { + mockFetch.mockResolvedValue({ ok: false, json: async () => ({ error: "Not found" }) }); + await expect(deleteRoast("u1", "brutal")).rejects.toThrow("Not found"); + }); +}); diff --git a/src/shared/services/roast.service.ts b/src/shared/services/roast.service.ts index dd056e9..84d2fdc 100644 --- a/src/shared/services/roast.service.ts +++ b/src/shared/services/roast.service.ts @@ -11,20 +11,64 @@ export interface RoastApiResponse { error?: string; } -export async function requestRoast(payload: RoastRequestPayload): Promise { +export async function requestRoast( + payload: RoastRequestPayload, + onChunk?: (text: string) => void, +): Promise { const response = await fetch("/api/roast", { method: "POST", - headers: { - "Content-Type": "application/json", - }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); - const data = (await response.json()) as RoastApiResponse; - - if (!response.ok) { + if (!response.ok || !response.body) { + const data = (await response.json()) as RoastApiResponse; throw new Error(data.error || "Erro ao gerar roast."); } - return data; + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let fullText = ""; + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const parts = buffer.split("\n\n"); + buffer = parts.pop() ?? ""; + + for (const part of parts) { + if (!part.startsWith("data: ")) continue; + const raw = part.slice(6).trim(); + + if (raw === "[DONE]") return { roast: fullText }; + + let parsed: { chunk?: string; error?: string }; + try { + parsed = JSON.parse(raw) as { chunk?: string; error?: string }; + } catch { + continue; + } + + if (parsed.error) throw new Error(parsed.error); + + if (parsed.chunk) { + fullText += parsed.chunk; + onChunk?.(parsed.chunk); + } + } + } + + return { roast: fullText }; +} + +export async function deleteRoast(memberId: string, persona?: RoastPersona): Promise { + const url = persona ? `/api/roast/${memberId}?persona=${persona}` : `/api/roast/${memberId}`; + const response = await fetch(url, { method: "DELETE" }); + if (!response.ok) { + const data = (await response.json()) as { error?: string }; + throw new Error(data.error ?? "Erro ao apagar roast."); + } } diff --git a/test-rules.js b/test-rules.js new file mode 100644 index 0000000..71f3da5 --- /dev/null +++ b/test-rules.js @@ -0,0 +1,37 @@ +const { initializeTestEnvironment, assertFails, assertSucceeds } = require("@firebase/rules-unit-testing"); +const { doc, setDoc, serverTimestamp, getDoc } = require("firebase/firestore"); +const fs = require("fs"); + +async function runTests() { + const rules = fs.readFileSync("firestore.rules", "utf8"); + const testEnv = await initializeTestEnvironment({ + projectId: "match-tech-test-rules", + firestore: { rules }, + }); + + const alice = testEnv.authenticatedContext("alice", { email: "alice@example.com" }); + + // Test reading skills + console.log("Testing read skill..."); + await assertSucceeds(getDoc(doc(alice.firestore(), "skills", "react"))); + + // Test creating skill + console.log("Testing create skill..."); + await assertSucceeds( + setDoc(doc(alice.firestore(), "skills", "react"), { + id: "react", + name: "React", + normalizedName: "react", + category: "core_tech", + status: "pending", + usageCount: 1, + createdBy: "", + createdAt: serverTimestamp(), + }) + ); + + console.log("All tests passed!"); + await testEnv.cleanup(); +} + +runTests().catch(console.error); diff --git a/test-skill.ts b/test-skill.ts new file mode 100644 index 0000000..33e04a7 --- /dev/null +++ b/test-skill.ts @@ -0,0 +1,28 @@ +import "dotenv/config"; +import { doc, setDoc, serverTimestamp } from "firebase/firestore"; +import { db } from "./src/shared/lib/firebase/firebase.client"; + +async function test() { + const skill = { + id: "test skill", + name: "Test Skill", + normalizedName: "test skill", + category: "core_tech", + status: "pending", + usageCount: 1, + createdBy: "", + }; + + console.log("Trying to create skill..."); + try { + await setDoc(doc(db, "skills", skill.normalizedName), { + ...skill, + createdAt: serverTimestamp(), + }, { merge: false }); + console.log("Success!"); + } catch (err) { + console.error("Failed:", err); + } +} + +test();