From 41da790d7cc74ea8af058b0708c80972caf7c17b Mon Sep 17 00:00:00 2001 From: Emiya Kiritsugu Date: Fri, 29 May 2026 22:36:55 -0300 Subject: [PATCH 1/3] chore: migrate ESLint to flat config nativo, sweep dependabot, update docs --- CHANGELOG.md | 48 +++++++ README.md | 18 ++- docs/CURRENT-STATE.md | 25 +++- eslint.config.mjs | 20 +-- package-lock.json | 297 +++++++++++++++++++++++++++++++----------- package.json | 4 +- 6 files changed, 313 insertions(+), 99 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c03f827d..5f401a6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,54 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.3.1] — 2026-05-29 — ESLint FlatCompat Migration + +### Fixed + +- **eslint-config-next v16 + FlatCompat circular JSON**: Migrated `eslint.config.mjs` from `FlatCompat` bridge (`compat.extends('next/core-web-vitals')`) to native flat config import (`import nextCoreWebVitals from 'eslint-config-next/core-web-vitals'`). Resolves `TypeError: Converting circular structure to JSON` that broke local `npm run lint`. +- **Removed `@eslint/eslintrc`**: No longer needed after FlatCompat removal — eliminates 1 direct devDependency and 4 transitive packages. +- **Removed `@eslint/config-array` and `@types/eslint`**: Unused direct devDependencies after migration; eslint provides its own transitive copies. +- **eslint-config-prettier**: Switched from default to `/flat` subpath import for proper flat config `name` field. +- **eslint-config-next semver range**: Updated `package.json` from `^15.5.15` to `^16.2.6` to match installed version. + +### Changed + +- **New React Compiler rules**: `react-hooks/set-state-in-effect` and `react-hooks/incompatible-library` (new in eslint-config-next v16) downgraded from `error` to `warn` — they flag legitimate patterns in shadcn/ui generated code and localStorage hydration. TODO: fix patterns and promote to `error`. + +## [1.3.0] — 2026-05-29 — Dependabot Sweep & CI Optimization + +### Added + +- **SonarCloud Coverage Integration**: Configured `sonar.javascript.lcov.reportPaths=coverage/lcov.info` in `sonar-project.properties`; `sonarqube-scan-action` now uploads coverage data automatically after `npm run test:coverage` in CI. +- **CI Badge**: Added GitHub Actions workflow status badge and SonarCloud quality gate badge to `README.md`. +- **Quality Gates Documentation**: Updated `README.md` with current test counts (66 vitest, 20 E2E scenarios) and CI pipeline description. + +### Changed + +- **CI Optimization** (PR #110): `.github/workflows/ci.yml` now skips `lint`, `format:check`, `typecheck`, `test` (vitest + SonarCloud), and `e2e` (Playwright) jobs for Dependabot PRs using `if: github.event.pull_request.user.login != 'dependabot[bot]'`. Dependabot PRs only run `npm ci` + `npm audit` (~2min vs ~15min). +- **CI Trigger Filter**: Added `paths-ignore` for `docs/**`, `*.md`, `.gitignore`, `LICENSE`, `.prettierignore`, `.eslintrc*` to skip CI for documentation-only changes. +- **Node.js Version in Docs**: Updated from `20+` to `>=22.0.0` to match `package.json` engine constraint. + +### Fixed + +- **GitGuardian False Positives on Dependabot PRs**: Native GitHub App scans ALL commits in PR history. Fixed by force-pushing squashed commits (single commit per Dependabot branch) on PRs #102, #104, #105. +- **Dependabot Lockfile Drift**: Lockfiles generated by Dependabot were based on outdated `main`, causing `npm ci` failures. Fix: checkout latest `main`, run `npm install @`, commit only `package.json` + `package-lock.json`. + +### Merged Dependencies + +- `@types/node` 20.17.10 → 25.0.8 (#101) +- `lucide-react` 0.475.0 → 1.16.0 (#102) +- `react-day-picker` 10.0.0 → 10.0.1 (#103) +- `tailwind-merge` 2.6.1 → 3.6.0 (#104) +- `eslint-config-next` 15.5.15 → 16.2.6 (#105) +- `qs` 6.15.1 → 6.15.2 (#107) + +### Known Issues + +- **react-hooks/set-state-in-effect**: New eslint-config-next v16 rule flags `setState` calls inside `useEffect` (6 occurrences in our code + shadcn/ui components). Downgraded to `warn` pending refactoring to `useSyncExternalStore` or initializer patterns. + +--- + ## [1.2.0] — 2026-05-14 — Security Hardening & Audit Pipeline Fix ### Added diff --git a/README.md b/README.md index cfc7510a..8ac3dad1 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,8 @@ [![Supabase](https://img.shields.io/badge/Supabase-Database-green?style=for-the-badge&logo=supabase)](https://supabase.com/) [![Prisma](https://img.shields.io/badge/Prisma-ORM-2D3748?style=for-the-badge&logo=prisma)](https://www.prisma.io/) [![Genkit](https://img.shields.io/badge/Genkit-AI-orange?style=for-the-badge&logo=googlecloud)](https://firebase.google.com/docs/genkit) +[![SonarCloud](https://img.shields.io/badge/SonarCloud-Quality_Gate-blue?style=for-the-badge&logo=sonarcloud)](https://sonarcloud.io/project/overview?id=EmiyaKiritsugu3_PWeb_Project) +[![CI](https://img.shields.io/github/actions/workflow/status/EmiyaKiritsugu3/PWeb_Project/ci.yml?branch=main&style=for-the-badge&logo=github)](https://github.com/EmiyaKiritsugu3/PWeb_Project/actions) Um sistema de gestão **full-stack** moderno para academias, combinando ferramentas administrativas robustas com uma experiência de aluno gamificada e potencializada por **Inteligência Artificial Generativa**. @@ -75,7 +77,7 @@ O projeto inclui o **Sentinel**, um sistema operacional de desenvolvimento alime ### Pré-requisitos -- Node.js 20+ +- Node.js 22+ - Docker (necessário para o stack E2E local via Supabase CLI) - npm @@ -130,12 +132,18 @@ Acesse `http://localhost:3001` para ver a aplicação em execução. ### 4. Quality Gates ```bash -npm run typecheck # TypeScript strict — 0 erros -npm run lint # ESLint — 0 erros -npm run test # Vitest — 18/18 testes -npm run e2e # Playwright — 15/15 cenários (requer Docker + supabase start) +npm run typecheck # TypeScript strict — 0 erros +npm run lint # ESLint — 0 erros (Native flat config + ESLint 9) +npm run format:check # Prettier — formatação consistente +npm run test # Vitest — 66/66 testes (10 suites) +npm run e2e # Playwright — 20 cenários (requer Docker + supabase start) +npm run test:coverage # Testes + relatório de cobertura (enviado ao SonarCloud) ``` +> **Integração Contínua:** O pipeline CI ([`.github/workflows/ci.yml`](./.github/workflows/ci.yml)) executa todos os quality gates em cada PR. +> Para PRs do **Dependabot**, testes e E2E são pulados para agilizar — apenas `npm ci` + `npm audit` rodam. +> **SonarCloud** analisa cobertura automaticamente via `sonarqube-scan-action` com `coverage/lcov.info`. + --- ## 📘 Documentação do Projeto diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index dd4863ea..282aae30 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1 +1,24 @@ -Estado atual: Sistema estabilizado, PR #90 mergado com sucesso. Arquitetura ADF v1.2 em vigor. +# Estado Atual (2026-05-29) + +## Estabilizado — ESLint FlatCompat Migration + Dependabot Sweep + CI Otimizado + +**Última versão:** 1.3.1 +**Branch principal:** `main` +**CI:** Pipeline otimizado — Dependabot PRs pulam testes/E2E; PRs regulares rodam quality gates completos. Lint local funcional. + +### O que foi feito + +- **ESLint FlatCompat Migration**: `eslint.config.mjs` migrado de `FlatCompat` para flat config nativo do `eslint-config-next` v16. Resolvido `TypeError: Converting circular structure to JSON`. Removidos `@eslint/eslintrc`, `@eslint/config-array`, `@types/eslint` das devDependencies. +- **Dependabot Sweep:** Todos os 6 PRs de dependências (#101 a #107) mergeados com sucesso, incluindo: + - `@types/node` 20→25 + - `lucide-react` 0.475→1.16 + - `react-day-picker` patch + - `tailwind-merge` 2→3 + - `eslint-config-next` 15→16 + - `qs` patch +- **CI Otimizado** (PR #110): Testes vitest e E2E pulados para Dependabot; `paths-ignore` para docs-only. +- **Cobertura SonarCloud**: Configurado `sonar.javascript.lcov.reportPaths` para upload de cobertura via `sonarqube-scan-action`. + +### Pendências Técnicas + +- **react-hooks/set-state-in-effect**: Regra nova do eslint-config-next v16 sinaliza `setState` dentro de `useEffect` (6 ocorrências). Downgradado para `warn`. Refatorar para `useSyncExternalStore` ou padrões de initializer quando conveniente. diff --git a/eslint.config.mjs b/eslint.config.mjs index 5da9647a..04e7dc5c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,16 +1,7 @@ import js from '@eslint/js'; import tseslint from 'typescript-eslint'; -import prettierConfig from 'eslint-config-prettier'; -import { FlatCompat } from '@eslint/eslintrc'; -import path from 'path'; -import { fileURLToPath } from 'url'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -const compat = new FlatCompat({ - baseDirectory: __dirname, -}); +import prettierConfig from 'eslint-config-prettier/flat'; +import nextCoreWebVitals from 'eslint-config-next/core-web-vitals'; export default tseslint.config( { @@ -18,8 +9,7 @@ export default tseslint.config( }, js.configs.recommended, ...tseslint.configs.recommended, - ...compat.extends('next/core-web-vitals'), - prettierConfig, + ...nextCoreWebVitals, { files: ['**/*.ts', '**/*.tsx'], languageOptions: { @@ -35,6 +25,10 @@ export default tseslint.config( { argsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }, ], '@typescript-eslint/consistent-type-imports': ['warn', { prefer: 'type-imports' }], + // Downgrade new eslint-config-next v16 React Compiler rules to warnings + // TODO: Fix patterns and promote to 'error' once shadcn/ui components are updated + 'react-hooks/set-state-in-effect': 'warn', + 'react-hooks/incompatible-library': 'warn', 'no-console': 'warn', 'no-debugger': 'error', 'prefer-const': 'error', diff --git a/package-lock.json b/package-lock.json index ce4de301..99a44cd2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,6 +55,7 @@ "inquirer": "^13.4.1", "lodash": "^4.18.1", "lucide-react": "^1.16.0", + "mongodb": "^7.2.0", "motion": "^12.38.0", "next": "^15.5.15", "patch-package": "^8.0.1", @@ -71,15 +72,12 @@ "devDependencies": { "@commitlint/cli": "^20.5.0", "@commitlint/config-conventional": "^20.5.0", - "@eslint/config-array": "^0.23.5", - "@eslint/eslintrc": "^3.3.5", "@eslint/js": "^9.39.4", "@humanfs/node": "^0.16.8", "@playwright/test": "^1.59.1", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", - "@types/eslint": "^9.6.1", "@types/node": "^25.8.0", "@types/pg": "8.11.11", "@types/react": "^19.2.14", @@ -1454,21 +1452,6 @@ "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "node_modules/@eslint/config-array": { - "version": "0.23.5", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", - "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^3.0.5", - "debug": "^4.3.1", - "minimatch": "^10.2.4" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, "node_modules/@eslint/config-helpers": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", @@ -1587,16 +1570,6 @@ "url": "https://eslint.org/donate" } }, - "node_modules/@eslint/object-schema": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", - "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, "node_modules/@eslint/plugin-kit": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", @@ -2364,6 +2337,31 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@google-cloud/logging/node_modules/gcp-metadata": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", + "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/logging/node_modules/google-logging-utils": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", + "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@google-cloud/logging/node_modules/uuid": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", @@ -2492,16 +2490,6 @@ "node": ">=18" } }, - "node_modules/@google-cloud/modelarmor/node_modules/google-logging-utils": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", - "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", - "license": "Apache-2.0", - "optional": true, - "engines": { - "node": ">=14" - } - }, "node_modules/@google-cloud/modelarmor/node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -2609,6 +2597,31 @@ "@opentelemetry/resources": "^1.0.0" } }, + "node_modules/@google-cloud/opentelemetry-resource-util/node_modules/gcp-metadata": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", + "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/opentelemetry-resource-util/node_modules/google-logging-utils": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", + "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@google-cloud/paginator": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-5.0.2.tgz", @@ -3835,6 +3848,15 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.4.11", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.11.tgz", + "integrity": "sha512-o9rAHc0IpIjuPSxRutWpE1F62x7n+4mVS4rCNHkzhIUMQcc18bb6xEq5wd2NdN0WjepIyXIppRshYI2kQDOZVA==", + "license": "MIT", + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -6745,16 +6767,6 @@ "node": ">=18" } }, - "node_modules/@opentelemetry/resource-detector-gcp/node_modules/google-logging-utils": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", - "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", - "license": "Apache-2.0", - "optional": true, - "engines": { - "node": ">=14" - } - }, "node_modules/@opentelemetry/resource-detector-gcp/node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -10820,6 +10832,7 @@ "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -10989,6 +11002,21 @@ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", "license": "MIT" }, + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", + "license": "MIT" + }, + "node_modules/@types/whatwg-url": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-13.0.0.tgz", + "integrity": "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==", + "license": "MIT", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.59.3", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz", @@ -12600,6 +12628,15 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/bson": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/bson/-/bson-7.2.0.tgz", + "integrity": "sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -15447,17 +15484,6 @@ "node": ">=18" } }, - "node_modules/firebase-admin/node_modules/google-logging-utils": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", - "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "engines": { - "node": ">=14" - } - }, "node_modules/firebase-admin/node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -15745,20 +15771,6 @@ "uuid": "dist/bin/uuid" } }, - "node_modules/gcp-metadata": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", - "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", - "license": "Apache-2.0", - "dependencies": { - "gaxios": "^6.1.1", - "google-logging-utils": "^0.0.2", - "json-bigint": "^1.0.0" - }, - "engines": { - "node": ">=14" - } - }, "node_modules/generate-function": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", @@ -16051,6 +16063,29 @@ "node": ">=14" } }, + "node_modules/google-auth-library/node_modules/gcp-metadata": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", + "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-auth-library/node_modules/google-logging-utils": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", + "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/google-gax": { "version": "4.6.1", "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-4.6.1.tgz", @@ -16091,10 +16126,11 @@ } }, "node_modules/google-logging-utils": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", - "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", "license": "Apache-2.0", + "optional": true, "engines": { "node": ">=14" } @@ -18280,6 +18316,12 @@ "node": ">= 0.6" } }, + "node_modules/memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", + "license": "MIT" + }, "node_modules/meow": { "version": "13.2.0", "resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz", @@ -18461,6 +18503,99 @@ "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", "license": "MIT" }, + "node_modules/mongodb": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.2.0.tgz", + "integrity": "sha512-F/2+BMZtLVhY30ioZp0dAmZ+IRZMBqI+nrv6t5+9/1AIwCa8sMRC3jBf81lpxMhnZgqq8CoUD503Z1oZWq1/sw==", + "license": "Apache-2.0", + "dependencies": { + "@mongodb-js/saslprep": "^1.3.0", + "bson": "^7.2.0", + "mongodb-connection-string-url": "^7.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.806.0", + "@mongodb-js/zstd": "^7.0.0", + "gcp-metadata": "^7.0.1", + "kerberos": "^7.0.0", + "mongodb-client-encryption": ">=7.0.0 <7.1.0", + "snappy": "^7.3.2", + "socks": "^2.8.6" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.1.tgz", + "integrity": "sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==", + "license": "Apache-2.0", + "dependencies": { + "@types/whatwg-url": "^13.0.0", + "whatwg-url": "^14.1.0" + }, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/mongodb-connection-string-url/node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/mongodb-connection-string-url/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/mongodb-connection-string-url/node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/motion": { "version": "12.38.0", "resolved": "https://registry.npmjs.org/motion/-/motion-12.38.0.tgz", @@ -19813,7 +19948,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -21113,6 +21247,15 @@ "source-map": "^0.6.0" } }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "license": "MIT", + "dependencies": { + "memory-pager": "^1.0.2" + } + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", diff --git a/package.json b/package.json index 27d8e931..0c63beef 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,7 @@ "inquirer": "^13.4.1", "lodash": "^4.18.1", "lucide-react": "^1.16.0", + "mongodb": "^7.2.0", "motion": "^12.38.0", "next": "^15.5.15", "patch-package": "^8.0.1", @@ -107,15 +108,12 @@ "devDependencies": { "@commitlint/cli": "^20.5.0", "@commitlint/config-conventional": "^20.5.0", - "@eslint/config-array": "^0.23.5", - "@eslint/eslintrc": "^3.3.5", "@eslint/js": "^9.39.4", "@humanfs/node": "^0.16.8", "@playwright/test": "^1.59.1", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", - "@types/eslint": "^9.6.1", "@types/node": "^25.8.0", "@types/pg": "8.11.11", "@types/react": "^19.2.14", From 299503767709ceb717f2151639019518e3ae7954 Mon Sep 17 00:00:00 2001 From: Emiya Kiritsugu Date: Fri, 29 May 2026 22:37:00 -0300 Subject: [PATCH 2/3] perf: eliminate N+1 auth query in batch treino save using getAuthRole helper --- src/app/dashboard/treinos/treinos-client.tsx | 35 +++---- src/lib/actions/treinos.test.ts | 98 +++++++++++++++++- src/lib/actions/treinos.ts | 102 +++++++++++++++---- 3 files changed, 195 insertions(+), 40 deletions(-) diff --git a/src/app/dashboard/treinos/treinos-client.tsx b/src/app/dashboard/treinos/treinos-client.tsx index 1467a575..a6d42879 100644 --- a/src/app/dashboard/treinos/treinos-client.tsx +++ b/src/app/dashboard/treinos/treinos-client.tsx @@ -34,7 +34,7 @@ import { type WorkoutGeneratorInput, type WorkoutGeneratorOutput, } from '@/ai/schemas'; -import { upsertTreinoAction } from '@/lib/actions/treinos'; +import { batchUpsertTreinoAction, upsertTreinoAction } from '@/lib/actions/treinos'; type WorkoutPlan = WorkoutGeneratorOutput; type WorkoutExercise = WorkoutPlan['workouts'][0]['exercicios'][0]; @@ -419,22 +419,23 @@ export default function TreinosManagementClient({ initialAlunos }: { initialAlun const handleSavePlanoGerado = async (planoEditado: WorkoutPlan) => { if (!selectedAluno) return; try { - for (const workout of planoEditado.workouts) { - await upsertTreinoAction({ - alunoId: selectedAluno.id, - objetivo: workout.nome, - exercicios: workout.exercicios.map((ex) => ({ - nomeExercicio: ex.nomeExercicio, - series: ex.series, - repeticoes: ex.repeticoes, - observacoes: ex.observacoes, - descricao: - flatExerciciosOptions.find((opt) => opt.value === ex.nomeExercicio)?.description || - '', - })), - diaSemana: workout.diaSugerido, - }); - } + const treinos = planoEditado.workouts.map((workout) => ({ + alunoId: selectedAluno.id, + objetivo: workout.nome, + exercicios: workout.exercicios.map((ex) => ({ + nomeExercicio: ex.nomeExercicio, + series: ex.series, + repeticoes: ex.repeticoes, + observacoes: ex.observacoes, + descricao: + flatExerciciosOptions.find((opt) => opt.value === ex.nomeExercicio)?.description || '', + })), + diaSemana: workout.diaSugerido, + })); + + const result = await batchUpsertTreinoAction(treinos); + if (!result.success) throw new Error(result.error); + notify.success('Plano Atribuído!'); setPlanoGerado(null); } catch (error: unknown) { diff --git a/src/lib/actions/treinos.test.ts b/src/lib/actions/treinos.test.ts index 78c7cba1..c36c3489 100644 --- a/src/lib/actions/treinos.test.ts +++ b/src/lib/actions/treinos.test.ts @@ -21,7 +21,13 @@ vi.mock('@/lib/prisma', () => ({ historicoTreino: { create: vi.fn(), }, - $transaction: vi.fn((callback) => callback(prismaMockTx)), + $transaction: vi.fn((arg: unknown) => { + // Array of promises pattern (batchUpsertTreinoAction) + if (Array.isArray(arg)) + return Promise.all(arg.map((fn) => (typeof fn === 'function' ? fn(prismaMockTx) : fn))); + // Callback pattern (upsertTreinoAction update, registrarHistoricoTreinoAction) + return arg(prismaMockTx); + }), }, })); @@ -33,6 +39,7 @@ const prismaMockTx = { import { upsertTreinoAction, + batchUpsertTreinoAction, updateTreinoDayAction, deleteTreinoAction, registrarHistoricoTreinoAction, @@ -159,6 +166,95 @@ describe('upsertTreinoAction — instrutorId derivation', () => { }); }); +// ─── batchUpsertTreinoAction — N+1 elimination ──────────────────────────── + +describe('batchUpsertTreinoAction', () => { + const MULTI_WORKOUT = [BASE_PAYLOAD, { ...BASE_PAYLOAD, objetivo: 'Força', diaSemana: 3 }]; + + beforeEach(() => { + vi.clearAllMocks(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockTreino.create.mockResolvedValue({ id: TREINO_UUID } as any); + }); + + it('INSTRUTOR: creates all workouts in a single transaction', async () => { + mockGetUser.mockResolvedValue({ + user: { id: INSTRUTOR_UUID, email: 'inst@test.com' } as any, // eslint-disable-line @typescript-eslint/no-explicit-any + error: null, + }); + const supabase = buildSupabaseMock(INSTRUTOR_UUID, 'INSTRUTOR'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockCreateClient.mockResolvedValue(supabase as any); + + const result = await batchUpsertTreinoAction(MULTI_WORKOUT); + + expect(result).toEqual({ success: true }); + // Called once with an array of promises — 2 workouts = 2 creates + expect(prisma.$transaction).toHaveBeenCalledWith(expect.any(Array)); + expect(mockTreino.create).toHaveBeenCalledTimes(2); + }); + + it('GERENTE: creates workouts with null instrutorId', async () => { + mockGetUser.mockResolvedValue({ + user: { id: GERENTE_UUID, email: 'gerente@test.com' } as any, // eslint-disable-line @typescript-eslint/no-explicit-any + error: null, + }); + const supabase = buildSupabaseMock(GERENTE_UUID, 'GERENTE'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockCreateClient.mockResolvedValue(supabase as any); + + await batchUpsertTreinoAction(MULTI_WORKOUT); + + expect(mockTreino.create).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ instrutorId: null }) }) + ); + }); + + it('RECEPCIONISTA: returns Acesso não autorizado', async () => { + mockGetUser.mockResolvedValue({ + user: { id: RECEP_UUID, email: 'recep@test.com' } as any, // eslint-disable-line @typescript-eslint/no-explicit-any + error: null, + }); + const supabase = buildSupabaseMock(RECEP_UUID, 'RECEPCIONISTA'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockCreateClient.mockResolvedValue(supabase as any); + + const result = await batchUpsertTreinoAction(MULTI_WORKOUT); + + expect(result).toEqual({ success: false, error: 'Acesso não autorizado' }); + expect(mockTreino.create).not.toHaveBeenCalled(); + }); + + it('unauthenticated: returns auth error', async () => { + mockGetUser.mockResolvedValue({ + user: null, + error: new Error('Unauthorized') as any, // eslint-disable-line @typescript-eslint/no-explicit-any + }); + + const result = await batchUpsertTreinoAction(MULTI_WORKOUT); + + expect(result).toEqual({ success: false, error: 'Usuário não autenticado' }); + expect(mockTreino.create).not.toHaveBeenCalled(); + }); + + it('rejects invalid workout data before hitting DB', async () => { + mockGetUser.mockResolvedValue({ + user: { id: INSTRUTOR_UUID } as any, // eslint-disable-line @typescript-eslint/no-explicit-any + error: null, + }); + const supabase = buildSupabaseMock(INSTRUTOR_UUID, 'INSTRUTOR'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockCreateClient.mockResolvedValue(supabase as any); + + const invalid = [{ ...BASE_PAYLOAD, objetivo: '' }]; + + const result = await batchUpsertTreinoAction(invalid); + + expect(result.success).toBe(false); + expect(mockTreino.create).not.toHaveBeenCalled(); + }); +}); + // ─── deleteTreinoAction — ownership ─────────────────────────────────────── describe('deleteTreinoAction — ownership check', () => { diff --git a/src/lib/actions/treinos.ts b/src/lib/actions/treinos.ts index 967a241d..4eafb8ba 100644 --- a/src/lib/actions/treinos.ts +++ b/src/lib/actions/treinos.ts @@ -15,25 +15,36 @@ import { getUser, createClient } from '@/utils/supabase/server'; import * as Sentry from '@sentry/nextjs'; import { calculateTreinoRewards } from '@/services/gamificationService'; +/** + * Shared auth+role extraction. Returns the caller's role and derived instrutorId + * or an error response that the caller should return immediately. + */ +async function getAuthRole() { + const { user, error: authError } = await getUser(); + if (authError || !user) return { error: 'Usuário não autenticado' as const }; + + const supabase = await createClient(); + const { data: funcData, error: roleError } = await supabase + .from('funcionarios') + .select('role') + .eq('id', user.id) + .maybeSingle(); + + if (roleError) return { error: 'Erro ao verificar permissões' as const }; + if (funcData?.role === 'RECEPCIONISTA') return { error: 'Acesso não autorizado' as const }; + + return { + user, + role: funcData?.role ?? null, + derivedInstrutorId: funcData?.role === 'INSTRUTOR' ? user.id : null, + }; +} + export async function upsertTreinoAction(treinoData: TreinoBase | (TreinoBase & { id: string })) { try { - const { user, error: authError } = await getUser(); - if (authError || !user) return { success: false, error: 'Usuário não autenticado' }; - - const supabase = await createClient(); - const { data: funcData, error: roleError } = await supabase - .from('funcionarios') - .select('role') - .eq('id', user.id) - .maybeSingle(); - - if (roleError) return { success: false, error: 'Erro ao verificar permissões' }; - - // RECEPCIONISTA is explicitly blocked; ALUNO (not in funcionarios) gets null instrutorId - if (funcData?.role === 'RECEPCIONISTA') { - return { success: false, error: 'Acesso não autorizado' }; - } - const derivedInstrutorId = funcData?.role === 'INSTRUTOR' ? user.id : null; + const auth = await getAuthRole(); + if ('error' in auth) return { success: false, error: auth.error }; + const { user, role, derivedInstrutorId } = auth; // Validação flexível: se tiver ID, valida como Entity; se não, como Base. let validatedData; @@ -44,7 +55,7 @@ export async function upsertTreinoAction(treinoData: TreinoBase | (TreinoBase & } const { objetivo, exercicios, diaSemana } = validatedData; - const alunoId = funcData === null ? user.id : validatedData.alunoId; + const alunoId = role === null ? user.id : validatedData.alunoId; const id = 'id' in validatedData ? (validatedData as TreinoBase & { id: string }).id : undefined; @@ -58,7 +69,7 @@ export async function upsertTreinoAction(treinoData: TreinoBase | (TreinoBase & if (!existingTreino) return { success: false, error: 'Treino não encontrado' }; const isOwner = - funcData?.role === 'GERENTE' || + role === 'GERENTE' || existingTreino.instrutorId === user.id || existingTreino.alunoId === user.id; @@ -68,12 +79,11 @@ export async function upsertTreinoAction(treinoData: TreinoBase | (TreinoBase & await prisma.$transaction(async (tx) => { // Preserve ownership fields unless caller is authorized to reassign them. // Only GERENTE can reassign a treino to a different aluno. - const nextAlunoId = funcData?.role === 'GERENTE' ? alunoId : existingTreino.alunoId; + const nextAlunoId = role === 'GERENTE' ? alunoId : existingTreino.alunoId; // If caller is an INSTRUTOR, they become the new instructor for this treino. // Otherwise (GERENTE/ALUNO), we preserve the existing instrutorId. - const nextInstrutorId = - funcData?.role === 'INSTRUTOR' ? user.id : existingTreino.instrutorId; + const nextInstrutorId = role === 'INSTRUTOR' ? user.id : existingTreino.instrutorId; // Validation: If alunoId changed, ensure the target student exists if (nextAlunoId !== existingTreino.alunoId) { @@ -136,6 +146,54 @@ export async function upsertTreinoAction(treinoData: TreinoBase | (TreinoBase & } } +/** + * Batch version of upsertTreinoAction. Authenticates and authorises once, + * then creates all workouts in a single Prisma transaction. + * Eliminates the N+1 auth+DB roundtrip when saving a generated plan. + */ +export async function batchUpsertTreinoAction( + treinos: TreinoBase[] +): Promise<{ success: boolean; error?: string }> { + try { + const auth = await getAuthRole(); + if ('error' in auth) return { success: false, error: auth.error }; + const { derivedInstrutorId } = auth; + + const validated = treinos.map((t) => TreinoBaseSchema.parse({ ...t, alunoId: t.alunoId })); + + await prisma.$transaction( + validated.map((data) => + prisma.treino.create({ + data: { + objetivo: data.objetivo, + diaSemana: data.diaSemana, + alunoId: data.alunoId, + instrutorId: derivedInstrutorId, + Exercicios: { + create: data.exercicios.map((ex) => ({ + nomeExercicio: ex.nomeExercicio, + series: ex.series, + repeticoes: ex.repeticoes, + observacoes: ex.observacoes || '', + descricao: ex.descricao || '', + })), + }, + }, + }) + ) + ); + + revalidatePath('/dashboard/treinos'); + return { success: true }; + } catch (error) { + Sentry.captureException(error); + if (error instanceof Error && error.name === 'ZodError') { + return { success: false, error: 'Dados do treino inválidos' }; + } + return { success: false, error: error instanceof Error ? error.message : 'Erro desconhecido' }; + } +} + export async function updateTreinoDayAction(treinoId: string, diaSemana: number | null) { try { const { user, error: authError } = await getUser(); From cdc58dc46bc8982cd41faf4e9a1cc988acae6172 Mon Sep 17 00:00:00 2001 From: Emiya Kiritsugu Date: Sun, 31 May 2026 22:39:05 -0300 Subject: [PATCH 3/3] fix: type error in $transaction mock - cast arg to callable --- src/lib/actions/treinos.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/actions/treinos.test.ts b/src/lib/actions/treinos.test.ts index c36c3489..18190b1e 100644 --- a/src/lib/actions/treinos.test.ts +++ b/src/lib/actions/treinos.test.ts @@ -26,7 +26,7 @@ vi.mock('@/lib/prisma', () => ({ if (Array.isArray(arg)) return Promise.all(arg.map((fn) => (typeof fn === 'function' ? fn(prismaMockTx) : fn))); // Callback pattern (upsertTreinoAction update, registrarHistoricoTreinoAction) - return arg(prismaMockTx); + return (arg as (tx: typeof prismaMockTx) => Promise)(prismaMockTx); }), }, }));