From 66fa4e1f3cfb4d63cdfd971a8c1800e7de95818e Mon Sep 17 00:00:00 2001 From: Jeremias Santos Date: Mon, 27 Jul 2026 18:45:21 -0300 Subject: [PATCH 01/18] chore(email): adiciona deps de e-mail (bullmq, resend, react 19) e habilita JSX no backend Co-Authored-By: Claude Opus 4.8 --- backend/package.json | 7 + backend/tsconfig.json | 1 + package-lock.json | 606 +++++++++++++++++++++++++++++++++++++----- 3 files changed, 545 insertions(+), 69 deletions(-) diff --git a/backend/package.json b/backend/package.json index 199dfe6..1556c60 100644 --- a/backend/package.json +++ b/backend/package.json @@ -35,17 +35,23 @@ "author": "Projeto Open Source - Bene Tesla Dev", "license": "ISC", "dependencies": { + "@react-email/render": "^2.1.0", "argon2": "^0.44.0", "axios": "^1.17.0", + "bullmq": "^5.81.2", "cheerio": "^1.2.0", "cors": "^2.8.6", "dotenv": "^17.4.2", "drizzle-orm": "^0.45.2", "express": "^5.2.1", + "ioredis": "^5.11.1", "iron-session": "^8.0.4", "pdfkit": "^0.18.0", "prom-client": "^15.1.3", + "react": "^19.2.8", + "react-dom": "^19.2.8", "redis": "^5.12.1", + "resend": "^6.18.0", "swagger-jsdoc": "^6.3.0", "swagger-ui-express": "^5.0.1", "xlsx": "^0.18.5", @@ -57,6 +63,7 @@ "devDependencies": { "@types/node": "^25.9.1", "@types/pg": "^8.20.0", + "@types/react": "^19.2.17", "@types/supertest": "^7.2.0", "@vitest/coverage-v8": "^4.1.8", "drizzle-kit": "^0.31.10", diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 368c555..84e28c4 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -6,6 +6,7 @@ "outDir": "./dist", "rootDir": "./src", "strict": true, + "jsx": "react-jsx", "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true diff --git a/package-lock.json b/package-lock.json index 02c8f26..5272d6f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,17 +54,23 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@react-email/render": "^2.1.0", "argon2": "^0.44.0", "axios": "^1.17.0", + "bullmq": "^5.81.2", "cheerio": "^1.2.0", "cors": "^2.8.6", "dotenv": "^17.4.2", "drizzle-orm": "^0.45.2", "express": "^5.2.1", + "ioredis": "^5.11.1", "iron-session": "^8.0.4", "pdfkit": "^0.18.0", "prom-client": "^15.1.3", + "react": "^19.2.8", + "react-dom": "^19.2.8", "redis": "^5.12.1", + "resend": "^6.18.0", "swagger-jsdoc": "^6.3.0", "swagger-ui-express": "^5.0.1", "xlsx": "^0.18.5", @@ -73,6 +79,7 @@ "devDependencies": { "@types/node": "^25.9.1", "@types/pg": "^8.20.0", + "@types/react": "^19.2.17", "@types/supertest": "^7.2.0", "@vitest/coverage-v8": "^4.1.8", "drizzle-kit": "^0.31.10", @@ -497,6 +504,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -577,6 +585,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -1400,6 +1409,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -1423,6 +1433,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -1642,6 +1653,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -1958,7 +1970,6 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, - "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -1980,7 +1991,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -1990,31 +2000,10 @@ "node": ">=14.14" } }, - "node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", "license": "MIT", "optional": true, "dependencies": { @@ -2051,6 +2040,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2220,6 +2210,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2457,6 +2448,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2756,6 +2748,12 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@ioredis/commands": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz", + "integrity": "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==", + "license": "MIT" + }, "node_modules/@isaacs/cliui": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", @@ -2965,6 +2963,84 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", @@ -4646,6 +4722,26 @@ "integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==", "license": "MIT" }, + "node_modules/@react-email/render": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@react-email/render/-/render-2.1.0.tgz", + "integrity": "sha512-F+zE3O6d6sW6Aj2UjvZAA17R7tJKM7kcq2mgV6k4HCT8jeLLFaVP2txMtH1lgqYFRMZ0Gxsd37q2PRyiXLXXxA==", + "license": "MIT", + "peer": true, + "dependencies": { + "entities": "^4.5.0", + "html-to-text": "^9.0.5", + "html5parser": "^3.0.0", + "prettier": "^3.5.3" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, "node_modules/@redis/bloom": { "version": "5.12.1", "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.12.1.tgz", @@ -4663,6 +4759,7 @@ "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.12.1.tgz", "integrity": "sha512-7aPGWeqA3uFm43o19umzdl16CEjK/JQGtSXVPevplTaOU3VJA/rseBC1QvYUz9lLDIMBimc4SW/zrW4S89BaCA==", "license": "MIT", + "peer": true, "dependencies": { "cluster-key-slot": "1.1.2" }, @@ -4928,6 +5025,37 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@rolldown/binding-win32-arm64-msvc": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", @@ -4979,6 +5107,19 @@ "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", "license": "MIT" }, + "node_modules/@selderee/plugin-htmlparser2": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz", + "integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "selderee": "^0.11.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/@simple-libs/child-process-utils": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@simple-libs/child-process-utils/-/child-process-utils-2.0.0.tgz", @@ -5033,6 +5174,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -5481,8 +5628,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/body-parser": { "version": "1.19.6", @@ -5659,6 +5805,7 @@ "integrity": "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } @@ -5723,6 +5870,7 @@ "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -5733,6 +5881,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -5861,6 +6010,7 @@ "integrity": "sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.63.0", "@typescript-eslint/types": "8.63.0", @@ -6327,6 +6477,7 @@ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -7183,6 +7334,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.42", "caniuse-lite": "^1.0.30001800", @@ -7258,6 +7410,31 @@ "node": ">= 14" } }, + "node_modules/bullmq": { + "version": "5.81.2", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.81.2.tgz", + "integrity": "sha512-Hi9GaVCC6HE9bQP65j/FNv1aL1fcEukTF99ezS5pl1Ud+joCFpNWiPCV45mWdkEmpuacS0XJdMMFGKPIHCwoPg==", + "license": "MIT", + "dependencies": { + "cron-parser": "4.9.0", + "ioredis": "5.11.1", + "msgpackr": "2.0.4", + "node-abort-controller": "3.1.1", + "semver": "7.8.5", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=12.22.0" + }, + "peerDependencies": { + "redis": ">=5.0.0" + }, + "peerDependenciesMeta": { + "redis": { + "optional": true + } + } + }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", @@ -8208,6 +8385,7 @@ "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz", "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", "license": "MIT", + "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -8275,14 +8453,25 @@ "node": ">=0.8" } }, + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "license": "MIT", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/cross-dirname": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/cross-env": { "version": "10.1.0", @@ -8613,6 +8802,15 @@ "node": ">=0.4.0" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -8724,6 +8922,7 @@ "integrity": "sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "26.15.3", "builder-util": "26.15.3", @@ -8748,8 +8947,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dom-serializer": { "version": "2.0.0", @@ -9384,7 +9582,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -9405,7 +9602,6 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -9421,7 +9617,6 @@ "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", "dev": true, "license": "MIT", - "peer": true, "optionalDependencies": { "graceful-fs": "^4.1.6" } @@ -9432,7 +9627,6 @@ "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 4.0.0" } @@ -9707,6 +9901,7 @@ "devOptional": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -10102,6 +10297,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10118,6 +10314,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10134,6 +10331,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10150,6 +10348,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10166,6 +10365,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10182,6 +10382,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10198,6 +10399,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10214,6 +10416,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10230,6 +10433,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10246,6 +10450,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10262,6 +10467,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10278,6 +10484,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10294,6 +10501,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10310,6 +10518,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10326,6 +10535,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10342,6 +10552,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10358,6 +10569,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10374,6 +10586,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10390,6 +10603,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10406,6 +10620,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10422,6 +10637,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10438,6 +10654,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10454,6 +10671,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10497,6 +10715,7 @@ "integrity": "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==", "dev": true, "license": "MIT", + "peer": true, "workspaces": [ "packages/*" ], @@ -10972,6 +11191,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -11093,6 +11313,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fast-uri": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", @@ -11871,6 +12097,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.28.tgz", "integrity": "sha512-YwUvVpSF7m1yOblFPrU3Hbo8XhPheBoiyfGuII6z19LnOr6JpDnyyp7LFNrfV56wS8tpvtBFGRISHN02pDdLOA==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -11927,6 +12154,47 @@ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "license": "MIT" }, + "node_modules/html-to-text": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz", + "integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==", + "license": "MIT", + "dependencies": { + "@selderee/plugin-htmlparser2": "^0.11.0", + "deepmerge": "^4.3.1", + "dom-serializer": "^2.0.0", + "htmlparser2": "^8.0.2", + "selderee": "^0.11.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/html-to-text/node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, + "node_modules/html5parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html5parser/-/html5parser-3.0.0.tgz", + "integrity": "sha512-iNpSopa+4YHX50UOk825tBy7MghmXHo/ZpLskBYN0kAr1xhH8GlIMk5bLRXcZlfP3AnLUcSuFMu8C4MdOUxA8A==", + "license": "MIT" + }, "node_modules/htmlparser2": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", @@ -12185,6 +12453,37 @@ } } }, + "node_modules/ioredis": { + "version": "5.11.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz", + "integrity": "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.10.0", + "cluster-key-slot": "1.1.1", + "debug": "4.4.3", + "denque": "2.1.0", + "redis-errors": "1.2.0", + "redis-parser": "3.0.0", + "standard-as-callback": "2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/ioredis/node_modules/cluster-key-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz", + "integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ip-address": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", @@ -12552,6 +12851,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "devOptional": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -12775,6 +13075,15 @@ "dev": true, "license": "MIT" }, + "node_modules/leac": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz", + "integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==", + "license": "MIT", + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -13375,13 +13684,21 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -13651,7 +13968,6 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -13706,6 +14022,37 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/msgpackr": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.0.4.tgz", + "integrity": "sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.4" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" + } + }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -13772,6 +14119,12 @@ "node": ">=22.12.0" } }, + "node_modules/node-abort-controller": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", + "license": "MIT" + }, "node_modules/node-addon-api": { "version": "8.9.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz", @@ -13827,6 +14180,21 @@ "node-gyp-build-test": "build-test.js" } }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, "node_modules/node-gyp/node_modules/isexe": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", @@ -14438,6 +14806,19 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/parseley": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz", + "integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==", + "license": "MIT", + "dependencies": { + "leac": "^0.6.0", + "peberminta": "^0.9.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -14571,11 +14952,21 @@ "url": "https://github.com/sponsors/jet2jet" } }, + "node_modules/peberminta": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz", + "integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==", + "license": "MIT", + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/pg": { "version": "8.22.0", "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.14.0", "pg-pool": "^3.14.0", @@ -14871,6 +15262,12 @@ "browserify-zlib": "^0.2.0" } }, + "node_modules/postal-mime": { + "version": "2.7.5", + "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.5.tgz", + "integrity": "sha512-GNEXKvWFQnbgO5NlrGzVa0FmWzBZ24PersAWErttSg1Hjpf0ATxTwS5DOMGaOpTG6bUh5cTr7xi0jAD942wCJA==", + "license": "MIT-0" + }, "node_modules/postcss": { "version": "8.5.16", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", @@ -14890,6 +15287,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", @@ -15079,7 +15477,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -15112,13 +15509,27 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -15134,7 +15545,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -15525,24 +15935,26 @@ } }, "node_modules/react": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", - "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", - "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.7" + "react": "^19.2.8" } }, "node_modules/react-icons": { @@ -15559,8 +15971,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-phone-number-input": { "version": "3.4.17", @@ -15795,6 +16206,7 @@ "resolved": "https://registry.npmjs.org/redis/-/redis-5.12.1.tgz", "integrity": "sha512-LDsoVvb/CpoV9EN3FXvgvSHNJWuCIzl9MiO3ppOevuGLpSGJhwfQjpEwfFJcQvNSddHADDdZaWx0HnmMxRXG7g==", "license": "MIT", + "peer": true, "dependencies": { "@redis/bloom": "5.12.1", "@redis/client": "5.12.1", @@ -15806,6 +16218,27 @@ "node": ">= 18.19.0" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -15843,6 +16276,27 @@ "url": "https://github.com/sponsors/jet2jet" } }, + "node_modules/resend": { + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.18.0.tgz", + "integrity": "sha512-EjxZ9AVzywJgOlUoIJe9ytBWVrfbUtJbjeoLnRSvpU1sv97Hh9DSwhw+k8kiujrG4Rg4bzTBsjlmwWWuoOxSug==", + "license": "MIT", + "dependencies": { + "postal-mime": "2.7.5", + "standardwebhooks": "1.0.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@react-email/render": "*" + }, + "peerDependenciesMeta": { + "@react-email/render": { + "optional": true + } + } + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -15963,7 +16417,6 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -15978,7 +16431,6 @@ "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", - "peer": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -16181,6 +16633,18 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/selderee": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz", + "integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==", + "license": "MIT", + "dependencies": { + "parseley": "^0.12.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -16606,6 +17070,22 @@ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", "license": "MIT" }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/stat-mode": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", @@ -17135,7 +17615,6 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -17295,6 +17774,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -17484,6 +17964,7 @@ "integrity": "sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.28.0" }, @@ -17577,6 +18058,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -17848,6 +18330,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", @@ -18320,22 +18803,6 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "license": "ISC" }, - "node_modules/yaml": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", - "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", - "license": "ISC", - "optional": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/yargs": { "version": "18.0.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", @@ -18427,6 +18894,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } From f908b09cd1437034a483ad7795cf26639bd43c7f Mon Sep 17 00:00:00 2001 From: Jeremias Santos Date: Mon, 27 Jul 2026 18:45:24 -0300 Subject: [PATCH 02/18] =?UTF-8?q?feat(config):=20adiciona=20configura?= =?UTF-8?q?=C3=A7=C3=A3o=20do=20m=C3=B3dulo=20de=20e-mail=20e=20exp=C3=B5e?= =?UTF-8?q?=20frontendUrl?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .env.example | 8 ++++++++ backend/src/config.ts | 10 ++++++++++ 2 files changed, 18 insertions(+) diff --git a/.env.example b/.env.example index f337aad..c7cf023 100644 --- a/.env.example +++ b/.env.example @@ -57,6 +57,14 @@ HEADLESS=false VIEWPORT_WIDTH=1280 VIEWPORT_HEIGHT=800 +# E-mail (módulo de e-mail transacional) +# EMAIL_API_KEY vazio => provider no-op (apenas loga, não envia). Preencha com a chave da Resend para enviar de verdade. +EMAIL_API_KEY= +EMAIL_FROM_ADDRESS= +EMAIL_FROM_NAME= +# Tentativas de reenvio na fila de e-mail (backoff exponencial). Default: 3. +EMAIL_QUEUE_ATTEMPTS=3 + # Third-party API credentials ADZUNA_APP_ID= ADZUNA_APP_KEY= diff --git a/backend/src/config.ts b/backend/src/config.ts index fc98ab7..7856822 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -17,6 +17,11 @@ export interface AppConfig { timeFilter: string; databaseUrl: string; valkeyUrl: string; + frontendUrl: string; + emailApiKey: string; + emailFromAddress: string; + emailFromName: string; + emailQueueAttempts: number; } function parseBoolean(value: string | undefined, fallback: boolean): boolean { @@ -65,5 +70,10 @@ export function getConfig(): AppConfig { timeFilter: parseTimeFilter(process.env.TIME_FILTER, "r604800"), databaseUrl: process.env.DATABASE_URL?.trim() ?? "", valkeyUrl: process.env.VALKEY_URL?.trim() ?? "", + frontendUrl: process.env.FRONTEND_URL?.trim() ?? "", + emailApiKey: process.env.EMAIL_API_KEY?.trim() ?? "", + emailFromAddress: process.env.EMAIL_FROM_ADDRESS?.trim() ?? "", + emailFromName: process.env.EMAIL_FROM_NAME?.trim() ?? "", + emailQueueAttempts: parseNumber(process.env.EMAIL_QUEUE_ATTEMPTS, 3), }; } From b329cf6c79450c1f7165a4ef54cf84966d8264aa Mon Sep 17 00:00:00 2001 From: Jeremias Santos Date: Mon, 27 Jul 2026 18:45:25 -0300 Subject: [PATCH 03/18] feat(email): adiciona interface MailProvider, NoopProvider e factory getMailProvider Co-Authored-By: Claude Opus 4.8 --- .../modules/email/providers/mail-provider.ts | 31 ++++++++++ .../modules/email/providers/noop.provider.ts | 16 +++++ .../unit/modules/email/mail-provider.test.ts | 60 +++++++++++++++++++ .../unit/modules/email/noop.provider.test.ts | 35 +++++++++++ 4 files changed, 142 insertions(+) create mode 100644 backend/src/modules/email/providers/mail-provider.ts create mode 100644 backend/src/modules/email/providers/noop.provider.ts create mode 100644 backend/tests/unit/modules/email/mail-provider.test.ts create mode 100644 backend/tests/unit/modules/email/noop.provider.test.ts diff --git a/backend/src/modules/email/providers/mail-provider.ts b/backend/src/modules/email/providers/mail-provider.ts new file mode 100644 index 0000000..d3a4be0 --- /dev/null +++ b/backend/src/modules/email/providers/mail-provider.ts @@ -0,0 +1,31 @@ +import { getConfig } from "../../../config"; +import { NoopProvider } from "./noop.provider"; +import { ResendProvider } from "./resend.provider"; + +/** + * Mensagem já renderizada, pronta para despacho pelo provedor. + */ +export interface MailMessage { + to: string; + subject: string; + html: string; + replyTo?: string; +} + +/** + * Contrato de envio. Desacopla o provedor concreto (Resend, no-op, ...) + * dos callers e do worker. + */ +export interface MailProvider { + send(msg: MailMessage): Promise; +} + +/** + * Seleciona o provedor com base na configuração: + * - `EMAIL_API_KEY` presente => ResendProvider (envia de verdade). + * - `EMAIL_API_KEY` vazio => NoopProvider (apenas loga; EMAIL-09). + */ +export function getMailProvider(): MailProvider { + const { emailApiKey } = getConfig(); + return emailApiKey ? new ResendProvider() : new NoopProvider(); +} diff --git a/backend/src/modules/email/providers/noop.provider.ts b/backend/src/modules/email/providers/noop.provider.ts new file mode 100644 index 0000000..7846837 --- /dev/null +++ b/backend/src/modules/email/providers/noop.provider.ts @@ -0,0 +1,16 @@ +import { logWarn } from "../../../logger"; +import { MailMessage, MailProvider } from "./mail-provider"; + +/** + * Provedor de fallback usado quando nenhuma credencial de e-mail está + * configurada. Loga um aviso e trata o envio como no-op, sem lançar, + * para não derrubar o boot nem o fluxo de negócio (EMAIL-09). + */ +export class NoopProvider implements MailProvider { + async send(msg: MailMessage): Promise { + logWarn("E-mail desabilitado: nenhum provedor configurado.", { + to: msg.to, + subject: msg.subject, + }); + } +} diff --git a/backend/tests/unit/modules/email/mail-provider.test.ts b/backend/tests/unit/modules/email/mail-provider.test.ts new file mode 100644 index 0000000..1eb4b63 --- /dev/null +++ b/backend/tests/unit/modules/email/mail-provider.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const configMocks = vi.hoisted(() => ({ + getConfig: vi.fn(), +})); + +// Providers concretos mockados (definidos em vi.hoisted para poder ser +// referenciados dentro das factories de vi.mock, que são içadas ao topo). +const providerMocks = vi.hoisted(() => ({ + FakeResend: class FakeResend {}, + FakeNoop: class FakeNoop {}, +})); +const { FakeResend, FakeNoop } = providerMocks; + +vi.mock("../../../../src/config", () => ({ + getConfig: configMocks.getConfig, +})); + +vi.mock("../../../../src/modules/email/providers/resend.provider", () => ({ + ResendProvider: providerMocks.FakeResend, +})); + +vi.mock("../../../../src/modules/email/providers/noop.provider", () => ({ + NoopProvider: providerMocks.FakeNoop, +})); + +import { getMailProvider } from "../../../../src/modules/email/providers/mail-provider"; + +function baseConfig(overrides: Record = {}) { + return { + emailApiKey: "", + emailFromAddress: "no-reply@example.com", + emailFromName: "Painel Vagas", + ...overrides, + }; +} + +describe("getMailProvider", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("retorna NoopProvider quando emailApiKey está vazio (EMAIL-09)", () => { + configMocks.getConfig.mockReturnValue(baseConfig({ emailApiKey: "" })); + + const provider = getMailProvider(); + + expect(provider).toBeInstanceOf(FakeNoop); + }); + + it("retorna ResendProvider quando emailApiKey está presente (EMAIL-04)", () => { + configMocks.getConfig.mockReturnValue( + baseConfig({ emailApiKey: "re_abc123" }), + ); + + const provider = getMailProvider(); + + expect(provider).toBeInstanceOf(FakeResend); + }); +}); diff --git a/backend/tests/unit/modules/email/noop.provider.test.ts b/backend/tests/unit/modules/email/noop.provider.test.ts new file mode 100644 index 0000000..0b5b5ad --- /dev/null +++ b/backend/tests/unit/modules/email/noop.provider.test.ts @@ -0,0 +1,35 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const loggerMocks = vi.hoisted(() => ({ + logWarn: vi.fn(), +})); + +vi.mock("../../../../src/logger", () => ({ + logWarn: loggerMocks.logWarn, + logInfo: vi.fn(), + logError: vi.fn(), +})); + +import { NoopProvider } from "../../../../src/modules/email/providers/noop.provider"; + +describe("NoopProvider", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("loga aviso e resolve sem lançar (EMAIL-09)", async () => { + const provider = new NoopProvider(); + + await expect( + provider.send({ + to: "user@example.com", + subject: "Assunto", + html: "

oi

", + }), + ).resolves.toBeUndefined(); + + expect(loggerMocks.logWarn).toHaveBeenCalledOnce(); + const [, ctx] = loggerMocks.logWarn.mock.calls[0]; + expect(ctx).toMatchObject({ to: "user@example.com" }); + }); +}); From 3a6cdba65090dd7eaaf3f1b625bfa4bead89b54f Mon Sep 17 00:00:00 2001 From: Jeremias Santos Date: Mon, 27 Jul 2026 18:45:27 -0300 Subject: [PATCH 04/18] =?UTF-8?q?feat(email):=20implementa=20ResendProvide?= =?UTF-8?q?r=20com=20propaga=C3=A7=C3=A3o=20de=20erro=20do=20SDK?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../email/providers/resend.provider.ts | 33 ++++++++ .../modules/email/resend.provider.test.ts | 79 +++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 backend/src/modules/email/providers/resend.provider.ts create mode 100644 backend/tests/unit/modules/email/resend.provider.test.ts diff --git a/backend/src/modules/email/providers/resend.provider.ts b/backend/src/modules/email/providers/resend.provider.ts new file mode 100644 index 0000000..907a3e0 --- /dev/null +++ b/backend/src/modules/email/providers/resend.provider.ts @@ -0,0 +1,33 @@ +import { Resend } from "resend"; +import { getConfig } from "../../../config"; +import { logError } from "../../../logger"; +import { MailMessage, MailProvider } from "./mail-provider"; + +/** + * Provedor concreto baseado na Resend. Envia o e-mail já renderizado. + * Em caso de falha da API, lança para que o BullMQ re-tente (EMAIL-02/03). + */ +export class ResendProvider implements MailProvider { + async send(msg: MailMessage): Promise { + const { emailApiKey, emailFromAddress, emailFromName } = getConfig(); + const resend = new Resend(emailApiKey); + const from = `${emailFromName} <${emailFromAddress}>`; + + const response = await resend.emails.send({ + from, + to: msg.to, + subject: msg.subject, + html: msg.html, + replyTo: msg.replyTo, + }); + + if (response.error) { + logError("Falha ao enviar e-mail pela Resend.", { + to: msg.to, + subject: msg.subject, + error: response.error.message, + }); + throw new Error(response.error.message); + } + } +} diff --git a/backend/tests/unit/modules/email/resend.provider.test.ts b/backend/tests/unit/modules/email/resend.provider.test.ts new file mode 100644 index 0000000..35774f8 --- /dev/null +++ b/backend/tests/unit/modules/email/resend.provider.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const configMocks = vi.hoisted(() => ({ + getConfig: vi.fn(), +})); + +const resendMocks = vi.hoisted(() => ({ + send: vi.fn(), + constructor: vi.fn(), +})); + +vi.mock("../../../../src/config", () => ({ + getConfig: configMocks.getConfig, +})); + +vi.mock("resend", () => ({ + Resend: class { + emails = { send: resendMocks.send }; + constructor(apiKey: string) { + resendMocks.constructor(apiKey); + } + }, +})); + +vi.mock("../../../../src/logger", () => ({ + logError: vi.fn(), + logWarn: vi.fn(), + logInfo: vi.fn(), +})); + +import { ResendProvider } from "../../../../src/modules/email/providers/resend.provider"; + +describe("ResendProvider", () => { + beforeEach(() => { + vi.clearAllMocks(); + configMocks.getConfig.mockReturnValue({ + emailApiKey: "re_abc123", + emailFromAddress: "no-reply@painelvagas.com", + emailFromName: "Painel Vagas", + }); + }); + + it("chama o SDK com from formatado e campos mapeados (EMAIL-02/04)", async () => { + resendMocks.send.mockResolvedValue({ data: { id: "eml_1" }, error: null }); + const provider = new ResendProvider(); + + await provider.send({ + to: "user@example.com", + subject: "Bem-vindo", + html: "

Olá

", + replyTo: "suporte@painelvagas.com", + }); + + expect(resendMocks.constructor).toHaveBeenCalledWith("re_abc123"); + expect(resendMocks.send).toHaveBeenCalledWith({ + from: "Painel Vagas ", + to: "user@example.com", + subject: "Bem-vindo", + html: "

Olá

", + replyTo: "suporte@painelvagas.com", + }); + }); + + it("propaga (lança) quando o SDK retorna erro para permitir retry (EMAIL-03)", async () => { + resendMocks.send.mockResolvedValue({ + data: null, + error: { message: "rate limit", name: "rate_limit_exceeded" }, + }); + const provider = new ResendProvider(); + + await expect( + provider.send({ + to: "user@example.com", + subject: "Bem-vindo", + html: "

Olá

", + }), + ).rejects.toThrow("rate limit"); + }); +}); From c35e2f680d33f417a24b577967de2f5728bc5350 Mon Sep 17 00:00:00 2001 From: Jeremias Santos Date: Mon, 27 Jul 2026 18:45:29 -0300 Subject: [PATCH 05/18] feat(email): adiciona templates react-email (welcome) e TemplateRegistry Co-Authored-By: Claude Opus 4.8 --- .../modules/email/templates/BaseLayout.tsx | 52 ++++++++++++++++++ .../src/modules/email/templates/registry.ts | 53 +++++++++++++++++++ .../src/modules/email/templates/welcome.tsx | 40 ++++++++++++++ .../tests/unit/modules/email/registry.test.ts | 40 ++++++++++++++ backend/vitest.config.js | 13 +++++ 5 files changed, 198 insertions(+) create mode 100644 backend/src/modules/email/templates/BaseLayout.tsx create mode 100644 backend/src/modules/email/templates/registry.ts create mode 100644 backend/src/modules/email/templates/welcome.tsx create mode 100644 backend/tests/unit/modules/email/registry.test.ts diff --git a/backend/src/modules/email/templates/BaseLayout.tsx b/backend/src/modules/email/templates/BaseLayout.tsx new file mode 100644 index 0000000..4cbe996 --- /dev/null +++ b/backend/src/modules/email/templates/BaseLayout.tsx @@ -0,0 +1,52 @@ +import * as React from "react"; + +interface BaseLayoutProps { + previewText?: string; + children: React.ReactNode; +} + +/** + * Estrutura HTML base compartilhada pelos e-mails transacionais. + * Mantém estilos inline para compatibilidade com clientes de e-mail. + */ +export function BaseLayout({ children }: BaseLayoutProps) { + return ( + + + + + + + + + + + + +
+ {children} +
+ + + ); +} diff --git a/backend/src/modules/email/templates/registry.ts b/backend/src/modules/email/templates/registry.ts new file mode 100644 index 0000000..a850b23 --- /dev/null +++ b/backend/src/modules/email/templates/registry.ts @@ -0,0 +1,53 @@ +import { render } from "@react-email/render"; +import * as React from "react"; +import { AppError } from "../../../lib/errors"; +import { Welcome, WelcomeProps } from "./welcome"; + +/** + * Mapa de templates disponíveis. Cada entrada define o subject fixo e o + * componente react-email usado para gerar o HTML. + */ +const templates = { + welcome: { + subject: "Bem-vindo ao Painel Vagas", + component: Welcome, + }, +} satisfies Record< + string, + { subject: string; component: (props: never) => React.ReactElement } +>; + +export type TemplateName = keyof typeof templates; + +/** + * Dados aceitos por cada template (props do componente). + */ +export interface TemplateDataMap { + welcome: WelcomeProps; +} + +export function isTemplate(name: string): name is TemplateName { + return Object.prototype.hasOwnProperty.call(templates, name); +} + +/** + * Renderiza o template indicado em HTML. Lança `AppError.validation` quando o + * template não existe (EMAIL-05). O render do react-email é assíncrono. + */ +export async function renderTemplate( + name: T, + data: TemplateDataMap[T], +): Promise<{ subject: string; html: string }> { + if (!isTemplate(name)) { + throw AppError.validation(`Template de e-mail desconhecido: ${name}`); + } + + const entry = templates[name]; + const element = React.createElement( + entry.component as (props: TemplateDataMap[T]) => React.ReactElement, + data, + ); + const html = await render(element); + + return { subject: entry.subject, html }; +} diff --git a/backend/src/modules/email/templates/welcome.tsx b/backend/src/modules/email/templates/welcome.tsx new file mode 100644 index 0000000..198d58f --- /dev/null +++ b/backend/src/modules/email/templates/welcome.tsx @@ -0,0 +1,40 @@ +import * as React from "react"; +import { BaseLayout } from "./BaseLayout"; + +export interface WelcomeProps { + name: string; + appUrl: string; +} + +/** + * E-mail de boas-vindas enviado após o registro. Contém o nome do usuário + * e um botão "Acessar plataforma" apontando para o frontend (EMAIL-07). + */ +export function Welcome({ name, appUrl }: WelcomeProps) { + return ( + +

+ Bem-vindo, {name}! +

+

+ Sua conta no Painel Vagas foi criada com sucesso. Agora você pode + acompanhar vagas, salvar oportunidades e organizar sua busca por emprego. +

+ + Acessar plataforma + +
+ ); +} diff --git a/backend/tests/unit/modules/email/registry.test.ts b/backend/tests/unit/modules/email/registry.test.ts new file mode 100644 index 0000000..10a8e10 --- /dev/null +++ b/backend/tests/unit/modules/email/registry.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { + isTemplate, + renderTemplate, +} from "../../../../src/modules/email/templates/registry"; + +describe("TemplateRegistry", () => { + describe("isTemplate", () => { + it("reconhece 'welcome' como template válido", () => { + expect(isTemplate("welcome")).toBe(true); + }); + + it("rejeita nome desconhecido (EMAIL-05)", () => { + expect(isTemplate("desconhecido")).toBe(false); + }); + }); + + describe("renderTemplate", () => { + it("renderiza 'welcome' com nome, CTA apontando para appUrl e subject (EMAIL-07)", async () => { + const appUrl = "https://app.example.com"; + const { subject, html } = await renderTemplate("welcome", { + name: "Maria", + appUrl, + }); + + expect(subject).toBeTruthy(); + expect(typeof subject).toBe("string"); + expect(html).toContain("Maria"); + expect(html).toContain(`href="${appUrl}"`); + expect(html).toContain("Acessar plataforma"); + }); + + it("rejeita template desconhecido (EMAIL-05)", async () => { + await expect( + // @ts-expect-error nome inválido é rejeitado em runtime + renderTemplate("desconhecido", { name: "X", appUrl: "https://x" }), + ).rejects.toThrow(); + }); + }); +}); diff --git a/backend/vitest.config.js b/backend/vitest.config.js index 9730815..1091c47 100644 --- a/backend/vitest.config.js +++ b/backend/vitest.config.js @@ -22,6 +22,19 @@ export default defineConfig(({ mode }) => { const env = loadTestEnv(mode ?? "test"); return { + // Habilita o runtime automático de JSX (react-email templates .tsx). + // O backend usa rolldown-vite (transform via oxc); mantemos também a + // chave esbuild para compatibilidade caso o vite padrão seja usado. + esbuild: { + jsx: "automatic", + jsxDev: false, + }, + oxc: { + jsx: { + runtime: "automatic", + development: false, + }, + }, test: { globals: true, environment: "node", From c29443a4bbcb2027ab94125c1a2f712afd475a5d Mon Sep 17 00:00:00 2001 From: Jeremias Santos Date: Mon, 27 Jul 2026 18:59:44 -0300 Subject: [PATCH 06/18] =?UTF-8?q?feat(email):=20adiciona=20fila=20BullMQ?= =?UTF-8?q?=20'email'=20com=20conex=C3=A3o=20ioredis=20dedicada=20e=20enqu?= =?UTF-8?q?eue=20com=20retry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- backend/src/modules/email/email.queue.ts | 72 ++++++++++ .../unit/modules/email/email.queue.test.ts | 125 ++++++++++++++++++ 2 files changed, 197 insertions(+) create mode 100644 backend/src/modules/email/email.queue.ts create mode 100644 backend/tests/unit/modules/email/email.queue.test.ts diff --git a/backend/src/modules/email/email.queue.ts b/backend/src/modules/email/email.queue.ts new file mode 100644 index 0000000..cac43aa --- /dev/null +++ b/backend/src/modules/email/email.queue.ts @@ -0,0 +1,72 @@ +import { Queue } from "bullmq"; +import IORedis, { type Redis } from "ioredis"; +import { getConfig } from "../../config"; +import type { TemplateName } from "./templates/registry"; + +/** + * Nome da fila BullMQ e do job de envio de e-mail. + */ +export const EMAIL_QUEUE_NAME = "email"; +const EMAIL_JOB_NAME = "send-email"; + +/** + * Payload do job enfileirado. Efêmero — sem persistência em DB (só na fila). + */ +export interface EmailJobData { + template: TemplateName; + to: string; + data: Record; +} + +let _connection: Redis | null = null; +let _queue: Queue | null = null; + +/** + * Conexão ioredis dedicada ao BullMQ, separada do node-redis do cache. + * `maxRetriesPerRequest: null` é exigido pelo BullMQ. + */ +export function getEmailConnection(): Redis { + if (_connection) return _connection; + + const { valkeyUrl } = getConfig(); + _connection = new IORedis(valkeyUrl, { maxRetriesPerRequest: null }); + return _connection; +} + +/** + * Singleton lazy da fila `email` sobre Valkey (via ioredis). + */ +export function getEmailQueue(): Queue { + if (_queue) return _queue; + + _queue = new Queue(EMAIL_QUEUE_NAME, { + connection: getEmailConnection(), + }); + return _queue; +} + +/** + * Enfileira um job de e-mail com retry/backoff configuráveis (EMAIL-01/03). + */ +export async function enqueueEmail(data: EmailJobData): Promise { + const { emailQueueAttempts } = getConfig(); + + await getEmailQueue().add(EMAIL_JOB_NAME, data, { + attempts: emailQueueAttempts, + backoff: { type: "exponential", delay: 2000 }, + }); +} + +/** + * Fecha a fila e a conexão no graceful shutdown. + */ +export async function closeEmailQueue(): Promise { + if (_queue) { + await _queue.close(); + _queue = null; + } + if (_connection) { + await _connection.quit(); + _connection = null; + } +} diff --git a/backend/tests/unit/modules/email/email.queue.test.ts b/backend/tests/unit/modules/email/email.queue.test.ts new file mode 100644 index 0000000..7f969ab --- /dev/null +++ b/backend/tests/unit/modules/email/email.queue.test.ts @@ -0,0 +1,125 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const configMocks = vi.hoisted(() => ({ + getConfig: vi.fn(), +})); + +const bullmqMocks = vi.hoisted(() => ({ + add: vi.fn(), + close: vi.fn(), + QueueConstructor: vi.fn(), +})); + +const ioredisMocks = vi.hoisted(() => ({ + RedisConstructor: vi.fn(), + quit: vi.fn(), +})); + +vi.mock("../../../../src/config", () => ({ + getConfig: configMocks.getConfig, +})); + +vi.mock("../../../../src/logger", () => ({ + logError: vi.fn(), + logWarn: vi.fn(), + logInfo: vi.fn(), +})); + +vi.mock("bullmq", () => ({ + Queue: class { + add = bullmqMocks.add; + close = bullmqMocks.close; + constructor(name: string, opts: unknown) { + bullmqMocks.QueueConstructor(name, opts); + } + }, +})); + +vi.mock("ioredis", () => ({ + default: class { + quit = ioredisMocks.quit; + constructor(url: string, opts: unknown) { + ioredisMocks.RedisConstructor(url, opts); + } + }, +})); + +import { + closeEmailQueue, + enqueueEmail, + getEmailQueue, +} from "../../../../src/modules/email/email.queue"; + +describe("EmailQueue", () => { + beforeEach(() => { + vi.clearAllMocks(); + configMocks.getConfig.mockReturnValue({ + valkeyUrl: "redis://localhost:6379", + emailQueueAttempts: 3, + }); + }); + + afterEach(async () => { + await closeEmailQueue(); + }); + + it("cria a conexão ioredis com maxRetriesPerRequest null (EMAIL-01)", () => { + getEmailQueue(); + + expect(ioredisMocks.RedisConstructor).toHaveBeenCalledWith( + "redis://localhost:6379", + { maxRetriesPerRequest: null }, + ); + }); + + it("cria a fila 'email' reutilizando a mesma instância (singleton)", () => { + const first = getEmailQueue(); + const second = getEmailQueue(); + + expect(first).toBe(second); + expect(bullmqMocks.QueueConstructor).toHaveBeenCalledTimes(1); + expect(bullmqMocks.QueueConstructor).toHaveBeenCalledWith( + "email", + expect.objectContaining({ connection: expect.anything() }), + ); + }); + + it("enfileira job com attempts do config e backoff exponencial (EMAIL-03)", async () => { + const data = { template: "welcome", to: "user@example.com", data: {} }; + + await enqueueEmail(data); + + expect(bullmqMocks.add).toHaveBeenCalledWith( + "send-email", + data, + expect.objectContaining({ + attempts: 3, + backoff: { type: "exponential", delay: 2000 }, + }), + ); + }); + + it("usa o valor de emailQueueAttempts do config para attempts", async () => { + configMocks.getConfig.mockReturnValue({ + valkeyUrl: "redis://localhost:6379", + emailQueueAttempts: 5, + }); + + await enqueueEmail({ template: "welcome", to: "a@b.com", data: {} }); + + expect(bullmqMocks.add).toHaveBeenCalledWith( + "send-email", + expect.anything(), + expect.objectContaining({ attempts: 5 }), + ); + }); + + it("fecha a fila e a conexão no closeEmailQueue", async () => { + getEmailQueue(); + + await closeEmailQueue(); + + expect(bullmqMocks.close).toHaveBeenCalled(); + expect(ioredisMocks.quit).toHaveBeenCalled(); + }); +}); From 18953f2ed31ffae745a8b7a8176e567a8a71d2ef Mon Sep 17 00:00:00 2001 From: Jeremias Santos Date: Mon, 27 Jul 2026 18:59:47 -0300 Subject: [PATCH 07/18] feat(email): adiciona worker in-process que renderiza template e despacha via provider Co-Authored-By: Claude Opus 4.8 --- backend/src/modules/email/email.worker.ts | 59 +++++++++ .../unit/modules/email/email.worker.test.ts | 125 ++++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 backend/src/modules/email/email.worker.ts create mode 100644 backend/tests/unit/modules/email/email.worker.test.ts diff --git a/backend/src/modules/email/email.worker.ts b/backend/src/modules/email/email.worker.ts new file mode 100644 index 0000000..125884e --- /dev/null +++ b/backend/src/modules/email/email.worker.ts @@ -0,0 +1,59 @@ +import { type Job, Worker } from "bullmq"; +import { logError, logInfo } from "../../logger"; +import { + EMAIL_QUEUE_NAME, + type EmailJobData, + getEmailConnection, +} from "./email.queue"; +import { getMailProvider } from "./providers/mail-provider"; +import { renderTemplate } from "./templates/registry"; + +let _worker: Worker | null = null; + +/** + * Processa um job: renderiza o template e despacha via provider. + * Um erro do provider é propagado para que o BullMQ re-tente (EMAIL-02/03). + */ +async function processEmailJob(job: Job): Promise { + const { template, to, data } = job.data; + + const { subject, html } = await renderTemplate( + template, + data as never, + ); + + await getMailProvider().send({ to, subject, html }); +} + +/** + * Cria o worker in-process que consome a fila `email`. Chamado no boot. + */ +export function startEmailWorker(): Worker { + if (_worker) return _worker; + + _worker = new Worker(EMAIL_QUEUE_NAME, processEmailJob, { + connection: getEmailConnection(), + }); + + // Fracasso final após esgotar os retries: apenas loga (EMAIL-03). + _worker.on("failed", (job, err) => { + logError("Falha no envio de e-mail após retries.", { + jobId: job?.id, + to: job?.data?.to, + error: err instanceof Error ? err.message : String(err), + }); + }); + + logInfo("Email worker iniciado."); + return _worker; +} + +/** + * Fecha o worker no graceful shutdown. + */ +export async function stopEmailWorker(): Promise { + if (_worker) { + await _worker.close(); + _worker = null; + } +} diff --git a/backend/tests/unit/modules/email/email.worker.test.ts b/backend/tests/unit/modules/email/email.worker.test.ts new file mode 100644 index 0000000..35e6dc5 --- /dev/null +++ b/backend/tests/unit/modules/email/email.worker.test.ts @@ -0,0 +1,125 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const registryMocks = vi.hoisted(() => ({ + renderTemplate: vi.fn(), +})); + +const providerMocks = vi.hoisted(() => ({ + send: vi.fn(), + getMailProvider: vi.fn(), +})); + +const queueMocks = vi.hoisted(() => ({ + getEmailConnection: vi.fn(() => ({})), +})); + +const loggerMocks = vi.hoisted(() => ({ + logError: vi.fn(), + logInfo: vi.fn(), + logWarn: vi.fn(), +})); + +// Captura o processor e os handlers registrados no Worker. +const workerState = vi.hoisted(() => ({ + processor: null as + | ((job: { data: unknown }) => Promise) + | null, + handlers: {} as Record void>, + close: vi.fn(), + on: vi.fn(), +})); + +vi.mock("bullmq", () => ({ + Worker: class { + close = workerState.close; + constructor( + _name: string, + processor: (job: { data: unknown }) => Promise, + ) { + workerState.processor = processor; + } + on(event: string, handler: (...args: unknown[]) => void) { + workerState.handlers[event] = handler; + workerState.on(event, handler); + return this; + } + }, +})); + +vi.mock("../../../../src/modules/email/email.queue", () => ({ + EMAIL_QUEUE_NAME: "email", + getEmailConnection: queueMocks.getEmailConnection, +})); + +vi.mock("../../../../src/modules/email/templates/registry", () => ({ + renderTemplate: registryMocks.renderTemplate, +})); + +vi.mock("../../../../src/modules/email/providers/mail-provider", () => ({ + getMailProvider: providerMocks.getMailProvider, +})); + +vi.mock("../../../../src/logger", () => loggerMocks); + +import { + startEmailWorker, + stopEmailWorker, +} from "../../../../src/modules/email/email.worker"; + +describe("EmailWorker", () => { + beforeEach(() => { + vi.clearAllMocks(); + workerState.processor = null; + workerState.handlers = {}; + providerMocks.getMailProvider.mockReturnValue({ send: providerMocks.send }); + registryMocks.renderTemplate.mockResolvedValue({ + subject: "Bem-vindo", + html: "

Olá

", + }); + providerMocks.send.mockResolvedValue(undefined); + }); + + afterEach(async () => { + await stopEmailWorker(); + }); + + it("renderiza o template e despacha via provider com to/subject/html (EMAIL-02)", async () => { + startEmailWorker(); + const job = { + data: { template: "welcome", to: "user@example.com", data: { name: "Ana" } }, + }; + + await workerState.processor!(job); + + expect(registryMocks.renderTemplate).toHaveBeenCalledWith("welcome", { + name: "Ana", + }); + expect(providerMocks.send).toHaveBeenCalledWith({ + to: "user@example.com", + subject: "Bem-vindo", + html: "

Olá

", + }); + }); + + it("propaga o erro do provider para permitir retry do BullMQ (EMAIL-03)", async () => { + providerMocks.send.mockRejectedValue(new Error("provider down")); + startEmailWorker(); + const job = { + data: { template: "welcome", to: "user@example.com", data: {} }, + }; + + await expect(workerState.processor!(job)).rejects.toThrow("provider down"); + }); + + it("registra o erro no handler 'failed' sem lançar (EMAIL-03)", () => { + startEmailWorker(); + const failedHandler = workerState.handlers["failed"]; + expect(failedHandler).toBeDefined(); + + const job = { id: "job-1", data: { to: "user@example.com" } }; + expect(() => + failedHandler(job, new Error("fracasso final")), + ).not.toThrow(); + expect(loggerMocks.logError).toHaveBeenCalled(); + }); +}); From 7c86bcf857effcf1ca323d14ea2352953c739cc9 Mon Sep 17 00:00:00 2001 From: Jeremias Santos Date: Mon, 27 Jul 2026 18:59:48 -0300 Subject: [PATCH 08/18] =?UTF-8?q?feat(email):=20adiciona=20EmailService=20?= =?UTF-8?q?com=20valida=C3=A7=C3=A3o,=20enqueue=20resiliente=20e=20sendWel?= =?UTF-8?q?come?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- backend/src/modules/email/email.service.ts | 65 ++++++++++ .../unit/modules/email/email.service.test.ts | 120 ++++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 backend/src/modules/email/email.service.ts create mode 100644 backend/tests/unit/modules/email/email.service.test.ts diff --git a/backend/src/modules/email/email.service.ts b/backend/src/modules/email/email.service.ts new file mode 100644 index 0000000..3f3e302 --- /dev/null +++ b/backend/src/modules/email/email.service.ts @@ -0,0 +1,65 @@ +import { getConfig } from "../../config"; +import { AppError } from "../../lib/errors"; +import { logError } from "../../logger"; +import { enqueueEmail } from "./email.queue"; +import { isTemplate, type TemplateName } from "./templates/registry"; + +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export interface SendEmailInput { + template: TemplateName; + to: string; + data: Record; +} + +/** + * API interna única de envio de e-mail. Valida e enfileira; nunca aguarda a + * entrega. Erros de validação (bug do caller) lançam `AppError.validation`; + * falha de enqueue (ex.: Valkey down) é logada e engolida (EMAIL-05/10). + */ +export class EmailService { + async send({ template, to, data }: SendEmailInput): Promise { + if (!EMAIL_REGEX.test(to)) { + throw AppError.validation(`E-mail de destino inválido: ${to}`); + } + + if (!isTemplate(template)) { + throw AppError.validation(`Template de e-mail desconhecido: ${template}`); + } + + try { + await enqueueEmail({ template, to, data }); + } catch (error) { + logError("Falha ao enfileirar e-mail.", { + template, + to, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + /** + * Açúcar sobre `send` para o e-mail de boas-vindas. Injeta `appUrl` a partir + * de `FRONTEND_URL` (config.frontendUrl) para o botão "Acessar plataforma". + */ + async sendWelcome({ + email, + name, + }: { + email: string; + name: string; + }): Promise { + const { frontendUrl } = getConfig(); + + await this.send({ + template: "welcome", + to: email, + data: { name, appUrl: frontendUrl }, + }); + } +} + +/** + * Instância compartilhada para uso pelos callers internos. + */ +export const emailService = new EmailService(); diff --git a/backend/tests/unit/modules/email/email.service.test.ts b/backend/tests/unit/modules/email/email.service.test.ts new file mode 100644 index 0000000..22703fb --- /dev/null +++ b/backend/tests/unit/modules/email/email.service.test.ts @@ -0,0 +1,120 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const queueMocks = vi.hoisted(() => ({ + enqueueEmail: vi.fn(), +})); + +const registryMocks = vi.hoisted(() => ({ + isTemplate: vi.fn(), +})); + +const configMocks = vi.hoisted(() => ({ + getConfig: vi.fn(), +})); + +const loggerMocks = vi.hoisted(() => ({ + logError: vi.fn(), + logInfo: vi.fn(), + logWarn: vi.fn(), +})); + +vi.mock("../../../../src/modules/email/email.queue", () => ({ + enqueueEmail: queueMocks.enqueueEmail, +})); + +vi.mock("../../../../src/modules/email/templates/registry", () => ({ + isTemplate: registryMocks.isTemplate, +})); + +vi.mock("../../../../src/config", () => ({ + getConfig: configMocks.getConfig, +})); + +vi.mock("../../../../src/logger", () => loggerMocks); + +import { EmailService } from "../../../../src/modules/email/email.service"; + +describe("EmailService", () => { + let service: EmailService; + + beforeEach(() => { + vi.clearAllMocks(); + registryMocks.isTemplate.mockReturnValue(true); + queueMocks.enqueueEmail.mockResolvedValue(undefined); + configMocks.getConfig.mockReturnValue({ + frontendUrl: "https://painelvagas.com", + }); + service = new EmailService(); + }); + + describe("send", () => { + it("enfileira job com { template, to, data } no caminho feliz (EMAIL-01)", async () => { + await service.send({ + template: "welcome", + to: "user@example.com", + data: { name: "Ana" }, + }); + + expect(queueMocks.enqueueEmail).toHaveBeenCalledWith({ + template: "welcome", + to: "user@example.com", + data: { name: "Ana" }, + }); + }); + + it("lança AppError.validation e NÃO enfileira quando 'to' é inválido (EMAIL-05)", async () => { + await expect( + service.send({ + template: "welcome", + to: "nao-e-email", + data: {}, + }), + ).rejects.toMatchObject({ code: "VALIDATION_ERROR" }); + + expect(queueMocks.enqueueEmail).not.toHaveBeenCalled(); + }); + + it("lança AppError.validation e NÃO enfileira quando template é desconhecido (EMAIL-05)", async () => { + registryMocks.isTemplate.mockReturnValue(false); + + await expect( + service.send({ + template: "inexistente" as never, + to: "user@example.com", + data: {}, + }), + ).rejects.toMatchObject({ code: "VALIDATION_ERROR" }); + + expect(queueMocks.enqueueEmail).not.toHaveBeenCalled(); + }); + + it("engole falha do enqueue: send resolve e loga o erro (EMAIL-10)", async () => { + queueMocks.enqueueEmail.mockRejectedValue(new Error("valkey down")); + + await expect( + service.send({ + template: "welcome", + to: "user@example.com", + data: {}, + }), + ).resolves.toBeUndefined(); + + expect(loggerMocks.logError).toHaveBeenCalled(); + }); + }); + + describe("sendWelcome", () => { + it("injeta appUrl do frontendUrl e usa template welcome (EMAIL-06/07)", async () => { + await service.sendWelcome({ + email: "user@example.com", + name: "Ana", + }); + + expect(queueMocks.enqueueEmail).toHaveBeenCalledWith({ + template: "welcome", + to: "user@example.com", + data: { name: "Ana", appUrl: "https://painelvagas.com" }, + }); + }); + }); +}); From 7c9e85505cc26cb07dd19131c4e22499bd2469df Mon Sep 17 00:00:00 2001 From: Jeremias Santos Date: Mon, 27 Jul 2026 18:59:50 -0300 Subject: [PATCH 09/18] feat(email): inicia worker no boot e fecha fila/worker no graceful shutdown Co-Authored-By: Claude Opus 4.8 --- backend/src/server.ts | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/backend/src/server.ts b/backend/src/server.ts index bd0b350..c8705be 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -1,6 +1,9 @@ import "dotenv/config"; import { createJobsApiApp } from "./app"; -import { logInfo, logWarn } from "./logger"; +import { closeCache } from "./lib/cache"; +import { logError, logInfo, logWarn } from "./logger"; +import { closeEmailQueue } from "./modules/email/email.queue"; +import { startEmailWorker, stopEmailWorker } from "./modules/email/email.worker"; const PORT = Number(process.env.PORT ?? 3001); @@ -22,10 +25,31 @@ async function registerSwaggerDocs(): Promise { async function startServer(): Promise { await registerSwaggerDocs(); + // Worker in-process de e-mail: sobe junto do servidor (EMAIL-02). + startEmailWorker(); + app.listen(PORT, () => { logInfo(`API rodando em http://localhost:${PORT}`); logInfo(`Documentação da API em http://localhost:${PORT}/docs`); }); } +async function shutdown(signal: string): Promise { + logInfo(`Recebido ${signal}, encerrando recursos...`); + try { + await stopEmailWorker(); + await closeEmailQueue(); + await closeCache(); + } catch (error) { + logError("Erro no graceful shutdown", { + error: error instanceof Error ? error.message : error, + }); + } finally { + process.exit(0); + } +} + +process.on("SIGTERM", () => void shutdown("SIGTERM")); +process.on("SIGINT", () => void shutdown("SIGINT")); + void startServer(); From d64d3629d2733b79485cb6c89a5ad1e6d90af5ad Mon Sep 17 00:00:00 2001 From: Jeremias Santos Date: Mon, 27 Jul 2026 18:59:51 -0300 Subject: [PATCH 10/18] feat(auth): dispara e-mail de boas-vindas no registro sem quebrar o fluxo Co-Authored-By: Claude Opus 4.8 --- .../src/modules/auth/credentials.service.ts | 15 ++++++++++ .../modules/auth/credentials.service.test.ts | 29 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/backend/src/modules/auth/credentials.service.ts b/backend/src/modules/auth/credentials.service.ts index a46a081..ef50c67 100644 --- a/backend/src/modules/auth/credentials.service.ts +++ b/backend/src/modules/auth/credentials.service.ts @@ -4,6 +4,8 @@ import { userPreferences } from "../../db/schema"; import { credentials } from "../../db/schema/credentials"; import type { User } from "../../db/schema/users"; import { AppError } from "../../lib/errors"; +import { logError } from "../../logger"; +import { emailService } from "../email/email.service"; import { encryptText } from "../../lib/security/encryption"; import { normalizeEmail } from "../../lib/security/normalization"; import { generateSearchableHash } from "../../lib/security/searchableHash"; @@ -81,6 +83,19 @@ export class CredentialsService { return createdUser; }); + // E-mail de boas-vindas: falha nunca derruba o registro (EMAIL-08). + try { + await emailService.sendWelcome({ + email: user.email, + name: user.displayName ?? user.username, + }); + } catch (error) { + logError("Falha ao disparar e-mail de boas-vindas.", { + userId: user.id, + error: error instanceof Error ? error.message : String(error), + }); + } + return { user, session: { userId: user.id, role: user.role } }; } diff --git a/backend/tests/unit/modules/auth/credentials.service.test.ts b/backend/tests/unit/modules/auth/credentials.service.test.ts index 988f0d2..b4147ef 100644 --- a/backend/tests/unit/modules/auth/credentials.service.test.ts +++ b/backend/tests/unit/modules/auth/credentials.service.test.ts @@ -15,6 +15,8 @@ const mocks = vi.hoisted(() => ({ verify: vi.fn(), // generateUsername generateUsername: vi.fn(), + // email de boas-vindas + sendWelcome: vi.fn(), })); // ─── Mock: database client ──────────────────────────────────────────────────── @@ -53,6 +55,14 @@ vi.mock("../../../../src/utils/generateUsername", () => ({ generateUsername: mocks.generateUsername, })); +// ─── Mock: EmailService (boas-vindas) ───────────────────────────────────────── + +vi.mock("../../../../src/modules/email/email.service", () => ({ + emailService: { + sendWelcome: mocks.sendWelcome, + }, +})); + // ─── Import after mocks ─────────────────────────────────────────────────────── import { CredentialsService } from "../../../../src/modules/auth/credentials.service"; @@ -119,6 +129,7 @@ describe("CredentialsService", () => { mocks.verify.mockResolvedValue(true); mocks.hash.mockResolvedValue("$argon2id$hashed"); mocks.generateUsername.mockResolvedValue("mocked-username"); + mocks.sendWelcome.mockResolvedValue(undefined); service = new CredentialsService(); }); @@ -183,6 +194,24 @@ describe("CredentialsService", () => { expect(session).toEqual({ userId: "uuid-user-1", role: "user" }); }); + + it("dispara e-mail de boas-vindas com email + nome no sucesso (EMAIL-06)", async () => { + await service.register(registerInput); + + expect(mocks.sendWelcome).toHaveBeenCalledWith({ + email: mockUser.email, + name: mockUser.displayName, + }); + }); + + it("conclui o registro mesmo se o envio de boas-vindas falhar (EMAIL-08)", async () => { + mocks.sendWelcome.mockRejectedValue(new Error("fila indisponível")); + + const result = await service.register(registerInput); + + expect(result.user).toMatchObject({ email: mockUser.email }); + expect(result.session).toEqual({ userId: mockUser.id, role: "user" }); + }); }); // ── login ───────────────────────────────────────────────────────────────── From 2ca37277b6a4a966d53b25a469b006f6b195c504 Mon Sep 17 00:00:00 2001 From: Jeremias Santos Date: Mon, 27 Jul 2026 18:59:53 -0300 Subject: [PATCH 11/18] =?UTF-8?q?docs(backend):=20documenta=20m=C3=B3dulo?= =?UTF-8?q?=20de=20e-mail,=20envs=20e=20extens=C3=A3o=20de=20templates/pro?= =?UTF-8?q?viders?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- BACKEND.md | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/BACKEND.md b/BACKEND.md index 9c7512c..b2eb227 100644 --- a/BACKEND.md +++ b/BACKEND.md @@ -84,6 +84,58 @@ Cache & Indexes: - `src/lib/cache.ts` — helpers para Redis/Valkey; usado por `jobs.routes` para obter ids e buscar vagas em memória. - Busca por palavras-chave usa índices invertidos e interseção para eficiência. +## Módulo de E-mail + +Módulo centralizado em `src/modules/email` para envio de e-mails transacionais de forma **assíncrona e resiliente**. Qualquer módulo do backend consome a mesma API interna (`emailService`), sem conhecer o provedor. + +**Fluxo:** `emailService.send()` valida e enfileira um job na fila BullMQ `email` (sobre Valkey, via `ioredis`) → um worker in-process (`startEmailWorker`, iniciado no boot do `server.ts`) renderiza o template react-email e despacha pelo `MailProvider` configurado. Falha do provedor aciona retry com backoff exponencial; no fracasso final apenas loga. Falha ao enfileirar (ex.: Valkey indisponível) é logada e **não** propaga para o fluxo de negócio chamador. + +**Arquivos:** + +- `email.service.ts` — API interna (`emailService.send` / `sendWelcome`). +- `email.queue.ts` — fila BullMQ + conexão `ioredis` dedicada (`getEmailQueue`, `enqueueEmail`, `closeEmailQueue`). +- `email.worker.ts` — worker in-process (`startEmailWorker`, `stopEmailWorker`). +- `providers/mail-provider.ts` — interface `MailProvider` + `getMailProvider()` (seleciona `ResendProvider` se `EMAIL_API_KEY` presente, senão `NoopProvider`). +- `providers/resend.provider.ts`, `providers/noop.provider.ts` — provedores concretos. +- `templates/registry.ts` + `templates/*.tsx` — templates react-email e lookup por nome. + +**Uso (API interna):** + +```ts +import { emailService } from "./modules/email/email.service"; + +// Envio genérico: valida `to` (formato) e `template` (existe no registry). +await emailService.send({ + template: "welcome", + to: "usuario@exemplo.com", + data: { name: "Ana", appUrl: "https://painelvagas.com" }, +}); + +// Açúcar para boas-vindas: injeta `appUrl` a partir de FRONTEND_URL. +await emailService.sendWelcome({ email: "usuario@exemplo.com", name: "Ana" }); +``` + +`send` resolve sem aguardar a entrega. `to` inválido ou `template` desconhecido lançam `AppError.validation` (antes de enfileirar). + +**Variáveis de ambiente:** + +- `EMAIL_API_KEY` — chave da Resend. Vazia ⇒ `NoopProvider` (apenas loga; não envia, não quebra o boot). +- `EMAIL_FROM_ADDRESS` — endereço remetente (ex.: `no-reply@painelvagas.com`). +- `EMAIL_FROM_NAME` — nome exibido do remetente (ex.: `Painel Vagas`). +- `EMAIL_QUEUE_ATTEMPTS` — nº de tentativas do job (padrão `3`). +- `FRONTEND_URL` — reusada para o botão "Acessar plataforma" do template de boas-vindas (nenhuma env de URL nova é criada). + +**Adicionar um novo template:** + +1. Crie o componente react-email em `src/modules/email/templates/.tsx` (props tipadas), reusando `BaseLayout`. +2. Registre-o no mapa `templates` de `registry.ts` (subject + component) e adicione as props em `TemplateDataMap`. O `TemplateName` e `isTemplate` passam a reconhecê-lo automaticamente. +3. Consuma via `emailService.send({ template: "", to, data })`. + +**Adicionar um novo provider:** + +1. Implemente a interface `MailProvider` (`send({ to, subject, html, replyTo? })`) em `src/modules/email/providers/.provider.ts`. Em falha, **lance** (para o BullMQ re-tentar). +2. Ajuste `getMailProvider()` em `mail-provider.ts` para selecioná-lo pela configuração. Nenhum caller precisa mudar (contrato via interface). + ## Middlewares - `withSession` — integra `iron-session` (sessões + cookie `vagas_session`). @@ -171,7 +223,12 @@ Definidas/consumidas em `src/config.ts` e outros módulos: - `JOB_TYPES` — filtros de tipo de vaga. - `TIME_FILTER` — filtro temporal (ex: `r604800`). - `DATABASE_URL` — conexão com Postgres. -- `VALKEY_URL` — endpoint do Valkey (se usado). +- `VALKEY_URL` — endpoint do Valkey (cache e fila de e-mail via BullMQ). +- `FRONTEND_URL` — URL do frontend; reusada no CTA do e-mail de boas-vindas. +- `EMAIL_API_KEY` — chave da Resend (vazio ⇒ envio no-op logado). +- `EMAIL_FROM_ADDRESS` — endereço remetente dos e-mails. +- `EMAIL_FROM_NAME` — nome exibido do remetente. +- `EMAIL_QUEUE_ATTEMPTS` — tentativas por job de e-mail (padrão 3). - `GO_SCRAPER_URL` — URL do serviço Go que realiza scraping. - `SESSION_SECRET` — senha para `iron-session` (obrigatória em produção). - `ENCRYPTION_MASTER_KEY`, `ENCRYPTION_KEY_ID`, `SEARCH_KEY` — criptografia e campos pesquisáveis de PII. From feba3053ba67445d8c37bcd919aa278eaa97ab6c Mon Sep 17 00:00:00 2001 From: Jeremias Santos Date: Mon, 27 Jul 2026 19:00:16 -0300 Subject: [PATCH 12/18] =?UTF-8?q?docs(specs):=20adiciona=20artefatos=20spe?= =?UTF-8?q?c-driven=20do=20m=C3=B3dulo=20de=20e-mail=20(PAV-76)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .specs/STATE.md | 20 + .specs/features/email-module/context.md | 17 + .specs/features/email-module/design.md | 191 +++++++++ .specs/features/email-module/spec.md | 119 ++++++ .specs/features/email-module/tasks.md | 426 +++++++++++++++++++++ .specs/features/email-module/validation.md | 123 ++++++ 6 files changed, 896 insertions(+) create mode 100644 .specs/STATE.md create mode 100644 .specs/features/email-module/context.md create mode 100644 .specs/features/email-module/design.md create mode 100644 .specs/features/email-module/spec.md create mode 100644 .specs/features/email-module/tasks.md create mode 100644 .specs/features/email-module/validation.md diff --git a/.specs/STATE.md b/.specs/STATE.md new file mode 100644 index 0000000..5c733b1 --- /dev/null +++ b/.specs/STATE.md @@ -0,0 +1,20 @@ +# Project State + +## Decisions + +Active project-level architectural decisions (AD-NNN). Each design must conform or explicitly supersede. + +| ID | Decision | Status | Source | +| --- | --- | --- | --- | +| AD-001 | E-mails transacionais são enviados via módulo centralizado `src/modules/email` — nenhum outro módulo chama provedor de e-mail diretamente. | active | PAV-76 | +| AD-002 | Envio de e-mail é assíncrono via fila BullMQ sobre Valkey (conexão ioredis dedicada). Callers apenas enfileiram; nunca bloqueiam nem falham por causa de e-mail. | active | PAV-76 | +| AD-003 | Provedor de e-mail acessado por trás da interface `MailProvider`. Implementação inicial: Resend. Trocar de provedor não altera código chamador. | active | PAV-76 | +| AD-004 | Templates de e-mail são componentes react-email tipados, renderizados server-side para HTML. Versões `react`/`react-dom`/`@types/react` fixadas em 19.x para casar com o frontend. | active | PAV-76 | + +## Handoff + +**Feature concluída:** `email-module` (PAV-76) — ✅ Done. +**Estado:** 11/11 tasks implementadas e commitadas na branch `feature/pav-76-modulo-email` (Batch A: 5 commits; Batch B: 6 commits). Verifier PASS (11/11 ACs, gate 529/0, sensor 5/5 mutantes mortos). Relatório em `.specs/features/email-module/validation.md`. +**Entregue:** API interna `emailService.send/sendWelcome`, fila BullMQ/Valkey (ioredis), worker in-process, `MailProvider`+Resend+Noop, template `welcome` react-email com CTA (`FRONTEND_URL`), boas-vindas no registro (`CredentialsService.register`), docs no `BACKEND.md`. +**Pendências deixadas ao usuário:** (1) push + PR ainda NÃO feitos (a pedido); (2) envs de produção `EMAIL_API_KEY`/`EMAIL_FROM_ADDRESS`/`EMAIL_FROM_NAME` a comunicar ao dev quando for pra prod — sem elas o `NoopProvider` só loga. +**Próximo passo:** quando o usuário pedir, abrir PR da PAV-76. diff --git a/.specs/features/email-module/context.md b/.specs/features/email-module/context.md new file mode 100644 index 0000000..9553570 --- /dev/null +++ b/.specs/features/email-module/context.md @@ -0,0 +1,17 @@ +# Email Module — User Decisions (context) + +Captured during the discuss phase of PAV-76. These constrain the design. + +| Área | Decisão do usuário | Detalhe | +| --- | --- | --- | +| Provedor | **Resend** como primeira implementação | Atrás de interface `MailProvider` para troca futura barata. | +| Estratégia de envio | **Fila de jobs (BullMQ)** | Usuário quer sistema de jobs. Projeto usava Redis; hoje usa Valkey. Verificado: BullMQ é 100% compatível com Valkey via ioredis. | +| Templates | **react-email** | ⚠️ Fixar versões de `react`/`react-dom`/`@types/react` em **19.x** para casar com o frontend (React 19.2.7) e não quebrar o build. | +| Persistência | **Somente logar** (sem tabela) | Sem `email_logs` no MVP. Auditoria em DB fica como evolução futura. | + +## Verificações técnicas (Knowledge Verification Chain) + +- **BullMQ + Valkey:** compatível em produção (Valkey = fork Redis 7.2.4). BullMQ usa adapter ioredis; backend hoje usa `node-redis` só p/ cache (`src/lib/cache.ts`) → BullMQ adiciona conexão ioredis dedicada ao mesmo `VALKEY_URL` com `maxRetriesPerRequest: null`. +- **react-email + React 19:** `render()` async funciona 100% com React 19; `@react-email/render` 2.1.0 suporta `react-dom ^19`. Bug conhecido com `@types/react@18` → fixar `@types/react@19`. Requer `jsx: react-jsx` no tsconfig do backend. +- **Frontend React:** `^19.2.7` (frontend/package.json). +- **Backend engine:** Node >= 22. diff --git a/.specs/features/email-module/design.md b/.specs/features/email-module/design.md new file mode 100644 index 0000000..991b905 --- /dev/null +++ b/.specs/features/email-module/design.md @@ -0,0 +1,191 @@ +# Email Module — Design + +**Spec**: `.specs/features/email-module/spec.md` +**Context**: `.specs/features/email-module/context.md` +**Status**: Draft — aguardando aprovação + +--- + +## Architecture Overview + +Módulo centralizado `src/modules/email` seguindo o padrão do backend (`Service` + arquivos coesos). Envio **assíncrono** via fila BullMQ sobre Valkey. Callers só enfileiram; um worker in-process renderiza o template (react-email) e despacha pelo `MailProvider` (Resend). Provider e templates são abstraídos e versionados. + +```mermaid +graph TD + Caller["Caller (AuthService.register, ...)"] -->|"send({template,to,data})"| Svc[EmailService] + Svc -->|valida to + template| Reg[TemplateRegistry] + Svc -->|enqueue| Q["BullMQ Queue 'email' (Valkey via ioredis)"] + Q --> W[EmailWorker in-process] + W -->|render| Reg + W -->|"send({to,subject,html})"| P{MailProvider} + P -->|env presente| Resend[ResendProvider -> Resend API] + P -->|env ausente| Noop[NoopProvider -> log only] + W -->|retry backoff / log final| Log[logger] +``` + +**Ciclo de vida:** a fila e o worker sobem em `server.ts` no boot e param no graceful shutdown (junto com `closeCache`). Enqueue é não-bloqueante; falha de enqueue é logada e engolida pelo caller. + +--- + +## Code Reuse Analysis + +### Existing Components to Leverage + +| Component | Location | How to Use | +| --- | --- | --- | +| Padrão de módulo (Service coeso) | `src/modules/notifications/*` | Espelhar estrutura e estilo. | +| `AppError` | `src/lib/errors.ts` | `AppError.validation` p/ `to`/template inválido no enqueue. | +| Logger | `src/logger.ts` | `logInfo/logWarn/logError` em enqueue, worker e providers. | +| Config pattern | `src/config.ts` | Adicionar envs de e-mail via `getConfig()` + parsers. | +| Valkey URL | `process.env.VALKEY_URL` (usado em `src/lib/cache.ts`) | Reusar a mesma instância Valkey p/ a conexão ioredis do BullMQ. | +| Registro de rotas / boot | `src/app.ts`, `src/server.ts` | Iniciar/parar worker no lifecycle do servidor. | +| Padrão de teste | `tests/unit/modules/*`, `tests/integration/*` | Vitest + mocks (hoisted), threshold 80%. | +| Fluxo de registro | `src/modules/auth/**` (`AuthService.register`) | Ponto de gatilho do e-mail de boas-vindas. | + +### Integration Points + +| System | Integration Method | +| --- | --- | +| Valkey | Nova conexão `ioredis` (BullMQ exige) ao mesmo `VALKEY_URL`, prefixo de chaves próprio (`bull:email`). Não interfere no `node-redis` do cache. | +| Auth (registro) | `AuthService.register` chama `emailService.sendWelcome(...)` dentro de try/catch que só loga. | +| Resend | HTTP via SDK `resend` usando `EMAIL_API_KEY`. | + +--- + +## Components + +### EmailService +- **Purpose**: API interna única de e-mail. Valida e enfileira jobs. +- **Location**: `src/modules/email/email.service.ts` +- **Interfaces**: + - `send(input: { template: TemplateName; to: string; data: TemplateData }): Promise` — valida `to` (formato) e `template` (existe no registry); enfileira job; nunca aguarda entrega. Erros de validação lançam `AppError.validation`; falha de enqueue é logada e **não** propagada. + - `sendWelcome(user: { email: string; name: string }): Promise` — açúcar sobre `send` com template `welcome`. +- **Dependencies**: `EmailQueue`, `TemplateRegistry`, logger. +- **Reuses**: `AppError`, logger. + +### EmailQueue +- **Purpose**: Encapsula a fila BullMQ e sua conexão Valkey. +- **Location**: `src/modules/email/email.queue.ts` +- **Interfaces**: + - `getEmailQueue(): Queue` — singleton lazy da `Queue`. + - `enqueueEmail(data: EmailJobData): Promise` — adiciona job com opções de retry/backoff. + - `closeEmailQueue(): Promise` — fecha conexão no shutdown. +- **Dependencies**: `bullmq`, `ioredis`, `VALKEY_URL`. +- **Reuses**: padrão singleton de `src/lib/cache.ts`. +- **Config da conexão**: `new IORedis(VALKEY_URL, { maxRetriesPerRequest: null })` (requisito do BullMQ). + +### EmailWorker +- **Purpose**: Processa jobs: renderiza template → despacha via provider. +- **Location**: `src/modules/email/email.worker.ts` +- **Interfaces**: + - `startEmailWorker(): Worker` — cria e retorna o `Worker` (chamado no boot). + - `stopEmailWorker(): Promise` — fecha o worker no shutdown. + - processor: `async (job) => { html = registry.render(job.data); await provider.send({ to, subject, html }); }` +- **Dependencies**: `TemplateRegistry`, `MailProvider` (via factory), logger. +- **Retry**: `attempts: 3`, `backoff: { type: 'exponential', delay: 2000 }` (config). Falha final logada em `worker.on('failed')`. + +### MailProvider (interface) + factory +- **Purpose**: Contrato de envio, desacoplando o provedor concreto. +- **Location**: `src/modules/email/providers/mail-provider.ts` (interface + `getMailProvider()` factory) +- **Interfaces**: + - `interface MailProvider { send(msg: { to: string; subject: string; html: string; replyTo?: string }): Promise }` + - `getMailProvider(): MailProvider` — retorna `ResendProvider` se `EMAIL_API_KEY` presente, senão `NoopProvider`. +- **Reuses**: config, logger. + +### ResendProvider +- **Location**: `src/modules/email/providers/resend.provider.ts` +- **Interfaces**: `send(...)` via SDK `resend` (`resend.emails.send({ from, to, subject, html, replyTo })`). Lança em falha (BullMQ faz retry). +- **Dependencies**: `resend`, config (`EMAIL_API_KEY`, `EMAIL_FROM_ADDRESS`, `EMAIL_FROM_NAME`). + +### NoopProvider +- **Location**: `src/modules/email/providers/noop.provider.ts` +- **Interfaces**: `send(...)` → `logWarn("Email disabled: no provider configured", {...})`. Não lança (EMAIL-09). + +### TemplateRegistry + templates +- **Purpose**: Mapear `TemplateName → { subject, render(data) }`, validando nomes e renderizando react-email para HTML. +- **Location**: `src/modules/email/templates/registry.ts`, `src/modules/email/templates/welcome.tsx`, `src/modules/email/templates/BaseLayout.tsx` +- **Interfaces**: + - `type TemplateName = 'welcome'` + - `isTemplate(name: string): name is TemplateName` + - `renderTemplate(name, data): Promise<{ subject: string; html: string }>` — usa `render()` (async) do `@react-email/render`. +- **Dependencies**: `react`, `react-dom`, `@react-email/render`. + +### Rotas / Boot +- **Location**: `src/server.ts` — chamar `startEmailWorker()` no boot e `stopEmailWorker()`/`closeEmailQueue()` no shutdown. +- Sem rota HTTP nova no MVP (contato fora de escopo). O módulo é consumido internamente. + +--- + +## Data Models + +### EmailJobData (payload do job BullMQ) +```typescript +interface EmailJobData { + template: TemplateName; // 'welcome' + to: string; // e-mail destino (validado no enqueue) + data: Record; // props do template (ex.: { name }) +} +``` +**Relationships**: efêmero (fila). Sem persistência em DB (decisão: só log). + +### Config additions (`src/config.ts`) +```typescript +interface AppConfig { + // ... + emailApiKey: string; // EMAIL_API_KEY (vazio => NoopProvider) + emailFromAddress: string; // EMAIL_FROM_ADDRESS + emailFromName: string; // EMAIL_FROM_NAME + emailQueueAttempts: number; // EMAIL_QUEUE_ATTEMPTS (default 3) +} +``` +`.env.example` ganha: `EMAIL_API_KEY=`, `EMAIL_FROM_ADDRESS=`, `EMAIL_FROM_NAME=`, `EMAIL_QUEUE_ATTEMPTS=3`. + +**URL do CTA:** o botão "Acessar plataforma" do template `welcome` reusa a env **`FRONTEND_URL`** (já existente em `.env.example` e usada em `auth.controller.ts`). Nenhuma env de URL nova é criada. O `welcome.tsx` recebe `appUrl` como prop, injetada pelo `EmailService.sendWelcome` a partir de `config.frontendUrl`. + +--- + +## Error Handling Strategy + +| Error Scenario | Handling | Impacto | +| --- | --- | --- | +| `to` inválido / template desconhecido no enqueue | `AppError.validation` lançado ao caller | Caller trata (é bug de programação interno). EMAIL-05. | +| Falha ao enfileirar (Valkey down) | log de erro, exceção engolida em `send` | Fluxo de negócio (registro) segue normal. EMAIL-10, EMAIL-13→10. | +| Falha do provider no worker | throw → BullMQ retry (3x, backoff exp.) | Transparente; e-mail eventualmente entregue ou logado no fracasso final. EMAIL-03. | +| Fracasso final após retries | `worker.on('failed')` → `logError` | Nenhum; e-mail perdido é logado (sem DB no MVP). EMAIL-03. | +| Env de e-mail ausente | `getMailProvider()` retorna `NoopProvider` (loga) | API sobe e opera; e-mails viram no-op logado. EMAIL-09. | + +--- + +## Risks & Concerns + +| Concern | Location | Impact | Mitigation | +| --- | --- | --- | --- | +| JSX/React no backend (hoje sem react/tsconfig JSX) | `backend/tsconfig*.json` | Build quebra sem `jsx: react-jsx` e sem deps React | Task dedicada: instalar `react@19`,`react-dom@19`,`@types/react@19` (casar frontend) + ativar JSX no tsconfig. Verificado como compatível. | +| Conflito de versões React 19 x `@types/react@18` | `backend/package.json` | Erros de tipo no `render()` | Fixar `@types/react@19` (bug conhecido documentado). | +| Nova conexão ioredis ao Valkey (além do node-redis) | `email.queue.ts` | Duas libs de client no mesmo processo | Aceitável: BullMQ exige ioredis; escopo isolado ao módulo, prefixo `bull:email`. Sem impacto no cache existente. | +| Worker in-process compete por CPU/event-loop com a API | `server.ts` | Em volume alto, render+envio pode pesar | Volume MVP é baixo (boas-vindas). Concurrency baixa no worker; extraível p/ processo separado depois sem mudar a fila (AD-002). | +| Perda de e-mail no fracasso final (sem DB) | worker | E-mail não entregue só vira log | Aceito no MVP (decisão do usuário). `email_logs` é evolução futura registrada em Out of Scope. | +| Testar código que toca Valkey/BullMQ real | testes | Testes lentos/flaky se conectarem de verdade | Mockar `email.queue` e `MailProvider`; testar `EmailService`/worker com fakes em memória (padrão hoisted já usado no repo). | + +> Concern flag: `AuthService.register` precisa de um try/catch ao redor do `sendWelcome` — hoje não existe; a task de integração adiciona sem alterar o contrato de retorno do registro. + +--- + +## Tech Decisions + +| Decisão | Escolha | Rationale | +| --- | --- | --- | +| Transporte | BullMQ sobre Valkey (ioredis) | Fila durável com retry/backoff nativo; base p/ futuros alertas de vagas. AD-002. | +| Provider | Resend atrás de `MailProvider` | Decisão do usuário; troca barata. AD-003. | +| Templates | react-email, React fixo em 19.x | Casa com frontend; evita quebra de build. AD-004. | +| Worker | In-process (boot em server.ts) | Simples p/ MVP; extraível depois. | +| Sem provider configurado | NoopProvider (no-op + log) | Dev local sobe sem credenciais. | +| Persistência | Nenhuma (só log) | Decisão do usuário; `email_logs` futuro. | + +> Decisões de nível de projeto já registradas em `.specs/STATE.md` como AD-001..AD-004. + +--- + +## Open decisions + +Nenhuma — todas confirmadas com o usuário. Pronto para a fase de Tasks. diff --git a/.specs/features/email-module/spec.md b/.specs/features/email-module/spec.md new file mode 100644 index 0000000..028042a --- /dev/null +++ b/.specs/features/email-module/spec.md @@ -0,0 +1,119 @@ +# Módulo de E-mail Centralizado — Specification + +**Task:** PAV-76 · **Team:** Painel Vagas + +## Problem Statement + +A plataforma passou a ter domínio próprio e precisa enviar e-mails transacionais (boas-vindas, contato/suporte e, futuramente, recuperação de senha, confirmação de e-mail, alertas de vagas). Hoje não existe nenhum mecanismo de envio. Sem um ponto único, cada funcionalidade implementaria seu próprio envio — duplicando código e dificultando manutenção e troca de provedor. + +## Goals + +- [ ] Expor uma **API interna única** de envio de e-mail, consumível por qualquer módulo do backend. +- [ ] Enviar de forma **assíncrona e resiliente** (fila com retry), sem que a falha de e-mail derrube o request que o originou. +- [ ] Separar **lógica de envio** (provider) de **templates** (react-email), ambos versionados no projeto. +- [ ] Entregar o fluxo funcional de **boas-vindas** (no registro) como primeiro consumidor real. +- [ ] Deixar o provedor **trocável** via configuração, sem alterar código chamador. +- [ ] Documentar o uso do módulo no repositório. + +## Out of Scope + +| Feature | Motivo | +| --- | --- | +| **Contato/Suporte** (endpoint + e-mail) | Removido do escopo pelo usuário: o link de "Ajuda & Suporte" foi retirado do frontend em outro PR e só voltará quando a seção for decidida. A arquitetura mantém a API interna genérica, então plugar esse fluxo depois é só um novo template + caller. | +| Recuperação de senha, confirmação de e-mail, troca de e-mail | Evolução futura (PAV-8, PAV-36); o módulo apenas prepara terreno. | +| Alertas de vagas / notificações em lote | Evolução futura; exige fan-out e agregação próprios. | +| Persistência de logs de envio em DB (`email_logs`) | Decisão do usuário: MVP só loga. Auditoria em DB fica para depois. | +| UI de administração de e-mails / dashboard | Não solicitado. | +| Preferências de opt-in/opt-out por usuário | `userPreferences.emailNotifications` já existe mas seu enforcement não é escopo desta task. | +| Segundo provedor (SES/SMTP) implementado | Só a interface é entregue; implementação concreta apenas Resend. | + +--- + +## Assumptions & Open Questions + +| Assumption / decisão | Escolha | Racional | Confirmado? | +| --- | --- | --- | --- | +| Provedor inicial | Resend | Decisão do usuário; API simples, integra com domínio próprio. | y | +| Transporte assíncrono | Fila BullMQ sobre Valkey | Decisão do usuário (quer jobs); BullMQ compatível com Valkey (verificado). | y | +| Templates | react-email, versões React fixadas em 19.x | Decisão do usuário; casa com frontend, evita quebra de build. | y | +| Persistência de envios | Somente log (sem tabela) | Decisão do usuário. | y | +| Deployment do worker BullMQ | **In-process** (inicia junto do servidor Express) | Volume baixo no MVP; simples. Extraível p/ processo separado depois sem mudar a fila. | y | +| Comportamento se env de e-mail ausente | Log de aviso + envio vira no-op (não derruba boot) | Ambiente local sem chave não deve quebrar a API. | y | +| Fluxo de contato/suporte | **Fora do escopo** | Seção removida do produto; volta em task futura. | y | + +**Open questions:** none — todas resolvidas ou registradas acima. + +--- + +## User Stories + +### P1: API interna de envio + entrega assíncrona ⭐ MVP + +**User Story**: Como desenvolvedor de qualquer módulo do backend, quero chamar uma única API interna (`emailService.send(...)`) para disparar um e-mail, para não implementar envio próprio. + +**Why P1**: É o núcleo do módulo — sem isso nada mais existe. Vertical slice: enfileira → worker renderiza template → provider envia. + +**Acceptance Criteria**: +1. WHEN um módulo chama `emailService.send({ template, to, data })` com dados válidos THEN o sistema SHALL enfileirar um job de e-mail e retornar sem aguardar a entrega. +2. WHEN um job de e-mail é processado pelo worker THEN o sistema SHALL renderizar o template HTML indicado e despachá-lo através do provedor configurado (Resend). +3. WHEN o despacho pelo provedor falha THEN o worker SHALL re-tentar com backoff até o limite configurado e, no fracasso final, registrar erro em log — sem lançar exceção para o caller original. +4. WHEN o provedor é trocado via configuração THEN nenhum código chamador SHALL precisar de alteração (contrato via interface `MailProvider`). +5. WHEN `to` é um e-mail inválido OU `template` é desconhecido THEN o sistema SHALL rejeitar no momento do enqueue com erro de validação, antes de criar o job. + +**Independent Test**: Chamar `emailService.send` com um provider fake em memória e asseverar que o job foi enfileirado, o template renderizou HTML esperado e o provider recebeu `{ to, subject, html }`; simular falha do provider e asseverar retry + log. + +--- + +### P1: E-mail de boas-vindas no registro ⭐ MVP + +**User Story**: Como novo usuário, quero receber um e-mail de boas-vindas ao me registrar, para confirmar que minha conta foi criada. + +**Why P1**: Critério de aceite explícito da task (template funcional de boas-vindas) e primeiro consumidor real da API interna. + +**Acceptance Criteria**: +1. WHEN um usuário completa o registro com sucesso THEN o sistema SHALL enfileirar um e-mail de boas-vindas para o endereço do usuário. +2. WHEN o e-mail de boas-vindas é renderizado THEN ele SHALL conter o nome do usuário e o HTML do template de boas-vindas. +3. WHEN o e-mail de boas-vindas é renderizado THEN ele SHALL incluir um botão "Acessar plataforma" cujo link aponta para `FRONTEND_URL` (env já existente, reusada). +4. WHEN o enfileiramento do e-mail de boas-vindas falha THEN o registro do usuário SHALL concluir normalmente mesmo assim (falha de e-mail é logada, não propagada). + +**Independent Test**: Executar `AuthService.register` com fila mockada e asseverar que um job "welcome" foi enfileirado com o `to`/nome corretos; forçar erro no enqueue e asseverar que o registro ainda retorna sucesso. + +--- + +## Edge Cases + +- WHEN as variáveis de ambiente de e-mail (ex: `EMAIL_API_KEY`) estão ausentes THEN o sistema SHALL logar aviso e tratar o envio como no-op, sem derrubar o boot da API. +- WHEN o Valkey está indisponível no momento do enqueue THEN o `emailService.send` SHALL logar erro e não propagar exceção que quebre o fluxo de negócio chamador. +- WHEN dois registros do mesmo usuário disparam boas-vindas THEN cada envio é um job independente (sem dedup no MVP — aceitável). +- WHEN o template referenciado não existe no registry de templates THEN o enqueue SHALL falhar com erro de validação (não gerar job órfão). + +--- + +## Requirement Traceability + +| Requirement ID | Story | Fase | Status | +| --- | --- | --- | --- | +| EMAIL-01 | P1 API interna — enqueue não-bloqueante | Design | Pending | +| EMAIL-02 | P1 API interna — worker renderiza + envia via provider | Design | Pending | +| EMAIL-03 | P1 API interna — retry com backoff + log no fracasso | Design | Pending | +| EMAIL-04 | P1 API interna — provider trocável via interface | Design | Pending | +| EMAIL-05 | P1 API interna — validação de `to`/`template` no enqueue | Design | Pending | +| EMAIL-06 | P1 Boas-vindas — enfileira no registro | Design | Pending | +| EMAIL-07 | P1 Boas-vindas — template com nome/HTML | Design | Pending | +| EMAIL-08 | P1 Boas-vindas — falha não quebra registro | Design | Pending | +| EMAIL-09 | Edge — env ausente vira no-op sem quebrar boot | Design | Pending | +| EMAIL-10 | Edge — Valkey down no enqueue não propaga | Design | Pending | +| EMAIL-11 | Doc — documentação de uso no repositório | Design | Pending | + +**ID format:** `EMAIL-NN` · **Status:** Pending → In Design → In Tasks → Implementing → Verified +**Coverage:** 11 total, 0 mapeados a tasks (Tasks ainda não iniciada). + +--- + +## Success Criteria + +- [ ] Um módulo consegue enviar e-mail com uma única chamada, sem conhecer o provedor. +- [ ] E-mail de boas-vindas chega em ambiente local e produção (template HTML funcional). +- [ ] Falha de provedor/fila nunca derruba o fluxo de negócio chamador. +- [ ] Trocar provider = implementar interface + mudar env, sem tocar callers. +- [ ] Documentação de uso adicionada ao repositório. diff --git a/.specs/features/email-module/tasks.md b/.specs/features/email-module/tasks.md new file mode 100644 index 0000000..3ffb8fc --- /dev/null +++ b/.specs/features/email-module/tasks.md @@ -0,0 +1,426 @@ +# Email Module — Tasks + +## Execution Protocol (MANDATORY — do not skip) + +Implement these tasks with the `tlc-spec-driven` skill: **activate it by name and follow its Execute flow and Critical Rules.** Do not search for skill files by filesystem path. + +**Política de commit (AGENTS.md sobrepõe o skill):** o repositório proíbe commits automáticos ("NUNCA faça commits automaticamente. Sempre pergunte"). Portanto, os workers **implementam + rodam o gate por task, mas NÃO commitam**. Cada task deixa a mudança pronta e verificada; o orchestrator apresenta os commits atômicos propostos (mensagens em **português**) para o usuário aprovar. Um commit por task continua sendo a unidade — só que aplicado após aprovação. + +**If the skill cannot be activated, STOP and tell the user.** + +--- + +**Design**: `.specs/features/email-module/design.md` +**Spec**: `.specs/features/email-module/spec.md` +**Status**: ✅ Done — 11/11 tasks commitadas; Verifier PASS (529 testes, sensor 5/5); ver `validation.md` + +--- + +## Test Coverage Matrix + +> Gerada de codebase + guidelines + spec — confirmar antes do Execute. Guidelines encontradas: `AGENTS.md` (commits/pt-BR), `backend/vitest.config.js` (threshold 80%, `include: src/**/*.ts`), `TESTING.md` (QA manual, não automatiza), padrão de testes em `backend/tests/unit/modules/*` e `backend/tests/integration/routes/*`. + +| Code Layer | Required Test Type | Coverage Expectation | Location Pattern | Run Command | +| --- | --- | --- | --- | --- | +| Service (`EmailService`) | unit | Todas as branches; 1:1 com ACs; cada edge case listado | `backend/tests/unit/modules/email/*.test.ts` | `cd backend && npm test` | +| Provider (`Resend`, `Noop`, factory) | unit | Caminho-chave de envio + erro + seleção por env | `backend/tests/unit/modules/email/*.test.ts` | `cd backend && npm test` | +| Queue (`email.queue`) | unit | enqueue com opções de retry; guarda de conexão (mock bullmq/ioredis) | `backend/tests/unit/modules/email/*.test.ts` | `cd backend && npm test` | +| Worker (`email.worker`) | unit | processor renderiza+envia; falha propaga p/ retry; handler `failed` loga | `backend/tests/unit/modules/email/*.test.ts` | `cd backend && npm test` | +| Template Registry (`registry.ts`) | unit | render de `welcome` (nome + CTA + subject); template desconhecido rejeitado | `backend/tests/unit/modules/email/*.test.ts` | `cd backend && npm test` | +| Templates (`*.tsx`) | none | Verificados indiretamente via Registry (coverage exclui `.tsx`) | `backend/src/modules/email/templates/*.tsx` | build gate | +| Auth integration (`AuthService.register`) | unit | boas-vindas enfileirado no sucesso; registro conclui mesmo se envio falhar | `backend/tests/unit/modules/auth/*.test.ts` (ou existente) | `cd backend && npm test` | +| Config (`config.ts`) + `.env.example` | none | Sem teste dedicado no repo (padrão config) — build gate | `backend/src/config.ts` | build gate | +| Boot wiring (`server.ts`) | none | Lifecycle wiring — build gate | `backend/src/server.ts` | build gate | +| Deps / tsconfig | none | Toolchain — build gate | `backend/package.json`, `backend/tsconfig.json` | build gate | +| Docs | none | Documentação — sem teste | `BACKEND.md` / `docs/` | — | + +## Gate Check Commands + +> Confirmar antes do Execute. Backend não possui script de build/lint/typecheck; `npm test` (vitest run) roda unit + integração no mesmo runner e é o verificador determinístico. + +| Gate Level | When to Use | Command | +| --- | --- | --- | +| Quick | Após tasks só com unit tests | `cd backend && npm test` | +| Full | Após tasks com integração | `cd backend && npm test` | +| Build | Fim de fase / tasks sem testes (config, deps, wiring, docs) | `cd backend && npm test` | + +> Alvo de cobertura: novos arquivos `src/**/*.ts` devem atingir ~80% localmente (threshold do `vitest.config.js`). `npm run test:coverage --workspace=backend` é o comando de conferência de cobertura, usado pelo Verifier — não como gate por-task (evita falha por cobertura global pré-existente). + +--- + +## Execution Plan + +Fases ordenadas, executadas em sequência; tasks dentro da fase em ordem. + +### Phase 1: Foundation (toolchain + config) + +``` +T1 → T2 +``` + +### Phase 2: Provider layer + +``` +T3 → T4 +``` + +### Phase 3: Templates + +``` +T5 +``` + +### Phase 4: Transport + API interna + +``` +T6 → T7 → T8 +``` + +### Phase 5: Integração + docs + +``` +T9 → T10 → T11 +``` + +--- + +## Task Breakdown + +### T1: Instalar dependências e habilitar JSX no backend + +**What**: Adicionar `bullmq`, `ioredis`, `resend`, `react@19`, `react-dom@19`, `@types/react@19` (dev), `@react-email/render` ao backend e habilitar `"jsx": "react-jsx"` no tsconfig. +**Where**: `backend/package.json`, `backend/tsconfig.json` +**Depends on**: None +**Reuses**: — +**Requirement**: EMAIL-02, EMAIL-04 (habilitadores) + +**Tools**: +- MCP: `context7` (conferir versões/peer deps de `@react-email/render` e `bullmq`) +- Skill: NONE + +**Done when**: +- [ ] `react`/`react-dom` em `^19` e `@types/react` em `^19` (casam com frontend; evita bug `@types/react@18`) +- [ ] `bullmq`, `ioredis`, `resend`, `@react-email/render` instalados +- [ ] `tsconfig.json` com `"jsx": "react-jsx"` +- [ ] Gate passa: `cd backend && npm test` (suíte existente continua verde) + +**Tests**: none +**Gate**: build + +--- + +### T2: Adicionar configuração de e-mail + +**What**: Adicionar campos de e-mail ao `AppConfig`/`getConfig()` e ao `.env.example`: `emailApiKey`, `emailFromAddress`, `emailFromName`, `emailQueueAttempts` (default 3) e reuso de `frontendUrl` (env `FRONTEND_URL` já existente) para o CTA. +**Where**: `backend/src/config.ts`, `.env.example` +**Depends on**: None +**Reuses**: padrão `parseNumber`/`getConfig` em `src/config.ts`; env `FRONTEND_URL` +**Requirement**: EMAIL-04, EMAIL-07, EMAIL-09 + +**Tools**: +- MCP: NONE +- Skill: NONE + +**Done when**: +- [ ] `getConfig()` retorna os novos campos com defaults seguros (chave vazia ⇒ string vazia) +- [ ] `emailQueueAttempts` parseado como número com default 3 +- [ ] `frontendUrl` exposto a partir de `FRONTEND_URL` +- [ ] `.env.example` documenta `EMAIL_API_KEY`, `EMAIL_FROM_ADDRESS`, `EMAIL_FROM_NAME`, `EMAIL_QUEUE_ATTEMPTS` +- [ ] Gate passa: `cd backend && npm test` + +**Tests**: none +**Gate**: build + +--- + +### T3: Interface MailProvider + NoopProvider + factory + +**What**: Definir `interface MailProvider { send(msg) }`, implementar `NoopProvider` (loga, não envia, não lança) e `getMailProvider()` que retorna Resend quando `EMAIL_API_KEY` presente, senão Noop. +**Where**: `backend/src/modules/email/providers/mail-provider.ts`, `backend/src/modules/email/providers/noop.provider.ts` +**Depends on**: T2 +**Reuses**: `logWarn` (`src/logger.ts`), `getConfig` (`src/config.ts`) +**Requirement**: EMAIL-04, EMAIL-09 + +**Tools**: +- MCP: NONE +- Skill: NONE + +**Done when**: +- [ ] `getMailProvider()` retorna `NoopProvider` quando `emailApiKey` vazio e `ResendProvider` quando presente +- [ ] `NoopProvider.send` loga aviso e resolve sem lançar +- [ ] Testes unitários cobrem seleção por env + no-op sem throw +- [ ] Gate passa: `cd backend && npm test` + +**Tests**: unit +**Gate**: quick + +--- + +### T4: ResendProvider + +**What**: Implementar `ResendProvider.send({to,subject,html,replyTo})` via SDK `resend`, usando `from` = `EMAIL_FROM_NAME `; lançar em falha do SDK (para o BullMQ re-tentar). +**Where**: `backend/src/modules/email/providers/resend.provider.ts` +**Depends on**: T1, T2, T3 +**Reuses**: interface de T3, `getConfig`, `logError` +**Requirement**: EMAIL-02, EMAIL-04 + +**Tools**: +- MCP: `context7` (assinatura de `resend.emails.send`) +- Skill: NONE + +**Done when**: +- [ ] `send` chama o SDK com `{ from, to, subject, html, replyTo? }` mapeados corretamente +- [ ] Erro do SDK é propagado (lançado), não engolido +- [ ] Testes unitários mockam `resend` e asseveram payload + propagação de erro +- [ ] Gate passa: `cd backend && npm test` + +**Tests**: unit +**Gate**: quick + +--- + +### T5: Templates react-email + TemplateRegistry + +**What**: Criar `BaseLayout.tsx`, `welcome.tsx` (props `{ name, appUrl }`, com botão "Acessar plataforma" → `appUrl`) e `registry.ts` (`TemplateName`, `isTemplate`, `renderTemplate` async via `@react-email/render`, com `subject` por template). +**Where**: `backend/src/modules/email/templates/BaseLayout.tsx`, `welcome.tsx`, `registry.ts` +**Depends on**: T1 +**Reuses**: `@react-email/render` +**Requirement**: EMAIL-07, EMAIL-05 (template desconhecido) + +**Tools**: +- MCP: `context7` (`render` async do react-email; componentes) +- Skill: NONE + +**Done when**: +- [ ] `renderTemplate('welcome', { name, appUrl })` retorna `{ subject, html }` com HTML contendo o `name` e um link/botão com `href` = `appUrl` +- [ ] `isTemplate('desconhecido')` é `false`; `renderTemplate` com nome inválido rejeita/lança +- [ ] Testes unitários no registry cobrem render de welcome (nome + CTA href + subject) e rejeição de template desconhecido +- [ ] Gate passa: `cd backend && npm test` + +**Tests**: unit (registry) · templates `.tsx` = none (via registry) +**Gate**: quick + +--- + +### T6: EmailQueue (BullMQ sobre Valkey) + +**What**: Criar fila `email` com conexão `ioredis` dedicada ao `VALKEY_URL` (`maxRetriesPerRequest: null`), expor `getEmailQueue()`, `enqueueEmail(data)` (com `attempts` = config, `backoff` exponencial) e `closeEmailQueue()`. +**Where**: `backend/src/modules/email/email.queue.ts` +**Depends on**: T1, T2 +**Reuses**: padrão singleton de `src/lib/cache.ts`; `VALKEY_URL`; `getConfig` +**Requirement**: EMAIL-01, EMAIL-03 + +**Tools**: +- MCP: `context7` (`Queue`/opções de `add` do BullMQ) +- Skill: NONE + +**Done when**: +- [ ] `enqueueEmail` adiciona job com `attempts` = `emailQueueAttempts` e backoff exponencial +- [ ] Conexão ioredis criada com `maxRetriesPerRequest: null` +- [ ] Testes unitários mockam `bullmq`/`ioredis` e asseveram opções do job +- [ ] Gate passa: `cd backend && npm test` + +**Tests**: unit +**Gate**: quick + +--- + +### T7: EmailWorker + +**What**: Criar `startEmailWorker()`/`stopEmailWorker()`: processor que renderiza o template (`renderTemplate`) e despacha via `getMailProvider().send(...)`; `on('failed')` loga erro no fracasso final. Falha do provider propaga (BullMQ re-tenta). +**Where**: `backend/src/modules/email/email.worker.ts` +**Depends on**: T3, T4, T5, T6 +**Reuses**: `getMailProvider`, `renderTemplate`, `logInfo/logError` +**Requirement**: EMAIL-02, EMAIL-03 + +**Tools**: +- MCP: `context7` (`Worker` do BullMQ) +- Skill: NONE + +**Done when**: +- [ ] Processor renderiza template e chama `provider.send({ to, subject, html })` com os valores renderizados +- [ ] Erro do provider é propagado pelo processor (permite retry do BullMQ) +- [ ] Handler `failed` registra erro (log) sem lançar +- [ ] Testes unitários mockam registry+provider e asseveram: payload enviado (to/subject/html), propagação de erro, log no failed +- [ ] Gate passa: `cd backend && npm test` + +**Tests**: unit +**Gate**: quick + +--- + +### T8: EmailService (API interna) + +**What**: Criar `EmailService` com `send({template,to,data})` — valida formato de `to` e existência de `template` (senão `AppError.validation`), enfileira via `enqueueEmail`; falha de enqueue é logada e **não** propagada. `sendWelcome({email,name})` injeta `appUrl` = `config.frontendUrl` e chama `send`. +**Where**: `backend/src/modules/email/email.service.ts` +**Depends on**: T5, T6 +**Reuses**: `enqueueEmail`, `isTemplate`, `AppError`, `logError`, `getConfig` +**Requirement**: EMAIL-01, EMAIL-05, EMAIL-06, EMAIL-07, EMAIL-10 + +**Tools**: +- MCP: NONE +- Skill: NONE + +**Done when**: +- [ ] `send` válido enfileira job com `{ template, to, data }` +- [ ] `to` inválido OU `template` desconhecido ⇒ `AppError.validation` e **nenhum** enqueue +- [ ] Falha no `enqueueEmail` ⇒ `send` resolve (erro logado, não propagado) +- [ ] `sendWelcome` injeta `appUrl` do `frontendUrl` e usa template `welcome` +- [ ] Testes unitários cobrem: enqueue no caminho feliz, validação (to/template) sem enqueue, enqueue-throw engolido, sendWelcome com appUrl +- [ ] Gate passa: `cd backend && npm test` + +**Tests**: unit +**Gate**: quick + +--- + +### T9: Iniciar/parar worker no boot do servidor + +**What**: Chamar `startEmailWorker()` no boot e `stopEmailWorker()` + `closeEmailQueue()` no graceful shutdown do servidor. +**Where**: `backend/src/server.ts` +**Depends on**: T6, T7 +**Reuses**: padrão de shutdown existente (`closeCache`) +**Requirement**: EMAIL-02 + +**Tools**: +- MCP: NONE +- Skill: NONE + +**Done when**: +- [ ] Worker inicia no boot do servidor +- [ ] Shutdown fecha worker e fila junto dos recursos existentes +- [ ] Gate passa: `cd backend && npm test` (suíte não quebra) + +**Tests**: none (wiring — build gate) +**Gate**: build + +--- + +### T10: Disparar boas-vindas no registro + +**What**: No `AuthService.register`, após o registro concluir com sucesso, chamar `emailService.sendWelcome({ email, name })` dentro de try/catch que apenas loga — nunca propaga. +**Where**: `backend/src/modules/auth/**` (arquivo do `register`) + teste correspondente +**Depends on**: T8 +**Reuses**: `EmailService`, `logError`, padrão de teste de auth existente +**Requirement**: EMAIL-06, EMAIL-08 + +**Tools**: +- MCP: NONE +- Skill: NONE + +**Done when**: +- [ ] Registro bem-sucedido chama `sendWelcome` com email + nome do usuário +- [ ] Exceção em `sendWelcome` NÃO altera o retorno de sucesso do registro +- [ ] Testes unitários: welcome enfileirado no sucesso; registro ainda retorna sucesso quando `sendWelcome` lança +- [ ] Gate passa: `cd backend && npm test` + +**Tests**: unit +**Gate**: full + +--- + +### T11: Documentação do módulo de e-mail + +**What**: Documentar uso do módulo (API interna `emailService.send/sendWelcome`, envs, como adicionar novo template/provider) no `BACKEND.md` (e/ou `docs/`). +**Where**: `BACKEND.md` +**Depends on**: T8 +**Reuses**: estilo do `BACKEND.md` +**Requirement**: EMAIL-11 + +**Tools**: +- MCP: NONE +- Skill: NONE + +**Done when**: +- [ ] Seção "Módulo de E-mail" com exemplo de uso, envs e passo p/ novo template/provider +- [ ] Gate passa: `cd backend && npm test` (docs não quebram nada) + +**Tests**: none (docs) +**Gate**: build + +--- + +## Phase Execution Map + +``` +Phase 1 → Phase 2 → Phase 3 → Phase 4 → Phase 5 + +Phase 1: T1 ──→ T2 +Phase 2: T3 ──→ T4 +Phase 3: T5 +Phase 4: T6 ──→ T7 ──→ T8 +Phase 5: T9 ──→ T10 ──→ T11 +``` + +Execução estritamente sequencial — sem paralelismo intra-fase. + +--- + +## Task Granularity Check + +| Task | Scope | Status | +| --- | --- | --- | +| T1: deps + jsx | toolchain (2 arquivos coesos) | ✅ Granular | +| T2: config e-mail | 1 concern (config) | ✅ Granular | +| T3: interface + Noop + factory | 1 concern coeso (contrato+fallback) | ✅ Granular | +| T4: ResendProvider | 1 classe | ✅ Granular | +| T5: templates + registry | 1 concern coeso (templates+lookup) | ✅ Granular | +| T6: EmailQueue | 1 módulo (fila) | ✅ Granular | +| T7: EmailWorker | 1 módulo (worker) | ✅ Granular | +| T8: EmailService | 1 classe (API interna) | ✅ Granular | +| T9: boot wiring | 1 arquivo (server.ts) | ✅ Granular | +| T10: integração register | 1 função (register) | ✅ Granular | +| T11: docs | 1 arquivo (docs) | ✅ Granular | + +--- + +## Diagram-Definition Cross-Check + +| Task | Depends On (body) | Diagram Shows | Status | +| --- | --- | --- | --- | +| T1 | None | (início) | ✅ Match | +| T2 | None | (início) | ✅ Match | +| T3 | T2 | Phase 2 pós Phase 1 | ✅ Match | +| T4 | T1, T2, T3 | T3 → T4 (+ deps fase 1) | ✅ Match | +| T5 | T1 | Phase 3 pós Phase 1 | ✅ Match | +| T6 | T1, T2 | Phase 4 pós fase 1 | ✅ Match | +| T7 | T3, T4, T5, T6 | T6 → T7 (+ provider/template) | ✅ Match | +| T8 | T5, T6 | T7 → T8 na ordem; deps T5/T6 anteriores | ✅ Match | +| T9 | T6, T7 | Phase 5 pós Phase 4 | ✅ Match | +| T10 | T8 | pós Phase 4 | ✅ Match | +| T11 | T8 | pós Phase 4 | ✅ Match | + +> Todas as dependências apontam para trás ou para fase anterior — nenhuma dependência de fase posterior. + +--- + +## Test Co-location Validation + +| Task | Layer Created/Modified | Matrix Requires | Task Says | Status | +| --- | --- | --- | --- | --- | +| T1 | deps/tsconfig | none | none | ✅ OK | +| T2 | config | none | none | ✅ OK | +| T3 | provider/factory | unit | unit | ✅ OK | +| T4 | provider | unit | unit | ✅ OK | +| T5 | registry (+ templates none) | unit | unit | ✅ OK | +| T6 | queue | unit | unit | ✅ OK | +| T7 | worker | unit | unit | ✅ OK | +| T8 | service | unit | unit | ✅ OK | +| T9 | boot wiring | none | none | ✅ OK | +| T10 | auth service (integração) | unit | unit | ✅ OK | +| T11 | docs | none | none | ✅ OK | + +> Nenhum `Tests: none` esconde deferral: todos os `none` correspondem a camadas que a matriz marca como `none` (deps, config, wiring, docs, templates `.tsx`). + +--- + +## Sub-Agent Batching (para o Execute) + +Total: **11 tasks** em 5 fases `[2,2,1,3,3]`. Empacotando ~7 tasks/worker por fronteira de fase: + +- **Batch A — Blocos de construção** (Fases 1–3): T1–T5 (5 tasks) — deps/config, providers, templates. Folhas sem interdependência além das deps. +- **Batch B — Montagem + integração** (Fases 4–5): T6–T11 (6 tasks) — fila, worker, service, boot, registro, docs. Depende integralmente do Batch A. + +→ **2 workers**, sequenciais (Batch B só inicia após Batch A reportar tudo completo). Verifier roda automaticamente após T11. + +**Commits:** workers NÃO commitam (AGENTS.md). Após cada batch, o orchestrator apresenta os commits atômicos propostos (pt-BR) para aprovação do usuário. diff --git a/.specs/features/email-module/validation.md b/.specs/features/email-module/validation.md new file mode 100644 index 0000000..4eafb35 --- /dev/null +++ b/.specs/features/email-module/validation.md @@ -0,0 +1,123 @@ +# Email Module Validation + +**Date**: 2026-07-27 +**Spec**: `.specs/features/email-module/spec.md` +**Diff range**: `master..feature/pav-76-modulo-email` (Batch A committed) + working-tree changes (Batch B) + untracked email module files +**Verifier**: independent sub-agent (author ≠ verifier), read-only over the real tree; sensor mutations ran on temp copies and were discarded + +--- + +## Verdict: PASS ✅ + +Todos os 11 ACs (EMAIL-01..11) têm evidência `file:line` com assertion que mira o valor/estado definido na spec. Gate limpo (529/529). Sensor: 5/5 mutantes mortos. + +--- + +## Spec-Anchored Acceptance Criteria + +| AC | Spec-defined outcome | `file:line` + assertion | Result | +| -- | -------------------- | ----------------------- | ------ | +| **EMAIL-01** — `send({template,to,data})` válido enfileira e retorna sem aguardar entrega | Job enfileirado com payload exato `{template,to,data}` | `tests/unit/modules/email/email.service.test.ts:58` — `expect(queueMocks.enqueueEmail).toHaveBeenCalledWith({ template:"welcome", to:"user@example.com", data:{name:"Ana"} })` | ✅ PASS | +| **EMAIL-01** (conexão fila) — ioredis com `maxRetriesPerRequest:null` | Conexão criada com esse opt (requisito BullMQ) | `tests/unit/modules/email/email.queue.test.ts:69` — `expect(RedisConstructor).toHaveBeenCalledWith("redis://localhost:6379", { maxRetriesPerRequest: null })` | ✅ PASS | +| **EMAIL-02** — worker renderiza template e despacha via provider | Provider recebe `{to,subject,html}` renderizados | `email.worker.test.ts:94-101` — `expect(renderTemplate).toHaveBeenCalledWith("welcome",{name:"Ana"})` + `expect(providerMocks.send).toHaveBeenCalledWith({ to:"user@example.com", subject:"Bem-vindo", html:"

Olá

" })` | ✅ PASS | +| **EMAIL-02/04** (provider Resend concreto) — SDK chamado com from formatado + campos mapeados | `resend.emails.send({from:"Painel Vagas ", to, subject, html, replyTo})` | `resend.provider.test.ts:54-61` — `expect(resendMocks.send).toHaveBeenCalledWith({ from:"Painel Vagas ", to, subject, html, replyTo })` | ✅ PASS | +| **EMAIL-03** — falha do provider ⇒ retry com backoff; fracasso final ⇒ log, sem exceção ao caller | (a) enqueue com `attempts` do config + backoff exp; (b) worker propaga erro p/ BullMQ retry; (c) provider Resend lança em erro; (d) handler `failed` só loga | (a) `email.queue.test.ts:92` — `toHaveBeenCalledWith("send-email", data, objectContaining({ attempts:3, backoff:{type:"exponential",delay:2000} }))`; (b) `email.worker.test.ts:111` — `await expect(processor(job)).rejects.toThrow("provider down")`; (c) `resend.provider.test.ts:71` — `rejects.toThrow("rate limit")`; (d) `email.worker.test.ts:120-123` — `expect(()=>failedHandler(...)).not.toThrow()` + `expect(logError).toHaveBeenCalled()` | ✅ PASS | +| **EMAIL-04** — provider trocável via interface, sem mudar caller | `getMailProvider()` retorna Resend se key presente, Noop se ausente | `mail-provider.test.ts:57` — `expect(provider).toBeInstanceOf(FakeResend)` (key presente); `:47` — `toBeInstanceOf(FakeNoop)` (key vazia) | ✅ PASS | +| **EMAIL-05** — `to` inválido OU template desconhecido ⇒ rejeita no enqueue, antes de criar job | Lança `VALIDATION_ERROR` e NÃO enfileira | `email.service.test.ts:65-74` — `rejects.toMatchObject({code:"VALIDATION_ERROR"})` + `expect(enqueueEmail).not.toHaveBeenCalled()` (`to` inválido); `:77-88` — idem p/ template desconhecido. Registry: `registry.test.ts:14` — `isTemplate("desconhecido")` `.toBe(false)`; `:33` — `renderTemplate("desconhecido",...)` `.rejects.toThrow()` | ✅ PASS | +| **EMAIL-06** — registro com sucesso enfileira welcome p/ endereço do usuário | `sendWelcome` chamado com `{email,name}` corretos | `credentials.service.test.ts:201` — `expect(mocks.sendWelcome).toHaveBeenCalledWith({ email: mockUser.email, name: mockUser.displayName })` | ✅ PASS | +| **EMAIL-06/07** (service) — sendWelcome injeta appUrl do frontendUrl + template welcome | Payload `{template:"welcome", to, data:{name, appUrl:frontendUrl}}` | `email.service.test.ts:113` — `toHaveBeenCalledWith({ template:"welcome", to:"user@example.com", data:{ name:"Ana", appUrl:"https://painelvagas.com" } })` | ✅ PASS | +| **EMAIL-07** — welcome renderizado contém nome + botão "Acessar plataforma" com href=appUrl | HTML contém nome, `href="${appUrl}"`, e "Acessar plataforma"; subject presente | `registry.test.ts:28-30` — `expect(html).toContain("Maria")` + `expect(html).toContain('href="https://app.example.com"')` + `expect(html).toContain("Acessar plataforma")` (+ `subject` truthy/string `:26-27`) | ✅ PASS | +| **EMAIL-08** — falha do welcome não quebra o registro | `register` resolve com user+session mesmo com sendWelcome rejeitando | `credentials.service.test.ts:207-213` — `sendWelcome.mockRejectedValue(...)` então `expect(result.user).toMatchObject({email})` + `expect(result.session).toEqual({userId, role:"user"})` | ✅ PASS | +| **EMAIL-09** — env de e-mail ausente ⇒ no-op sem quebrar boot | (a) getMailProvider retorna Noop com key vazia; (b) Noop loga e resolve sem lançar | (a) `mail-provider.test.ts:47` — `toBeInstanceOf(FakeNoop)`; (b) `noop.provider.test.ts:28-33` — `resolves.toBeUndefined()` + `logWarn` chamado once + `ctx` `toMatchObject({to})` | ✅ PASS | +| **EMAIL-10** — Valkey down no enqueue ⇒ `send` loga e não propaga | `send` resolve `undefined` + `logError` chamado | `email.service.test.ts:91-102` — `enqueueEmail.mockRejectedValue(new Error("valkey down"))` então `resolves.toBeUndefined()` + `expect(logError).toHaveBeenCalled()` | ✅ PASS | +| **EMAIL-11** — documentação de uso no repositório | Seção de módulo em doc do repo | `BACKEND.md` — nova seção "## Módulo de E-mail" (fluxo, arquivos, uso, envs, como adicionar template/provider) + envs listadas na seção de variáveis | ✅ PASS (verificação documental; sem teste automatizado — apropriado) | + +**Status**: ✅ Todos os 11 ACs cobertos com assertion não-rasa alinhada ao outcome da spec. + +### Edge cases da spec + +- [x] Env de e-mail ausente ⇒ no-op sem quebrar boot — EMAIL-09 (mail-provider + noop tests). +- [x] Valkey down no enqueue ⇒ não propaga — EMAIL-10 (email.service.test.ts:91). +- [x] Template inexistente ⇒ enqueue falha com validação (sem job órfão) — EMAIL-05 (email.service.test.ts:77 + registry.test.ts:33). +- [~] Dois registros do mesmo usuário ⇒ jobs independentes (sem dedup) — comportamento default aceito no MVP; sem teste dedicado, mas a spec o marca como "aceitável" e não define outcome preciso. Não é gap. + +--- + +## Discrimination Sensor + +Mutações injetadas UMA POR VEZ em cópias temporárias (backup em `/tmp`, restauradas após cada rodada). Árvore real intacta ao final. + +| # | Alvo | Mutação | Teste executado | Killed? | +| - | ---- | ------- | --------------- | ------- | +| 1 | `email.service.ts:22` | Desativa validação de `to` (`if (false && ...)`) | `email.service.test.ts` | ✅ Killed (1 failed) | +| 2 | `email.service.ts:30-38` | Remove try/catch — deixa a falha de enqueue PROPAGAR | `email.service.test.ts` | ✅ Killed (teste de resiliência EMAIL-10) | +| 3 | `email.worker.ts:25` | Engole o erro do provider (try/catch vazio) em vez de propagar | `email.worker.test.ts` | ✅ Killed (teste de propagação/retry EMAIL-03) | +| 4 | `welcome.tsx:24` | `href={appUrl}` → `href="#"` (CTA não aponta p/ appUrl) | `registry.test.ts` | ✅ Killed (EMAIL-07) | +| 5 | `credentials.service.ts:87-97` | Remove try/catch — propaga exceção do sendWelcome | `credentials.service.test.ts` | ✅ Killed (EMAIL-08) | + +**Sensor depth**: lightweight fault-injection (5 mutações, alta cobertura das branches de resiliência + payload). +**Result**: 5/5 killed — PASS ✅. Nenhum mutante sobrevivente ⇒ nenhuma fix task. + +--- + +## Code Quality + +| Principle | Status | +| --------- | ------ | +| Minimum code (sem features além do pedido) | ✅ | +| Surgical changes (só arquivos necessários) | ✅ | +| No scope creep (contato/suporte fora, como spec exige) | ✅ | +| Matches patterns (Service coeso, hoisted mocks, singleton lazy à la cache.ts) | ✅ | +| Spec-anchored outcome check (valores asseverados batem com a spec) | ✅ | +| Per-layer coverage (service/queue/worker/providers/registry/integração cada um com happy+edge+erro) | ✅ | +| Todo teste mapeia a um AC/edge/Done-when (sem testes órfãos) | ✅ | +| Guidelines: threshold 80% Vitest; mock de Valkey/BullMQ/provider (design §Risks) seguido | ✅ | + +Observação (não-bloqueante): `EmailQueue`/`EmailWorker`/`ResendProvider` são exercitados via mocks de `bullmq`/`ioredis`/`resend` — apropriado para unit (design marca conexão real como out-of-scope de teste). Não há teste de integração de ponta-a-ponta com Valkey real, o que é consistente com o design (risco "testes lentos/flaky se conectarem de verdade"). + +--- + +## Gate Check + +- **Gate command**: `cd backend && npm test` (`vitest run`) +- **Result**: 529 passed, 0 failed, 0 skipped (55 test files) +- **Delta**: testes de e-mail novos (email.service 5, email.queue 5, email.worker 3, registry 4, mail-provider 2, noop 1, resend 2) + 2 novos em credentials.service (EMAIL-06/08) +- **Flaky pré-existentes**: nenhum acionado nesta rodada; `savedJobs.routes`/`app.test` passaram. +- **Failures**: nenhuma. +- Rodado 2x (antes e depois do sensor) — verde em ambas. + +--- + +## Requirement Traceability Update + +| Requirement | Previous Status | New Status | +| ----------- | --------------- | ---------- | +| EMAIL-01 | Implementing | ✅ Verified | +| EMAIL-02 | Implementing | ✅ Verified | +| EMAIL-03 | Implementing | ✅ Verified | +| EMAIL-04 | Implementing | ✅ Verified | +| EMAIL-05 | Implementing | ✅ Verified | +| EMAIL-06 | Implementing | ✅ Verified | +| EMAIL-07 | Implementing | ✅ Verified | +| EMAIL-08 | Implementing | ✅ Verified | +| EMAIL-09 | Implementing | ✅ Verified | +| EMAIL-10 | Implementing | ✅ Verified | +| EMAIL-11 | Implementing | ✅ Verified | + +--- + +## Summary + +**Overall**: ✅ Ready + +**Spec-anchored check**: 11/11 ACs matched spec outcome — 0 spec-precision gaps. +**Sensor**: 5/5 mutations killed. +**Gate**: 529 passed, 0 failed. + +**What works**: API interna `emailService.send/sendWelcome` (validação + enqueue não-bloqueante), worker render+dispatch, retry/backoff, provider trocável (Resend/Noop) via interface, welcome com nome+CTA para FRONTEND_URL, resiliência (enqueue e welcome nunca quebram o caller), no-op sem env, documentação no BACKEND.md. + +**Issues found**: nenhum. + +**Diff surface covered**: `backend/src/modules/email/**` (service, queue, worker, providers, templates), `credentials.service.ts` (integração welcome), `config.ts`, `server.ts` (boot/shutdown do worker), `.env.example`, `tsconfig.json`, `vitest.config.js`, `BACKEND.md`. `server.ts` boot/shutdown e `config.ts` parsing não têm assertion unitária dedicada, mas seus comportamentos observáveis (worker sobe/desce; envs lidas) são cobertos indiretamente pelos testes de worker/queue/service que consomem esses valores — nível de risco baixo, consistente com o design. + +**Next steps**: marcar a feature como Verified; nenhuma fix task. From 3cc9ac1ea5c15809b90cad94bb6ec4f00882e1cc Mon Sep 17 00:00:00 2001 From: Benevanio Santos Date: Sun, 2 Aug 2026 11:04:33 -0300 Subject: [PATCH 13/18] =?UTF-8?q?fix:=20corre=C3=A7=C3=A3o=20do=20campo=20?= =?UTF-8?q?de=20telefone=20=20BR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/RegisterFormPanel.tsx | 201 +++++++++++++++--- .../components/login/RegisterSide.test.tsx | 83 +++++++- .../new_dashboard/branch-coverage.test.tsx | 30 ++- .../unit/new_dashboard/home.profile.test.tsx | 20 +- 4 files changed, 292 insertions(+), 42 deletions(-) diff --git a/frontend/src/domains/auth/presentation/components/RegisterFormPanel.tsx b/frontend/src/domains/auth/presentation/components/RegisterFormPanel.tsx index ab5b951..3f2245a 100644 --- a/frontend/src/domains/auth/presentation/components/RegisterFormPanel.tsx +++ b/frontend/src/domains/auth/presentation/components/RegisterFormPanel.tsx @@ -1,11 +1,9 @@ +import { useTheme } from "@/shared/hooks/useTheme"; +import { ThemeToggle } from "@/shared/ui/theme-toggle"; import { Image } from "@unpic/react"; import { motion } from "framer-motion"; import { ArrowLeft, Eye, EyeOff } from "lucide-react"; import { FormEvent, useState } from "react"; -import PhoneInput from "react-phone-number-input"; -import "react-phone-number-input/style.css"; -import { ThemeToggle } from "@/shared/ui/theme-toggle"; -import { useTheme } from "@/shared/hooks/useTheme"; import { getGithubAuthUrl, @@ -36,11 +34,136 @@ const LEVEL_OPTIONS = [ const REGISTER_LIMITS = { name: 100, email: 254, - phoneDigits: 15, + phoneDigitsWithCountryCode: 13, + phoneDigitsMobileWithoutCountryCode: 11, + phoneDigitsLandlineWithoutCountryCode: 10, + phoneInput: 19, password: 128, cpf: 14, } as const; +const PHONE_ALLOWED_CHARS_REGEX = /^[0-9+()\s-]*$/; +const BRAZIL_MOBILE_PHONE_REGEX = /^(?:\+55)?[1-9]{2}9\d{8}$/; +const BRAZIL_LANDLINE_PHONE_REGEX = /^(?:\+55)?[1-9]{2}[2-8]\d{7}$/; + +function formatBrazilianPhoneFromDigits(digits: string) { + const ddd = digits.slice(0, 2); + const subscriber = digits.slice(2); + + if (digits.length <= 2) return `(${ddd}`; + + const isMobile = digits.length === 11 || subscriber.startsWith("9"); + if (isMobile) { + if (subscriber.length <= 5) return `(${ddd}) ${subscriber}`; + return `(${ddd}) ${subscriber.slice(0, 5)}-${subscriber.slice(5, 9)}`; + } + + if (subscriber.length <= 4) return `(${ddd}) ${subscriber}`; + return `(${ddd}) ${subscriber.slice(0, 4)}-${subscriber.slice(4, 8)}`; +} + +function normalizePhoneInput(rawValue: string) { + const trimmed = rawValue.trim(); + const digits = trimmed.replace(/\D/g, ""); + const hasExplicitPlus = trimmed.startsWith("+"); + + if (hasExplicitPlus && !digits.startsWith("55")) { + return { + masked: `+${digits.slice(0, REGISTER_LIMITS.phoneDigitsWithCountryCode)}`, + }; + } + + const inferredCountryCode = + hasExplicitPlus || (digits.startsWith("55") && digits.length > 11); + + let nationalDigits = inferredCountryCode ? digits.slice(2) : digits; + const defaultMaxDigits = + nationalDigits[2] === "9" + ? REGISTER_LIMITS.phoneDigitsMobileWithoutCountryCode + : REGISTER_LIMITS.phoneDigitsLandlineWithoutCountryCode; + + const maxNationalDigits = Math.min( + defaultMaxDigits, + REGISTER_LIMITS.phoneDigitsMobileWithoutCountryCode, + ); + + nationalDigits = nationalDigits.slice(0, maxNationalDigits); + const maskedNational = nationalDigits + ? formatBrazilianPhoneFromDigits(nationalDigits) + : ""; + + return { + masked: inferredCountryCode + ? `+55${maskedNational ? ` ${maskedNational}` : ""}` + : maskedNational, + }; +} + +function sanitizePhoneForValidation(value: string) { + const trimmed = value.trim(); + const plusCount = (trimmed.match(/\+/g) || []).length; + + if (!PHONE_ALLOWED_CHARS_REGEX.test(trimmed)) { + return { valid: false as const }; + } + + if (plusCount > 1 || (plusCount === 1 && !trimmed.startsWith("+"))) { + return { valid: false as const }; + } + + const compact = trimmed.replace(/[\s()-]/g, ""); + if (/[^\d+]/.test(compact)) { + return { valid: false as const }; + } + + const hasCountryCode = compact.startsWith("+"); + if (hasCountryCode && !compact.startsWith("+55")) { + return { valid: false as const }; + } + + if (hasCountryCode && !/^\+55\d+$/.test(compact)) { + return { valid: false as const }; + } + + const digitsOnly = compact.replace(/\D/g, ""); + const nationalDigits = hasCountryCode ? digitsOnly.slice(2) : digitsOnly; + const isMobileLength = + nationalDigits.length === REGISTER_LIMITS.phoneDigitsMobileWithoutCountryCode; + const isLandlineLength = + nationalDigits.length === + REGISTER_LIMITS.phoneDigitsLandlineWithoutCountryCode; + + if (!isMobileLength && !isLandlineLength) { + return { valid: false as const }; + } + + if ( + hasCountryCode && + digitsOnly.length !== REGISTER_LIMITS.phoneDigitsWithCountryCode && + digitsOnly.length !== REGISTER_LIMITS.phoneDigitsWithCountryCode - 1 + ) { + return { valid: false as const }; + } + + const ddd = nationalDigits.slice(0, 2); + if (!/^[1-9]{2}$/.test(ddd)) { + return { valid: false as const }; + } + + const candidate = hasCountryCode ? `+55${nationalDigits}` : nationalDigits; + const isValidMobile = BRAZIL_MOBILE_PHONE_REGEX.test(candidate); + const isValidLandline = BRAZIL_LANDLINE_PHONE_REGEX.test(candidate); + + if (!isValidMobile && !isValidLandline) { + return { valid: false as const }; + } + + return { + valid: true as const, + payload: hasCountryCode ? `+55${nationalDigits}` : nationalDigits, + }; +} + function getErrorMessage(error: unknown, fallback: string) { return error instanceof Error && error.message ? error.message : fallback; } @@ -131,6 +254,34 @@ export default function RegisterSide() { const handleRevealPassword = () => setShowPassword((prev) => !prev); + const handlePhoneChange = (rawValue: string) => { + const typed = rawValue ?? ""; + if (typed && !PHONE_ALLOWED_CHARS_REGEX.test(typed)) { + return; + } + + const digits = typed.replace(/\D/g, ""); + const trimmed = typed.trim(); + const hasCountryCode = + trimmed.startsWith("+") || (digits.startsWith("55") && digits.length > 11); + const nationalDigits = hasCountryCode ? digits.slice(2) : digits; + const maxNationalDigits = + nationalDigits[2] === "9" + ? REGISTER_LIMITS.phoneDigitsMobileWithoutCountryCode + : REGISTER_LIMITS.phoneDigitsLandlineWithoutCountryCode; + const maxTotalDigits = hasCountryCode ? 2 + maxNationalDigits : maxNationalDigits; + + if (digits.length > maxTotalDigits) { + return; + } + + const normalized = normalizePhoneInput(typed); + setTelefone(normalized.masked); + if (telefoneError) { + setTelefoneError(""); + } + }; + const formatCpf = (value: string) => { const digits = value.replace(/\D/g, "").slice(0, 11); if (digits.length <= 3) return digits; @@ -168,9 +319,16 @@ export default function RegisterSide() { } } - if (!telefone) { - setTelefoneError("O campo de telefone é obrigatório."); - isValid = false; + const normalizedPhone = (telefone ?? "").trim(); + let phoneToSend: string | undefined; + if (normalizedPhone) { + const parsedPhone = sanitizePhoneForValidation(normalizedPhone); + if (!parsedPhone.valid) { + setTelefoneError("Informe um telefone brasileiro válido."); + isValid = false; + } else { + phoneToSend = parsedPhone.payload; + } } if (!password) { @@ -202,7 +360,7 @@ export default function RegisterSide() { email: email, password: password, name: nome, - phone: telefone, + phone: phoneToSend, cpf: cpf || undefined, level, }); @@ -305,26 +463,21 @@ export default function RegisterSide() {
- { - if ( - !value || - value.replace(/\D/g, "").length <= REGISTER_LIMITS.phoneDigits - ) { - setTelefone(value); - } - }} - numberInputProps={{ maxLength: REGISTER_LIMITS.phoneDigits + 1 }} + handlePhoneChange(e.target.value)} + maxLength={REGISTER_LIMITS.phoneInput} disabled={isLoading} - className="w-full px-4 py-3.5 text-gray-900 dark:text-white bg-transparent focus:outline-none phone-input-custom" + className="w-full px-4 py-3.5 text-gray-900 dark:text-white bg-transparent focus:outline-none" placeholder="(34) 23456-7890" />
diff --git a/frontend/tests/unit/components/login/RegisterSide.test.tsx b/frontend/tests/unit/components/login/RegisterSide.test.tsx index 0486826..ab87594 100644 --- a/frontend/tests/unit/components/login/RegisterSide.test.tsx +++ b/frontend/tests/unit/components/login/RegisterSide.test.tsx @@ -26,6 +26,21 @@ function fillRequiredRegisterFields() { }); } +function fillRequiredRegisterFieldsWithoutPhone() { + fireEvent.change(screen.getByLabelText(/nome/i), { + target: { value: "Bene" }, + }); + fireEvent.change(screen.getByLabelText(/email/i), { + target: { value: "bene@teste.com" }, + }); + fireEvent.change(screen.getByLabelText(/senha/i), { + target: { value: "12345678" }, + }); + fireEvent.change(screen.getByLabelText(/nível de experiência/i), { + target: { value: "pleno" }, + }); +} + vi.mock("@/domains/auth/infrastructure/authApi", () => ({ register: (...args: any[]) => mockRegister(...args), getGoogleAuthUrl: (...args: any[]) => mockGetGoogleAuthUrl(...args), @@ -110,7 +125,7 @@ describe("RegisterSide", () => { expect(screen.getByLabelText(/email/i)).toHaveAttribute("maxlength", "254"); expect(screen.getByPlaceholderText(/\(34\)/i)).toHaveAttribute( "maxlength", - "16", + "19", ); expect(screen.getByLabelText(/senha/i)).toHaveAttribute("maxlength", "128"); expect(screen.getByLabelText(/cpf/i)).toHaveAttribute("maxlength", "14"); @@ -139,9 +154,6 @@ describe("RegisterSide", () => { expect( await screen.findByText(/campo de e-mail é obrigatório/i), ).toBeInTheDocument(); - expect( - await screen.findByText(/campo de telefone é obrigatório/i), - ).toBeInTheDocument(); expect( await screen.findByText(/campo de senha é obrigatório/i), ).toBeInTheDocument(); @@ -190,6 +202,69 @@ describe("RegisterSide", () => { expect(window.location.href).toBe("/login?registered=true"); }); + it("envia formulário válido sem telefone", async () => { + mockRegister.mockResolvedValueOnce({ message: "Usuário criado" }); + render(); + fillRequiredRegisterFieldsWithoutPhone(); + fireEvent.click(screen.getByRole("button", { name: /cadastrar/i })); + + await waitFor(() => { + expect(mockRegister).toHaveBeenCalledWith({ + email: "bene@teste.com", + password: "12345678", + name: "Bene", + phone: undefined, + cpf: undefined, + level: "pleno", + }); + }); + }); + + it("rejeita telefone inválido quando preenchido", async () => { + render(); + fillRequiredRegisterFieldsWithoutPhone(); + fireEvent.change(screen.getByPlaceholderText(/\(34\)/i), { + target: { value: "123" }, + }); + fireEvent.click(screen.getByRole("button", { name: /cadastrar/i })); + + expect( + await screen.findByText(/telefone brasileiro válido/i), + ).toBeInTheDocument(); + expect(mockRegister).not.toHaveBeenCalled(); + }); + + it("aplica máscara para celular e fixo brasileiros", () => { + render(); + const phoneInput = screen.getByPlaceholderText(/\(34\)/i) as HTMLInputElement; + + fireEvent.change(phoneInput, { target: { value: "11912345678" } }); + expect(phoneInput.value).toBe("(11) 91234-5678"); + + fireEvent.change(phoneInput, { target: { value: "1134567890" } }); + expect(phoneInput.value).toBe("(11) 3456-7890"); + + fireEvent.change(phoneInput, { target: { value: "5511912345678" } }); + expect(phoneInput.value).toBe("+55 (11) 91234-5678"); + }); + + it("bloqueia números maiores que o limite permitido", async () => { + render(); + fillRequiredRegisterFieldsWithoutPhone(); + const phoneInput = screen.getByPlaceholderText(/\(34\)/i) as HTMLInputElement; + + fireEvent.change(phoneInput, { target: { value: "+55 1891898989989999" } }); + expect(phoneInput.value).toBe(""); + + fireEvent.click(screen.getByRole("button", { name: /cadastrar/i })); + + await waitFor(() => { + expect(mockRegister).toHaveBeenCalledWith( + expect.objectContaining({ phone: undefined }), + ); + }); + }); + it("envia formulário válido com CPF como usuário (sem tecnologias/nível)", async () => { mockRegister.mockResolvedValueOnce({ message: "Usuário criado" }); render(); diff --git a/frontend/tests/unit/new_dashboard/branch-coverage.test.tsx b/frontend/tests/unit/new_dashboard/branch-coverage.test.tsx index 765865e..d449ffc 100644 --- a/frontend/tests/unit/new_dashboard/branch-coverage.test.tsx +++ b/frontend/tests/unit/new_dashboard/branch-coverage.test.tsx @@ -1,21 +1,21 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { MemoryRouter } from "react-router-dom"; -import { beforeEach, describe, expect, it, vi } from "vitest"; import { CareerChecklist } from "@/domains/new_dashboard/components/home/CareerChecklist"; -import { Header } from "@/domains/new_dashboard/components/layout/Header"; -import { MessageDetailModal } from "@/domains/new_dashboard/components/layout/MessageDetailModal"; import { JobDetailModal } from "@/domains/new_dashboard/components/jobs/JobDetailModal"; import { JobRow } from "@/domains/new_dashboard/components/jobs/JobRow"; +import { Header } from "@/domains/new_dashboard/components/layout/Header"; +import { MessageDetailModal } from "@/domains/new_dashboard/components/layout/MessageDetailModal"; import { MentoringTab } from "@/domains/new_dashboard/components/mentoring/MentoringTab"; import { ProfileForm } from "@/domains/new_dashboard/components/profile/ProfileForm"; import { Modal } from "@/domains/new_dashboard/components/shared/Modal"; import { - clearDashboardNotifications, - getDashboardNotificationFeed, - markDashboardNotificationsRead, + clearDashboardNotifications, + getDashboardNotificationFeed, + markDashboardNotificationsRead, } from "@/domains/new_dashboard/infrastructure/notificationsApi"; import type { Job, UserProfile } from "@/domains/new_dashboard/types"; import { DASHBOARD_NOTIFICATIONS_REFRESH_EVENT } from "@/domains/new_dashboard/utils/notificationEvents"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const mockUseAuth = vi.fn(); const mockUseTheme = vi.fn(); @@ -73,6 +73,15 @@ const profileWithoutAvatar: UserProfile = { technologyExperiences: [{ name: "React", years: 2 }], }; +function getCurrentMonthLabel() { + const currentMonth = new Date().toISOString().slice(0, 7); + const [year, monthNumber] = currentMonth.split("-").map(Number); + return new Intl.DateTimeFormat("pt-BR", { + month: "long", + year: "numeric", + }).format(new Date(year, monthNumber - 1, 1)); +} + describe("new_dashboard branch coverage", () => { beforeEach(() => { localStorage.clear(); @@ -299,6 +308,7 @@ describe("new_dashboard branch coverage", () => { it("usa o checklist com título padrão, enter e remoção da lista", () => { render(); + const currentMonthLabel = getCurrentMonthLabel(); expect( screen.getByText(/crie uma lista mensal para começar/i), @@ -306,7 +316,9 @@ describe("new_dashboard branch coverage", () => { fireEvent.click(screen.getByRole("button", { name: /^lista$/i })); expect( - screen.getByRole("button", { name: /checklist de julho de 2026/i }), + screen.getByRole("button", { + name: new RegExp(`checklist de ${currentMonthLabel}`, "i"), + }), ).toBeInTheDocument(); fireEvent.change(screen.getByPlaceholderText(/novo item do checklist/i), { diff --git a/frontend/tests/unit/new_dashboard/home.profile.test.tsx b/frontend/tests/unit/new_dashboard/home.profile.test.tsx index 93df62b..b08707d 100644 --- a/frontend/tests/unit/new_dashboard/home.profile.test.tsx +++ b/frontend/tests/unit/new_dashboard/home.profile.test.tsx @@ -3,18 +3,27 @@ import { HomeTab } from "@/domains/new_dashboard/components/home/HomeTab"; import { PreferencesForm } from "@/domains/new_dashboard/components/profile/PreferencesForm"; import { ProfileForm } from "@/domains/new_dashboard/components/profile/ProfileForm"; import { - initialPreferences, - initialUser, + initialPreferences, + initialUser, } from "@/domains/new_dashboard/constants"; import type { - SearchPreferences, - UserProfile, + SearchPreferences, + UserProfile, } from "@/domains/new_dashboard/types"; import { fireEvent, render, screen } from "@testing-library/react"; import type { ReactElement } from "react"; import { useState } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; +function getCurrentMonthLabel() { + const currentMonth = new Date().toISOString().slice(0, 7); + const [year, monthNumber] = currentMonth.split("-").map(Number); + return new Intl.DateTimeFormat("pt-BR", { + month: "long", + year: "numeric", + }).format(new Date(year, monthNumber - 1, 1)); +} + function renderWithProfileState( ui: (props: { userProfile: UserProfile; @@ -132,6 +141,7 @@ describe("new_dashboard home and profile components", () => { it("permite criar lista, adicionar item, marcar e excluir no CareerChecklist", () => { render(); + const currentMonthLabel = getCurrentMonthLabel(); fireEvent.change(screen.getByPlaceholderText(/nome da nova lista/i), { target: { value: "Metas" }, @@ -140,7 +150,7 @@ describe("new_dashboard home and profile components", () => { expect(screen.getByRole("button", { name: /metas/i })).toBeInTheDocument(); expect( - screen.getByText("julho de 2026", { selector: "span" }), + screen.getByText(currentMonthLabel, { selector: "span" }), ).toBeInTheDocument(); fireEvent.change(screen.getByPlaceholderText(/novo item do checklist/i), { From ce6346de7d553cb2d4a2af91a95465992210f993 Mon Sep 17 00:00:00 2001 From: Benevanio Santos Date: Sun, 2 Aug 2026 11:28:28 -0300 Subject: [PATCH 14/18] =?UTF-8?q?docs:=20adicionando=20documenta=C3=A7?= =?UTF-8?q?=C3=A3o=20para=20iniciantes=20no=20projeto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- LOCAL_DEVELOPMENT.md | 651 +++++++++++++++++++++++++++++++++++++++++++ README.md | 4 +- 2 files changed, 654 insertions(+), 1 deletion(-) create mode 100644 LOCAL_DEVELOPMENT.md diff --git a/LOCAL_DEVELOPMENT.md b/LOCAL_DEVELOPMENT.md new file mode 100644 index 0000000..315c6ac --- /dev/null +++ b/LOCAL_DEVELOPMENT.md @@ -0,0 +1,651 @@ +# Guia de Desenvolvimento Local + +Este guia foi escrito para quem acabou de clonar o repositório e precisa subir o projeto do zero. + +Objetivo: permitir execução local com o mínimo de tentativa e erro, usando apenas o que existe hoje no repositório. + +## Visão rápida + +O monorepo possui 5 blocos principais: + +- frontend: aplicação principal do usuário final (React + Vite) +- backend: API Node.js/Express (TypeScript + Drizzle) +- front_admin: painel administrativo (React + Vite) +- scraper-go: serviço Go para coleta/agregação de vagas +- observability: stack de métricas e logs (Prometheus/Grafana/Loki etc.) + +Além disso, há Docker Compose para infraestrutura e execução completa. + +--- + +## 1) Pré-requisitos + +### Obrigatórios + +1. Git +2. Node.js 22+ (o backend exige >= 22) +3. npm (projeto usa package-lock e scripts npm) +4. Docker Desktop com Docker Compose (recomendado para subir stack completa) + +### Opcionais (dependendo do fluxo) + +1. Go 1.26+ (apenas se você quiser rodar o scraper-go fora do Docker) +2. PostgreSQL local (se quiser rodar backend local sem backend em container) +3. Valkey/Redis local (se quiser rodar backend/scraper local fora de container) + +### Como verificar instalação + +No terminal: + +```bash +git --version +node -v +npm -v +docker --version +docker compose version +``` + +Opcional (Go): + +```bash +go version +``` + +### Versão recomendada de gerenciador de pacotes + +- Recomendado pelo projeto: npm +- Observação: existe pnpm-workspace.yaml, mas o lockfile ativo do projeto é package-lock.json. + +--- + +## 2) Clonando o projeto + +Exemplo: + +```bash +git clone https://github.com/Cla-Code-Community/candidate.git +cd candidate +``` + +Se seu fork/repo tiver outro nome, ajuste os comandos. + +--- + +## 3) Estrutura do monorepo + +Estrutura de alto nível relevante: + +- backend/ + - API Express, rotas de auth/users/jobs/keywords/saved-jobs/admin + - migrações em backend/drizzle + - testes unitários e integração em backend/tests +- frontend/ + - app principal (landing, login/cadastro, callback OAuth, dashboard) + - testes em frontend/tests +- front_admin/ + - painel administrativo (dashboard, usuários, scrapers, observabilidade, auditoria, permissões) + - testes em front_admin/tests +- scraper-go/ + - serviço Go de scraping + - endpoints como /scrape, /health, /metrics, /api/keywords +- shared/ + - componentes compartilhados (ex.: CandidateLogo) +- docker/ + - Dockerfile multi-stage para backend/frontend/front_admin +- observability/ + - arquivos de configuração do Prometheus/Grafana/Loki/Alertmanager etc. +- docs/ + - documentação complementar +- docker-compose.infra.yml + - PostgreSQL + Valkey +- docker-compose.yml + - scraper-go + backend + frontend + front_admin +- docker-compose.migrate.yml + - job de migração/backfill antes do backend +- docker-compose.observability.yml + - stack de observabilidade + +--- + +## 4) Instalação + +Execute na raiz do monorepo: + +```bash +npm install +``` + +Isso instala dependências da raiz e dos workspaces. + +--- + +## 5) Variáveis de ambiente + +## Arquivos de ambiente existentes + +No estado atual do repositório, existem: + +- .env.example (raiz) +- backend/.env.example +- frontend/.env.example + +Também podem existir localmente após setup: + +- .env +- backend/.env + +Observação importante: + +- front_admin não possui front_admin/.env.example versionado. + +## Como criar + +Na raiz: + +```bash +cp .env.example .env +cp backend/.env.example backend/.env +cp frontend/.env.example frontend/.env +``` + +No PowerShell: + +```powershell +Copy-Item .env.example .env +Copy-Item backend/.env.example backend/.env +Copy-Item frontend/.env.example frontend/.env +``` + +## Variáveis obrigatórias vs opcionais + +### Obrigatórias na prática para uso completo + +1. SESSION_SECRET +2. DATABASE_URL +3. CORS_ALLOWED_ORIGINS +4. FRONTEND_URL +5. GO_SCRAPER_URL (ou SCRAPER_URL em alguns fluxos) + +### Necessárias somente se usar OAuth + +1. GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET +2. GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET +3. LINKEDIN_CLIENT_ID / LINKEDIN_CLIENT_SECRET + +### Necessárias somente para fontes externas de scraping + +1. ADZUNA_APP_ID / ADZUNA_APP_KEY +2. JOOBLE_API_KEY + +### Segurança/PII + +1. ENCRYPTION_MASTER_KEY +2. SEARCH_KEY +3. ENCRYPTION_KEY_ID + +Se esses valores não estiverem definidos adequadamente, recursos que dependem de criptografia e busca segura podem falhar. + +--- + +## 6) Banco de dados + +## O que existe hoje + +- ORM: Drizzle +- Dialeto: PostgreSQL +- Migrações: backend/drizzle +- Config Drizzle: backend/drizzle.config.js +- Não há arquivos de seeders versionados no repositório. + +## Migrações (manual) + +Rodando no workspace backend: + +```bash +npm run db:migrate --workspace=backend +``` + +Alternativas: + +```bash +npm run db:generate --workspace=backend +npm run db:push --workspace=backend +``` + +## Migrações via Docker + +No fluxo Docker completo, há o serviço migrate em docker-compose.migrate.yml que roda: + +- db:migrate +- security:backfill-user-pii + +## Usuários padrão + +Não há usuários padrão documentados como seed no repositório. + +Como criar usuário para testes: + +1. Use a tela de cadastro em /register +2. Ou envie POST /auth/register + +--- + +## 7) Docker + +## 7.1 Subir stack completa (recomendado para onboarding) + +1. Criar rede: + +```bash +docker network create vagas-net +``` + +2. Subir infra + app + migrate: + +```bash +docker compose -f docker-compose.infra.yml -f docker-compose.yml -f docker-compose.migrate.yml up --build -d +``` + +## 7.2 Parar stack + +```bash +docker compose -f docker-compose.infra.yml -f docker-compose.yml -f docker-compose.migrate.yml down +``` + +## 7.3 Rebuild + +```bash +docker compose -f docker-compose.infra.yml -f docker-compose.yml -f docker-compose.migrate.yml up --build -d +``` + +## 7.4 Logs + +Logs de todos os serviços: + +```bash +docker compose -f docker-compose.infra.yml -f docker-compose.yml -f docker-compose.migrate.yml logs -f +``` + +Logs de um serviço específico (exemplo backend): + +```bash +docker compose -f docker-compose.infra.yml -f docker-compose.yml -f docker-compose.migrate.yml logs -f backend +``` + +## 7.5 Observabilidade (opcional) + +Subir stack de observabilidade: + +```bash +docker compose -f docker-compose.observability.yml up -d +``` + +Parar: + +```bash +docker compose -f docker-compose.observability.yml down +``` + +--- + +## 8) Executando o projeto + +Você tem 2 caminhos principais. + +## Caminho A: Docker completo (mais simples para começar) + +Use o comando da seção de Docker. + +Portas esperadas: + +- frontend: http://localhost:5173 +- front_admin: http://localhost:5174 +- backend: http://localhost:3001 +- scraper-go: http://localhost:8081 + +## Caminho B: Node local (frontend + backend) + +Na raiz: + +```bash +npm run dev +``` + +Isso sobe: + +- frontend em 5173 +- backend em 3001 + +Para incluir admin junto: + +```bash +npm run dev:admin +``` + +Comandos separados: + +```bash +npm run dev:frontend +npm run dev:backend +npm run dev:front_admin +``` + +Observação importante para o Caminho B: + +- Se backend estiver fora de container, DATABASE_URL e VALKEY_URL precisam apontar para serviços acessíveis pelo host. +- No compose de infra atual, PostgreSQL e Valkey não estão expostos por portas no host por padrão. +- Portanto, para backend local funcionar, você precisa: + 1. usar banco/valkey locais no host, ou + 2. expor portas no compose (ajuste manual), ou + 3. rodar backend também em container. + +--- + +## 9) Acessando a aplicação + +URLs principais: + +- App principal: http://localhost:5173 +- Login: http://localhost:5173/login +- Cadastro: http://localhost:5173/register +- Dashboard app: /home, /dashboard, /vagas, /mentoria, /perfil, /ajuda +- Callback OAuth: /auth/callback + +Backend: + +- Health: http://localhost:3001/health +- Swagger: http://localhost:3001/docs +- Metrics: http://localhost:3001/metrics + +Scraper: + +- Health: http://localhost:8081/health +- Metrics: http://localhost:8081/metrics +- Admin jobs count: http://localhost:8081/admin/jobs/count + +Front admin: + +- http://localhost:5174 +- rota de login: /login +- rotas principais: /dashboard, /users, /scrapers, /observability, /audit, /permissions, /settings + +## Login/senha padrão + +Não há credenciais padrão versionadas/documentadas para produção/local no repositório. + +Fluxo recomendado para ambiente local: + +1. criar usuário via cadastro na aplicação principal +2. usar login com email/senha criados + +Para OAuth, é necessário configurar credenciais de provedores no .env. + +--- + +## 10) Fluxo da aplicação (visão funcional) + +## Aplicação principal (frontend) + +1. Landing page pública em / +2. Cadastro em /register +3. Login em /login +4. Callback OAuth em /auth/callback +5. Após autenticação, acesso a rotas protegidas: + - /home + - /dashboard + - /vagas + - /mentoria + - /perfil + - /ajuda + +## Backend + +- Sessão via cookie (iron-session) +- Rotas protegidas para usuários autenticados: + - /users + - /jobs + - /keywords + - /notifications + - /saved-jobs + - /admin + +## Scraper e fila + +- Backend pode enfileirar keywords no Valkey (chave scraper:keywords:pending) +- Scraper-go processa keywords e agrega vagas + +## Front admin + +- Login próprio do painel +- Controle de acesso por papel (support/admin/super_admin) +- Seções administrativas para operação da plataforma + +--- + +## 11) Testando manualmente (roteiro prático) + +Abaixo, os testes manuais sugeridos para os módulos principais. + +## 11.1 Login + +Passos: + +1. Acesse http://localhost:5173/login +2. Tente enviar vazio +3. Informe credenciais inválidas +4. Informe credenciais válidas + +Resultado esperado: + +- validações de campo aparecem +- credenciais inválidas não autenticam +- credenciais válidas redirecionam para área protegida + +## 11.2 Cadastro + +Passos: + +1. Acesse http://localhost:5173/register +2. Preencha campos obrigatórios +3. Teste telefone opcional vazio +4. Teste telefone válido +5. Teste telefone inválido + +Resultado esperado: + +- cadastro válido cria conta e redireciona para login +- telefone vazio é permitido +- telefone inválido exibe erro e bloqueia envio + +## 11.3 Busca de vagas + +Passos: + +1. Faça login +2. Vá para /vagas +3. Acione busca/filtros + +Resultado esperado: + +- requests de busca retornam sem quebrar a UI +- estados de loading/erro são exibidos corretamente + +## 11.4 Vagas salvas + +Passos: + +1. Em /vagas, salve uma vaga +2. Abra lista de salvas +3. Edite status/notas se disponível +4. Remova vaga salva + +Resultado esperado: + +- operações de criar/editar/remover refletem na interface + +## 11.5 Perfil e preferências + +Passos: + +1. Vá para /perfil +2. Atualize dados do perfil +3. Atualize preferências + +Resultado esperado: + +- alterações persistem +- recarregar a tela mantém dados + +## 11.6 Painel administrativo + +Passos: + +1. Acesse http://localhost:5174/login +2. Faça login com conta com permissão +3. Navegue por dashboard/users/scrapers/observability/audit/permissions/settings + +Resultado esperado: + +- acesso a páginas conforme papel +- usuário sem papel mínimo deve cair em 403 + +--- + +## 12) Como reproduzir bugs corretamente + +Use sempre este formato: + +1. Contexto + - branch + - commit + - ambiente (Docker ou local) + - variáveis relevantes +2. Passos para reproduzir + - sequenciais e exatos +3. Resultado atual +4. Resultado esperado +5. Evidências + - print, log, request/response, stack trace + +Modelo: + +- Passos: + 1. ... + 2. ... + 3. ... +- Resultado atual: ... +- Resultado esperado: ... + +Exemplo real (telefone): + +- Passos: + 1. abrir /register + 2. inserir telefone muito longo + 3. tentar enviar +- Resultado atual (bug): campo aceitava valor inválido +- Resultado esperado: bloquear dígitos excedentes e rejeitar telefone inválido + +--- + +## 13) Testes automatizados + +## 13.1 Monorepo (cobertura consolidada) + +Na raiz: + +```bash +npm run test:coverage +``` + +## 13.2 Backend + +```bash +npm run test --workspace=backend +npm run test:coverage --workspace=backend +npm run test:watch --workspace=backend +``` + +## 13.3 Frontend + +```bash +npm run test --workspace=frontend +npm run test:coverage --workspace=frontend +npm run test:watch --workspace=frontend +``` + +## 13.4 Front admin + +```bash +npm run test --workspace=front_admin +npm run test:coverage --workspace=front_admin +``` + +## 13.5 Testes de integração e E2E + +- Integração: existe no backend (backend/tests/integration). +- E2E browser (Playwright/Cypress): não há suíte E2E ativa/versionada no estado atual do repositório. + +--- + +## 14) Checklist antes de abrir Pull Request + +Use esta checklist: + +- [ ] Projeto instala do zero (npm install) +- [ ] App sobe localmente (npm run dev) ou Docker completo +- [ ] Backend responde /health +- [ ] Frontend abre sem erro crítico +- [ ] Testes do escopo alterado passando +- [ ] Cobertura mantida para o escopo afetado +- [ ] Lint sem erros no frontend/front_admin +- [ ] Sem erro de TypeScript no escopo alterado +- [ ] Funcionalidade validada manualmente +- [ ] Sem regressões observáveis +- [ ] Logs limpos (sem erro não tratado) + +--- + +## Comandos úteis extras + +Builds: + +```bash +npm run build:frontend +npm run build:front_admin +``` + +Validação rápida da raiz: + +```bash +npm run validate +``` + +Electron: + +```bash +npm run electron +npm run electron:dev +``` + +--- + +## Referências do projeto + +- README.md (visão geral) +- BACKEND.md (detalhes da API) +- SCRAPER.md (detalhes do scraper-go) +- TESTING.md (roteiro de QA) +- frontend/README.md +- front_admin/README.md + +--- + +## Lacunas identificadas no estado atual (sem suposição) + +1. Não há front_admin/.env.example versionado. +2. Não há seed oficial versionado para usuários/dados iniciais. +3. Não há credenciais padrão oficiais documentadas para login local. +4. Não há suíte E2E browser ativa/versionada. +5. Há documentação antiga em alguns pontos com prefixo /api que pode divergir das rotas montadas em runtime (que usam /auth, /users, /jobs, etc.). + +Se você for manter este guia, priorize resolver essas lacunas para reduzir tempo de onboarding. diff --git a/README.md b/README.md index 3d419c9..0fe7ce1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # Painel de Vagas -[![CI](https://github.com/Benevanio/Jobs_Scraper_Global/actions/workflows/ci.yml/badge.svg)](https://github.com/Benevanio/Jobs_Scraper_Global/actions/workflows/ci.yml) +> Novo no projeto? Comece por aqui: [LOCAL_DEVELOPMENT.md](LOCAL_DEVELOPMENT.md) + +[![CI](https://github.com/Cla-Code-Community/candidate/actions/workflows/ci.yml/badge.svg)](https://github.com/Cla-Code-Community/candidate/actions/workflows/ci.yml) ![Node >= 22](https://img.shields.io/badge/node-%3E%3D22-339933) ![Monorepo](https://img.shields.io/badge/architecture-monorepo-0A66C2) ![License ISC](https://img.shields.io/badge/license-ISC-lightgrey) From 47116b3f15861daea4e79cdeee3d3f48d5184a38 Mon Sep 17 00:00:00 2001 From: Benevanio Santos Date: Sun, 2 Aug 2026 12:01:36 -0300 Subject: [PATCH 15/18] =?UTF-8?q?docs(PAV-114):=20adiciona=20documenta?= =?UTF-8?q?=C3=A7=C3=A3o=20de=20ambiente=20local=20e=20padr=C3=A3o=20de=20?= =?UTF-8?q?testes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- GUIA_TESTES_AUTOMATIZADOS.md | 337 ++++++++++++++++++ .../unit/adapters/TP-01.goScraper.test.ts | 57 +++ backend/tests/unit/adapters/goScraper.test.ts | 134 ------- .../adapters/goScrapper/goScraper.TC.types.ts | 38 ++ .../adapters/goScrapper/goScraper.TC01.ts | 36 ++ .../adapters/goScrapper/goScraper.TC02.ts | 26 ++ .../adapters/goScrapper/goScraper.TC03.ts | 29 ++ .../adapters/goScrapper/goScraper.TC04.ts | 14 + .../adapters/goScrapper/goScraper.TC05.ts | 31 ++ .../adapters/goScrapper/goScraper.TC06.ts | 42 +++ 10 files changed, 610 insertions(+), 134 deletions(-) create mode 100644 GUIA_TESTES_AUTOMATIZADOS.md create mode 100644 backend/tests/unit/adapters/TP-01.goScraper.test.ts delete mode 100644 backend/tests/unit/adapters/goScraper.test.ts create mode 100644 backend/tests/unit/adapters/goScrapper/goScraper.TC.types.ts create mode 100644 backend/tests/unit/adapters/goScrapper/goScraper.TC01.ts create mode 100644 backend/tests/unit/adapters/goScrapper/goScraper.TC02.ts create mode 100644 backend/tests/unit/adapters/goScrapper/goScraper.TC03.ts create mode 100644 backend/tests/unit/adapters/goScrapper/goScraper.TC04.ts create mode 100644 backend/tests/unit/adapters/goScrapper/goScraper.TC05.ts create mode 100644 backend/tests/unit/adapters/goScrapper/goScraper.TC06.ts diff --git a/GUIA_TESTES_AUTOMATIZADOS.md b/GUIA_TESTES_AUTOMATIZADOS.md new file mode 100644 index 0000000..4ce20f6 --- /dev/null +++ b/GUIA_TESTES_AUTOMATIZADOS.md @@ -0,0 +1,337 @@ +# Guia de Testes Automatizados + +Este documento foi criado para quem nunca trabalhou com testes automatizados. + +Se voce esta contribuindo pela primeira vez no projeto, começe por aqui. + +## O que e um teste automatizado? + +Um teste automatizado e um pequeno programa que verifica se uma funcionalidade continua funcionando como esperado. + +Em vez de testar tudo manualmente toda vez, o teste automatizado executa cenarios e confirma o resultado por voce. + +## Por que escrevemos testes? + +Escrevemos testes para: + +- evitar quebrar funcionalidades antigas +- dar seguranca para melhorar o codigo +- documentar como o sistema deve se comportar +- facilitar revisao de Pull Request +- ajudar novos contribuidores a entender o projeto + +## O que acontece quando um teste falha? + +Quando um teste falha, isso e um alerta. + +Pode significar: + +- bug novo +- comportamento mudou sem querer +- regra esperada deixou de ser atendida + +Em geral, um teste falhando e uma protecao do projeto, nao um incomodo. + +## Como testes ajudam novos desenvolvedores + +Testes funcionam como documentacao viva. + +Ao ler um teste, voce entende: + +- qual entrada foi usada +- qual comportamento era esperado +- o que nao pode regredir + +--- + +## Conceitos basicos + +### O que e um cenario de teste? + +Cenario de teste e uma situacao real do sistema. + +Exemplo simples: + +"Quando faco login com senha correta, espero entrar no sistema." + +Isso vira: + +- Cenario: usuario informa senha correta +- Resultado esperado: sistema permite acesso + +--- + +## O que e um TC (Test Case) + +TC significa Test Case (Caso de Teste). + +Um TC responde: + +"Qual comportamento do sistema estou garantindo?" + +Importante: + +- TC nao e uma linha de codigo isolada +- TC e um comportamento relevante + +Exemplo real do projeto: + +- backend/tests/unit/adapters/goScrapper/goScraper.TC01.ts + +Responsabilidade desse TC: + +- garantir que o adapter retorna vagas quando o Go Scraper responde com dados validos + +--- + +## O que e um TP (Test Plan) + +TP significa Test Plan (Plano de Testes). + +TP e um documento/arquivo que organiza: + +- o que sera testado +- quais cenarios existem +- quais TCs pertencem ao plano +- resultados esperados + +Exemplo real do projeto: + +- backend/tests/unit/adapters/TP-01.goScraper.test.ts + +Esse arquivo representa o planejamento dos testes do adapter Go Scraper. + +Ele define: + +- qual funcionalidade sera validada +- qual o objetivo dos testes +- quais cenarios precisam ser protegidos +- quais TCs fazem parte desse plano + +Antes de escrever codigo de teste, o primeiro passo e entender quais comportamentos precisam ser garantidos. + +--- + +## Relacao entre TP e TC + +O projeto utiliza esta relacao: + +TP (Test Plan) +-> +TC (Test Case) +-> +Teste automatizado executavel + +Explicando de forma simples: + +- TP: planeja os cenarios +- TC: implementa cada cenario +- Suite de testes: executa tudo e valida o comportamento + +### Estrutura de relacionamento (Go Scraper) + +```text +Go Scraper Adapter + +TP-01.goScraper.test.ts + +Define os cenarios: + +|- TC-01 +| |- Resposta valida +| +|- TC-02 +| |- Erro HTTP +| +|- TC-03 +| |- Contrato invalido +| +|- TC-04 +| |- Parametros invalidos +| +|- TC-05 +| |- Envio de parametros +| +|- TC-06 +| |- Variavel de ambiente +``` + +--- + +## Explicacao dos TCs reais do projeto + +Arquivos reais atualmente: + +- backend/tests/unit/adapters/goScrapper/goScraper.TC01.ts +- backend/tests/unit/adapters/goScrapper/goScraper.TC02.ts +- backend/tests/unit/adapters/goScrapper/goScraper.TC03.ts +- backend/tests/unit/adapters/goScrapper/goScraper.TC04.ts +- backend/tests/unit/adapters/goScrapper/goScraper.TC05.ts +- backend/tests/unit/adapters/goScrapper/goScraper.TC06.ts + +Resumo de cada cenario: + +- TC01 + - Cenario: resposta valida do Go Scraper + - Esperado: adapter retorna vagas corretamente + +- TC02 + - Cenario: servico externo retorna erro HTTP + - Esperado: adapter trata e propaga erro corretamente + +- TC03 + - Cenario: resposta nao segue contrato esperado + - Esperado: sistema rejeita resposta invalida + +- TC04 + - Cenario: parametros invalidos de entrada + - Esperado: falha antes de chamar servico externo + +- TC05 + - Cenario: envio de parametros para o servico + - Esperado: payload preserva dados esperados + +- TC06 + - Cenario: URL do Go Scraper via variavel de ambiente + - Esperado: adapter usa GO_SCRAPER_URL configurada + +--- + +## Primeiro exemplo do projeto (passo a passo) + +Use como referencia: + +- backend/tests/unit/adapters/goScrapper/goScraper.TC01.ts + +Passo a passo do que ele faz: + +1. prepara uma resposta simulada (mock) da API externa +2. executa o adapter +3. verifica os dados retornados +4. confirma que o comportamento esperado aconteceu + +--- + +## Regra de qualidade dos testes + +Teste unitario nao deve validar apenas uma linha ou chamada isolada. + +Exemplo ruim: + +```ts +it("deve chamar funcao", () => { + expect(mock.execute).toHaveBeenCalled(); +}); +``` + +Por que e ruim: + +- valida detalhe interno +- nao prova comportamento real + +Exemplo melhor: + +```ts +it("deve retornar vagas quando a resposta do Go Scraper for valida", async () => { + // cenario completo +}); +``` + +Pergunta que todo bom teste deve responder: + +"Qual comportamento do sistema estou garantindo?" + +--- + +## Quando usar varios testes e quando usar it.each + +Use TCs separados quando os cenarios sao diferentes. + +Exemplo: + +- resposta valida +- erro HTTP +- contrato invalido + +Use `it.each` quando a regra e a mesma e so os dados mudam. + +Exemplo: + +```ts +it.each([ + { keyword: "Java" }, + { keyword: "Node.js" }, +])( "deve buscar vagas corretamente", async ({ keyword }) => { + // mesma regra, dados diferentes +}); +``` + +Isso evita copiar e colar testes quase iguais. + +--- + +## Processo para criar novos testes + +1. Identificar a funcionalidade +2. Criar ou atualizar o TP +3. Definir cenarios +4. Criar TCs necessarios +5. Implementar os testes +6. Executar testes localmente +7. Abrir Pull Request + +Comando util para backend: + +```bash +npm run test --workspace=backend +``` + +--- + +## Beneficios desse padrao + +Para iniciantes: + +- facilita entender o projeto +- funciona como documentacao viva +- ajuda na primeira contribuicao + +Para o projeto: + +- melhora manutencao +- facilita revisao de PR +- reduz regressao +- mantem qualidade com crescimento da comunidade + +--- + +## Checklist para novos contribuidores + +Antes de abrir PR, confirme: + +- Existe um TP documentando o objetivo? +- O TC representa um comportamento real? +- O nome do teste esta claro? +- Evitei testar detalhes internos? +- Usei it.each quando os cenarios sao equivalentes? + +--- + +## Nota de consistencia sobre nomes dos arquivos + +Este guia usa os caminhos reais encontrados no repositorio no momento da escrita. + +No prompt de referencia, alguns nomes aparecem como: + +- TC-01.goScraper.test.ts +- TP-01.goScraper.test.ts (como documento) + +No codigo atual, os arquivos de TC estao nomeados como: + +- goScraper.TC01.ts ate goScraper.TC06.ts + +Isso nao muda o conceito. + +O importante e manter a relacao: + +TP planeja -> TC implementa -> teste automatizado protege comportamento. diff --git a/backend/tests/unit/adapters/TP-01.goScraper.test.ts b/backend/tests/unit/adapters/TP-01.goScraper.test.ts new file mode 100644 index 0000000..e4652c8 --- /dev/null +++ b/backend/tests/unit/adapters/TP-01.goScraper.test.ts @@ -0,0 +1,57 @@ +import { afterEach, beforeEach, describe, vi } from "vitest"; +import { TC01 } from "./goScrapper/goScraper.TC01.ts"; +import { TC02 } from "./goScrapper/goScraper.TC02.ts"; +import { TC03 } from "./goScrapper/goScraper.TC03.ts"; +import { TC04 } from "./goScrapper/goScraper.TC04.ts"; +import { TC05 } from "./goScrapper/goScraper.TC05.ts"; +import { TC06 } from "./goScrapper/goScraper.TC06.ts"; + +const mocks = vi.hoisted(() => ({ + logWarn: vi.fn(), + fetch: vi.fn(), +})); + +vi.mock("../../../src/logger.ts", () => ({ + logWarn: mocks.logWarn, +})); + +import { searchJobs } from "../../../src/adapters/goScraper.ts"; + +const validParams = { + keywords: ["Java", "Node.js"], + location: "Brasil", +}; + +const validResponse = { + jobs: [ + { + id: "1", + title: "Dev", + company: "ACME", + location: "Brasil", + url: "https://example.com/job/1", + source: "LinkedIn", + }, + ], + total: 1, + cachedAt: "2026-01-01T00:00:00Z", + fromCache: false, +}; + +describe("goScraper", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", mocks.fetch); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + TC01({ searchJobs, validParams, validResponse, mocks }); + TC02({ searchJobs, validParams, mocks }); + TC03({ searchJobs, validParams, mocks }); + TC04({ searchJobs, mocks }); + TC05({ searchJobs, validResponse, mocks }); + TC06({ validParams, validResponse, mocks }); +}); diff --git a/backend/tests/unit/adapters/goScraper.test.ts b/backend/tests/unit/adapters/goScraper.test.ts deleted file mode 100644 index 8b474a1..0000000 --- a/backend/tests/unit/adapters/goScraper.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -const mocks = vi.hoisted(() => ({ - logWarn: vi.fn(), - fetch: vi.fn(), -})); - -vi.mock("../../../src/logger.ts", () => ({ - logWarn: mocks.logWarn, -})); - -import { searchJobs } from "../../../src/adapters/goScraper.ts"; - -const validParams = { - keywords: ["Java", "Node.js"], - location: "Brasil", -}; - -const validResponse = { - jobs: [ - { - id: "1", - title: "Dev", - company: "ACME", - location: "Brasil", - url: "https://example.com/job/1", - source: "LinkedIn", - }, - ], - total: 1, - cachedAt: "2026-01-01T00:00:00Z", - fromCache: false, -}; - -describe("goScraper", () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.stubGlobal("fetch", mocks.fetch); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it("returns jobs when response is valid", async () => { - mocks.fetch.mockResolvedValueOnce({ - ok: true, - json: async () => validResponse, - }); - - const result = await searchJobs(validParams); - - expect(result.total).toBe(1); - expect(result.jobs[0].title).toBe("Dev"); - expect(mocks.fetch).toHaveBeenCalledWith( - expect.stringContaining("/scrape"), - expect.objectContaining({ - method: "POST", - headers: { "Content-Type": "application/json" }, - }), - ); - }); - - it("throws when response is not ok", async () => { - mocks.fetch.mockResolvedValueOnce({ - ok: false, - status: 500, - statusText: "Internal Server Error", - }); - - await expect(searchJobs(validParams)).rejects.toThrow( - "Go scraper: 500 Internal Server Error", - ); - }); - - it("throws when response does not match schema", async () => { - mocks.fetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ invalid: "data" }), - }); - - await expect(searchJobs(validParams)).rejects.toThrow( - "Go scraper: resposta invalida", - ); - expect(mocks.logWarn).toHaveBeenCalledWith( - "Go scraper: resposta fora do contrato", - expect.objectContaining({ error: expect.any(String) }), - ); - }); - - it("throws when params are invalid", async () => { - await expect(searchJobs({ keywords: [] })).rejects.toThrow(); - expect(mocks.fetch).not.toHaveBeenCalled(); - }); - - it("sends validated params to Go scraper", async () => { - mocks.fetch.mockResolvedValueOnce({ - ok: true, - json: async () => validResponse, - }); - - await searchJobs({ - keywords: ["Java", " Java ", "Node.js"], - location: "SP", - }); - - const body = JSON.parse(mocks.fetch.mock.calls[0][1].body); - expect(body.keywords).toEqual(["Java", " Java ", "Node.js"]); - expect(body.location).toBe("SP"); - }); - - it("uses GO_SCRAPER_URL from environment", async () => { - process.env.GO_SCRAPER_URL = "http://custom-go:9999"; - vi.resetModules(); - - vi.stubGlobal("fetch", mocks.fetch); - mocks.fetch.mockResolvedValueOnce({ - ok: true, - json: async () => validResponse, - }); - - const { searchJobs: searchJobsFresh } = - await import("../../../src/adapters/goScraper.ts"); - await searchJobsFresh(validParams); - - expect(mocks.fetch).toHaveBeenCalledWith( - "http://custom-go:9999/scrape", - expect.anything(), - ); - - delete process.env.GO_SCRAPER_URL; - vi.resetModules(); - }); -}); diff --git a/backend/tests/unit/adapters/goScrapper/goScraper.TC.types.ts b/backend/tests/unit/adapters/goScrapper/goScraper.TC.types.ts new file mode 100644 index 0000000..ffde4e0 --- /dev/null +++ b/backend/tests/unit/adapters/goScrapper/goScraper.TC.types.ts @@ -0,0 +1,38 @@ +export interface GoScraperJob { + id: string; + title: string; + company: string; + location: string; + url: string; + source: string; +} + +export interface GoScraperValidParams { + keywords: string[]; + location: string; +} + +export interface GoScraperValidResponse { + jobs: GoScraperJob[]; + total: number; + cachedAt: string; + fromCache: boolean; +} + +export type SearchJobs = (params: { + keywords: string[]; + location?: string; +}) => Promise; + +export interface GoScraperMocks { + logWarn: { + mockClear: () => void; + }; + fetch: { + mockResolvedValueOnce: (value: unknown) => void; + mockClear: () => void; + mock: { + calls: unknown[][]; + }; + }; +} diff --git a/backend/tests/unit/adapters/goScrapper/goScraper.TC01.ts b/backend/tests/unit/adapters/goScrapper/goScraper.TC01.ts new file mode 100644 index 0000000..6a4aa82 --- /dev/null +++ b/backend/tests/unit/adapters/goScrapper/goScraper.TC01.ts @@ -0,0 +1,36 @@ +import { expect, it } from "vitest"; +import type { + GoScraperMocks, + GoScraperValidParams, + GoScraperValidResponse, + SearchJobs, +} from "./goScraper.TC.types"; + +interface TC01Deps { + searchJobs: SearchJobs; + validParams: GoScraperValidParams; + validResponse: GoScraperValidResponse; + mocks: GoScraperMocks; +} + +export function TC01({ searchJobs, validParams, validResponse, mocks }: TC01Deps) { + it("deve retornar vagas quando o Go Scraper responder com dados válidos", async () => { + mocks.fetch.mockResolvedValueOnce({ + ok: true, + json: async () => validResponse, + }); + + const result = await searchJobs(validParams); + + expect(result.total).toBe(validResponse.total); + expect(result.jobs).toHaveLength(validResponse.jobs.length); + expect(result.jobs[0]).toMatchObject({ + id: validResponse.jobs[0].id, + title: validResponse.jobs[0].title, + company: validResponse.jobs[0].company, + location: validResponse.jobs[0].location, + url: validResponse.jobs[0].url, + source: validResponse.jobs[0].source, + }); + }); +} diff --git a/backend/tests/unit/adapters/goScrapper/goScraper.TC02.ts b/backend/tests/unit/adapters/goScrapper/goScraper.TC02.ts new file mode 100644 index 0000000..c6e15c6 --- /dev/null +++ b/backend/tests/unit/adapters/goScrapper/goScraper.TC02.ts @@ -0,0 +1,26 @@ +import { expect, it } from "vitest"; +import type { + GoScraperMocks, + GoScraperValidParams, + SearchJobs, +} from "./goScraper.TC.types"; + +interface TC02Deps { + searchJobs: SearchJobs; + validParams: GoScraperValidParams; + mocks: GoScraperMocks; +} + +export function TC02({ searchJobs, validParams, mocks }: TC02Deps) { + it("deve falhar quando o Go Scraper responder com status de erro", async () => { + mocks.fetch.mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: "Internal Server Error", + }); + + await expect(searchJobs(validParams)).rejects.toThrow( + "Go scraper: 500 Internal Server Error", + ); + }); +} diff --git a/backend/tests/unit/adapters/goScrapper/goScraper.TC03.ts b/backend/tests/unit/adapters/goScrapper/goScraper.TC03.ts new file mode 100644 index 0000000..002ee72 --- /dev/null +++ b/backend/tests/unit/adapters/goScrapper/goScraper.TC03.ts @@ -0,0 +1,29 @@ +import { expect, it } from "vitest"; +import type { + GoScraperMocks, + GoScraperValidParams, + SearchJobs, +} from "./goScraper.TC.types"; + +interface TC03Deps { + searchJobs: SearchJobs; + validParams: GoScraperValidParams; + mocks: GoScraperMocks; +} + +export function TC03({ searchJobs, validParams, mocks }: TC03Deps) { + it("deve rejeitar resposta inválida e registrar aviso de contrato", async () => { + mocks.fetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ invalid: "data" }), + }); + + await expect(searchJobs(validParams)).rejects.toThrow( + "Go scraper: resposta invalida", + ); + expect(mocks.logWarn).toHaveBeenCalledWith( + "Go scraper: resposta fora do contrato", + expect.objectContaining({ error: expect.any(String) }), + ); + }); +} diff --git a/backend/tests/unit/adapters/goScrapper/goScraper.TC04.ts b/backend/tests/unit/adapters/goScrapper/goScraper.TC04.ts new file mode 100644 index 0000000..128004f --- /dev/null +++ b/backend/tests/unit/adapters/goScrapper/goScraper.TC04.ts @@ -0,0 +1,14 @@ +import { expect, it } from "vitest"; +import type { GoScraperMocks, SearchJobs } from "./goScraper.TC.types"; + +interface TC04Deps { + searchJobs: SearchJobs; + mocks: GoScraperMocks; +} + +export function TC04({ searchJobs, mocks }: TC04Deps) { + it("deve falhar com parâmetros inválidos sem chamar o serviço externo", async () => { + await expect(searchJobs({ keywords: [] })).rejects.toThrow(); + expect(mocks.fetch).not.toHaveBeenCalled(); + }); +} diff --git a/backend/tests/unit/adapters/goScrapper/goScraper.TC05.ts b/backend/tests/unit/adapters/goScrapper/goScraper.TC05.ts new file mode 100644 index 0000000..47c6e6b --- /dev/null +++ b/backend/tests/unit/adapters/goScrapper/goScraper.TC05.ts @@ -0,0 +1,31 @@ +import { expect, it } from "vitest"; +import type { + GoScraperMocks, + GoScraperValidResponse, + SearchJobs, +} from "./goScraper.TC.types"; + +interface TC05Deps { + searchJobs: SearchJobs; + validResponse: GoScraperValidResponse; + mocks: GoScraperMocks; +} + +export function TC05({ searchJobs, validResponse, mocks }: TC05Deps) { + it("deve enviar payload válido ao Go Scraper preservando keywords e localização", async () => { + mocks.fetch.mockResolvedValueOnce({ + ok: true, + json: async () => validResponse, + }); + + await searchJobs({ + keywords: ["Java", " Java ", "Node.js"], + location: "SP", + }); + + const firstCall = mocks.fetch.mock.calls[0] as [string, { body: string }]; + const body = JSON.parse(firstCall[1].body); + expect(body.keywords).toEqual(["Java", " Java ", "Node.js"]); + expect(body.location).toBe("SP"); + }); +} diff --git a/backend/tests/unit/adapters/goScrapper/goScraper.TC06.ts b/backend/tests/unit/adapters/goScrapper/goScraper.TC06.ts new file mode 100644 index 0000000..e766a26 --- /dev/null +++ b/backend/tests/unit/adapters/goScrapper/goScraper.TC06.ts @@ -0,0 +1,42 @@ +import { expect, it, vi } from "vitest"; +import type { + GoScraperMocks, + GoScraperValidParams, + GoScraperValidResponse, +} from "./goScraper.TC.types"; + +interface TC06Deps { + validParams: GoScraperValidParams; + validResponse: GoScraperValidResponse; + mocks: GoScraperMocks; +} + +export function TC06({ validParams, validResponse, mocks }: TC06Deps) { + it("deve usar GO_SCRAPER_URL do ambiente ao montar a URL de scrape", async () => { + const previousGoScraperUrl = process.env.GO_SCRAPER_URL; + process.env.GO_SCRAPER_URL = "http://custom-go:9999"; + vi.resetModules(); + + vi.stubGlobal("fetch", mocks.fetch); + mocks.fetch.mockResolvedValueOnce({ + ok: true, + json: async () => validResponse, + }); + + const { searchJobs: searchJobsFresh } = + await import("../../../../src/adapters/goScraper.ts"); + await searchJobsFresh(validParams); + + expect(mocks.fetch).toHaveBeenCalledWith( + "http://custom-go:9999/scrape", + expect.anything(), + ); + + if (previousGoScraperUrl === undefined) { + delete process.env.GO_SCRAPER_URL; + } else { + process.env.GO_SCRAPER_URL = previousGoScraperUrl; + } + vi.resetModules(); + }); +} From 01da58847b0a9341052ffdcff31b643939688009 Mon Sep 17 00:00:00 2001 From: Jeremias Santos Date: Sun, 2 Aug 2026 13:20:29 -0300 Subject: [PATCH 16/18] feat(auth): dispara e-mail de boas-vindas no primeiro login social (PAV-76) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Estende o gatilho de boas-vindas para o login social (Google, GitHub, LinkedIn), apenas quando a conta é criada pela primeira vez. - findOrCreateUser passa a retornar { user, isNewUser }: true só quando um usuário é realmente criado; false em relogin (achado por provider) ou ao vincular provider a usuário existente (achado por e-mail). - auth.service.handleCallback dispara emailService.sendWelcome após a transação, em try/catch que só loga (login nunca quebra) — mesmo padrão resiliente do CredentialsService.register. - Atualiza spec/design do módulo (ACs, edge cases, EMAIL-12/13). - Testes: novos casos de novo usuário, relogin/vínculo e resiliência. Co-Authored-By: Claude Opus 4.8 --- .specs/features/email-module/design.md | 6 +- .specs/features/email-module/spec.md | 30 ++++++---- backend/src/modules/auth/auth.service.ts | 29 +++++++++- .../users/functions/findOrCreateUser.ts | 23 ++++++-- .../unit/modules/auth/auth.service.test.ts | 58 ++++++++++++++++++- .../users/functions/users.functions.test.ts | 6 +- 6 files changed, 128 insertions(+), 24 deletions(-) diff --git a/.specs/features/email-module/design.md b/.specs/features/email-module/design.md index 991b905..b3ab976 100644 --- a/.specs/features/email-module/design.md +++ b/.specs/features/email-module/design.md @@ -40,14 +40,16 @@ graph TD | Valkey URL | `process.env.VALKEY_URL` (usado em `src/lib/cache.ts`) | Reusar a mesma instância Valkey p/ a conexão ioredis do BullMQ. | | Registro de rotas / boot | `src/app.ts`, `src/server.ts` | Iniciar/parar worker no lifecycle do servidor. | | Padrão de teste | `tests/unit/modules/*`, `tests/integration/*` | Vitest + mocks (hoisted), threshold 80%. | -| Fluxo de registro | `src/modules/auth/**` (`AuthService.register`) | Ponto de gatilho do e-mail de boas-vindas. | +| Fluxo de registro | `src/modules/auth/**` (`CredentialsService.register`) | Gatilho de boas-vindas no cadastro por e-mail/senha. | +| Fluxo de login social | `src/modules/auth/auth.service.ts` (`handleCallback`) + `src/modules/users/functions/findOrCreateUser.ts` | Gatilho de boas-vindas no primeiro login social; `findOrCreateUser` retorna `isNewUser` para disparar só em conta recém-criada. | ### Integration Points | System | Integration Method | | --- | --- | | Valkey | Nova conexão `ioredis` (BullMQ exige) ao mesmo `VALKEY_URL`, prefixo de chaves próprio (`bull:email`). Não interfere no `node-redis` do cache. | -| Auth (registro) | `AuthService.register` chama `emailService.sendWelcome(...)` dentro de try/catch que só loga. | +| Auth (registro) | `CredentialsService.register` chama `emailService.sendWelcome(...)` dentro de try/catch que só loga. | +| Auth (login social) | `AuthService.handleCallback` chama `emailService.sendWelcome(...)` dentro de try/catch (só loga) quando `findOrCreateUser` retorna `isNewUser: true`. | | Resend | HTTP via SDK `resend` usando `EMAIL_API_KEY`. | --- diff --git a/.specs/features/email-module/spec.md b/.specs/features/email-module/spec.md index 028042a..b20eaf7 100644 --- a/.specs/features/email-module/spec.md +++ b/.specs/features/email-module/spec.md @@ -11,7 +11,7 @@ A plataforma passou a ter domínio próprio e precisa enviar e-mails transaciona - [ ] Expor uma **API interna única** de envio de e-mail, consumível por qualquer módulo do backend. - [ ] Enviar de forma **assíncrona e resiliente** (fila com retry), sem que a falha de e-mail derrube o request que o originou. - [ ] Separar **lógica de envio** (provider) de **templates** (react-email), ambos versionados no projeto. -- [ ] Entregar o fluxo funcional de **boas-vindas** (no registro) como primeiro consumidor real. +- [ ] Entregar o fluxo funcional de **boas-vindas** como primeiro consumidor real, disparado tanto no **registro por e-mail/senha** quanto no **primeiro login social** (Google, GitHub, LinkedIn) — apenas para contas recém-criadas. - [ ] Deixar o provedor **trocável** via configuração, sem alterar código chamador. - [ ] Documentar o uso do módulo no repositório. @@ -64,19 +64,21 @@ A plataforma passou a ter domínio próprio e precisa enviar e-mails transaciona --- -### P1: E-mail de boas-vindas no registro ⭐ MVP +### P1: E-mail de boas-vindas no registro e no primeiro login social ⭐ MVP -**User Story**: Como novo usuário, quero receber um e-mail de boas-vindas ao me registrar, para confirmar que minha conta foi criada. +**User Story**: Como novo usuário, quero receber um e-mail de boas-vindas quando minha conta é criada — seja por cadastro com e-mail/senha ou pelo primeiro login social (Google, GitHub, LinkedIn) —, para confirmar que minha conta foi criada. -**Why P1**: Critério de aceite explícito da task (template funcional de boas-vindas) e primeiro consumidor real da API interna. +**Why P1**: Critério de aceite explícito da task (template funcional de boas-vindas) e primeiro consumidor real da API interna. Como a maioria dos cadastros ocorre via login social, o gatilho precisa cobrir esse caminho além do registro por credenciais. **Acceptance Criteria**: -1. WHEN um usuário completa o registro com sucesso THEN o sistema SHALL enfileirar um e-mail de boas-vindas para o endereço do usuário. -2. WHEN o e-mail de boas-vindas é renderizado THEN ele SHALL conter o nome do usuário e o HTML do template de boas-vindas. -3. WHEN o e-mail de boas-vindas é renderizado THEN ele SHALL incluir um botão "Acessar plataforma" cujo link aponta para `FRONTEND_URL` (env já existente, reusada). -4. WHEN o enfileiramento do e-mail de boas-vindas falha THEN o registro do usuário SHALL concluir normalmente mesmo assim (falha de e-mail é logada, não propagada). +1. WHEN um usuário completa o registro por e-mail/senha com sucesso THEN o sistema SHALL enfileirar um e-mail de boas-vindas para o endereço do usuário. +2. WHEN um usuário faz login social (Google, GitHub ou LinkedIn) E uma conta é criada pela primeira vez THEN o sistema SHALL enfileirar um e-mail de boas-vindas para o endereço do usuário. +3. WHEN um usuário faz login social E a conta já existe (relogin, ou vínculo de um novo provider a um usuário que já se cadastrou antes) THEN o sistema SHALL NÃO enfileirar e-mail de boas-vindas. +4. WHEN o e-mail de boas-vindas é renderizado THEN ele SHALL conter o nome do usuário e o HTML do template de boas-vindas. +5. WHEN o e-mail de boas-vindas é renderizado THEN ele SHALL incluir um botão "Acessar plataforma" cujo link aponta para `FRONTEND_URL` (env já existente, reusada). +6. WHEN o enfileiramento do e-mail de boas-vindas falha THEN o fluxo de origem (registro OU login social) SHALL concluir normalmente mesmo assim (falha de e-mail é logada, não propagada). -**Independent Test**: Executar `AuthService.register` com fila mockada e asseverar que um job "welcome" foi enfileirado com o `to`/nome corretos; forçar erro no enqueue e asseverar que o registro ainda retorna sucesso. +**Independent Test**: (a) Executar `CredentialsService.register` com fila mockada e asseverar que um job "welcome" foi enfileirado com o `to`/nome corretos. (b) Executar `AuthService.handleCallback` para um perfil OAuth sem usuário correspondente e asseverar que boas-vindas é enfileirado; repetir para um usuário já existente (achado por provider ou por e-mail) e asseverar que **não** é enfileirado. (c) Em ambos os fluxos, forçar erro no envio e asseverar que a operação de origem ainda conclui com sucesso. --- @@ -85,6 +87,8 @@ A plataforma passou a ter domínio próprio e precisa enviar e-mails transaciona - WHEN as variáveis de ambiente de e-mail (ex: `EMAIL_API_KEY`) estão ausentes THEN o sistema SHALL logar aviso e tratar o envio como no-op, sem derrubar o boot da API. - WHEN o Valkey está indisponível no momento do enqueue THEN o `emailService.send` SHALL logar erro e não propagar exceção que quebre o fluxo de negócio chamador. - WHEN dois registros do mesmo usuário disparam boas-vindas THEN cada envio é um job independente (sem dedup no MVP — aceitável). +- WHEN um usuário que já se cadastrou por e-mail/senha faz login social pela primeira vez THEN o provider é vinculado à conta existente e boas-vindas NÃO é reenviado (usuário não é considerado novo). +- WHEN um usuário faz relogin social (conta já vinculada ao provider) THEN nenhum e-mail de boas-vindas é enfileirado. - WHEN o template referenciado não existe no registry de templates THEN o enqueue SHALL falhar com erro de validação (não gerar job órfão). --- @@ -98,15 +102,17 @@ A plataforma passou a ter domínio próprio e precisa enviar e-mails transaciona | EMAIL-03 | P1 API interna — retry com backoff + log no fracasso | Design | Pending | | EMAIL-04 | P1 API interna — provider trocável via interface | Design | Pending | | EMAIL-05 | P1 API interna — validação de `to`/`template` no enqueue | Design | Pending | -| EMAIL-06 | P1 Boas-vindas — enfileira no registro | Design | Pending | +| EMAIL-06 | P1 Boas-vindas — enfileira no registro por e-mail/senha | Design | Pending | | EMAIL-07 | P1 Boas-vindas — template com nome/HTML | Design | Pending | -| EMAIL-08 | P1 Boas-vindas — falha não quebra registro | Design | Pending | +| EMAIL-08 | P1 Boas-vindas — falha não quebra fluxo de origem | Design | Pending | | EMAIL-09 | Edge — env ausente vira no-op sem quebrar boot | Design | Pending | | EMAIL-10 | Edge — Valkey down no enqueue não propaga | Design | Pending | | EMAIL-11 | Doc — documentação de uso no repositório | Design | Pending | +| EMAIL-12 | P1 Boas-vindas — enfileira no primeiro login social (Google/GitHub/LinkedIn, usuário novo) | Design | Pending | +| EMAIL-13 | P1 Boas-vindas — não reenvia em relogin/vínculo de provider a usuário existente | Design | Pending | **ID format:** `EMAIL-NN` · **Status:** Pending → In Design → In Tasks → Implementing → Verified -**Coverage:** 11 total, 0 mapeados a tasks (Tasks ainda não iniciada). +**Coverage:** 13 total, 0 mapeados a tasks (Tasks ainda não iniciada). --- diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index 2bd8384..b5f5dba 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -1,4 +1,6 @@ import { User } from "../../db/schema/users"; +import { logError } from "../../logger"; +import { emailService } from "../email/email.service"; import type { AuthCallbackParams, OAuthProfile, @@ -39,11 +41,16 @@ export class AuthService { throw new Error("oauth_email_required"); } - const user = await findOrCreateUser({ + const { user, isNewUser } = await findOrCreateUser({ provider, profile, }); + // Boas-vindas apenas no primeiro login social (usuário recém-criado). + if (isNewUser) { + await this.sendWelcomeEmail(user); + } + const session = await this.createSession(user); return { @@ -52,6 +59,26 @@ export class AuthService { }; } + /** + * Dispara o e-mail de boas-vindas para um usuário recém-criado via login + * social. Falha nunca derruba o login (EMAIL-08): erro é apenas logado. + */ + private async sendWelcomeEmail(user: User): Promise { + if (!user.email) return; + + try { + await emailService.sendWelcome({ + email: user.email, + name: user.displayName ?? user.username, + }); + } catch (error) { + logError("Falha ao disparar e-mail de boas-vindas no login social.", { + userId: user.id, + error: error instanceof Error ? error.message : String(error), + }); + } + } + async getProfileFromProvider({ provider, code, diff --git a/backend/src/modules/users/functions/findOrCreateUser.ts b/backend/src/modules/users/functions/findOrCreateUser.ts index 98752e0..0e009b6 100644 --- a/backend/src/modules/users/functions/findOrCreateUser.ts +++ b/backend/src/modules/users/functions/findOrCreateUser.ts @@ -9,6 +9,16 @@ type FindOrCreateUserParams = { profile: OAuthProfile; }; +/** + * Resultado do `findOrCreateUser`. `isNewUser` distingue o primeiro login + * social (conta recém-criada) de um relogin ou da vinculação de um provider + * a um usuário que já existia — usado para disparar boas-vindas só uma vez. + */ +export type FindOrCreateUserResult = { + user: Awaited>; + isNewUser: boolean; +}; + function mapOAuthProfileToCreateUserParams( profile: OAuthProfile, ): CreateUserParams { @@ -25,14 +35,16 @@ function mapOAuthProfileToCreateUserParams( export async function findOrCreateUser({ provider, profile, -}: FindOrCreateUserParams) { +}: FindOrCreateUserParams): Promise { return db.transaction(async (tx) => { const existingByProvider = await findUserByProvider( { provider, providerAccountId: profile.id }, tx, ); - if (existingByProvider) return existingByProvider; + // Relogin social: conta já existia para este provider. + if (existingByProvider) + return { user: existingByProvider, isNewUser: false }; if (profile.email) { const existingByEmail = await findUserByEmail(profile.email, tx); @@ -47,7 +59,9 @@ export async function findOrCreateUser({ tx, ); - return existingByEmail; + // Usuário já existia (ex.: cadastro por e-mail/senha) — só vincula o + // provider, sem reenviar boas-vindas. + return { user: existingByEmail, isNewUser: false }; } } @@ -66,6 +80,7 @@ export async function findOrCreateUser({ tx, ); - return newUser; + // Primeiro login social: usuário recém-criado. + return { user: newUser, isNewUser: true }; }); } diff --git a/backend/tests/unit/modules/auth/auth.service.test.ts b/backend/tests/unit/modules/auth/auth.service.test.ts index a379f0e..9382037 100644 --- a/backend/tests/unit/modules/auth/auth.service.test.ts +++ b/backend/tests/unit/modules/auth/auth.service.test.ts @@ -4,6 +4,13 @@ const mocks = vi.hoisted(() => ({ getAuthUrl: vi.fn(), exchangeCode: vi.fn(), findOrCreateUser: vi.fn(), + sendWelcome: vi.fn(), +})); + +vi.mock("../../../../src/modules/email/email.service", () => ({ + emailService: { + sendWelcome: mocks.sendWelcome, + }, })); vi.mock("../../../../src/modules/auth/providers/auth.provider", () => ({ @@ -113,7 +120,51 @@ describe("AuthService", () => { describe("handleCallback", () => { it("returns user and session on success", async () => { mocks.exchangeCode.mockResolvedValueOnce(mockProfile); - mocks.findOrCreateUser.mockResolvedValueOnce(mockUser); + mocks.findOrCreateUser.mockResolvedValueOnce({ + user: mockUser, + isNewUser: false, + }); + + const result = await service.handleCallback(validCallbackParams); + + expect(result.user).toEqual(mockUser); + expect(result.session).toEqual({ userId: "uuid-123", role: "user" }); + }); + + it("enfileira boas-vindas quando é o primeiro login social (usuário novo)", async () => { + mocks.exchangeCode.mockResolvedValueOnce(mockProfile); + mocks.findOrCreateUser.mockResolvedValueOnce({ + user: mockUser, + isNewUser: true, + }); + + await service.handleCallback(validCallbackParams); + + expect(mocks.sendWelcome).toHaveBeenCalledWith({ + email: mockUser.email, + name: mockUser.username, + }); + }); + + it("não enfileira boas-vindas quando o usuário já existia (relogin/vínculo)", async () => { + mocks.exchangeCode.mockResolvedValueOnce(mockProfile); + mocks.findOrCreateUser.mockResolvedValueOnce({ + user: mockUser, + isNewUser: false, + }); + + await service.handleCallback(validCallbackParams); + + expect(mocks.sendWelcome).not.toHaveBeenCalled(); + }); + + it("conclui o login mesmo se o envio de boas-vindas falhar", async () => { + mocks.exchangeCode.mockResolvedValueOnce(mockProfile); + mocks.findOrCreateUser.mockResolvedValueOnce({ + user: mockUser, + isNewUser: true, + }); + mocks.sendWelcome.mockRejectedValueOnce(new Error("valkey down")); const result = await service.handleCallback(validCallbackParams); @@ -153,7 +204,10 @@ describe("AuthService", () => { it("calls findOrCreateUser with provider and profile", async () => { mocks.exchangeCode.mockResolvedValueOnce(mockProfile); - mocks.findOrCreateUser.mockResolvedValueOnce(mockUser); + mocks.findOrCreateUser.mockResolvedValueOnce({ + user: mockUser, + isNewUser: false, + }); await service.handleCallback(validCallbackParams); diff --git a/backend/tests/unit/modules/users/functions/users.functions.test.ts b/backend/tests/unit/modules/users/functions/users.functions.test.ts index 1e82663..9877215 100644 --- a/backend/tests/unit/modules/users/functions/users.functions.test.ts +++ b/backend/tests/unit/modules/users/functions/users.functions.test.ts @@ -138,7 +138,7 @@ describe("findOrCreateUser", () => { profile: oauthProfile, }); - expect(result).toMatchObject(mockUser); + expect(result).toEqual({ user: mockUser, isNewUser: false }); }); it("encontra por email, cria account e retorna usuário existente", async () => { @@ -152,7 +152,7 @@ describe("findOrCreateUser", () => { profile: oauthProfile, }); - expect(result).toMatchObject(mockUser); + expect(result).toEqual({ user: mockUser, isNewUser: false }); expect(mocks.createAccountMock).toHaveBeenCalledWith( { userId: mockUser.id, provider: "github", profile: oauthProfile }, expect.anything(), @@ -187,7 +187,7 @@ describe("findOrCreateUser", () => { { userId: mockUser.id, provider: "github", profile: oauthProfile }, expect.anything(), ); - expect(result).toMatchObject(mockUser); + expect(result).toEqual({ user: mockUser, isNewUser: true }); }); it("pula a busca por email quando profile.email é undefined", async () => { From 63cac0024e5bcb49a1ea0f8d4a9856d9572096f2 Mon Sep 17 00:00:00 2001 From: Jeremias Santos Date: Sun, 2 Aug 2026 13:23:16 -0300 Subject: [PATCH 17/18] feat(email): renomeia projeto para "Candidate" na mensagem de boas-vindas Atualiza subject, preview e corpo do e-mail de boas-vindas de "Painel Vagas" para "Candidate", refletindo o novo nome do projeto. Co-Authored-By: Claude Opus 4.8 --- backend/src/modules/email/templates/registry.ts | 2 +- backend/src/modules/email/templates/welcome.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/src/modules/email/templates/registry.ts b/backend/src/modules/email/templates/registry.ts index a850b23..66f2d78 100644 --- a/backend/src/modules/email/templates/registry.ts +++ b/backend/src/modules/email/templates/registry.ts @@ -9,7 +9,7 @@ import { Welcome, WelcomeProps } from "./welcome"; */ const templates = { welcome: { - subject: "Bem-vindo ao Painel Vagas", + subject: "Bem-vindo ao Candidate", component: Welcome, }, } satisfies Record< diff --git a/backend/src/modules/email/templates/welcome.tsx b/backend/src/modules/email/templates/welcome.tsx index 198d58f..9f903c3 100644 --- a/backend/src/modules/email/templates/welcome.tsx +++ b/backend/src/modules/email/templates/welcome.tsx @@ -12,12 +12,12 @@ export interface WelcomeProps { */ export function Welcome({ name, appUrl }: WelcomeProps) { return ( - +

Bem-vindo, {name}!

- Sua conta no Painel Vagas foi criada com sucesso. Agora você pode + Sua conta no Candidate foi criada com sucesso. Agora você pode acompanhar vagas, salvar oportunidades e organizar sua busca por emprego.

Date: Sun, 2 Aug 2026 21:35:35 -0300 Subject: [PATCH 18/18] fix(deps): sincroniza package-lock com npm 10 (CI node 22) Adiciona @emnapi/core e @emnapi/runtime que faltavam no lock, fazendo `npm ci` passar no CI (node 22 / npm 10). O lock havia sido atualizado com npm 11, que resolve deps opcionais napi/wasm de forma diferente. Co-Authored-By: Claude Opus 4.8 --- package-lock.json | 114 ++++++++++++++++++++++++++++++++-------------- 1 file changed, 80 insertions(+), 34 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5272d6f..61d1c8f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -504,7 +504,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -585,7 +584,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -1409,7 +1407,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -1433,7 +1430,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -1653,7 +1649,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -1970,6 +1965,7 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, + "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -1991,6 +1987,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -2000,6 +1997,27 @@ "node": ">=14.14" } }, + "node_modules/@emnapi/core": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", + "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.3", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", @@ -2046,6 +2064,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -2216,6 +2235,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -2454,6 +2474,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -4727,7 +4748,6 @@ "resolved": "https://registry.npmjs.org/@react-email/render/-/render-2.1.0.tgz", "integrity": "sha512-F+zE3O6d6sW6Aj2UjvZAA17R7tJKM7kcq2mgV6k4HCT8jeLLFaVP2txMtH1lgqYFRMZ0Gxsd37q2PRyiXLXXxA==", "license": "MIT", - "peer": true, "dependencies": { "entities": "^4.5.0", "html-to-text": "^9.0.5", @@ -4759,7 +4779,6 @@ "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.12.1.tgz", "integrity": "sha512-7aPGWeqA3uFm43o19umzdl16CEjK/JQGtSXVPevplTaOU3VJA/rseBC1QvYUz9lLDIMBimc4SW/zrW4S89BaCA==", "license": "MIT", - "peer": true, "dependencies": { "cluster-key-slot": "1.1.2" }, @@ -5628,7 +5647,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/body-parser": { "version": "1.19.6", @@ -5805,7 +5825,6 @@ "integrity": "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } @@ -5870,7 +5889,6 @@ "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -5881,7 +5899,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -6010,7 +6027,6 @@ "integrity": "sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.63.0", "@typescript-eslint/types": "8.63.0", @@ -6477,7 +6493,6 @@ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -7334,7 +7349,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.42", "caniuse-lite": "^1.0.30001800", @@ -8385,7 +8399,6 @@ "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz", "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", "license": "MIT", - "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -8471,7 +8484,8 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/cross-env": { "version": "10.1.0", @@ -8922,7 +8936,6 @@ "integrity": "sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "26.15.3", "builder-util": "26.15.3", @@ -8947,7 +8960,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dom-serializer": { "version": "2.0.0", @@ -9582,6 +9596,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -9602,6 +9617,7 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -9617,6 +9633,7 @@ "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", "dev": true, "license": "MIT", + "peer": true, "optionalDependencies": { "graceful-fs": "^4.1.6" } @@ -9627,6 +9644,7 @@ "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 4.0.0" } @@ -9901,7 +9919,6 @@ "devOptional": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -10303,6 +10320,7 @@ "os": [ "aix" ], + "peer": true, "engines": { "node": ">=18" } @@ -10320,6 +10338,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -10337,6 +10356,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -10354,6 +10374,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -10371,6 +10392,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -10388,6 +10410,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -10405,6 +10428,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -10422,6 +10446,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -10439,6 +10464,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -10456,6 +10482,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -10473,6 +10500,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -10490,6 +10518,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -10507,6 +10536,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -10524,6 +10554,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -10541,6 +10572,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -10558,6 +10590,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -10575,6 +10608,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -10592,6 +10626,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -10609,6 +10644,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -10626,6 +10662,7 @@ "os": [ "openharmony" ], + "peer": true, "engines": { "node": ">=18" } @@ -10643,6 +10680,7 @@ "os": [ "sunos" ], + "peer": true, "engines": { "node": ">=18" } @@ -10660,6 +10698,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -10677,6 +10716,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -10715,7 +10755,6 @@ "integrity": "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==", "dev": true, "license": "MIT", - "peer": true, "workspaces": [ "packages/*" ], @@ -11191,7 +11230,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -12097,7 +12135,6 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.28.tgz", "integrity": "sha512-YwUvVpSF7m1yOblFPrU3Hbo8XhPheBoiyfGuII6z19LnOr6JpDnyyp7LFNrfV56wS8tpvtBFGRISHN02pDdLOA==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -12851,7 +12888,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "devOptional": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -13145,6 +13181,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -13165,6 +13202,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -13185,6 +13223,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -13205,6 +13244,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -13225,6 +13265,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -13245,6 +13286,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -13265,6 +13307,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -13285,6 +13328,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -13305,6 +13349,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -13325,6 +13370,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -13345,6 +13391,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -13699,6 +13746,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -13968,6 +14016,7 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -14966,7 +15015,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.14.0", "pg-pool": "^3.14.0", @@ -15287,7 +15335,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", @@ -15477,6 +15524,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -15530,6 +15578,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -15545,6 +15594,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -15939,7 +15989,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -15949,7 +15998,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -15971,7 +16019,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-phone-number-input": { "version": "3.4.17", @@ -16206,7 +16255,6 @@ "resolved": "https://registry.npmjs.org/redis/-/redis-5.12.1.tgz", "integrity": "sha512-LDsoVvb/CpoV9EN3FXvgvSHNJWuCIzl9MiO3ppOevuGLpSGJhwfQjpEwfFJcQvNSddHADDdZaWx0HnmMxRXG7g==", "license": "MIT", - "peer": true, "dependencies": { "@redis/bloom": "5.12.1", "@redis/client": "5.12.1", @@ -16417,6 +16465,7 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -16431,6 +16480,7 @@ "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", + "peer": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -17615,6 +17665,7 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -17774,7 +17825,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -17964,7 +18014,6 @@ "integrity": "sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.28.0" }, @@ -18058,7 +18107,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -18330,7 +18378,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", @@ -18894,7 +18941,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" }