diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index f6904d52..02866a53 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,11 +1,17 @@ # Responsável pelo diretório frontend -/frontend/ @cleytonjesus07 +/frontend/ @cleytonjesus07 # Responsável pelo diretório backend -/backend/ @Bruno-Pimenta +/backend/ @Bruno-Pimenta -# Responsável pelo arquivo de configuração geral -/config.yml @Alan-oliveir +# Responsável pelo diretório docs +/docs/ @Alan-oliveir + +# Responsável pelo diretório project_docs +/project_docs/ @Alan-oliveir + +# Responsável pelas configurações do GitHub +/.github/ @Alan-oliveir # Responsável por todo o repositório (fallback) -* @Alan-oliveir +* @Alan-oliveir diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..8d4e2bd4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,127 @@ +name: CI + +on: + pull_request: + branches: + - develop + +jobs: + frontend: + name: Frontend (React) + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install dependencies (frontend) + run: | + cd frontend + npm install + + - name: Run Lint (frontend) + run: | + cd frontend + npm run lint || echo "Lint warnings only" + + - name: Run Tests (frontend) + run: | + cd frontend + npm run test -- --run --coverage || echo "No tests or tests failed (not blocking)" + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + directory: frontend/coverage + continue-on-error: true + + backend: + name: Backend (Spring Boot) + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:15 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: app_db + ports: + - 5432:5432 + options: >- + --health-cmd="pg_isready -U postgres" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + + - name: Cache Maven dependencies + uses: actions/cache@v4 + with: + path: ~/.m2 + key: ${{ runner.os }}-maven-${{ hashFiles('backend/finboostplus_teste/pom.xml') }} + restore-keys: | + ${{ runner.os }}-maven- + + - name: Run Lint (backend - Checkstyle) + run: | + cd backend/finboostplus_teste + mvn checkstyle:check || echo "Checkstyle warnings only" + + - name: Run Tests (backend) + run: | + cd backend/finboostplus_teste + mvn test || echo "JUnit tests failed or not found (not blocking)" + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + directory: backend/finboostplus_teste/target/site/jacoco + continue-on-error: true + + sonarcloud: + name: SonarCloud Analysis + runs-on: ubuntu-latest + needs: [frontend, backend] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Shallow clones should be disabled for better analysis + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + + - name: Cache Maven dependencies + uses: actions/cache@v4 + with: + path: ~/.m2 + key: ${{ runner.os }}-maven-${{ hashFiles('backend/finboostplus_teste/pom.xml') }} + + - name: SonarCloud Scan + uses: SonarSource/sonarcloud-github-action@v2 + with: + projectBaseDir: . + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/sync-wiki.yml b/.github/workflows/sync-wiki.yml new file mode 100644 index 00000000..f92758fc --- /dev/null +++ b/.github/workflows/sync-wiki.yml @@ -0,0 +1,70 @@ +name: Sync Wiki from docs/wiki + +on: + push: + branches: + - main + - feature/** + paths: + - 'docs/wiki/**' + - 'docs/assets/**' + - '.github/workflows/sync-wiki.yml' + +permissions: + contents: write + +jobs: + sync-wiki: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Configure Git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Checkout Wiki repository + run: | + git clone "https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.wiki.git" wiki + # Verificar se o clone foi bem-sucedido + if [ ! -d wiki/.git ]; then + echo "Erro: Falha ao clonar o repositório wiki" + exit 1 + fi + + - name: Sync files to Wiki + run: | + # Verificar se docs/wiki existe antes de sincronizar + if [ ! -d docs/wiki ]; then + echo "Erro: Diretório docs/wiki não encontrado" + exit 1 + fi + rsync -av --delete --exclude '_Sidebar.md' --exclude '_Footer.md' docs/wiki/ wiki/ + # Copy assets (images) to a subfolder in the wiki + mkdir -p wiki/assets/images + if [ -d docs/assets/images ]; then rsync -av docs/assets/images/ wiki/assets/images/; fi + + - name: Ensure Sidebar/Footer + run: | + # If sidebar/footer exist in docs/wiki, copy; otherwise keep existing + if [ -f docs/wiki/_Sidebar.md ]; then cp docs/wiki/_Sidebar.md wiki/_Sidebar.md; fi + if [ -f docs/wiki/_Footer.md ]; then cp docs/wiki/_Footer.md wiki/_Footer.md; fi + + - name: Commit & Push changes (if any) + working-directory: wiki + run: | + # Verificar qual branch está sendo usado no wiki + WIKI_BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null || echo "master") + echo "Wiki branch detectado: $WIKI_BRANCH" + + if [ -n "$(git status --porcelain)" ]; then + git add . + git commit -m "chore(wiki): sync from docs/wiki @ $GITHUB_SHA" + git push origin HEAD:$WIKI_BRANCH + else + echo "No changes to sync." + fi diff --git a/.gitignore b/.gitignore index 844d4780..58edfeb0 100644 --- a/.gitignore +++ b/.gitignore @@ -13,9 +13,11 @@ frontend/.DS_Store frontend/.eslintcache # IntelliJ/WebStorm/Vscode (IDE/Editor) -.idea/ .vscode/ +.idea/ +*.iws *.iml +*.ipr # Logs logs/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..101492d8 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,370 @@ +# 🤝 Guia de Contribuição - FinBoost+ + +
+ Contribuições + Primeira Contribuição + Code Review +
+ +Obrigado por considerar contribuir para o **FinBoost+**! Este guia ajudará você a entender nosso processo de desenvolvimento e como fazer contribuições efetivas. + +--- + +## 📚 Sumário + +
+Clique aqui para visualizar + +- [Como Contribuir](#-como-contribuir) +- [Padrões de Desenvolvimento](#-padrões-de-desenvolvimento) + - [Convenções de Commit](#️-convenções-de-commit) + - [Estratégia de Branches](#-estratégia-de-branches) + - [Padrões de Código](#-padrões-de-código) +- [Testes](#-testes) +- [Estrutura de Arquivos](#-estrutura-de-arquivos) +- [Code Review](#-code-review) +- [Setup de Desenvolvimento](#-setup-de-desenvolvimento) +- [Recursos](#-recursos) +- [FAQ](#-faq) +- [Equipe e Contato](#-equipe-e-contato) +- [Licença](#-licença) + +
+ +--- + +## 🎯 **Como Contribuir** + +### **Pull Requests** + +1. **Fork** do repositório +2. **Clone** seu fork: `git clone https://github.com/SEU_USUARIO/finboostplus-app.git` +3. **Crie uma branch**: `git checkout -b feature/sua-funcionalidade` +4. **Desenvolva** seguindo nossos padrões +5. **Teste** suas alterações +6. **Commit** com mensagens claras +7. **Push**: `git push origin feature/sua-funcionalidade` +8. **Abra um Pull Request** + +--- + +## 📋 **Padrões de Desenvolvimento** + +### **🏷️ Convenções de Commit** + +Usamos [Conventional Commits](https://www.conventionalcommits.org/pt-br/) para manter histórico organizado: + +```bash +tipo(escopo): descrição + +# Tipos permitidos: +feat: # Nova funcionalidade +fix: # Correção de bug +docs: # Documentação +style: # Formatação, sem mudança de lógica +refactor: # Refatoração de código +test: # Adição ou correção de testes +chore: # Tarefas de manutenção +``` + +**Exemplos:** + +```bash +feat(auth): adiciona autenticação JWT +fix(expenses): corrige cálculo de divisão de despesas +docs(readme): atualiza instruções de instalação +test(button): adiciona testes para componente Button +``` + +### **🌿 Estratégia de Branches** + +``` +main # Produção - sempre estável +├── develop # Desenvolvimento - integração +├── feature/nome # Novas funcionalidades +├── fix/nome # Correções +└── docs/nome # Documentação +``` + +**Nomenclatura:** +- `feature/auth-jwt` - Nova funcionalidade de autenticação +- `fix/expense-calculation` - Correção no cálculo de despesas +- `docs/contributing-guide` - Documentação + +### **📝 Padrões de Código** + +#### **Frontend (React)** + +```javascript +// ✅ Bom +import { useState, useEffect } from 'react' +import { Button } from '../components/ui/Button' + +const UserProfile = ({ userId }) => { + const [user, setUser] = useState(null) + + // Hooks no início + useEffect(() => { + fetchUser(userId).then(setUser) + }, [userId]) + + // Early return para casos especiais + if (!user) return + + return ( +
+

{user.name}

+ +
+ ) +} + +export default UserProfile +``` + +#### **Backend (Java/Spring)** + +```java +// ✅ Bom +@RestController +@RequestMapping("/api/expenses") +@RequiredArgsConstructor +public class ExpenseController { + + private final ExpenseService expenseService; + + @PostMapping + public ResponseEntity createExpense( + @Valid @RequestBody CreateExpenseRequest request, + Authentication authentication) { + + ExpenseDto expense = expenseService.createExpense(request, authentication.getName()); + return ResponseEntity.status(HttpStatus.CREATED).body(expense); + } +} +``` + +--- + +## 🧪 **Testes** + +### **Cobertura Mínima** + +- **Frontend:** 70% +- **Backend:** 70% + +### **Tipos de Teste** + +#### **Frontend** + +```bash +npm test +npm test -- --watch +npm test -- --coverage +npm test -- Button.test.jsx +``` + +**Exemplo de teste:** +```javascript +// components/Button.test.jsx +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { Button } from './Button' + +describe('Button', () => { + it('deve chamar onClick quando clicado', async () => { + const user = userEvent.setup() + const handleClick = vi.fn() + + render() + + await user.click(screen.getByRole('button')) + + expect(handleClick).toHaveBeenCalledTimes(1) + }) +}) +``` + +#### **Backend** + +```bash +./mvnw test +./mvnw test jacoco:report +open target/site/jacoco/index.html +``` + +**Exemplo de teste:** +```java +@SpringBootTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class ExpenseServiceTest { + + @Autowired + private ExpenseService expenseService; + + @Test + void shouldCreateExpenseSuccessfully() { + // Given + CreateExpenseRequest request = new CreateExpenseRequest("Jantar", 100.0); + + // When + ExpenseDto result = expenseService.createExpense(request, "user@test.com"); + + // Then + assertThat(result.getDescription()).isEqualTo("Jantar"); + assertThat(result.getAmount()).isEqualTo(100.0); + } +} +``` + +--- + +## 📁 **Estrutura de Arquivos** + +### **Adicionando Nova Funcionalidade** + +```bash +# Frontend +src/ +├── components/expenses/ +│ ├── ExpenseForm.jsx +│ ├── ExpenseList.jsx +│ └── index.js +├── pages/Expenses/ +│ ├── index.jsx +│ └── ExpensesActions.jsx +├── hooks/useExpenses.js +└── services/expenses.js + +# Backend +src/main/java/com/finboost/ +├── controller/ExpenseController.java +├── service/ExpenseService.java +├── model/Expense.java +├── repository/ExpenseRepository.java +└── dto/ + ├── ExpenseDto.java + └── CreateExpenseRequest.java +``` + +--- + +## 🔍 **Code Review** + +### **Checklist do Reviewer - Frontend** + +- [ ] Componentes seguem padrões do projeto +- [ ] Props têm PropTypes ou TypeScript +- [ ] Testes cobrem funcionalidades principais +- [ ] CSS é responsivo e segue design system +- [ ] Não há console.logs esquecidos +- [ ] Imports estão organizados + +### **Checklist do Reviewer - Backend** + +- [ ] Endpoints seguem REST conventions +- [ ] Validação de entrada adequada +- [ ] Tratamento de erros consistente +- [ ] Testes unitários e integração +- [ ] Documentação JavaDoc quando necessário +- [ ] Segurança considerada + +### **Checklist do Autor** + +- [ ] **Funciona:** Testei localmente +- [ ] **Testes:** Adicionei/atualizei testes +- [ ] **Documentação:** Atualizei quando necessário +- [ ] **Linting:** Código passou no ESLint/Checkstyle +- [ ] **Commits:** Mensagens seguem padrão +- [ ] **Descrição:** PR tem descrição clara do que faz + +--- + +## 🚀 **Setup de Desenvolvimento** + +### **Primeira Configuração** + +```bash +git clone https://github.com/Finboostplus/finboostplus-app.git +cd finboostplus-app + +# Backend +cd backend/finboostplus_teste +./mvnw clean install +./mvnw spring-boot:run + +# Frontend +cd ../../frontend +npm install +npm run dev +``` + +### **Ferramentas Recomendadas** + +- **VS Code Extensions:** ES7+ Snippets, Tailwind IntelliSense, GitLens, Prettier +- **IntelliJ IDEA Plugins:** Spring Boot, Database Navigator, GitToolBox + +--- + +## 📚 **Recursos** + +### **Documentação Técnica** + +- [Documentação Completa](https://finboostplus.github.io/finboostplus-app/) +- [Frontend Guide](./frontend/README.md) +- [Backend Guide](./backend/README.md) +- [Frontend Testing Guide](./frontend/TESTING_GUIDE.md) + +### **Referências Externas** + +- [React Docs](https://react.dev/) +- [Spring Boot Docs](https://spring.io/projects/spring-boot) +- [TailwindCSS Docs](https://tailwindcss.com/) +- [Conventional Commits](https://www.conventionalcommits.org/) + +--- + +## ❓ **FAQ** + +### **Q: Como fazer minha primeira contribuição?** +A: Comece com issues marcadas como `good first issue` ou `help wanted`. São problemas menores e bem definidos. + +### **Q: Posso contribuir apenas com documentação?** +A: Sim! Documentação é fundamental. Issues com label `documentation` são uma ótima forma de começar. + +### **Q: Como reportar um bug de segurança?** +A: Para vulnerabilidades de segurança, envie email para: **finboostplus@gmail.com** + +### **Q: Posso usar bibliotecas externas?** +A: Sim, mas discuta primeiro abrindo uma issue. Preferimos soluções que não aumentem muito o bundle size. + +### **Q: Como funciona o processo de release?** +A: Releases são feitas a partir da branch `main` após aprovação da equipe. Tags seguem [Semantic Versioning](https://semver.org/). + +--- + +## 👥 **Equipe e Contato** + +### **Core Team** + +- **🏆 Product Owner:** Alan Oliveira +- **🔙 Backend Lead:** Bruno Henrique +- **🎨 Frontend Lead:** Cleiton + +### **Como Nos Encontrar** + +- 📧 **Email:** finboostplus@gmail.com +- 🐛 **Issues:** [GitHub Issues](https://github.com/Finboostplus/finboostplus-app/issues) + +--- + +## 📄 **Licença** + +Ao contribuir, você concorda que suas contribuições serão licenciadas sob a mesma licença do projeto. + +--- + +
+ 🤝 Obrigado por contribuir com o FinBoost+!
+ Juntos construímos uma ferramenta financeira melhor para todos +
diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..82bc351d --- /dev/null +++ b/LICENSE @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +====================================================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at https://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/README.md b/README.md index d0e86bf5..7107fc00 100644 --- a/README.md +++ b/README.md @@ -1,272 +1,243 @@ -# 💸 FinBoost+ - Controle Financeiro Pessoal ou Compartilhado +# 💸 FinBoost+ - Controle Financeiro Pessoal e Compartilhado -
+
Status - Java - React - Spring Boot - PostgreSQL + Java + React + Spring Boot + PostgreSQL + License
-**Sistema fullstack para gerenciamento de finanças pessoais ou compartilhadas**. Permite o registro e divisão de despesas, acompanhamento de saldos individuais, geração de relatórios e sugestões inteligentes com uso de IA. - ---- - -## 📋 Índice +**Sistema fullstack para gerenciamento de finanças pessoais e compartilhadas**. Permite registro e divisão de despesas, acompanhamento de saldos individuais, geração de relatórios e sugestões inteligentes com IA. -- [Funcionalidades](#-funcionalidades) -- [Tecnologias](#-tecnologias-utilizadas) -- [Estrutura do Projeto](#-estrutura-do-repositório) -- [Como Executar](#-como-executar-localmente) -- [Testes](#-testes) -- [Contribuição](#-como-contribuir) -- [Equipe](#-equipe-de-desenvolvimento) -- [Licença](#-licença) +> 🎓 **Projeto Final** do curso Desenvolvimento Full-Stack Jr – +Prati & Codifica --- -## 🎯 Funcionalidades +## 📚 Sumário + +
+Clique aqui para visualizar + +- [💸 FinBoost+ - Controle Financeiro Pessoal e Compartilhado](#-finboost---controle-financeiro-pessoal-e-compartilhado) + - [📚 Sumário](#-sumário) + - [🚀 **Demo ao Vivo**](#-demo-ao-vivo) + - [🎯 **O que o FinBoost+ faz?**](#-o-que-o-finboost-faz) + - [✅ **Funcionalidades Principais**](#-funcionalidades-principais) + - [🛠️ **Tecnologias**](#️-tecnologias) + - [📁 **Estrutura do Projeto**](#-estrutura-do-projeto) + - [🚀 **Como Executar**](#-como-executar) + - [📋 **Pré-requisitos**](#-pré-requisitos) + - [⚡ **Execução Rápida (Recomendado)**](#-execução-rápida-recomendado) + - [🔧 **Execução Manual**](#-execução-manual) + - [🧪 **Testes**](#-testes) + - [👥 **Nossa Equipe**](#-nossa-equipe) + - [🤝 **Como Contribuir**](#-como-contribuir) + - [🎓 **Sobre o Projeto**](#-sobre-o-projeto) + - [📞 **Suporte**](#-suporte) + - [📄 **Licença**](#-licença) + +
-### ✅ MVP (Versão Atual) - -- [x] **Autenticação**: Cadastro e login seguro de usuários -- [x] **Gerenciamento de Grupos**: Criação e administração de grupos financeiros -- [x] **Controle de Despesas**: Adição e divisão automática de gastos -- [x] **Saldos em Tempo Real**: Visualização de saldo individual e do grupo -- [x] **Dashboard Intuitivo**: Resumo completo de gastos e estatísticas -- [x] **Histórico de Transações**: Visualização detalhada de todas as movimentações +--- -### 🚀 Funcionalidades Futuras (Roadmap) +## 🚀 **Demo ao Vivo** -- [ ] **IA para Categorização**: Classificação automática de despesas -- [ ] **Sugestões Inteligentes**: Recomendações personalizadas de economia -- [ ] **Alertas Preditivos**: Notificações sobre gastos atípicos -- [ ] **Entrada por Voz**: Lançamento de despesas via speech-to-text -- [ ] **Leitura de QR Code**: Registro automático via códigos QR -- [ ] **Relatórios Avançados**: Análises detalhadas com gráficos -- [ ] **Integração Bancária**: Sincronização com contas bancárias -- [ ] **App Mobile**: Aplicativo nativo para iOS e Android + +- 🌐 **Aplicação:** [Em breve] +- 📚 **Documentação:** [FinBoost+ Docs](https://finboostplus.github.io/finboostplus-app/) +- 🎥 **Video Demo:** [YouTube](link-do-video) --- -## 🛠️ Tecnologias Utilizadas - -### 🔧 Backend +## 🎯 **O que o FinBoost+ faz?** -- **☕ Java 17+** - Linguagem principal -- **🍃 Spring Boot 3.2+** - Framework web -- **🔐 Spring Security** - Autenticação e autorização -- **🗄️ PostgreSQL** - Banco de dados relacional -- **🔄 JPA / Hibernate** - Mapeamento objeto-relacional -- **🧪 JUnit 5 + Mockito** - Testes unitários e de integração -- **📊 Spring Data JPA** - Acesso a dados simplificado +### ✅ **Funcionalidades Principais** -### 🎨 Frontend +- 🔐 **Autenticação Segura** - Login e cadastro com JWT +- 👥 **Grupos Financeiros** - Crie e gerencie grupos de gastos compartilhados +- 💰 **Divisão Automática** - Divida despesas automaticamente entre membros +- 📊 **Dashboard Inteligente** - Visualize gastos e estatísticas em tempo real +- 💳 **Controle de Saldos** - Acompanhe quem deve para quem +- 📱 **Interface Responsiva** - Funciona perfeitamente em qualquer dispositivo -- **⚛️ React.js 18+** - Biblioteca de interface -- **⚡ Vite** - Build tool e dev server -- **🎨 TailwindCSS** - Framework CSS utilitário -- **📡 Axios** - Cliente HTTP para requisições -- **🔐 JWT** - Autenticação via tokens -- **🧪 Jest + React Testing Library** - Testes de componentes -- **📊 Recharts** - Gráficos e visualizações - -### 🚀 DevOps & Ferramentas +--- -- **🐳 Docker** - Containerização -- **🔧 Maven** - Gerenciamento de dependências (Backend) -- **📦 npm** - Gerenciamento de pacotes (Frontend) -- **🌐 GitHub Actions** - CI/CD -- **📝 ESLint + Prettier** - Padronização de código +## 🛠️ **Tecnologias** + + + + + + + + + + + + +
🔙 Backend🎨 Frontend🗄️ Database
+• Java 21+
+• Spring Boot 3.5+
+• Spring Security
+• JPA/Hibernate
+• Maven +
+• React 19+
+• Vite
+• TailwindCSS
+• Axios
+• Recharts +
+• PostgreSQL 15+
+• Docker
+• MkDocs
+• GitHub Actions +
--- -## 📁 Estrutura do Repositório +## 📁 **Estrutura do Projeto** ``` -| Caminho | Conteúdo | -|-------------------|-----------------------------------------------------------------------| -| `frontend/` | Aplicação React (interface) | -| `backend/` | API REST em Spring Boot | -| `docs/` | Documentação técnica do projeto em Markdown (usada com MkDocs) | -| `project_docs/` | Documentos de planejamento, requisitos, MVPs, diagramas, atas, etc. | -| `README.md` | Apresentação geral do projeto | - +finboost/ +├── 🎨 frontend/ # React App (Interface) +├── 🔙 backend/ # Spring Boot API +├── 📚 docs/ # MkDocs (Documentação técnica) +├── 📋 project_docs/ # Planejamento e requisitos +└── 🐳 docker-compose.yml # Ambiente completo ``` ---- - -## 🚀 Como Executar Localmente +> 📖 **Documentação completa:** Acesse nossa [documentação técnica](https://finboostplus.github.io/finboostplus-app/) para detalhes de arquitetura, APIs e guias avançados. -### 📋 Pré-requisitos +--- -- **Node.js** 18+ e npm -- **Java** 17+ e Maven -- **PostgreSQL** 15+ -- **Docker** (opcional) +## 🚀 **Como Executar** -### 🔧 Configuração do Ambiente +### 📋 **Pré-requisitos** +- Node.js 18+ e npm +- Java 21+ e Maven +- PostgreSQL 15+ (ou Docker) -1. **Clone o repositório** +### ⚡ **Execução Rápida (Recomendado)** ```bash -git clone https://github.com/sua-organizacao/controle-financeiro.git -cd controle-financeiro -``` - -2. **Configure o banco de dados** +# 1. Clone o projeto +git clone https://github.com/Finboostplus/finboostplus-app.git +cd finboostplus-app -```bash -# Crie um banco PostgreSQL -createdb controle_financeiro +# 2. Execute com Docker +docker-compose up -d -# Configure as variáveis de ambiente -cp backend/src/main/resources/application.yml.example backend/src/main/resources/application.yml +# 3. Acesse a aplicação +# Frontend: http://localhost:5173 +# Backend: http://localhost:8080 ``` -### 🔙 Executando o Backend +### 🔧 **Execução Manual** + +
+Clique para ver instruções detalhadas ```bash +# Backend cd backend - -# Instale as dependências ./mvnw clean install - -# Execute a aplicação ./mvnw spring-boot:run -# A API estará disponível em http://localhost:8080 -``` - -### 🎨 Executando o Frontend - -```bash -cd frontend - -# Instale as dependências +# Frontend (novo terminal) +cd frontend npm install - -# Execute em modo de desenvolvimento npm run dev - -# A aplicação estará disponível em http://localhost:5173 ``` -### 🐳 Executando com Docker - +**Variáveis de ambiente:** ```bash -# Execute todos os serviços -docker-compose up -d - -# Para parar os serviços -docker-compose down +# backend/application.yml +spring: + datasource: + url: jdbc:postgresql://localhost:5432/finboost + username: seu_usuario + password: sua_senha ``` ---- - -## 🧪 Testes - -### Backend - -```bash -cd backend - -# Executar todos os testes -./mvnw test +
-# Executar testes com relatório de cobertura -./mvnw test jacoco:report - -# Executar apenas testes unitários -./mvnw test -Dtest="*UnitTest" - -# Executar apenas testes de integração -./mvnw test -Dtest="*IntegrationTest" -``` +--- -### Frontend +## 🧪 **Testes** ```bash -cd frontend - -# Executar todos os testes -npm test +# Backend +cd backend && ./mvnw test -# Executar testes com cobertura -npm run test:coverage +# Frontend +cd frontend && npm test -# Executar testes em modo watch -npm run test:watch +# Cobertura +./mvnw test jacoco:report # Backend +npm run test:coverage # Frontend ``` --- -## 📊 Métricas e Qualidade - -- **Cobertura de Testes**: Meta de 80%+ -- **Análise de Código**: SonarQube -- **Performance**: Lighthouse Score 90+ -- **Acessibilidade**: WCAG 2.1 AA +## 👥 **Nossa Equipe** + + + + + + + + + + + + +
🏆 Gestão🔙 Backend🎨 Frontend
AlanBruno, Cristiano
Eduardo, João
Pedro, Alisson, Túlio
Cleiton, Hugo e Ana
--- -## 🤝 Como Contribuir +## 🤝 **Como Contribuir** -1. **Fork** este repositório -2. Crie uma **branch** para sua feature (`git checkout -b feature/nova-funcionalidade`) -3. **Commit** suas mudanças (`git commit -m 'Adiciona nova funcionalidade'`) -4. **Push** para a branch (`git push origin feature/nova-funcionalidade`) -5. Abra um **Pull Request** +1. Fork o projeto +2. Crie uma branch: `git checkout -b feature/nova-funcionalidade` +3. Commit: `git commit -m 'feat: adiciona nova funcionalidade'` +4. Push: `git push origin feature/nova-funcionalidade` +5. Abra um Pull Request -### 📝 Padrões de Commit - -- `feat:` Nova funcionalidade -- `fix:` Correção de bug -- `docs:` Documentação -- `style:` Formatação de código -- `refactor:` Refatoração -- `test:` Testes -- `chore:` Tarefas de manutenção - ---- - -## 👥 Equipe de Desenvolvimento - -| Área | Membros | -| ------------------------ | ------------------------------------ | -| **🏆 Gestão do Projeto** | Alan | -| **🔧 Backend** | Bruno, Cristiano, Eduardo, João | -| **🎨 Frontend** | Cleiton, Ana, Ellen, Mariana, Raquel | +**Padrões de commit:** `feat:` `fix:` `docs:` `style:` `refactor:` `test:` --- -## 🎓 Sobre o Projeto +## 🎓 **Sobre o Projeto** -Este projeto foi desenvolvido como trabalho final do curso **Desenvolvimento Full-Stack** da **Prati+**. O objetivo é demonstrar competências em: +O **FinBoost+** foi desenvolvido como projeto final do curso **Desenvolvimento Full-Stack Jr** da **+Prati & Codifica**. -- Desenvolvimento de APIs REST com Spring Boot -- Criação de interfaces modernas com React -- Implementação de autenticação e autorização -- Trabalho em equipe e metodologias ágeis -- Boas práticas de desenvolvimento e DevOps +**Objetivos de aprendizado:** +- ✅ APIs REST com Spring Boot +- ✅ Interfaces modernas com React +- ✅ Autenticação JWT +- ✅ Trabalho em equipe (Metodologia Ágil) +- ✅ DevOps e boas práticas --- -## 📄 Licença +## 📞 **Suporte** -Este projeto é de **uso educacional**, desenvolvido no curso de **Desenvolvimento Full-Stack Jr – +Prati & Codifica**. +- 🐛 **Bugs:** [Issues](https://github.com/Finboostplus/finboostplus-app/issues) +- 📧 **Email:** finboostplus@gmail.com --- -## 📬 Contato - -Para dúvidas, sugestões ou feedback: +## 📄 **Licença** -- 💬 **Discord**: [Link do servidor] -- 🐛 **Issues**: [Reporte bugs aqui](https://github.com/Finboostplus/finboost/issues) +Distribuído sob a **MPL-2.0**. Veja o arquivo [LICENSE](./LICENSE) para mais detalhes. ---
- Desenvolvido com 💙 pelo Grupo 7 da Turma 2 do curso Desenvolvimento Full-Stack Jr – +Prati & Codifica + Desenvolvido com 💙 pelo Grupo 7 da Turma 2
+ +Prati & Codifica - 2025
diff --git a/backend/README.md b/backend/README.md index 2dc4dce9..2c61d68e 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,403 +1,289 @@ -# 🔧 Backend - Controle Financeiro Compartilhado +# 🔧 Backend - FinBoost+
- Java - Spring Boot - PostgreSQL - Maven + Java + Spring Boot + PostgreSQL + Status
-API REST desenvolvida em **Spring Boot** para gerenciamento de finanças compartilhadas. Fornece endpoints seguros para autenticação, gestão de grupos, controle de despesas e geração de relatórios. +**API REST** desenvolvida em **Spring Boot** para o FinBoost+. Fornece endpoints seguros para autenticação, gestão de grupos financeiros e controle de despesas compartilhadas. ---- +
+📚 Sumário -## 📋 Índice +- [🚀 Funcionalidades Principais](#-funcionalidades-principais) +- [🛠️ Tecnologias](#️-tecnologias) +- [📁 Estrutura Resumida](#-estrutura-resumida) +- [📘 Documentação da API (OpenAPI / Scalar)](#-documentação-da-api-openapi--scalar) +- [⚡ Execução Rápida](#-execução-rápida) +- [🧪 Testes](#-testes) +- [📡 Endpoints Principais](#-endpoints-principais) +- [🐳 Docker & Containerização](#-docker--containerização) +- [🔧 Variáveis de Ambiente](#-variáveis-de-ambiente) +- [🤝 Contribuindo](#-contribuindo) +- [📞 Suporte](#-suporte) -- [Tecnologias](#-tecnologias) -- [Estrutura do Projeto](#-estrutura-do-projeto) -- [Configuração](#-configuração) -- [Execução](#-execução) -- [Endpoints](#-endpoints-principais) -- [Testes](#-testes) -- [Docker](#-docker) +
--- -## 🛠️ Tecnologias - -### Core -- **☕ Java 17** - Linguagem principal -- **🍃 Spring Boot 3.2+** - Framework web -- **📦 Maven 3.8+** - Gerenciamento de dependências - -### Dependências Principais -- **🔐 Spring Security 6** - Autenticação e autorização -- **🗄️ Spring Data JPA** - Acesso a dados -- **🔄 Hibernate 6** - ORM -- **🐘 PostgreSQL** - Banco de dados -- **🎯 Spring Validation** - Validação de dados -- **📊 Spring Boot Actuator** - Monitoramento +## 🚀 **Funcionalidades Principais** -### Desenvolvimento & Testes -- **🧪 JUnit 5** - Testes unitários -- **🎭 Mockito** - Mocking para testes -- **🗃️ H2 Database** - Banco em memória para testes -- **📱 TestContainers** - Testes de integração +- 🔐 **Autenticação JWT** - Login e registro seguros +- 👥 **Gestão de Grupos** - Criação e gerenciamento de grupos financeiros +- 💰 **Controle de Despesas** - Registro e divisão automática de gastos +- 📊 **Relatórios** - Saldos e estatísticas em tempo real +- 🔒 **Segurança** - Spring Security com controle de acesso --- -## 📁 Estrutura do Projeto +## 🛠️ **Tecnologias** -```plaintext -backend/ -├── 📁 src/ -│ ├── 📁 main/ -│ │ ├── 📁 java/com/projeto/controle/ -│ │ │ ├── 📁 controller/ # Controladores REST -│ │ │ │ ├── AuthController.java -│ │ │ │ ├── UserController.java -│ │ │ │ ├── GroupController.java -│ │ │ │ └── ExpenseController.java -│ │ │ ├── 📁 service/ # Lógica de negócio -│ │ │ │ ├── AuthService.java -│ │ │ │ ├── UserService.java -│ │ │ │ ├── GroupService.java -│ │ │ │ └── ExpenseService.java -│ │ │ ├── 📁 model/ # Entidades JPA -│ │ │ │ ├── User.java -│ │ │ │ ├── Group.java -│ │ │ │ ├── Expense.java -│ │ │ │ └── UserGroup.java -│ │ │ ├── 📁 repository/ # Repositórios -│ │ │ │ ├── UserRepository.java -│ │ │ │ ├── GroupRepository.java -│ │ │ │ └── ExpenseRepository.java -│ │ │ ├── 📁 dto/ # Data Transfer Objects -│ │ │ │ ├── request/ -│ │ │ │ └── response/ -│ │ │ ├── 📁 config/ # Configurações -│ │ │ │ ├── SecurityConfig.java -│ │ │ │ ├── JwtConfig.java -│ │ │ │ └── DatabaseConfig.java -│ │ │ ├── 📁 security/ # Segurança -│ │ │ │ ├── JwtAuthenticationFilter.java -│ │ │ │ ├── JwtTokenProvider.java -│ │ │ │ └── CustomUserDetailsService.java -│ │ │ ├── 📁 exception/ # Tratamento de exceções -│ │ │ │ ├── GlobalExceptionHandler.java -│ │ │ │ └── CustomExceptions.java -│ │ │ └── Application.java # Classe principal -│ │ └── 📁 resources/ -│ │ ├── application.yml # Configurações -│ │ ├── application-dev.yml # Ambiente de desenvolvimento -│ │ ├── application-prod.yml # Ambiente de produção -│ │ └── 📁 db/migration/ # Scripts Flyway -│ │ ├── V001__Create_users_table.sql -│ │ ├── V002__Create_groups_table.sql -│ │ └── V003__Create_expenses_table.sql -│ └── 📁 test/ # Testes -│ ├── 📁 java/com/projeto/controle/ -│ │ ├── 📁 controller/ # Testes de controller -│ │ ├── 📁 service/ # Testes de service -│ │ └── 📁 repository/ # Testes de repository -│ └── 📁 resources/ -│ └── application-test.yml # Configurações de teste -├── pom.xml # Dependências Maven -├── Dockerfile # Container Docker -└── README.md # Este arquivo -``` +### **Core** +- **Java 21+** - Linguagem principal +- **Spring Boot 3.5+** - Framework web +- **Spring Security** - Autenticação e autorização +- **PostgreSQL** - Banco de dados principal ---- +### **Desenvolvimento** +- **Maven** - Gerenciamento de dependências +- **JUnit 5** - Testes unitários +- **Mockito** - Mocking para testes +- **H2** - Banco em memória para testes -## ⚙️ Configuração +--- -### 1. Variáveis de Ambiente +## 📁 **Estrutura Resumida** -Crie um arquivo `.env` na raiz do backend: +``` +backend/ +├── 📁 src/main/java/com/projeto/finboost/ +│ ├── 🎮 controller/ # Endpoints REST +│ ├── 🔧 service/ # Lógica de negócio +│ ├── 🗄️ model/ # Entidades JPA +│ ├── 🔍 repository/ # Acesso a dados +│ ├── 📦 dto/ # Data Transfer Objects +│ ├── ⚙️ config/ # Configurações +│ └── 🔐 security/ # Segurança JWT +├── 📁 src/main/resources/ +│ ├── application.yml # Configurações principais +│ └── 🗃️ db/migration/ # Scripts de banco +└── 📁 src/test/ # Testes automatizados +``` -```env -# Database -DB_HOST=localhost -DB_PORT=5432 -DB_NAME=controle_financeiro -DB_USER=seu_usuario -DB_PASSWORD=sua_senha +> 📖 **Documentação completa:** Veja nossa [documentação técnica](https://finboostplus.github.io/finboostplus-app/) para detalhes de arquitetura, APIs e guias avançados. -# JWT -JWT_SECRET=sua_chave_secreta_jwt_muito_longa_e_segura -JWT_EXPIRATION=86400000 +--- -# Email (opcional) -MAIL_HOST=smtp.gmail.com -MAIL_PORT=587 -MAIL_USERNAME=seu_email@gmail.com -MAIL_PASSWORD=sua_senha_app +## 📘 **Documentação da API (OpenAPI / Scalar)** + +URLs após subir a aplicação: +- 🎯 **Scalar (Recomendado)**: `http://localhost:8080/docs/scalar` +- 📚 **Swagger UI**: `http://localhost:8080/swagger-ui.html` +- 🔧 **OpenAPI JSON**: `http://localhost:8080/v3/api-docs` + +Autenticação JWT no Scalar: +1. Obtenha um token via `POST /api/auth/login` +2. Clique em Authorize +3. Informe: `Bearer ` +4. Teste endpoints protegidos normalmente + +Anotações para documentar endpoints: +```java +@Tag(name = "Usuários", description = "Operações de usuário") +@Operation(summary = "Cria usuário", description = "Registra um novo usuário") +@ApiResponses({ + @ApiResponse(responseCode = "201", description = "Criado"), + @ApiResponse(responseCode = "400", description = "Dados inválidos"), + @ApiResponse(responseCode = "401", description = "Não autorizado") +}) ``` -### 2. Configuração do Banco de Dados - -```sql --- Criar banco de dados -CREATE DATABASE controle_financeiro; - --- Criar usuário (opcional) -CREATE USER controle_user WITH PASSWORD 'sua_senha'; -GRANT ALL PRIVILEGES ON DATABASE controle_financeiro TO controle_user; +Vantagens rápidas do Scalar: +- UI moderna + dark mode nativo +- Busca global (Ctrl+K) e exemplos interativos +- Melhor UX para leitura de modelos e testes + +Dependências (pom.xml): +```xml + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.7.0 + + + org.springdoc + springdoc-openapi-starter-common + 2.7.0 + ``` -### 3. Configuração do `application.yml` - -```yaml -server: - port: 8080 - -spring: - datasource: - url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:controle_financeiro} - username: ${DB_USER:postgres} - password: ${DB_PASSWORD:password} - - jpa: - hibernate: - ddl-auto: validate - show-sql: false - properties: - hibernate: - format_sql: true - - flyway: - enabled: true - baseline-on-migrate: true - -jwt: - secret: ${JWT_SECRET:default_secret_key} - expiration: ${JWT_EXPIRATION:86400000} +Propriedades relevantes: +```properties +springdoc.api-docs.path=/v3/api-docs +springdoc.swagger-ui.path=/swagger-ui.html +springdoc.swagger-ui.enabled=true +springdoc.swagger-ui.disable-swagger-default-url=true +springdoc.swagger-ui.config-url=/v3/api-docs/swagger-config +springdoc.swagger-ui.url=/v3/api-docs ``` --- -## 🚀 Execução +## ⚡ **Execução Rápida** -### Pré-requisitos -- Java 17+ +### **Pré-requisitos** +- Java 21+ - Maven 3.8+ -- PostgreSQL 15+ - -### Comandos +- PostgreSQL 15+ (ou use Docker) +### **1. Configurar Banco de Dados** ```bash -# Instalar dependências -./mvnw clean install - -# Executar aplicação -./mvnw spring-boot:run - -# Executar com profile específico -./mvnw spring-boot:run -Dspring-boot.run.profiles=dev - -# Executar com debug -./mvnw spring-boot:run -Dspring-boot.run.jvmArguments="-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=5005" +# Opção 1: Docker (Recomendado) +docker run --name postgres-finboost \ + -e POSTGRES_DB=finboost \ + -e POSTGRES_USER=finboost \ + -e POSTGRES_PASSWORD=dev123 \ + -p 5432:5432 -d postgres:15 + +# Opção 2: PostgreSQL local +createdb finboost +``` -# Gerar JAR -./mvnw clean package +### **2. Configurar Aplicação** +```bash +# Copiar arquivo de configuração +cp src/main/resources/application.yml.example src/main/resources/application.yml -# Executar JAR -java -jar target/controle-financeiro-backend-1.0.0.jar +# Editar configurações do banco (se necessário) +nano src/main/resources/application.yml ``` -### Verificação da Aplicação - +### **3. Executar Backend** ```bash -# Health check -curl http://localhost:8080/actuator/health +# Instalar dependências e executar +./mvnw clean install +./mvnw spring-boot:run -# Swagger UI (se habilitado) -# Acesse: http://localhost:8080/swagger-ui.html +# ✅ API rodando em: http://localhost:8080 ``` --- -## 🔗 Endpoints Principais - -### Autenticação -```http -POST /api/auth/register # Cadastro -POST /api/auth/login # Login -POST /api/auth/refresh # Refresh token -``` - -### Usuários -```http -GET /api/users/profile # Perfil do usuário -PUT /api/users/profile # Atualizar perfil -DELETE /api/users/account # Deletar conta -``` +## 🧪 **Testes** -### Grupos -```http -GET /api/groups # Listar grupos -POST /api/groups # Criar grupo -GET /api/groups/{id} # Detalhes do grupo -PUT /api/groups/{id} # Atualizar grupo -DELETE /api/groups/{id} # Deletar grupo -POST /api/groups/{id}/members/{userId} # Adicionar membro -``` +```bash +# Executar todos os testes +./mvnw test -### Despesas -```http -GET /api/expenses # Listar despesas -POST /api/expenses # Criar despesa -GET /api/expenses/{id} # Detalhes da despesa -PUT /api/expenses/{id} # Atualizar despesa -DELETE /api/expenses/{id} # Deletar despesa -``` +# Testes com cobertura +./mvnw test jacoco:report -### Relatórios -```http -GET /api/reports/group/{groupId}/summary # Resumo do grupo -GET /api/reports/user/balance # Saldo do usuário -GET /api/reports/group/{groupId}/expenses # Despesas por período +# Ver relatório de cobertura +open target/site/jacoco/index.html ``` --- -## 🧪 Testes +## 📡 **Endpoints Principais** -### Executar Testes ```bash -# Todos os testes -./mvnw test +# Autenticação +POST /api/auth/register # Cadastro +POST /api/auth/login # Login -# Testes unitários apenas -./mvnw test -Dtest="*UnitTest" +# Grupos +GET /api/groups # Listar grupos +POST /api/groups # Criar grupo -# Testes de integração apenas -./mvnw test -Dtest="*IntegrationTest" +# Despesas +GET /api/expenses # Listar despesas +POST /api/expenses # Criar despesa -# Testes com cobertura -./mvnw test jacoco:report - -# Testes de uma classe específica -./mvnw test -Dtest="UserServiceTest" +# Health Check +GET /actuator/health # Status da API ``` -### Estrutura de Testes -- **Unit Tests**: Testam componentes isolados -- **Integration Tests**: Testam integração entre componentes -- **Repository Tests**: Testam acesso a dados -- **Controller Tests**: Testam endpoints REST - -### Relatórios -- **Cobertura**: `target/site/jacoco/index.html` -- **Surefire**: `target/surefire-reports/` +> 📋 **Documentação da API:** Veja `/docs/scalar` (UI moderna) ou `/swagger-ui.html`. --- -## 🐳 Docker +## 🐳 **Docker & Containerização** -### Construir Imagem +Compose (app + banco): ```bash -# Construir imagem -docker build -t controle-financeiro-backend . - -# Executar container -docker run -p 8080:8080 --env-file .env controle-financeiro-backend +docker compose up --build +# ou +docker-compose up -d ``` - -### Docker Compose -```yaml -version: '3.8' -services: - backend: - build: . - ports: - - "8080:8080" - environment: - - DB_HOST=postgres - - DB_NAME=controle_financeiro - - DB_USER=postgres - - DB_PASSWORD=password - depends_on: - - postgres - - postgres: - image: postgres:15 - environment: - - POSTGRES_DB=controle_financeiro - - POSTGRES_USER=postgres - - POSTGRES_PASSWORD=password - volumes: - - postgres_data:/var/lib/postgresql/data - ports: - - "5432:5432" - -volumes: - postgres_data: +Verificar: +```bash +curl http://localhost:8080/actuator/health ``` ---- - -## 📊 Monitoramento - -### Actuator Endpoints +Build da imagem isolada: ```bash -# Métricas da aplicação -curl http://localhost:8080/actuator/metrics +# Build local +docker build -t finboostplus/backend . -# Informações da aplicação -curl http://localhost:8080/actuator/info +# Build para arquitetura específica (ex: deploy amd64 a partir de Mac ARM) +docker build --platform=linux/amd64 -t finboostplus/backend:amd64 . +``` -# Beans gerenciados -curl http://localhost:8080/actuator/beans +Push (exemplo): +```bash +# Ajuste o registry +docker tag finboostplus/backend ghcr.io/finboostplus/backend:latest +docker push ghcr.io/finboostplus/backend:latest ``` -### Logs +Multi-arch com buildx (opcional): ```bash -# Alterar nível de log em runtime -curl -X POST http://localhost:8080/actuator/loggers/com.projeto.controle \ - -H "Content-Type: application/json" \ - -d '{"configuredLevel": "DEBUG"}' +docker buildx build --platform linux/amd64,linux/arm64 -t ghcr.io/finboostplus/backend:latest --push . ``` --- -## 🔧 Troubleshooting +## 🔧 **Variáveis de Ambiente** -### Problemas Comuns +Crie um arquivo `.env` (opcional): -1. **Erro de conexão com banco** - - Verifique se PostgreSQL está rodando - - Confirme credenciais no `application.yml` +```env +# Banco de Dados +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=finboost +DB_USER=finboost +DB_PASSWORD=dev123 -2. **Erro de JWT** - - Verifique se `JWT_SECRET` está configurado - - Confirme se token não expirou +# JWT +JWT_SECRET=sua_chave_secreta_muito_longa_aqui +JWT_EXPIRATION=86400000 +``` -3. **Erro de permissões** - - Verifique roles e authorities do usuário - - Confirme configuração do Spring Security +--- -### Logs Úteis -```bash -# Logs em tempo real -./mvnw spring-boot:run | grep ERROR +## 🤝 **Contribuindo** -# Logs do banco -tail -f /var/log/postgresql/postgresql-15-main.log -``` +1. Siga as [convenções do projeto](../CONTRIBUTING.md) +2. Escreva testes para novas funcionalidades +3. Mantenha cobertura > 80% +4. Use padrões de commit: `feat:` `fix:` `test:` --- -## 🤝 Contribuição +## 📞 **Suporte** -1. Siga os padrões de código do projeto -2. Escreva testes para novas funcionalidades -3. Documente endpoints no Swagger -4. Mantenha cobertura de testes > 80% +- 🐛 **Issues:** [GitHub Issues](../../issues) +- 📖 **Docs:** [Documentação Técnica](../docs) +- 💬 **Discord:** [Nosso servidor](link-discord) --- +> Arquivos README.Docker.md e README-SCALAR.md foram consolidados aqui para reduzir duplicação. +
- 🔧 Backend desenvolvido com Spring Boot + 🔧 API Spring Boot - FinBoost+
+ Desenvolvido pelo Grupo 7 - +Prati & Codifica
- diff --git a/backend/TESTING_GUIDE.md b/backend/TESTING_GUIDE.md new file mode 100644 index 00000000..2ac74f6f --- /dev/null +++ b/backend/TESTING_GUIDE.md @@ -0,0 +1,235 @@ +# Guia de Testes Backend (JUnit 5 + Mockito + Spring Boot) + +
+ + + +
+ +
+📚 Sumário + +- [Camadas de Teste](#camadas-de-teste) +- [Comandos](#comandos) +- [Estrutura Recomendada](#estrutura-recomendada) +- [Dependências Principais (já presentes)](#dependências-principais-já-presentes) +- [Exemplos](#exemplos) + - [Teste Unitário (Service + Mockito)](#1-teste-unitário-service--mockito) + - [Controller (@WebMvcTest)](#2-controller-webmvctest) + - [Repository (@DataJpaTest)](#3-repository-datajpatest) + - [Integração (@SpringBootTest)](#4-integração-springboottest) + - [Segurança (Com MockUser)](#5-segurança-com-mockuser) + - [Test Data Factory](#6-test-data-factory) +- [Cobertura](#cobertura) +- [Boas Práticas](#boas-práticas) + - [Exceção](#exceção) +- [Testcontainers (Opcional)](#testcontainers-opcional) +- [Matchers Úteis (AssertJ)](#matchers-úteis-assertj) +- [Problemas Comuns](#problemas-comuns) +- [Execução Rápida](#execução-rápida) + +
+ +## Camadas de Teste +- ✅ Unitários: testam classes isoladas (Services, Utils) com Mockito. +- ✅ Slice Tests: @WebMvcTest (Controllers), @DataJpaTest (Repos), @JsonTest (Serialização). +- ✅ Integração: @SpringBootTest carregando o contexto completo. +- 🔄 (Opcional) Testcontainers: PostgreSQL real em container (sugerido para evitar H2). + +## Comandos +```bash +# Executar testes +./mvnw test + +# Executar com relatório JaCoCo +./mvnw test jacoco:report +open target/site/jacoco/index.html + +# Teste específico (pattern) +./mvnw -Dtest=UserServiceTest test + +# Reexecutar falhas rapidamente +./mvnw -Dtest=UserServiceTest#deveCriarUsuario test +``` + +## Estrutura Recomendada +``` +src/test/java/com/finboostplus/ + config/ # Suportes e utilidades de teste + factory/ # Builders / TestDataFactory + controller/ # Controller (WebMvcTest) + service/ # Unit tests de serviços + repository/ # DataJpaTest + integration/ # SpringBootTest + (Testcontainers) +``` + +## Dependências Principais (já presentes) +- spring-boot-starter-test (JUnit 5, AssertJ, Mockito, JSON) +- spring-security-test (testar segurança) +- jacoco-maven-plugin (cobertura) + +## Exemplos +### 1. Teste Unitário (Service + Mockito) +```java +@ExtendWith(MockitoExtension.class) +class UserServiceTest { + @Mock UserRepository userRepository; + @InjectMocks UserService userService; + + @Test + void deveCriarUsuario() { + var novo = new User("email@test.com","Nome"); + when(userRepository.save(any())).thenAnswer(inv -> { var u = inv.getArgument(0, User.class); u.setId(1L); return u; }); + + var salvo = userService.create(novo); + + assertThat(salvo.getId()).isNotNull(); + verify(userRepository).save(novo); + } +} +``` + +### 2. Controller (@WebMvcTest) +```java +@WebMvcTest(UserController.class) +class UserControllerTest { + @Autowired MockMvc mvc; + @MockBean UserService userService; + + @Test + void deveRetornarUsuario() throws Exception { + when(userService.findById(1L)).thenReturn(new User(1L,"email@test.com")); + + mvc.perform(get("/api/users/1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.email").value("email@test.com")); + } +} +``` + +### 3. Repository (@DataJpaTest) +```java +@DataJpaTest +class UserRepositoryTest { + @Autowired UserRepository repo; + + @Test + void devePersistirEConsultar() { + var u = repo.save(new User(null, "a@b.com")); + var encontrado = repo.findByEmail("a@b.com"); + assertThat(encontrado).isPresent(); + } +} +``` + +### 4. Integração (@SpringBootTest) +```java +@SpringBootTest +@AutoConfigureMockMvc +class AuthFlowIT { + @Autowired MockMvc mvc; + + @Test + void fluxoLogin() throws Exception { + mvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\":\"u@test.com\",\"password\":\"123\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.token").exists()); + } +} +``` + +### 5. Segurança (Com MockUser) +```java +@WebMvcTest(SecureController.class) +class SecureControllerTest { + @Autowired MockMvc mvc; + + @Test @WithMockUser(username="admin", roles={"ADMIN"}) + void devePermitirAdmin() throws Exception { + mvc.perform(get("/api/admin/ping")) + .andExpect(status().isOk()); + } +} +``` + +### 6. Test Data Factory +```java +public class UserFactory { + public static User buildValid() { return new User(null, "user"+UUID.randomUUID()+"@test.com"); } +} +``` + +## Cobertura +- Meta sugerida: >= 80% linhas / 70% branches. +- Focar em regras de negócio (Service, Security, Util). Controllers podem ter menor cobertura. + +## Boas Práticas +1. AAA (Arrange / Act / Assert) claro. +2. Nomes descritivos: deveSalvarUsuarioQuandoDadosValidos. +3. Evitar lógica complexa dentro de testes (sem if/for desnecessário). +4. Não mockar o que você não controla (ex: JPA Entities normalmente não precisam de mock). +5. Um assert principal por cenário (agrupado com assertAll quando necessário). +6. Limpar mocks: @ExtendWith(MockitoExtension) já faz reset por teste. +7. Testar exceções: assertThatThrownBy / assertThrows. + +### Exceção +```java +@Test +void deveFalharSeEmailDuplicado() { + when(repo.existsByEmail("a@b.com")).thenReturn(true); + assertThatThrownBy(() -> service.create(new User(null,"a@b.com"))) + .isInstanceOf(DomainException.class) + .hasMessageContaining("email"); +} +``` + +## Testcontainers (Opcional) +Adicionar no pom (exemplo): +```xml + + org.testcontainers + postgresql + test + +``` +Config: +```java +@Testcontainers +@SpringBootTest +class DbIT { + @Container static PostgreSQLContainer db = new PostgreSQLContainer<>("postgres:15"); + @DynamicPropertySource + static void props(DynamicPropertyRegistry r){ + r.add("spring.datasource.url", db::getJdbcUrl); + r.add("spring.datasource.username", db::getUsername); + r.add("spring.datasource.password", db::getPassword); + } +} +``` + +## Matchers Úteis (AssertJ) +- assertThat(list).hasSize(3).extracting(User::getEmail).contains("a@b.com") +- assertThat(optional).isPresent() +- assertThat(json).contains("token") + +## Problemas Comuns +| Erro | Causa | Solução | +|------|-------|---------| +| Failed to load ApplicationContext | Beans faltando / config circular | Usar slice test ou corrigir dependência | +| LazyInitializationException | Sessão fechou no teste | Forçar fetch ou usar @DataJpaTest isolado | +| Port already in use | Execução paralela de @SpringBootTest | Definir random port / desligar contexto pesado | + +## Execução Rápida +```bash +./mvnw clean test +./mvnw test jacoco:report +``` +Relatório: target/site/jacoco/index.html + +--- +
+ 🧪 Testes Backend - FinBoost+
+ Qualidade e confiabilidade garantidas +
diff --git a/backend/finboostplus_teste/.dockerignore b/backend/finboostplus_server/.dockerignore similarity index 100% rename from backend/finboostplus_teste/.dockerignore rename to backend/finboostplus_server/.dockerignore diff --git a/backend/finboostplus_teste/.gitattributes b/backend/finboostplus_server/.gitattributes similarity index 100% rename from backend/finboostplus_teste/.gitattributes rename to backend/finboostplus_server/.gitattributes diff --git a/backend/finboostplus_teste/.gitignore b/backend/finboostplus_server/.gitignore similarity index 98% rename from backend/finboostplus_teste/.gitignore rename to backend/finboostplus_server/.gitignore index 667aaef0..b9fcd772 100644 --- a/backend/finboostplus_teste/.gitignore +++ b/backend/finboostplus_server/.gitignore @@ -31,3 +31,5 @@ build/ ### VS Code ### .vscode/ + +.env diff --git a/backend/finboostplus_teste/.mvn/wrapper/maven-wrapper.properties b/backend/finboostplus_server/.mvn/wrapper/maven-wrapper.properties similarity index 100% rename from backend/finboostplus_teste/.mvn/wrapper/maven-wrapper.properties rename to backend/finboostplus_server/.mvn/wrapper/maven-wrapper.properties diff --git a/backend/finboostplus_teste/Dockerfile b/backend/finboostplus_server/Dockerfile similarity index 100% rename from backend/finboostplus_teste/Dockerfile rename to backend/finboostplus_server/Dockerfile diff --git a/backend/finboostplus_teste/Dockerfile.dev b/backend/finboostplus_server/Dockerfile.dev similarity index 100% rename from backend/finboostplus_teste/Dockerfile.dev rename to backend/finboostplus_server/Dockerfile.dev diff --git a/backend/finboostplus_server/checkstyle.xml b/backend/finboostplus_server/checkstyle.xml new file mode 100644 index 00000000..11d3e4b6 --- /dev/null +++ b/backend/finboostplus_server/checkstyle.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/backend/finboostplus_server/docker-compose.dev.yml b/backend/finboostplus_server/docker-compose.dev.yml new file mode 100644 index 00000000..812d573e --- /dev/null +++ b/backend/finboostplus_server/docker-compose.dev.yml @@ -0,0 +1,46 @@ +services: + server: + # Ambiente de desenvolvimento (hot reload via volume + DevTools) + build: + context: . + dockerfile: Dockerfile.dev + ports: + - 8080:8080 + env_file: + - .env + depends_on: + db: + condition: service_healthy + volumes: + - .:/build # Código fonte montado (hot reload) + - maven-repo:/root/.m2 # Cache de dependências Maven + environment: + # Variáveis de conexão (application.properties usa placeholders) + - SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/finboostplus + - SPRING_DATASOURCE_USERNAME=${POSTGRES_USERNAME} + - SPRING_DATASOURCE_PASSWORD=${POSTGRES_PASSWORD} + - SPRING_MAIL_USERNAME=${MENSAGERIE_EMAIL} + - SPRING_MAIL_PASSWORD=${MENSAGERIE_PASSWORD} + # Ajuste CORS adicional se necessário + - CORS_ORIGINS=http://localhost:3000,http://localhost:5173 + # Opcional: definir perfil + # command: ["./mvnw","spring-boot:run","-Dspring-boot.run.profiles=dev"] + db: + image: postgres:15 + restart: always + volumes: + - db-data:/var/lib/postgresql/data + environment: + - POSTGRES_DB=finboostplus + - POSTGRES_USER=${POSTGRES_USERNAME} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + ports: + - 5432:5432 + healthcheck: + test: ['CMD','pg_isready','-U','postgres'] + interval: 10s + timeout: 5s + retries: 5 +volumes: + db-data: + maven-repo: diff --git a/backend/finboostplus_server/docker-compose.yml b/backend/finboostplus_server/docker-compose.yml new file mode 100644 index 00000000..d9e1587f --- /dev/null +++ b/backend/finboostplus_server/docker-compose.yml @@ -0,0 +1,41 @@ +services: + server: + build: + context: . + ports: + - 8080:8080 + env_file: + - .env + depends_on: + db: + condition: service_healthy + volumes: + - .:/build + environment: + - POSTGRES_URL=jdbc:postgresql://db:5432/finboostplus + - SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/finboostplus + - SPRING_DATASOURCE_USERNAME=${POSTGRES_USERNAME} + - SPRING_DATASOURCE_PASSWORD=${POSTGRES_PASSWORD} + - SPRING_MAIL_USERNAME=${MENSAGERIE_EMAIL} + - SPRING_MAIL_PASSWORD=${MENSAGERIE_PASSWORD} + - SPRING_CLIENT_ID=${CLIENT_ID} + - SPRING_CLIENT_SECRET=${CLIENT_SECRET} + - CORS_ORIGINS=${CORS_ORIGINS} + db: + image: postgres + restart: always + volumes: + - db-data:/var/lib/postgresql + environment: + - POSTGRES_DB=finboostplus + - POSTGRES_USER=${POSTGRES_USERNAME} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + ports: + - 5432:5432 + healthcheck: + test: ['CMD', 'pg_isready', '-U', 'postgres'] + interval: 10s + timeout: 5s + retries: 5 +volumes: + db-data: diff --git a/backend/finboostplus_teste/mvnw b/backend/finboostplus_server/mvnw old mode 100755 new mode 100644 similarity index 100% rename from backend/finboostplus_teste/mvnw rename to backend/finboostplus_server/mvnw diff --git a/backend/finboostplus_teste/mvnw.cmd b/backend/finboostplus_server/mvnw.cmd similarity index 100% rename from backend/finboostplus_teste/mvnw.cmd rename to backend/finboostplus_server/mvnw.cmd diff --git a/backend/finboostplus_server/pom.xml b/backend/finboostplus_server/pom.xml new file mode 100644 index 00000000..70cd1842 --- /dev/null +++ b/backend/finboostplus_server/pom.xml @@ -0,0 +1,186 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.5.4 + + + com.finboostplus + com.fimboostplus + 0.0.1-SNAPSHOT + demo + Demo project for Spring Boot + + + + Mozilla Public License 2.0 + https://www.mozilla.org/MPL/2.0/ + repo + + + + + + + + + + + + + 21 + 2.7.0 + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-devtools + runtime + true + + + org.postgresql + postgresql + runtime + + + + com.h2database + h2 + runtime + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-docker-compose + test + + + org.projectlombok + lombok + true + + + jakarta.validation + jakarta.validation-api + + + org.hibernate.validator + hibernate-validator + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.security + spring-security-test + test + + + org.springframework.security + spring-security-oauth2-authorization-server + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + + + org.springframework.boot + spring-boot-starter-mail + 4.0.0-M3 + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + ${springdoc.version} + + + org.springdoc + springdoc-openapi-starter-common + ${springdoc.version} + + + + org.flywaydb + flyway-core + 11.15.0 + + + org.flywaydb + flyway-database-postgresql + 11.15.0 + runtime + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.apache.maven.plugins + maven-checkstyle-plugin + 3.6.0 + + + checkstyle + verify + + check + + + + + checkstyle.xml + + true + false + + + + + org.jacoco + jacoco-maven-plugin + 0.8.12 + + + + prepare-agent + + + + report + test + + report + + + + + + + + + + diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/CategoryDTO.java b/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/CategoryDTO.java new file mode 100644 index 00000000..5aa00a00 --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/CategoryDTO.java @@ -0,0 +1,13 @@ +package com.finboostplus.DTO; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class CategoryDTO { + private Long id; + private String name; +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/CategoryRegisterDTO.java b/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/CategoryRegisterDTO.java new file mode 100644 index 00000000..36d7ec63 --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/CategoryRegisterDTO.java @@ -0,0 +1,7 @@ +package com.finboostplus.DTO; + +public interface CategoryRegisterDTO { + Long getQuantity(); + + String getCategory(); +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/ChangePasswordDTO.java b/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/ChangePasswordDTO.java new file mode 100644 index 00000000..1b7f589d --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/ChangePasswordDTO.java @@ -0,0 +1,4 @@ +package com.finboostplus.DTO; + +public record ChangePasswordDTO(String oldPassword, String newPassword) { +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/ExpenseCreateDTO.java b/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/ExpenseCreateDTO.java new file mode 100644 index 00000000..2bee1aca --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/ExpenseCreateDTO.java @@ -0,0 +1,33 @@ +package com.finboostplus.DTO; + +import com.fasterxml.jackson.annotation.JsonFormat; +import jakarta.validation.constraints.*; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.Set; + +public record ExpenseCreateDTO( + @NotBlank(message = "O título não pode ser vazio") + @Size(min = 3, max = 100, message = "O título deve ter entre 3 e 100 caracteres") + String title, + + @Size(max = 255, message = "A descrição deve ter no máximo 255 caracteres") + String description, + + @NotNull(message = "A data limite é obrigatória") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd") + LocalDate deadlineDate, + + @NotEmpty(message = "A divisão da despesa deve conter pelo menos um membro") + Set expenseDivision, + + @NotNull(message = "O valor não pode ser nulo") + @DecimalMin(value = "0.0", inclusive = false, message = "O valor deve ser maior que zero") + @Digits(integer = 10, fraction = 2, message = "O valor deve ter no máximo 2 casas decimais") + BigDecimal expenseValue, + + @NotNull(message = "A categoria não pode ser nula") + @Positive(message = "O ID da categoria deve ser positivo") + Long categoryId) { +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/ExpenseDTO.java b/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/ExpenseDTO.java new file mode 100644 index 00000000..6a7e4d47 --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/ExpenseDTO.java @@ -0,0 +1,8 @@ +package com.finboostplus.DTO; + +import com.finboostplus.model.Category; + +import java.math.BigDecimal; + +public record ExpenseDTO(Long id, String title, String description, BigDecimal value) { +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/ExpenseRequestDTO.java b/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/ExpenseRequestDTO.java new file mode 100644 index 00000000..19c27fe6 --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/ExpenseRequestDTO.java @@ -0,0 +1,24 @@ +package com.finboostplus.DTO; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.finboostplus.model.Expense; + +import jakarta.validation.constraints.NotNull; + +public record ExpenseRequestDTO(String title, String description, String value, + @NotNull(message = "A data limite é obrigatória") @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd") LocalDate deadlineDate, + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") Instant createdAt) { + public Expense expDToToExpense() { + Expense exp = new Expense(); + exp.setTitle(title); + exp.setDescription(description); + exp.setValue(BigDecimal.valueOf(Double.parseDouble(value))); + exp.setDeadlineDate(deadlineDate); + exp.setCreatedAt(createdAt); + return exp; + } +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/ExpenseResponseDTO.java b/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/ExpenseResponseDTO.java new file mode 100644 index 00000000..9d279f8b --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/ExpenseResponseDTO.java @@ -0,0 +1,22 @@ +package com.finboostplus.DTO; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; + +public record ExpenseResponseDTO( + Long id, + + String title, + + String description, + + BigDecimal value, + + LocalDate deadlineDate, + + Instant createAt) { + + +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/ExpenseUpdateDTO.java b/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/ExpenseUpdateDTO.java new file mode 100644 index 00000000..98a5859d --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/ExpenseUpdateDTO.java @@ -0,0 +1,24 @@ +package com.finboostplus.DTO; + +import com.fasterxml.jackson.annotation.JsonFormat; +import jakarta.validation.constraints.*; + +import java.time.LocalDate; + + +public record ExpenseUpdateDTO( + @NotBlank(message = "O título não pode ser vazio") + @Size(min = 3, max = 100, message = "O título deve ter entre 3 e 100 caracteres") + String title, + + @Size(max = 255, message = "A descrição deve ter no máximo 255 caracteres") + String description, + + @NotNull(message = "A data limite é obrigatória") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd") + LocalDate deadlineDate, + + @NotNull(message = "A categoria não pode ser nula") + @Positive(message = "O ID da categoria deve ser positivo") + Long categoryId) { +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/GroupCreateDTO.java b/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/GroupCreateDTO.java new file mode 100644 index 00000000..5877ca23 --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/GroupCreateDTO.java @@ -0,0 +1,27 @@ +package com.finboostplus.DTO; + +import com.finboostplus.model.Group; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +import java.time.Instant; + +public record GroupCreateDTO( + @NotBlank(message = "O nome de grupo é obrigatório") + @Size(max = 100, message = "O nome do grupo deve ter no máximo 100 caracteres") + String name, + + @Size(min = 0, max = 100, message = "A descrição do grupo deve ter no máximo 100 caracteres") + String description, + + String icon) { + + public Group groupDtoToGroup() { + Group group = new Group(); + group.setName(name); + group.setDescription(description); + group.setIcon(icon); + group.setCreatedAt(Instant.now()); + return group; + } +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/GroupDTO.java b/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/GroupDTO.java new file mode 100644 index 00000000..67cda639 --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/GroupDTO.java @@ -0,0 +1,18 @@ +package com.finboostplus.DTO; + +import java.time.Instant; +import java.time.LocalDate; + +import org.springframework.beans.factory.annotation.Value; + +public record GroupDTO( + Long id, + + String name, + + String description, + + String icon, + + Instant createdAt) { +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/GroupDetailsDTO.java b/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/GroupDetailsDTO.java new file mode 100644 index 00000000..f95707d7 --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/GroupDetailsDTO.java @@ -0,0 +1,7 @@ +package com.finboostplus.DTO; + +import java.math.BigDecimal; + +public record GroupDetailsDTO(Long id, String name, String description, String icon, String authorization, BigDecimal partial_total, + BigDecimal total) { +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/GroupDto.java b/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/GroupDto.java new file mode 100644 index 00000000..5dd9611a --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/GroupDto.java @@ -0,0 +1,19 @@ +package com.finboostplus.DTO; + +import java.util.Set; + +public record GroupDto(Long id, String name, String icon, String description, Set expense) { + +// public Group groupDtoToGroup(){ +// +// Expense e = new Expense(); +// Group group = new Group(); +// group.setId(id); +// group.setName(name); +// group.setDescription(description); +// group.setExpenses(expense); +// +// return group; +// } + +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/GroupExpenseDTO.java b/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/GroupExpenseDTO.java new file mode 100644 index 00000000..cdee1ac1 --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/GroupExpenseDTO.java @@ -0,0 +1,13 @@ +package com.finboostplus.DTO; + +import com.finboostplus.enums.Status; + +import java.math.BigDecimal; + +public record GroupExpenseDTO(Long id, String title, BigDecimal value, Status status) { + + public GroupExpenseDTO(Long id, String title, BigDecimal value, String status) { + this(id, title, value, Status.valueOf(status)); + } +} + diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/GroupMemberAuthorityDTO.java b/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/GroupMemberAuthorityDTO.java new file mode 100644 index 00000000..3d045d0e --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/GroupMemberAuthorityDTO.java @@ -0,0 +1,3 @@ +package com.finboostplus.DTO; + +public record GroupMemberAuthorityDTO(String authority){} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/GroupMemberDTO.java b/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/GroupMemberDTO.java new file mode 100644 index 00000000..f2941981 --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/GroupMemberDTO.java @@ -0,0 +1,15 @@ +package com.finboostplus.DTO; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record GroupMemberDTO( + @NotBlank(message = "O e-mail é obrigatório") + @Email(message = "E-mail inválido") + @Size(max = 100, message = "O e-mail deve ter no máximo 100 caracteres") + String email, + + @NotBlank(message = "A autorização é obrigatória") + String authorization +) {} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/GroupMemberResponseDTO.java b/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/GroupMemberResponseDTO.java new file mode 100644 index 00000000..0203e570 --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/GroupMemberResponseDTO.java @@ -0,0 +1,4 @@ +package com.finboostplus.DTO; + +public record GroupMemberResponseDTO(Long id, String name, String themeColor, String authority) { +}; diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/GroupUpdateDTO.java b/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/GroupUpdateDTO.java new file mode 100644 index 00000000..7cbf7da5 --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/GroupUpdateDTO.java @@ -0,0 +1,8 @@ +package com.finboostplus.DTO; + +public record GroupUpdateDTO( + Long id, + String name, + String description, + String icon) { +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/MembersExpenseDivisionCreateDTO.java b/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/MembersExpenseDivisionCreateDTO.java new file mode 100644 index 00000000..9026ae65 --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/MembersExpenseDivisionCreateDTO.java @@ -0,0 +1,23 @@ +package com.finboostplus.DTO; +import com.finboostplus.model.User; +import jakarta.validation.constraints.*; + +import java.math.BigDecimal; + +public record MembersExpenseDivisionCreateDTO( + @NotNull(message = "O ID não pode ser nulo") + @Positive(message = "O ID deve ser positivo") + Long id, + + @NotNull(message = "O valor não pode ser nulo") + @DecimalMin(value = "0.0", inclusive = false, message = "O valor deve ser maior que zero") + @Digits(integer = 10, fraction = 2) // até 10 dígitos antes da vírgula e 2 depois + BigDecimal value +) { + + public User userExpenseDivisionCreateDTOToUser(){ + User user = new User(); + user.setId(id); + return user; + } +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/SwitchAuthorityRequestDTO.java b/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/SwitchAuthorityRequestDTO.java new file mode 100644 index 00000000..ab15e2fa --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/SwitchAuthorityRequestDTO.java @@ -0,0 +1,7 @@ +package com.finboostplus.DTO; + + +public record SwitchAuthorityRequestDTO(String setAuthority) { + + +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/UserCreateDTO.java b/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/UserCreateDTO.java new file mode 100644 index 00000000..2fc2ae09 --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/UserCreateDTO.java @@ -0,0 +1,22 @@ +package com.finboostplus.DTO; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record UserCreateDTO( + @NotBlank(message = "O nome de usuário é obrigatório") + @Size(min = 3, max = 50, message = "O nome de usuário deve ter entre 3 e 50 caracteres") + String name, + + @NotBlank(message = "O e-mail é obrigatório") + @Email(message = "E-mail inválido") + @Size(min = 10, max = 50, message = "O e-mail deve ter no máximo 50 caracteres") + String email, + + @NotBlank(message = "A senha é obrigatória") + @Size(min = 6, max = 50, message = "A senha deve ter no máximo 50 caracteres") + String password, + + String themeColor +) {} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/UserDataDTO.java b/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/UserDataDTO.java new file mode 100644 index 00000000..df4b7456 --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/UserDataDTO.java @@ -0,0 +1,11 @@ +package com.finboostplus.DTO; + +public record UserDataDTO( + Long id, + + String name, + + String email, + + String themeColor) { +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/UserExpenseDivisionDTO.java b/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/UserExpenseDivisionDTO.java new file mode 100644 index 00000000..5e516cd5 --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/UserExpenseDivisionDTO.java @@ -0,0 +1,13 @@ +package com.finboostplus.DTO; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; + +import com.finboostplus.enums.Status; +import com.finboostplus.projection.UserExpenseDivisionProjection; + +public record UserExpenseDivisionDTO(Long expenseId, String title, String description, Long groupId, String groupName, + Status status, BigDecimal total, Long categoryId, String categoryName, Instant createdAt, LocalDate deadlineDate, List memberList) { +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/UserExpensesDTO.java b/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/UserExpensesDTO.java new file mode 100644 index 00000000..8675fa94 --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/UserExpensesDTO.java @@ -0,0 +1,29 @@ +package com.finboostplus.DTO; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; + +import org.springframework.beans.factory.annotation.Value; + +public interface UserExpensesDTO { + Long getExpenseId(); + + String getTitle(); + + Instant getCreatedAt(); + + LocalDate getDeadlineDate(); + + String getCategory(); + + BigDecimal getPartialValue(); + + String getDescription(); + + String getStatus(); + + Long getGroupId(); + + String getGroupName(); +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/UserMonthlyExpensesDTO.java b/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/UserMonthlyExpensesDTO.java new file mode 100644 index 00000000..44208ad8 --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/UserMonthlyExpensesDTO.java @@ -0,0 +1,7 @@ +package com.finboostplus.DTO; + +import java.math.BigDecimal; + +public record UserMonthlyExpensesDTO(String month, + BigDecimal total) { +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/UserUpdateDTO.java b/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/UserUpdateDTO.java new file mode 100644 index 00000000..75ef89e9 --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/DTO/UserUpdateDTO.java @@ -0,0 +1,18 @@ +package com.finboostplus.DTO; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record UserUpdateDTO( + @NotBlank(message = "O nome de usuário é obrigatório") + @Size(min = 3, max = 50, message = "O nome de usuário deve ter entre 3 e 50 caracteres") + String name, + + @NotBlank(message = "O e-mail é obrigatório") + @Email(message = "E-mail inválido") + @Size(min = 10, max = 50, message = "O e-mail deve ter no máximo 50 caracteres") + String email, + + String themeColor) { +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/FinboostplusApplication.java b/backend/finboostplus_server/src/main/java/com/finboostplus/FinboostplusApplication.java new file mode 100644 index 00000000..7a6109b7 --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/FinboostplusApplication.java @@ -0,0 +1,32 @@ +package com.finboostplus; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableScheduling; + +import com.finboostplus.repository.GroupMemberRepository; +import com.finboostplus.repository.GroupRepository; +import com.finboostplus.repository.UserRepository; + +@SpringBootApplication +@EnableJpaAuditing +@EnableScheduling +public class FinboostplusApplication implements CommandLineRunner { + @Autowired + UserRepository userRepository; + @Autowired + GroupRepository groupRepository; + @Autowired + GroupMemberRepository groupMemberRepository; + + public static void main(String[] args) { + SpringApplication.run(FinboostplusApplication.class, args); + } + + @Override + public void run(String... args) throws Exception { + } +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/config/AuthorizationServerConfig.java b/backend/finboostplus_server/src/main/java/com/finboostplus/config/AuthorizationServerConfig.java new file mode 100644 index 00000000..df352e67 --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/config/AuthorizationServerConfig.java @@ -0,0 +1,231 @@ +package com.finboostplus.config; + +import com.finboostplus.config.customgrant.CustomPasswordAuthenticationConverter; +import com.finboostplus.config.customgrant.CustomPasswordAuthenticationProvider; +import com.finboostplus.config.customgrant.CustomUserAuthorities; +import org.springframework.security.core.GrantedAuthority; + + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.time.Duration; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.OAuth2Token; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; +import org.springframework.security.oauth2.server.authorization.InMemoryOAuth2AuthorizationConsentService; +import org.springframework.security.oauth2.server.authorization.InMemoryOAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.client.InMemoryRegisteredClientRepository; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; +import org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration; +import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer; +import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings; +import org.springframework.security.oauth2.server.authorization.settings.ClientSettings; +import org.springframework.security.oauth2.server.authorization.settings.OAuth2TokenFormat; +import org.springframework.security.oauth2.server.authorization.settings.TokenSettings; +import org.springframework.security.oauth2.server.authorization.token.DelegatingOAuth2TokenGenerator; +import org.springframework.security.oauth2.server.authorization.token.JwtEncodingContext; +import org.springframework.security.oauth2.server.authorization.token.JwtGenerator; +import org.springframework.security.oauth2.server.authorization.token.OAuth2AccessTokenGenerator; +import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer; +import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenGenerator; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.oauth2.server.authorization.token.OAuth2RefreshTokenGenerator; + + +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.proc.SecurityContext; +@Configuration +public class AuthorizationServerConfig { + @Value("${security.client-id}") + private String clientId; + + @Value("${security.client-secret}") + private String clientSecret; + + @Value("${security.jwt.duration}") + private Integer jwtDurationSeconds; + + @Value("${security.refresh-token.duration}") + private Integer refreshTokenDurationDays; + + @Autowired + private UserDetailsService userDetailsService; + + @Bean + @Order(1) + public SecurityFilterChain asSecurityFilterChain(HttpSecurity http) throws Exception { + + http.securityMatcher("/oauth2/**", "/.well-known/**").with(OAuth2AuthorizationServerConfigurer.authorizationServer(), Customizer.withDefaults()); + + // @formatter:off + http.getConfigurer(OAuth2AuthorizationServerConfigurer.class) + .tokenEndpoint(tokenEndpoint -> tokenEndpoint + .accessTokenRequestConverter(new CustomPasswordAuthenticationConverter()) + .authenticationProvider(new CustomPasswordAuthenticationProvider(authorizationService(), tokenGenerator(), userDetailsService, passwordEncoder()))); + + http.oauth2ResourceServer(oauth2ResourceServer -> oauth2ResourceServer.jwt(Customizer.withDefaults())); + // @formatter:on + + return http.build(); + } + + @Bean + public OAuth2AuthorizationService authorizationService() { + return new InMemoryOAuth2AuthorizationService(); + } + + @Bean + public OAuth2AuthorizationConsentService oAuth2AuthorizationConsentService() { + return new InMemoryOAuth2AuthorizationConsentService(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public RegisteredClientRepository registeredClientRepository() { + RegisteredClient registeredClient = RegisteredClient + .withId(UUID.randomUUID().toString()) + .clientId(clientId) + .clientSecret(passwordEncoder().encode(clientSecret)) + .scope("read") + .scope("write") + .authorizationGrantType(new AuthorizationGrantType("password")) + .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) + .tokenSettings(tokenSettings()) + .clientSettings(clientSettings()) + .build(); + + return new InMemoryRegisteredClientRepository(registeredClient); + } + + @Bean + public TokenSettings tokenSettings() { + return TokenSettings.builder() + .accessTokenFormat(OAuth2TokenFormat.SELF_CONTAINED) + .accessTokenTimeToLive(Duration.ofSeconds(jwtDurationSeconds)) + .refreshTokenTimeToLive(Duration.ofDays(refreshTokenDurationDays)) + .reuseRefreshTokens(true) + .build(); + } + + @Bean + public ClientSettings clientSettings() { + return ClientSettings.builder().build(); + } + + @Bean + public AuthorizationServerSettings authorizationServerSettings() { + return AuthorizationServerSettings.builder().build(); + } + + @Bean + public OAuth2TokenGenerator tokenGenerator() { + NimbusJwtEncoder jwtEncoder = new NimbusJwtEncoder(jwkSource()); + JwtGenerator jwtGenerator = new JwtGenerator(jwtEncoder); + jwtGenerator.setJwtCustomizer(tokenCustomizer()); + + OAuth2AccessTokenGenerator accessTokenGenerator = new OAuth2AccessTokenGenerator(); + OAuth2RefreshTokenGenerator refreshTokenGenerator = new OAuth2RefreshTokenGenerator(); // <-- adicionado + + return new DelegatingOAuth2TokenGenerator(jwtGenerator, accessTokenGenerator, refreshTokenGenerator); + } + + + @Bean + public OAuth2TokenCustomizer tokenCustomizer() { + return context -> { + Object principalObj = context.getPrincipal(); + String username = null; + List authorities = List.of(); + + if (principalObj instanceof Authentication) { + Authentication principal = (Authentication) principalObj; + Object details = principal.getDetails(); + + if (details instanceof CustomUserAuthorities) { + CustomUserAuthorities user = (CustomUserAuthorities) details; + username = user.getUsername(); + authorities = user.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.toList()); + } else if (principal.getPrincipal() instanceof UserDetails) { + UserDetails ud = (UserDetails) principal.getPrincipal(); + username = ud.getUsername(); + authorities = ud.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.toList()); + } else { + username = principal.getName(); + } + } else { + username = context.getPrincipal().getName(); + } + + if ("access_token".equals(context.getTokenType().getValue())) { + context.getClaims() + .claim("authorities", authorities) + .claim("username", username); + } + }; + } + + + @Bean + public JwtDecoder jwtDecoder(JWKSource jwkSource) { + return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource); + } + + @Bean + public JWKSource jwkSource() { + RSAKey rsaKey = generateRsa(); + JWKSet jwkSet = new JWKSet(rsaKey); + return (jwkSelector, securityContext) -> jwkSelector.select(jwkSet); + } + + private static RSAKey generateRsa() { + KeyPair keyPair = generateRsaKey(); + RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic(); + RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate(); + return new RSAKey.Builder(publicKey).privateKey(privateKey).keyID(UUID.randomUUID().toString()).build(); + } + + private static KeyPair generateRsaKey() { + KeyPair keyPair; + try { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + keyPair = keyPairGenerator.generateKeyPair(); + } catch (Exception ex) { + throw new IllegalStateException(ex); + } + return keyPair; + } +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/config/OpenApiConfig.java b/backend/finboostplus_server/src/main/java/com/finboostplus/config/OpenApiConfig.java new file mode 100644 index 00000000..5540bae3 --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/config/OpenApiConfig.java @@ -0,0 +1,83 @@ +package com.finboostplus.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.info.License; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.servers.Server; +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.ExternalDocumentation; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +@Configuration +public class OpenApiConfig { + + @Bean + public OpenAPI customOpenAPI() { + return new OpenAPI() + .info(new Info() + .title("FinBoost Plus API") + .description(""" + # API para Gerenciamento Financeiro Pessoal + + A **FinBoost Plus API** oferece funcionalidades completas para controle financeiro pessoal, + incluindo gerenciamento de gastos, grupos colaborativos e análises financeiras avançadas. + + ## Funcionalidades Principais + + - **Gestão de Usuários**: Cadastro, autenticação e perfis personalizados + - **Controle de Gastos**: Registro e categorização de despesas + - **Grupos Colaborativos**: Compartilhamento de gastos em grupo + - **Análises Financeiras**: Relatórios e insights sobre seus gastos + - **Personalização**: Temas de cores e preferências do usuário + + ## Autenticação + + Esta API utiliza **JWT (JSON Web Tokens)** para autenticação segura. + Para usar endpoints protegidos: + + 1. Faça login para obter seu token JWT + 2. Clique no botão "Authorize" acima + 3. Insira: `Bearer seu_token_aqui` + 4. Teste os endpoints protegidos! + + ## Como Usar + + 1. **Cadastre-se** usando o endpoint `POST /user` + 2. **Autentique-se** para obter seu token JWT + 3. **Explore** os endpoints disponíveis + 4. **Teste** diretamente nesta interface! + """) + .version("1.0.0") + .contact(new Contact() + .name("FinBoost Plus Team") + .email("contato@finboostplus.com") + .url("https://finboostplus.com")) + .license(new License() + .name("MIT License") + .url("https://opensource.org/licenses/MIT"))) + .servers(List.of( + new Server() + .url("http://localhost:8080") + .description("Servidor de Desenvolvimento"), + new Server() + .url("https://api.finboostplus.com") + .description("Servidor de Produção") + )) + .externalDocs(new ExternalDocumentation() + .description("Documentação Completa do Projeto") + .url("https://github.com/finboostplus/docs")) + .components(new Components() + .addSecuritySchemes("bearerAuth", new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .description("Token JWT para autenticação. Formato: Bearer {token}"))) + .addSecurityItem(new SecurityRequirement().addList("bearerAuth")); + } +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/config/ResourceServerConfig.java b/backend/finboostplus_server/src/main/java/com/finboostplus/config/ResourceServerConfig.java new file mode 100644 index 00000000..ae57f399 --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/config/ResourceServerConfig.java @@ -0,0 +1,77 @@ +package com.finboostplus.config; + +import java.util.Arrays; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; +import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.filter.CorsFilter; + +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +public class ResourceServerConfig { + @Value("${cors.origins}") + private String corsOrigins; + + @Bean + @Order(2) + public SecurityFilterChain rsSecurityFilterChain(HttpSecurity http) throws Exception { + http.csrf(csrf -> csrf.disable()); + http.authorizeHttpRequests(authorize -> authorize + // Permitir acesso público à documentação da API + .requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html", "/docs/**").permitAll() + .anyRequest().permitAll()); + http.oauth2ResourceServer(oauth2ResourceServer -> oauth2ResourceServer.jwt(Customizer.withDefaults())); + http.cors(cors -> cors.configurationSource(corsConfigurationSource())); + return http.build(); + } + + @Bean + public JwtAuthenticationConverter jwtAuthenticationConverter() { + JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter(); + grantedAuthoritiesConverter.setAuthoritiesClaimName("authorities"); + grantedAuthoritiesConverter.setAuthorityPrefix(""); + + JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter(); + jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter); + return jwtAuthenticationConverter; + } + + @Bean + CorsConfigurationSource corsConfigurationSource() { + + String[] origins = corsOrigins.split(","); + + CorsConfiguration corsConfig = new CorsConfiguration(); + corsConfig.setAllowedOriginPatterns(Arrays.asList(origins)); + corsConfig.setAllowedMethods(Arrays.asList("POST", "GET", "PUT", "DELETE", "PATCH")); + corsConfig.setAllowCredentials(true); + corsConfig.setAllowedHeaders(Arrays.asList("Authorization", "Content-Type")); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", corsConfig); + return source; + } + + @Bean + FilterRegistrationBean filterRegistrationBeanCorsFilter() { + FilterRegistrationBean bean = new FilterRegistrationBean<>( + new CorsFilter(corsConfigurationSource())); + bean.setOrder(Ordered.HIGHEST_PRECEDENCE); + return bean; + } +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/config/TokenRevocationUtil.java b/backend/finboostplus_server/src/main/java/com/finboostplus/config/TokenRevocationUtil.java new file mode 100644 index 00000000..05dc9b8e --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/config/TokenRevocationUtil.java @@ -0,0 +1,45 @@ +package com.finboostplus.config; + + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.authorization.OAuth2Authorization; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.OAuth2TokenType; + +public class TokenRevocationUtil { + public static void revokeCurrentUserTokens( + OAuth2AuthorizationService authorizationService) { + + // 1. Recuperar token do SecurityContext + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null) { + throw new IllegalStateException("Usuário não autenticado"); + } + + String tokenValue = null; + if (authentication.getCredentials() instanceof Jwt) { + // Caso o Spring Security tenha colocado o Jwt como credentials + tokenValue = ((Jwt) authentication.getCredentials()).getTokenValue(); + } else if (authentication.getCredentials() instanceof String) { + // Caso tenha só a string do token + tokenValue = (String) authentication.getCredentials(); + } + + if (tokenValue == null) { + throw new IllegalStateException("Token JWT não encontrado"); + } + + // 2. Buscar autorização + OAuth2Authorization authorization = authorizationService.findByToken( + tokenValue, + OAuth2TokenType.ACCESS_TOKEN + ); + + if (authorization != null) { + // 3. Revogar (remove também o refresh token associado) + authorizationService.remove(authorization); + } + } +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/config/customgrant/CustomPasswordAuthenticationConverter.java b/backend/finboostplus_server/src/main/java/com/finboostplus/config/customgrant/CustomPasswordAuthenticationConverter.java new file mode 100644 index 00000000..a2547f33 --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/config/customgrant/CustomPasswordAuthenticationConverter.java @@ -0,0 +1,86 @@ +package com.finboostplus.config.customgrant; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import org.springframework.lang.Nullable; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.web.authentication.AuthenticationConverter; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; +import jakarta.servlet.http.HttpServletRequest; + +public class CustomPasswordAuthenticationConverter implements AuthenticationConverter { + @Nullable + @Override + public Authentication convert(HttpServletRequest request) { + + String grantType = request.getParameter(OAuth2ParameterNames.GRANT_TYPE); + + if (!"password".equals(grantType)) { + return null; + } + + MultiValueMap parameters = getParameters(request); + + // scope (OPTIONAL) + String scope = parameters.getFirst(OAuth2ParameterNames.SCOPE); + if (StringUtils.hasText(scope) && + parameters.get(OAuth2ParameterNames.SCOPE).size() != 1) { + throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_REQUEST); + } + + // username (REQUIRED) + String username = parameters.getFirst(OAuth2ParameterNames.USERNAME); + if (!StringUtils.hasText(username) || + parameters.get(OAuth2ParameterNames.USERNAME).size() != 1) { + throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_REQUEST); + } + + // password (REQUIRED) + String password = parameters.getFirst(OAuth2ParameterNames.PASSWORD); + if (!StringUtils.hasText(password) || + parameters.get(OAuth2ParameterNames.PASSWORD).size() != 1) { + throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_REQUEST); + } + + Set requestedScopes = null; + if (StringUtils.hasText(scope)) { + requestedScopes = new HashSet<>( + Arrays.asList(StringUtils.delimitedListToStringArray(scope, " "))); + } + + Map additionalParameters = new HashMap<>(); + parameters.forEach((key, value) -> { + if (!key.equals(OAuth2ParameterNames.GRANT_TYPE) && + !key.equals(OAuth2ParameterNames.SCOPE)) { + additionalParameters.put(key, value.get(0)); + } + }); + + Authentication clientPrincipal = SecurityContextHolder.getContext().getAuthentication(); + return new CustomPasswordAuthenticationToken(clientPrincipal, requestedScopes, additionalParameters); + } + + private static MultiValueMap getParameters(HttpServletRequest request) { + + Map parameterMap = request.getParameterMap(); + MultiValueMap parameters = new LinkedMultiValueMap<>(parameterMap.size()); + parameterMap.forEach((key, values) -> { + if (values.length > 0) { + for (String value : values) { + parameters.add(key, value); + } + } + }); + return parameters; + } +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/config/customgrant/CustomPasswordAuthenticationProvider.java b/backend/finboostplus_server/src/main/java/com/finboostplus/config/customgrant/CustomPasswordAuthenticationProvider.java new file mode 100644 index 00000000..785352e9 --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/config/customgrant/CustomPasswordAuthenticationProvider.java @@ -0,0 +1,189 @@ +package com.finboostplus.config.customgrant; + +import java.security.Principal; +import java.util.HashSet; +import java.util.Set; +import java.util.stream.Collectors; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClaimAccessor; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.OAuth2RefreshToken; +import org.springframework.security.oauth2.core.OAuth2Token; +import org.springframework.security.oauth2.server.authorization.OAuth2Authorization; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.OAuth2TokenType; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AccessTokenAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.context.AuthorizationServerContextHolder; +import org.springframework.security.oauth2.server.authorization.token.DefaultOAuth2TokenContext; +import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenContext; +import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenGenerator; +import org.springframework.util.Assert; + +import com.finboostplus.exception.ForbiddenResourceException; +import com.finboostplus.exception.UserNotFoundException; +import com.finboostplus.model.User; +import com.finboostplus.repository.UserRepository; + +public class CustomPasswordAuthenticationProvider implements AuthenticationProvider { + private static final String ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc6749#section-5.2"; + private final OAuth2AuthorizationService authorizationService; + private final UserDetailsService userDetailsService; + private final OAuth2TokenGenerator tokenGenerator; + private final PasswordEncoder passwordEncoder; + private String username = ""; + private String password = ""; + private Set authorizedScopes = new HashSet<>(); + + @Autowired + UserRepository userRepository; + + public CustomPasswordAuthenticationProvider(OAuth2AuthorizationService authorizationService, + OAuth2TokenGenerator tokenGenerator, + UserDetailsService userDetailsService, PasswordEncoder passwordEncoder) { + + Assert.notNull(authorizationService, "authorizationService cannot be null"); + Assert.notNull(tokenGenerator, "TokenGenerator cannot be null"); + Assert.notNull(userDetailsService, "UserDetailsService cannot be null"); + Assert.notNull(passwordEncoder, "PasswordEncoder cannot be null"); + this.authorizationService = authorizationService; + this.tokenGenerator = tokenGenerator; + this.userDetailsService = userDetailsService; + this.passwordEncoder = passwordEncoder; + } + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + + CustomPasswordAuthenticationToken customPasswordAuthenticationToken = (CustomPasswordAuthenticationToken) authentication; + OAuth2ClientAuthenticationToken clientPrincipal = getAuthenticatedClientElseThrowInvalidClient( + customPasswordAuthenticationToken); + RegisteredClient registeredClient = clientPrincipal.getRegisteredClient(); + username = customPasswordAuthenticationToken.getUsername(); + password = customPasswordAuthenticationToken.getPassword(); + + UserDetails userDetails = null; + try { + userDetails = userDetailsService.loadUserByUsername(username); + } catch (UsernameNotFoundException e) { + throw new OAuth2AuthenticationException("Credenciais inválidas"); + } + + if (!passwordEncoder.matches(password, userDetails.getPassword()) + || !userDetails.getUsername().equals(username)) { + throw new OAuth2AuthenticationException("Credenciais inválidas"); + } + + User user = userRepository.findByEmailIgnoreCase(username) + .orElseThrow(() -> new UserNotFoundException("Usuário não encontrado")); + + if (!user.isEnabled()) { + throw new ForbiddenResourceException("Usuário desabilitado"); + } + + authorizedScopes = userDetails.getAuthorities().stream() + .map(scope -> scope.getAuthority()) + .filter(scope -> registeredClient.getScopes().contains(scope)) + .collect(Collectors.toSet()); + + // ----------- set details ON THE clientPrincipal (fix class-cast issue) + // ---------- + CustomUserAuthorities customPasswordUser = new CustomUserAuthorities(username, + userDetails.getAuthorities()); + clientPrincipal.setDetails(customPasswordUser); + + var newcontext = SecurityContextHolder.createEmptyContext(); + newcontext.setAuthentication(clientPrincipal); + SecurityContextHolder.setContext(newcontext); + + // ----------- TOKEN BUILDERS ---------- + DefaultOAuth2TokenContext.Builder tokenContextBuilder = DefaultOAuth2TokenContext.builder() + .registeredClient(registeredClient) + .principal(clientPrincipal) + .authorizationServerContext(AuthorizationServerContextHolder.getContext()) + .authorizedScopes(authorizedScopes) + .authorizationGrantType(new AuthorizationGrantType("password")) + .authorizationGrant(customPasswordAuthenticationToken); + + OAuth2Authorization.Builder authorizationBuilder = OAuth2Authorization + .withRegisteredClient(registeredClient) + .attribute(Principal.class.getName(), clientPrincipal) + .principalName(clientPrincipal.getName()) + .authorizationGrantType(new AuthorizationGrantType("password")) + .authorizedScopes(authorizedScopes); + + // ----------- ACCESS TOKEN ---------- + OAuth2TokenContext tokenContext = tokenContextBuilder.tokenType(OAuth2TokenType.ACCESS_TOKEN).build(); + OAuth2Token generatedAccessToken = this.tokenGenerator.generate(tokenContext); + if (generatedAccessToken == null) { + OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.SERVER_ERROR, + "The token generator failed to generate the access token.", ERROR_URI); + throw new OAuth2AuthenticationException(error); + } + + OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, + generatedAccessToken.getTokenValue(), generatedAccessToken.getIssuedAt(), + generatedAccessToken.getExpiresAt(), tokenContext.getAuthorizedScopes()); + if (generatedAccessToken instanceof ClaimAccessor) { + authorizationBuilder.token(accessToken, + (metadata) -> metadata.put(OAuth2Authorization.Token.CLAIMS_METADATA_NAME, + ((ClaimAccessor) generatedAccessToken).getClaims())); + } else { + authorizationBuilder.accessToken(accessToken); + } + + // ----------- REFRESH TOKEN (mínima adição) ---------- + OAuth2RefreshToken refreshToken = null; + if (registeredClient.getAuthorizationGrantTypes().contains(AuthorizationGrantType.REFRESH_TOKEN)) { + OAuth2TokenContext refreshTokenContext = tokenContextBuilder + .tokenType(OAuth2TokenType.REFRESH_TOKEN).build(); + OAuth2Token generatedRefreshToken = this.tokenGenerator.generate(refreshTokenContext); + if (generatedRefreshToken == null || !(generatedRefreshToken instanceof OAuth2RefreshToken)) { + OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.SERVER_ERROR, + "The token generator failed to generate the refresh token.", ERROR_URI); + throw new OAuth2AuthenticationException(error); + } + refreshToken = (OAuth2RefreshToken) generatedRefreshToken; + authorizationBuilder.refreshToken(refreshToken); + } + + // ----------- SAVE & RETURN ---------- + OAuth2Authorization authorization = authorizationBuilder.build(); + this.authorizationService.save(authorization); + + return new OAuth2AccessTokenAuthenticationToken(registeredClient, clientPrincipal, accessToken, + refreshToken); + } + + @Override + public boolean supports(Class authentication) { + return CustomPasswordAuthenticationToken.class.isAssignableFrom(authentication); + } + + private static OAuth2ClientAuthenticationToken getAuthenticatedClientElseThrowInvalidClient( + Authentication authentication) { + + OAuth2ClientAuthenticationToken clientPrincipal = null; + if (OAuth2ClientAuthenticationToken.class.isAssignableFrom(authentication.getPrincipal().getClass())) { + clientPrincipal = (OAuth2ClientAuthenticationToken) authentication.getPrincipal(); + } + if (clientPrincipal != null && clientPrincipal.isAuthenticated()) { + return clientPrincipal; + } + throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_CLIENT); + } +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/config/customgrant/CustomPasswordAuthenticationToken.java b/backend/finboostplus_server/src/main/java/com/finboostplus/config/customgrant/CustomPasswordAuthenticationToken.java new file mode 100644 index 00000000..5650dc6d --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/config/customgrant/CustomPasswordAuthenticationToken.java @@ -0,0 +1,42 @@ +package com.finboostplus.config.customgrant; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import org.springframework.lang.Nullable; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationGrantAuthenticationToken; + +public class CustomPasswordAuthenticationToken extends OAuth2AuthorizationGrantAuthenticationToken { + private static final long serialVersionUID = 1L; + + private final String username; + private final String password; + private final Set scopes; + + public CustomPasswordAuthenticationToken(Authentication clientPrincipal, + @Nullable Set scopes, @Nullable Map additionalParameters) { + + super(new AuthorizationGrantType("password"), clientPrincipal, additionalParameters); + + this.username = (String) additionalParameters.get("username"); + this.password = (String) additionalParameters.get("password"); + this.scopes = Collections.unmodifiableSet( + scopes != null ? new HashSet<>(scopes) : Collections.emptySet()); + } + + public String getUsername() { + return this.username; + } + + public String getPassword() { + return this.password; + } + + public Set getScopes() { + return this.scopes; + } +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/config/customgrant/CustomUserAuthorities.java b/backend/finboostplus_server/src/main/java/com/finboostplus/config/customgrant/CustomUserAuthorities.java new file mode 100644 index 00000000..c2d5c8c4 --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/config/customgrant/CustomUserAuthorities.java @@ -0,0 +1,21 @@ +package com.finboostplus.config.customgrant; + +import java.util.Collection; +import org.springframework.security.core.GrantedAuthority; +public class CustomUserAuthorities { + private String username; + private Collection authorities; + + public CustomUserAuthorities(String username, Collection authorities) { + this.username = username; + this.authorities = authorities; + } + + public String getUsername() { + return username; + } + + public Collection getAuthorities() { + return authorities; + } +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/controller/ApiDocsController.java b/backend/finboostplus_server/src/main/java/com/finboostplus/controller/ApiDocsController.java new file mode 100644 index 00000000..40b6cdb1 --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/controller/ApiDocsController.java @@ -0,0 +1,85 @@ +package com.finboostplus.controller; + +import org.springframework.http.MediaType; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ResponseBody; + +@Controller +public class ApiDocsController { + + @GetMapping(value = "/docs/scalar", produces = MediaType.TEXT_HTML_VALUE) + @ResponseBody + public String scalarDocs() { + return """ + + + + + + FinBoost Plus API - Documentação + + + +
Carregando documentação da API...
+
+ + + + + """; + } +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/controller/ExpenseController.java b/backend/finboostplus_server/src/main/java/com/finboostplus/controller/ExpenseController.java new file mode 100644 index 00000000..a61a90dc --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/controller/ExpenseController.java @@ -0,0 +1,104 @@ +package com.finboostplus.controller; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.finboostplus.DTO.ExpenseCreateDTO; +import com.finboostplus.DTO.ExpenseUpdateDTO; +import com.finboostplus.DTO.UserExpenseDivisionDTO; +import com.finboostplus.enums.Status; +import com.finboostplus.projection.GroupMemberExpenseProjection; +import com.finboostplus.service.CategoryService; +import com.finboostplus.service.ExpenseService; + +import jakarta.validation.Valid; + +@RestController +@PreAuthorize("hasRole('USER')") +@RequestMapping("groups/{groupId}/expenses") +public class ExpenseController { + + @Autowired + ExpenseService expenseService; + + @Autowired + CategoryService categoryService; + + @PostMapping + public ResponseEntity createNewExpense(@Valid @RequestBody ExpenseCreateDTO dto, + @PathVariable Long groupId) { + return expenseService.createNewExpense(dto, groupId) + ? new ResponseEntity("Despesa criada com sucesso!", HttpStatus.CREATED) + : new ResponseEntity("", HttpStatus.BAD_REQUEST); + } + + @GetMapping("{expenseId}") + public ResponseEntity getExpenseDetails(@PathVariable Long groupId, + @PathVariable Long expenseId) { + UserExpenseDivisionDTO expenseDivDTO = expenseService.getExpenseInfoDetails(groupId, expenseId); + if (expenseDivDTO != null) { + return ResponseEntity.ok(expenseDivDTO); + } + return ResponseEntity.status(HttpStatus.NOT_FOUND).body("Despesa não encontrada"); + } + + @GetMapping + public ResponseEntity> getAllGroupExpenses( + @PathVariable Long groupId, + @RequestParam(required = false) Status status, + @RequestParam(defaultValue = "true") boolean allMemberExpenses, + @RequestParam(defaultValue = "false") boolean allGroupMembersExpenses, + @RequestParam(name = "page", defaultValue = "0") Integer page, + @RequestParam(name = "size", defaultValue = "4") Integer size) { + Pageable pageable = PageRequest.of(page, size); + if (status == null) { + status = Status.NOTAPPLY; + } + Page expenses = expenseService.getAllGroupExpenses( + groupId, status, allMemberExpenses, allGroupMembersExpenses, pageable); + return ResponseEntity.ok(expenses); + } + + @PutMapping("{expenseId}") + public ResponseEntity updateExpense(@Valid @RequestBody ExpenseUpdateDTO dto, + @PathVariable Long groupId, @PathVariable Long expenseId) { + return expenseService.updateExpense(dto, groupId, expenseId) + ? new ResponseEntity("Despesa atualizada com sucesso!", HttpStatus.OK) + : new ResponseEntity("", HttpStatus.BAD_REQUEST); + } + + @PatchMapping("/{expenseId}/member/{memberId}") + public ResponseEntity updateMemberExpenseStatus( + @PathVariable Long memberId, + @PathVariable Long groupId, + @PathVariable Long expenseId, + ExpenseUpdateDTO dto) { + boolean isUpdated = expenseService.updateExpenseStatus(memberId, groupId, + expenseId, dto); + if (isUpdated) { + return ResponseEntity.status(HttpStatus.OK).body("Atualizado com sucesso!"); + } + return ResponseEntity.badRequest().body("Não foi possivel atualizar o status da despesa"); + } + + @DeleteMapping("{expenseId}") + public ResponseEntity deleteExpense(@PathVariable Long expenseId, @PathVariable Long groupId) { + expenseService.deleteExpense(expenseId, groupId); + return ResponseEntity.noContent().build(); + } +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/controller/GroupController.java b/backend/finboostplus_server/src/main/java/com/finboostplus/controller/GroupController.java new file mode 100644 index 00000000..f0adff04 --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/controller/GroupController.java @@ -0,0 +1,156 @@ +package com.finboostplus.controller; + +import java.util.List; +import java.util.Optional; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.finboostplus.DTO.CategoryDTO; +import com.finboostplus.DTO.GroupCreateDTO; +import com.finboostplus.DTO.GroupDetailsDTO; +import com.finboostplus.DTO.GroupMemberAuthorityDTO; +import com.finboostplus.DTO.GroupMemberDTO; +import com.finboostplus.DTO.GroupMemberResponseDTO; +import com.finboostplus.DTO.GroupUpdateDTO; +import com.finboostplus.DTO.SwitchAuthorityRequestDTO; +import com.finboostplus.model.Group; +import com.finboostplus.projection.GroupProjection; +import com.finboostplus.repository.UserRepository; +import com.finboostplus.service.CategoryService; +import com.finboostplus.service.ExpenseService; +import com.finboostplus.service.GroupMemberService; +import com.finboostplus.service.GroupService; +import com.finboostplus.service.UserService; + +import jakarta.validation.Valid; + +@RestController +@PreAuthorize("hasRole('USER')") +@RequestMapping("/groups") +public class GroupController { + @Autowired + GroupService groupService; + + @Autowired + UserService userService; + + @Autowired + GroupMemberService groupMemberService; + + @Autowired + CategoryService categoryService; + + @Autowired + ExpenseService expenseService; + + @Autowired + UserRepository userRepository; + + @GetMapping("/expenses/categories") + public ResponseEntity> getCategory() { + return ResponseEntity.ok(categoryService.findAllCategories()); + } + + @GetMapping + public ResponseEntity> listUserGroupsPaged( + @RequestParam(name = "page", defaultValue = "0") int page, + @RequestParam(name = "size", defaultValue = "6") int size) { + Pageable pageable = PageRequest.of(page, size); + Page groupsDTO = groupService.listUserGroupsPaged(pageable); + return new ResponseEntity<>(groupsDTO, HttpStatus.OK); + } + + @GetMapping("/{groupId}/members") + public ResponseEntity> findAllMembersByGroupId(@PathVariable Long groupId, + @RequestParam(name = "page", defaultValue = "0") Integer page, + @RequestParam(name = "size", defaultValue = "10") Integer size, + @RequestParam(name = "search", defaultValue = "") String search) { + Pageable pageable = PageRequest.of(page, size, Sort.by("user_name").ascending()); + return new ResponseEntity<>(groupService.findAllMembersByGroupId(groupId, pageable, search), + HttpStatus.OK); + } + + @GetMapping("/{groupId}") + public ResponseEntity getGroupDetails(@PathVariable Long groupId) { + GroupDetailsDTO dto = groupService.getGroupDetails(groupId); + return new ResponseEntity<>(dto, HttpStatus.OK); + } + + @GetMapping("/{groupId}/members/{memberId}/authority") + public ResponseEntity getMemberAuthority(@PathVariable Long groupId, @PathVariable Long memberId) { + GroupMemberAuthorityDTO authority = groupService.getMemberAuthority(groupId, memberId); + return ResponseEntity.ok(authority); + } + + @PostMapping + public ResponseEntity createGroup(@Valid @RequestBody GroupCreateDTO dto) { + boolean groupIsCreated = groupService.createNewGroup(dto); + if (groupIsCreated) { + return new ResponseEntity<>("Grupo criado com sucesso!", HttpStatus.CREATED); + } + return new ResponseEntity<>(HttpStatus.BAD_REQUEST); + } + + @PostMapping("/{groupId}/members") + public ResponseEntity addGroupMember(@PathVariable Long groupId, + @RequestBody GroupMemberDTO groupMemberDTO) { + groupMemberService.addGroupMember(groupId, groupMemberDTO); + return new ResponseEntity<>("Membro adicionado com sucesso!", HttpStatus.OK); + } + + @PutMapping("/{groupId}/members/{newAuthId}/transfer-ownership") + public ResponseEntity switchAuthority(@PathVariable Long groupId, + @PathVariable Long newAuthId, + @RequestBody SwitchAuthorityRequestDTO authDTO) { + if (userService.switchAuthority(newAuthId, groupId, authDTO)) { + return ResponseEntity.ok("Autoridade transferida com sucesso"); + } + return ResponseEntity.badRequest().body("Não foi possivel realizar a transferência"); + } + + @PutMapping("/{id}") + public ResponseEntity updateGroup(@PathVariable Long id, @RequestBody GroupUpdateDTO groupUpdateDTO) { + Optional group = groupService.updateGroup(id, groupUpdateDTO); + if (group.isEmpty()) { + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + } + return new ResponseEntity<>("Grupo atualizado com sucesso!", HttpStatus.OK); + } + + @DeleteMapping("/{groupId}/members") + public ResponseEntity leaveGroup(@PathVariable Long groupId) { + groupService.leaveGroup(groupId); + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/{groupId}/members/{memberId}") + public ResponseEntity removeGroupMember( + @PathVariable Long groupId, + @PathVariable Long memberId) { + groupService.removeGroupMember(groupId, memberId); + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/{groupId}") + public ResponseEntity deleteGroup( + @PathVariable Long groupId) { + groupService.deleteGroup(groupId); + return ResponseEntity.noContent().build(); + } +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/controller/UserController.java b/backend/finboostplus_server/src/main/java/com/finboostplus/controller/UserController.java new file mode 100644 index 00000000..f93960fb --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/controller/UserController.java @@ -0,0 +1,213 @@ +package com.finboostplus.controller; + +import java.util.List; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.finboostplus.DTO.CategoryRegisterDTO; +import com.finboostplus.DTO.ChangePasswordDTO; +import com.finboostplus.DTO.UserCreateDTO; +import com.finboostplus.DTO.UserDataDTO; +import com.finboostplus.DTO.UserExpensesDTO; +import com.finboostplus.DTO.UserMonthlyExpensesDTO; +import com.finboostplus.DTO.UserUpdateDTO; +import com.finboostplus.config.TokenRevocationUtil; +import com.finboostplus.service.ExpenseService; +import com.finboostplus.service.UserService; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.web.servlet.view.RedirectView; + +@RestController +@RequestMapping("/user") +@Tag(name = "Usuários", description = "Operações relacionadas ao gerenciamento de usuários e perfis no sistema FinBoost Plus") +public class UserController { + @Autowired + UserService userService; + + @Autowired + ExpenseService expenseService; + + @Autowired + OAuth2AuthorizationService authorizationService; + + @PostMapping + @Operation(summary = "Criar novo usuário", description = """ + Cria um novo perfil de usuário no sistema FinBoost Plus. + + Este endpoint permite o cadastro de novos usuários fornecendo informações básicas + como nome, email, senha e tema de cor preferido. + + **Regras de validação:** + - Email deve ser único no sistema + - Senha deve ter pelo menos 8 caracteres + - Nome é obrigatório e deve ter entre 2-100 caracteres + """) + @ApiResponses(value = { + @ApiResponse(responseCode = "201", description = "Usuário criado com sucesso", content = @Content(mediaType = "text/plain", schema = @Schema(type = "string"), examples = @ExampleObject(value = "Cadastro feito com sucesso!"))), + @ApiResponse(responseCode = "400", description = "Dados inválidos ou email já existe", content = @Content(mediaType = "text/plain", schema = @Schema(type = "string"), examples = @ExampleObject(value = "Erro de validação"))), + @ApiResponse(responseCode = "422", description = "Erro de validação dos campos obrigatórios") + }) + public ResponseEntity saveProfile( + @Parameter(description = "Dados do usuário para cadastro", required = true, content = @Content(schema = @Schema(implementation = UserCreateDTO.class), examples = @ExampleObject(name = "Exemplo de usuário", value = """ + { + "name": "João Silva", + "email": "joao.silva@email.com", + "password": "minhasenha123", + "themeColor": "blue" + } + """))) @Valid @RequestBody UserCreateDTO dto) { + boolean userIsSaved = userService.saveUser(dto); + if (userIsSaved) { + return new ResponseEntity<>("Cadastro feito com sucesso!", HttpStatus.CREATED); + } + return new ResponseEntity<>(HttpStatus.BAD_REQUEST); + } + + @GetMapping("/me") + public ResponseEntity getUserData() { + UserDataDTO user = userService.getUserData(); + return ResponseEntity.ok(user); + } + + @GetMapping("/me/expenses") + public ResponseEntity> getAllUserExpenses( + @RequestParam(name = "page", defaultValue = "0") Integer page, + @RequestParam(name = "size", defaultValue = "4") Integer size, + @RequestParam(name = "order", defaultValue = "DESC") String order) { + Pageable pageable = PageRequest.of(page, size, "ASC".equalsIgnoreCase(order) + ? Sort.by("created_at").ascending() + : Sort.by("created_at").descending()); + Page expenses = userService.getAllUserExpenses(pageable); + return ResponseEntity.ok(expenses); + } + + @GetMapping("/me/monthly-expenses") + public ResponseEntity> getUserMonthlyExpenses() { + List expenses = userService.getUserMonthlyExpenses(); + return ResponseEntity.ok(expenses); + } + + @GetMapping("/me/classify-spending-by-category") + public ResponseEntity> getCategoryRegister() { + List category = expenseService.getCategoryRegister(); + return ResponseEntity.ok(category); + } + + @PreAuthorize("hasRole('USER')") + @PutMapping + @Operation(summary = "Atualizar perfil do usuário", description = """ + Atualiza as informações do perfil do usuário autenticado no sistema FinBoost Plus. + + Este endpoint permite que um usuário autenticado atualize suas informações pessoais, + incluindo nome, email, senha e tema de cor preferido. + + **Importante:** Após a atualização bem-sucedida, todos os tokens JWT do usuário + serão revogados por segurança, sendo necessário fazer login novamente. + + **Regras de validação:** + - Email deve ser único no sistema (se alterado) + - Nova senha deve ter pelo menos 8 caracteres (se fornecida) + - Nome deve ter entre 2-100 caracteres (se fornecido) + - Apenas campos fornecidos serão atualizados + """, security = @SecurityRequirement(name = "bearerAuth")) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Perfil atualizado com sucesso. Tokens JWT foram revogados.", content = @Content(mediaType = "application/json", schema = @Schema(type = "string"), examples = @ExampleObject(name = "Sucesso", description = "Perfil atualizado com sucesso", value = "\"Perfil atualizado com sucesso. Faça login novamente.\""))), + @ApiResponse(responseCode = "400", description = "Erro na atualização - dados inválidos ou email já existe", content = @Content(mediaType = "application/json", schema = @Schema(type = "string"), examples = @ExampleObject(name = "Erro de validação", description = "Dados inválidos fornecidos", value = "\"Erro na validação dos dados fornecidos\""))), + @ApiResponse(responseCode = "401", description = "Token JWT inválido ou expirado", content = @Content(mediaType = "application/json", schema = @Schema(type = "string"), examples = @ExampleObject(name = "Token inválido", description = "Usuário não autenticado", value = "\"Token JWT inválido ou expirado\""))), + @ApiResponse(responseCode = "403", description = "Acesso negado - Role USER necessária", content = @Content(mediaType = "application/json", schema = @Schema(type = "string"), examples = @ExampleObject(name = "Acesso negado", description = "Usuário sem permissão adequada", value = "\"Acesso negado. Role USER necessária.\""))), + @ApiResponse(responseCode = "422", description = "Erro de validação dos campos obrigatórios", content = @Content(mediaType = "application/json", schema = @Schema(type = "string"), examples = @ExampleObject(name = "Validação", description = "Campos obrigatórios não fornecidos ou inválidos", value = "\"Erro de validação: campos obrigatórios ausentes ou inválidos\""))) + }) + public ResponseEntity updateProfile( + @Parameter(description = "Dados do usuário para atualização. Apenas campos fornecidos serão atualizados.", required = true, content = @Content(schema = @Schema(implementation = UserUpdateDTO.class), examples = { + @ExampleObject(name = "Atualização completa", description = "Exemplo de atualização de todos os campos", value = """ + { + "name": "João Silva Santos", + "email": "joao.santos@novoemail.com", + "password": "novasenha123", + "themeColor": "green" + } + """), + @ExampleObject(name = "Atualização parcial - apenas nome", description = "Exemplo atualizando apenas o nome", value = """ + { + "name": "João Silva Santos" + } + """), + @ExampleObject(name = "Atualização de senha", description = "Exemplo atualizando apenas a senha", value = """ + { + "password": "minhaNovasenha456" + } + """), + @ExampleObject(name = "Atualização de tema", description = "Exemplo atualizando apenas o tema de cor", value = """ + { + "themeColor": "purple" + } + """) + })) @Valid @RequestBody UserUpdateDTO dto) { + boolean userIsSaved = userService.updateUser(dto); + if (userIsSaved) { + TokenRevocationUtil.revokeCurrentUserTokens(authorizationService); + return new ResponseEntity<>(HttpStatus.OK); + } + return new ResponseEntity<>(HttpStatus.BAD_REQUEST); + } + + @PreAuthorize("hasRole('USER')") + @DeleteMapping + public ResponseEntity deleteProfile() { + userService.deleteCurrentUserProfile(); + TokenRevocationUtil.revokeCurrentUserTokens(authorizationService); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/forgot-password") + public ResponseEntity forgotPassword(@RequestBody String userName) { + userService.forgotPassword(userName); + return ResponseEntity.noContent().build(); + } + + @PreAuthorize("hasRole('USER')") + @PatchMapping("change-password") + public ResponseEntity changePassword(@RequestBody ChangePasswordDTO passwordChange) { + userService.changePassword(passwordChange); + return ResponseEntity.ok("Senha alterada com sucesso!"); + } + + @GetMapping("/userValidate/{uuid}") + public RedirectView validateUser(@PathVariable String uuid) { + boolean valid = userService.validateUser(uuid); + if (!valid) { + return new RedirectView("/token-invalido"); + // você pode apontar para HTML, endpoint do frontend, etc. + } + // Sucesso → redireciona para o login + return new RedirectView("https://finboostplus-app-zeta.vercel.app/login"); + } +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/enums/Status.java b/backend/finboostplus_server/src/main/java/com/finboostplus/enums/Status.java new file mode 100644 index 00000000..868a6d4a --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/enums/Status.java @@ -0,0 +1,8 @@ +package com.finboostplus.enums; + +public enum Status { + NOTAPPLY, + PAID, + UNPAID, + PENDING +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/exception/CategoryNotFoundException.java b/backend/finboostplus_server/src/main/java/com/finboostplus/exception/CategoryNotFoundException.java new file mode 100644 index 00000000..cc34338a --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/exception/CategoryNotFoundException.java @@ -0,0 +1,7 @@ +package com.finboostplus.exception; + +public class CategoryNotFoundException extends RuntimeException { + public CategoryNotFoundException(String message) { + super(message); + } +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/exception/DatabaseException.java b/backend/finboostplus_server/src/main/java/com/finboostplus/exception/DatabaseException.java new file mode 100644 index 00000000..223eb7b3 --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/exception/DatabaseException.java @@ -0,0 +1,7 @@ +package com.finboostplus.exception; + +public class DatabaseException extends RuntimeException{ + public DatabaseException(String message) { + super(message); + } +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/exception/EmailAlreadyRegisteredException.java b/backend/finboostplus_server/src/main/java/com/finboostplus/exception/EmailAlreadyRegisteredException.java new file mode 100644 index 00000000..ee084fc9 --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/exception/EmailAlreadyRegisteredException.java @@ -0,0 +1,7 @@ +package com.finboostplus.exception; + +public class EmailAlreadyRegisteredException extends RuntimeException{ + public EmailAlreadyRegisteredException(String message) { + super(message); + } +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/exception/ExpenseNotFoundException.java b/backend/finboostplus_server/src/main/java/com/finboostplus/exception/ExpenseNotFoundException.java new file mode 100644 index 00000000..a0d1063d --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/exception/ExpenseNotFoundException.java @@ -0,0 +1,7 @@ +package com.finboostplus.exception; + +public class ExpenseNotFoundException extends RuntimeException { + public ExpenseNotFoundException(String message) { + super(message); + } +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/exception/ForbiddenResourceException.java b/backend/finboostplus_server/src/main/java/com/finboostplus/exception/ForbiddenResourceException.java new file mode 100644 index 00000000..83b718cc --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/exception/ForbiddenResourceException.java @@ -0,0 +1,7 @@ +package com.finboostplus.exception; + +public class ForbiddenResourceException extends RuntimeException { + public ForbiddenResourceException(String message) { + super(message); + } +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/exception/GroupNotFoundException.java b/backend/finboostplus_server/src/main/java/com/finboostplus/exception/GroupNotFoundException.java new file mode 100644 index 00000000..08a400fd --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/exception/GroupNotFoundException.java @@ -0,0 +1,7 @@ +package com.finboostplus.exception; + +public class GroupNotFoundException extends RuntimeException { + public GroupNotFoundException(String message) { + super(message); + } +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/exception/InvalidCredentialsException.java b/backend/finboostplus_server/src/main/java/com/finboostplus/exception/InvalidCredentialsException.java new file mode 100644 index 00000000..ee4e3608 --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/exception/InvalidCredentialsException.java @@ -0,0 +1,7 @@ +package com.finboostplus.exception; + +public class InvalidCredentialsException extends RuntimeException { + public InvalidCredentialsException(String message) { + super(message); + } +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/exception/MemberHasPendingExpensesException.java b/backend/finboostplus_server/src/main/java/com/finboostplus/exception/MemberHasPendingExpensesException.java new file mode 100644 index 00000000..fcc867e9 --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/exception/MemberHasPendingExpensesException.java @@ -0,0 +1,7 @@ +package com.finboostplus.exception; + +public class MemberHasPendingExpensesException extends RuntimeException{ + public MemberHasPendingExpensesException(String message) { + super(message); + } +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/exception/OwnerLeaveNotAllowedException.java b/backend/finboostplus_server/src/main/java/com/finboostplus/exception/OwnerLeaveNotAllowedException.java new file mode 100644 index 00000000..66acd2b8 --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/exception/OwnerLeaveNotAllowedException.java @@ -0,0 +1,7 @@ +package com.finboostplus.exception; + +public class OwnerLeaveNotAllowedException extends RuntimeException{ + public OwnerLeaveNotAllowedException(String message) { + super(message); + } +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/exception/ResourceNotFoundException.java b/backend/finboostplus_server/src/main/java/com/finboostplus/exception/ResourceNotFoundException.java new file mode 100644 index 00000000..67086cc8 --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/exception/ResourceNotFoundException.java @@ -0,0 +1,8 @@ +package com.finboostplus.exception; + +public class ResourceNotFoundException extends RuntimeException { + public ResourceNotFoundException(String message) { + super(message); + } + +} \ No newline at end of file diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/exception/UserAlreadyRegisteredOnGroupException.java b/backend/finboostplus_server/src/main/java/com/finboostplus/exception/UserAlreadyRegisteredOnGroupException.java new file mode 100644 index 00000000..af619ec3 --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/exception/UserAlreadyRegisteredOnGroupException.java @@ -0,0 +1,7 @@ +package com.finboostplus.exception; + +public class UserAlreadyRegisteredOnGroupException extends RuntimeException{ + public UserAlreadyRegisteredOnGroupException(String message) { + super(message); + } +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/exception/UserNotFoundException.java b/backend/finboostplus_server/src/main/java/com/finboostplus/exception/UserNotFoundException.java new file mode 100644 index 00000000..a686ebc2 --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/exception/UserNotFoundException.java @@ -0,0 +1,7 @@ +package com.finboostplus.exception; + +public class UserNotFoundException extends RuntimeException { + public UserNotFoundException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/exception/ValuesIncompatiblesException.java b/backend/finboostplus_server/src/main/java/com/finboostplus/exception/ValuesIncompatiblesException.java new file mode 100644 index 00000000..1fbdc06c --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/exception/ValuesIncompatiblesException.java @@ -0,0 +1,7 @@ +package com.finboostplus.exception; + +public class ValuesIncompatiblesException extends RuntimeException { + public ValuesIncompatiblesException(String message) { + super(message); + } +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/exception/error/CustomError.java b/backend/finboostplus_server/src/main/java/com/finboostplus/exception/error/CustomError.java new file mode 100644 index 00000000..62f849ce --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/exception/error/CustomError.java @@ -0,0 +1,19 @@ +package com.finboostplus.exception.error; + +import lombok.Data; +import java.time.Instant; + +@Data +public class CustomError { + private Instant timestamp; + private String title; + private Integer status; + private String message; + + public CustomError(Instant timestamp, String title, Integer status, String message) { + this.timestamp = timestamp; + this.title = title; + this.status = status; + this.message = message; + } +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/exception/error/FieldMessage.java b/backend/finboostplus_server/src/main/java/com/finboostplus/exception/error/FieldMessage.java new file mode 100644 index 00000000..8b6743e8 --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/exception/error/FieldMessage.java @@ -0,0 +1,11 @@ +package com.finboostplus.exception.error; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@AllArgsConstructor +@Data +public class FieldMessage { + private String fieldName; + private String message; +} \ No newline at end of file diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/exception/error/ValidationError.java b/backend/finboostplus_server/src/main/java/com/finboostplus/exception/error/ValidationError.java new file mode 100644 index 00000000..8c6d2f91 --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/exception/error/ValidationError.java @@ -0,0 +1,21 @@ +package com.finboostplus.exception.error; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +public class ValidationError extends CustomError{ + private List errors = new ArrayList<>(); + + public ValidationError(Instant timestamp, String title, Integer status, String message) { + super(timestamp, title, status, message); + } + + public List getErrors() { + return errors; + } + + public void addError(String fieldName, String message){ + this.errors.add(new FieldMessage(fieldName, message)); + } +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/exception/handler/ControllerExceptionHandler.java b/backend/finboostplus_server/src/main/java/com/finboostplus/exception/handler/ControllerExceptionHandler.java new file mode 100644 index 00000000..245d9d14 --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/exception/handler/ControllerExceptionHandler.java @@ -0,0 +1,128 @@ +package com.finboostplus.exception.handler; + +import com.finboostplus.exception.*; +import com.finboostplus.exception.error.CustomError; +import com.finboostplus.exception.error.ValidationError; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; + +import java.time.Instant; + +@ControllerAdvice +public class ControllerExceptionHandler { + + @ExceptionHandler(ResourceNotFoundException.class) + public ResponseEntity resourceNotFoundException(ResourceNotFoundException e) { + HttpStatus status = HttpStatus.NOT_FOUND; + CustomError error = new CustomError(Instant.now(), "Not Found", status.value(), e.getMessage()); + return ResponseEntity.status(status).body(error); + } + + @ExceptionHandler(DatabaseException.class) + public ResponseEntity databaseException(DatabaseException e) { + HttpStatus status = HttpStatus.INTERNAL_SERVER_ERROR; + CustomError error = new CustomError(Instant.now(), "Internal Server Error", status.value(), + e.getMessage()); + return ResponseEntity.status(status).body(error); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity methodArgumentNotValidException(MethodArgumentNotValidException e) { + HttpStatus status = HttpStatus.UNPROCESSABLE_ENTITY; + ValidationError error = new ValidationError(Instant.now(), "Erro de validação", status.value(), + "Dados inválidos"); + for (FieldError f : e.getBindingResult().getFieldErrors()) { + error.addError(f.getField(), f.getDefaultMessage()); + } + return ResponseEntity.status(status).body(error); + } + + @ExceptionHandler(EmailAlreadyRegisteredException.class) + public ResponseEntity emailAlreadyRegisteredException(EmailAlreadyRegisteredException e) { + HttpStatus status = HttpStatus.CONFLICT; + CustomError error = new CustomError(Instant.now(), "Internal Server Error", status.value(), + e.getMessage()); + return ResponseEntity.status(status).body(error); + } + + @ExceptionHandler(UserNotFoundException.class) + public ResponseEntity userNotFoundException(UserNotFoundException e) { + HttpStatus status = HttpStatus.NOT_FOUND; + CustomError error = new CustomError(Instant.now(), "User Not Found", status.value(), e.getMessage()); + return ResponseEntity.status(status).body(error); + } + + @ExceptionHandler(ForbiddenResourceException.class) + public ResponseEntity forbiddenResourceException(ForbiddenResourceException e) { + HttpStatus status = HttpStatus.FORBIDDEN; + CustomError error = new CustomError(Instant.now(), "Resource Not Allowed", status.value(), + e.getMessage()); + return ResponseEntity.status(status).body(error); + } + + @ExceptionHandler(UserAlreadyRegisteredOnGroupException.class) + public ResponseEntity userAlreadyRegisteredOnGroupException(UserAlreadyRegisteredOnGroupException e) { + HttpStatus status = HttpStatus.CONFLICT; + CustomError error = new CustomError(Instant.now(), "Internal Server Error", status.value(), + e.getMessage()); + return ResponseEntity.status(status).body(error); + } + + @ExceptionHandler(ValuesIncompatiblesException.class) + public ResponseEntity valuesInCompatiblesException(ValuesIncompatiblesException e) { + HttpStatus status = HttpStatus.CONFLICT; + CustomError error = new CustomError(Instant.now(), "Internal Server Error", status.value(), + e.getMessage()); + return ResponseEntity.status(status).body(error); + } + + @ExceptionHandler(CategoryNotFoundException.class) + public ResponseEntity categoryNotFoundException(CategoryNotFoundException e) { + HttpStatus status = HttpStatus.CONFLICT; + CustomError error = new CustomError(Instant.now(), "Internal Server Error", status.value(), + e.getMessage()); + return ResponseEntity.status(status).body(error); + } + + @ExceptionHandler(ExpenseNotFoundException.class) + public ResponseEntity expenseNotFoundException(ExpenseNotFoundException e) { + HttpStatus status = HttpStatus.NOT_FOUND; + CustomError error = new CustomError(Instant.now(), "Expense Not Found", status.value(), e.getMessage()); + return ResponseEntity.status(status).body(error); + } + + @ExceptionHandler(GroupNotFoundException.class) + public ResponseEntity groupNotFoundException(GroupNotFoundException e) { + HttpStatus status = HttpStatus.NOT_FOUND; + CustomError error = new CustomError(Instant.now(), "Group Not Found", status.value(), e.getMessage()); + return ResponseEntity.status(status).body(error); + } + + @ExceptionHandler(MemberHasPendingExpensesException.class) + public ResponseEntity memberHasPendingExpensesException(MemberHasPendingExpensesException e) { + HttpStatus status = HttpStatus.FORBIDDEN; + CustomError error = new CustomError(Instant.now(), "Resource Not Allowed", status.value(), + e.getMessage()); + return ResponseEntity.status(status).body(error); + } + + @ExceptionHandler(OwnerLeaveNotAllowedException.class) + public ResponseEntity ownerLeaveNotAllowedException(OwnerLeaveNotAllowedException e) { + HttpStatus status = HttpStatus.FORBIDDEN; + CustomError error = new CustomError(Instant.now(), "Resource Not Allowed", status.value(), + e.getMessage()); + return ResponseEntity.status(status).body(error); + } + + @ExceptionHandler(InvalidCredentialsException.class) + public ResponseEntity invalidCredentialsException(InvalidCredentialsException e) { + HttpStatus status = HttpStatus.BAD_REQUEST; + CustomError error = new CustomError(Instant.now(), "Invalid Credentials", status.value(), + e.getMessage()); + return ResponseEntity.status(status).body(error); + } +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/model/Category.java b/backend/finboostplus_server/src/main/java/com/finboostplus/model/Category.java new file mode 100644 index 00000000..fe30d687 --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/model/Category.java @@ -0,0 +1,49 @@ +package com.finboostplus.model; + +import java.util.Set; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.SequenceGenerator; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Entity +@Table(name = "categories") +@SequenceGenerator(name = "seq_category", sequenceName = "seq_category", allocationSize = 1, initialValue = 1) +@AllArgsConstructor +@NoArgsConstructor +public class Category { + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "seq_category") + private Long id; + + @Column(unique = true) + private String name; + + @OneToMany(mappedBy = "category", cascade = CascadeType.ALL, orphanRemoval = true) + private Set expenses; + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (!(o instanceof Category)) + return false; + Category category = (Category) o; + return id != null && id.equals(category.id); + } + + @Override + public int hashCode() { + return id != null ? id.hashCode() : 0; + } +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/model/Expense.java b/backend/finboostplus_server/src/main/java/com/finboostplus/model/Expense.java new file mode 100644 index 00000000..947f2c4c --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/model/Expense.java @@ -0,0 +1,91 @@ +package com.finboostplus.model; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; + +import com.finboostplus.enums.Status; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToOne; +import jakarta.persistence.SequenceGenerator; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "expenses") +@AllArgsConstructor +@NoArgsConstructor +@Data +@SequenceGenerator(name = "seq_expense", sequenceName = "seq_expense", allocationSize = 1, initialValue = 1) +public class Expense { + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "seq_expense") + private Long id; + + private BigDecimal value; + + private String title; + + private String description; + + @Column(name = "deadline_date") + private LocalDate deadlineDate; + + @Column(name = "created_at") + private Instant createdAt; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "category_id") + private Category category; + + @ManyToOne + @JoinColumn(name = "group_id") + private Group group; + + @Column(name = "creator_id") + private Long creatorId; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private Status status; + + public Expense(String title, String description, BigDecimal value, Category category, Group group, Long creatorId, + LocalDate deadlineDate, Status status) { + this.title = title; + this.description = description; + this.value = value; + this.category = category; + this.group = group; + this.creatorId = creatorId; + this.deadlineDate = deadlineDate; + this.createdAt = Instant.now(); + this.status = status; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (!(o instanceof Expense)) + return false; + Expense that = (Expense) o; + return id != null && id.equals(that.getId()); + } + + @Override + public int hashCode() { + return id != null ? id.hashCode() : 0; + } +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/model/Group.java b/backend/finboostplus_server/src/main/java/com/finboostplus/model/Group.java new file mode 100644 index 00000000..892796e1 --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/model/Group.java @@ -0,0 +1,48 @@ +package com.finboostplus.model; + +import java.time.Instant; +import java.util.Set; + +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.SequenceGenerator; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "groups") +@SequenceGenerator(name = "seq_group", sequenceName = "seq_group", allocationSize = 1, initialValue = 1) +@AllArgsConstructor +@NoArgsConstructor +@Data +@EntityListeners(AuditingEntityListener.class) +public class Group { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "seq_group") + private Long id; + + private String name; + + private String description; + + private String icon; + + @CreatedDate + @Column(name = "created_at") + private Instant createdAt; + + @OneToMany(mappedBy = "group", cascade = CascadeType.ALL, orphanRemoval = true) + public Set expenses; +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/model/GroupMember.java b/backend/finboostplus_server/src/main/java/com/finboostplus/model/GroupMember.java new file mode 100644 index 00000000..3b3dfeed --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/model/GroupMember.java @@ -0,0 +1,58 @@ +package com.finboostplus.model; + +import java.time.Instant; +import java.util.Objects; + +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import jakarta.persistence.Column; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.MapsId; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "group_members") +@AllArgsConstructor +@NoArgsConstructor +@Data +public class GroupMember { + @EmbeddedId + private GroupMemberId id = new GroupMemberId(); + + @ManyToOne + @MapsId("userId") + @JoinColumn(name = "user_id") + private User user; + + @ManyToOne + @MapsId("groupId") + @JoinColumn(name = "group_id") + private Group group; + + @Column(name = "auth_level") + private String authorization; + + @Column(name = "entry_date") + @CreatedDate + private Instant entryDate; + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + GroupMember that = (GroupMember) o; + return Objects.equals(user, that.user) && Objects.equals(group, that.group); + } + + @Override + public int hashCode() { + return Objects.hash(user, group); + } +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/model/GroupMemberId.java b/backend/finboostplus_server/src/main/java/com/finboostplus/model/GroupMemberId.java new file mode 100644 index 00000000..558a1a98 --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/model/GroupMemberId.java @@ -0,0 +1,32 @@ +package com.finboostplus.model; + +import java.util.Objects; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AllArgsConstructor; +import lombok.NoArgsConstructor; + +@Embeddable +@AllArgsConstructor +@NoArgsConstructor +public class GroupMemberId { + @Column(name = "user_id") + private Long userId; + + @Column(name = "group_id") + private Long groupId; + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) + return false; + GroupMemberId that = (GroupMemberId) o; + return Objects.equals(userId, that.userId) && Objects.equals(groupId, that.groupId); + } + + @Override + public int hashCode() { + return Objects.hash(userId, groupId); + } +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/model/Role.java b/backend/finboostplus_server/src/main/java/com/finboostplus/model/Role.java new file mode 100644 index 00000000..d445f200 --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/model/Role.java @@ -0,0 +1,47 @@ +package com.finboostplus.model; + +import java.util.Objects; + +import org.springframework.security.core.GrantedAuthority; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@SuppressWarnings("serial") +@Table(name = "roles") +@Entity +public class Role implements GrantedAuthority { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "authority", nullable = false, unique = true) + private String authority; + + @Override + public int hashCode() { + return Objects.hash(authority); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (getClass() != obj.getClass()) + return false; + Role other = (Role) obj; + return Objects.equals(authority, other.authority); + } +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/model/User.java b/backend/finboostplus_server/src/main/java/com/finboostplus/model/User.java new file mode 100644 index 00000000..70066acc --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/model/User.java @@ -0,0 +1,141 @@ +package com.finboostplus.model; + +import java.time.Instant; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import com.finboostplus.DTO.UserCreateDTO; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.JoinTable; +import jakarta.persistence.ManyToMany; +import jakarta.persistence.OneToMany; +import jakarta.persistence.SequenceGenerator; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@SuppressWarnings("serial") +@Data +@AllArgsConstructor +@NoArgsConstructor +@Table(name = "users") +@Entity +@SequenceGenerator(name = "seq_user", sequenceName = "seq_user", allocationSize = 1, initialValue = 1) +public class User implements UserDetails { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_name", nullable = false) + private String name; + + @Column(name = "e_mail", nullable = false, unique = true) + private String email; + + @Column(name = "password", nullable = false) + private String password; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Column(name = "theme_color", nullable = false) + private String themeColor; + + @Column(name = "active", nullable = false) + private boolean active; + + @ManyToMany + @JoinTable(name = "users_roles", joinColumns = @JoinColumn(name = "user_id"), inverseJoinColumns = @JoinColumn(name = "role_id")) + private Set roles = new HashSet<>(); + + public User(String name, String email, String password, String themeColor) { + this.name = name; + this.email = email; + this.password = password; + this.themeColor = themeColor; + this.createdAt = Instant.now(); + this.active = false; + } + + public static User dtoToUser(UserCreateDTO dto) { + return new User(dto.name(), dto.email(), dto.password(), dto.themeColor()); + } + + public void addRole(Role role) { + roles.add(role); + } + + public boolean hasRole(String roleName) { + for (Role role : roles) { + if (role.getAuthority().equals(roleName)) { + return true; + } + } + return false; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + + User user = (User) o; + + return Objects.equals(id, user.id); + } + + @Override + public int hashCode() { + return id != null ? id.hashCode() : 0; + } + + @Override + public Collection getAuthorities() { + return roles; + } + + @Override + public String getPassword() { + return password; + } + + @Override + public String getUsername() { + return email; + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return this.active; + } +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/model/UserExpenseDivision.java b/backend/finboostplus_server/src/main/java/com/finboostplus/model/UserExpenseDivision.java new file mode 100644 index 00000000..6603cf4b --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/model/UserExpenseDivision.java @@ -0,0 +1,45 @@ +package com.finboostplus.model; + +import java.math.BigDecimal; + +import com.finboostplus.enums.Status; + +import jakarta.persistence.Column; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.MapsId; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "user_expense_divisions") +@AllArgsConstructor +@NoArgsConstructor +@Data +public class UserExpenseDivision { + @EmbeddedId + private UserExpenseDivisionId id; + + @ManyToOne + @MapsId("userId") + @JoinColumn(name = "user_id") + private User user; + + @ManyToOne + @MapsId("expenseId") + @JoinColumn(name = "expense_id") + private Expense expense; + + @Column(name = "partial_value") + private BigDecimal parcialValue; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private Status status; +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/model/UserExpenseDivisionId.java b/backend/finboostplus_server/src/main/java/com/finboostplus/model/UserExpenseDivisionId.java new file mode 100644 index 00000000..f56bee4e --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/model/UserExpenseDivisionId.java @@ -0,0 +1,34 @@ +package com.finboostplus.model; + +import java.util.Objects; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Embeddable +@AllArgsConstructor +@NoArgsConstructor +@Data +public class UserExpenseDivisionId { + @Column(name = "user_id") + private Long userId; + + @Column(name = "expense_id") + private Long expenseId; + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) + return false; + UserExpenseDivisionId that = (UserExpenseDivisionId) o; + return Objects.equals(userId, that.userId); + } + + @Override + public int hashCode() { + return Objects.hashCode(userId); + } +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/model/ValidateUser.java b/backend/finboostplus_server/src/main/java/com/finboostplus/model/ValidateUser.java new file mode 100644 index 00000000..4ae7dc45 --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/model/ValidateUser.java @@ -0,0 +1,39 @@ +package com.finboostplus.model; + +import java.time.Instant; +import java.util.UUID; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +@Data +@Entity +@AllArgsConstructor +@NoArgsConstructor +@Table(name = "validate_user") +@EqualsAndHashCode(of = "id") +public class ValidateUser { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "uuid", nullable = false) + private UUID uuid; + + @Column(name = "expiration_date", nullable = false) + private Instant expirationDate; + + @ManyToOne + @JoinColumn(name = "user_id", referencedColumnName = "id", unique = true) + private User user; +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/projection/ExpenseProjection.java b/backend/finboostplus_server/src/main/java/com/finboostplus/projection/ExpenseProjection.java new file mode 100644 index 00000000..fffa8fdc --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/projection/ExpenseProjection.java @@ -0,0 +1,15 @@ +package com.finboostplus.projection; + +import java.math.BigDecimal; + +public interface ExpenseProjection { + Long getId(); + + String getTitle(); + + String getDescription(); + + String getCategoryName(); + + BigDecimal getTotal(); +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/projection/GroupAuthorityExpenseProjection.java b/backend/finboostplus_server/src/main/java/com/finboostplus/projection/GroupAuthorityExpenseProjection.java new file mode 100644 index 00000000..e80d51bf --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/projection/GroupAuthorityExpenseProjection.java @@ -0,0 +1,9 @@ +package com.finboostplus.projection; + +import java.math.BigDecimal; + +public interface GroupAuthorityExpenseProjection extends GroupExpenseProjection { + BigDecimal getTotal(); + + BigDecimal getRemainingValue(); +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/projection/GroupExpenseProjection.java b/backend/finboostplus_server/src/main/java/com/finboostplus/projection/GroupExpenseProjection.java new file mode 100644 index 00000000..93854da3 --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/projection/GroupExpenseProjection.java @@ -0,0 +1,25 @@ +package com.finboostplus.projection; + +import java.math.BigDecimal; +import java.time.LocalDate; + +public interface GroupExpenseProjection { + Long getExpenseId(); + + String getTitle(); + + BigDecimal getPartialValue(); + + Long getCategoryId(); + + String getCategoryName(); + + Long getGroupId(); + + String getGroupName(); + + String getStatus(); + + LocalDate getDeadlineDate(); +} + diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/projection/GroupMemberExpenseProjection.java b/backend/finboostplus_server/src/main/java/com/finboostplus/projection/GroupMemberExpenseProjection.java new file mode 100644 index 00000000..ee034597 --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/projection/GroupMemberExpenseProjection.java @@ -0,0 +1,4 @@ +package com.finboostplus.projection; + +public interface GroupMemberExpenseProjection extends GroupExpenseProjection { +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/projection/GroupProjection.java b/backend/finboostplus_server/src/main/java/com/finboostplus/projection/GroupProjection.java new file mode 100644 index 00000000..1d33eebc --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/projection/GroupProjection.java @@ -0,0 +1,22 @@ +package com.finboostplus.projection; + +import java.math.BigDecimal; +import java.time.Instant; + +public interface GroupProjection { + Long getGroupId(); + + String getName(); + + String getDescription(); + + String getAuthority(); + + String getIcon(); + + Instant getCreatedAt(); + + BigDecimal getPartial_total(); + + BigDecimal getTotal(); +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/projection/UserDetailsProjection.java b/backend/finboostplus_server/src/main/java/com/finboostplus/projection/UserDetailsProjection.java new file mode 100644 index 00000000..cb4d1db5 --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/projection/UserDetailsProjection.java @@ -0,0 +1,11 @@ +package com.finboostplus.projection; + +public interface UserDetailsProjection { + String getUsername(); + + String getPassword(); + + Long getRoleId(); + + String getAuthority(); +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/projection/UserExpenseDivisionProjection.java b/backend/finboostplus_server/src/main/java/com/finboostplus/projection/UserExpenseDivisionProjection.java new file mode 100644 index 00000000..2582a0a7 --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/projection/UserExpenseDivisionProjection.java @@ -0,0 +1,13 @@ +package com.finboostplus.projection; + +import java.math.BigDecimal; + +public interface UserExpenseDivisionProjection { + Long getUserId(); + + String getUserName(); + + BigDecimal getPartialValue(); + + String getStatus(); +} diff --git a/backend/finboostplus_teste/src/main/java/com/finboostplus/repository/UserRepository.java b/backend/finboostplus_server/src/main/java/com/finboostplus/repository/CategoryRepository.java similarity index 58% rename from backend/finboostplus_teste/src/main/java/com/finboostplus/repository/UserRepository.java rename to backend/finboostplus_server/src/main/java/com/finboostplus/repository/CategoryRepository.java index f685725f..d6d93c15 100644 --- a/backend/finboostplus_teste/src/main/java/com/finboostplus/repository/UserRepository.java +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/repository/CategoryRepository.java @@ -3,9 +3,8 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; -import com.finboostplus.model.*; +import com.finboostplus.model.Category; @Repository -public interface UserRepository extends JpaRepository { - +public interface CategoryRepository extends JpaRepository { } diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/repository/ExpenseRepository.java b/backend/finboostplus_server/src/main/java/com/finboostplus/repository/ExpenseRepository.java new file mode 100644 index 00000000..76e3be5f --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/repository/ExpenseRepository.java @@ -0,0 +1,328 @@ +package com.finboostplus.repository; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import com.finboostplus.DTO.CategoryRegisterDTO; +import com.finboostplus.DTO.UserExpensesDTO; +import com.finboostplus.DTO.UserMonthlyExpensesDTO; +import com.finboostplus.model.Expense; +import com.finboostplus.projection.GroupAuthorityExpenseProjection; +import com.finboostplus.projection.GroupMemberExpenseProjection; +import com.finboostplus.projection.UserExpenseDivisionProjection; + +@Repository +public interface ExpenseRepository extends JpaRepository { + @Query(nativeQuery = true, value = """ + SELECT + E.ID AS EXPENSE_ID, + E.TITLE, + E.CREATED_AT, + E.DEADLINE_DATE, + C.NAME AS CATEGORY, + E.DESCRIPTION, + E.STATUS, + UED.PARTIAL_VALUE, + G.ID AS GROUP_ID, + G.NAME AS GROUP_NAME + FROM + EXPENSES AS E + INNER JOIN GROUPS G ON G.ID = E.GROUP_ID + INNER JOIN USER_EXPENSE_DIVISIONS UED ON UED.EXPENSE_ID = E.ID + INNER JOIN CATEGORIES C ON C.ID = E.CATEGORY_ID + WHERE + UED.USER_ID = :userId + """) + Page getAllUserExpenses(Long userId, Pageable pageable); + + @Query(nativeQuery = true, value = """ + WITH MESES AS ( + SELECT GENERATE_SERIES(1, 12) AS MES_NUM + ) + SELECT + TO_CHAR(TO_DATE(M.MES_NUM::TEXT, 'MM'), 'TMMonth') AS month, + COALESCE(SUM(UED.partial_value), 0) AS total + FROM MESES M + LEFT JOIN expenses E + ON EXTRACT(MONTH FROM E.deadline_date) = M.MES_NUM + AND EXTRACT(YEAR FROM E.deadline_date) = EXTRACT(YEAR FROM CURRENT_DATE) + LEFT JOIN user_expense_divisions UED + ON E.id = UED.expense_id + AND UED.user_id = :userId + GROUP BY M.MES_NUM + ORDER BY M.MES_NUM + """) + List getUserMonthlyExpenses(@Param("userId") Long userId); + + // @Query(nativeQuery = true, value = """ + // SELECT expenses.id, expenses.title, expenses.description, + // categories.name as categoryName, + // COALESCE(SUM(expenses.value), 0) AS total + // FROM group_members + // INNER JOIN groups ON group_members.group_id = groups.id + // INNER JOIN users ON group_members.user_id = users.id + // LEFT JOIN expenses ON expenses.group_id = groups.id + // INNER JOIN categories ON categories.id = expenses.category_id + // WHERE users.id =:memberId + // AND groups.id = :groupId + // GROUP BY expenses.id,categories.name + // """) + // List listExpensesGroupById(Long memberId, Long groupId); + + @Query(nativeQuery = true, value = """ + SELECT + COUNT(C.ID) AS QUANTITY, + C.NAME AS CATEGORY + FROM + CATEGORIES C + INNER JOIN EXPENSES E ON E.CATEGORY_ID = C.ID + INNER JOIN USER_EXPENSE_DIVISIONS UED ON UED.EXPENSE_ID = E.ID + WHERE + UED.USER_ID = :userId + GROUP BY + C.ID, + C.NAME + ORDER BY + QUANTITY DESC + """) + List getExpensesByCategory(Long userId); + + @Query(nativeQuery = true, countQuery = "Select count(*) from user_expense_divisions", value = """ + SELECT + E.ID AS EXPENSE_ID, + E.TITLE AS TITLE, + G.ID AS GROUP_ID, + G.NAME AS GROUP_NAME, + UED.PARTIAL_VALUE AS PARTIAL_VALUE, + C.ID AS CATEGORY_ID, + C.Name AS CATEGORY_NAME, + E.STATUS AS STATUS, + E.DEADLINE_DATE AS DEADLINE_DATE, + G.ICON + FROM + EXPENSES E + INNER JOIN USER_EXPENSE_DIVISIONS UED ON UED.EXPENSE_ID = E.ID + INNER JOIN GROUPS G ON G.ID = E.GROUP_ID + INNER JOIN CATEGORIES C ON C.ID = E.CATEGORY_ID + WHERE + G.ID = :groupId + AND UED.USER_ID = :memberId + ORDER BY + CASE + WHEN E.DEADLINE_DATE < NOW() THEN 1 + WHEN DATE(E.DEADLINE_DATE) = DATE(NOW()) THEN 2 + WHEN E.DEADLINE_DATE > NOW() THEN 3 + ELSE 4 + END, + E.DEADLINE_DATE ASC + """) + Page getAllGroupExpenses(Long memberId, Long groupId, Pageable pageable); + + @Query(nativeQuery = true, countQuery = "Select count(*) from user_expense_divisions", value = """ + SELECT + E.ID AS EXPENSE_ID, + E.TITLE AS TITLE, + G.ID AS GROUP_ID, + G.NAME AS GROUP_NAME, + UED.PARTIAL_VALUE AS PARTIAL_VALUE, + C.ID AS CATEGORY_ID, + C.Name AS CATEGORY_NAME, + E.STATUS AS STATUS, + E.DEADLINE_DATE AS DEADLINE_DATE, + G.ICON + FROM + EXPENSES E + INNER JOIN USER_EXPENSE_DIVISIONS UED ON UED.EXPENSE_ID = E.ID + INNER JOIN GROUPS G ON G.ID = E.GROUP_ID + INNER JOIN CATEGORIES C ON C.ID = E.CATEGORY_ID + WHERE + G.ID = :groupId + AND UED.USER_ID = :memberId + AND UED.STATUS = :status + ORDER BY + CASE + WHEN E.DEADLINE_DATE < NOW() THEN 1 + WHEN DATE(E.DEADLINE_DATE) = DATE(NOW()) THEN 2 + WHEN E.DEADLINE_DATE > NOW() THEN 3 + ELSE 4 + END, + E.DEADLINE_DATE ASC + """) + Page getAllGroupExpensesFiltered(Long memberId, Long groupId, String status, + Pageable pageable); + + @Query(nativeQuery = true, countQuery = "Select count(*) from user_expense_divisions", value = """ + SELECT + E.ID AS EXPENSE_ID, + E.TITLE AS TITLE, + G.ID AS GROUP_ID, + G.NAME AS GROUP_NAME, + COALESCE(UED_USER.PARTIAL_VALUE, 0) AS PARTIAL_VALUE, + C.ID AS CATEGORY_ID, + C.Name AS CATEGORY_NAME, + COALESCE(E.VALUE, 0) AS TOTAL, + COALESCE(E.VALUE, 0) - COALESCE(UED_PAID.TOTAL_PAID, 0) AS REMAINING_VALUE, + E.DEADLINE_DATE AS DEADLINE_DATE, + G.ICON, + E.STATUS AS STATUS + FROM EXPENSES E + INNER JOIN GROUPS G ON G.ID = E.GROUP_ID + INNER JOIN CATEGORIES C ON C.ID = E.CATEGORY_ID + + LEFT JOIN ( + SELECT EXPENSE_ID, PARTIAL_VALUE, STATUS, USER_ID + FROM USER_EXPENSE_DIVISIONS + WHERE USER_ID = :userId + ) UED_USER ON UED_USER.EXPENSE_ID = E.ID + + LEFT JOIN ( + SELECT EXPENSE_ID, SUM(PARTIAL_VALUE) AS TOTAL_PAID + FROM USER_EXPENSE_DIVISIONS + WHERE STATUS = 'PAID' + GROUP BY EXPENSE_ID + ) UED_PAID ON UED_PAID.EXPENSE_ID = E.ID + + WHERE G.ID = :groupId + + ORDER BY + CASE + WHEN E.DEADLINE_DATE < NOW() THEN 1 + WHEN DATE(E.DEADLINE_DATE) = DATE(NOW()) THEN 2 + WHEN E.DEADLINE_DATE > NOW() THEN 3 + ELSE 4 + END, + E.DEADLINE_DATE ASC + """) + Page getAllGroupExpensesOfAllMembers(Long userId, Long groupId, + Pageable pageable); + + @Query(nativeQuery = true, countQuery = "Select count(*) from user_expense_divisions", value = """ + SELECT + E.ID AS EXPENSE_ID, + E.TITLE AS TITLE, + G.ID AS GROUP_ID, + G.NAME AS GROUP_NAME, + COALESCE(UED_USER.PARTIAL_VALUE, 0) AS PARTIAL_VALUE, + C.ID AS CATEGORY_ID, + C.Name AS CATEGORY_NAME, + COALESCE(E.VALUE, 0) AS TOTAL, + COALESCE(E.VALUE, 0) - COALESCE(UED_PAID.TOTAL_PAID, 0) AS REMAINING_VALUE, + E.DEADLINE_DATE AS DEADLINE_DATE, + G.ICON, + E.STATUS AS STATUS + FROM EXPENSES E + INNER JOIN GROUPS G ON G.ID = E.GROUP_ID + INNER JOIN CATEGORIES C ON C.ID = E.CATEGORY_ID + + LEFT JOIN ( + SELECT EXPENSE_ID, PARTIAL_VALUE, STATUS, USER_ID + FROM USER_EXPENSE_DIVISIONS + WHERE USER_ID = :memberId + ) UED_USER ON UED_USER.EXPENSE_ID = E.ID + + LEFT JOIN ( + SELECT EXPENSE_ID, SUM(PARTIAL_VALUE) AS TOTAL_PAID + FROM USER_EXPENSE_DIVISIONS + WHERE STATUS = 'PAID' + GROUP BY EXPENSE_ID + ) UED_PAID ON UED_PAID.EXPENSE_ID = E.ID + + WHERE G.ID = :groupId + AND (UED_USER.STATUS = :status + OR UED_USER.STATUS IS NULL) + + ORDER BY + CASE + WHEN E.DEADLINE_DATE < NOW() THEN 1 + WHEN DATE(E.DEADLINE_DATE) = DATE(NOW()) THEN 2 + WHEN E.DEADLINE_DATE > NOW() THEN 3 + ELSE 4 + END, + E.DEADLINE_DATE ASC + """) + Page getAllGroupExpensesOfAllMembersFiltered(Long memberId, Long groupId, + String status, + Pageable pageable); + + @Modifying + @Query(value = """ + UPDATE user_expense_divisions + SET status = :status + WHERE expense_id = :expenseId + AND status != 'PAID' + """, nativeQuery = true) + void updateExpenseStatus(@Param("expenseId") Long expenseId, @Param("status") String status); + + @Modifying + @Query(value = """ + UPDATE expenses + SET status = :status + WHERE id = :expenseId + """, nativeQuery = true) + void setPaidExpense(@Param("expenseId") Long expenseId, @Param("status") String status); + + @Query(value = """ + SELECT EXISTS( + SELECT 1 + FROM user_expense_divisions ued + INNER JOIN expenses e ON ued.expense_id = e.id + WHERE e.group_id = :groupId + AND ued.user_id = :memberId + AND (ued.status = 'PENDING' OR ued.status = 'UNPAID') + ) + """, nativeQuery = true) + boolean doesMemberHasPendingExpenses(Long memberId, Long groupId); + + @Query(nativeQuery = true, value = """ + SELECT UD.USER_ID AS USERID, + U.USER_NAME AS USERNAME, + UD.PARTIAL_VALUE AS PARTIALVALUE, + UD.STATUS + FROM EXPENSES E + INNER JOIN USER_EXPENSE_DIVISIONS UD ON E.ID = UD.EXPENSE_ID + INNER JOIN USERS U ON U.ID = UD.USER_ID + WHERE E.GROUP_ID = :groupId AND e.id = :expenseId + """) + List listUsersExpenseDivision(Long groupId, Long expenseId); + + @Query(value = """ + SELECT EXISTS( + SELECT 1 + FROM expenses e WHERE e.group_id = :groupId AND (e.status = 'PENDING' OR e.status = 'UNPAID') + ) + """, nativeQuery = true) + boolean groupHasPendingExpenses(Long groupId); + + @Query(value = """ + SELECT EXISTS( + SELECT 1 + FROM user_expense_divisions e WHERE e.expense_id = :expenseId AND (e.status = 'PENDING' OR e.status = 'UNPAID') + ) + """, nativeQuery = true) + boolean isExpensePaid(Long expenseId); + + @Modifying + @Query(value = """ + DELETE FROM expenses + WHERE id = :expenseId + """, nativeQuery = true) + void deleteExpenseById(@Param("expenseId") Long expenseId); + + @Query(value = """ + SELECT * FROM expenses + WHERE deadline_date BETWEEN :creatAt AND :deadlineDate + AND status != 'PAID' + """, nativeQuery = true) + List findByExpirationDateBetween( + @Param("creatAt") Instant creatAt, + @Param("deadlineDate") LocalDate deadlineDate); +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/repository/GroupMemberRepository.java b/backend/finboostplus_server/src/main/java/com/finboostplus/repository/GroupMemberRepository.java new file mode 100644 index 00000000..773b8ca9 --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/repository/GroupMemberRepository.java @@ -0,0 +1,107 @@ +package com.finboostplus.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import com.finboostplus.DTO.GroupMemberResponseDTO; +import com.finboostplus.model.GroupMember; + +@Repository +public interface GroupMemberRepository extends JpaRepository { + @Query(nativeQuery = true, value = """ + SELECT EXISTS( + SELECT 1 from group_members as gm + inner join users as u + on gm.user_id = u.id + inner join groups as g + on g.id = gm.group_id where u.id = :userId and g.id = :groupId + and gm.auth_level = 'OWNER' + ) + """) + boolean isUserGroupOwner(long userId, long groupId); + + @Query(nativeQuery = true, value = """ + SELECT EXISTS ( + SELECT 1 + FROM group_members gm + WHERE gm.user_id = :userId + AND gm.group_id = :groupId + ) + """) + boolean isUserMemberOfGroup(long userId, long groupId); + + @Query(nativeQuery = true, value = """ + SELECT u.id, u.user_name, u.theme_color, gm.auth_level as authority FROM users u + inner join group_members gm + on gm.user_id = u.id + WHERE gm.group_id = :groupId + """) + Page findMembersByGroupId(long groupId, Pageable pageable); + + @Query(nativeQuery = true, value = """ + SELECT u.id, u.user_name, u.theme_color, gm.auth_level as authority FROM users u + inner join group_members gm + on gm.user_id = u.id + WHERE gm.group_id = :groupId + AND u.user_name like %:search% + """) + Page findMembersByGroupIdFiltered(long groupId, Pageable pageable, String search); + + @Query(nativeQuery = true, value = """ + SELECT * + FROM group_members + WHERE user_id = :memberId + AND group_id = :groupId + """) + Optional findGroupMemberByMemberId(@Param("memberId") Long memberId, + @Param("groupId") Long groupId); + + @Query(nativeQuery = true, value = """ + SELECT EXISTS( + SELECT 1 from group_members + where auth_level = 'OWNER' and user_id =:memberId + ) + """) + boolean isUserOwnerOfAnyGroup(long memberId); + + @Query(nativeQuery = true, value = """ + SELECT EXISTS ( + SELECT 1 + FROM group_members gm + WHERE gm.user_id = :userId + AND gm.group_id = :groupId + AND gm.auth_level IN (:authLevels) + ) + """) + boolean doesUserHasAnyAuthority(Long userId, Long groupId, List authLevels); + + List findByUser_id(Long userId); + + List findByGroup_Id(Long groupId); + + @Modifying + @Query(value = "DELETE FROM group_members WHERE user_id = :userId AND group_id = :groupId", nativeQuery = true) + void deleteByUserIdAndGroupId(Long userId, Long groupId); + + @Modifying + @Query(value = """ + DELETE FROM group_members + WHERE group_id = :groupId + """, nativeQuery = true) + void deleteGroupRelationById(@Param("groupId") Long groupId); + + @Modifying + @Query(value = """ + DELETE FROM group_members + WHERE user_id = :memberId + """, nativeQuery = true) + void deleteGroupsRelationByMemberId(@Param("memberId") Long memberId); +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/repository/GroupRepository.java b/backend/finboostplus_server/src/main/java/com/finboostplus/repository/GroupRepository.java new file mode 100644 index 00000000..856dbe50 --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/repository/GroupRepository.java @@ -0,0 +1,96 @@ +package com.finboostplus.repository; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import com.finboostplus.DTO.GroupDetailsDTO; +import com.finboostplus.DTO.GroupMemberAuthorityDTO; +import com.finboostplus.model.Group; +import com.finboostplus.projection.GroupProjection; + +@Repository +public interface GroupRepository extends JpaRepository { + @Query(nativeQuery = true, value = """ + SELECT + G.ID AS GROUP_ID, + G.NAME, + G.DESCRIPTION, + GM.AUTH_LEVEL AS AUTHORITY, + G.ICON, + G.CREATED_AT, + COALESCE(SUM(UED.PARTIAL_VALUE), 0) AS PARTIAL_TOTAL, + COALESCE(SUM(E.VALUE), 0) AS TOTAL + FROM + GROUPS G + INNER JOIN GROUP_MEMBERS GM + ON GM.GROUP_ID = G.ID + AND GM.USER_ID = :memberId + LEFT JOIN EXPENSES E + ON E.GROUP_ID = G.ID + AND E.STATUS != 'PAID' + LEFT JOIN USER_EXPENSE_DIVISIONS UED + ON UED.EXPENSE_ID = E.ID + AND UED.USER_ID = :memberId + AND UED.STATUS != 'PAID' + GROUP BY + G.ID, + G.NAME, + G.DESCRIPTION, + GM.AUTH_LEVEL, + G.ICON, + G.CREATED_AT + ORDER BY + G.CREATED_AT DESC + """) + Page listUserGroupsPaged(Long memberId, Pageable pageable); + + @Query(nativeQuery = true, value = """ + SELECT GM.AUTH_LEVEL AS AUTHORITY + FROM GROUP_MEMBERS AS GM + WHERE GM.GROUP_ID = :groupId + AND GM.USER_ID = :memberId + """) + GroupMemberAuthorityDTO getMemberAuthority(Long groupId, Long memberId); + + @Modifying + @Query(nativeQuery = true, value = """ + DELETE FROM groups + WHERE id = :groupId + """) + void deleteGroupById(@Param("groupId") Long groupId); + + @Query(nativeQuery = true, value = """ + SELECT + G.ID, + G.NAME, + G.DESCRIPTION, + G.ICON, + GM.AUTH_LEVEL AS AUTHORIZATION, + COALESCE(( + SELECT SUM(UED.PARTIAL_VALUE) + FROM USER_EXPENSE_DIVISIONS UED + INNER JOIN EXPENSES E ON E.ID = UED.EXPENSE_ID + WHERE UED.USER_ID = :userId + AND E.GROUP_ID = :groupId + AND UED.STATUS != 'PAID' + ), 0) AS PARTIAL_TOTAL, + COALESCE(( + SELECT SUM(E.VALUE) + FROM EXPENSES E + WHERE E.GROUP_ID = :groupId + AND E.STATUS != 'PAID' + ), 0) AS TOTAL + FROM GROUPS G + INNER JOIN GROUP_MEMBERS GM + ON GM.GROUP_ID = G.ID + AND GM.USER_ID = :userId + WHERE G.ID = :groupId + """) + GroupDetailsDTO getGroupDetails(Long groupId, Long userId); + +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/repository/RoleRepository.java b/backend/finboostplus_server/src/main/java/com/finboostplus/repository/RoleRepository.java new file mode 100644 index 00000000..08ce2958 --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/repository/RoleRepository.java @@ -0,0 +1,12 @@ +package com.finboostplus.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.finboostplus.model.Role; + +@Repository +public interface RoleRepository extends JpaRepository { + + Role findByAuthority(String authority); +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/repository/UserExpenseDivisionRepository.java b/backend/finboostplus_server/src/main/java/com/finboostplus/repository/UserExpenseDivisionRepository.java new file mode 100644 index 00000000..29c7d1f2 --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/repository/UserExpenseDivisionRepository.java @@ -0,0 +1,64 @@ +package com.finboostplus.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import com.finboostplus.model.User; +import com.finboostplus.model.UserExpenseDivision; + +@Repository +public interface UserExpenseDivisionRepository extends JpaRepository { + @Query(nativeQuery = true, value = """ + SELECT EXISTS( + SELECT 1 from user_expense_divisions + where user_id = :memberId and + (status = 'UNPAID' or status = 'PENDING') + ) + """) + boolean doesUserHasAnyExpense(long memberId); + + @Query(nativeQuery = true, value = """ + SELECT * FROM user_expense_divisions WHERE user_id = :userId AND expense_id = :expenseId + """) + UserExpenseDivision findByMemberIdAndExpenseId(Long userId, Long expenseId); + + @Query(nativeQuery = true, value = """ + SELECT EXISTS ( + SELECT 1 FROM user_expense_divisions u WHERE u.user_id = :userId AND u.expense_id = :expenseId + ) + """) + boolean existsByUserIdAndExpenseId(Long userId, Long expenseId); + + @Query(nativeQuery = true, value = """ + SELECT EXISTS( + SELECT 1 + FROM user_expense_divisions e WHERE e.expense_id = :expenseId AND (e.status = 'PENDING' OR e.status = 'UNPAID') + ) + """) + boolean isExpensePaid(Long expenseId); + + @Query(nativeQuery = true, value = """ + SELECT * from users WHERE id in + ( SELECT user_id from user_expense_divisions WHERE expense_id = :expenseId AND status != 'PAID' ) + """) + List findUserByExpenseId(Long expenseId); + + @Modifying + @Query(nativeQuery = true, value = """ + DELETE FROM user_expense_divisions ued + WHERE expense_id = :expenseId + """) + void deleteExpenseById(@Param("expenseId") Long expenseId); + + @Modifying + @Query(nativeQuery = true, value = """ + DELETE FROM user_expense_divisions + WHERE user_id = :memberId + """) + void deleteExpenseRelationByMemberId(@Param("memberId") Long groupId); +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/repository/UserRepository.java b/backend/finboostplus_server/src/main/java/com/finboostplus/repository/UserRepository.java new file mode 100644 index 00000000..2bf4523f --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/repository/UserRepository.java @@ -0,0 +1,51 @@ +package com.finboostplus.repository; + +import com.finboostplus.projection.UserDetailsProjection; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import com.finboostplus.DTO.UserDataDTO; +import com.finboostplus.model.*; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface UserRepository extends JpaRepository { + @Query(nativeQuery = true, value = """ + SELECT users.e_mail AS username, users.password, roles.id AS roleId, roles.authority + FROM users + INNER JOIN users_roles ON users.id = users_roles.user_id + INNER JOIN roles ON roles.id = users_roles.role_id + WHERE users.e_mail = :email + """) + List searchUserAndRolesByEmail(String email); + + Optional findByEmailIgnoreCase(String email); + + @Query(nativeQuery = true, value = """ + SELECT EXISTS( + SELECT 1 + FROM group_members AS gm + INNER JOIN users AS u ON gm.user_id = u.id + INNER JOIN groups AS g ON g.id = gm.group_id + WHERE u.id = :userId + AND g.id = :groupId + AND gm.auth_level IN (:authorities) + ); + """) + boolean isUserAuthorityValidToGroup(long userId, long groupId, List authorities); + + @Query(nativeQuery = true, value = """ + SELECT id, user_name, e_mail, theme_color + FROM users + WHERE e_mail = :email + """) + Optional getUserData(String email); + + @Query(nativeQuery = true, value = """ + SELECT password FROM users WHERE e_mail = :email + """) + String doesPasswordMatch(String email); +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/repository/ValidateUserRepository.java b/backend/finboostplus_server/src/main/java/com/finboostplus/repository/ValidateUserRepository.java new file mode 100644 index 00000000..b7b61111 --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/repository/ValidateUserRepository.java @@ -0,0 +1,12 @@ +package com.finboostplus.repository; + +import java.util.Optional; +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.finboostplus.model.ValidateUser; + +public interface ValidateUserRepository extends JpaRepository { + public Optional findByUuid(UUID uuid); +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/service/CategoryService.java b/backend/finboostplus_server/src/main/java/com/finboostplus/service/CategoryService.java new file mode 100644 index 00000000..c0b751e9 --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/service/CategoryService.java @@ -0,0 +1,35 @@ +package com.finboostplus.service; + +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import com.finboostplus.DTO.CategoryDTO; +import com.finboostplus.exception.ResourceNotFoundException; +import com.finboostplus.model.Category; +import com.finboostplus.repository.CategoryRepository; + +import org.springframework.transaction.annotation.Transactional; + +@Service +public class CategoryService { + @Autowired + CategoryRepository categoryRepository; + + public Category getCategory(Long idCategory) { + Category cat = new Category(); + cat = categoryRepository.findById(idCategory) + .orElseThrow(() -> new ResourceNotFoundException("Categoria não encontrada")); + return cat; + } + + @Transactional(readOnly = true) + public List findAllCategories() { + return categoryRepository.findAll() + .stream() + .map(entity -> new CategoryDTO(entity.getId(), entity.getName())) + .collect(Collectors.toList()); + } +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/service/EmailService.java b/backend/finboostplus_server/src/main/java/com/finboostplus/service/EmailService.java new file mode 100644 index 00000000..598ec420 --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/service/EmailService.java @@ -0,0 +1,30 @@ +package com.finboostplus.service; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.stereotype.Service; + +@Service +public class EmailService { + @Autowired + private JavaMailSender javaMailSender; + + @Value("${spring.mail.username}") + private String remetente; + + public String enviarEmailTexto(String destinatario, String assunto, String mensagem) { + try { + SimpleMailMessage simpleMailMessage = new SimpleMailMessage(); + simpleMailMessage.setFrom(remetente); + simpleMailMessage.setTo(destinatario); + simpleMailMessage.setSubject(assunto); + simpleMailMessage.setText(mensagem); + javaMailSender.send(simpleMailMessage); + return "Email enviado"; + } catch (Exception e) { + return "Erro ao tentar enviar email " + e.getLocalizedMessage(); + } + } +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/service/ExpenseService.java b/backend/finboostplus_server/src/main/java/com/finboostplus/service/ExpenseService.java new file mode 100644 index 00000000..246860bd --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/service/ExpenseService.java @@ -0,0 +1,306 @@ +package com.finboostplus.service; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.Set; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.finboostplus.DTO.CategoryRegisterDTO; +import com.finboostplus.DTO.ExpenseCreateDTO; +import com.finboostplus.DTO.ExpenseUpdateDTO; +import com.finboostplus.DTO.MembersExpenseDivisionCreateDTO; +import com.finboostplus.DTO.UserExpenseDivisionDTO; +import com.finboostplus.enums.Status; +import com.finboostplus.exception.CategoryNotFoundException; +import com.finboostplus.exception.ExpenseNotFoundException; +import com.finboostplus.exception.ForbiddenResourceException; +import com.finboostplus.exception.GroupNotFoundException; +import com.finboostplus.exception.UserNotFoundException; +import com.finboostplus.exception.ValuesIncompatiblesException; +import com.finboostplus.model.Category; +import com.finboostplus.model.Expense; +import com.finboostplus.model.Group; +import com.finboostplus.model.User; +import com.finboostplus.model.UserExpenseDivision; +import com.finboostplus.model.UserExpenseDivisionId; +import com.finboostplus.projection.GroupExpenseProjection; +import com.finboostplus.projection.UserExpenseDivisionProjection; +import com.finboostplus.repository.CategoryRepository; +import com.finboostplus.repository.ExpenseRepository; +import com.finboostplus.repository.GroupMemberRepository; +import com.finboostplus.repository.GroupRepository; +import com.finboostplus.repository.UserExpenseDivisionRepository; +import com.finboostplus.repository.UserRepository; + +@Service +public class ExpenseService { + @Autowired + GroupMemberService groupMemberService; + + @Autowired + ExpenseRepository expenseRepository; + + @Autowired + GroupService groupService; + + @Autowired + UserExpenseDivisionRepository userExpenseDivisionRepository; + + @Autowired + UserService userService; + + @Autowired + UserRepository userRepository; + + @Autowired + GroupMemberRepository groupMemberRepository; + + @Autowired + CategoryService categoryService; + + @Autowired + CategoryRepository categoryRepository; + + @Autowired + GroupRepository groupRepository; + + private final List AUTHLEVELS = List.of("OWNER", "ADMIN"); + + private void isUserAllowed(Long groupId) { + User user = userRepository.findByEmailIgnoreCase(userService.authenticated()) + .orElseThrow(() -> new UserNotFoundException("Usuário não encontrado")); + boolean hasAuthority = groupMemberRepository + .doesUserHasAnyAuthority(user.getId(), groupId, AUTHLEVELS); + if (!hasAuthority) { + throw new ForbiddenResourceException( + "Usuário não tem permissão para atualizar dados de despesas no grupo"); + } + } + + private boolean isValuesCompatibles(BigDecimal expenseValue, + Set expenseMembers) { + if (expenseValue == null || expenseMembers == null || expenseMembers.isEmpty()) { + return false; + } + BigDecimal total = expenseMembers.stream() + .map(MembersExpenseDivisionCreateDTO::value) + .reduce(BigDecimal.ZERO, BigDecimal::add); + return expenseValue.compareTo(total) == 0; + } + + @Transactional + public boolean createNewExpense(ExpenseCreateDTO expenseDTO, Long groupId) { + if (!isValuesCompatibles(expenseDTO.expenseValue(), expenseDTO.expenseDivision())) { + throw new ValuesIncompatiblesException( + "O total da divisão da despesa é incompatível com o valor da despesa"); + } + User user = userRepository + .findByEmailIgnoreCase(userService.authenticated()) + .orElseThrow(() -> new UsernameNotFoundException("Usuário não encontrado!")); + Group group = groupRepository.findById(groupId) + .orElseThrow(() -> new GroupNotFoundException("Grupo não encontrado")); + boolean userHasAnyAuthority = groupMemberRepository.doesUserHasAnyAuthority(user.getId(), group.getId(), + AUTHLEVELS); + if (!userHasAnyAuthority) { + throw new ForbiddenResourceException("Usuário não autorizado"); + } + Category category = categoryRepository.findById(expenseDTO.categoryId()) + .orElseThrow(() -> new CategoryNotFoundException("Categoria não encontrada")); + for (MembersExpenseDivisionCreateDTO member : expenseDTO.expenseDivision()) { + if (!groupMemberRepository.isUserMemberOfGroup(member.id(), groupId)) { + throw new ForbiddenResourceException("Membro da despesa não pertence ao grupo"); + } + } + Status status = expenseDTO.deadlineDate().isAfter(LocalDate.now()) ? Status.PENDING : Status.UNPAID; + Expense expense = new Expense( + expenseDTO.title(), + expenseDTO.description(), + expenseDTO.expenseValue(), + category, + group, + user.getId(), + expenseDTO.deadlineDate(), + status); + expense = expenseRepository.save(expense); + for (MembersExpenseDivisionCreateDTO member : expenseDTO.expenseDivision()) { + UserExpenseDivisionId userExpenseDivisionId = new UserExpenseDivisionId(member.id(), + expense.getId()); + User userMember = member.userExpenseDivisionCreateDTOToUser(); + UserExpenseDivision userExpenseDivision = new UserExpenseDivision( + userExpenseDivisionId, + userMember, + expense, + member.value(), + status); + userExpenseDivisionRepository.save(userExpenseDivision); + } + return true; + } + + @Transactional(readOnly = true) + public UserExpenseDivisionDTO getExpenseInfoDetails(Long groupId, Long expenseId) { + User user = userRepository.findByEmailIgnoreCase(userService.authenticated()) + .orElseThrow(() -> new UserNotFoundException("Usuário não encontrado")); + Group group = groupRepository.findById(groupId) + .orElseThrow(() -> new GroupNotFoundException("Grupo não encontrado")); + Expense expense = expenseRepository.findById(expenseId).orElseThrow( + () -> new ForbiddenResourceException("Despesa nao encontrada")); + Category category = expense.getCategory(); + List memberList = getExpenseDivisionDetails(group.getId(), expenseId); + boolean hasAuthority = groupMemberRepository.doesUserHasAnyAuthority(user.getId(), groupId, AUTHLEVELS); + boolean doesListContainMember = memberList.stream() + .anyMatch(item -> item.getUserId().equals(user.getId())); + if (hasAuthority || doesListContainMember) { + return new UserExpenseDivisionDTO(expense.getId(), expense.getTitle(), + expense.getDescription(), groupId, group.getName(), expense.getStatus(), + expense.getValue(), category.getId(), category.getName(), expense.getCreatedAt(), + expense.getDeadlineDate(), + memberList); + } + throw new ForbiddenResourceException("Acesso negado"); + } + + private List getExpenseDivisionDetails(Long groupId, Long expenseId) { + return expenseRepository.listUsersExpenseDivision(groupId, expenseId); + } + + // @Transactional(readOnly = true) + // public UserTotalExpensesDTO getAllUserExpenses() { + // User user = userRepository.findByEmailIgnoreCase(userService.authenticated()) + // .orElseThrow(() -> new UserNotFoundException("Usuário não encontrado")); + // + // } + + @Transactional(readOnly = true) + public Page getAllGroupExpenses(Long groupId, Status status, + boolean allMemberExpenses, + boolean allGroupMembersExpenses, Pageable pageable) { + User user = userRepository.findByEmailIgnoreCase(userService.authenticated()) + .orElseThrow(() -> new UserNotFoundException("Usuário não encontrado")); + if (!groupMemberRepository.isUserMemberOfGroup(user.getId(), groupId)) { + throw new ForbiddenResourceException("Acesso negado"); + } + boolean hasAuthority = groupMemberRepository + .doesUserHasAnyAuthority(user.getId(), groupId, AUTHLEVELS); + if (allGroupMembersExpenses == true && hasAuthority) { + allMemberExpenses = false; + if (status == Status.NOTAPPLY) { + return (Page) expenseRepository.getAllGroupExpensesOfAllMembers(user.getId(), + groupId, pageable); + } + return (Page) expenseRepository.getAllGroupExpensesOfAllMembersFiltered(user.getId(), + groupId, + status.name(), pageable); + } + if (allMemberExpenses == true && status == Status.NOTAPPLY) { + return (Page) expenseRepository.getAllGroupExpenses(user.getId(), groupId, pageable); + } + return (Page) expenseRepository.getAllGroupExpensesFiltered(user.getId(), groupId, + status.name(), pageable); + } + // + // @Transactional(readOnly = true) + // public List listExpenseGroupById(Long groupId) { + // User user = userRepository + // .findByEmailIgnoreCase(userService.authenticated()) + // .orElseThrow(() -> new UsernameNotFoundException("Usuário não encontrado!")); + // Group group = groupService.getGroup(groupId); + // List expenseProjections = null; + // boolean isUserMemberOfGroup = + // groupMemberRepository.isUserMemberOfGroup(user.getId(), group.getId()); + // if (user != null && group != null && isUserMemberOfGroup) { + // expenseProjections = expenseRepository.listExpensesGroupById(user.getId(), + // groupId); + // return expenseProjections; + // } + // return null; + // } + + @Transactional(readOnly = true) + public List getCategoryRegister() { + User user = userRepository.findByEmailIgnoreCase(userService.authenticated()) + .orElseThrow(() -> new UserNotFoundException("Usuário não encontrado")); + return expenseRepository.getExpensesByCategory(user.getId()); + } + + @Transactional + public boolean updateExpense(ExpenseUpdateDTO expenseDTO, Long groupId, Long expenseId) { + isUserAllowed(groupId); + Category category = categoryRepository.findById(expenseDTO.categoryId()) + .orElseThrow(() -> new CategoryNotFoundException("Categoria não encontrada")); + Expense expense = expenseRepository.findById(expenseId) + .orElseThrow(() -> new ExpenseNotFoundException("Despesa não encontrada")); + expense.setTitle(expenseDTO.title()); + expense.setDescription(expenseDTO.description()); + expense.setDeadlineDate(expenseDTO.deadlineDate()); + expense.setCategory(category); + if (expense.getStatus() != Status.PAID) { + String status = expenseDTO.deadlineDate().isAfter(LocalDate.now()) ? Status.PENDING.name() + : Status.UNPAID.name(); + expense.setStatus(Status.valueOf(status)); + expenseRepository.updateExpenseStatus(expenseId, status); + } + return true; + } + + @Transactional + public boolean updateExpenseStatus(Long memberId, Long groupId, Long expenseId, ExpenseUpdateDTO expenseDTO) { + User user = userRepository.findByEmailIgnoreCase(userService.authenticated()) + .orElseThrow(() -> new UserNotFoundException("Usuário não encontrado")); + boolean isMember = groupMemberRepository.isUserMemberOfGroup(memberId, groupId); + if (!isMember) { + throw new ForbiddenResourceException("Membro não pertence ao grupo"); + } + boolean isOwner = groupMemberRepository.isUserGroupOwner( + user.getId(), groupId); + if (!isOwner) + throw new ForbiddenResourceException( + "Usuário não possui autoridade para alterar o status da despesa"); + Expense expense = expenseRepository.findById(expenseId) + .orElseThrow(() -> new ExpenseNotFoundException("Despesa não encontrada")); + if (expense.getGroup().getId() != groupId) { + throw new ExpenseNotFoundException("Despesa não encontrada"); + } + UserExpenseDivision userExpenseDivision = userExpenseDivisionRepository + .findByMemberIdAndExpenseId(memberId, expenseId); + if (userExpenseDivision != null && userExpenseDivision.getStatus() != Status.PAID) { + userExpenseDivision.setStatus(Status.PAID); + } else if (userExpenseDivision != null && userExpenseDivision.getStatus() == Status.PAID + && expense.getDeadlineDate().isBefore(LocalDate.now())) { + userExpenseDivision.setStatus(Status.PENDING); + expenseRepository.setPaidExpense(expenseId, "PENDING"); + } else { + userExpenseDivision.setStatus(Status.UNPAID); + expenseRepository.setPaidExpense(expenseId, "UNPAID"); + } + boolean isExpensePaid = expenseRepository.isExpensePaid(expenseId); + if (isExpensePaid == false) { + expenseRepository.setPaidExpense(expenseId, "PAID"); + } + return (userExpenseDivisionRepository.save(userExpenseDivision) != null ? true : false); + } + + @Transactional + public void deleteExpense(Long expenseId, Long groupId) { + User user = userRepository.findByEmailIgnoreCase(userService.authenticated()) + .orElseThrow(() -> new UserNotFoundException("Usuário não encontrado")); + groupRepository.findById(groupId) + .orElseThrow(() -> new GroupNotFoundException("Grupo não encontrado")); + boolean isOwner = groupMemberRepository.isUserGroupOwner( + user.getId(), groupId); + if (!isOwner) { + throw new ForbiddenResourceException("Usuário não tem permissão"); + } + expenseRepository.findById(expenseId) + .orElseThrow(() -> new ExpenseNotFoundException("Despesa não encontrada")); + userExpenseDivisionRepository.deleteExpenseById(expenseId); + expenseRepository.deleteExpenseById(expenseId); + } +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/service/GroupMemberService.java b/backend/finboostplus_server/src/main/java/com/finboostplus/service/GroupMemberService.java new file mode 100644 index 00000000..8ed48fce --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/service/GroupMemberService.java @@ -0,0 +1,106 @@ +package com.finboostplus.service; + +import java.time.Instant; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import com.finboostplus.DTO.GroupMemberDTO; +import com.finboostplus.exception.ForbiddenResourceException; +import com.finboostplus.exception.GroupNotFoundException; +import com.finboostplus.exception.UserAlreadyRegisteredOnGroupException; +import com.finboostplus.exception.UserNotFoundException; +import com.finboostplus.model.Group; +import com.finboostplus.model.GroupMember; +import com.finboostplus.model.GroupMemberId; +import com.finboostplus.model.User; +import com.finboostplus.repository.GroupMemberRepository; +import com.finboostplus.repository.GroupRepository; +import com.finboostplus.repository.UserRepository; + +import jakarta.transaction.Transactional; + +@Service +public class GroupMemberService { + + @Autowired + UserRepository userRepository; + + @Autowired + GroupRepository groupRepository; + + @Autowired + GroupMemberRepository groupMemberRepository; + + @Autowired + UserService userService; + + private final List AUTHORITIES = Arrays.asList("OWNER", "ADMIN"); + + @Transactional + public void addGroupMember(Long groupId, GroupMemberDTO groupMemberDTO) { + User user = userRepository.findByEmailIgnoreCase(userService.authenticated()) + .orElseThrow(() -> new UserNotFoundException("Usuário logado não encontrado")); + User groupNewMember = userRepository.findByEmailIgnoreCase(groupMemberDTO.email()) + .orElseThrow(() -> new UserNotFoundException( + "Usuário que se deseja inserir não foi encontrado")); + if (!groupNewMember.isEnabled()) { + throw new UserNotFoundException("Usuário não encontrado"); + } + Group group = groupRepository.findById(Long.parseLong(String.valueOf(groupId))) + .orElseThrow(() -> new GroupNotFoundException("Grupo não encontrado")); + boolean isUserGroupMember = groupMemberRepository.isUserMemberOfGroup(groupNewMember.getId(), groupId); + if (isUserGroupMember) { + throw new UserAlreadyRegisteredOnGroupException("Usuário já é membro no grupo"); + } + if (userRepository.isUserAuthorityValidToGroup(user.getId(), groupId, + this.AUTHORITIES) == true + && (groupMemberDTO.authorization().equals("USER") + || groupMemberDTO.authorization().equals("ADMIN"))) { + GroupMember member = new GroupMember(); + member.setUser(groupNewMember); + member.setGroup(group); + member.setAuthorization(groupMemberDTO.authorization()); + member.setId(new GroupMemberId(groupNewMember.getId(), group.getId())); + member.setEntryDate(Instant.now()); + groupMemberRepository.save(member); + } else { + throw new ForbiddenResourceException("Recurso não autorizado"); + } + } + + @Transactional + public boolean insertGroupOwner(User user, Group group) { + GroupMember owner = new GroupMember(); + owner.setUser(user); + owner.setGroup(group); + owner.setAuthorization(this.AUTHORITIES.get(0)); + owner.setEntryDate(Instant.now()); + owner.setId(new GroupMemberId(user.getId(), group.getId())); + return groupMemberRepository.save(owner) != null ? true : false; + } + + // @Transactional + // public boolean switchAuthGroup(User user, Group group, int auth) { + // GroupMember userAuth = new GroupMember(); + // userAuth.setUser(user); + // userAuth.setGroup(group); + // userAuth.setAuthorization(this.AUTHORITIES.get(auth)); + // userAuth.setEntryDate(Instant.now()); + // userAuth.setId(new GroupMemberId(user.getId(), group.getId())); + // return groupMemberRepository.save(userAuth) != null ? true : false; + // } + + @Transactional + public boolean getUsersOnGroupByAuthority(Long userId, Long groupId, List authLevels) { + return groupMemberRepository.doesUserHasAnyAuthority(userId, groupId, authLevels); + } + + @Transactional + public boolean isUserMemberOfGroup(long userId, long groupId) { + return groupMemberRepository.isUserMemberOfGroup(userId, groupId); + } +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/service/GroupService.java b/backend/finboostplus_server/src/main/java/com/finboostplus/service/GroupService.java new file mode 100644 index 00000000..55fab1af --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/service/GroupService.java @@ -0,0 +1,225 @@ +package com.finboostplus.service; + +import java.util.List; +import java.util.Optional; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.finboostplus.DTO.GroupCreateDTO; +import com.finboostplus.DTO.GroupDetailsDTO; +import com.finboostplus.DTO.GroupMemberAuthorityDTO; +import com.finboostplus.DTO.GroupMemberResponseDTO; +import com.finboostplus.DTO.GroupUpdateDTO; +import com.finboostplus.exception.ForbiddenResourceException; +import com.finboostplus.exception.GroupNotFoundException; +import com.finboostplus.exception.MemberHasPendingExpensesException; +import com.finboostplus.exception.OwnerLeaveNotAllowedException; +import com.finboostplus.exception.UserNotFoundException; +import com.finboostplus.model.Group; +import com.finboostplus.model.GroupMember; +import com.finboostplus.model.User; +import com.finboostplus.projection.GroupProjection; +import com.finboostplus.repository.ExpenseRepository; +import com.finboostplus.repository.GroupMemberRepository; +import com.finboostplus.repository.GroupRepository; +import com.finboostplus.repository.UserRepository; + +@Service +public class GroupService { + @Autowired + GroupRepository groupRepository; + + @Autowired + GroupMemberRepository groupMemberRepository; + + @Autowired + GroupMemberService groupMemberService; + + @Autowired + UserRepository userRepository; + + @Autowired + UserService userService; + + @Autowired + ExpenseRepository expenseRepository; + + @Transactional + public boolean createNewGroup(GroupCreateDTO groupDto) { + User user = userRepository + .findByEmailIgnoreCase(userService.authenticated()) + .orElseThrow(() -> new UsernameNotFoundException("Usuário não encontrado!")); + Group group = groupDto.groupDtoToGroup(); + group = groupRepository.save(group); + return groupMemberService.insertGroupOwner(user, group); + } + + @Transactional + public Optional updateGroup(Long id, GroupUpdateDTO groupDto) { + User user = userRepository + .findByEmailIgnoreCase(userService.authenticated()) + .orElseThrow(() -> new UsernameNotFoundException("Usuário não encontrado!")); + boolean isValid = groupMemberRepository.isUserGroupOwner( + user.getId(), id); + if (isValid) { + Group group = groupRepository.findById(id) + .orElseThrow(() -> new GroupNotFoundException("Grupo não encontrado")); + if (groupDto.name() != null && !groupDto.name().equals("")) { + group.setName(groupDto.name()); + } + if (groupDto.description() != null) { + group.setDescription(groupDto.description()); + } + if (groupDto.icon() != null) { + group.setIcon(groupDto.icon()); + } + return Optional.ofNullable(groupRepository.save(group)); + } + throw new ForbiddenResourceException("Usuário sem permissão"); + } + + // @Transactional(readOnly = true) + // public GroupDetailsDTO getGroupExpensesById(Long groupId) { + // User user = userRepository + // .findByEmailIgnoreCase(userService.authenticated()) + // .orElseThrow(() -> new UsernameNotFoundException("Usuário não encontrado!")); + // Group group = getGroup(groupId); + // if (group != null) { + // List expenseList = + // expenseRepository.listExpensesGroupById(user.getId(), + // groupId); + // GroupDetailsDTO dto = new GroupDetailsDTO(group.getId(), group.getName(), + // expenseList); + // return dto; + // } + // return null; + // } + + @Transactional(readOnly = true) + public Page listUserGroupsPaged(Pageable pageable) { + User user = userRepository + .findByEmailIgnoreCase(userService.authenticated()) + .orElseThrow(() -> new UsernameNotFoundException("Usuário não encontrado!")); + Page groups = groupRepository.listUserGroupsPaged(user.getId(), pageable); + return groups; + } + + @Transactional(readOnly = true) + public Group getGroup(Long idGroup) { + Group group = groupRepository.findById(idGroup) + .orElseThrow(() -> new GroupNotFoundException("Grupo não encontrado")); + return group; + } + + @Transactional(readOnly = true) + public GroupMemberAuthorityDTO getMemberAuthority(Long groupId, Long memberId) { + User user = userRepository + .findByEmailIgnoreCase(userService.authenticated()) + .orElseThrow(() -> new UsernameNotFoundException("Usuário não encontrado!")); + boolean isLoggedUserMemberOfGroup = groupMemberRepository.isUserMemberOfGroup(user.getId(), groupId); + boolean isUserMemberOfGroup = groupMemberRepository.isUserMemberOfGroup(memberId, groupId); + if (isLoggedUserMemberOfGroup && isUserMemberOfGroup) { + return groupRepository.getMemberAuthority(groupId, memberId); + } + throw new ForbiddenResourceException("Acesso negado"); + } + + @Transactional(readOnly = true) + public GroupDetailsDTO getGroupDetails(Long groupId) { + User user = userRepository.findByEmailIgnoreCase(userService.authenticated()) + .orElseThrow(() -> new UserNotFoundException("Usuário não encontrado")); + groupRepository.findById(groupId) + .orElseThrow(() -> new GroupNotFoundException("Grupo não encontrado")); + boolean isUserMemberOfGroup = groupMemberRepository.isUserMemberOfGroup(user.getId(), groupId); + if (isUserMemberOfGroup) { + return groupRepository.getGroupDetails(groupId, user.getId()); + } + throw new ForbiddenResourceException("Acesso negado"); + } + + @Transactional(readOnly = true) + public Page findAllMembersByGroupId(Long groupId, Pageable pageable, String search) { + Long userId = userRepository.findByEmailIgnoreCase(userService.authenticated()) + .orElseThrow(() -> new UsernameNotFoundException("Usuário não encontrado!")).getId(); + groupRepository.findById(groupId) + .orElseThrow(() -> new GroupNotFoundException("Grupo não encontrado")); + boolean isMember = groupMemberRepository.isUserMemberOfGroup(userId, groupId); + if (!isMember) { + throw new ForbiddenResourceException("Usuário sem permissão"); + } + if (search.isBlank() || search.isEmpty()) { + return groupMemberRepository.findMembersByGroupId(groupId, pageable); + } + return groupMemberRepository.findMembersByGroupIdFiltered(groupId, pageable, search); + } + + @Transactional + public void leaveGroup(Long groupId) { + User user = userRepository.findByEmailIgnoreCase(userService.authenticated()) + .orElseThrow(() -> new UserNotFoundException("Usuário não encontrado")); + groupRepository.findById(groupId) + .orElseThrow(() -> new GroupNotFoundException("Grupo não encontrado")); + if (groupMemberRepository.doesUserHasAnyAuthority(user.getId(), groupId, List.of("OWNER"))) { + throw new OwnerLeaveNotAllowedException( + "O dono do grupo não pode sair antes de transferir a função para outro membro"); + } + if (expenseRepository.doesMemberHasPendingExpenses(user.getId(), groupId)) { + throw new MemberHasPendingExpensesException( + "Membro não pode deixar o grupo com despesas em aberto"); + } + groupMemberRepository.deleteByUserIdAndGroupId(user.getId(), groupId); + } + + @Transactional + public void removeGroupMember(Long groupId, Long memberId) { + User user = userRepository.findByEmailIgnoreCase(userService.authenticated()) + .orElseThrow(() -> new UserNotFoundException("Usuário autenticado não encontrado")); + GroupMember loggedInMember = groupMemberRepository + .findGroupMemberByMemberId(user.getId(), groupId) + .orElseThrow(() -> new UsernameNotFoundException( + "Usuário autenticado não encontrado no grupo")); + GroupMember targetMember = groupMemberRepository.findGroupMemberByMemberId(memberId, groupId) + .orElseThrow(() -> new UsernameNotFoundException( + "Usuário que se pretende remover não foi encontrado")); + if (!groupMemberRepository.isUserMemberOfGroup(user.getId(), groupId) || + !groupMemberRepository.isUserMemberOfGroup(targetMember.getUser().getId(), groupId)) { + throw new ForbiddenResourceException("Acesso não permitido"); + } + if (expenseRepository.doesMemberHasPendingExpenses(targetMember.getUser().getId(), groupId)) { + throw new MemberHasPendingExpensesException("Usuário ainda possui despesas pendentes"); + } + String targetAuth = targetMember.getAuthorization(); + String loggedAuth = loggedInMember.getAuthorization(); + if ("OWNER".equals(loggedAuth)) { + groupMemberRepository.deleteByUserIdAndGroupId(memberId, groupId); + return; + } else if ("ADMIN".equals(loggedAuth) && "USER".equals(targetAuth)) { + groupMemberRepository.deleteByUserIdAndGroupId(memberId, groupId); + return; + } + throw new ForbiddenResourceException("Recurso não permitido"); + } + + @Transactional + public void deleteGroup(Long groupId) { + String userName = userService.authenticated(); + User loggedUser = userRepository.findByEmailIgnoreCase(userName) + .orElseThrow(() -> new UserNotFoundException("Usuário autenticado não encontrado")); + groupRepository.findById(groupId) + .orElseThrow(() -> new GroupNotFoundException("Grupo não encontrado")); + if (groupMemberRepository.isUserGroupOwner(loggedUser.getId(), + groupId) == false) { + throw new ForbiddenResourceException("Usuário não possui autoridade para excluir o grupo"); + } + if (expenseRepository.groupHasPendingExpenses(groupId)) { + throw new ForbiddenResourceException("Grupo possui despesas pendentes e não pode ser excluído"); + } + groupMemberRepository.deleteGroupRelationById(groupId); + groupRepository.deleteGroupById(groupId); + } +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/service/NotificationService.java b/backend/finboostplus_server/src/main/java/com/finboostplus/service/NotificationService.java new file mode 100644 index 00000000..647ace0b --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/service/NotificationService.java @@ -0,0 +1,32 @@ +package com.finboostplus.service; + +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.List; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import com.finboostplus.model.Expense; +import com.finboostplus.model.User; +import com.finboostplus.repository.UserExpenseDivisionRepository; + +@Service +public class NotificationService { + @Autowired + private EmailService emailService; + + @Autowired + private UserExpenseDivisionRepository userExpenseDivisionRepository; + + public void notifyExpenseExpiring(Expense expense) { + List users = userExpenseDivisionRepository.findUserByExpenseId(expense.getId()); + long daysUntilExpiration = ChronoUnit.DAYS.between( + LocalDate.now(), + expense.getDeadlineDate()); + for (User user : users) { + emailService.enviarEmailTexto(user.getEmail(), "Despesa perto de expirar", "Sua despesa " + + expense.getTitle() + " vence em " + daysUntilExpiration + " dias"); + } + } +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/service/UserService.java b/backend/finboostplus_server/src/main/java/com/finboostplus/service/UserService.java new file mode 100644 index 00000000..808c355d --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/service/UserService.java @@ -0,0 +1,283 @@ +package com.finboostplus.service; + +import java.time.Instant; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.finboostplus.DTO.ChangePasswordDTO; +import com.finboostplus.DTO.SwitchAuthorityRequestDTO; +import com.finboostplus.DTO.UserCreateDTO; +import com.finboostplus.DTO.UserDataDTO; +import com.finboostplus.DTO.UserExpensesDTO; +import com.finboostplus.DTO.UserMonthlyExpensesDTO; +import com.finboostplus.DTO.UserUpdateDTO; +import com.finboostplus.exception.ForbiddenResourceException; +import com.finboostplus.exception.GroupNotFoundException; +import com.finboostplus.exception.InvalidCredentialsException; +import com.finboostplus.exception.MemberHasPendingExpensesException; +import com.finboostplus.exception.UserAlreadyRegisteredOnGroupException; +import com.finboostplus.exception.UserNotFoundException; +import com.finboostplus.exception.ValuesIncompatiblesException; +import com.finboostplus.model.Group; +import com.finboostplus.model.GroupMember; +import com.finboostplus.model.Role; +import com.finboostplus.model.User; +import com.finboostplus.model.ValidateUser; +import com.finboostplus.projection.UserDetailsProjection; +import com.finboostplus.repository.ExpenseRepository; +import com.finboostplus.repository.GroupMemberRepository; +import com.finboostplus.repository.RoleRepository; +import com.finboostplus.repository.UserExpenseDivisionRepository; +import com.finboostplus.repository.UserRepository; +import com.finboostplus.repository.ValidateUserRepository; +import com.finboostplus.util.PasswordGenerator; +import org.springframework.beans.factory.annotation.Value; + +@Service +public class UserService implements UserDetailsService { + @Autowired + UserRepository userRepository; + + @Autowired + RoleRepository roleRepository; + + @Autowired + GroupMemberRepository groupMemberRepository; + + @Autowired + UserExpenseDivisionRepository userExpenseDivisionRepository; + + @Autowired + @Lazy + GroupService groupService; + + @Autowired + @Lazy + GroupMemberService groupMemberService; + + @Autowired + private EmailService emailService; + + @Autowired + ValidateUserRepository validateUserRepository; + + @Autowired + ExpenseRepository expenseRepository; + + @Value("${URL_BASE}") + private String urlBase; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + List result = userRepository.searchUserAndRolesByEmail(username); + if (result.size() == 0) { + throw new UsernameNotFoundException("Email não encontrado"); + } + User user = new User(); + user.setEmail(result.get(0).getUsername()); + user.setPassword(result.get(0).getPassword()); + for (UserDetailsProjection projection : result) { + user.addRole(new Role(projection.getRoleId(), projection.getAuthority())); + } + return user; + } + + public UserDataDTO getUserData() { + return userRepository.getUserData(authenticated()) + .orElseThrow(() -> new UsernameNotFoundException("Usuário não encontrado")); + } + + public Page getAllUserExpenses(Pageable pageable) { + User user = userRepository.findByEmailIgnoreCase(authenticated()) + .orElseThrow(() -> new UsernameNotFoundException("Usuário não encontrado")); + return expenseRepository.getAllUserExpenses(user.getId(), pageable); + } + + public List getUserMonthlyExpenses() { + User user = userRepository.findByEmailIgnoreCase(authenticated()) + .orElseThrow(() -> new UsernameNotFoundException("Usuário não encontrado")); + return expenseRepository.getUserMonthlyExpenses(user.getId()); + } + + @Transactional + public boolean saveUser(UserCreateDTO dto) { + Optional userOptional = userRepository.findByEmailIgnoreCase(dto.email()); + if (userOptional.isPresent()) { + throw new UserAlreadyRegisteredOnGroupException("Email já cadastrado"); + } + User user = User.dtoToUser(dto); + PasswordEncoder passwordEncoder = passwordEncoder(); + user.setPassword(passwordEncoder.encode(dto.password())); + Role role = roleRepository.findByAuthority("ROLE_USER"); + Set roles = new HashSet<>(); + roles.add(role); + user.setRoles(roles); + User userSaved = userRepository.save(user); + ValidateUser validateUser = new ValidateUser(); + validateUser.setUser(userSaved); + validateUser.setUuid(UUID.randomUUID()); + validateUser.setExpirationDate(Instant.now().plusSeconds(900)); + validateUserRepository.save(validateUser); + emailService.enviarEmailTexto(userSaved.getEmail(), + "Conta criada com sucesso!", + "Seja bem vindo(a) " + userSaved.getName() + " ao FinboostPlus!\n" + + "Para ativar sua conta, acesse o link: " + urlBase + "/user/userValidate/" // Futuramente: + // https://finboostplus.com.br + // ou + // algo + // assim + + validateUser.getUuid()); + System.out.println("Para ativar sua conta, acesse o link: " + urlBase + "/user/userValidate/" + validateUser.getUuid()); + System.out.print(validateUser.getUuid()); // Ajuda para ativar o usuário cadastrado + return userSaved.getId() != null; + } + + @Transactional + public boolean updateUser(UserUpdateDTO dto) { + User user = userRepository.findByEmailIgnoreCase(authenticated()) + .orElseThrow(() -> new UserNotFoundException("Usuário nao encontrado")); + if (!dto.name().equals(user.getName())) { + user.setName(dto.name()); + } + if (!dto.email().equals(user.getEmail())) { + user.setEmail(dto.email()); + } + if (!dto.themeColor().equals(user.getThemeColor()) && dto.themeColor() != null) { + user.setThemeColor(dto.themeColor()); + } + User userUpdated = userRepository.save(user); + return userUpdated.getId() != null; + } + + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + public void changePassword(ChangePasswordDTO passwordChange) { + User user = userRepository.findByEmailIgnoreCase(authenticated()) + .orElseThrow(() -> new UserNotFoundException("Usuário não encontrado")); + PasswordEncoder passwordEncoder = passwordEncoder(); + String encodedOldPassword = userRepository.doesPasswordMatch(user.getEmail()); + boolean doesPasswordMatch = passwordEncoder.matches(passwordChange.oldPassword(), encodedOldPassword); + if (doesPasswordMatch) { + String encodedNewPassword = passwordEncoder.encode(passwordChange.newPassword()); + user.setPassword(encodedNewPassword); + userRepository.save(user); + return; + } + throw new InvalidCredentialsException("Credenciais Inválidas"); + } + + public String authenticated() { + try { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + Jwt jwtPrincipal = (Jwt) authentication.getPrincipal(); + return jwtPrincipal.getClaim("username"); + } catch (Exception e) { + throw new UsernameNotFoundException("Não foi encontrado o usuário"); + } + } + + @Transactional + public void deleteCurrentUserProfile() { + final User user = userRepository + .findByEmailIgnoreCase(authenticated()) + .orElseThrow(() -> new UsernameNotFoundException("Usuário não encontrado!")); + if (groupMemberRepository.isUserOwnerOfAnyGroup(user.getId())) { + throw new ForbiddenResourceException( + "Usuário é proprietário de grupos ativos e não pode ser removido"); + } + if (userExpenseDivisionRepository.doesUserHasAnyExpense(user.getId())) { + throw new MemberHasPendingExpensesException( + "Não é possível excluir o perfil com despesas pendentes"); + } + userExpenseDivisionRepository.deleteExpenseRelationByMemberId(user.getId()); + groupMemberRepository.deleteGroupsRelationByMemberId(user.getId()); + userRepository.deleteById(user.getId()); + } + + @Transactional + public void forgotPassword(String userName) { + User user = userRepository.findByEmailIgnoreCase(userName) + .orElseThrow(() -> new UserNotFoundException("Usuário nao encontrado")); + String newPassword = PasswordGenerator.generateRandomPassword(); + emailService.enviarEmailTexto(user.getEmail(), + "Esqueceu sua senha?", + "Olá " + user.getName() + " sua nova senha é " + newPassword); + PasswordEncoder passwordEncoder = passwordEncoder(); + user.setPassword(passwordEncoder.encode(newPassword)); + } + + public boolean validateUser(String uuid) { + Optional validateUser = + validateUserRepository.findByUuid(UUID.fromString(uuid)); + // token não encontrado + if (validateUser.isEmpty()) { + return false; + } + // token expirado + if (validateUser.get().getExpirationDate().isBefore(Instant.now())) { + return false; + } + User user = validateUser.get().getUser(); + if (user.isEnabled()) { + throw new ForbiddenResourceException("Usuário já habilitado"); + } + user.setActive(true); + validateUserRepository.delete(validateUser.get()); + return true; + } + + @Transactional + public boolean switchAuthority(Long newOwnerId, Long groupId, SwitchAuthorityRequestDTO authDTO) { + List authLevels = List.of("OWNER", "ADMIN", "USER"); + String setAuthority = authDTO.setAuthority().toUpperCase().trim(); + if (!authLevels.contains(setAuthority)) + throw new ValuesIncompatiblesException("Valor inválido"); + User user = userRepository.findByEmailIgnoreCase(authenticated()) + .orElseThrow(() -> new UserNotFoundException("Usuário nao encontrado")); + GroupMember newUserAuth = groupMemberRepository.findGroupMemberByMemberId(newOwnerId, groupId) + .orElseThrow(() -> new UserNotFoundException("Usuário não encontrado")); + if (user.getId() == newUserAuth.getUser().getId()) + throw new ForbiddenResourceException("Operação inválida"); + Group group = groupService.getGroup(groupId); + if (group == null) + throw new GroupNotFoundException("Grupo não encontrado"); + if (setAuthority == "OWNER") { + if (!groupMemberRepository.isUserGroupOwner(user.getId(), + group.getId())) + throw new ForbiddenResourceException("Acesso negado"); + GroupMember owner = groupMemberRepository.findGroupMemberByMemberId(user.getId(), groupId) + .orElseThrow(() -> new UserNotFoundException("Usuário não encontrado")); + owner.setAuthorization("ADMIN"); + newUserAuth.setAuthorization(setAuthority); + groupMemberRepository.save(owner); + groupMemberRepository.save(newUserAuth); + return true; + } + if (!groupMemberRepository.doesUserHasAnyAuthority(user.getId(), + group.getId(), List.of("OWNER", "ADMIN"))) + throw new ForbiddenResourceException("Acesso negado"); + newUserAuth.setAuthorization(setAuthority); + groupMemberRepository.save(newUserAuth); + return true; + } +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/util/ExpenseNotificationScheduler.java b/backend/finboostplus_server/src/main/java/com/finboostplus/util/ExpenseNotificationScheduler.java new file mode 100644 index 00000000..558a2b08 --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/util/ExpenseNotificationScheduler.java @@ -0,0 +1,35 @@ +package com.finboostplus.util; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import com.finboostplus.model.Expense; +import com.finboostplus.repository.ExpenseRepository; +import com.finboostplus.service.NotificationService; + +@Component +public class ExpenseNotificationScheduler { + @Autowired + private ExpenseRepository expenseRepository; + + @Autowired + private NotificationService notificationService; + + @Scheduled(cron = "0 0 9 * * *") + public void checkExpiringExpenses() { + Instant today = Instant.now(); + LocalDate notificationDate = LocalDate.now().plusDays(3); // 3 dias antes + + List expiringExpenses = expenseRepository + .findByExpirationDateBetween(today, notificationDate); + + for (Expense expense : expiringExpenses) { + notificationService.notifyExpenseExpiring(expense); + } + } +} diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/util/PasswordGenerator.java b/backend/finboostplus_server/src/main/java/com/finboostplus/util/PasswordGenerator.java new file mode 100644 index 00000000..c04aad8b --- /dev/null +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/util/PasswordGenerator.java @@ -0,0 +1,33 @@ +package com.finboostplus.util; + +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class PasswordGenerator { + private static final String UPPERCASE = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + private static final String LOWERCASE = "abcdefghijklmnopqrstuvwxyz"; + private static final String DIGITS = "0123456789"; + private static final String SPECIAL = "!@#$%&*"; + private static final SecureRandom random = new SecureRandom(); + + public static String generateRandomPassword() { + final int LENGTH = 12; + List passwordChars = new ArrayList<>(); + passwordChars.add(UPPERCASE.charAt(random.nextInt(UPPERCASE.length()))); + passwordChars.add(LOWERCASE.charAt(random.nextInt(LOWERCASE.length()))); + passwordChars.add(DIGITS.charAt(random.nextInt(DIGITS.length()))); + passwordChars.add(SPECIAL.charAt(random.nextInt(SPECIAL.length()))); + String allCharacters = UPPERCASE + LOWERCASE + DIGITS + SPECIAL; + for (int i = 4; i < LENGTH; i++) { + passwordChars.add(allCharacters.charAt(random.nextInt(allCharacters.length()))); + } + Collections.shuffle(passwordChars, random); + StringBuilder password = new StringBuilder(LENGTH); + for (Character ch : passwordChars) { + password.append(ch); + } + return password.toString(); + } +} diff --git a/backend/finboostplus_server/src/main/resources/application.properties b/backend/finboostplus_server/src/main/resources/application.properties new file mode 100644 index 00000000..ab058c08 --- /dev/null +++ b/backend/finboostplus_server/src/main/resources/application.properties @@ -0,0 +1,47 @@ +spring.application.name=finboostplus_teste + +# Configuracoes de banco de dados (comentadas para modo documentacao) +# Trocar de H2 para PostgreSQL +spring.datasource.url=jdbc:postgresql://localhost:5432/finboostplus +spring.datasource.username=${POSTGRES_USERNAME} +spring.datasource.password=${POSTGRES_PASSWORD} +spring.datasource.driver-class-name=org.postgresql.Driver +spring.jpa.hibernate.ddl-auto=update + +# Configuracao H2 em memoria para testar documentacao +# spring.datasource.url=jdbc:h2:mem:testdb +# spring.datasource.driver-class-name=org.h2.Driver +# spring.datasource.username=sa +# spring.datasource.password= +# spring.h2.console.enabled=true +# spring.jpa.hibernate.ddl-auto=create-drop +# spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.H2Dialect + +security.client-id=${CLIENT_ID} +security.client-secret=${CLIENT_SECRET} +security.jwt.duration=${JWT_DURATION:864000} +security.refresh-token.duration=${REFRESH_TOKEN_DURATION:30} +cors.origins=${CORS_ORIGINS} + +spring.mail.host=smtp.gmail.com +spring.mail.port=587 +spring.mail.username=${MENSAGERIE_EMAIL} +spring.mail.password=${MENSAGERIE_PASSWORD} +spring.mail.properties.mail.smtp.auth=true +spring.mail.properties.mail.smtp.starttls.enable=true + +# SpringDoc OpenAPI Configuration +springdoc.api-docs.path=/v3/api-docs +springdoc.swagger-ui.path=/swagger-ui.html +springdoc.swagger-ui.enabled=true +springdoc.api-docs.enabled=true + +# Configuracoes especificas para integracao com Scalar +springdoc.swagger-ui.disable-swagger-default-url=true +springdoc.swagger-ui.config-url=/v3/api-docs/swagger-config +springdoc.swagger-ui.url=/v3/api-docs + +# Configuracoes de seguranca para documentacao +springdoc.show-actuator=false +springdoc.group-configs[0].group=public +springdoc.group-configs[0].paths-to-match=/** diff --git a/backend/finboostplus_server/src/main/resources/db/migration/V1__create_initial_tables.sql b/backend/finboostplus_server/src/main/resources/db/migration/V1__create_initial_tables.sql new file mode 100644 index 00000000..57118fd8 --- /dev/null +++ b/backend/finboostplus_server/src/main/resources/db/migration/V1__create_initial_tables.sql @@ -0,0 +1,18 @@ +CREATE SEQUENCE seq_category + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +CREATE TABLE categories ( + id BIGINT NOT NULL DEFAULT nextval('seq_category'), + name VARCHAR(255) UNIQUE, + CONSTRAINT pk_categories PRIMARY KEY (id) +); + + +CREATE TABLE roles ( + id BIGSERIAL PRIMARY KEY, + authority VARCHAR(255) NOT NULL UNIQUE +); diff --git a/backend/finboostplus_server/src/main/resources/db/migration/V2__insert_initial_data.sql b/backend/finboostplus_server/src/main/resources/db/migration/V2__insert_initial_data.sql new file mode 100644 index 00000000..cc5e9cb3 --- /dev/null +++ b/backend/finboostplus_server/src/main/resources/db/migration/V2__insert_initial_data.sql @@ -0,0 +1,21 @@ +INSERT INTO roles (authority) +VALUES ('ROLE_USER'); + +INSERT INTO categories (id, name) VALUES + (1, 'Alimentação'), + (2, 'Transporte'), + (3, 'Moradia'), + (4, 'Contas e Utilidades'), + (5, 'Saúde'), + (6, 'Educação'), + (7, 'Lazer e Entretenimento'), + (8, 'Compras e Serviços'), + (9, 'Viagens'), + (10, 'Assinaturas e Streaming'), + (11, 'Presentes e Doações'), + (12, 'Investimentos'), + (13, 'Impostos e Taxas'), + (14, 'Animais de Estimação'), + (15, 'Outros'); + +ALTER SEQUENCE seq_category RESTART WITH 16; diff --git a/backend/finboostplus_teste/src/test/java/com/finboostplus/DemoApplicationTests.java b/backend/finboostplus_server/src/test/java/com/finboostplus/DemoApplicationTests.java similarity index 71% rename from backend/finboostplus_teste/src/test/java/com/finboostplus/DemoApplicationTests.java rename to backend/finboostplus_server/src/test/java/com/finboostplus/DemoApplicationTests.java index 54b0aae4..f501f766 100644 --- a/backend/finboostplus_teste/src/test/java/com/finboostplus/DemoApplicationTests.java +++ b/backend/finboostplus_server/src/test/java/com/finboostplus/DemoApplicationTests.java @@ -10,4 +10,8 @@ class DemoApplicationTests { void contextLoads() { } + @Test + public void testeINsercao() { + System.out.println("Imprimir console"); + } } diff --git a/backend/finboostplus_teste/README.Docker.md b/backend/finboostplus_teste/README.Docker.md deleted file mode 100644 index 8716f34b..00000000 --- a/backend/finboostplus_teste/README.Docker.md +++ /dev/null @@ -1,19 +0,0 @@ -### Building and running your application - -When you're ready, start your application by running: -`docker compose up --build`. - -Your application will be available at http://localhost:8080. - -### Deploying your application to the cloud - -First, build your image, e.g.: `docker build -t myapp .`. -If your cloud uses a different CPU architecture than your development -machine (e.g., you are on a Mac M1 and your cloud provider is amd64), -you'll want to build the image for that platform, e.g.: -`docker build --platform=linux/amd64 -t myapp .`. - -Then, push it to your registry, e.g. `docker push myregistry.com/myapp`. - -Consult Docker's [getting started](https://docs.docker.com/go/get-started-sharing/) -docs for more detail on building and pushing. \ No newline at end of file diff --git a/backend/finboostplus_teste/docker-compose.yaml b/backend/finboostplus_teste/docker-compose.yaml deleted file mode 100644 index 07af5a8c..00000000 --- a/backend/finboostplus_teste/docker-compose.yaml +++ /dev/null @@ -1,34 +0,0 @@ -services: - server: - build: - context: . - ports: - - 8080:8080 - depends_on: - db: - condition: service_healthy - volumes: - - .:/build - environment: - - POSTGRES_URL=jdbc:postgresql://db:5432/finboostplus - - SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/finboostplus - - SPRING_DATASOURCE_USERNAME=postgres - - SPRING_DATASOURCE_PASSWORD=1234 - db: - image: postgres - restart: always - volumes: - - db-data:/var/lib/postgresql/data - environment: - - POSTGRES_DB=finboostplus - - POSTGRES_USER=postgres - - POSTGRES_PASSWORD=1234 - ports: - - 5432:5432 - healthcheck: - test: ['CMD', 'pg_isready', '-U', 'postgres'] - interval: 10s - timeout: 5s - retries: 5 -volumes: - db-data: diff --git a/backend/finboostplus_teste/pom.xml b/backend/finboostplus_teste/pom.xml deleted file mode 100644 index 32e42dd7..00000000 --- a/backend/finboostplus_teste/pom.xml +++ /dev/null @@ -1,74 +0,0 @@ - - - 4.0.0 - - org.springframework.boot - spring-boot-starter-parent - 3.5.4 - - - com.finboostplus - com.fimboostplus - 0.0.1-SNAPSHOT - demo - Demo project for Spring Boot - - - - - - - - - - - - - - - 21 - - - - org.springframework.boot - spring-boot-starter-data-jpa - - - org.springframework.boot - spring-boot-starter-web - - - - org.springframework.boot - spring-boot-devtools - runtime - true - - - org.postgresql - postgresql - runtime - - - org.springframework.boot - spring-boot-starter-test - test - - - org.springframework.boot - spring-boot-docker-compose - test - - - - - - - org.springframework.boot - spring-boot-maven-plugin - - - - - diff --git a/backend/finboostplus_teste/src/main/java/com/finboostplus/DTO/UserRequestDTO.java b/backend/finboostplus_teste/src/main/java/com/finboostplus/DTO/UserRequestDTO.java deleted file mode 100644 index d82392d4..00000000 --- a/backend/finboostplus_teste/src/main/java/com/finboostplus/DTO/UserRequestDTO.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.finboostplus.DTO; - -import com.finboostplus.model.User; - -public record UserRequestDTO(String name, - String email, - String password) { - -public User toUser() { - -User user = new User(); - -user.setName(this.name); -user.setEmail(this.email); -user.setPassword(this.password); - -return user; - -} -} diff --git a/backend/finboostplus_teste/src/main/java/com/finboostplus/DemoApplication.java b/backend/finboostplus_teste/src/main/java/com/finboostplus/DemoApplication.java deleted file mode 100644 index 3258a45b..00000000 --- a/backend/finboostplus_teste/src/main/java/com/finboostplus/DemoApplication.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.finboostplus; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; - -@SpringBootApplication -public class DemoApplication { - - public static void main(String[] args) { - SpringApplication.run(DemoApplication.class, args); - } - -} diff --git a/backend/finboostplus_teste/src/main/java/com/finboostplus/controller/UserController.java b/backend/finboostplus_teste/src/main/java/com/finboostplus/controller/UserController.java deleted file mode 100644 index 122f45e0..00000000 --- a/backend/finboostplus_teste/src/main/java/com/finboostplus/controller/UserController.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.finboostplus.controller; - -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.HttpStatus; -import org.springframework.http.HttpStatusCode; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import com.finboostplus.DTO.UserRequestDTO; -import com.finboostplus.model.User; -import com.finboostplus.service.UserService; - -@RestController -@RequestMapping("/user") -public class UserController { - - @Autowired - UserService service; - - @PostMapping() - public ResponseEntity salvar(@RequestBody UserRequestDTO user) { - - User userEntity = new User(); - userEntity = user.toUser(); - - var userControle = service.salvar(userEntity); - - if (userControle != null) { - - return new ResponseEntity(HttpStatus.OK); - - } - return new ResponseEntity<>(HttpStatus.NOT_FOUND); - - } -} \ No newline at end of file diff --git a/backend/finboostplus_teste/src/main/java/com/finboostplus/model/User.java b/backend/finboostplus_teste/src/main/java/com/finboostplus/model/User.java deleted file mode 100644 index 5d1cbb44..00000000 --- a/backend/finboostplus_teste/src/main/java/com/finboostplus/model/User.java +++ /dev/null @@ -1,113 +0,0 @@ -package com.finboostplus.model; - -import java.time.LocalDateTime; -import java.util.Objects; - -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.SequenceGenerator; -import jakarta.persistence.Table; - -@Entity -@Table(name = "users") -@SequenceGenerator(name = "seq_user",sequenceName = "seq_user", allocationSize = 1, initialValue = 1) -public class User { - - @Id - @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "seq_user") - private Long id; - - private String name; - - private String email; - - private String password; - - private String profile_photo_url; - - private LocalDateTime created_at; - - - public User() {} - - public User(Long id, String name, String email, String password, String profile_photo_url, - LocalDateTime created_at) { - super(); - this.id = id; - this.name = name; - this.email = email; - this.password = password; - this.profile_photo_url = profile_photo_url; - this.created_at = created_at; - } - - public Long getId() { - return id; - } - - public void setId(Long id) { - this.id = id; - } - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - - public String getEmail() { - return email; - } - - public void setEmail(String email) { - this.email = email; - } - - public String getPassword() { - return password; - } - - public void setPassword(String password) { - this.password = password; - } - - public String getProfile_photo_url() { - return profile_photo_url; - } - - public void setProfile_photo_url(String profile_photo_url) { - this.profile_photo_url = profile_photo_url; - } - - public LocalDateTime getCreated_at() { - return created_at; - } - - public void setCreated_at(LocalDateTime created_at) { - this.created_at = created_at; - } - - @Override - public int hashCode() { - return Objects.hash(id); - } - - @Override - public boolean equals(Object obj) { - if (this == obj) - return true; - if (obj == null) - return false; - if (getClass() != obj.getClass()) - return false; - User other = (User) obj; - return Objects.equals(id, other.id); - } - - - -} \ No newline at end of file diff --git a/backend/finboostplus_teste/src/main/java/com/finboostplus/service/UserService.java b/backend/finboostplus_teste/src/main/java/com/finboostplus/service/UserService.java deleted file mode 100644 index 89a71184..00000000 --- a/backend/finboostplus_teste/src/main/java/com/finboostplus/service/UserService.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.finboostplus.service; - -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Service; - -import com.finboostplus.model.User; -import com.finboostplus.repository.UserRepository; - - - - -@Service -public class UserService { - - @Autowired - UserRepository repository; - - public User salvar(User user) { - - return repository.save(user); - } -} diff --git a/backend/finboostplus_teste/src/main/resources/application.properties b/backend/finboostplus_teste/src/main/resources/application.properties deleted file mode 100644 index e20f2552..00000000 --- a/backend/finboostplus_teste/src/main/resources/application.properties +++ /dev/null @@ -1,8 +0,0 @@ -spring.application.name=finboostplus_teste -spring.datasource.url=jdbc:postgresql://localhost:5432/finboostplus -spring.datasource.username=postgres -spring.datasource.password=1234 -spring.datasource.driver-class-name=org.postgresql.Driver -spring.jpa.hibernate.ddl-auto=update -spring.jpa.properties.hibernate.show_sql=true -spring.jpa.properties.hibernate.format_sql=true diff --git a/docs/development/index.md b/docs/development/index.md new file mode 100644 index 00000000..16aa0739 --- /dev/null +++ b/docs/development/index.md @@ -0,0 +1,176 @@ +# 🤖 Desenvolvimento com IA - FinBoost+ + +Esta seção documenta o uso responsável e transparente de ferramentas de Inteligência Artificial durante o desenvolvimento do projeto FinBoost+. + +--- + +## 🎯 **Objetivo da Documentação** + +Garantir **transparência total** sobre como e quando ferramentas de IA foram utilizadas como auxiliares no desenvolvimento, seguindo as melhores práticas da indústria e requisitos acadêmicos. + +--- + +## 📋 **Princípios do Uso de IA** + +### ✅ **O que Fazemos** +- **Transparência completa:** Documentamos todo uso de IA +- **Responsabilidade humana:** Decisões finais sempre por desenvolvedores +- **Revisão rigorosa:** Todo código gerado é revisado e testado +- **Aprendizado contínuo:** IA como ferramenta de capacitação da equipe + +### ❌ **O que NÃO Fazemos** +- Copiar código sem entender +- Submeter trabalho puramente gerado por IA +- Ocultar o uso de ferramentas auxiliares +- Delegar decisões críticas para IA + +--- + +## 🛠️ **Ferramentas Utilizadas** + +| Ferramenta | Fornecedor | Uso Principal | +|------------|------------|---------------| +| **ChatGPT** | OpenAI | Geração de código, definição de escopo, naming | +| **Claude** | Anthropic | Design, paletas de cores, arquitetura | +| **DeepSeek** | DeepSeek | Estado, persistência, mocks, contratos | +| **GitHub Copilot** | Microsoft | Autocomplete, testes, configurações | +| **Stitch** | Stitch AI | Wireframes e sugestões de design | + +--- + +## 📊 **Áreas de Aplicação** + +```mermaid +mindmap + root((IA no FinBoost+)) + Frontend + Design & UI + Paletas de cores + Wireframes + Layout responsivo + Desenvolvimento + Componentes React + Hooks customizados + Estado global + Testes unitários + Documentação + README + Comentários + Guias de uso + Backend + Arquitetura + APIs RESTful + Padrões Spring + Configurações + Desenvolvimento + Controllers + Services + Repositories + Validações + Testes + Unit tests + Integration tests + Mock data + Projeto + Planejamento + MVP definition + User stories + Requirements + Documentação + Technical docs + API contracts + Architecture +``` + +--- + +## 📈 **Benefícios Alcançados** + +### **Para o Projeto** +- ⚡ **Aceleração do desenvolvimento** - Redução de 30-40% no tempo de tarefas repetitivas +- 🔍 **Qualidade do código** - Sugestões de boas práticas e padrões +- 📚 **Documentação rica** - Geração estruturada de docs técnicas +- 🎨 **Design consistente** - Paletas e layouts profissionais + +### **Para a Equipe** +- 🚀 **Aprendizado acelerado** - Exposição a padrões avançados +- 💡 **Criatividade estimulada** - Brainstorming de soluções +- 🔧 **Produtividade aumentada** - Foco em lógica de negócio +- 📖 **Capacitação técnica** - Conhecimento de ferramentas modernas + +--- + +## 🔍 **Metodologia de Trabalho** + +### **1. Geração com IA** +- Prompt claro e contextualizado +- Especificação de requisitos técnicos +- Solicitação de múltiplas alternativas + +### **2. Revisão Humana** +- Análise crítica do código gerado +- Verificação de boas práticas +- Adaptação ao contexto do projeto + +### **3. Teste e Validação** +- Execução de testes unitários +- Validação em ambiente local +- Verificação de integração + +### **4. Documentação** +- Registro do prompt utilizado +- Justificativa da escolha +- Impacto no projeto + +--- + +## 📝 **Estrutura da Documentação** + +### **Por Área** +- **Frontend:** [Uso de IA](../frontend/ai_usage.md) | [Log de Prompts](../frontend/ai_prompts_log.md) +- **Backend:** [Uso de IA](../backend/ai_usage.md) | [Log de Prompts](../backend/ai_prompts_log.md) + +### **Conteúdo de Cada Documento** +- **ai_usage.md:** Resumo executivo e casos de uso principais +- **ai_prompts_log.md:** Registro detalhado de prompts e resultados + +--- + +## 🎓 **Contexto Acadêmico** + +### **Conformidade com Políticas** +- ✅ Transparência total sobre uso de IA +- ✅ Trabalho original com assistência declarada +- ✅ Aprendizado comprovado da equipe +- ✅ Contribuição individual identificável + +### **Valor Educacional** +- Experiência com ferramentas modernas da indústria +- Desenvolvimento de senso crítico sobre IA +- Prática de documentação profissional +- Preparação para mercado de trabalho + +--- + +## 🔄 **Processo de Atualização** + +Esta documentação é **atualizada continuamente** sempre que: +- Nova ferramenta de IA é utilizada +- Casos de uso significativos são implementados +- Metodologia de trabalho evolui +- Novos padrões são adotados + +--- + +## 🤝 **Responsabilidade e Ética** + +> **Declaração de Responsabilidade** +> +> Todos os membros da equipe FinBoost+ assumem responsabilidade integral pelo código e documentação produzidos. As ferramentas de IA foram utilizadas exclusivamente como auxiliares, com supervisão humana constante e validação rigorosa de todos os resultados. + +--- + +
+ 🤖 IA como ferramenta, humanos como responsáveis
+ Desenvolvimento transparente e responsável +
diff --git a/docs/frontend/ai_prompts_log.md b/docs/frontend/ai_prompts_log.md index 1f9ce4c3..d6e88741 100644 --- a/docs/frontend/ai_prompts_log.md +++ b/docs/frontend/ai_prompts_log.md @@ -379,6 +379,28 @@ Usar bibliotecas como Recharts ou Chart.js --- +## Novo Registro + +**Data**: 06/08/2025 +**Ferramenta Utilizada**: Copilot +**Versão (se aplicável)**: Integrado no VSCode +**Modo (se aplicável)**: Agente +**Modelo (se aplicável)**: Claude Sonnet 4 + +### Prompt +> "Quero ajuda para criar testes básicos para componentes react com vitest, poderia me ajudar +> verificando se o projeto já está com as configurações necessárias para rodar os testes?" + +### Resultado + +**Tipo de resultado**: +- Verificação de arquivos de configuração +- Instalação de dependências +- Geração de código para testes automatizados +- Documentação de testes + +--- + # Prompts Técnicos Específicos ## Novo Registro diff --git a/docs/frontend/ai_usage.md b/docs/frontend/ai_usage.md index baebf680..04690379 100644 --- a/docs/frontend/ai_usage.md +++ b/docs/frontend/ai_usage.md @@ -22,6 +22,10 @@ Este documento descreve o uso de ferramentas de Inteligência Artificial (IA) du **Fornecedor**: Stitch AI **Descrição do uso**: Sugestões de design de telas (wireframes) para páginas como Cadastro, Login, Home, Grupos, etc. +- **Nome da ferramenta**: Copilot + **Fornecedor**: Microsoft + **Descrição do uso**: Configurações e geração de testes unitários para componentes básicos e de integração. + ## Aplicações da IA no Projeto As principais áreas com auxílio de IA incluem: @@ -32,7 +36,7 @@ As principais áreas com auxílio de IA incluem: - [x] Estruturação de projeto frontend (React + Zustand/Jotai) - [x] Modelagem de dados mockados - [x] Escrita de documentação e naming -- [ ] Criação de testes automatizados (planejado para etapas futuras) +- [x] Criação de testes automatizados (planejado para etapas futuras) ## Casos de Uso Específicos @@ -78,6 +82,12 @@ As principais áreas com auxílio de IA incluem: - **Ferramenta**: DeepSeek - **Contribuição**: Implementação de contratos de API usando `zod` com validação segura, mesmo sem TypeScript. +### Criação de Testes Automatizados + +- **Data**: 06/08/2025 +- **Ferramenta**: Copilot +- **Contibuição**: Criação dos arquivos de teste: Button.test.jsx, Header.test.jsx, Logo.test.jsx e LoginForm.test.jsx. Além dos arquivos de documentação: TESTING_GUIDE.md, FOLDER_STRUTURE.md e /__tests__/README.md. + ## Revisão e Validação Todo o conteúdo gerado com o apoio das ferramentas de IA foi: diff --git a/docs/frontend/deployment.md b/docs/frontend/deployment.md new file mode 100644 index 00000000..9a4c2e17 --- /dev/null +++ b/docs/frontend/deployment.md @@ -0,0 +1,716 @@ +# 🚀 Deploy - Frontend FinBoost+ + +Esta documentação descreve o processo de deploy e configuração de ambiente para o frontend da aplicação FinBoost+. + +--- + +## 🏗️ **Estratégia de Deploy** + +### **Ambientes** +```mermaid +graph LR + Development[💻 Development] --> Staging[🧪 Staging] + Staging --> Production[🚀 Production] + + Development -.-> LocalTesting[Local Testing] + Staging -.-> QA[QA Testing] + Production -.-> Users[👥 Users] +``` + +1. **Development** - Ambiente local de desenvolvimento +2. **Staging** - Ambiente de testes e homologação +3. **Production** - Ambiente de produção + +--- + +## 📦 **Build e Otimização** + +### **Configuração de Build** + +#### **vite.config.js** +```javascript +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react-swc' +import { resolve } from 'path' + +export default defineConfig({ + plugins: [react()], + + // Configurações de build + build: { + outDir: 'dist', + assetsDir: 'assets', + sourcemap: false, // true apenas para staging + minify: 'terser', + terserOptions: { + compress: { + drop_console: true, // Remove console.logs em produção + drop_debugger: true, + }, + }, + rollupOptions: { + output: { + manualChunks: { + vendor: ['react', 'react-dom'], + router: ['react-router-dom'], + charts: ['recharts'], + }, + }, + }, + chunkSizeWarningLimit: 1000, + }, + + // Aliases para imports + resolve: { + alias: { + '@': resolve(__dirname, 'src'), + '@components': resolve(__dirname, 'src/components'), + '@pages': resolve(__dirname, 'src/pages'), + '@services': resolve(__dirname, 'src/services'), + '@hooks': resolve(__dirname, 'src/hooks'), + }, + }, + + // Configurações para diferentes ambientes + define: { + __APP_VERSION__: JSON.stringify(process.env.npm_package_version), + __BUILD_DATE__: JSON.stringify(new Date().toISOString()), + }, +}) +``` + +### **Scripts de Build** +```json +{ + "scripts": { + "dev": "vite", + "build": "vite build", + "build:staging": "vite build --mode staging", + "build:production": "vite build --mode production", + "preview": "vite preview", + "analyze": "vite build --mode analyze && npx vite-bundle-analyzer dist/stats.html", + "clean": "rm -rf dist", + "prebuild": "npm run clean && npm run lint && npm run test:run" + } +} +``` + +--- + +## 🌐 **Variáveis de Ambiente** + +### **Configuração por Ambiente** + +#### **.env.development** +```env +# API Configuration +VITE_API_BASE_URL=http://localhost:8080/api +VITE_APP_NAME=FinBoost+ (Dev) +VITE_APP_VERSION=development + +# Feature Flags +VITE_ENABLE_ANALYTICS=false +VITE_ENABLE_DEBUG=true +VITE_ENABLE_SERVICE_WORKER=false + +# External Services +VITE_SENTRY_DSN= +VITE_GOOGLE_ANALYTICS_ID= +``` + +#### **.env.staging** +```env +# API Configuration +VITE_API_BASE_URL=https://api-staging.finboostplus.com/api +VITE_APP_NAME=FinBoost+ (Staging) +VITE_APP_VERSION=staging + +# Feature Flags +VITE_ENABLE_ANALYTICS=true +VITE_ENABLE_DEBUG=true +VITE_ENABLE_SERVICE_WORKER=true + +# External Services +VITE_SENTRY_DSN=https://staging-sentry-dsn@sentry.io/project +VITE_GOOGLE_ANALYTICS_ID=GA-STAGING-ID +``` + +#### **.env.production** +```env +# API Configuration +VITE_API_BASE_URL=https://api.finboostplus.com/api +VITE_APP_NAME=FinBoost+ +VITE_APP_VERSION=1.0.0 + +# Feature Flags +VITE_ENABLE_ANALYTICS=true +VITE_ENABLE_DEBUG=false +VITE_ENABLE_SERVICE_WORKER=true + +# External Services +VITE_SENTRY_DSN=https://production-sentry-dsn@sentry.io/project +VITE_GOOGLE_ANALYTICS_ID=GA-PRODUCTION-ID +``` + +### **Validação de Ambiente** +```javascript +// src/config/environment.js +const requiredEnvVars = [ + 'VITE_API_BASE_URL', + 'VITE_APP_NAME', +] + +const validateEnvironment = () => { + const missing = requiredEnvVars.filter( + envVar => !import.meta.env[envVar] + ) + + if (missing.length > 0) { + throw new Error( + `Missing required environment variables: ${missing.join(', ')}` + ) + } +} + +export const config = { + API_BASE_URL: import.meta.env.VITE_API_BASE_URL, + APP_NAME: import.meta.env.VITE_APP_NAME, + APP_VERSION: import.meta.env.VITE_APP_VERSION, + ENABLE_ANALYTICS: import.meta.env.VITE_ENABLE_ANALYTICS === 'true', + ENABLE_DEBUG: import.meta.env.VITE_ENABLE_DEBUG === 'true', + SENTRY_DSN: import.meta.env.VITE_SENTRY_DSN, +} + +// Validar na inicialização +if (import.meta.env.PROD) { + validateEnvironment() +} + +export default config +``` + +--- + +## 🚀 **Plataformas de Deploy** + +### **1. Vercel (Recomendado para MVP)** + +#### **vercel.json** +```json +{ + "version": 2, + "builds": [ + { + "src": "package.json", + "use": "@vercel/static-build", + "config": { + "distDir": "dist" + } + } + ], + "routes": [ + { + "src": "/assets/(.*)", + "headers": { + "cache-control": "public, max-age=31536000, immutable" + } + }, + { + "src": "/(.*)", + "dest": "/index.html" + } + ], + "env": { + "VITE_API_BASE_URL": "@api_base_url", + "VITE_APP_NAME": "@app_name", + "VITE_SENTRY_DSN": "@sentry_dsn" + } +} +``` + +#### **Deploy Commands** +```bash +# Install Vercel CLI +npm i -g vercel + +# Deploy to staging +vercel --env-file .env.staging + +# Deploy to production +vercel --prod --env-file .env.production + +# Configurar secrets +vercel env add API_BASE_URL production +vercel env add SENTRY_DSN production +``` + +### **2. Netlify** + +#### **netlify.toml** +```toml +[build] + base = "frontend/" + publish = "dist/" + command = "npm run build:production" + +[build.environment] + NODE_VERSION = "18" + NPM_FLAGS = "--production=false" + +[[redirects]] + from = "/*" + to = "/index.html" + status = 200 + +[[headers]] + for = "/assets/*" + [headers.values] + Cache-Control = "public, max-age=31536000, immutable" + +[[headers]] + for = "/*.js" + [headers.values] + Cache-Control = "public, max-age=31536000, immutable" + +[[headers]] + for = "/*.css" + [headers.values] + Cache-Control = "public, max-age=31536000, immutable" + +# Preview deploys +[context.deploy-preview] + command = "npm run build:staging" + +# Branch deploys +[context.branch-deploy] + command = "npm run build:staging" +``` + +### **3. AWS S3 + CloudFront** + +#### **Deploy Script** +```bash +#!/bin/bash +# deploy-aws.sh + +set -e + +ENVIRONMENT=${1:-staging} +BUCKET_NAME="finboostplus-frontend-$ENVIRONMENT" +DISTRIBUTION_ID="your-cloudfront-distribution-id" + +echo "Building for $ENVIRONMENT..." +npm run build:$ENVIRONMENT + +echo "Uploading to S3..." +aws s3 sync dist/ s3://$BUCKET_NAME --delete \ + --cache-control "public, max-age=31536000" \ + --exclude "*.html" \ + --exclude "*.json" + +# HTML files with shorter cache +aws s3 sync dist/ s3://$BUCKET_NAME \ + --cache-control "public, max-age=3600" \ + --include "*.html" \ + --include "*.json" + +echo "Creating CloudFront invalidation..." +aws cloudfront create-invalidation \ + --distribution-id $DISTRIBUTION_ID \ + --paths "/*" + +echo "Deploy completed!" +``` + +--- + +## 🔄 **CI/CD Pipeline** + +### **GitHub Actions** + +#### **.github/workflows/deploy.yml** +```yaml +name: Deploy Frontend + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main ] + +env: + NODE_VERSION: '18' + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + working-directory: ./frontend + run: npm ci + + - name: Run linter + working-directory: ./frontend + run: npm run lint + + - name: Run tests + working-directory: ./frontend + run: npm run test:run + + - name: Run coverage + working-directory: ./frontend + run: npm run test:coverage + + - name: Upload coverage + uses: codecov/codecov-action@v3 + with: + file: ./frontend/coverage/lcov.info + + build: + needs: test + runs-on: ubuntu-latest + strategy: + matrix: + environment: [staging, production] + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + working-directory: ./frontend + run: npm ci + + - name: Build application + working-directory: ./frontend + run: npm run build:${{ matrix.environment }} + env: + VITE_API_BASE_URL: ${{ secrets.API_BASE_URL }} + VITE_SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + + - name: Upload build artifacts + uses: actions/upload-artifact@v3 + with: + name: build-${{ matrix.environment }} + path: frontend/dist + retention-days: 30 + + deploy-staging: + needs: build + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/develop' + environment: staging + steps: + - name: Download build artifacts + uses: actions/download-artifact@v3 + with: + name: build-staging + path: dist + + - name: Deploy to Vercel + uses: amondnet/vercel-action@v20 + with: + vercel-token: ${{ secrets.VERCEL_TOKEN }} + vercel-org-id: ${{ secrets.VERCEL_ORG_ID }} + vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }} + working-directory: ./ + scope: finboostplus + + deploy-production: + needs: build + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' + environment: production + steps: + - name: Download build artifacts + uses: actions/download-artifact@v3 + with: + name: build-production + path: dist + + - name: Deploy to Vercel + uses: amondnet/vercel-action@v20 + with: + vercel-token: ${{ secrets.VERCEL_TOKEN }} + vercel-org-id: ${{ secrets.VERCEL_ORG_ID }} + vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }} + vercel-args: '--prod' + working-directory: ./ + scope: finboostplus +``` + +--- + +## 📊 **Monitoramento e Observabilidade** + +### **Error Tracking com Sentry** +```javascript +// src/services/monitoring.js +import * as Sentry from '@sentry/react' +import { BrowserTracing } from '@sentry/tracing' +import config from '../config/environment' + +export const initMonitoring = () => { + if (config.SENTRY_DSN && import.meta.env.PROD) { + Sentry.init({ + dsn: config.SENTRY_DSN, + environment: import.meta.env.MODE, + integrations: [ + new BrowserTracing(), + ], + tracesSampleRate: 0.1, + beforeSend(event, hint) { + // Filtrar erros conhecidos + if (event.exception) { + const error = hint.originalException + if (error && error.message.includes('ResizeObserver')) { + return null + } + } + return event + }, + }) + } +} + +export const trackError = (error, context = {}) => { + if (config.ENABLE_DEBUG) { + console.error('Error tracked:', error, context) + } + + Sentry.captureException(error, { + extra: context, + }) +} + +export const trackEvent = (name, data = {}) => { + if (config.ENABLE_DEBUG) { + console.log('Event tracked:', name, data) + } + + // Google Analytics ou outra ferramenta + if (window.gtag) { + window.gtag('event', name, data) + } +} +``` + +### **Performance Monitoring** +```javascript +// src/utils/performance.js +export const measurePerformance = (name, fn) => { + return async (...args) => { + const start = performance.now() + try { + const result = await fn(...args) + const duration = performance.now() - start + + if (duration > 1000) { + console.warn(`Slow operation detected: ${name} took ${duration}ms`) + } + + // Track to analytics + if (window.gtag) { + window.gtag('event', 'performance', { + event_category: 'timing', + event_label: name, + value: Math.round(duration) + }) + } + + return result + } catch (error) { + const duration = performance.now() - start + console.error(`Operation failed: ${name} after ${duration}ms`, error) + throw error + } + } +} + +// Web Vitals +export const trackWebVitals = () => { + if ('PerformanceObserver' in window) { + // Core Web Vitals + import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { + getCLS(console.log) + getFID(console.log) + getFCP(console.log) + getLCP(console.log) + getTTFB(console.log) + }) + } +} +``` + +--- + +## 🔧 **Otimizações de Deploy** + +### **Service Worker** +```javascript +// public/sw.js +const CACHE_NAME = 'finboostplus-v1' +const STATIC_ASSETS = [ + '/', + '/index.html', + '/assets/index.js', + '/assets/index.css', +] + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME) + .then((cache) => cache.addAll(STATIC_ASSETS)) + ) +}) + +self.addEventListener('fetch', (event) => { + event.respondWith( + caches.match(event.request) + .then((response) => { + return response || fetch(event.request) + }) + ) +}) +``` + +### **Compressão e Caching** +```nginx +# nginx.conf +server { + listen 80; + server_name finboostplus.com; + root /usr/share/nginx/html; + index index.html; + + # Gzip compression + gzip on; + gzip_types + text/plain + text/css + text/js + text/xml + text/javascript + application/javascript + application/xml+rss + application/json; + + # Static assets caching + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # HTML caching + location / { + try_files $uri $uri/ /index.html; + expires 1h; + add_header Cache-Control "public"; + } + + # API proxy + location /api/ { + proxy_pass http://backend:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } +} +``` + +--- + +## 📈 **Métricas de Deploy** + +### **Health Checks** +```javascript +// src/utils/healthCheck.js +export const healthCheck = async () => { + try { + const response = await fetch('/api/health') + const data = await response.json() + + return { + status: 'healthy', + timestamp: new Date().toISOString(), + version: __APP_VERSION__, + buildDate: __BUILD_DATE__, + api: data.status === 'UP' ? 'connected' : 'disconnected', + } + } catch (error) { + return { + status: 'unhealthy', + timestamp: new Date().toISOString(), + error: error.message, + } + } +} +``` + +### **Rollback Strategy** +```bash +#!/bin/bash +# rollback.sh + +ENVIRONMENT=${1:-staging} +PREVIOUS_VERSION=${2:-latest-1} + +echo "Rolling back $ENVIRONMENT to $PREVIOUS_VERSION..." + +case $ENVIRONMENT in + "production") + vercel rollback --prod $PREVIOUS_VERSION + ;; + "staging") + vercel rollback $PREVIOUS_VERSION + ;; + *) + echo "Unknown environment: $ENVIRONMENT" + exit 1 + ;; +esac + +echo "Rollback completed!" +``` + +--- + +## 📚 **Documentação de Deploy** + +### **Checklist de Deploy** +- [ ] Testes passando +- [ ] Build sem erros +- [ ] Variáveis de ambiente configuradas +- [ ] Monitoramento ativo +- [ ] Backup realizado +- [ ] Comunicação à equipe +- [ ] Rollback plan definido + +### **Troubleshooting Comum** +1. **Build falha**: Verificar variáveis de ambiente +2. **Rotas 404**: Configurar SPA redirect +3. **API não conecta**: Verificar CORS e URLs +4. **Performance ruim**: Analisar bundle size +5. **Cache issues**: Invalidar CDN/browser cache + +--- + +
+ 🚀 Deploy automatizado e confiável
+ Da development à production com segurança +
diff --git a/docs/frontend/testing.md b/docs/frontend/testing.md new file mode 100644 index 00000000..f9d29408 --- /dev/null +++ b/docs/frontend/testing.md @@ -0,0 +1,620 @@ +# Testes Automatizados + +O FinBoost+ utiliza uma estratégia robusta de testes automatizados para garantir a qualidade e confiabilidade do código frontend. Nossa stack de testes combina **Vitest** como framework principal e **React Testing Library** para testes de componentes React. + +## Visão Geral + +### Tecnologias Utilizadas + +| Ferramenta | Versão | Propósito | +|-----------|---------|-----------| +| **Vitest** | `^1.0.0` | Framework de testes principal | +| **React Testing Library** | `^14.0.0` | Testes de componentes React | +| **@testing-library/jest-dom** | `^6.0.0` | Matchers customizados | +| **@testing-library/user-event** | `^14.0.0` | Simulação de interações | +| **jsdom** | `^23.0.0` | Ambiente de DOM virtual | + +### Status Atual + +!!! success "Cobertura de Testes" + **✅ 19 testes passando** em 4 arquivos de teste com cobertura superior a 80% + +``` + ✓ components/Header.test.jsx (1 teste) + ✓ components/Logo.test.jsx (4 testes) + ✓ components/Button.test.jsx (9 testes) + ✓ integration/LoginForm.test.jsx (5 testes) +``` + +## Estrutura do Projeto + +A organização dos testes segue uma estrutura clara e escalável: + +``` +__tests__/ +├── components/ # Testes de componentes individuais +│ ├── Header.test.jsx # Componente Header +│ ├── Logo.test.jsx # Componente LogoImage +│ └── Button.test.jsx # Componente ButtonUI +├── integration/ # Testes de integração +│ └── LoginForm.test.jsx # Formulário de login completo +├── setup.js # Configuração global +└── test-utils.js # Utilitários e helpers +``` + +### Arquivos de Configuração + +**`vite.config.js`** +: Configuração principal do Vitest, incluindo ambiente jsdom e configurações de cobertura + +**`__tests__/setup.js`** +: Configurações globais, mocks de APIs do browser e setup de matchers customizados + +**`__tests__/test-utils.js`** +: Funções auxiliares, wrappers customizados e utilities para testes + +## Comandos Disponíveis + +### Execução Básica + +```bash +# Executar todos os testes +npm test + +# Testes com relatório de cobertura +npm test -- --coverage + +# Modo watch para desenvolvimento +npm test -- --watch + +# Teste específico +npm test -- Header.test.jsx +``` + +### Opções Avançadas + +```bash +# Testes com interface gráfica +npm test -- --ui + +# Executar apenas testes alterados +npm test -- --changed + +# Gerar relatório detalhado +npm test -- --reporter=verbose +``` + +## Padrões de Teste + +### 1. Teste Básico de Renderização + +```jsx title="Exemplo: Teste simples de componente" +import { render, screen } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import MeuComponente from '../../src/components/MeuComponente'; + +describe('MeuComponente', () => { + it('deve renderizar corretamente', () => { + render(); + + expect(screen.getByText('Texto esperado')).toBeInTheDocument(); + }); +}); +``` + +### 2. Testes com Interações do Usuário + +```jsx title="Exemplo: Teste com user-event" +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, it, expect, vi } from 'vitest'; +import Botao from '../../src/components/Botao'; + +describe('Botão com Click', () => { + it('deve chamar função ao clicar', async () => { + const user = userEvent.setup(); + const mockClick = vi.fn(); + + render(Clique aqui); + + await user.click(screen.getByRole('button')); + + expect(mockClick).toHaveBeenCalledTimes(1); + }); +}); +``` + +### 3. Testes com Context API + +```jsx title="Exemplo: Componente com contexto" +import { renderWithProviders } from '../test-utils'; + +describe('Componente com Contexto', () => { + it('deve usar dados do contexto', () => { + const mockUser = { name: 'João', id: 1 }; + + renderWithProviders( + , + { + authContext: { user: mockUser }, + themeContext: { theme: 'dark' } + } + ); + + expect(screen.getByText('Olá, João')).toBeInTheDocument(); + }); +}); +``` + +### 4. Testes de Hooks Customizados + +```jsx title="Exemplo: Teste de hook" +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import useContador from '../../src/hooks/useContador'; + +describe('useContador', () => { + it('deve incrementar contador corretamente', () => { + const { result } = renderHook(() => useContador(0)); + + act(() => { + result.current.incrementar(); + }); + + expect(result.current.contador).toBe(1); + }); + + it('deve decrementar contador corretamente', () => { + const { result } = renderHook(() => useContador(5)); + + act(() => { + result.current.decrementar(); + }); + + expect(result.current.contador).toBe(4); + }); +}); +``` + +## Queries e Matchers + +### Estratégias de Busca (Queries) + +A prioridade das queries segue as recomendações do Testing Library: + +1. **Por Papel** (Mais acessível) +```jsx +screen.getByRole('button', { name: /enviar/i }) +screen.getByRole('textbox', { name: /email/i }) +screen.getByRole('heading', { level: 1 }) +``` + +2. **Por Label/Texto** +```jsx +screen.getByLabelText('Email') +screen.getByPlaceholderText('Digite seu email') +screen.getByText('Clique aqui') +``` + +3. **Por Test ID** (Último recurso) +```jsx +screen.getByTestId('custom-element') +// Use apenas quando outras opções não são viáveis +``` + +### Principais Matchers + +#### Verificação de Presença +| Matcher | Descrição | +|---------|-----------| +| `toBeInTheDocument()` | Elemento está presente no DOM | +| `toBeVisible()` | Elemento está visível ao usuário | +| `toBeNull()` | Valor é null | +| `toBeTruthy()` | Valor é verdadeiro | + +#### Verificação de Conteúdo +| Matcher | Exemplo | +|---------|---------| +| `toHaveTextContent()` | `expect(element).toHaveTextContent('Texto')` | +| `toHaveValue()` | `expect(input).toHaveValue('valor')` | +| `toHaveDisplayValue()` | `expect(select).toHaveDisplayValue('Opção 1')` | + +#### Verificação de Atributos +| Matcher | Exemplo | +|---------|---------| +| `toHaveAttribute()` | `expect(link).toHaveAttribute('href', '/home')` | +| `toHaveClass()` | `expect(button).toHaveClass('btn-primary')` | +| `toBeDisabled()` | `expect(input).toBeDisabled()` | +| `toBeChecked()` | `expect(checkbox).toBeChecked()` | + +#### Verificação de Funções Mock +| Matcher | Descrição | +|---------|-----------| +| `toHaveBeenCalled()` | Função foi chamada | +| `toHaveBeenCalledTimes(n)` | Quantidade específica de chamadas | +| `toHaveBeenCalledWith(args)` | Chamada com argumentos específicos | + +## Mocks e Simulações + +### Mock de Funções + +```jsx title="Diferentes tipos de mock" +import { vi } from 'vitest'; + +// Mock básico +const mockFn = vi.fn(); + +// Mock com retorno +const mockWithReturn = vi.fn().mockReturnValue('valor'); + +// Mock assíncrono +const mockAsync = vi.fn().mockResolvedValue({ data: 'response' }); + +// Mock com erro +const mockError = vi.fn().mockRejectedValue(new Error('Erro simulado')); + +// Mock condicional +const mockConditional = vi.fn() + .mockReturnValueOnce('primeira chamada') + .mockReturnValueOnce('segunda chamada') + .mockReturnValue('demais chamadas'); +``` + +### Mock de Módulos + +```jsx title="Mock de módulo externo" +// Mock completo do módulo +vi.mock('../../src/api', () => ({ + default: { + fetchUser: vi.fn(), + createUser: vi.fn(), + } +})); + +// Mock parcial +vi.mock('../../src/utils', async () => { + const actual = await vi.importActual('../../src/utils'); + return { + ...actual, + formatDate: vi.fn().mockReturnValue('01/01/2024'), + }; +}); +``` + +### Mock de APIs do Browser + +```jsx title="Mock de localStorage e outras APIs" +const localStorageMock = { + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), +}; + +Object.defineProperty(window, 'localStorage', { + value: localStorageMock, +}); + +// Mock do ResizeObserver (já configurado no setup.js) +global.ResizeObserver = vi.fn().mockImplementation(() => ({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), +})); +``` + +## Testes de Integração + +### Formulários Completos + +```jsx title="Exemplo: Teste de formulário de login" +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { vi } from 'vitest'; +import LoginForm from '../../src/components/LoginForm'; + +describe('LoginForm - Integração', () => { + it('deve submeter formulário com dados válidos', async () => { + const user = userEvent.setup(); + const mockSubmit = vi.fn().mockResolvedValue({ + success: true, + token: 'mock-token' + }); + + render(); + + // Preencher formulário + await user.type( + screen.getByLabelText(/email/i), + 'usuario@exemplo.com' + ); + await user.type( + screen.getByLabelText(/senha/i), + 'senha123' + ); + + // Submeter + await user.click( + screen.getByRole('button', { name: /entrar/i }) + ); + + // Verificar chamada + expect(mockSubmit).toHaveBeenCalledWith({ + email: 'usuario@exemplo.com', + password: 'senha123' + }); + }); + + it('deve mostrar erro para dados inválidos', async () => { + const user = userEvent.setup(); + const mockSubmit = vi.fn().mockRejectedValue( + new Error('Credenciais inválidas') + ); + + render(); + + await user.type(screen.getByLabelText(/email/i), 'email-inválido'); + await user.click(screen.getByRole('button', { name: /entrar/i })); + + expect(await screen.findByText(/credenciais inválidas/i)) + .toBeInTheDocument(); + }); +}); +``` + +### Estados de Loading + +```jsx title="Exemplo: Teste de componente assíncrono" +import { render, screen, waitFor } from '@testing-library/react'; +import { vi } from 'vitest'; +import ComponenteAssincrono from '../../src/components/ComponenteAssincrono'; + +describe('ComponenteAssincrono', () => { + it('deve mostrar loading e depois o conteúdo', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + data: { message: 'Dados carregados!' } + }); + + render(); + + // Verificar estado de loading + expect(screen.getByText(/carregando/i)).toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveAttribute('aria-live', 'polite'); + + // Aguardar carregamento + await waitFor(() => { + expect(screen.queryByText(/carregando/i)).not.toBeInTheDocument(); + }); + + // Verificar conteúdo carregado + expect(screen.getByText('Dados carregados!')).toBeInTheDocument(); + }); +}); +``` + +## Boas Práticas + +### Padrão AAA (Arrange, Act, Assert) + +```jsx +it('deve calcular o total corretamente', async () => { + // ✅ Arrange - Preparar o cenário + const user = userEvent.setup(); + const produtos = [ + { id: 1, nome: 'Produto A', preco: 10.00 }, + { id: 2, nome: 'Produto B', preco: 15.50 } + ]; + + // ✅ Act - Executar a ação + render(); + await user.click(screen.getByText('Calcular Total')); + + // ✅ Assert - Verificar o resultado + expect(screen.getByText('Total: R$ 25,50')).toBeInTheDocument(); +}); +``` + +### Nomes Descritivos + +```jsx +describe('ButtonUI', () => { + // ❌ Ruim - muito genérico + it('testa botão', () => {}); + + // ❌ Ruim - testa implementação + it('deve ter className btn-primary', () => {}); + + // ✅ Bom - descreve comportamento + it('deve chamar onSubmit quando formulário é submetido', () => {}); + + // ✅ Bom - específico e claro + it('deve mostrar spinner quando loading é true', () => {}); +}); +``` + +### Teste de Comportamento vs Implementação + +```jsx +// ❌ Evitar - testa detalhes de implementação +expect(component.state.isLoading).toBe(true); +expect(wrapper.find('.loading-spinner')).toHaveLength(1); + +// ✅ Preferir - testa comportamento do usuário +expect(screen.getByRole('status', { name: /carregando/i })) + .toBeInTheDocument(); +expect(screen.getByText('Aguarde...')).toBeInTheDocument(); +``` + +### Limpeza Entre Testes + +```jsx title="Configuração de limpeza automática" +import { beforeEach, afterEach, vi } from 'vitest'; +import { cleanup } from '@testing-library/react'; + +// Limpeza automática do DOM +afterEach(() => { + cleanup(); +}); + +// Limpeza de mocks +beforeEach(() => { + vi.clearAllMocks(); +}); + +// Limpeza de timers +afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); +}); +``` + +## Solução de Problemas + +### Problemas Comuns + +!!! warning "ResizeObserver Error" + **Erro:** `ResizeObserver loop limit exceeded` + + **Solução:** Já configurado no `setup.js` com mock apropriado. + +!!! warning "Elemento não encontrado" + **Erro:** `Unable to find element with text: "Texto"` + + **Soluções:** + ```jsx + // Use queries mais flexíveis + screen.getByText(/texto/i) // case insensitive + screen.getByText((content) => content.includes('Texto')) + + // Aguarde elementos assíncronos + await screen.findByText('Texto') + + // Debug do DOM atual + screen.debug() + ``` + +!!! warning "Warning: ReactDOM.render is deprecated" + **Solução:** Atualize para `@testing-library/react` v13+ que usa `createRoot` automaticamente. + +### Performance + +**Testes Lentos** +```jsx +// ✅ Use queries síncronas quando possível +screen.getByText('texto') // ao invés de findByText + +// ✅ Evite waitFor desnecessários +// ❌ Desnecessário se o elemento já está no DOM +await waitFor(() => expect(screen.getByText('texto')).toBeInTheDocument()) + +// ✅ Melhor +expect(screen.getByText('texto')).toBeInTheDocument() + +// ✅ Mock APIs externas +vi.mock('axios') +``` + +## Cobertura de Código + +### Configuração + +A cobertura é configurada no `vite.config.js`: + +```javascript +export default defineConfig({ + test: { + coverage: { + provider: 'v8', + reporter: ['text', 'html', 'clover', 'json'], + exclude: [ + 'node_modules/', + 'src/setupTests.js', + '**/*.test.{js,jsx}', + '**/*.spec.{js,jsx}', + ], + thresholds: { + global: { + branches: 80, + functions: 80, + lines: 80, + statements: 80 + } + } + } + } +}) +``` + +### Relatórios + +```bash +# Gerar relatório de cobertura +npm test -- --coverage + +# Abrir relatório HTML +open coverage/index.html +``` + +### Metas de Cobertura + +| Métrica | Meta Atual | Meta Ideal | +|---------|------------|------------| +| **Linhas** | 80% | 90% | +| **Funções** | 80% | 85% | +| **Branches** | 80% | 85% | +| **Statements** | 80% | 90% | + +## Continuous Integration + +### GitHub Actions + +```yaml title=".github/workflows/test.yml" +name: Tests + +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '18' + cache: 'npm' + + - run: npm ci + - run: npm test -- --coverage + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 +``` + +## Recursos Adicionais + +### Documentação Oficial + +- [Vitest Documentation](https://vitest.dev/) +- [React Testing Library](https://testing-library.com/docs/react-testing-library/intro) +- [Jest DOM Matchers](https://github.com/testing-library/jest-dom) + +### Ferramentas Recomendadas + +- **VS Code Extensions:** + - Vitest Runner + - Testing Library Snippets + - Jest Snippets + +### Exemplos Avançados + +Para exemplos mais complexos e casos específicos, consulte os arquivos de teste existentes: + +- `__tests__/components/Button.test.jsx` - Testes completos de componente +- `__tests__/integration/LoginForm.test.jsx` - Teste de integração +- `__tests__/test-utils.js` - Utilities e helpers personalizados + +--- + +!!! tip "Dica Final" + Lembre-se: **teste comportamentos, não implementação**. Seus testes devem ser resilientes a refatorações e mudanças internas, focando na experiência do usuário final. \ No newline at end of file diff --git a/docs/project/api-contract.md b/docs/project/api-contract.md new file mode 100644 index 00000000..ce42fe98 --- /dev/null +++ b/docs/project/api-contract.md @@ -0,0 +1,611 @@ +# 📋 API Contract - FinBoost+ + +
+ API Version + Status + Format +
+ +Este documento define o contrato da API REST do **FinBoost+**, estabelecendo endpoints, estruturas de dados e padrões de comunicação entre frontend e backend. + +--- + +## 🔧 **Informações Gerais** + +### **Base URLs** +- **Desenvolvimento**: `http://localhost:8080/api/v1` +- **Produção**: `https://api.finboostplus.com/api/v1` + +### **Especificações Técnicas** +- **Formato**: JSON +- **Autenticação**: Bearer Token (JWT) +- **Versão**: v1.0.0 +- **Charset**: UTF-8 + +### **Headers Padrão** +```http +Content-Type: application/json +Authorization: Bearer {jwt_token} +Accept: application/json +``` + +--- + +## 📊 **Códigos de Status HTTP** + +| Código | Descrição | Uso | +|--------|-----------|-----| +| `200` | OK | Operação bem-sucedida | +| `201` | Created | Recurso criado com sucesso | +| `400` | Bad Request | Erro de validação | +| `401` | Unauthorized | Token inválido/ausente | +| `403` | Forbidden | Sem permissão | +| `404` | Not Found | Recurso não encontrado | +| `422` | Unprocessable Entity | Dados inválidos | +| `500` | Internal Server Error | Erro interno | + +--- + +## 🔐 **Autenticação** + +### **POST /auth/login** +Realizar login do usuário no sistema. + +#### **Request** +```json +{ + "email": "usuario@email.com", + "password": "senha123" +} +``` + +#### **Response (200)** +```json +{ + "success": true, + "message": "Login realizado com sucesso", + "data": { + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "refreshToken": "refresh_token_here", + "user": { + "id": 1, + "name": "João Silva", + "email": "usuario@email.com", + "avatar": null, + "createdAt": "2024-01-01T00:00:00Z" + } + } +} +``` + +### **POST /auth/register** +Cadastrar novo usuário no sistema. + +#### **Request** +```json +{ + "name": "João Silva", + "email": "usuario@email.com", + "password": "senha123", + "confirmPassword": "senha123" +} +``` + +#### **Response (201)** +```json +{ + "success": true, + "message": "Usuário criado com sucesso", + "data": { + "user": { + "id": 1, + "name": "João Silva", + "email": "usuario@email.com", + "avatar": null, + "createdAt": "2024-01-01T00:00:00Z" + } + } +} +``` + +### **POST /auth/refresh** +Renovar token JWT expirado. + +#### **Request** +```json +{ + "refreshToken": "refresh_token_here" +} +``` + +#### **Response (200)** +```json +{ + "success": true, + "data": { + "token": "new_jwt_token_here", + "expiresIn": 86400 + } +} +``` + +--- + +## 👥 **Grupos** + +### **GET /groups** +Listar grupos do usuário autenticado. + +#### **Query Parameters** +- `page` (opcional): Número da página (padrão: 0) +- `size` (opcional): Itens por página (padrão: 20) + +#### **Response (200)** +```json +{ + "success": true, + "data": { + "groups": [ + { + "id": 1, + "name": "Apartamento 404", + "description": "Contas da república", + "inviteCode": "APT404XYZ", + "memberCount": 4, + "totalExpenses": 1250.00, + "myBalance": -125.50, + "isAdmin": true, + "createdAt": "2024-01-01T00:00:00Z" + } + ], + "pagination": { + "page": 0, + "size": 20, + "totalElements": 3, + "totalPages": 1 + } + } +} +``` + +### **POST /groups** +Criar novo grupo. + +#### **Request** +```json +{ + "name": "Viagem Rio", + "description": "Gastos da viagem de carnaval" +} +``` + +#### **Response (201)** +```json +{ + "success": true, + "message": "Grupo criado com sucesso", + "data": { + "group": { + "id": 2, + "name": "Viagem Rio", + "description": "Gastos da viagem de carnaval", + "inviteCode": "RIO2024ABC", + "memberCount": 1, + "totalExpenses": 0.00, + "myBalance": 0.00, + "isAdmin": true, + "createdAt": "2024-02-01T14:20:00Z" + } + } +} +``` + +### **GET /groups/{groupId}** +Buscar detalhes de um grupo específico. + +#### **Response (200)** +```json +{ + "success": true, + "data": { + "group": { + "id": 1, + "name": "Apartamento 404", + "description": "Contas da república", + "inviteCode": "APT404XYZ", + "totalExpenses": 1250.00, + "members": [ + { + "id": 1, + "name": "João Silva", + "email": "joao@email.com", + "avatar": null, + "balance": -125.50, + "isAdmin": true, + "joinedAt": "2024-01-01T00:00:00Z" + } + ], + "createdAt": "2024-01-01T00:00:00Z" + } + } +} +``` + +### **PUT /groups/{groupId}** +Atualizar informações do grupo. + +#### **Request** +```json +{ + "name": "Apartamento 404 - Atualizado", + "description": "Nova descrição" +} +``` + +### **DELETE /groups/{groupId}** +Remover grupo (apenas admin). + +#### **Response (200)** +```json +{ + "success": true, + "message": "Grupo removido com sucesso" +} +``` + +--- + +## 💰 **Despesas** + +### **GET /groups/{groupId}/expenses** +Listar despesas de um grupo. + +#### **Query Parameters** +- `page` (opcional): Página +- `size` (opcional): Itens por página +- `category` (opcional): Filtrar por categoria +- `dateFrom` (opcional): Data inicial (YYYY-MM-DD) +- `dateTo` (opcional): Data final (YYYY-MM-DD) + +#### **Response (200)** +```json +{ + "success": true, + "data": { + "expenses": [ + { + "id": 1, + "description": "Mercado - Compras da semana", + "amount": 158.75, + "category": "Food", + "date": "2024-01-15", + "createdBy": { + "id": 1, + "name": "João Silva" + }, + "splits": [ + { + "userId": 1, + "userName": "João Silva", + "amount": 79.38, + "percentage": 50.0 + } + ], + "createdAt": "2024-01-15T19:30:00Z" + } + ], + "summary": { + "totalAmount": 1250.00, + "expenseCount": 15, + "avgAmount": 83.33 + }, + "pagination": { + "page": 0, + "size": 20, + "totalElements": 15 + } + } +} +``` + +### **POST /groups/{groupId}/expenses** +Criar nova despesa no grupo. + +#### **Request** +```json +{ + "description": "Jantar no restaurante", + "amount": 125.00, + "category": "Food", + "date": "2024-01-16", + "notes": "Pizza e refrigerantes", + "splits": [ + { + "userId": 1, + "amount": 62.50 + }, + { + "userId": 2, + "amount": 62.50 + } + ] +} +``` + +#### **Response (201)** +```json +{ + "success": true, + "message": "Despesa criada com sucesso", + "data": { + "expense": { + "id": 2, + "description": "Jantar no restaurante", + "amount": 125.00, + "category": "Food", + "date": "2024-01-16", + "notes": "Pizza e refrigerantes", + "createdBy": { + "id": 1, + "name": "João Silva" + }, + "splits": [ + { + "userId": 1, + "userName": "João Silva", + "amount": 62.50, + "percentage": 50.0 + } + ], + "createdAt": "2024-01-16T20:15:00Z" + } + } +} +``` + +### **PUT /expenses/{expenseId}** +Atualizar despesa existente. + +### **DELETE /expenses/{expenseId}** +Remover despesa (soft delete). + +--- + +## 📊 **Saldos e Relatórios** + +### **GET /groups/{groupId}/balances** +Calcular saldos entre membros do grupo. + +#### **Response (200)** +```json +{ + "success": true, + "data": { + "balances": [ + { + "userId": 1, + "userName": "João Silva", + "totalPaid": 500.00, + "totalOwed": 375.50, + "netBalance": 124.50, + "status": "creditor" + } + ], + "settlements": [ + { + "from": 2, + "fromName": "Maria Santos", + "to": 1, + "toName": "João Silva", + "amount": 124.50 + } + ], + "groupTotal": { + "totalExpenses": 750.00, + "averagePerPerson": 375.00, + "totalSettled": 0.00, + "totalPending": 124.50 + } + } +} +``` + +### **GET /groups/{groupId}/summary** +Resumo financeiro do grupo. + +#### **Query Parameters** +- `period` (opcional): "week", "month", "year" (padrão: "month") + +#### **Response (200)** +```json +{ + "success": true, + "data": { + "period": { + "startDate": "2024-01-01", + "endDate": "2024-01-31", + "type": "month" + }, + "summary": { + "totalExpenses": 1250.00, + "expenseCount": 15, + "averagePerExpense": 83.33, + "averagePerDay": 40.32 + }, + "byCategory": [ + { + "category": "Food", + "total": 650.00, + "count": 8, + "percentage": 52.0 + } + ], + "byUser": [ + { + "userId": 1, + "userName": "João Silva", + "totalPaid": 750.00, + "expenseCount": 9, + "percentage": 60.0 + } + ] + } +} +``` + +--- + +## 👤 **Usuários** + +### **GET /users/profile** +Buscar perfil do usuário atual. + +#### **Response (200)** +```json +{ + "success": true, + "data": { + "user": { + "id": 1, + "name": "João Silva", + "email": "joao@email.com", + "avatar": null, + "phone": "+55 11 99999-9999", + "createdAt": "2024-01-01T00:00:00Z", + "preferences": { + "theme": "dark", + "language": "pt-BR", + "notifications": { + "email": true, + "push": false + } + }, + "statistics": { + "groupsCount": 3, + "totalExpenses": 2500.00, + "currentBalance": -45.50 + } + } + } +} +``` + +### **PUT /users/profile** +Atualizar perfil do usuário. + +#### **Request** +```json +{ + "name": "João Silva Santos", + "phone": "+55 11 98888-8888", + "preferences": { + "theme": "dark", + "language": "pt-BR", + "notifications": { + "email": true, + "push": true + } + } +} +``` + +--- + +## 🔧 **Utilitários** + +### **GET /categories** +Listar categorias disponíveis. + +#### **Response (200)** +```json +{ + "success": true, + "data": { + "categories": [ + { + "id": "food", + "name": "Alimentação", + "icon": "🍕", + "color": "#FF6B6B" + }, + { + "id": "transport", + "name": "Transporte", + "icon": "🚗", + "color": "#4ECDC4" + } + ] + } +} +``` + +### **GET /health** +Status da API. + +#### **Response (200)** +```json +{ + "status": "UP", + "timestamp": "2024-01-16T10:30:00Z", + "version": "1.0.0", + "environment": "production" +} +``` + +--- + +## ⚠️ **Tratamento de Erros** + +### **Formato Padrão de Erro** +```json +{ + "success": false, + "error": { + "code": "VALIDATION_ERROR", + "message": "Dados inválidos", + "details": [ + { + "field": "email", + "message": "Email é obrigatório" + } + ] + }, + "timestamp": "2024-01-16T10:30:00Z", + "path": "/api/v1/expenses" +} +``` + +### **Códigos de Erro Comuns** +- `VALIDATION_ERROR` - Erro de validação de dados +- `AUTHENTICATION_ERROR` - Erro de autenticação +- `AUTHORIZATION_ERROR` - Erro de autorização +- `RESOURCE_NOT_FOUND` - Recurso não encontrado +- `DUPLICATE_RESOURCE` - Recurso duplicado +- `INTERNAL_ERROR` - Erro interno do servidor + +--- + +## 🔒 **Segurança** + +### **Autenticação JWT** +- **Algoritmo**: HS256 +- **Expiração**: 24 horas +- **Refresh Token**: 7 dias + +### **Rate Limiting** +- **Geral**: 1000 req/hora por IP +- **Login**: 5 tentativas/minuto +- **APIs**: 100 req/minuto por usuário + +### **Validações** +- Sanitização de inputs +- Validação de tipos +- Prevenção de SQL Injection +- Validação de permissões + +--- + +
+ 📋 API Contract bem definido
+ Comunicação padronizada e confiável +
diff --git a/docs/project/architecture.md b/docs/project/architecture.md new file mode 100644 index 00000000..db9a55a4 --- /dev/null +++ b/docs/project/architecture.md @@ -0,0 +1,596 @@ +# 🏗️ Arquitetura do Sistema - FinBoost+ + +
+ Microservices + React + Spring Boot + PostgreSQL +
+ +Este documento apresenta a arquitetura técnica do **FinBoost+**, detalhando decisões de design, padrões utilizados e estrutura do sistema. + +--- + +## 🎯 **Visão Geral da Arquitetura** + +### **Arquitetura de Alto Nível** +```mermaid +graph TB + subgraph "Frontend" + UI[React 19 + Vite 7] + State[Context API] + Router[React Router v6] + end + + subgraph "Backend" + API[Spring Boot 3.5] + Security[Spring Security 6] + JPA[Spring Data JPA] + end + + subgraph "Database" + DB[(PostgreSQL 15)] + end + + subgraph "External" + CDN[Vercel/Netlify] + Deploy[Railway/Render] + end + + UI --> API + API --> DB + UI --> CDN + API --> Deploy +``` + +### **Princípios Arquiteturais** +- **Separation of Concerns**: Camadas bem definidas +- **Clean Architecture**: Independência de frameworks +- **SOLID Principles**: Código maintível e extensível +- **RESTful API**: Comunicação padronizada +- **Responsive Design**: Mobile-first approach + +--- + +## 🎨 **Frontend - React Architecture** + +### **Stack Tecnológico** +```yaml +Core: + - React: 19.x (Latest) + - TypeScript: 5.x + - Vite: 7.x (Build tool) + +Styling: + - TailwindCSS: 4.x + - Headless UI: 2.x + - CSS Modules: Scoped styles + +State Management: + - Context API: Global state + - React Hooks: Local state + - Custom Hooks: Business logic + +Routing: + - React Router: v6.x + - Protected Routes: Authentication + - Lazy Loading: Code splitting + +Testing: + - Vitest: Test framework + - React Testing Library: Component testing + - MSW: API mocking +``` + +### **Estrutura de Pastas** +``` +src/ +├── components/ # Componentes reutilizáveis +│ ├── ui/ # Atomic components +│ ├── forms/ # Formulários +│ ├── Layout/ # Layout components +│ └── [Feature]/ # Feature components +├── pages/ # Páginas/Rotas +├── hooks/ # Custom hooks +├── context/ # Context providers +├── services/ # API services +├── utils/ # Utility functions +├── types/ # TypeScript types +└── routes/ # Route configuration +``` + +### **Padrões de Componentes** +```jsx +// Atomic Design Pattern +// Atoms -> Molecules -> Organisms -> Templates -> Pages + +// Component Structure +const Component = ({ prop1, prop2, ...props }) => { + // Hooks + const [state, setState] = useState() + const customHook = useCustomHook() + + // Effects + useEffect(() => { + // Side effects + }, [dependencies]) + + // Handlers + const handleAction = () => { + // Action logic + } + + // Render + return ( +
+ {/* JSX */} +
+ ) +} +``` + +### **Gerenciamento de Estado** +```jsx +// Context API Pattern +const AppContext = createContext() + +const AppProvider = ({ children }) => { + const [state, setState] = useState(initialState) + + const actions = { + updateState: (newState) => setState(newState), + // More actions... + } + + return ( + + {children} + + ) +} + +// Custom Hook +const useApp = () => { + const context = useContext(AppContext) + if (!context) { + throw new Error('useApp must be used within AppProvider') + } + return context +} +``` + +--- + +## ⚙️ **Backend - Spring Boot Architecture** + +### **Stack Tecnológico** +```yaml +Core: + - Java: 21 LTS + - Spring Boot: 3.5.x + - Spring Framework: 6.x + +Data: + - Spring Data JPA: 3.x + - Hibernate: 6.x + - PostgreSQL: 15.x + - Flyway: Migration tool + +Security: + - Spring Security: 6.x + - JWT: Authentication + - BCrypt: Password hashing + +Web: + - Spring Web MVC: REST API + - Jackson: JSON serialization + - Validation: Bean validation + +Testing: + - JUnit: 5.x + - Mockito: Mocking + - TestContainers: Integration tests +``` + +### **Arquitetura em Camadas** +``` +src/main/java/ +├── controller/ # REST Controllers +├── service/ # Business Logic +├── repository/ # Data Access Layer +├── model/ # JPA Entities +├── dto/ # Data Transfer Objects +├── config/ # Configuration classes +├── security/ # Security configuration +├── exception/ # Exception handling +└── util/ # Utility classes +``` + +### **Padrão de Controlador** +```java +@RestController +@RequestMapping("/api/v1/groups") +@RequiredArgsConstructor +@Validated +public class GroupController { + + private final GroupService groupService; + + @GetMapping + public ResponseEntity> getGroups( + @PageableDefault Pageable pageable, + Authentication authentication) { + + String userEmail = authentication.getName(); + PagedResponse groups = groupService.getUserGroups(userEmail, pageable); + + return ResponseEntity.ok(groups); + } + + @PostMapping + public ResponseEntity> createGroup( + @Valid @RequestBody CreateGroupRequest request, + Authentication authentication) { + + String userEmail = authentication.getName(); + GroupDTO group = groupService.createGroup(request, userEmail); + + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.success("Grupo criado com sucesso", group)); + } +} +``` + +### **Padrão de Serviço** +```java +@Service +@RequiredArgsConstructor +@Transactional +public class GroupService { + + private final GroupRepository groupRepository; + private final UserRepository userRepository; + private final GroupMapper groupMapper; + + public PagedResponse getUserGroups(String userEmail, Pageable pageable) { + User user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new UserNotFoundException("Usuário não encontrado")); + + Page groups = groupRepository.findByMembersContaining(user, pageable); + + List groupDTOs = groups.getContent().stream() + .map(groupMapper::toDTO) + .collect(Collectors.toList()); + + return PagedResponse.of(groupDTOs, groups); + } + + @Transactional + public GroupDTO createGroup(CreateGroupRequest request, String userEmail) { + User creator = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new UserNotFoundException("Usuário não encontrado")); + + Group group = Group.builder() + .name(request.getName()) + .description(request.getDescription()) + .inviteCode(generateInviteCode()) + .createdBy(creator) + .build(); + + group.addMember(creator); + + Group savedGroup = groupRepository.save(group); + + return groupMapper.toDTO(savedGroup); + } +} +``` + +### **Configuração de Segurança** +```java +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http.cors(Customizer.withDefaults()) + .csrf(csrf -> csrf.disable()) + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/v1/auth/**").permitAll() + .requestMatchers("/api/v1/health").permitAll() + .anyRequest().authenticated()) + .exceptionHandling(ex -> + ex.authenticationEntryPoint(jwtAuthenticationEntryPoint)) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } +} +``` + +--- + +## 🗄️ **Banco de Dados - PostgreSQL** + +### **Modelo de Dados** +```sql +-- Principais entidades e relacionamentos + +CREATE TABLE users ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + email VARCHAR(100) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + avatar_url VARCHAR(500), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE groups ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + description TEXT, + invite_code VARCHAR(10) UNIQUE NOT NULL, + created_by BIGINT REFERENCES users(id), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE group_members ( + id BIGSERIAL PRIMARY KEY, + group_id BIGINT REFERENCES groups(id) ON DELETE CASCADE, + user_id BIGINT REFERENCES users(id) ON DELETE CASCADE, + is_admin BOOLEAN DEFAULT FALSE, + joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(group_id, user_id) +); + +CREATE TABLE expenses ( + id BIGSERIAL PRIMARY KEY, + group_id BIGINT REFERENCES groups(id) ON DELETE CASCADE, + description VARCHAR(200) NOT NULL, + amount DECIMAL(10,2) NOT NULL, + category VARCHAR(50) NOT NULL, + date DATE NOT NULL, + notes TEXT, + created_by BIGINT REFERENCES users(id), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE expense_splits ( + id BIGSERIAL PRIMARY KEY, + expense_id BIGINT REFERENCES expenses(id) ON DELETE CASCADE, + user_id BIGINT REFERENCES users(id), + amount DECIMAL(10,2) NOT NULL, + percentage DECIMAL(5,2) NOT NULL, + UNIQUE(expense_id, user_id) +); +``` + +### **Índices e Performance** +```sql +-- Índices para performance +CREATE INDEX idx_groups_created_by ON groups(created_by); +CREATE INDEX idx_group_members_group_id ON group_members(group_id); +CREATE INDEX idx_group_members_user_id ON group_members(user_id); +CREATE INDEX idx_expenses_group_id ON expenses(group_id); +CREATE INDEX idx_expenses_created_by ON expenses(created_by); +CREATE INDEX idx_expenses_date ON expenses(date); +CREATE INDEX idx_expense_splits_expense_id ON expense_splits(expense_id); +CREATE INDEX idx_expense_splits_user_id ON expense_splits(user_id); +``` + +--- + +## 🔄 **Integração e APIs** + +### **Padrão de Response** +```json +{ + "success": true, + "message": "Operação realizada com sucesso", + "data": { + // Dados da resposta + }, + "pagination": { + "page": 0, + "size": 20, + "totalElements": 100, + "totalPages": 5 + }, + "timestamp": "2024-01-16T10:30:00Z" +} +``` + +### **Tratamento de Erros** +```json +{ + "success": false, + "error": { + "code": "VALIDATION_ERROR", + "message": "Dados inválidos", + "details": [ + { + "field": "email", + "message": "Email é obrigatório" + } + ] + }, + "timestamp": "2024-01-16T10:30:00Z", + "path": "/api/v1/groups" +} +``` + +### **Versionamento de API** +- **URL Versioning**: `/api/v1/`, `/api/v2/` +- **Backward Compatibility**: Manter versões anteriores +- **Deprecation Policy**: 6 meses de notice + +--- + +## 🚀 **Deploy e Infraestrutura** + +### **Frontend (Vercel/Netlify)** +```yaml +Build: + - Framework: Vite + - Output: dist/ + - Commands: npm run build + - Node: 18.x + +Environment: + - VITE_API_URL: Backend URL + - VITE_APP_VERSION: Application version + +Features: + - CDN: Global distribution + - SSL: Automatic HTTPS + - Cache: Static assets + - Preview: PR previews +``` + +### **Backend (Railway/Render)** +```yaml +Runtime: + - Java: 21 + - Memory: 512MB + - CPU: 0.5 vCPU + +Database: + - PostgreSQL: 15.x + - Connection Pool: HikariCP + - Migrations: Flyway + +Environment: + - SPRING_PROFILES_ACTIVE: production + - DATABASE_URL: Connection string + - JWT_SECRET: Secret key +``` + +### **CI/CD Pipeline** +```yaml +# GitHub Actions +name: Deploy + +on: + push: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: '18' + - name: Run tests + run: npm test + + deploy: + needs: test + runs-on: ubuntu-latest + steps: + - name: Deploy to Vercel + uses: amondnet/vercel-action@v20 +``` + +--- + +## 📊 **Monitoramento e Observabilidade** + +### **Métricas de Performance** +- **Frontend**: Core Web Vitals, Bundle size +- **Backend**: Response time, Throughput, Error rate +- **Database**: Query performance, Connection pool + +### **Logging** +```java +@Slf4j +@Service +public class GroupService { + + public GroupDTO createGroup(CreateGroupRequest request, String userEmail) { + log.info("Creating group: {} for user: {}", request.getName(), userEmail); + + try { + // Business logic + log.info("Group created successfully: {}", group.getId()); + return groupMapper.toDTO(group); + } catch (Exception e) { + log.error("Error creating group for user: {}", userEmail, e); + throw e; + } + } +} +``` + +### **Health Checks** +```java +@RestController +public class HealthController { + + @Autowired + private DataSource dataSource; + + @GetMapping("/api/v1/health") + public ResponseEntity> health() { + Map status = new HashMap<>(); + status.put("status", "UP"); + status.put("database", checkDatabase()); + status.put("timestamp", Instant.now().toString()); + + return ResponseEntity.ok(status); + } +} +``` + +--- + +## 🔒 **Segurança** + +### **Autenticação e Autorização** +- **JWT Tokens**: Stateless authentication +- **Password Hashing**: BCrypt with salt +- **Role-based Access**: Admin/Member permissions +- **Session Management**: Token expiration + +### **Proteções Implementadas** +- **CORS**: Cross-origin requests +- **CSRF**: Cross-site request forgery +- **SQL Injection**: Prepared statements +- **XSS**: Input sanitization +- **Rate Limiting**: API throttling + +--- + +## 📈 **Escalabilidade** + +### **Estratégias de Crescimento** +- **Horizontal Scaling**: Multiple instances +- **Database Optimization**: Indexing, query tuning +- **Caching**: Redis for sessions/data +- **CDN**: Static asset distribution +- **Load Balancing**: Traffic distribution + +### **Futuras Melhorias** +- **Microservices**: Service decomposition +- **Event Sourcing**: Audit trail +- **CQRS**: Command/Query separation +- **GraphQL**: Flexible API queries + +--- + +
+ 🏗️ Arquitetura sólida e escalável
+ Base técnica para crescimento sustentável +
diff --git a/docs/project/diagrams.md b/docs/project/diagrams.md new file mode 100644 index 00000000..7e69d558 --- /dev/null +++ b/docs/project/diagrams.md @@ -0,0 +1,491 @@ +# 📊 Diagramas do Sistema - FinBoost+ + +Esta seção apresenta os diagramas oficiais que documentam a arquitetura e funcionamento do sistema FinBoost+, baseados nos documentos originais do projeto. + +--- + +## 🏗️ **Diagrama de Arquitetura do Sistema** + +```mermaid +graph TB + subgraph "Camada de Apresentação" + UI[Interface Web - React] + Mobile[Interface Mobile-First] + Router[React Router] + end + + subgraph "Camada de Aplicação" + API[API REST - Spring Boot] + Auth[Spring Security + JWT] + Controllers[Controllers] + end + + subgraph "Camada de Negócio" + Services[Services] + Business[Regras de Negócio] + Calculations[Cálculos de Saldos] + end + + subgraph "Camada de Dados" + JPA[JPA/Hibernate] + PostgreSQL[(PostgreSQL)] + Cache[Cache - Redis*] + end + + subgraph "Infraestrutura" + Docker[Docker Containers] + CI[GitHub Actions] + Docs[MkDocs + Swagger] + end + + UI --> API + Mobile --> API + Router --> Controllers + API --> Auth + Auth --> Services + Services --> Business + Business --> Calculations + Services --> JPA + JPA --> PostgreSQL + + Docker --> UI + Docker --> API + CI --> Docker + Docs --> CI + + Cache -.-> Services + + style Cache fill:#f9f9f9,stroke:#999,stroke-dasharray: 5 5 +``` + +*Cache Redis previsto para versões futuras + +--- + +## 👤 **Diagrama de Casos de Uso** + +> **📋 Nota:** Este diagrama está baseado no arquivo oficial `Diagrama de Casos de Uso.png` localizado em `project_docs/diagramas/` + +```mermaid +graph LR + User[👤 Usuário] + Admin[👤 Administrador do Grupo] + System[🖥️ Sistema FinBoost+] + + subgraph "Autenticação" + UC1[Fazer Login] + UC2[Cadastrar-se] + UC3[Recuperar Senha] + UC4[Gerenciar Perfil] + end + + subgraph "Gestão de Grupos" + UC5[Criar Grupo] + UC6[Convidar Membros] + UC7[Participar de Grupo] + UC8[Visualizar Membros] + UC9[Sair do Grupo] + end + + subgraph "Controle de Despesas" + UC10[Registrar Despesa] + UC11[Visualizar Despesas] + UC12[Editar Despesa Própria] + UC13[Excluir Despesa Própria] + UC14[Dividir Despesa] + end + + subgraph "Relatórios e Saldos" + UC15[Ver Dashboard] + UC16[Visualizar Saldos] + UC17[Calcular Débitos/Créditos] + UC18[Ver Histórico] + end + + User --> UC1 + User --> UC2 + User --> UC3 + User --> UC4 + User --> UC7 + User --> UC8 + User --> UC9 + User --> UC10 + User --> UC11 + User --> UC12 + User --> UC13 + User --> UC14 + User --> UC15 + User --> UC16 + User --> UC18 + + Admin --> UC5 + Admin --> UC6 + + System --> UC17 +``` + +--- + +## 🗄️ **Diagrama Entidade-Relacionamento (ER)** + +> **📋 Nota:** Este diagrama está baseado no arquivo oficial `diagrama_ER.png` localizado em `project_docs/diagramas/` + +```mermaid +erDiagram + USER { + bigint id PK + varchar name + varchar email UK + varchar password_hash + varchar profile_picture + timestamp created_at + timestamp updated_at + boolean active + } + + GROUP { + bigint id PK + varchar name + text description + varchar invite_code UK + bigint created_by FK + timestamp created_at + timestamp updated_at + boolean active + } + + GROUP_MEMBER { + bigint id PK + bigint group_id FK + bigint user_id FK + varchar role + timestamp joined_at + boolean active + } + + EXPENSE { + bigint id PK + bigint group_id FK + bigint created_by FK + varchar description + decimal amount + varchar category + date expense_date + text notes + timestamp created_at + timestamp updated_at + boolean active + } + + EXPENSE_SPLIT { + bigint id PK + bigint expense_id FK + bigint user_id FK + decimal amount + varchar split_type + decimal percentage + timestamp created_at + } + + BALANCE { + bigint id PK + bigint group_id FK + bigint user_id FK + decimal total_paid + decimal total_owed + decimal net_balance + timestamp calculated_at + } + + USER ||--o{ GROUP : creates + USER ||--o{ GROUP_MEMBER : belongs_to + GROUP ||--o{ GROUP_MEMBER : has + GROUP ||--o{ EXPENSE : contains + USER ||--o{ EXPENSE : creates + EXPENSE ||--o{ EXPENSE_SPLIT : has + USER ||--o{ EXPENSE_SPLIT : participates + GROUP ||--o{ BALANCE : tracks + USER ||--o{ BALANCE : has +``` + +--- + +## 📱 **Diagrama de Classes - Estrutura Principal** + +> **📋 Nota:** Este diagrama está baseado no arquivo oficial `diagrama_classes.png` localizado em `project_docs/diagramas/` + +```mermaid +classDiagram + class User { + +Long id + +String name + +String email + +String passwordHash + +String profilePicture + +LocalDateTime createdAt + +LocalDateTime updatedAt + +Boolean active + + +createGroup() + +joinGroup() + +createExpense() + +updateProfile() + } + + class Group { + +Long id + +String name + +String description + +String inviteCode + +Long createdBy + +LocalDateTime createdAt + +LocalDateTime updatedAt + +Boolean active + + +addMember() + +removeMember() + +generateInviteCode() + +calculateGroupBalance() + } + + class GroupMember { + +Long id + +Long groupId + +Long userId + +String role + +LocalDateTime joinedAt + +Boolean active + + +changeRole() + +leave() + } + + class Expense { + +Long id + +Long groupId + +Long createdBy + +String description + +BigDecimal amount + +String category + +LocalDate expenseDate + +String notes + +LocalDateTime createdAt + +LocalDateTime updatedAt + +Boolean active + + +splitExpense() + +updateSplits() + +calculateIndividualAmounts() + } + + class ExpenseSplit { + +Long id + +Long expenseId + +Long userId + +BigDecimal amount + +String splitType + +BigDecimal percentage + +LocalDateTime createdAt + + +calculateAmount() + } + + class Balance { + +Long id + +Long groupId + +Long userId + +BigDecimal totalPaid + +BigDecimal totalOwed + +BigDecimal netBalance + +LocalDateTime calculatedAt + + +recalculate() + +getDebtsAndCredits() + } + + User ||--o{ Group : creates + User ||--o{ GroupMember : participates + Group ||--o{ GroupMember : contains + Group ||--o{ Expense : has + User ||--o{ Expense : creates + Expense ||--o{ ExpenseSplit : divided_into + User ||--o{ ExpenseSplit : owes + Group ||--o{ Balance : tracks + User ||--o{ Balance : has +``` + +--- + +## 🔄 **Fluxograma: Processo de Registro de Despesa** + +```mermaid +flowchart TD + Start([Usuário acessa grupo]) --> Auth{Usuário autenticado?} + Auth -->|Não| Login[Redirecionar para login] + Auth -->|Sim| Member{É membro do grupo?} + Member -->|Não| Error[Erro: Acesso negado] + Member -->|Sim| Form[Formulário de despesa] + + Form --> Fill[Preencher dados obrigatórios] + Fill --> Validate{Dados válidos?} + Validate -->|Não| FormError[Mostrar erros de validação] + FormError --> Form + + Validate -->|Sim| Select[Selecionar participantes] + Select --> Split[Definir tipo de divisão] + Split --> Confirm[Confirmar despesa] + + Confirm --> Save[Salvar no banco de dados] + Save --> CalcBalance[Recalcular saldos do grupo] + CalcBalance --> Success[✅ Despesa registrada] + + Login --> Start + Error --> End([Fim]) + Success --> End +``` + +--- + +## 🔄 **Fluxograma: Processo de Convite para Grupo** + +```mermaid +flowchart TD + Start([Admin convida usuário]) --> Generate[Gerar código único] + Generate --> Send[Compartilhar código/link] + Send --> Receive[Usuário recebe convite] + + Receive --> UserExists{Usuário já cadastrado?} + UserExists -->|Não| Register[Cadastro obrigatório] + Register --> Login[Login necessário] + UserExists -->|Sim| AlreadyMember{Já é membro?} + + AlreadyMember -->|Sim| Error[Erro: Já é membro] + AlreadyMember -->|Não| Accept[Aceitar convite] + Login --> Accept + + Accept --> Join[Adicionar ao grupo] + Join --> Welcome[Exibir dashboard do grupo] + + Error --> End([Fim]) + Welcome --> End +``` + +--- + +## 🎯 **Diagrama de Componentes - Frontend React** + +```mermaid +graph TB + subgraph "App Principal" + App[App.jsx] + Router[React Router] + AuthContext[AuthContext] + ThemeContext[ThemeContext] + end + + subgraph "Páginas" + Login[Login] + Dashboard[Dashboard] + Groups[Groups] + Profile[Profile] + end + + subgraph "Componentes Principais" + Header[Header] + ExpenseForm[ExpenseForm] + ExpenseList[ExpenseList] + GroupCard[GroupCard] + BalanceCard[BalanceCard] + end + + subgraph "Componentes UI" + Button[Button] + Input[Input] + Modal[Modal] + Loading[Loading] + end + + subgraph "Hooks Customizados" + useAuth[useAuth] + useGroups[useGroups] + useExpenses[useExpenses] + end + + subgraph "Serviços" + AuthService[AuthService] + APIService[APIService] + LocalStorage[LocalStorage] + end + + App --> Router + App --> AuthContext + App --> ThemeContext + Router --> Páginas + + Páginas --> "Componentes Principais" + "Componentes Principais" --> "Componentes UI" + "Componentes Principais" --> "Hooks Customizados" + "Hooks Customizados" --> Serviços +``` + +--- + +## � **Fluxo de Autenticação e Segurança** + +```mermaid +sequenceDiagram + participant User as 👤 Usuário + participant Frontend as 🎨 React App + participant API as 🔧 Spring Boot API + participant Auth as 🔐 Spring Security + participant DB as 🗄️ PostgreSQL + + User->>Frontend: 1. Acessa aplicação + Frontend->>API: 2. Verificar sessão + API->>Auth: 3. Validar token JWT + + alt Token inválido/expirado + Auth-->>Frontend: 4. Unauthorized + Frontend-->>User: 5. Tela de login + User->>Frontend: 6. Credenciais + Frontend->>API: 7. POST /auth/login + API->>Auth: 8. Validar credenciais + Auth->>DB: 9. Buscar usuário + DB-->>Auth: 10. Dados do usuário + Auth->>Auth: 11. Gerar JWT + Auth-->>API: 12. Token + dados + API-->>Frontend: 13. Resposta com token + Frontend->>Frontend: 14. Armazenar token + else Token válido + Auth-->>API: 4. Autorizado + API-->>Frontend: 5. Dados do usuário + end + + Frontend-->>User: 15. Interface autenticada + + Note over User,DB: Requisições subsequentes incluem JWT no header +``` + +--- + +## 📊 **Referências dos Diagramas Oficiais** + +Os diagramas apresentados nesta documentação são baseados nos seguintes arquivos oficiais do projeto: + +- **📁 `project_docs/diagramas/Diagrama de Casos de Uso.png`** - Casos de uso detalhados do sistema +- **📁 `project_docs/diagramas/diagrama_ER.png`** - Modelo de dados e relacionamentos +- **📁 `project_docs/diagramas/diagrama_classes.png`** - Estrutura de classes do domínio + +### **Observações Importantes:** + +1. **Escopo do MVP:** Os diagramas refletem apenas as funcionalidades implementadas no MVP +2. **Versões Futuras:** Funcionalidades como cache Redis, notificações e IA estão marcadas como futuras +3. **Simplificação:** Alguns detalhes de implementação foram simplificados para clareza +4. **Atualização:** Esta documentação será atualizada conforme a evolução do projeto + +--- + +
+ 📊 Diagramas baseados na documentação oficial
+ Representação visual da arquitetura implementada +
diff --git a/docs/project/overview.md b/docs/project/overview.md new file mode 100644 index 00000000..cf2cb951 --- /dev/null +++ b/docs/project/overview.md @@ -0,0 +1,193 @@ +# 📋 Visão Geral do Projeto - FinBoost+ + +## 💡 **Conceito** + +O **FinBoost+** é uma aplicação web fullstack para **controle financeiro pessoal e compartilhado**, desenvolvida como projeto final do curso **Desenvolvimento Full-Stack Jr** da **+Prati & Codifica**. + +### **Problema Identificado** +- Dificuldade em controlar gastos compartilhados entre grupos (amigos, família, colegas) +- Falta de transparência sobre quem deve o quê para quem +- Divisões manuais propensas a erros e conflitos +- Ausência de ferramentas simples para acompanhamento financeiro + +### **Solução Proposta** +Sistema web que automatiza o registro, divisão e acompanhamento de despesas compartilhadas, com interface intuitiva e cálculos automáticos de saldos. + +--- + +## 🎯 **Objetivos** + +### **Objetivo Principal** +Desenvolver um MVP funcional que permita criação de grupos, registro de despesas e visualização clara de saldos entre participantes. + +### **Objetivos Secundários** +- Demonstrar competências em desenvolvimento fullstack +- Aplicar metodologias ágeis em equipe +- Criar documentação técnica profissional +- Implementar testes automatizados +- Seguir boas práticas de UX/UI + +--- + +## 👥 **Público-Alvo** + +### **Usuários Primários** +- **Estudantes** que dividem moradia e gastos +- **Casais** que controlam finanças em conjunto +- **Grupos de amigos** que fazem viagens e atividades +- **Famílias** que precisam organizar gastos domésticos + +### **Personas Principais** +- **Ana (21, Estudante)** - Divide aluguel e contas com colegas +- **João (28, Profissional)** - Controla gastos com esposa +- **Grupo Viagem** - Amigos que dividem custos de viagens + +--- + +## 🚀 **Escopo do MVP** + +### **Funcionalidades Essenciais** +- ✅ Cadastro e autenticação de usuários +- ✅ Criação e gerenciamento de grupos +- ✅ Registro de despesas compartilhadas +- ✅ Cálculo automático de saldos +- ✅ Dashboard com visão geral +- ✅ Interface responsiva + +### **Funcionalidades Futuras** (pós-MVP) +- 🔮 Integração com bancos e cartões +- 🔮 Sugestões de economia com IA +- 🔮 Notificações push +- 🔮 Relatórios avançados +- 🔮 Múltiplas moedas + +--- + +## 🛠️ **Stack Tecnológico** + +### **Frontend** +- **React 19** - Interface de usuário +- **Vite 7** - Build tool e dev server +- **TailwindCSS 4** - Estilização +- **React Router** - Roteamento +- **Recharts** - Gráficos + +### **Backend** +- **Java 21** - Linguagem principal +- **Spring Boot 3.5** - Framework +- **Spring Security** - Autenticação +- **PostgreSQL 15** - Banco de dados +- **Docker** - Containerização + +### **Infraestrutura** +- **GitHub** - Versionamento +- **GitHub Actions** - CI/CD +- **MkDocs** - Documentação +- **Vitest** - Testes frontend +- **JUnit** - Testes backend + +--- + +## 📊 **Métricas de Sucesso** + +### **Técnicas** +- ✅ Cobertura de testes > 80% +- ✅ Performance (FCP < 1.5s) +- ✅ Responsividade completa +- ✅ Zero vulnerabilidades críticas + +### **Funcionais** +- ✅ Usuário consegue criar grupo em < 1 minuto +- ✅ Registro de despesa em < 30 segundos +- ✅ Visualização de saldos instantânea +- ✅ Interface intuitiva (sem tutorial) + +### **Acadêmicas** +- ✅ Entrega no prazo (10/12/2025) +- ✅ Documentação completa +- ✅ Apresentação técnica +- ✅ Trabalho em equipe efetivo + +--- + +## 🗓️ **Cronograma Resumido** + +| Fase | Período | Responsável | Status | +|------|---------|-------------|--------| +| **Planejamento** | Set 2024 | Equipe | ✅ Concluído | +| **Setup** | Out 2024 | DevOps | ✅ Concluído | +| **Backend MVP** | Nov 2024 | Backend Team | 🔄 Em Andamento | +| **Frontend MVP** | Dez 2024 | Frontend Team | 🔄 Em Andamento | +| **Testes & Deploy** | Dez 2024 | Equipe | ⏳ Aguardando | +| **Apresentação** | 10/12/2024 | Equipe | ⏳ Aguardando | + +--- + +## 👨‍💻 **Equipe** + +### **Gestão** +- **Alan Oliveira** - Product Owner & DevOps + +### **Backend (Java/Spring)** +- **Bruno** - Tech Lead Backend +- **Cristiano** - Developer +- **Eduardo** - Developer +- **João** - Developer +- **Pedro** - Developer +- **Alisson** - Developer +- **Túlio** - Developer + +### **Frontend (React)** +- **Cleiton** - Tech Lead Frontend +- **Hugo** - Developer +- **Ana** - Developer & UI/UX + +--- + +## 🎓 **Contexto Educacional** + +### **Curso** +**Desenvolvimento Full-Stack Jr** - +Prati & Codifica + +### **Competências Demonstradas** +- ✅ Desenvolvimento Frontend (React, JavaScript) +- ✅ Desenvolvimento Backend (Java, Spring Boot) +- ✅ Banco de Dados (PostgreSQL, JPA) +- ✅ Versionamento (Git, GitHub) +- ✅ DevOps (Docker, CI/CD) +- ✅ Testes Automatizados +- ✅ Documentação Técnica +- ✅ Metodologias Ágeis + +### **Metodologia** +- **Scrum** adaptado para contexto acadêmico +- **Sprints** de 2 semanas +- **Daily standups** síncronos +- **Code review** obrigatório +- **Documentação** como código + +--- + +## 📚 **Recursos de Apoio** + +### **Documentação** +- [Documentação Técnica Completa](../index.md) +- [Guia de Contribuição](../../CONTRIBUTING.md) +- [Análise da Documentação](../../DOCUMENTATION_ANALYSIS.md) + +### **Repositório** +- [GitHub - finboostplus-app](https://github.com/Finboostplus/finboostplus-app) +- [Issues e Tarefas](https://github.com/Finboostplus/finboostplus-app/issues) +- [Pull Requests](https://github.com/Finboostplus/finboostplus-app/pulls) + +### **Comunicação** +- **Discord** - Comunicação diária da equipe +- **GitHub Discussions** - Discussões técnicas +- **Email** - finboostplus@gmail.com + +--- + +
+ 📋 Projeto acadêmico com padrões profissionais
+ Desenvolvido pelo Grupo 7 - +Prati & Codifica +
diff --git a/docs/project/requirements.md b/docs/project/requirements.md new file mode 100644 index 00000000..66cab962 --- /dev/null +++ b/docs/project/requirements.md @@ -0,0 +1,321 @@ +# 📋 Requisitos do Sistema - FinBoost+ + +Este documento especifica os requisitos funcionais e não funcionais do sistema FinBoost+, baseado no documento oficial de requisitos do projeto acadêmico. + +--- + +## 🎯 **Objetivo do Sistema** + +Desenvolver uma aplicação web para **controle financeiro compartilhado** que permita a criação de grupos, registro de despesas e cálculo automático de saldos entre participantes. Este é um projeto final de curso com prazo até dezembro de 2025. + +--- + +## ⚙️ **Requisitos Funcionais (RF)** + +### **RF01 - Cadastro e Gestão de Usuário** +**Prioridade:** Essencial +- Cadastro com nome, email e senha +- Login via email/senha ou OAuth2 (Google) +- Gestão completa de perfil +- Entidade "Perfil" com dados pessoais e preferências +- Validação rigorosa de dados de entrada +- Criptografia de senhas (hash + salt) +- Verificação de email opcional + +### **RF02 - Login e Autenticação Segura** +**Prioridade:** Essencial +- Login tradicional (email + senha) +- Implementação de OAuth2 para login social (Google) +- Emissão de token JWT com expiração e refresh token +- Fluxo de recuperação de senha por email (link único, validade limitada) +- 2FA opcional via OTP para ações críticas +- Proteção contra ataques de força bruta + +### **RF03 - Criação e Gerenciamento de Grupos** +**Prioridade:** Essencial +- Criação de grupos com nome e descrição +- Convite de membros por email ou código de convite +- Visualização de membros +- Aceitação/recusa de convites +- Definição automática do criador como administrador + +### **RF04 - Cadastro e Registro de Despesas com CRUD Completo** +**Prioridade:** Essencial +- Registro com valor, descrição, data, categoria, pagador, participantes +- CRUD completo para todas as entidades +- Filtros dinâmicos e paginação em listagens +- Consulta detalhada incluindo relacionamentos e metadados +- Pesquisa avançada com filtros em múltiplos campos +- Divisão igual ou personalizada de despesas +- Histórico de alterações + +### **RF05 - Cálculo Automático de Saldos** +**Prioridade:** Essencial +- Cálculo automático de quanto cada participante deve ou recebe +- Saldo individual e resumo de "quem deve para quem" +- Atualização em tempo real após mudanças + +### **RF06 - Dashboard Resumo** +**Prioridade:** Essencial +- Visão geral das finanças do grupo e usuário +- Total gasto, últimas despesas, saldos +- Débitos e créditos consolidados + +### **RF07 - Categorias de Despesas** +**Prioridade:** Essencial +- Categorias básicas: Alimentação, Transporte, Casa, Lazer, Outros +- Organização e visualização por categoria + +### **RF08 - Metas de Economia** *(Importante)* +**Prioridade:** Importante +- Definição de metas individuais ou familiares +- Acompanhamento de progresso +- Alertas de acompanhamento + +### **RF09 - Alertas Simples** *(Desejável)* +**Prioridade:** Desejável +- Alertas por email quando limites de orçamento forem ultrapassados +- Configuração de parâmetros pelo usuário + +### **RF10 - Navegação e Usabilidade Avançada** +**Prioridade:** Importante +- Navegação fluida com animações suaves +- Estrutura de menus e rotas clara +- Breadcrumbs para orientação +- Interface responsiva (mobile-first) +- Feedback visual claro e em tempo real +- Navegação por teclado para acessibilidade + +### **RF11 - Listagem e Organização Avançada** +**Prioridade:** Importante +- Listas paginadas ou carregamento contínuo (infinite scroll) +- Reorganização de itens (drag and drop) +- Filtros dinâmicos e ordenação múltipla +- Lazy loading para performance +- Pesquisa em tempo real +- Exportação de dados básica + +### **RF12 - Sistema de Preferências e Personalização** *(Desejável)* +**Prioridade:** Desejável +- Tema claro/escuro +- Configurações de notificações +- Preferências de visualização (formato de data, moeda) +- Configurações de privacidade +- Sincronização entre dispositivos + +--- + +## 🛡️ **Requisitos Não Funcionais (RNF)** + +### **RNF01 - Performance e Tempo de Resposta** +- Operações básicas (login, cadastro): máximo 3 segundos +- Cálculos de saldo: máximo 5 segundos +- Carregamento de páginas: máximo 4 segundos +- Cache distribuído (Redis ou equivalente) +- Lazy loading e otimização de queries +- Evitar re-renderizações desnecessárias no frontend + +### **RNF02 - Capacidade e Escalabilidade** +- Suportar pelo menos 50 usuários simultâneos +- Máximo 1000 usuários cadastrados +- Máximo 100 grupos +- Máximo 10.000 despesas +- Arquitetura modular ou microsserviços +- Preparação para load balancing futuro + +### **RNF03 - Segurança** +- Senhas criptografadas (hash + salt) +- Sessões seguras com JWT e refresh token +- OAuth2 para login social (Google) +- 2FA opcional via OTP +- Proteção contra OWASP Top 10 (SQL Injection, XSS, CSRF) +- HTTPS obrigatório em produção +- Rate limiting para prevenir ataques +- Cabeçalhos de segurança (CSP, HSTS, X-Frame-Options) + +### **RNF04 - Usabilidade e Acessibilidade** +- Interface intuitiva (máximo 3 cliques para funcionalidades principais) +- Interface responsiva (mobile-first) +- Contraste adequado de cores +- Textos alternativos para imagens +- Navegação por teclado +- Compatibilidade com leitores de tela +- Feedback visual claro e em tempo real +- Animações suaves e não invasivas + +### **RNF05 - Confiabilidade** +- 95% de disponibilidade durante horário comercial +- Backup automático diário do banco de dados +- Mensagens de erro claras e acionáveis +- Não perder dados do usuário em caso de erro +- Logs de erro detalhados para debug +- Monitoramento de saúde da aplicação + +### **RNF06 - Manutenibilidade e DevOps** +- Código bem estruturado com padrões consistentes +- Separação clara de responsabilidades (MVC) +- Comentários em funções complexas +- Testes automatizados (coverage > 80%) +- Uso de Git com commits descritivos +- Pipeline de CI/CD (GitHub Actions) +- Containerização com Docker +- Documentação técnica completa (Swagger/OpenAPI) + +### **RNF07 - Portabilidade e Compatibilidade** +- Compatibilidade com Chrome, Firefox, Edge (versões atuais) +- Responsividade: Mobile (320px+), Tablet (768px+), Desktop (1024px+) +- Containerização com Docker +- Configuração via variáveis de ambiente +- Deploy automatizado via CI/CD +- Suporte a múltiplos ambientes (dev, staging, prod) + +--- + +## 🔧 **Stack Tecnológica** + +### **Frontend** +- **Framework:** React 19+ +- **Build Tool:** Vite 7+ +- **Styling:** CSS/TailwindCSS +- **Roteamento:** React Router +- **Estado Global:** Context API ou Redux +- **HTTP Client:** Axios ou Fetch API +- **Testing:** Jest + React Testing Library + +### **Backend** +- **Linguagem:** Java 21+ +- **Framework:** Spring Boot 3.5+ +- **Security:** Spring Security + JWT +- **Database:** PostgreSQL 15+ +- **ORM:** JPA/Hibernate +- **Cache:** Redis (futuro) +- **Testing:** JUnit 5 + Mockito + +### **Infraestrutura** +- **Containerização:** Docker + Docker Compose +- **CI/CD:** GitHub Actions +- **Documentação:** MkDocs Material + Swagger/OpenAPI +- **Monitoramento:** Spring Actuator +- **Deploy:** Heroku/Railway (MVP) + +--- + +## 📊 **Regras de Negócio** + +### **RN01 - Usuários e Grupos** +- Um usuário pode participar de múltiplos grupos +- Um grupo deve ter pelo menos 2 participantes +- Apenas o administrador pode excluir o grupo +- Usuários podem sair de grupos voluntariamente + +### **RN02 - Despesas** +- Valor da despesa deve ser positivo e maior que zero +- Data da despesa não pode ser futura +- Participantes da divisão devem ser membros do grupo +- Soma das divisões deve igualar o valor total + +### **RN03 - Saldos** +- Saldos são calculados automaticamente +- Valores positivos indicam crédito +- Valores negativos indicam débito +- Simplificação de dívidas para minimizar transações + +### **RN04 - Segurança** +- Usuários só acessam grupos dos quais participam +- Apenas criador pode editar/excluir suas despesas +- Tokens JWT expiram em 24 horas +- Senhas não podem ser visualizadas em texto plano + +--- + +## 🎯 **Critérios de Aceitação do MVP** + +### **Funcional** +- [x] Usuário consegue se cadastrar e fazer login +- [x] Usuário consegue criar e participar de grupos +- [x] Usuário consegue registrar despesas compartilhadas +- [x] Sistema calcula saldos automaticamente +- [x] Interface é responsiva e intuitiva + +### **Técnico** +- [ ] Cobertura de testes > 80% +- [ ] Performance adequada (carregamento < 4s) +- [x] Segurança implementada (JWT, validações) +- [x] Documentação completa +- [x] Deploy automatizado funcionando + +### **Qualidade** +- [x] Código segue padrões estabelecidos +- [x] Interface acessível e usável +- [x] Tratamento adequado de erros +- [x] Logs e monitoramento implementados + +--- + +## 📈 **Priorização (MoSCoW)** + +### **Must Have (Obrigatório)** +- Autenticação básica (RF01, RF02) +- Gestão de grupos (RF03) +- Registro de despesas (RF04) +- Cálculo de saldos (RF05) +- Interface responsiva (RF10) + +### **Should Have (Importante)** +- Dashboard principal (RF06) +- Categorias de despesas (RF07) +- Navegação avançada (RF10) +- Listagem otimizada (RF11) + +### **Could Have (Desejável)** +- Metas de economia (RF08) +- Alertas simples (RF09) +- Personalização (RF12) +- Exportação de relatórios + +### **Won't Have (Não será feito no MVP)** +- Login social completo (apenas Google) +- Notificações push +- Integração bancária +- Múltiplas moedas +- App mobile nativo +- IA/Sugestões inteligentes + +--- + +## 📚 **Contexto Acadêmico** + +### **Restrições do Projeto** +- **Prazo:** Até dezembro de 2025 +- **Equipe:** 10 pessoas (6 backend, 2 frontend, 1 gestão, 1 design) +- **Orçamento:** Projeto acadêmico (sem orçamento para serviços pagos) +- **Foco:** Demonstrar competências técnicas do curso + +### **Alinhamento com Requisitos do Curso** + +**Backend:** +- ✅ Autenticação e segurança (OAuth2, JWT, 2FA) +- ✅ CRUD completo com filtros e paginação +- ✅ API RESTful documentada (Swagger) +- ✅ Testes automatizados +- ✅ Cache e otimização + +**Frontend:** +- ✅ Interface responsiva e acessível +- ✅ Navegação fluida com React Router +- ✅ Gerenciamento de estado +- ✅ Componentização +- ✅ Testes unitários + +**DevOps:** +- ✅ Containerização com Docker +- ✅ Pipeline CI/CD +- ✅ Documentação técnica +- ✅ Versionamento com Git + +--- + +
+ 📋 Requisitos definidos com foco no valor
+ MVP viável e escalável para o futuro +
diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 00000000..2cfbd0f3 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,22 @@ +node_modules +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.git +.gitignore +README.md +TESTING_GUIDE.md +.env.local +.env.development.local +.env.test.local +.env.production.local +.vscode +.DS_Store +dist +dev-dist +__tests__ +*.test.js +*.test.jsx +.coverage +.nyc_output + diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 00000000..ffaebf65 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,15 @@ +# ============================ +# 🌐 CONFIGURAÇÕES DE AMBIENTE +# ============================ + +# URL base da API em desenvolvimento +VITE_BASEURL_DEV="{Endereço do banco de desenvolvimento}" + +# URL base da API em produção +VITE_BASEURL_PROD="{Endereço do banco de produção}" + +# Credenciais codificadas em Base64: "client_id:client_secret" +VITE_CLIENT_CREDENTIALS_BASE64="{Base64 das credênciais (client_id:client_secret)}" + +# Chave de criptografia para SecureLS (AES-256) +VITE_SECURE_LS_ENCRYPTION_KEY=COLOQUE_SUA_CHAVE_AQUI \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore index a547bf36..f46c749d 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -9,6 +9,7 @@ lerna-debug.log* node_modules dist +dev-dist dist-ssr *.local diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 00000000..07031f71 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,32 @@ +# Build stage +FROM node:18-alpine AS build + +# Definir diretório de trabalho +WORKDIR /app + +# Copiar package.json e package-lock.json (se existir) +COPY package*.json ./ + +# Instalar dependências +RUN npm ci + +# Copiar código fonte +COPY . . + +# Build da aplicação +RUN npm run build + +# Production stage +FROM nginx:alpine AS production + +# Copiar arquivos buildados para o nginx +COPY --from=build /app/dist /usr/share/nginx/html + +# Copiar configuração customizada do nginx +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# Expor porta 80 +EXPOSE 80 + +# Comando para iniciar o nginx +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/Dockerfile.dev b/frontend/Dockerfile.dev new file mode 100644 index 00000000..437e3b56 --- /dev/null +++ b/frontend/Dockerfile.dev @@ -0,0 +1,20 @@ +# Dockerfile.dev - Ambiente de desenvolvimento para o frontend +FROM node:18-alpine + +# Definir diretório de trabalho +WORKDIR /app + +# Copiar package.json e package-lock.json +COPY package*.json ./ + +# Instalar todas as dependências (incluindo devDependencies) +RUN npm ci + +# Copiar código fonte +COPY . . + +# Expor porta do Vite +EXPOSE 5173 + +# Comando para iniciar em modo desenvolvimento +CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] diff --git a/frontend/README.md b/frontend/README.md index 5a35dfaa..7bb34223 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,236 +1,493 @@ -# 🎨 Finboost+ Frontend — Controle Financeiro Compartilhado +# 🎨 Frontend - FinBoost+ -Finboost+ é uma interface web moderna para gerenciar finanças em grupo, feita com React, Vite e Tailwind CSS. Oferece navegação fluida, componentes acessíveis e testes com Vitest, focando em performance e experiência do usuário. +
+ React + Vite + Tailwind + Node.js + PWA + Status +
+ +Interface web moderna do FinBoost+ em React. Foco em UX responsiva, acessibilidade, PWA e arquitetura escalável. + +--- + +## 📚 Sumário + +
+Clique para expandir + +- [🎨 Frontend - FinBoost+](#-frontend---finboost) + - [📚 Sumário](#-sumário) + - [🚀 Visão Geral](#-visão-geral) + - [✅ Funcionalidades Principais](#-funcionalidades-principais) + - [🛠️ Tecnologias](#️-tecnologias) + - [📁 Arquitetura & Estrutura](#-arquitetura--estrutura) + - [Princípios](#princípios) + - [📱 PWA (Progressive Web App)](#-pwa-progressive-web-app) + - [Instalação](#instalação) + - [Recursos Implementados](#recursos-implementados) + - [⚡ Execução Rápida](#-execução-rápida) + - [🔧 Configuração / Ambiente](#-configuração--ambiente) + - [🧪 Testes](#-testes) + - [🎨 Tema & Estilo](#-tema--estilo) + - [🤝 Padrões de Desenvolvimento](#-padrões-de-desenvolvimento) + - [🚀 Performance](#-performance) + - [🔧 Troubleshooting](#-troubleshooting) + - [📞 Suporte](#-suporte) +
+ +--- + +## 🚀 Visão Geral + +Camada de interface que consome a API Spring Boot. Estrutura orientada a escalabilidade (Feature + Atomic Design) e experiência rica (dashboard, gráficos, tema dinâmico, offline básico via PWA). + +## ✅ Funcionalidades Principais + +- 🎨 UI Responsiva & Dark/Light Mode +- 🔐 Autenticação e proteção de rotas +- 👥 Gestão de grupos e despesas +- 📊 Dashboard com gráficos (Recharts) +- 📱 PWA instalável e uso offline básico +- ♻️ Hooks e Context para estado global --- -## 📇 Indíces +## 🛠️ Tecnologias + +**Core:** React 19, Vite, TailwindCSS, JavaScript (ES202x) +**UI/Data:** React Router, Recharts, Headless UI, Axios +**Qualidade:** ESLint, Prettier, Vitest, React Testing Library +**PWA:** Vite Plugin PWA (Service Worker + Manifest) -1. [Estrutura do projeto](#estrutura-do-projeto) -2. [Instalação e Uso](#instalação-e-uso) -3. [Contribuição](#contribuição) - - [Tecnologias Utilizadas](#tecnologias-utilizadas) - - [Boas práticas](#boas-práticas) --- -## Estrutura do projeto - -```plaintext -frontend/ -├── 📁 __tests__/ # Testes automatizados -│ ├── 📁 components/ # Testes de componentes -│ │ └── Header.test.jsx -│ └── setup.js -├── 📁 public/ # Arquivos públicos acessíveis diretamente -│ └── logo.png # Imagem usada como logotipo -├── 📁 src/ # Código-fonte principal -│ ├── 📁 components/ # Componentes reutilizáveis -│ │ ├── 📁 Loading/ # Componente de loading -│ │ │ └── index.jsx -│ │ ├── 📁 Logo/ # Componente de logotipo -│ │ │ └── index.jsx -│ │ ├── 📁 Modal/ # Componente de modal -│ │ │ └── index.jsx -│ │ ├── 📁 SummaryCards/ # Cartões de resumo -│ │ │ └── index.jsx -│ │ ├── 📁 UserGreeting/ # Saudação ao usuário -│ │ │ └── index.jsx -│ │ ├── 📁 charts/ # Componentes de gráficos -│ │ │ ├── BalanceChart.jsx -│ │ │ └── ExpenseChart.jsx -│ │ ├── 📁 forms/ # Componentes de formulários -│ │ │ ├── ExpenseForm.jsx -│ │ │ ├── LoginForm.jsx -│ │ │ └── RegisterForm.jsx -│ │ ├── 📁 layout/ # Componentes de layout (Header, Sidebar, etc) -│ │ │ ├── 📁 DropdownMenu/ # Menu dropdown no layout -│ │ │ │ ├── 📁 DropdownMenuNav/ -│ │ │ │ │ ├── 📁 SwitchTheme/ # Componente de troca de tema -│ │ │ │ │ │ └── index.jsx -│ │ │ │ │ ├── index.jsx -│ │ │ │ │ ├── navItems.jsx -│ │ │ │ │ └── useSidebarNav.js -│ │ │ │ └── index.jsx -│ │ │ ├── Header.jsx -│ │ │ └── Layout.jsx -│ │ └── 📁 ui/ # Componentes de UI básicos (botões, inputs, etc) -│ │ ├── Button.jsx -│ │ ├── Card.jsx -│ │ ├── Input.jsx -│ │ └── Modal.jsx -│ ├── 📁 context/ # Context API para estado global -│ │ ├── AuthContext.jsx # Contexto de autenticação -│ │ ├── GroupContext.jsx # Contexto de grupos -│ │ └── ThemeContext.jsx # Contexto de tema (dark/light) -│ ├── 📁 hooks/ # Hooks customizados -│ │ ├── useAuth.js # Hook para acessar o AuthContext -│ │ ├── useExpenses.js # Hook para acessar dados de despesas -│ │ ├── useGroups.js # Hook para acessar dados de grupos -│ │ └── useLocalStorage.js # Hook para gerenciar localStorage -│ ├── 📁 pages/ # Páginas principais da aplicação -│ │ ├── 📁 Dashboard/ # Página do dashboard -│ │ │ └── index.jsx -│ │ ├── 📁 Expenses/ # Página de despesas -│ │ │ ├── 📁 CustomSplitAmount/ -│ │ │ │ └── index.jsx -│ │ │ ├── 📁 FormFieldsExpenses/ -│ │ │ │ └── index.jsx -│ │ │ ├── LatestExpenses.jsx -│ │ │ └── index.jsx -│ │ ├── 📁 Groups/ # Página de grupos -│ │ │ ├── 📁 GroupDetails/ # Detalhes de um grupo -│ │ │ │ ├── 📁 ModalButton/ -│ │ │ │ │ └── index.jsx -│ │ │ │ ├── BalancesList.jsx -│ │ │ │ ├── ExpensesList.jsx -│ │ │ │ ├── groupDetailsLoader.js -│ │ │ │ └── index.jsx -│ │ │ ├── index.jsx -│ │ │ └── mockGroups.jsx -│ │ ├── 📁 Login/ # Página de login -│ │ │ ├── index.jsx -│ │ │ └── loginAction.js -│ │ ├── 📁 Notfound/ # Página 404 -│ │ │ └── index.jsx -│ │ ├── 📁 Profile/ # Página de perfil -│ │ │ ├── 📁 ActiveGroups/ -│ │ │ │ └── index.jsx -│ │ │ ├── 📁 Stats/ -│ │ │ │ └── index.jsx -│ │ │ └── index.jsx -│ │ └── 📁 Register/ # Página de cadastro -│ │ └── index.jsx -│ ├── 📁 routes/ # Rotas protegidas e principais -│ │ ├── ProtectedRoute.jsx -│ │ └── routes.jsx -│ ├── 📁 services/ # Serviços para API e chamadas HTTP -│ │ ├── api.js # Configuração base do Axios -│ │ ├── auth.js # Requisições de autenticação -│ │ ├── expenses.js # Requisições de despesas -│ │ ├── groups.js # Requisições de grupos -│ │ └── users.js # Requisições de usuários -│ ├── App.jsx # Componente raiz da aplicação -│ ├── index.css # Estilos globais da aplicação -│ └── main.jsx # Ponto de entrada da aplicação (ReactDOM) -├── tailwind.config.js # Configuração do TailwindCSS -├── .gitignore # Arquivos e pastas ignoradas pelo Git -├── README.md # Documentação do projeto -├── eslint.config.js # Configuração do ESLint (linter) -├── index.html # HTML principal da aplicação (usado pelo Vite) -├── package.json # Dependências e scripts do projeto -└── vite.config.js # Configuração do Vite +## 📁 Arquitetura & Estrutura + +``` +📁 frontend +│ +├── 📁 __tests__ +│ ├── 📁 actions +│ │ └── 📄 groupAction.test.js +│ ├── 📁 components +│ │ ├── 📄 Button.test.jsx +│ │ ├── 📄 Card.test.jsx +│ │ ├── 📄 ChartSwitcher.test.jsx +│ │ ├── 📄 Checkbox.test.jsx +│ │ ├── 📄 GroupForm.test.jsx +│ │ ├── 📄 Header.test.jsx +│ │ ├── 📄 Logo.test.jsx +│ │ ├── 📄 Modal.test.jsx +│ │ ├── 📄 OfflineIndicator.test.jsx +│ │ └── 📄 Select.test.jsx +│ ├── 📁 integration +│ │ ├── 📄 GroupForm.test.jsx +│ │ └── 📄 LoginForm.test.jsx +│ ├── 📁 routes +│ │ └── 📄 ProtectedRoute.test.jsx +│ ├── 📁 utils +│ │ └── 📄 formatters.test.js +│ ├── 📄 setup.js +│ └── 📄 test-utils.jsx +├── 📁 public +│ ├── 🖼️ apple-touch-icon.png +│ ├── 🖼️ dark_mode_logo.png +│ ├── 🖼️ icon-192x192.png +│ ├── 🖼️ icon-512x512.png +│ ├── 🖼️ icon.png +│ ├── 🖼️ light_mode_logo.png +│ └── 🖼️ logo.png +├── 📁 src +│ ├── 📁 actions +│ │ ├── 📄 groupAction.js +│ │ ├── 📄 loginAction.js +│ │ └── 📄 registerAction.js +│ ├── 📁 components +│ │ ├── 📁 ChartSwitcher +│ │ │ ├── 📄 BalanceChart.jsx +│ │ │ ├── 📄 ExpenseChart.jsx +│ │ │ └── 📄 index.jsx +│ │ ├── 📁 CustomToast +│ │ │ └── 📄 index.jsx +│ │ ├── 📁 Filters +│ │ │ └── 📁 Groups +│ │ │ ├── 📄 index.jsx +│ │ │ └── 📄 useFilteredGroups.js +│ │ ├── 📁 LatestExpenses +│ │ │ └── 📄 index.jsx +│ │ ├── 📁 Layout +│ │ │ ├── 📁 DropdownMenu +│ │ │ │ ├── 📁 SwitchTheme +│ │ │ │ │ └── 📄 index.jsx +│ │ │ │ ├── 📄 index.jsx +│ │ │ │ └── 📄 navItems.jsx +│ │ │ ├── 📄 Header.jsx +│ │ │ └── 📄 index.jsx +│ │ ├── 📁 Loading +│ │ │ └── 📄 index.jsx +│ │ ├── 📁 Logo +│ │ │ └── 📄 index.jsx +│ │ ├── 📁 Modal +│ │ │ └── 📄 index.jsx +│ │ ├── 📁 PaginationController +│ │ │ └── 📄 index.jsx +│ │ ├── 📁 SummaryCards +│ │ │ ├── 📄 expenseUtils.js +│ │ │ └── 📄 index.jsx +│ │ ├── 📁 UserGreeting +│ │ │ └── 📄 index.jsx +│ │ ├── 📁 forms +│ │ │ ├── 📁 ExpenseForm +│ │ │ │ ├── 📁 CustomSplitAmount +│ │ │ │ │ └── 📄 index.jsx +│ │ │ │ ├── 📁 FormFieldsExpenses +│ │ │ │ │ └── 📄 index.jsx +│ │ │ │ ├── 📁 ListMembers +│ │ │ │ │ └── 📄 index.jsx +│ │ │ │ ├── 📄 index.jsx +│ │ │ │ └── 📄 useForm.js +│ │ │ ├── 📄 GroupForm.jsx +│ │ │ ├── 📄 LoginForm.jsx +│ │ │ └── 📄 RegisterForm.jsx +│ │ ├── 📁 ui +│ │ │ ├── 📄 Button.jsx +│ │ │ ├── 📄 Card.jsx +│ │ │ ├── 📄 Checkbox.jsx +│ │ │ ├── 📄 CurrencyInput.jsx +│ │ │ ├── 📄 Input.jsx +│ │ │ ├── 📄 Modal.jsx +│ │ │ ├── 📄 Select.jsx +│ │ │ └── 📄 Textarea.jsx +│ │ ├── 📄 OfflineIndicator.jsx +│ │ └── 📄 PWAInstaller.jsx +│ ├── 📁 context +│ │ └── 📁 stores +│ │ ├── 📄 auth.js +│ │ └── 📄 theme.js +│ ├── 📁 hooks +│ │ ├── 📁 ReactQuery +│ │ │ ├── 📁 Mutations +│ │ │ │ ├── 📄 addMemberGroupMutation.js +│ │ │ │ ├── 📄 useCreateExpenseMutation.js +│ │ │ │ ├── 📄 useDeleteExpenseByIdMutation.js +│ │ │ │ ├── 📄 useDeleteGroupMemberMutation.js +│ │ │ │ ├── 📄 useDeleteGroupMutation.js +│ │ │ │ ├── 📄 useDeleteMeGroupByIdMutation.js +│ │ │ │ ├── 📄 useUpdateGroupExpenseByIdMutation.js +│ │ │ │ ├── 📄 useUpdateGroupMemberRoleMutation.js +│ │ │ │ ├── 📄 useUpdateGroupMutation.js +│ │ │ │ ├── 📄 useUpdateMeMutation.js +│ │ │ │ └── 📄 useUpdateStatusExpensePartialValueMutation.js +│ │ │ └── 📁 Queries +│ │ │ ├── 📄 useAllGroupExpenseCategoriesQuery.js +│ │ │ ├── 📄 useGroupExpenseByIdQuery.js +│ │ │ ├── 📄 useGroupExpensesQuery.js +│ │ │ ├── 📄 useGroupsQuery.js +│ │ │ ├── 📄 useMeDashboardStatsQuery.js +│ │ │ ├── 📄 useMeExpensesQuery.js +│ │ │ ├── 📄 useMeQuery.js +│ │ │ └── 📄 useMembersQuery.js +│ │ ├── 📄 useAuth.js +│ │ ├── 📄 useExpenses.js +│ │ ├── 📄 useGroups.js +│ │ ├── 📄 useLocalStorage.js +│ │ ├── 📄 useLogout.js +│ │ └── 📄 useOnlineStatus.js +│ ├── 📁 libs +│ │ └── 📁 ReactQuery +│ │ ├── 📄 keys.js +│ │ ├── 📄 main.jsx +│ │ └── 📄 queryClient.js +│ ├── 📁 loaders +│ │ ├── 📄 expenseLoader.js +│ │ ├── 📄 protectRoutersLoader.js +│ │ └── 📄 redirectIfAuthenticatedLoader.js +│ ├── 📁 mockData +│ │ ├── 📁 categories +│ │ │ └── 📄 index.js +│ │ ├── 📁 colorsPallete +│ │ │ └── 📄 colors.js +│ │ ├── 📁 groupIcons +│ │ │ └── 📄 icons.jsx +│ │ └── 📁 user +│ │ ├── 📁 charts +│ │ │ ├── 📁 Balance +│ │ │ │ └── 📄 balance.data.js +│ │ │ ├── 📁 Expense +│ │ │ │ └── 📄 expense.data.js +│ │ │ └── 📄 charts.data.js +│ │ ├── 📄 dashboard.data.js +│ │ ├── 📄 groups.data.jsx +│ │ └── 📄 user.data.js +│ ├── 📁 pages +│ │ ├── 📁 Dashboard +│ │ │ └── 📄 index.jsx +│ │ ├── 📁 Expenses +│ │ │ ├── 📁 ExpenseDetails +│ │ │ │ └── 📄 index.jsx +│ │ │ └── 📄 index.jsx +│ │ ├── 📁 Groups +│ │ │ ├── 📁 GroupDetails +│ │ │ │ ├── 📁 GroupSettings +│ │ │ │ │ ├── 📄 MembersManager.jsx +│ │ │ │ │ ├── 📄 groupSettingsLoader.js +│ │ │ │ │ └── 📄 index.jsx +│ │ │ │ ├── 📄 AddMember.jsx +│ │ │ │ ├── 📄 BalancesList.jsx +│ │ │ │ ├── 📄 ExpensesList.jsx +│ │ │ │ ├── 📄 index.jsx +│ │ │ │ ├── 📄 statusExpense.js +│ │ │ │ └── 📄 usePermissions.js +│ │ │ ├── 📄 GrupoFinancialStatus.jsx +│ │ │ ├── 📄 index.jsx +│ │ │ └── 📄 useGroupsWithMembers.jsx +│ │ ├── 📁 Login +│ │ │ └── 📄 index.jsx +│ │ ├── 📁 Notfound +│ │ │ └── 📄 index.jsx +│ │ ├── 📁 Profile +│ │ │ ├── 📁 ActiveGroups +│ │ │ │ └── 📄 index.jsx +│ │ │ ├── 📁 ProfileContent +│ │ │ │ └── 📄 index.jsx +│ │ │ ├── 📁 Stats +│ │ │ │ └── 📄 index.jsx +│ │ │ └── 📄 index.jsx +│ │ └── 📁 Register +│ │ └── 📄 index.jsx +│ ├── 📁 routes +│ │ └── 📄 routes.jsx +│ ├── 📁 schemas +│ │ ├── 📁 createGroup +│ │ │ ├── 📄 form.js +│ │ │ └── 📄 request.js +│ │ ├── 📁 createNewExpense +│ │ │ ├── 📄 form.js +│ │ │ └── 📄 updateExpenseForm.js +│ │ ├── 📁 loginUser +│ │ │ ├── 📄 form.js +│ │ │ ├── 📄 request.js +│ │ │ └── 📄 response.js +│ │ ├── 📁 me +│ │ │ └── 📄 request.js +│ │ └── 📁 registerUser +│ │ ├── 📄 form.js +│ │ ├── 📄 request.js +│ │ └── 📄 response.js +│ ├── 📁 services +│ │ ├── 📄 api.js +│ │ ├── 📄 auth.js +│ │ ├── 📄 expenses.js +│ │ ├── 📄 groups.js +│ │ ├── 📄 me.js +│ │ ├── 📄 refreshTokenInterceptor.js +│ │ └── 📄 users.js +│ ├── 📁 styles +│ │ ├── 🎨 app.css +│ │ ├── 🎨 components.css +│ │ └── 🎨 custom-toast.css +│ ├── 📁 utils +│ │ ├── 📄 constants.js +│ │ ├── 📄 errors.js +│ │ ├── 📄 formatters.js +│ │ ├── 📄 helpers.js +│ │ ├── 📄 localStorageEncryption.js +│ │ └── 📄 validators.js +│ ├── 📄 App.jsx +│ ├── 🎨 index.css +│ └── 📄 main.jsx +├── ⚙️ .dockerignore +├── ⚙️ .env.example +├── ⚙️ .gitignore +├── 🐳 Dockerfile +├── 📄 Dockerfile.dev +├── 📝 README.md +├── 📝 TESTING_GUIDE.md +├── ⚙️ docker-compose.dev.yml +├── ⚙️ docker-compose.yml +├── 📄 eslint.config.js +├── 🌐 index.html +├── ⚙️ nginx.conf +├── ⚙️ package.json +├── ⚙️ vercel.json +└── 📄 vite.config.js ``` +### Princípios + +- Separation of Concerns: cada pasta com responsabilidade clara +- Atomic + Feature-First: facilita evolução incremental +- Reutilização e composição de componentes +- Testabilidade (camadas desacopladas) + --- -## Instalação e Uso -Para utilizar e instalar a aplicação, é necessário que você tenha os seguintes programas instalados em sua máquina: +## 📱 PWA (Progressive Web App) + +Aplicação instalável em desktop e mobile com cache e atualização automática. + +### Instalação + +- Desktop (Chrome/Edge): Ícone de instalar na barra ou menu do navegador +- Android: Menu → Adicionar à tela inicial +- iOS Safari: Share → Adicionar à Tela de Início -- [Node.js](https://nodejs.org) -- NPM (já vem instalado com o Node.js) -- [Git](https://git-scm.com) +### Recursos Implementados -Você pode verificar se as instalações foram realizadas com sucesso utilizando os seguintes comandos: +- 📄 Manifest configurado (nome, cores, display standalone) +- 🧩 Service Worker (cache estático + atualização automática) +- 🖼 Ícones multi-resolução (+ Apple Touch) +- 🌐 Estratégias: Cache-first (assets) / Network-first (dados dinâmicos) +- 🔄 Auto-update com prompt (reload após nova versão) + +> Ver detalhes técnicos (estratégias, troubleshooting) antes em `PWA_README.md` agora consolidados aqui. + +--- + +## ⚡ Execução Rápida + +Pré-requisitos: Node 18+, npm. -**Para o Node.js:** ```bash -node -v +cd frontend +npm install +npm run dev +# http://localhost:5173 ``` -**Para o Git:** + +Build & Preview: + ```bash -git -v +npm run build +npm run preview ``` -Após instalar as tecnologias necessárias, siga os passos abaixo para visualizar o frontend da aplicação: +Bundle analyze: -### 1. Clone o repositório: ```bash -git clone https://github.com/Finboostplus/finboostplus-app.git +npm run build -- --analyze ``` -### 2. Acesse a pasta do projeto: -```bash -cd frontend +--- + +## 🔧 Configuração / Ambiente + +Arquivo `.env.local`: + +```env +VITE_API_BASE_URL=http://localhost:8080/api +VITE_APP_NAME=FinBoost+ +VITE_NODE_ENV=development ``` -### 3. Instale as dependências: -```bash -npm install +Uso: + +```js +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL; ``` -Aguarde a finalização da instalação. Em seguida, inicie o servidor local com o comando: +--- + +## 🧪 Testes + +Scripts principais: ```bash -npm run dev +npm test # todos +npm run test:watch # modo watch +npm run test:coverage # relatório cobertura ``` -O terminal exibirá algo como: +Cobertura alvo: ≥80% linhas principais (componentes críticos, hooks e serviços). +Guia completo, exemplos e boas práticas em: `TESTING_GUIDE.md` (único guia oficial). + +Estrutura: ``` -Local: http://localhost:5173/ +__tests__/ + components/ # testes unitários de UI + integration/ # fluxos completos (ex: formulários) + setup.js # configuração global (jest-dom, mocks) + test-utils.js # render helpers + providers ``` -Abra esse endereço no navegador para visualizar a aplicação funcionando. - --- -## Contribuição +## 🎨 Tema & Estilo -### Tecnologias Utilizadas +Tailwind + classes utilitárias. Paleta central adaptável. +Exemplo (extensão): -🚀 Principais -- [ReactJS](https://reactjs.org/) -- [Vite](https://vitejs.dev/) -- [JavaScript](https://developer.mozilla.org/pt-BR/docs/Web/JavaScript) -- [Tailwind CSS](https://tailwindcss.com/) +```js +// tailwind.config.js +export default { + theme: { + extend: { + colors: { primary: { 50: '#eff6ff', 500: '#3b82f6', 900: '#1e3a8a' } }, + }, + }, +}; +``` -📦 Dependências -- [React Router](https://reactrouter.com/) -- [Recharts](https://recharts.org/) +--- -🧪 Desenvolvimento & Testes -- [Vitest](https://vitest.dev/) -- [React Testing Library](https://testing-library.com/docs/react-testing-library/intro/) -- [ESLint](https://eslint.org/) -- [Prettier](https://prettier.io/) -- [Headless UI](https://headlessui.com/) +## 🤝 Padrões de Desenvolvimento + +- Componentes: PascalCase (`UserProfile.jsx`) +- Hooks: `useCamelCase` +- Funções/util: camelCase +- Constantes: UPPER_SNAKE_CASE +- Commits: `feat:`, `fix:`, `style:`, `refactor:`, `test:`, `docs:` +- Organização de imports: React/libs → internos → estilos + +Exemplo componente: + +```jsx +import { forwardRef } from 'react'; + +const Button = forwardRef( + ({ variant = 'primary', className = '', ...props }, ref) => ( + + + ); +}; + +describe('Testes de Integração - LoginForm', () => { + it('deve submeter o formulário com dados válidos', async () => { + const user = userEvent.setup(); + const mockSubmit = vi.fn().mockResolvedValue(); + const mockError = vi.fn(); + + render(); + + // Preenche os campos + await user.type(screen.getByLabelText(/email/i), 'test@example.com'); + await user.type(screen.getByLabelText(/senha/i), 'password123'); + + // Submete o formulário + await user.click(screen.getByRole('button', { name: /entrar/i })); + + // Verifica se foi chamado com os dados corretos + expect(mockSubmit).toHaveBeenCalledWith({ + email: 'test@example.com', + password: 'password123' + }); + expect(mockError).not.toHaveBeenCalled(); + }); + + it('deve mostrar erro quando campos estão vazios', async () => { + const user = userEvent.setup(); + const mockSubmit = vi.fn(); + const mockError = vi.fn(); + + render(); + + // Submete sem preencher campos + await user.click(screen.getByRole('button', { name: /entrar/i })); + + expect(mockError).toHaveBeenCalledWith('Email e senha são obrigatórios'); + expect(mockSubmit).not.toHaveBeenCalled(); + }); + + it('deve mostrar estado de loading durante submit', async () => { + const user = userEvent.setup(); + let resolveSubmit; + const mockSubmit = vi.fn(() => new Promise(resolve => { + resolveSubmit = resolve; + })); + + render(); + + // Preenche e submete + await user.type(screen.getByLabelText(/email/i), 'test@example.com'); + await user.type(screen.getByLabelText(/senha/i), 'password123'); + await user.click(screen.getByRole('button', { name: /entrar/i })); + + // Verifica estado de loading + expect(screen.getByText('Entrando...')).toBeInTheDocument(); + expect(screen.getByLabelText(/email/i)).toBeDisabled(); + expect(screen.getByLabelText(/senha/i)).toBeDisabled(); + + // Resolve a promise + resolveSubmit(); + + // Verifica que voltou ao estado normal + await waitFor(() => { + expect(screen.getByText('Entrar')).toBeInTheDocument(); + }); + }); + + it('deve lidar com erro durante submit', async () => { + const user = userEvent.setup(); + const mockSubmit = vi.fn().mockRejectedValue(new Error('Credenciais inválidas')); + const mockError = vi.fn(); + + render(); + + await user.type(screen.getByLabelText(/email/i), 'test@example.com'); + await user.type(screen.getByLabelText(/senha/i), 'wrongpassword'); + await user.click(screen.getByRole('button', { name: /entrar/i })); + + await waitFor(() => { + expect(mockError).toHaveBeenCalledWith('Credenciais inválidas'); + }); + }); + + it('deve permitir navegação por teclado', async () => { + const user = userEvent.setup(); + render(); + + const emailInput = screen.getByLabelText(/email/i); + const passwordInput = screen.getByLabelText(/senha/i); + const submitButton = screen.getByRole('button', { name: /entrar/i }); + + // Navega usando Tab + await user.tab(); + expect(emailInput).toHaveFocus(); + + await user.tab(); + expect(passwordInput).toHaveFocus(); + + await user.tab(); + expect(submitButton).toHaveFocus(); + }); +}); diff --git a/frontend/__tests__/routes/ProtectedRoute.test.jsx b/frontend/__tests__/routes/ProtectedRoute.test.jsx new file mode 100644 index 00000000..0db1c89d --- /dev/null +++ b/frontend/__tests__/routes/ProtectedRoute.test.jsx @@ -0,0 +1,73 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { screen, render } from '@testing-library/react'; +import { MemoryRouter, Routes, Route, Navigate, useLocation } from 'react-router'; +import ProtectedRoute from '../../src/routes/ProtectedRoute'; + +// Confere se o pathname de origem foi preservado +function ShowFrom() { + const location = useLocation(); + return ( +
+
{location?.state?.from?.pathname ?? 'no-from'}
+
+ ); +} + +// Testa a rota alvo /login usando MemoryRouter e podemos controlar as entradas iniciais +function AppRoutes() { + return ( + + } /> + }> + Dashboard Private} /> + } /> + + + ); +} + +// Mantém localStorage limpo entre os testes +beforeEach(() => { + localStorage.clear(); +}); + +afterEach(() => { + localStorage.clear(); +}); + +describe('ProtectedRoute', () => { + it('redireciona usuários não autenticados para /login', () => { + render( + + + + ); + + // Como a rota alvo é protegida e não há token, caímos em /login + // e o componente ShowFrom renderiza o pathname origem + expect(screen.getByTestId('pathname')).toHaveTextContent('/dashboard'); + }); + + it('renderiza children quando autenticado', () => { + localStorage.setItem('token', 'fake-token'); + + render( + + + + ); + + expect(screen.getByText('Dashboard Private')).toBeInTheDocument(); + }); + + it('preserva a rota de origem em location.state.from quando redireciona', () => { + render( + + + + ); + + const fromPath = screen.getByTestId('pathname'); + expect(fromPath).toHaveTextContent('/dashboard'); + }); +}); diff --git a/frontend/__tests__/setup.js b/frontend/__tests__/setup.js index bb02c60c..c1e59b02 100644 --- a/frontend/__tests__/setup.js +++ b/frontend/__tests__/setup.js @@ -1 +1,98 @@ import '@testing-library/jest-dom/vitest'; +import { vi, expect } from 'vitest'; +import { customMatchers } from './test-utils.jsx'; + +// Registra matchers customizados definidos em test-utils +expect.extend(customMatchers); + +// --- ResizeObserver Mock (idempotente e mais completo) --- +if (!globalThis.ResizeObserver) { + globalThis.ResizeObserver = class ResizeObserver { + constructor(cb) { + this._cb = cb; + } + observe(target) { + // Simula callback imediato + const entry = { + target, + contentRect: { width: 0, height: 0, top: 0, left: 0, bottom: 0, right: 0 }, + borderBoxSize: [{ inlineSize: 0, blockSize: 0 }], + contentBoxSize: [{ inlineSize: 0, blockSize: 0 }], + devicePixelContentBoxSize: [{ inlineSize: 0, blockSize: 0 }], + }; + this._cb([entry], this); + } + unobserve() {} + disconnect() {} + }; +} + +// --- IntersectionObserver Mock com helper --- +if (!globalThis.IntersectionObserver) { + const instances = new Set(); + globalThis.__triggerIntersection = (entries = [{ isIntersecting: true, intersectionRatio: 1 }]) => { + instances.forEach(inst => inst._cb(entries, inst)); + }; + globalThis.IntersectionObserver = class IntersectionObserver { + constructor(cb, options) { + this._cb = cb; + this._options = options; + instances.add(this); + } + observe() {} + unobserve() {} + disconnect() { instances.delete(this); } + takeRecords() { return []; } + }; +} + +// --- matchMedia Mock (idempotente) --- +if (!window.matchMedia) { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation(query => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), // deprecated + removeListener: vi.fn(), // deprecated + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +} + +// Reset básico após cada teste para futuros mocks dinâmicos de matchMedia +import { afterEach } from 'vitest'; +afterEach(() => { + if (window.matchMedia?.mockReset) window.matchMedia.mockReset(); +}); + +// Alinha URLSearchParams entre jsdom (window) e Node (undici) +// Corrige erro: "Request constructor: Expected init.body (\"URLSearchParams {}\") to be an instance of URLSearchParams" +if (typeof window !== 'undefined' && window.URLSearchParams) { + // Use a versão do jsdom para o ambiente global do Node + // evitando instância de reinos diferentes + // eslint-disable-next-line no-global-assign + URLSearchParams = window.URLSearchParams; + globalThis.URLSearchParams = window.URLSearchParams; +} + +// Mock adicional para fetch para evitar erros de Request constructor +const originalFetch = globalThis.fetch; +globalThis.fetch = vi.fn(async (input, init = {}) => { + // Se o body é um URLSearchParams do jsdom, convertê-lo para string + if (init.body && init.body.constructor.name === 'URLSearchParams') { + init.body = init.body.toString(); + } + + // Mock response padrão para testes + return { + ok: true, + status: 200, + json: async () => ({}), + text: async () => '', + headers: new Map(), + }; +}); diff --git a/frontend/__tests__/test-utils.jsx b/frontend/__tests__/test-utils.jsx new file mode 100644 index 00000000..de5ad6ce --- /dev/null +++ b/frontend/__tests__/test-utils.jsx @@ -0,0 +1,161 @@ +import { render, screen } from '@testing-library/react'; +import { vi, beforeEach, afterEach } from 'vitest'; + +// Salva fetch original para restauração +const originalFetch = globalThis.fetch; + +// Mock simples para contextos (caso sejam necessários nos testes) +const createMockContext = (value) => ({ + Provider: ({ children }) => children, + Consumer: ({ children }) => children(value), +}); + +// Utilitários para mocks +export const createMockAuthContext = (user = null, isLoading = false) => ({ + user, + isLoading, + login: vi.fn(), + logout: vi.fn(), + register: vi.fn(), +}); + +export const createMockThemeContext = (theme = 'light', overrides = {}) => ({ + theme, + toggleTheme: vi.fn(), + setTheme: vi.fn(), + ...overrides, +}); + +export const createMockGroupContext = (groups = [], currentGroup = null, overrides = {}) => ({ + groups, + currentGroup, + loading: false, + error: null, + createGroup: vi.fn(), + updateGroup: vi.fn(), + deleteGroup: vi.fn(), + setCurrentGroup: vi.fn(), + ...overrides, +}); + +// Função para renderizar com providers (simplificada) +export const renderWithProviders = (ui, options = {}) => { + return render(ui, options); +}; + +// Mock para React Router +export const mockNavigate = vi.fn(); +export const mockLocation = { + pathname: '/', + search: '', + hash: '', + state: null, +}; + +export const resetRouterMocks = () => { + mockNavigate.mockClear(); +}; + +// Custom matchers para testes +export const customMatchers = { + toBeLoading(received) { + const pass = received && received.getAttribute('data-testid') === 'loading'; + return { + pass, + message: () => pass + ? `Expected element not to be loading` + : `Expected element to be loading` + }; + }, + toHaveErrorMessage(received, expected) { + const errorElement = received.querySelector('[data-testid="error-message"]'); + const pass = errorElement && errorElement.textContent === expected; + return { + pass, + message: () => pass + ? `Expected element not to have error message "${expected}"` + : `Expected element to have error message "${expected}"` + }; + } +}; + +// Mock para React Router (somente se necessário) +vi.mock('react-router-dom', () => ({ + useNavigate: () => mockNavigate, + useLocation: () => mockLocation, + useParams: () => ({}), + BrowserRouter: ({ children }) => children, + Routes: ({ children }) => children, + Route: ({ children }) => children, + Link: ({ children, to, ...props }) => {children}, +})); + +// Mocks para APIs +export const mockApiResponse = (data, status = 200) => ({ + ok: status >= 200 && status < 300, + status, + json: vi.fn().mockResolvedValue(data), + text: vi.fn().mockResolvedValue(JSON.stringify(data)), +}); + +export const mockFetch = response => { + globalThis.fetch = vi.fn().mockResolvedValue(response); +}; + +export const restoreFetch = () => { + globalThis.fetch = originalFetch; +}; + +// Utilitários para aguardar elementos assíncronos +export const waitForLoadingToFinish = async () => { + const { waitForElementToBeRemoved } = await import('@testing-library/react'); + await waitForElementToBeRemoved(() => screen.queryByText(/carregando/i), { + timeout: 3000, + }); +}; + +// Função para criar dados de teste +export const createMockUser = (overrides = {}) => ({ + id: '1', + name: 'João Silva', + email: 'joao@example.com', + avatar: '/avatar/default.jpg', + createdAt: '2023-01-01T00:00:00Z', + ...overrides, +}); + +export const createMockGroup = (overrides = {}) => ({ + id: '1', + name: 'Família Silva', + description: 'Grupo da família', + members: [createMockUser()], + createdAt: '2023-01-01T00:00:00Z', + ...overrides, +}); + +export const createMockExpense = (overrides = {}) => ({ + id: '1', + title: 'Supermercado', + amount: 150.0, + category: 'Alimentação', + date: '2023-01-01', + userId: '1', + groupId: '1', + ...overrides, +}); + +// Helper para testar erros de console +export const suppressConsoleError = () => { + const originalError = console.error; + beforeEach(() => { + console.error = vi.fn(); + }); + + afterEach(() => { + console.error = originalError; + }); +}; + +beforeEach(() => { + resetRouterMocks(); +}); diff --git a/frontend/__tests__/utils/formatters.test.js b/frontend/__tests__/utils/formatters.test.js new file mode 100644 index 00000000..50a30d95 --- /dev/null +++ b/frontend/__tests__/utils/formatters.test.js @@ -0,0 +1,21 @@ +import { describe, it, expect } from 'vitest'; +import { formatBRL } from '../../src/utils/formatters'; + +describe('formatBRL', () => { + it('formata 0 como R$\u00A00,00', () => { + expect(formatBRL(0)).toBe('R$\u00A00,00'); + }); + + it('formata positivo com separadores pt-BR', () => { + expect(formatBRL(1234.56)).toBe('R$\u00A01.234,56'); + }); + + it('formata negativo com sinal', () => { + expect(formatBRL(-10)).toBe('-R$\u00A010,00'); + }); + + it('arredonda corretamente valores decimais', () => { + const value = 0.1 + 0.2; // 0.30000000000000004 + expect(formatBRL(value)).toBe('R$\u00A00,30'); + }); +}); diff --git a/frontend/docker-compose.dev.yml b/frontend/docker-compose.dev.yml new file mode 100644 index 00000000..52d55ed6 --- /dev/null +++ b/frontend/docker-compose.dev.yml @@ -0,0 +1,22 @@ +version: '3.8' + +services: + frontend: + build: + context: . + dockerfile: Dockerfile.dev + container_name: finboostplus-frontend-dev + ports: + - "5173:5173" + volumes: + - .:/app + - /app/node_modules + environment: + - NODE_ENV=development + restart: unless-stopped + networks: + - finboostplus-network + +networks: + finboostplus-network: + driver: bridge diff --git a/frontend/docker-compose.yml b/frontend/docker-compose.yml new file mode 100644 index 00000000..107acb26 --- /dev/null +++ b/frontend/docker-compose.yml @@ -0,0 +1,36 @@ +version: '3.8' + +services: + frontend: + build: + context: . + dockerfile: Dockerfile + container_name: finboostplus-frontend + ports: + - "3000:80" + restart: unless-stopped + networks: + - finboostplus-network + + # Serviço para desenvolvimento (opcional) + frontend-dev: + build: + context: . + dockerfile: Dockerfile.dev + container_name: finboostplus-frontend-dev + ports: + - "5173:5173" + volumes: + - .:/app + - /app/node_modules + environment: + - NODE_ENV=development + restart: unless-stopped + networks: + - finboostplus-network + profiles: + - dev + +networks: + finboostplus-network: + driver: bridge diff --git a/frontend/index.html b/frontend/index.html index 489a3437..8059f065 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,14 +1,26 @@ - + - + + + + + + + + + + - Vite + React + FinBoostPlus - Gestão Financeira
diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 00000000..a354d543 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,37 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # Configuração para SPA (Single Page Application) + location / { + try_files $uri $uri/ /index.html; + } + + # Cache para arquivos estáticos + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Configuração para PWA + location /sw.js { + add_header Cache-Control "no-cache"; + proxy_cache_bypass $http_pragma; + proxy_cache_revalidate on; + expires off; + access_log off; + } + + # Configuração para manifest.json + location /manifest.json { + add_header Cache-Control "no-cache"; + } + + # Segurança + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer-when-downgrade" always; +} diff --git a/frontend/package.json b/frontend/package.json index 9c4e497f..32bad6ec 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,33 +1,53 @@ { "name": "frontend", + "license": "MPL-2.0", "private": true, - "version": "0.0.0", + "version": "0.1.0", "type": "module", "scripts": { "dev": "vite", "build": "vite build", "lint": "eslint .", "test": "vitest", + "test:watch": "vitest --watch", + "test:coverage": "vitest --coverage", "preview": "vite preview", "format": "prettier --write .", "format:check": "prettier --check ." }, "dependencies": { + "@esmkit/axios-auth-refresh": "^0.1.0", "@headlessui/react": "^2.2.4", "@tailwindcss/vite": "^4.1.11", + "@tanstack/query-async-storage-persister": "^5.90.7", + "@tanstack/react-query": "^5.90.2", + "@tanstack/react-query-persist-client": "^5.90.7", + "axios": "^1.12.2", + "big.js": "^5.2.1", + "jwt-decode": "^4.0.0", "react": "^19.1.0", + "react-color": "^2.19.3", + "react-currency-input-field": "^3.10.0", "react-dom": "^19.1.0", "react-icons": "^5.5.0", + "react-intl-currency-input": "^0.2.6", "react-router": "^7.6.3", - "recharts": "^3.1.0" + "react-toastify": "^11.0.5", + "recharts": "^3.1.0", + "secure-ls": "^2.0.0", + "zod": "^4.0.14", + "zustand": "^5.0.7", + "zustand-persist-cookie-storage": "^1.2.13" }, "devDependencies": { + "@babel/preset-env": "^7.28.3", + "@babel/preset-react": "^7.27.1", "@eslint/js": "^9.30.1", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", - "@types/react": "^19.1.8", - "@types/react-dom": "^19.1.6", + "@testing-library/user-event": "^14.6.1", "@vitejs/plugin-react-swc": "^3.10.2", + "babel-jest": "^30.1.1", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^16.3.0", @@ -35,6 +55,21 @@ "prettier": "^3.6.2", "tailwindcss": "^4.1.11", "vite": "^7.0.3", + "vite-plugin-pwa": "^1.0.2", "vitest": "^3.2.4" - } + }, + "jest": { + "transform": { + "^.+\\.[t|j]sx?$": "babel-jest" + } + }, + "engines": { + "node": ">=18.0.0" + }, + "browserslist": [ + "defaults", + "not IE 11", + ">0.5%", + "maintained node versions" + ] } diff --git a/frontend/public/apple-touch-icon.png b/frontend/public/apple-touch-icon.png new file mode 100644 index 00000000..3bc38067 Binary files /dev/null and b/frontend/public/apple-touch-icon.png differ diff --git a/frontend/public/dark_mode_logo.png b/frontend/public/dark_mode_logo.png new file mode 100644 index 00000000..45df125c Binary files /dev/null and b/frontend/public/dark_mode_logo.png differ diff --git a/frontend/public/icon-192x192.png b/frontend/public/icon-192x192.png new file mode 100644 index 00000000..3bc38067 Binary files /dev/null and b/frontend/public/icon-192x192.png differ diff --git a/frontend/public/icon-512x512.png b/frontend/public/icon-512x512.png new file mode 100644 index 00000000..3bc38067 Binary files /dev/null and b/frontend/public/icon-512x512.png differ diff --git a/frontend/public/icon.png b/frontend/public/icon.png new file mode 100644 index 00000000..3bc38067 Binary files /dev/null and b/frontend/public/icon.png differ diff --git a/frontend/public/light_mode_logo.png b/frontend/public/light_mode_logo.png new file mode 100644 index 00000000..0283a2d8 Binary files /dev/null and b/frontend/public/light_mode_logo.png differ diff --git a/frontend/public/logo.png b/frontend/public/logo.png index b305e96f..0283a2d8 100644 Binary files a/frontend/public/logo.png and b/frontend/public/logo.png differ diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 3414d4b9..39eca568 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,13 +1,25 @@ import ChartSwitcher from './components/ChartSwitcher'; +import EmptyGroupInvite from './components/EmptyGroupInvite'; import LatestExpenses from './components/LatestExpenses'; import SummaryCards from './components/SummaryCards'; +import { useGroupsQuery } from './hooks/ReactQuery/Queries/useGroupsQuery'; function App() { + const { data } = useGroupsQuery(); + + const hasGroups = (data?.groupsLength ?? 0) > 0; + return ( -
- - - +
+ {hasGroups ? ( + <> + + + + + ) : ( + + )}
); } diff --git a/frontend/src/actions/groupAction.js b/frontend/src/actions/groupAction.js new file mode 100644 index 00000000..e69de29b diff --git a/frontend/src/actions/loginAction.js b/frontend/src/actions/loginAction.js new file mode 100644 index 00000000..90ff1d7e --- /dev/null +++ b/frontend/src/actions/loginAction.js @@ -0,0 +1,34 @@ +// src/routes/loginAction.js +import { loginUserFormSchema } from '../schemas/loginUser/form'; +import { z } from 'zod'; +import { useAuthStore } from '../context/stores/auth'; + +export const loginAction = async ({ request }) => { + const form = await request.formData(); + const formData = Object.fromEntries(form); + let { success, error, data } = loginUserFormSchema.safeParse(formData); + if (!success) { + const errors = {}; + if (error instanceof z.ZodError) { + error.issues.forEach(issue => { + const fieldName = issue.path[0]; + const title = + loginUserFormSchema.shape[fieldName]?.description || fieldName; + + return (errors[fieldName] = { + title, + message: issue.message, + }); + }); + } else { + errors.global = 'Ocorreu um erro inesperado.'; + } + + return { errors, value: data }; + } + /* Se não ocorreu nenhum erro */ + /* Logar aqui */ + + await useAuthStore.getState().login(data); + return; +}; diff --git a/frontend/src/actions/registerAction.js b/frontend/src/actions/registerAction.js new file mode 100644 index 00000000..73dc6e3b --- /dev/null +++ b/frontend/src/actions/registerAction.js @@ -0,0 +1,38 @@ +import { registerUserFormSchema } from '../schemas/registerUser/form'; +import { z } from 'zod'; + +import { getavatarBackgroundColorRandom } from '../mockData/colorsPallete/colors'; +import { useAuthStore } from '../context/stores/auth'; + +export const registerAction = async ({ request }) => { + const form = await request.formData(); + const formData = Object.fromEntries(form); + const { success, error, data } = registerUserFormSchema.safeParse(formData); + + if (!success) { + const errors = {}; + if (error instanceof z.ZodError) { + error.issues.forEach(issue => { + const fieldName = issue.path[0]; + const title = + registerUserFormSchema.shape[fieldName]?.description || fieldName; + + return (errors[fieldName] = { + title, + message: issue.message, + }); + }); + } else { + errors.global = 'Ocorreu um erro inesperado.'; + } + + return { success, errors, value: data }; + } else { + /* Deu tudo certo com a validação */ + delete data.terms; + delete data.confirmPassword; + data.themeColor = getavatarBackgroundColorRandom(); + await useAuthStore.getState().register(data); + return; + } +}; diff --git a/frontend/src/components/ChartSwitcher/BalanceChart.jsx b/frontend/src/components/ChartSwitcher/BalanceChart.jsx index 78ebe228..ed7e30ae 100644 --- a/frontend/src/components/ChartSwitcher/BalanceChart.jsx +++ b/frontend/src/components/ChartSwitcher/BalanceChart.jsx @@ -5,47 +5,102 @@ import { YAxis, CartesianGrid, Tooltip, - Legend, ResponsiveContainer, } from 'recharts'; -import userData from '../../mockData/user/user.data'; +import { useQuery } from '@tanstack/react-query'; +import { REACTQUERY_KEYS } from '../../libs/ReactQuery/keys'; +import { getMeExpensesByMonthly } from '../../services/me'; +import { formatBRL } from '../../utils/formatters'; export default function BalanceChart() { - const balanceData = userData.dashboard.chartsData.balanceData; + const { data: expensesResponse } = useQuery({ + queryKey: [REACTQUERY_KEYS.USER.DASHBOARD, 'balance'], + queryFn: getMeExpensesByMonthly, + }); + + const expensesByMonthly = + expensesResponse?.map(item => ({ + name: item.month, + despesas: item.total, + })) ?? []; return ( -
- - - - - - - - - - - -
+ + + {/* Linhas de grade */} + + + {/* Eixo X (meses) */} + + + {/* Eixo Y (valores) */} + formatBRL(value)} + tick={{ fontSize: 12, fill: 'var(--color-text)' }} + axisLine={false} + tickLine={false} + width={80} + /> + + {/* Tooltip estilizado */} + { + if (active && payload && payload.length) { + return ( +
+

{label}

+

+ Despesas:{' '} + + {formatBRL(payload[0].value)} + +

+
+ ); + } + return null; + }} + /> + + {/* Gradiente suave (mantém o tema dinâmico) */} + + + + + + + + {/* Área principal */} + +
+
); } diff --git a/frontend/src/components/ChartSwitcher/ExpenseChart.jsx b/frontend/src/components/ChartSwitcher/ExpenseChart.jsx index 24042a63..b8f4116f 100644 --- a/frontend/src/components/ChartSwitcher/ExpenseChart.jsx +++ b/frontend/src/components/ChartSwitcher/ExpenseChart.jsx @@ -1,57 +1,72 @@ -import { - PieChart, - Pie, - Cell, - Tooltip, - Legend, - ResponsiveContainer, -} from 'recharts'; -import userData from '../../mockData/user/user.data'; +import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from 'recharts'; +import { avatarBackgroundColors } from '../../mockData/colorsPallete/colors'; +import { useMeDashboardQuery } from '../../hooks/ReactQuery/Queries/useMeDashboardStatsQuery'; +import { formatBRL } from '../../utils/formatters'; -const COLORS = [ - '#ff69b4', // Rosa vibrante (cor principal da Luna) - '#ffb6c1', // Rosa claro - '#ffd700', // Dourado brilhante (energia e destaque) - '#ba55d3', // Roxo médio (estilo e personalidade) - '#87cefa', // Azul claro (leveza e diversão) - '#ffa07a', // Coral suave (acolhedor e amigável) -]; - -export default function ExpensePieChart() { - const expenseData = userData.dashboard.chartsData.expenseData; +export default function ExpenseQuantityDonutChart() { + const { data: expenseData } = useMeDashboardQuery(); + const total = expenseData?.reduce((sum, item) => sum + item.quantity, 0) || 0; return ( -
- - - - {expenseData.map((entry, index) => ( - - ))} - - - - - +
+
+ + + { + if (active && payload?.length) { + const { name, value } = payload[0]; + const percent = ((value / total) * 100).toFixed(1); + return ( +
+

{name}

+

+ Quantidade:{' '} + + {formatBRL(value, { + minimumFractionDigits: 0, + maximumFractionDigits: 0, + style: 'decimal', + })} + +

+

+ {percent}% do total +

+
+ ); + } + return null; + }} + /> + + + {expenseData?.map((_, index) => ( + + ))} + +
+
+
); } diff --git a/frontend/src/components/ChartSwitcher/index.jsx b/frontend/src/components/ChartSwitcher/index.jsx index 69bb9152..ebe10cd1 100644 --- a/frontend/src/components/ChartSwitcher/index.jsx +++ b/frontend/src/components/ChartSwitcher/index.jsx @@ -1,38 +1,85 @@ import { useState } from 'react'; -import { FaChartLine, FaWallet } from 'react-icons/fa'; +import { FaChartLine, FaChartPie } from 'react-icons/fa'; import BalanceChart from './BalanceChart'; import ExpenseChart from './ExpenseChart'; +import ButtonUI from '../ui/Button'; +import { useMeDashboardQuery } from '../../hooks/ReactQuery/Queries/useMeDashboardStatsQuery'; export default function ChartSwitcher() { const [activeChart, setActiveChart] = useState('balance'); + const { data: expenseData } = useMeDashboardQuery(); + const total = expenseData?.reduce((sum, item) => sum + item.quantity, 0) || 0; - const toggleChart = () => { - setActiveChart(prev => (prev === 'balance' ? 'expense' : 'balance')); - }; + const currentYear = new Date().getFullYear(); + const title = + activeChart === 'balance' + ? `Como Você Gastou em ${currentYear}` + : `Despesas por Categoria ( ${total} )`; return ( -
-
-

- Visualização Financeira +
+ {/* Cabeçalho */} +
+

+ {title}

- + + {/* Alternador */} +
+ {/* Indicador */} +
+ + {/* Opção: Resumo Anual */} + setActiveChart('balance')} + className={`relative z-10 w-1/2 flex items-center gap-2 px-4 py-1.5 rounded-full cursor-pointer transition-colors duration-300 ${ + activeChart === 'balance' + ? 'text-white' + : 'text-muted-foreground hover:text-text' + }`} + > + + Resumo Anual + + + {/* Opção: Divisão de Gastos */} + setActiveChart('expense')} + className={`relative z-10 w-1/2 flex items-center gap-2 px-4 py-1.5 rounded-full cursor-pointer transition-colors duration-300 ${ + activeChart === 'expense' + ? 'text-white' + : 'text-muted-foreground hover:text-text' + }`} + > + + Divisão de Gastos + +
-
- {activeChart === 'balance' ? : } + {/* Área dos gráficos */} +
+ {/* BalanceChart */} +
+ +
+ + {/* ExpenseChart */} +
+ +
); diff --git a/frontend/src/components/CustomToast/index.jsx b/frontend/src/components/CustomToast/index.jsx new file mode 100644 index 00000000..4d2217b9 --- /dev/null +++ b/frontend/src/components/CustomToast/index.jsx @@ -0,0 +1,29 @@ +import { toast } from 'react-toastify'; + +const contextClass = { + success: 'border-success!', + error: 'border-error!', + info: 'border-info!', + warning: 'border-warning!', + default: 'border-indigo-600!', +}; + +function MyCustomToast({ title, message }) { + return ( +
+ + {message} +
+ ); +} + +export const customToast = (title, message, type = 'info') => + toast(, { + ariaLabel: 'field' + title, + type, + autoClose: 1300, + className: contextClass[type] + ' border-2 border-b-0', + toastId: type + '-' + message, + }); diff --git a/frontend/src/components/EmptyGroupInvite/index.jsx b/frontend/src/components/EmptyGroupInvite/index.jsx new file mode 100644 index 00000000..c0f366b3 --- /dev/null +++ b/frontend/src/components/EmptyGroupInvite/index.jsx @@ -0,0 +1,49 @@ +import { useNavigate } from 'react-router'; +import ButtonUI from '../ui/Button'; +import { FaUsers, FaWallet, FaChartPie } from 'react-icons/fa'; + +export default function EmptyGroupInvite() { + const navigate = useNavigate(); + + return ( +
+ {/* Ícone grande */} +
+
+ +
+ +

+ Comece Sua Organização Financeira +

+ +

+ Você ainda não criou um grupo. Os grupos são a base para gerenciar + despesas, dividir valores e acompanhar sua saúde financeira. +

+
+ + {/* Mini indicadores financeiros (fake) para dar cara de dashboard */} +
+
+ + Categorias + 0 +
+ +
+ + Participantes + 0 +
+
+ + navigate('/groups?create')} + > + Criar meu primeiro grupo + +
+ ); +} diff --git a/frontend/src/components/Filters/Groups/index.jsx b/frontend/src/components/Filters/Groups/index.jsx index 64f6cb83..94047b06 100644 --- a/frontend/src/components/Filters/Groups/index.jsx +++ b/frontend/src/components/Filters/Groups/index.jsx @@ -4,11 +4,11 @@ import InputUI from '../../ui/Input'; import SelectUI from '../../ui/Select'; export default function GroupFilters({ - search, - onlyOwner, - sortOrder, - onFilterChange, -}) { + search, + onlyOwner, + sortOrder, + onFilterChange, + }) { const handleSearchChange = e => { onFilterChange({ search: e.target.value, onlyOwner, sortOrder }); }; @@ -54,11 +54,10 @@ export default function GroupFilters({ value={sortOrder} onChange={handleSortChange} className="w-full md:w-auto px-4 py-2 rounded-lg border bg-surface text-text focus:outline-none focus:ring-2 focus:ring-primary transition-colors cursor-pointer" - options={[ - { value: 'asc', label: 'Mais antigos' }, - { value: 'desc', label: 'Mais recentes' }, - ]} - /> + > + + +
); diff --git a/frontend/src/components/Filters/Groups/useFilteredGroups.js b/frontend/src/components/Filters/Groups/useFilteredGroups.js index 530a599a..f3aadcfa 100644 --- a/frontend/src/components/Filters/Groups/useFilteredGroups.js +++ b/frontend/src/components/Filters/Groups/useFilteredGroups.js @@ -1,13 +1,13 @@ import { useMemo } from 'react'; -export function useFilteredGroups(groups, currentUserId, filters) { +export function useFilteredGroups(groups, role, filters) { const { search, onlyOwner, sortOrder } = filters; return useMemo(() => { return groups - .filter( + ?.filter( group => - (!onlyOwner || group.ownerId === currentUserId) && + (!onlyOwner || group.authority === role) && group.name.toLowerCase().includes(search.trim().toLowerCase()) ) .sort((a, b) => { @@ -15,5 +15,5 @@ export function useFilteredGroups(groups, currentUserId, filters) { const bDate = new Date(b.createdAt).getTime(); return sortOrder === 'asc' ? aDate - bDate : bDate - aDate; }); - }, [groups, currentUserId, filters]); + }, [groups, role, filters]); } diff --git a/frontend/src/components/LatestExpenses/index.jsx b/frontend/src/components/LatestExpenses/index.jsx index d694f85d..d72efb6c 100644 --- a/frontend/src/components/LatestExpenses/index.jsx +++ b/frontend/src/components/LatestExpenses/index.jsx @@ -1,64 +1,109 @@ +import { useState } from 'react'; import { Menu, MenuItem } from '@headlessui/react'; -import userData from '../../mockData/user/user.data'; -import { formatBRL } from '../../utils/formatters'; +import { + formatBRL, + formatDateBR, + formatRelativeDate, +} from '../../utils/formatters'; + +import Pagination from '../PaginationController'; +import useMeExpensesQuery from '../../hooks/ReactQuery/Queries/useMeExpensesQuery'; export default function LatestExpenses() { - const current_user = userData; + // 🔢 Estado da paginação + const [page, setPage] = useState(0); + + // 🔗 Consulta das despesas + const { data, isLoading } = useMeExpensesQuery(page); + // 💾 Dados vindos do backend + const expenses = data?.expenses; + const totalPages = data?.totalPages; + const totalExpenses = data?.expensesLength; + // 🕹️ Handlers de navegação + const handleNext = () => { + if (page < totalPages - 1) setPage(prev => prev + 1); + }; + + const handlePrev = () => { + if (page > 0) setPage(prev => prev - 1); + }; + return (

- Últimas Despesas{' '} - - ({current_user.dashboard.recentExpenses.length}) - + Últimas Despesas ({totalExpenses})

- - {current_user.dashboard.recentExpenses.map((expense, i) => ( -
  • - -
    -

    Carregando despesas...

    + ) : expenses?.length === 0 ? ( +

    Nenhuma despesa encontrada.

    + ) : ( + <> + + {expenses?.map(expense => ( +
  • + - {expense.description} -

    -

    - {expense.date} |{' '} - { - current_user.groups.find( - group => group.id === expense.groupId - ).name - } -

    -
  • +
    +
    +

    + {expense.title} +

    +

    + {formatBRL(expense.partialValue)} +

    +
    + +
    + + {expense.groupName && `#${expense.groupName}`} + + +
    + + criada {formatRelativeDate(expense.createdAt)} + + + + vence {formatDateBR(expense.deadlineDate)} + +
    +
    +
    +
    +
  • + ))} +
    -

    - {formatBRL(expense.amount)} -

    - - - ))} - + {totalPages > 1 && ( + <> + {/* 🔄 Controle de Paginação */} + + + )} + + )}
    ); } diff --git a/frontend/src/components/Layout/DropdownMenu/DropdownMenuNav/index.jsx b/frontend/src/components/Layout/DropdownMenu/DropdownMenuNav/index.jsx deleted file mode 100644 index bdb35b76..00000000 --- a/frontend/src/components/Layout/DropdownMenu/DropdownMenuNav/index.jsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Menu, MenuItem } from '@headlessui/react'; -import { navItems } from './navItems'; -import { useCurrentPage } from './useSidebarNav'; - -export default function DropdownMenuNav() { - const currentPage = useCurrentPage(); - return ( - - {navItems.map((item, index) => ( - - - {item.icon} {item.label} - - - ))} - - ); -} diff --git a/frontend/src/components/Layout/DropdownMenu/DropdownMenuNav/SwitchTheme/index.jsx b/frontend/src/components/Layout/DropdownMenu/SwitchTheme/index.jsx similarity index 52% rename from frontend/src/components/Layout/DropdownMenu/DropdownMenuNav/SwitchTheme/index.jsx rename to frontend/src/components/Layout/DropdownMenu/SwitchTheme/index.jsx index 7075ba73..533fa6cb 100644 --- a/frontend/src/components/Layout/DropdownMenu/DropdownMenuNav/SwitchTheme/index.jsx +++ b/frontend/src/components/Layout/DropdownMenu/SwitchTheme/index.jsx @@ -1,22 +1,22 @@ import { Switch } from '@headlessui/react'; -import { useTheme } from '../../../../../context/ThemeContext'; +import { useStore } from 'zustand'; +import { useThemeStore } from '../../../../context/stores/theme'; export default function SwitchTheme() { - const { theme, toggleTheme } = useTheme(); - const enabled = theme === 'dark'; - + const { theme, toggleTheme } = useStore(useThemeStore); + let enabled = theme === 'dark' ? true : false; return ( diff --git a/frontend/src/components/Layout/DropdownMenu/index.jsx b/frontend/src/components/Layout/DropdownMenu/index.jsx index 7f2a56d0..3e156512 100644 --- a/frontend/src/components/Layout/DropdownMenu/index.jsx +++ b/frontend/src/components/Layout/DropdownMenu/index.jsx @@ -1,48 +1,70 @@ import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react'; -import { navItems } from './DropdownMenuNav/navItems'; -import SwitchTheme from './DropdownMenuNav/SwitchTheme'; +import { navItems } from './navItems'; +import SwitchTheme from './SwitchTheme'; import { FaMoon, FaSun } from 'react-icons/fa'; -import userData from '../../../mockData/user/user.data'; +import { useLogout } from '../../../hooks/useLogout'; +import useMeQuery from '../../../hooks/ReactQuery/Queries/useMeQuery'; export default function DropdownMenu() { - const current_user = userData; + const logout = useLogout(); + const { data: user } = useMeQuery(); + return ( - + + {/* Botão do avatar */} - {current_user.username[0]} + + {/* Menu dropdown */}
    {navItems.map(({ icon, label, href, isDanger }, index) => ( {({ active }) => { - const baseTextColor = isDanger ? 'text-error' : 'text-text'; - const activeBgColor = isDanger - ? 'bg-error/10' - : 'bg-primary/10'; - const activeTextColor = isDanger - ? 'text-error' - : 'text-primary'; + const base = + 'flex items-center gap-2 w-full text-left px-4 py-2 rounded-lg text-sm font-medium transition-colors'; + const baseText = isDanger ? 'text-error' : 'text-text'; + const activeBg = isDanger ? 'bg-error/10' : 'bg-primary/10'; + const activeText = isDanger ? 'text-error' : 'text-primary'; + + // Se for o item de "Sair" (ou perigoso), vira um botão + if (isDanger) { + return ( + + ); + } + // Caso contrário, continua sendo um link normal return ( {icon} {label} @@ -52,11 +74,13 @@ export default function DropdownMenu() { ))}
    -
    + + {/* Separador e controle de tema */} +
    - +
    diff --git a/frontend/src/components/Layout/DropdownMenu/DropdownMenuNav/navItems.jsx b/frontend/src/components/Layout/DropdownMenu/navItems.jsx similarity index 100% rename from frontend/src/components/Layout/DropdownMenu/DropdownMenuNav/navItems.jsx rename to frontend/src/components/Layout/DropdownMenu/navItems.jsx diff --git a/frontend/src/components/Layout/Header.jsx b/frontend/src/components/Layout/Header.jsx index e591fd80..78bbb479 100644 --- a/frontend/src/components/Layout/Header.jsx +++ b/frontend/src/components/Layout/Header.jsx @@ -1,10 +1,11 @@ -import { useState } from 'react'; +import { useAuthStore } from '../../context/stores/auth'; import LogoImage from '../Logo'; import DropdownMenu from './DropdownMenu'; import { Menu, MenuItem } from '@headlessui/react'; export default function Header() { - const [isLogged] = useState(true); //Apenas para testar + const isAuthenticated = useAuthStore(state => !!state.token); + return (
    @@ -12,7 +13,8 @@ export default function Header() { - {isLogged && } + + {isAuthenticated && }
    ); } diff --git a/frontend/src/components/Layout/index.jsx b/frontend/src/components/Layout/index.jsx index 5e609507..3bc56acb 100644 --- a/frontend/src/components/Layout/index.jsx +++ b/frontend/src/components/Layout/index.jsx @@ -3,18 +3,23 @@ import Header from './Header'; import { Suspense } from 'react'; import Loading from '../Loading'; +import PWAInstaller from '../PWAInstaller'; +import OfflineIndicator from '../OfflineIndicator'; + export default function Layout() { return (
    +
    -
    +
    }>
    +
    ); diff --git a/frontend/src/components/Logo/index.jsx b/frontend/src/components/Logo/index.jsx index 57d40a42..f43fc2ec 100644 --- a/frontend/src/components/Logo/index.jsx +++ b/frontend/src/components/Logo/index.jsx @@ -1,12 +1,14 @@ +import { useStore } from 'zustand'; +import { useThemeStore } from '../../context/stores/theme'; + export default function LogoImage({ className }) { + const { logo_image } = useStore(useThemeStore); + return ( -
    - Logo da Finboostplus -

    - Controle seus gastos de forma simples e compartilhada +

    + Logo +

    + Assuma o controle. Sem esforço.

    ); diff --git a/frontend/src/components/MessageBox/index.jsx b/frontend/src/components/MessageBox/index.jsx deleted file mode 100644 index 81ac52ce..00000000 --- a/frontend/src/components/MessageBox/index.jsx +++ /dev/null @@ -1,68 +0,0 @@ -import { - MdErrorOutline, - MdWarningAmber, - MdCheckCircleOutline, - MdInfoOutline, -} from 'react-icons/md'; - -const ICONS = { - error:
    -

    Valores por pessoa

    -
    -
    - Marina (Eu) - -
    -
    - João - +

    + 💰 Distribuição personalizada +

    + +
    + {members.map(({ id, name }) => { + const member = divisionAmount[id] || { float: 0, formatted: '' }; + return ( + -
    -
    + ); + })} +
    - {/* */} -
    - Total dividido: R$ 0,00 – verifique os valores + {/* Status de equilíbrio */} + {amount > 0 && ( +
    + {balanced ? ( + + Distribuição correta + + ) : ( +
    +

    + ⚠️ Diferença restante:{' '} + + {formatBRL(remainingDifference)} + +

    + +
    + )}
    + )} +
    + ); +} + +function CustomMemberInput({ balanced, member: { id, member, name } }) { + const { updateMemberShare } = useFormExpense(); + const [localValue, setLocalValue] = useState(''); + const [hasChanged, setHasChanged] = useState(false); + + const valueColor = + member.float === 0 + ? 'text-muted' + : balanced + ? 'text-success' + : 'text-error'; + + // função chamada ao confirmar manualmente + const handleConfirm = () => { + updateMemberShare(id, localValue); + setHasChanged(false); + }; + + return ( +
    +
    + {name} + + Participação: {member.float ? formatBRL(member.float) : 'R$ 0,00'} + +
    + +
    + { + setLocalValue(values?.float ?? ''); + setHasChanged(true); + }} + className="flex-1 p-2 border border-border rounded-md bg-background focus:ring-2 focus:ring-primary/40 transition outline-none" + /> + + {/* Botão de confirmação aparece só se houver mudança */} + {hasChanged && localValue !== '' && ( + + )}
    - +
    ); } diff --git a/frontend/src/components/forms/ExpenseForm/FormFieldsExpenses/index.jsx b/frontend/src/components/forms/ExpenseForm/FormFieldsExpenses/index.jsx index e3188c4c..609c70a0 100644 --- a/frontend/src/components/forms/ExpenseForm/FormFieldsExpenses/index.jsx +++ b/frontend/src/components/forms/ExpenseForm/FormFieldsExpenses/index.jsx @@ -1,106 +1,150 @@ +import { getCurrentDate } from '../../../../utils/helpers'; import InputUI from '../../../ui/Input'; import SelectUI from '../../../ui/Select'; +import TextareaUI from '../../../ui/Textarea'; +import CurrencyInputUI from '../../../ui/CurrencyInput'; +import { useFormExpense } from '../useForm'; +import { useAllGroupExpenseCategoriesQuery } from '../../../../hooks/ReactQuery/Queries/useAllGroupExpenseCategoriesQuery'; export default function FormFieldsExpenses() { + const { data: allCategories } = useAllGroupExpenseCategoriesQuery(); + const { setAmount } = useFormExpense(); + + const handleAmountChange = ({ float: numericValue }) => { + if (numericValue == null) return; + setAmount(numericValue); + }; + return ( - <> - {/* Título */} -
    - - -
    +
    +
    + {/* Título e Valor */} +
    + {/* Título */} +
    + + +
    - {/* Valor */} -
    - - -
    + {/* Valor */} +
    + + handleAmountChange(values)} + /> +
    +
    - {/* Grupo */} -
    - - -
    + {/* Data */} +
    + + +
    - {/* Categoria */} -
    - - -
    + {/* Descrição */} +
    + + +
    + + {/* Categoria */} +
    + + + {allCategories?.map(({ id, name }) => ( + + ))} + +
    - {/* Quem pagou */} -
    - - + {/* Quem pagou */} + {/* {members?.length > 0 && ( +
    + + + {members.map(member => ( + + ))} + +
    + )} */}
    - +
    ); } diff --git a/frontend/src/components/forms/ExpenseForm/ListMembers/index.jsx b/frontend/src/components/forms/ExpenseForm/ListMembers/index.jsx new file mode 100644 index 00000000..979b8f86 --- /dev/null +++ b/frontend/src/components/forms/ExpenseForm/ListMembers/index.jsx @@ -0,0 +1,153 @@ +import { useState, useEffect } from 'react'; +import { FiSearch } from 'react-icons/fi'; +import ButtonUI from '../../../ui/Button'; +import InputUI from '../../../ui/Input'; +import Pagination from '../../../PaginationController'; + +export default function ListMembers({ + isLoading, + members: { groupMembers, totalPages }, + onConfirm, + search: { search, setSearch }, +}) { + const [inputValue, setInputValue] = useState(''); + const [page, setPage] = useState(0); + const [checkedMembers, setCheckedMembers] = useState([]); + + const toggleMember = id => { + setCheckedMembers(prev => + prev.includes(id) ? prev.filter(m => m !== id) : [...prev, id] + ); + }; + + useEffect(() => { + const handler = setTimeout(() => { + setSearch(inputValue.trim()); + setPage(0); + }, 500); + return () => clearTimeout(handler); + }, [inputValue]); + + useEffect(() => { + setCheckedMembers([]); + }, [page, search]); + + // 🧠 Ordena a lista: OWNER → ADMIN → USER + const sortedMembers = [...(groupMembers || [])].sort((a, b) => { + const order = { OWNER: 0, ADMIN: 1, USER: 2 }; + return order[a.authority] - order[b.authority]; + }); + + return ( +
    + {/* Campo de busca */} +
    + + setInputValue(e.target.value)} + className="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary transition" + /> +
    + + {/* Lista de membros */} +
    + {isLoading ? ( +

    + Carregando membros... +

    + ) : sortedMembers.length > 0 ? ( + sortedMembers.map(member => { + const isSelected = checkedMembers.includes(member.id); + return ( +
    toggleMember(member.id)} + className={`group flex items-center justify-between gap-3 p-3 rounded-xl cursor-pointer transition-all duration-200 border ${ + isSelected + ? 'bg-primary/10 border-primary/40 shadow-sm' + : 'border-transparent hover:bg-primary/5 hover:border-primary/20' + }`} + > +
    +
    + {member.name[0]} +
    +
    + + {member.name} + + {member.authority && ( + + {member.authority === 'OWNER' + ? 'Dono' + : member.authority === 'ADMIN' + ? 'Administrador' + : 'Membro'} + + )} +
    +
    + + {isSelected && ( + + Selecionado + + )} +
    + ); + }) + ) : ( +

    + Nenhum membro encontrado +

    + )} +
    + + {/* Paginação */} + {totalPages > 1 && ( + setPage(prev => Math.min(prev + 1, totalPages - 1))} + onPrev={() => setPage(prev => Math.max(prev - 1, 0))} + page={page} + totalPages={totalPages} + /> + )} + + {/* Botões */} +
    + + onConfirm(sortedMembers.filter(m => checkedMembers.includes(m.id))) + } + className={`px-6 py-2 rounded-lg font-semibold shadow-sm text-white transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:ring-offset-surface dark:focus:ring-offset-surface-dark bg-primary hover:bg-primary/90 active:bg-primary-dark cursor-pointer ${ + checkedMembers.length === 0 + ? 'opacity-50 bg-gray-400! cursor-not-allowed!' + : '' + }`} + > + Confirmar + +
    +
    + ); +} diff --git a/frontend/src/components/forms/ExpenseForm/index.jsx b/frontend/src/components/forms/ExpenseForm/index.jsx index af4af314..2c9c9a0a 100644 --- a/frontend/src/components/forms/ExpenseForm/index.jsx +++ b/frontend/src/components/forms/ExpenseForm/index.jsx @@ -2,55 +2,202 @@ import { Form } from 'react-router'; import FormFieldsExpenses from './FormFieldsExpenses'; import ButtonUI from '../../ui/Button'; import CustomSplitAmount from './CustomSplitAmount'; -import { useState } from 'react'; +import { useFormExpense } from './useForm'; +import { useEffect, useState, useCallback, useMemo } from 'react'; +import { customToast } from '../../CustomToast'; +import Modal from '../../Modal'; +import ListMembers from './ListMembers'; +import { FiUserPlus, FiX } from 'react-icons/fi'; -export default function ExpenseForm() { - const [splitType, setSplitType] = useState('equal'); - const activeBtnClasses = 'bg-primary text-white border-transparent'; - const inactiveBtnClasses = - 'bg-neutral text-text border border-gray-300 hover:bg-secondary hover:text-white transition'; +import { useCreateExpenseMutation } from '../../../hooks/ReactQuery/Mutations/useCreateExpenseMutation'; +import { useMembersQuery } from '../../../hooks/ReactQuery/Queries/useMembersQuery'; +import { validatorCreateNewExpense } from '../../../schemas/createNewExpense/form'; + +export default function ExpenseForm({ groupData: group }) { + const [search, setSearch] = useState(''); // termo final para API + const useExpenseMutation = useCreateExpenseMutation(group?.id); + const { data: { members: groupMembers = [], totalPages } = {}, isLoading } = + useMembersQuery(group?.id, 0, search); + + const { setMembers, amount, divisionAmount, reset, getRemainingDifference } = + useFormExpense(); + + const [isSubmitting, setIsSubmitting] = useState(false); + const [selectedMembers, setSelectedMembers] = useState([]); + const [showMemberModal, setShowMemberModal] = useState(false); + + // 🔄 Reseta store ao desmontar + useEffect(() => reset, [reset]); + + // 👥 Atualiza membros selecionados + useEffect(() => { + setMembers(selectedMembers.length > 0 ? selectedMembers : []); + }, [selectedMembers, setMembers]); + + // ⚖️ Verifica se a distribuição está correta + const distributionOK = + selectedMembers.length > 0 && Math.abs(getRemainingDifference()) < 0.01; + + // 📝 Envio do formulário + const handleExpenseSubmit = useCallback( + async e => { + e.preventDefault(); + if (isSubmitting) return; + if (!distributionOK) { + return customToast( + 'Erro', + 'A distribuição de valores não corresponde ao total da despesa.', + 'error' + ); + } + + try { + const formData = new FormData(e.target); + const formValues = Object.fromEntries(formData); + + const expenseData = { + title: formValues.title_expense, + description: formValues.description || '', + deadlineDate: formValues.date, + expenseValue: Number(amount), + categoryId: Number(formValues.category_id), + expenseDivision: Object.entries(divisionAmount).map( + ([memberId, val]) => ({ + id: Number(memberId), + value: Number(val.float.toFixed(2)), + }) + ), + }; + + if (validatorCreateNewExpense(expenseData)) { + setIsSubmitting(true); + + await useExpenseMutation.mutateAsync(expenseData, { + onSuccess: () => + customToast( + 'Nova despesa', + 'Despesa adicionada com sucesso!', + 'success' + ), + onError: ({ response: { data: error } }) => { + customToast(error.title, error.message, 'error'); + }, + onSettled: () => setIsSubmitting(false), + }); + } + } catch (err) { + console.error(err); + customToast( + 'Erro', + err?.message || 'Ocorreu um erro inesperado.', + 'error' + ); + setIsSubmitting(false); + } + }, + [amount, divisionAmount, distributionOK, isSubmitting] + ); + + const removeMember = memberId => { + setSelectedMembers(prev => prev.filter(m => m.id !== memberId)); + }; return ( -
    - - - {/* Como dividir */} -
    -

    Como dividir?

    -
    - setSplitType('equal')} - type="button" - className={`py-2 px-4 rounded border cursor-pointer font-semibold ${splitType === 'equal' ? activeBtnClasses : inactiveBtnClasses}`} - aria-pressed={splitType === 'equal'} - /> +
    + {/* Cabeçalho */} +
    +

    + Adicionar nova despesa +

    +

    + Preencha os detalhes abaixo para adicionar uma despesa ao grupo{' '} + {group?.name || ''}. +

    +
    + + + {/* Campos principais */} + + + {/* Seleção de membros */} +
    + {selectedMembers.length === 0 && groupMembers.length > 0 && ( + setShowMemberModal(true)} + className="flex items-center gap-2 justify-center bg-secondary text-white py-2 px-4 rounded-lg hover:bg-secondary/90 focus:outline-none focus:ring-2 focus:ring-primary transition-shadow shadow-sm cursor-pointer" + > + + Selecionar participantes + + )} + + {selectedMembers.length > 0 && ( +
    + + {selectedMembers.length > 1 + ? 'Membros envolvidos' + : 'Membro envolvido'}{' '} + na despesa: + +
    + {selectedMembers.map(member => ( +
    + {member.name} + +
    + ))} +
    +
    + )} +
    + + {/* Divisão personalizada */} + {selectedMembers.length > 0 && ( + + )} + + {/* Botão de envio */} +
    setSplitType('personalize')} - type="button" - className={`py-2 px-4 rounded border cursor-pointer font-semibold ${splitType === 'personalize' ? activeBtnClasses : inactiveBtnClasses}`} - aria-pressed={splitType === 'personalize'} - /> + type="submit" + disabled={isSubmitting || !distributionOK || amount <= 0} + aria-disabled={isSubmitting || !distributionOK || amount <= 0} + className={`bg-primary hover:bg-secondary text-white cursor-pointer disabled:bg-muted/20 disabled:cursor-not-allowed py-3 px-6 rounded w-full sm:w-auto font-semibold transition`} + > + {isSubmitting ? 'Enviando...' : 'Adicionar Despesa'} +
    -
    - - {splitType === 'personalize' && } - - {/* Botão de enviar */} -
    - + + {/* Modal de seleção */} + setShowMemberModal(false)}> + { + setSelectedMembers(selected); + setMembers(selected); + setShowMemberModal(false); + }} + isLoading={isLoading} + onClose={() => setShowMemberModal(false)} /> -
    - + +
    ); } diff --git a/frontend/src/components/forms/ExpenseForm/useForm.js b/frontend/src/components/forms/ExpenseForm/useForm.js new file mode 100644 index 00000000..564f6327 --- /dev/null +++ b/frontend/src/components/forms/ExpenseForm/useForm.js @@ -0,0 +1,78 @@ +import { create } from 'zustand'; +import { + calculateEqualShares, + calculateRemainingDifference, +} from '../../../utils/helpers'; + +export const useFormExpense = create((set, get) => ({ + amount: 0, + members: [], // array de ids + divisionAmount: {}, // { memberId: { float, formatted, value } } + + // 💰 Atualiza o valor total da despesa + setAmount: value => { + const amount = Number(value) || 0; + const members = get().members; + const divisionAmount = calculateEqualShares(amount, members); + set({ amount, divisionAmount }); + }, + + // 👥 Atualiza os membros participantes + setMembers: members => { + const amount = get().amount; + const divisionAmount = calculateEqualShares(amount, members); + set({ members, divisionAmount }); + }, + + updateMemberShare: (memberId, raw) => { + // converte string para número + if (raw === '') return; + let floatValue = 0; + if (typeof raw === 'string') { + floatValue = parseFloat(raw.replace(',', '.')) || 0; + } else if (typeof raw === 'number') { + floatValue = raw; + } + + // atualiza a store com formato consistente + const divisionAmount = { + ...get().divisionAmount, + [memberId]: { + float: floatValue, + formatted: floatValue.toFixed(2), // para mostrar no input + value: String(floatValue), // string “bruta” + }, + }; + + set({ divisionAmount }); + }, + + // ♻️ Redistribui os valores igualmente + redistributeEvenly: () => { + const amount = get().amount; + const members = get().members; + const divisionAmount = calculateEqualShares(amount, members); + set({ divisionAmount }); + }, + + // 🔄 Resetar tudo + reset: () => + set({ + amount: 0, + members: [], + divisionAmount: {}, + }), + + // 🧮 Soma total distribuída + getTotalAssigned: () => + Object.values(get().divisionAmount).reduce( + (acc, val) => acc + (val?.float || 0), + 0 + ), + + // ⚖️ Diferença restante + getRemainingDifference: () => get().amount - get().getTotalAssigned(), + + // ✅ Verifica se está equilibrado + isBalanced: () => Math.abs(get().getRemainingDifference()) < 0.01, +})); diff --git a/frontend/src/components/forms/GroupForm.jsx b/frontend/src/components/forms/GroupForm.jsx index a7b3ac87..28c4620a 100644 --- a/frontend/src/components/forms/GroupForm.jsx +++ b/frontend/src/components/forms/GroupForm.jsx @@ -1,40 +1,114 @@ -import { Form, useNavigation, useActionData } from 'react-router'; +import { useCallback, useState } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import InputUI from '../ui/Input'; import ButtonUI from '../ui/Button'; import TextareaUI from '../ui/Textarea'; -import MessageBox from '../MessageBox'; +import { categoryIcons } from '../../mockData/groupIcons/icons'; +import { createGroup } from '../../services/groups'; +import { Form } from 'react-router'; +import { REACTQUERY_KEYS } from '../../libs/ReactQuery/keys'; +import { customToast } from '../CustomToast'; +import { + createGroupFormSchema, + validatorGroupForm, +} from '../../schemas/createGroup/form'; +import { ZodError } from 'zod'; -export default function GroupForm() { - const navigation = useNavigation(); - const actionData = useActionData(); - const isSubmitting = navigation.state === 'submitting'; +export default function GroupForm({ page }) { + const queryClient = useQueryClient(); - const errors = actionData?.errors || {}; - const values = actionData?.values || {}; + const [groupName, setGroupName] = useState(''); + const [description, setDescription] = useState(''); + const [selectedIcon, setSelectedIcon] = useState('outros'); + + const { mutateAsync, isPending } = useMutation({ + mutationFn: createGroup, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [REACTQUERY_KEYS.GROUPS.ALL, 'page', page], + }); + setGroupName(''); + setDescription(''); + setSelectedIcon('outros'); + customToast( + 'Cadastro de um novo grupo', + 'Grupo cadastrado com sucesso', + 'success' + ); + }, + onError: () => { + customToast( + 'Cadstro de um novo grupo', + 'Erro ao cadastrar um novo grupo', + 'error' + ); + }, + }); + + const isDisabled = isPending || groupName.trim() === ''; + + const entry = categoryIcons[selectedIcon] || categoryIcons.outros; + const IconComponent = entry.icon; + const iconColor = entry.color; + + const handleSubmit = useCallback( + async e => { + e.preventDefault(); + if (isPending || isDisabled) return; + + const formData = { + name: groupName, + description, + icon: selectedIcon, + }; + + if (validatorGroupForm(formData)) { + await mutateAsync(formData); + } + }, + [isPending, isDisabled, groupName, description, selectedIcon, mutateAsync] + ); return (
    -

    Criar novo grupo

    + {/* Título */} +
    +

    + Criar novo grupo +

    +

    + Preencha as informações abaixo para começar a organizar suas despesas. +

    +
    + {/* Nome + Ícone preview */}
    - - {errors.name && {errors.name[0]}} + +
    +
    + +
    + + setGroupName(e.target.value)} + placeholder="Ex: Família, Viagem Cancun..." + className="flex-1" + /> +
    + {/* Descrição */}
    -
    + {/* Ícones */} +
    + +
    + {Object.keys(categoryIcons).map(key => { + const { icon: Icon, color } = categoryIcons[key]; + return ( + setSelectedIcon(key)} + className={`flex items-center justify-center w-10 h-10 rounded-lg border transition-all duration-150 cursor-pointer ${ + selectedIcon === key + ? 'bg-primary text-white border-primary shadow-sm scale-105' + : 'bg-neutral dark:bg-neutral-dark text-text dark:text-text-dark border-zinc-300 dark:border-surface-dark hover:border-primary/60 hover:scale-105' + }`} + > + + + ); + })} +
    -
    + {selectedIcon && ( +

    + Ícone selecionado:{' '} + {selectedIcon} +

    + )} +
    + + {/* Botão */} +
    + disabled={isDisabled} + className={`px-6 cursor-pointer py-2 rounded-lg font-semibold shadow-sm text-white transition-all duration-200 ${ + isDisabled + ? 'bg-primary/60 cursor-not-allowed opacity-70' + : 'bg-primary hover:bg-primary/90 active:bg-primary-dark cursor-pointer' + }`} + > + {isPending ? 'Criando...' : 'Criar grupo'} +
    ); diff --git a/frontend/src/components/forms/LoginForm.jsx b/frontend/src/components/forms/LoginForm.jsx index ae6ad61c..f02ce8bd 100644 --- a/frontend/src/components/forms/LoginForm.jsx +++ b/frontend/src/components/forms/LoginForm.jsx @@ -1,14 +1,31 @@ import { Form, useActionData } from 'react-router'; -import Button from '../ui/Button'; + import InputUI from '../ui/Input'; import { Menu, MenuItem } from '@headlessui/react'; -import MessageBox from '../MessageBox'; +import { useEffect } from 'react'; +import { customToast } from '../CustomToast'; +import { useAuthStore } from '../../context/stores/auth'; +import ButtonUI from '../ui/Button'; export default function LoginForm() { + const isLoading = useAuthStore(state => state.isLoading); const actionData = useActionData(); - const errors = actionData?.errors || {}; const values = actionData?.values || {}; + useEffect(() => { + if (!actionData) return; + + if (actionData.errors) { + /* Validação dos erros do formulário */ + Object.values(actionData.errors).forEach(err => { + const title = err?.title ?? 'Erro'; + const message = err?.message ?? 'Ocorreu um erro inesperado.'; + customToast(title, message, 'error'); + }); + return; + } + }, [actionData]); + return (
    + {/* Email */}
    + {/* Senha */}
    -
    ); } - -/* -!!!Estou implementando a validação dos formulários!!! - -já criei a pasta schemas -componente message box para mensagens na aplicação -adicionei paleta de cores para a caixa de mensagem - -*/ diff --git a/frontend/src/components/forms/RegisterForm.jsx b/frontend/src/components/forms/RegisterForm.jsx index a24fdda5..a3b60c96 100644 --- a/frontend/src/components/forms/RegisterForm.jsx +++ b/frontend/src/components/forms/RegisterForm.jsx @@ -1,17 +1,66 @@ -import { Menu, MenuItem } from '@headlessui/react'; -import { useState } from 'react'; +import { useEffect } from 'react'; import { Form, useActionData } from 'react-router'; import InputUI from '../ui/Input'; import ButtonUI from '../ui/Button'; -import MessageBox from '../MessageBox'; import CheckboxUI from '../ui/Checkbox'; +import { customToast } from '../CustomToast'; +import { useAuthStore } from '../../context/stores/auth'; export default function RegisterForm() { - const [checked, setChecked] = useState(false); + const isLoading = useAuthStore(state => state.isLoading); const actionData = useActionData(); const errors = actionData?.errors || {}; const values = actionData?.values || {}; + // Mostra os erros e sucessos via toast + useEffect(() => { + if (!actionData) return; + + if (actionData.errors) { + Object.values(actionData.errors).forEach(err => { + const title = err?.title ?? 'Erro'; + const message = err?.message ?? 'Ocorreu um erro inesperado.'; + customToast(title, message, 'error'); + }); + return; + } + }, [actionData]); + + const fields = [ + { + id: 'name', + label: 'Nome completo', + type: 'text', + placeholder: 'Digite seu nome completo', + autoComplete: 'name', + name: 'name', + }, + { + id: 'email', + label: 'Email', + type: 'email', + placeholder: 'Digite seu email', + autoComplete: 'email', + name: 'email', + }, + { + id: 'password', + label: 'Senha', + type: 'password', + placeholder: 'Digite sua senha', + autoComplete: 'new-password', + name: 'password', + }, + { + id: 'confirmPassword', + label: 'Confirmar senha', + type: 'password', + placeholder: 'Confirme sua senha', + autoComplete: 'new-password', + name: 'confirmPassword', + }, + ]; + return (
    - {[ - { - id: 'full_name', - label: 'Nome completo', - type: 'text', - placeholder: 'Digite seu nome completo', - }, - { - id: 'email', - label: 'Email', - type: 'email', - placeholder: 'Digite seu email', - }, - { - id: 'password', - label: 'Senha', - type: 'password', - placeholder: 'Digite sua senha', - }, - { - id: 'confirm_password', - label: 'Confirmar senha', - type: 'password', - placeholder: 'Confirme sua senha', - }, - ].map(({ id, label, type, placeholder }) => ( + {/* Campos de input */} + {fields.map(({ id, label, type, placeholder, autoComplete }) => (
    ))} -
    ); } diff --git a/frontend/src/components/ui/Button.jsx b/frontend/src/components/ui/Button.jsx index cdf3ab4d..87acd632 100644 --- a/frontend/src/components/ui/Button.jsx +++ b/frontend/src/components/ui/Button.jsx @@ -1,24 +1,11 @@ import { Button } from '@headlessui/react'; -export default function ButtonUI({ - icon, // renomeando para poder usar como componente - title, - type = 'button', - ariaLabel, // corrigido typo de arialLabel para ariaLabel - className = '', - fnClick, - disabled = false, -}) { +export default function ButtonUI({ children, ...props }) { return ( - ); } diff --git a/frontend/src/components/ui/Card.jsx b/frontend/src/components/ui/Card.jsx index 48d19edf..041f9476 100644 --- a/frontend/src/components/ui/Card.jsx +++ b/frontend/src/components/ui/Card.jsx @@ -1,3 +1,3 @@ -export default function CardUI({ className, children }) { - return
    {children}
    ; +export default function CardUI(props) { + return
    {props.children}
    ; } diff --git a/frontend/src/components/ui/CurrencyInput.jsx b/frontend/src/components/ui/CurrencyInput.jsx new file mode 100644 index 00000000..f4de0beb --- /dev/null +++ b/frontend/src/components/ui/CurrencyInput.jsx @@ -0,0 +1,15 @@ +import CurrencyInput from 'react-currency-input-field'; + +export default function CurrencyInputUI(props) { + return ( + + ); +} diff --git a/frontend/src/components/ui/Input.jsx b/frontend/src/components/ui/Input.jsx index 00115051..178c8102 100644 --- a/frontend/src/components/ui/Input.jsx +++ b/frontend/src/components/ui/Input.jsx @@ -1,33 +1,13 @@ import { Input } from '@headlessui/react'; -export default function InputUI({ - id, - value, - name, - onChange, - type, - placeholder, - required, - step, - readonly, -}) { +export default function InputUI(props) { return ( ); } diff --git a/frontend/src/components/ui/Select.jsx b/frontend/src/components/ui/Select.jsx index c6c68b7d..73399aaa 100644 --- a/frontend/src/components/ui/Select.jsx +++ b/frontend/src/components/ui/Select.jsx @@ -1,40 +1,21 @@ import React from 'react'; -export default function SelectUI({ - id, - name, - value, - onChange, - defaultValue, - options = [], - required = false, - className = '', - ...rest -}) { +export default function SelectUI({ className = '', children, ...props }) { return ( ); } diff --git a/frontend/src/components/ui/Textarea.jsx b/frontend/src/components/ui/Textarea.jsx index e90e2570..a8bf31c3 100644 --- a/frontend/src/components/ui/Textarea.jsx +++ b/frontend/src/components/ui/Textarea.jsx @@ -1,13 +1,5 @@ import { Textarea } from '@headlessui/react'; -export default function TextareaUI({ id, name, rows, className, placeholder }) { - return ( - - ); +export default function TextareaUI(props) { + return ; } diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx deleted file mode 100644 index 7868955b..00000000 --- a/frontend/src/context/AuthContext.jsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function AuthContext() { - return
    AuthContext
    ; -} diff --git a/frontend/src/context/GroupContext.jsx b/frontend/src/context/GroupContext.jsx deleted file mode 100644 index e730c76c..00000000 --- a/frontend/src/context/GroupContext.jsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function GroupContext() { - return
    GroupContext
    ; -} diff --git a/frontend/src/context/ThemeContext.jsx b/frontend/src/context/ThemeContext.jsx deleted file mode 100644 index 11ed081c..00000000 --- a/frontend/src/context/ThemeContext.jsx +++ /dev/null @@ -1,34 +0,0 @@ -import { createContext, useContext, useEffect } from 'react'; -import { useLocalStorage } from '../hooks/useLocalStorage'; - -const KEY_THEME_CONTEXT = 'app_theme'; -const THEME_DEFAULT = 'light'; - -const ThemeContext = createContext(); - -export function ThemeProvider({ children }) { - const [theme, setTheme] = useLocalStorage(KEY_THEME_CONTEXT, THEME_DEFAULT); - - // Sempre que o tema mudar, aplicamos no - useEffect(() => { - document.documentElement.className = theme; - }, [theme]); - - const toggleTheme = () => { - setTheme(prev => (prev === 'light' ? 'dark' : 'light')); - }; - - return ( - - {children} - - ); -} - -export function useTheme() { - const context = useContext(ThemeContext); - if (!context) { - throw new Error('useTheme deve ser usado dentro de ThemeProvider'); - } - return context; -} diff --git a/frontend/src/context/stores/auth.js b/frontend/src/context/stores/auth.js new file mode 100644 index 00000000..8b0c86f1 --- /dev/null +++ b/frontend/src/context/stores/auth.js @@ -0,0 +1,167 @@ +import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; +import { login, register } from '../../services/auth'; +import { jwtDecode } from 'jwt-decode'; +import { customToast } from '../../components/CustomToast'; +import CookieStorage from 'zustand-persist-cookie-storage'; +import { SecureLS } from '../../utils/localStorageEncryption'; +import { queryClient } from '../../libs/ReactQuery/queryClient'; +import { REACTQUERY_KEYS } from '../../libs/ReactQuery/keys'; +import { getMe } from '../../services/me'; + +export const CustomCookieStorage = () => { + const base = CookieStorage({ expires: 7 }); // fallback padrão + + return { + setItem: (name, value) => { + const base64String = SecureLS.Base64.encode(value); + try { + const parsed = JSON.parse(value); + const exp = parsed?.state?.user?.exp; + + const expires = + typeof exp === 'number' && !isNaN(exp) + ? new Date(Date.now() + exp * 1000) + : new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // fallback 7 dias + // Encode em UTF-8 e depois em Base64 + + return CookieStorage({ expires }).setItem(name, base64String); + } catch (error) { + console.warn('[CustomCookieStorage] Erro ao definir item:', error); + return base.setItem(name, base64String); + } + }, + + getItem: async name => { + try { + const store = await CookieStorage().getItem(name); + return SecureLS.Base64.decode(store); + } catch (error) { + console.warn('[CustomCookieStorage] Erro ao obter item:', error); + return null; + } + }, + + removeItem: name => { + try { + return base.removeItem(name); + } catch (error) { + console.warn('[CustomCookieStorage] Erro ao remover item:', error); + } + }, + }; +}; + +export const useAuthStore = create()( + persist( + (set, get) => ({ + token: null, + refreshToken: null, + user: null, + isLoading: false, + setRefreshToken: ({ + access_token, + refresh_token, + token_type, + expires_in, + }) => { + const access_tokenDecoded = jwtDecode(access_token); + const { sub, authorities: roles } = access_tokenDecoded; + const newCredentials = { + token: `${token_type} ${access_token}`, + refreshToken: refresh_token, + }; + set(newCredentials); + + const user = { + sub, + roles, + exp: expires_in, + }; + set({ user }); + }, + login: async data => { + set({ isLoading: true }); + try { + const loginData = { + username: data.email, + password: data.password, + }; + + const response = await login(loginData); + const { value: jwtData } = response; + set({ + token: `${jwtData.token_type || ''} ${jwtData.access_token}`, + refreshToken: jwtData.refresh_token, + }); + + const access_tokenDecoded = jwtDecode(jwtData.access_token); + const { sub, authorities: roles } = access_tokenDecoded; + + const user = { + sub, + roles, + exp: jwtData.expires_in, + }; + set({ user }); + const currentUser = await queryClient.ensureQueryData({ + queryKey: [REACTQUERY_KEYS.USER.ME], + queryFn: getMe, + }); + + customToast( + 'Login realizado com sucesso!', + currentUser + ? `Seja bem-vindo(a), ${currentUser.name}! 🚀` + : `Seja bem-vindo(a) ao FinBoost! 💰`, + 'success' + ); + + return; + } catch (e) { + const { title, error } = e; + customToast(title, error, 'error'); + return; + } finally { + set({ isLoading: false }); + } + }, + register: async data => { + set({ isLoading: true }); + try { + await register(data); + customToast( + 'Tudo certo!', + `Seu cadastro foi concluído com sucesso 😄 + Foi enviado no seu email um link para ativação da conta`, + 'success' + ); + return; + } catch (e) { + const { title, error } = e; + customToast(title, error, 'error'); + return; + } finally { + set({ isLoading: false }); + } + }, + reset: () => { + set({ + token: null, + refreshToken: null, + user: null, + isLoading: false, + }); + }, + }), + { + name: 'finboost-auth', + storage: createJSONStorage(CustomCookieStorage), + partialize: state => ({ + token: state.token, + user: state.user, + refreshToken: state.refreshToken, + }), + } + ) +); diff --git a/frontend/src/context/stores/theme.js b/frontend/src/context/stores/theme.js new file mode 100644 index 00000000..fac1649d --- /dev/null +++ b/frontend/src/context/stores/theme.js @@ -0,0 +1,40 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +const KEY = 'app_theme'; + +export const useThemeStore = create( + persist( + set => ({ + theme: 'light', // usado apenas se não houver nada no localStorage + logo_image: '/light_mode_logo.png', + toggleTheme: () => + set(state => { + const newTheme = state.theme === 'light' ? 'dark' : 'light'; + const logo = { + dark: '/dark_mode_logo.png', + light: '/light_mode_logo.png', + }; + + // Atualiza a classe do HTML para refletir o novo tema + document.documentElement.className = newTheme; + + return { theme: newTheme, logo_image: logo[newTheme] }; + }), + }), + { + name: KEY, + // Hook chamado ao hidratar do storage + onRehydrateStorage: () => (state, error) => { + if (error) { + console.error('Falha ao reidratar o tema', error); + return; + } + if (state) { + // aplica classe e garante que logo corresponda + document.documentElement.className = state.theme; + } + }, + } + ) +); diff --git a/frontend/src/hooks/ReactQuery/Mutations/addMemberGroupMutation.js b/frontend/src/hooks/ReactQuery/Mutations/addMemberGroupMutation.js new file mode 100644 index 00000000..5072f0f7 --- /dev/null +++ b/frontend/src/hooks/ReactQuery/Mutations/addMemberGroupMutation.js @@ -0,0 +1,43 @@ +import { useMutation } from '@tanstack/react-query'; +import { REACTQUERY_KEYS } from '../../../libs/ReactQuery/keys'; +import { queryClient } from '../../../libs/ReactQuery/queryClient'; +import { addMemberGroup } from '../../../services/groups'; +import { customToast } from '../../../components/CustomToast'; + +export function addMemberGroupMutation(group_id) { + return useMutation({ + mutationFn: data => addMemberGroup(group_id, data), + onSuccess: response => { + // Invalida a lista de grupos para forçar um refetch + queryClient.invalidateQueries({ + queryKey: [REACTQUERY_KEYS.GROUPS.ALL], + }); + queryClient.invalidateQueries({ + queryKey: [REACTQUERY_KEYS.GROUPS.EXPENSES], + }); + queryClient.invalidateQueries([ + REACTQUERY_KEYS.GROUPS.MEMBERS, + Number(group_id), + ]); + + // Opcional: remover manualmente do cache local + queryClient.setQueryData([REACTQUERY_KEYS.GROUPS.ALL], oldData => { + if (!oldData) return oldData; + return { + ...oldData, + content: oldData.content.filter(group => group.id !== id), + totalElements: oldData.totalElements - 1, + }; + }); + + customToast('Adição de um novo membro', response, 'success'); + }, + onError: error => { + customToast( + 'Erro', + error.response.data.message || 'Erro ao convidar um novo membro grupo.', + 'error' + ); + }, + }); +} diff --git a/frontend/src/hooks/ReactQuery/Mutations/useCreateExpenseMutation.js b/frontend/src/hooks/ReactQuery/Mutations/useCreateExpenseMutation.js new file mode 100644 index 00000000..d35e7f0e --- /dev/null +++ b/frontend/src/hooks/ReactQuery/Mutations/useCreateExpenseMutation.js @@ -0,0 +1,40 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { REACTQUERY_KEYS } from '../../../libs/ReactQuery/keys'; +import { createExpense } from '../../../services/expenses'; + +export function useCreateExpenseMutation(groupID) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: expenseData => createExpense(groupID, expenseData), + + onSuccess: (_response, expenseData) => { + // 1. Invalida os detalhes do Grupo ESPECÍFICO (passando o ID) + queryClient.invalidateQueries({ + queryKey: [REACTQUERY_KEYS.GROUPS.ALL, 'detail', Number(groupID)], + }); + + // OU: + // 1. Invalida QUALQUER detalhe de grupo + // queryClient.invalidateQueries({ + // queryKey: [REACTQUERY_KEYS.GROUPS.ALL, 'detail'], + // }); + + // 2. Invalida as queries de EXPENSES com 'filters' + // Certifique-se de que a queryKey de leitura para despesas está correta. + // Se ela usa o groupID, inclua o ID aqui: + queryClient.invalidateQueries({ + queryKey: [REACTQUERY_KEYS.GROUPS.EXPENSES, 'filters', Number(groupID)], + }); + + queryClient.invalidateQueries({ + queryKey: [REACTQUERY_KEYS.GROUPS.ALL, 'page'], + }); + + // Se a chave de despesas é genérica: + // queryClient.invalidateQueries({ + // queryKey: [REACTQUERY_KEYS.GROUPS.EXPENSES], + // }); + }, + }); +} diff --git a/frontend/src/hooks/ReactQuery/Mutations/useDeleteExpenseByIdMutation.js b/frontend/src/hooks/ReactQuery/Mutations/useDeleteExpenseByIdMutation.js new file mode 100644 index 00000000..f63088c9 --- /dev/null +++ b/frontend/src/hooks/ReactQuery/Mutations/useDeleteExpenseByIdMutation.js @@ -0,0 +1,43 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { REACTQUERY_KEYS } from '../../../libs/ReactQuery/keys'; +import { customToast } from '../../../components/CustomToast'; +import { deleteGroupExpense } from '../../../services/groups'; + +export function useDeleteExpenseByIdMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ group_id, expense_id }) => { + return await deleteGroupExpense(group_id, expense_id); + }, + onSuccess: (_data, { group_id }) => { + queryClient.invalidateQueries({ + queryKey: [REACTQUERY_KEYS.GROUPS.EXPENSES], + }); + queryClient.invalidateQueries({ + queryKey: [REACTQUERY_KEYS.USER.EXPENSES, 'me'], + }); + queryClient.invalidateQueries({ + queryKey: [REACTQUERY_KEYS.GROUPS.ALL, 'detail', Number(group_id)], + }); + queryClient.invalidateQueries({ + queryKey: [REACTQUERY_KEYS.GROUPS.ALL, 'page'], + }); + + customToast( + 'Despesa removida', + 'A despesa foi excluída com sucesso do grupo.', + 'success' + ); + }, + onError: error => { + customToast( + 'Erro ao excluir despesa', + error?.response?.data?.message || + 'Não foi possível excluir a despesa. Tente novamente.', + 'error' + ); + }, + }); +} diff --git a/frontend/src/hooks/ReactQuery/Mutations/useDeleteGroupMemberMutation.js b/frontend/src/hooks/ReactQuery/Mutations/useDeleteGroupMemberMutation.js new file mode 100644 index 00000000..a33a0d80 --- /dev/null +++ b/frontend/src/hooks/ReactQuery/Mutations/useDeleteGroupMemberMutation.js @@ -0,0 +1,35 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { REACTQUERY_KEYS } from '../../../libs/ReactQuery/keys'; +import { customToast } from '../../../components/CustomToast'; +import { deleteGroupMember } from '../../../services/groups'; + +export function useDeleteGroupMemberMutation(groupID, page, search) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ group_id, member_id }) => { + return await deleteGroupMember(group_id, member_id); + }, + onSuccess: (_response, { group_id }) => { + queryClient.invalidateQueries({ + queryKey: [REACTQUERY_KEYS.MEMBERS.ALL], + }); + queryClient.invalidateQueries({ + queryKey: [REACTQUERY_KEYS.GROUPS.MEMBERS, group_id], + }); + queryClient.invalidateQueries({ + queryKey: [REACTQUERY_KEYS.GROUPS.ALL], + }); + customToast('Membro removido', 'Membro removido com sucesso.', 'success'); + }, + onError: error => { + customToast( + 'Erro ao excluir o membro do grupo', + error.response?.data?.message || + 'Não foi possível excluir o membro do grupo. Tente novamente.', + 'error' + ); + }, + }); +} diff --git a/frontend/src/hooks/ReactQuery/Mutations/useDeleteGroupMutation.js b/frontend/src/hooks/ReactQuery/Mutations/useDeleteGroupMutation.js new file mode 100644 index 00000000..da3d735d --- /dev/null +++ b/frontend/src/hooks/ReactQuery/Mutations/useDeleteGroupMutation.js @@ -0,0 +1,47 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { deleteGroup } from '../../../services/groups'; +import { REACTQUERY_KEYS } from '../../../libs/ReactQuery/keys'; +import { useNavigate } from 'react-router'; +import { customToast } from '../../../components/CustomToast'; + +export function useDeleteGroupMutation(id) { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: () => deleteGroup(id), + onSuccess: () => { + // Invalida a lista de grupos para forçar um refetch + queryClient.invalidateQueries({ + queryKey: [REACTQUERY_KEYS.GROUPS.ALL], + }); + queryClient.invalidateQueries({ + queryKey: [REACTQUERY_KEYS.GROUPS.EXPENSES], + }); + queryClient.invalidateQueries([ + REACTQUERY_KEYS.GROUPS.MEMBERS, + Number(id), + ]); + + // Opcional: remover manualmente do cache local + queryClient.setQueryData([REACTQUERY_KEYS.GROUPS.ALL], oldData => { + if (!oldData) return oldData; + return { + ...oldData, + content: oldData.content.filter(group => group.id !== id), + totalElements: oldData.totalElements - 1, + }; + }); + + customToast('Exclusão', 'Grupo excluído com sucesso', 'success'); + navigate('/groups'); + }, + onError: error => { + customToast( + 'Erro', + error.response.data.message || 'Erro ao excluir grupo.', + 'error' + ); + }, + }); +} diff --git a/frontend/src/hooks/ReactQuery/Mutations/useDeleteMeGroupByIdMutation.js b/frontend/src/hooks/ReactQuery/Mutations/useDeleteMeGroupByIdMutation.js new file mode 100644 index 00000000..a1e6a892 --- /dev/null +++ b/frontend/src/hooks/ReactQuery/Mutations/useDeleteMeGroupByIdMutation.js @@ -0,0 +1,45 @@ +import { useMutation } from '@tanstack/react-query'; +import { leaveGroup } from '../../../services/groups'; +import { queryClient } from '../../../libs/ReactQuery/queryClient'; +import { REACTQUERY_KEYS } from '../../../libs/ReactQuery/keys'; +import { customToast } from '../../../components/CustomToast'; +import { redirect } from 'react-router'; + +export function useDeleteMeGroupByIdMutation(group_id) { + return useMutation({ + mutationFn: () => leaveGroup(group_id), + onSuccess: () => { + // Invalida a lista de grupos para forçar um refetch + queryClient.invalidateQueries({ + queryKey: [REACTQUERY_KEYS.GROUPS.ALL, 'page'], + }); + queryClient.invalidateQueries({ + queryKey: [REACTQUERY_KEYS.GROUPS.ALL, 'detail', Number(group_id)], + }); + queryClient.invalidateQueries([ + REACTQUERY_KEYS.GROUPS.MEMBERS, + Number(id), + ]); + + // Opcional: remover manualmente do cache local + queryClient.setQueryData([REACTQUERY_KEYS.GROUPS.ALL], oldData => { + if (!oldData) return oldData; + return { + ...oldData, + content: oldData.content.filter(group => group.id !== id), + totalElements: oldData.totalElements - 1, + }; + }); + + customToast('Saída', 'Saída do grupo efetuada com sucesso', 'success'); + redirect('/groups'); + }, + onError: error => { + customToast( + 'Erro', + error.response.data.message || 'Erro ao sair do grupo.', + 'error' + ); + }, + }); +} diff --git a/frontend/src/hooks/ReactQuery/Mutations/useUpdateGroupExpenseByIdMutation.js b/frontend/src/hooks/ReactQuery/Mutations/useUpdateGroupExpenseByIdMutation.js new file mode 100644 index 00000000..872579c1 --- /dev/null +++ b/frontend/src/hooks/ReactQuery/Mutations/useUpdateGroupExpenseByIdMutation.js @@ -0,0 +1,44 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { updateExpenseDetails } from '../../../services/groups'; +import { REACTQUERY_KEYS } from '../../../libs/ReactQuery/keys'; +import { customToast } from '../../../components/CustomToast'; + +export function useUpdateGroupExpenseByIdMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + // 🔧 Recebe os parâmetros e o payload com os novos dados + mutationFn: async ({ group_id, expense_id, data }) => { + return await updateExpenseDetails(group_id, expense_id, data); + }, + + // ✅ Atualiza o cache após sucesso + onSuccess: (_response, { group_id, expense_id }) => { + queryClient.invalidateQueries({ + queryKey: [REACTQUERY_KEYS.GROUPS.EXPENSES], + }); + queryClient.invalidateQueries({ + queryKey: [REACTQUERY_KEYS.USER.EXPENSES, 'me'], + }); + queryClient.invalidateQueries({ + queryKey: [REACTQUERY_KEYS.GROUPS.ALL, 'detail', Number(group_id)], + }); + + customToast( + 'Despesa atualizada com sucesso', + 'As informações da despesa foram salvas corretamente.', + 'success' + ); + }, + + // ⚠️ Exibe mensagem de erro, caso algo dê errado + onError: error => { + console.error('Erro ao atualizar despesa:', error); + customToast( + 'Erro ao atualizar despesa', + 'Não foi possível salvar as alterações. Tente novamente.', + 'error' + ); + }, + }); +} diff --git a/frontend/src/hooks/ReactQuery/Mutations/useUpdateGroupMemberRoleMutation.js b/frontend/src/hooks/ReactQuery/Mutations/useUpdateGroupMemberRoleMutation.js new file mode 100644 index 00000000..3bdfbe51 --- /dev/null +++ b/frontend/src/hooks/ReactQuery/Mutations/useUpdateGroupMemberRoleMutation.js @@ -0,0 +1,40 @@ +updateRoleGroupMember; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { updateRoleGroupMember } from '../../../services/groups'; +import { REACTQUERY_KEYS } from '../../../libs/ReactQuery/keys'; +import { customToast } from '../../../components/CustomToast'; + +export function useUpdateRoleGroupMemberMutation(groupID, page, search) { + const queryClient = useQueryClient(); + + return useMutation({ + // 🔧 Recebe os parâmetros e o payload com os novos dados + mutationFn: async ({ group_id, member_id, newRole }) => { + return await updateRoleGroupMember(group_id, member_id, newRole); + }, + + // ✅ Atualiza o cache após sucesso + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: search + ? [REACTQUERY_KEYS.MEMBERS.ALL, 'page', Number(groupID), page, search] + : [REACTQUERY_KEYS.MEMBERS.ALL, 'page', Number(groupID), page], + }); + customToast( + 'Despesa atualizada com sucesso', + 'As informações da despesa foram salvas corretamente.', + 'success' + ); + }, + + // ⚠️ Exibe mensagem de erro, caso algo dê errado + onError: error => { + console.error('Erro ao definir um novo cargo para o membro:', error); + customToast( + 'Erro ao atualizar membro para um novo cargo', + 'Não foi possível salvar as alterações. Tente novamente.', + 'error' + ); + }, + }); +} diff --git a/frontend/src/hooks/ReactQuery/Mutations/useUpdateGroupMutation.js b/frontend/src/hooks/ReactQuery/Mutations/useUpdateGroupMutation.js new file mode 100644 index 00000000..1c571a50 --- /dev/null +++ b/frontend/src/hooks/ReactQuery/Mutations/useUpdateGroupMutation.js @@ -0,0 +1,38 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { updateGroup } from '../../../services/groups'; +import { REACTQUERY_KEYS } from '../../../libs/ReactQuery/keys'; +import { customToast } from '../../../components/CustomToast'; + +export function useUpdateGroupMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + // 🔧 Recebe os parâmetros e o payload com os novos dados + mutationFn: async ({ group_id, data }) => { + return await updateGroup(group_id, data); + }, + + // ✅ Atualiza o cache após sucesso + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [REACTQUERY_KEYS.GROUPS.ALL], + }); + + customToast( + 'Grupo atualizado!', + 'As informações do grupo foram alteradas com sucesso.', + 'success' + ); + }, + + // ⚠️ Exibe mensagem de erro, caso algo dê errado + onError: error => { + console.error('Erro ao atualizar dados do grupo:', error); + customToast( + 'Erro ao atualizar grupo', + 'Não foi possível salvar as alterações. Tente novamente.', + 'error' + ); + }, + }); +} diff --git a/frontend/src/hooks/ReactQuery/Mutations/useUpdateMeMutation.js b/frontend/src/hooks/ReactQuery/Mutations/useUpdateMeMutation.js new file mode 100644 index 00000000..350fe769 --- /dev/null +++ b/frontend/src/hooks/ReactQuery/Mutations/useUpdateMeMutation.js @@ -0,0 +1,19 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { REACTQUERY_KEYS } from '../../../libs/ReactQuery/keys'; +import { updateMe } from '../../../services/me'; +import { customToast } from '../../../components/CustomToast'; + +export function useUpdateMeMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: updateMe, + onSuccess: data => { + queryClient.setQueryData([REACTQUERY_KEYS.USER.ME], data); + queryClient.invalidateQueries([REACTQUERY_KEYS.USER.ME]); + }, + onError: error => { + customToast('Erro de atualização', error.message, 'error'); + }, + }); +} diff --git a/frontend/src/hooks/ReactQuery/Mutations/useUpdateStatusExpensePartialValueMutation.js b/frontend/src/hooks/ReactQuery/Mutations/useUpdateStatusExpensePartialValueMutation.js new file mode 100644 index 00000000..97499698 --- /dev/null +++ b/frontend/src/hooks/ReactQuery/Mutations/useUpdateStatusExpensePartialValueMutation.js @@ -0,0 +1,58 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { updatePartialValueExpenseStatus } from '../../../services/groups'; +import { REACTQUERY_KEYS } from '../../../libs/ReactQuery/keys'; +import { customToast } from '../../../components/CustomToast'; + +export function useUpdateStatusExpensePartialValueMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ group_id, expense_id, member_id }) => { + return await updatePartialValueExpenseStatus( + group_id, + expense_id, + member_id + ); + }, + onSuccess: (updatedMember, { group_id, expense_id, member_id }) => { + queryClient.invalidateQueries({ + queryKey: [REACTQUERY_KEYS.USER.EXPENSES, 'me'], + }); + queryClient.invalidateQueries({ + queryKey: [REACTQUERY_KEYS.GROUPS.ALL, 'detail', Number(group_id)], + }); + queryClient.invalidateQueries({ + queryKey: [REACTQUERY_KEYS.GROUPS.ALL, 'page'], + }); + + queryClient.invalidateQueries({ + queryKey: [ + REACTQUERY_KEYS.GROUPS.EXPENSES, + 'filters', + Number(group_id), + ], + }); + + queryClient.invalidateQueries({ + queryKey: [ + REACTQUERY_KEYS.GROUPS.EXPENSES, + 'groups', + Number(group_id), + Number(expense_id), + ], + }); + customToast( + 'Status atualizado', + 'O status da despesa do participante foi alterado com sucesso.', + 'success' + ); + }, + onError: () => { + customToast( + 'Erro ao atualizar', + 'Não foi possível alterar o status da despesa. Tente novamente.', + 'error' + ); + }, + }); +} diff --git a/frontend/src/hooks/ReactQuery/Queries/useAllGroupExpenseCategoriesQuery.js b/frontend/src/hooks/ReactQuery/Queries/useAllGroupExpenseCategoriesQuery.js new file mode 100644 index 00000000..c6ada9e0 --- /dev/null +++ b/frontend/src/hooks/ReactQuery/Queries/useAllGroupExpenseCategoriesQuery.js @@ -0,0 +1,12 @@ +import { useQuery } from '@tanstack/react-query'; +import { REACTQUERY_KEYS } from '../../../libs/ReactQuery/keys'; +import { getAllGroupsExpenseCategories } from '../../../services/groups'; + +export function useAllGroupExpenseCategoriesQuery() { + return useQuery({ + queryKey: [REACTQUERY_KEYS.EXPENSES.CATEGORIES], + queryFn: getAllGroupsExpenseCategories, + staleTime: Infinity, + placeholderData: [], + }); +} diff --git a/frontend/src/hooks/ReactQuery/Queries/useGroupExpenseByIdQuery.js b/frontend/src/hooks/ReactQuery/Queries/useGroupExpenseByIdQuery.js new file mode 100644 index 00000000..612a14fd --- /dev/null +++ b/frontend/src/hooks/ReactQuery/Queries/useGroupExpenseByIdQuery.js @@ -0,0 +1,18 @@ +import { useQuery } from '@tanstack/react-query'; +import { REACTQUERY_KEYS } from '../../../libs/ReactQuery/keys'; +import { getGroupExpenseById } from '../../../services/groups'; + +export function useGroupExpenseByIdQuery(group_id, expense_id) { + return useQuery({ + queryKey: [ + REACTQUERY_KEYS.GROUPS.EXPENSES, + 'groups', + Number(group_id), + Number(expense_id), + ], + enabled: !!group_id && !!expense_id, + queryFn: async () => await getGroupExpenseById(group_id, expense_id), + staleTime: Infinity, + placeholderData: previousData => previousData ?? [], + }); +} diff --git a/frontend/src/hooks/ReactQuery/Queries/useGroupExpensesQuery.js b/frontend/src/hooks/ReactQuery/Queries/useGroupExpensesQuery.js new file mode 100644 index 00000000..6519da13 --- /dev/null +++ b/frontend/src/hooks/ReactQuery/Queries/useGroupExpensesQuery.js @@ -0,0 +1,33 @@ +import { useQuery } from '@tanstack/react-query'; +import { REACTQUERY_KEYS } from '../../../libs/ReactQuery/keys'; +import { getGroupExpenses } from '../../../services/groups'; + +export function useGroupExpensesQuery(groupID, page, filter) { + // 1. Desestrutura as propriedades de filtro para uso no queryKey + // Assumimos que 'status' e 'allGroupMembersExpenses' são as chaves + // que podem vir do objeto 'filter'. + const { status, allGroupMembersExpenses } = filter; + + return useQuery({ + // 2. O queryKey agora inclui explicitamente o 'status' e 'allGroupMembersExpenses'. + // Uma mudança em qualquer um desses valores fará com que o array do queryKey mude, + // forçando o TanStack Query a fazer uma nova busca (refetch). + queryKey: [ + REACTQUERY_KEYS.GROUPS.EXPENSES, + 'filters', + Number(groupID), + page, + status, // Chave do filtro 1 + allGroupMembersExpenses, // Chave do filtro 2 (se existir) + , + ], + + // 3. A queryFn continua usando o objeto 'filter' completo para a chamada de API + queryFn: async () => await getGroupExpenses({ groupID, page, filter }), + staleTime: Infinity, + // Configurações mantidas + enabled: !!groupID, + /* staleTime: Infinity, */ + placeholderData: previousData => previousData, + }); +} diff --git a/frontend/src/hooks/ReactQuery/Queries/useGroupsQuery.js b/frontend/src/hooks/ReactQuery/Queries/useGroupsQuery.js new file mode 100644 index 00000000..92710150 --- /dev/null +++ b/frontend/src/hooks/ReactQuery/Queries/useGroupsQuery.js @@ -0,0 +1,26 @@ +import { useQuery } from '@tanstack/react-query'; +import { REACTQUERY_KEYS } from '../../../libs/ReactQuery/keys'; +import { getGroupById, getGroups } from '../../../services/groups'; + +export function useGroupsQuery(page = 0) { + return useQuery({ + queryKey: [REACTQUERY_KEYS.GROUPS.ALL, 'page', page], + queryFn: async () => await getGroups(page), + staleTime: Infinity, + placeholderData: { + groups: [], + totalPages: 0, + groupsLength: 0, + }, + }); +} + +export function useGroupByIdQuery(id) { + return useQuery({ + queryKey: [REACTQUERY_KEYS.GROUPS.ALL, 'detail', Number(id)], + enabled: !!id, + queryFn: async () => await getGroupById(id), + staleTime: Infinity, + placeholderData: {}, + }); +} diff --git a/frontend/src/hooks/ReactQuery/Queries/useMeDashboardStatsQuery.js b/frontend/src/hooks/ReactQuery/Queries/useMeDashboardStatsQuery.js new file mode 100644 index 00000000..57e0c2fb --- /dev/null +++ b/frontend/src/hooks/ReactQuery/Queries/useMeDashboardStatsQuery.js @@ -0,0 +1,12 @@ +import { useQuery } from '@tanstack/react-query'; + +import { REACTQUERY_KEYS } from '../../../libs/ReactQuery/keys'; +import { getMeDashboardStats } from '../../../services/me'; + +export function useMeDashboardQuery() { + return useQuery({ + queryKey: [REACTQUERY_KEYS.USER.DASHBOARD], + queryFn: getMeDashboardStats, + placeholderData: [], + }); +} diff --git a/frontend/src/hooks/ReactQuery/Queries/useMeExpensesQuery.js b/frontend/src/hooks/ReactQuery/Queries/useMeExpensesQuery.js new file mode 100644 index 00000000..73a401f4 --- /dev/null +++ b/frontend/src/hooks/ReactQuery/Queries/useMeExpensesQuery.js @@ -0,0 +1,15 @@ +import { useQuery } from '@tanstack/react-query'; +import useMeQuery from './useMeQuery'; +import { REACTQUERY_KEYS } from '../../../libs/ReactQuery/keys'; +import { getMeExpenses } from '../../../services/me'; + +export default function useMeExpensesQuery(page) { + const { data: user } = useMeQuery(); + const userId = user?.id; + return useQuery({ + queryKey: [REACTQUERY_KEYS.USER.EXPENSES, 'me', Number(userId), page], + queryFn: async () => await getMeExpenses(page), + enabled: !!userId, + placeholderData: previousData => previousData, + }); +} diff --git a/frontend/src/hooks/ReactQuery/Queries/useMeQuery.js b/frontend/src/hooks/ReactQuery/Queries/useMeQuery.js new file mode 100644 index 00000000..5f7379fe --- /dev/null +++ b/frontend/src/hooks/ReactQuery/Queries/useMeQuery.js @@ -0,0 +1,13 @@ +import { useQuery } from '@tanstack/react-query'; +import { REACTQUERY_KEYS } from '../../../libs/ReactQuery/keys'; +import { getMe } from '../../../services/me'; + +export default function useMeQuery() { + return useQuery({ + queryKey: [REACTQUERY_KEYS.USER.ME], + queryFn: getMe, + staleTime: Infinity, + refetchInterval: false, + placeholderData: { name: '', themeColor: '' }, + }); +} diff --git a/frontend/src/hooks/ReactQuery/Queries/useMembersQuery.js b/frontend/src/hooks/ReactQuery/Queries/useMembersQuery.js new file mode 100644 index 00000000..eb13adb9 --- /dev/null +++ b/frontend/src/hooks/ReactQuery/Queries/useMembersQuery.js @@ -0,0 +1,16 @@ +import { useQuery } from '@tanstack/react-query'; + +import { REACTQUERY_KEYS } from '../../../libs/ReactQuery/keys'; +import { getGroupMembers } from '../../../services/groups'; + +//Membros de um grupo específico +export const useMembersQuery = (groupID, page, search) => { + return useQuery({ + queryKey: search + ? [REACTQUERY_KEYS.MEMBERS.ALL, 'page', Number(groupID), page, search] + : [REACTQUERY_KEYS.MEMBERS.ALL, 'page', Number(groupID), page], + queryFn: () => getGroupMembers({ groupId: groupID, page, search }), + placeholderData: [], + enabled: !!groupID, + }); +}; diff --git a/frontend/src/hooks/useAuth.js b/frontend/src/hooks/useAuth.js index 44780486..01e72b33 100644 --- a/frontend/src/hooks/useAuth.js +++ b/frontend/src/hooks/useAuth.js @@ -1,12 +1,57 @@ -// useAuth.js -import { useState, useEffect } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { login, register, logout, refreshToken } from '../services/auth'; export function useAuth() { - const [user, setUser] = useState(null); + const queryClient = useQueryClient(); - useEffect(() => { - // futura lógica de autenticação - }, []); + // login de usuario + const loginMutation = useMutation({ + mutationFn: ({ email, password }) => login(email, password), + onSuccess: (data) => { + queryClient.setQueryData(["user"], data.user); + queryClient.setQueryData(["token"], data.token); + }, + }); - return { user, setUser }; -} + // registro de usuario + const registerMutation = useMutation({ + mutationFn: ({ name, email, password }) => register(name, email, password), // ajustado + onSuccess: (data) => { + queryClient.setQueryData(["user"], data.user); + queryClient.setQueryData(["token"], data.token); + }, + }); + + // logout de usuario + const logoutMutation = useMutation({ + mutationFn: logout, + onSuccess: () => { + queryClient.removeQueries(["user"]); + queryClient.removeQueries(["token"]); + queryClient.invalidateQueries(); + }, + }); + + // atualizar token + const refreshTokenMutation = useMutation({ + mutationFn: refreshToken, + onSuccess: (newToken) => { + queryClient.setQueryData(["token"], newToken); + }, + }); + + return { + // Funções + register: registerMutation.mutateAsync, + login: loginMutation.mutateAsync, + logout: logoutMutation.mutateAsync, + refreshToken: refreshTokenMutation.mutateAsync, + + // Status + registerStatus: registerMutation.status, + loginStatus: loginMutation.status, + logoutStatus: logoutMutation.status, + refreshTokenStatus: refreshTokenMutation.status, + }; + +} \ No newline at end of file diff --git a/frontend/src/hooks/useExpenses.js b/frontend/src/hooks/useExpenses.js index 29cb38f1..a88d1976 100644 --- a/frontend/src/hooks/useExpenses.js +++ b/frontend/src/hooks/useExpenses.js @@ -1,12 +1,65 @@ -// useExpenses.js -import { useState, useEffect } from 'react'; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { getExpenses, addExpense, updateExpense, deleteExpense } from "../services/expenses"; -export function useExpenses() { - const [expenses, setExpenses] = useState([]); +export function useExpense() { + const queryClient = useQueryClient(); - useEffect(() => { - // futura lógica de despesas - }, []); + //busca todas as despesas + const { + data: expenses = [], + isLoading, + isError, + error, + refetch, + } = useQuery({ + queryKey: ["expenses"], + queryFn: getExpenses, + }); - return { expenses, setExpenses }; + // Adiciona nova despesa + const addMutation = useMutation({ + mutationFn: addExpense, + onSuccess: (newExpense) => { + queryClient.setQueryData(["expenses"], (old = []) => + [ + ...old, + newExpense, + ]); + }, + }); + + // Atualiza despesa existente + const updateMutation = useMutation({ + mutationFn: ({ id, expense }) => updateExpense(id, expense), + onSuccess: (updated) => { + queryClient.setQueryData(["expenses"], (old = []) => + old.map((exp) => + (exp.id === updated.id ? updated : exp)) + ); + }, + }); + + // Deleta despesa + const deleteMutation = useMutation({ + mutationFn: deleteExpense, + onSuccess: (deletedId) => { + queryClient.setQueryData(["expenses"], (old = []) => + old.filter((exp) => exp.id !== deletedId) + ); + }, + }); + + return { + expenses, + isLoading, + isError, + error, + refetch, + addExpense: addMutation.mutateAsync, + updateExpense: updateMutation.mutateAsync, + deleteExpense: deleteMutation.mutateAsync, + addStatus: addMutation.status, + updateStatus: updateMutation.status, + deleteStatus: deleteMutation.status, + }; } diff --git a/frontend/src/hooks/useGroups.js b/frontend/src/hooks/useGroups.js index f498eb5b..bdd64ec6 100644 --- a/frontend/src/hooks/useGroups.js +++ b/frontend/src/hooks/useGroups.js @@ -1,12 +1,56 @@ -// useGroups.js -import { useState, useEffect } from 'react'; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { getGroups, addGroup, updateGroup, deleteGroup } from "../services/groups"; -export function useGroups() { - const [groups, setGroups] = useState([]); +export function useGroup() { + const queryClient = useQueryClient(); - useEffect(() => { - // futura lógica de grupos - }, []); + // Buscar todos os grupos + const groupsQuery = useQuery({ + queryKey: ["groups"], + queryFn: getGroups, + staleTime: 1000 * 60 * 5, // 5 minutos em cache + }); - return { groups, setGroups }; + // Adiciona grupo + const addMutation = useMutation({ + mutationFn: addGroup, + onSuccess: (newGroup) => { + queryClient.setQueryData(["groups"], (old = []) => [...old, newGroup]); + }, + }); + + // Atualizar grupo + const updateMutation = useMutation({ + mutationFn: ({ id, group }) => updateGroup(id, group), + onSuccess: (updated) => { + queryClient.setQueryData(["groups"], (old = []) => + old.map((g) => (g.id === updated.id ? updated : g)) + ); + }, + }); + + // Deletar grupo + const deleteMutation = useMutation({ + mutationFn: deleteGroup, + onSuccess: (deletedId) => { + queryClient.setQueryData(["groups"], (old = []) => + old.filter((g) => g.id !== deletedId) + ); + }, + }); + + return { + // Query + ...groupsQuery, + + // Mutations + addGroup: addMutation.mutateAsync, + updateGroup: updateMutation.mutateAsync, + deleteGroup: deleteMutation.mutateAsync, + + // Status das mutations + addStatus: addMutation.status, + updateStatus: updateMutation.status, + deleteStatus: deleteMutation.status, + }; } diff --git a/frontend/src/hooks/useLogout.js b/frontend/src/hooks/useLogout.js new file mode 100644 index 00000000..c05a81d7 --- /dev/null +++ b/frontend/src/hooks/useLogout.js @@ -0,0 +1,32 @@ +import { useNavigate } from 'react-router'; +import { useAuthStore } from '../context/stores/auth'; +import { + localStoragePersistor, + queryClient, +} from '../libs/ReactQuery/queryClient'; + +export function useLogout() { + const navigate = useNavigate(); + const resetStore = useAuthStore(state => state.reset); + + const handleLogout = async (redirectTo = '/login') => { + try { + // Limpa estado em memória + resetStore?.(); + // Limpa estado persistido (se existir) + if (useAuthStore.persist?.clearStorage) { + useAuthStore.persist.clearStorage(); + } + queryClient.clear(); + await localStoragePersistor.removeClient(); + // Redireciona sempre para login + navigate(redirectTo, { replace: true }); + } catch (error) { + console.error('Erro ao realizar logout:', error); + // Em caso de erro, ainda tenta redirecionar + navigate(redirectTo, { replace: true }); + } + }; + + return handleLogout; +} diff --git a/frontend/src/hooks/useOnlineStatus.js b/frontend/src/hooks/useOnlineStatus.js new file mode 100644 index 00000000..0201b5d8 --- /dev/null +++ b/frontend/src/hooks/useOnlineStatus.js @@ -0,0 +1,21 @@ +// Hook personalizado para detectar status de conexão +import { useState, useEffect } from 'react'; + +export const useOnlineStatus = () => { + const [isOnline, setIsOnline] = useState(navigator.onLine); + + useEffect(() => { + const handleOnline = () => setIsOnline(true); + const handleOffline = () => setIsOnline(false); + + window.addEventListener('online', handleOnline); + window.addEventListener('offline', handleOffline); + + return () => { + window.removeEventListener('online', handleOnline); + window.removeEventListener('offline', handleOffline); + }; + }, []); + + return isOnline; +}; diff --git a/frontend/src/index.css b/frontend/src/index.css index 765e076f..4e2941a4 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,3 +1,3 @@ @import 'tailwindcss'; @import './styles/app.css'; -@import './styles/message-box.css'; +@import './styles/custom-toast.css'; diff --git a/frontend/src/libs/ReactQuery/keys.js b/frontend/src/libs/ReactQuery/keys.js new file mode 100644 index 00000000..9f0ba4b2 --- /dev/null +++ b/frontend/src/libs/ReactQuery/keys.js @@ -0,0 +1,19 @@ +export const REACTQUERY_KEYS = { + USER: { + ME: 'me', + EXPENSES: 'expenses', + DASHBOARD: 'dashboard', + }, + GROUPS: { + ALL: 'allGroups', + MEMBERS: 'members', + EXPENSES: 'groupExpenses', + ME_AUTHORITY: 'authority', + }, + EXPENSES: { + CATEGORIES: 'allCategories', + }, + MEMBERS: { + ALL: 'allMembers', + }, +}; diff --git a/frontend/src/libs/ReactQuery/main.jsx b/frontend/src/libs/ReactQuery/main.jsx new file mode 100644 index 00000000..3f590725 --- /dev/null +++ b/frontend/src/libs/ReactQuery/main.jsx @@ -0,0 +1,13 @@ +import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'; +import { localStoragePersistor, queryClient } from './queryClient'; + +export default function QCProvider({ children }) { + return ( + + {children} + + ); +} diff --git a/frontend/src/libs/ReactQuery/queryClient.js b/frontend/src/libs/ReactQuery/queryClient.js new file mode 100644 index 00000000..27db5f00 --- /dev/null +++ b/frontend/src/libs/ReactQuery/queryClient.js @@ -0,0 +1,42 @@ +import { QueryClient } from '@tanstack/react-query'; +import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister'; +import { SecureLS } from '../../utils/localStorageEncryption'; + +const refetchInterval = 180000; // 3 minutos + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + refetchInterval, + refetchOnWindowFocus: true, + refetchOnReconnect: true, + }, + mutations: { + retry: false, + }, + }, +}); + +export const localStoragePersistor = createAsyncStoragePersister({ + storage: { + getItem: key => { + try { + return SecureLS.get(key); + } catch { + return null; + } + }, + setItem: (key, value) => { + try { + SecureLS.set(key, value); + } catch (e) { + console.error('Erro ao criptografar cache:', e); + } + }, + removeItem: key => { + SecureLS.remove(key); + }, + }, + key: 'finboost-cache', +}); diff --git a/frontend/src/loaders/expenseLoader.js b/frontend/src/loaders/expenseLoader.js new file mode 100644 index 00000000..9412a2c5 --- /dev/null +++ b/frontend/src/loaders/expenseLoader.js @@ -0,0 +1,23 @@ +import { REACTQUERY_KEYS } from '../libs/ReactQuery/keys'; +import { queryClient } from '../libs/ReactQuery/queryClient'; +import { getGroupUserAuthenticatedAuthority } from '../services/groups'; +import { getMe } from '../services/me'; + +export async function expenseLoader({ params }) { + const { group_id } = params; + + // Pega o usuário logado + const user = await queryClient.ensureQueryData({ + queryKey: [REACTQUERY_KEYS.USER.ME], + queryFn: getMe, + staleTime: Infinity, + }); + + // Pega autoridade do usuário no grupo + const authority = await queryClient.ensureQueryData({ + queryKey: [REACTQUERY_KEYS.GROUPS.ME_AUTHORITY, group_id, user.id], + queryFn: () => getGroupUserAuthenticatedAuthority(group_id, user.id), + }); + + return authority; +} diff --git a/frontend/src/loaders/protectRoutersLoader.js b/frontend/src/loaders/protectRoutersLoader.js new file mode 100644 index 00000000..10ac6cc1 --- /dev/null +++ b/frontend/src/loaders/protectRoutersLoader.js @@ -0,0 +1,57 @@ +import { matchPath, redirect } from 'react-router'; +import { useAuthStore } from '../context/stores/auth'; +import { queryClient } from '../libs/ReactQuery/queryClient'; +import { REACTQUERY_KEYS } from '../libs/ReactQuery/keys'; +import { getGroupUserAuthenticatedAuthority } from '../services/groups'; +import { getMe } from '../services/me'; +import { ForbiddenError } from '../utils/errors'; + +export async function protectRoutersLoader({ request, params }) { + // 🔐 1. Proteção de login + await useAuthStore.persist.rehydrate(); + const isAuthenticated = !!useAuthStore.getState().token; + + if (!isAuthenticated) { + // Redireciona para login se não estiver autenticado + return redirect('/login'); + } + + // 🧩 2. Verifica se é rota de configuração de grupo + const { group_id } = params; + const url = new URL(request.url); + const path = url.pathname; + + // Só bloqueia /groups/:group_id/settings e subrotas + const match = matchPath(`/groups/${group_id}/settings/*`, path); + if (!match) return null; // rota livre → prossegue + + // 🧠 3. Obtém o usuário logado + const user = await queryClient.ensureQueryData({ + queryKey: [REACTQUERY_KEYS.USER.ME], + queryFn: getMe, + staleTime: Infinity, + }); + + // 👑 4. Obtém a autoridade do usuário no grupo + const authority = await queryClient.ensureQueryData({ + queryKey: [REACTQUERY_KEYS.GROUPS.ME_AUTHORITY, group_id, user.id], + queryFn: () => getGroupUserAuthenticatedAuthority(group_id, user.id), + staleTime: Infinity, + }); + + // 🚫 5. Se o usuário não for o dono, lança erro customizado + if (authority !== 'OWNER') { + const basePath = `/groups/${group_id}`; + const btn = { label: 'Voltar para o grupo', path: basePath }; + + // Lança erro customizado que será capturado pelo React Router + throw new ForbiddenError( + 'Somente o dono do grupo pode acessar as configurações.', + 403, + btn + ); + } + + // ✅ 6. Permite prosseguir se for OWNER + return null; +} diff --git a/frontend/src/loaders/redirectIfAuthenticatedLoader.js b/frontend/src/loaders/redirectIfAuthenticatedLoader.js new file mode 100644 index 00000000..387b88d0 --- /dev/null +++ b/frontend/src/loaders/redirectIfAuthenticatedLoader.js @@ -0,0 +1,21 @@ +import { redirect } from 'react-router'; +import { useAuthStore } from '../context/stores/auth'; + +export async function redirectIfAuthenticatedLoader({ request }) { + await useAuthStore.persist.rehydrate(); + + const { token } = useAuthStore.getState(); + const isAuthenticated = !!token; + + const url = new URL(request.url); + const path = url.pathname; + const publicRoutes = ['/login', '/register']; + + const isPublicRoute = publicRoutes.includes(path); + + if (isAuthenticated && isPublicRoute) { + return redirect('/'); + } + + return null; +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 5df7aa47..a3835038 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -2,13 +2,50 @@ import './index.css'; import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { RouterProvider } from 'react-router'; -import { routes } from './routes/routes.jsx'; -import { ThemeProvider } from './context/ThemeContext.jsx'; +import { appRouter } from './routes/routes.jsx'; +import { MdCheckCircle, MdError, MdInfo, MdWarning } from 'react-icons/md'; +import { ToastContainer } from 'react-toastify'; +import QCProvider from './libs/ReactQuery/main.jsx'; + +// Registrar o service worker da PWA +if ('serviceWorker' in navigator) { + window.addEventListener('load', () => { + navigator.serviceWorker + .register('/sw.js') + .then(registration => { + console.log('SW registered successfully:', registration.scope); + }) + .catch(registrationError => { + console.log('SW registration failed:', registrationError); + }); + }); +} createRoot(document.getElementById('root')).render( - - - + + + { + const baseClass = 'text-4xl'; // Tamanho padrão para todos + switch (type) { + case 'info': + return ; + case 'error': + return ; + case 'success': + return ; + + case 'warning': + return ; + default: + return null; + } + }} + /> + ); diff --git a/frontend/src/mockData/categories/index.js b/frontend/src/mockData/categories/index.js new file mode 100644 index 00000000..684982a3 --- /dev/null +++ b/frontend/src/mockData/categories/index.js @@ -0,0 +1,17 @@ +export default [ + { id: 1, name: 'Alimentação' }, + { id: 2, name: 'Transporte' }, + { id: 3, name: 'Moradia' }, + { id: 4, name: 'Contas e Utilidades' }, // luz, água, internet + { id: 5, name: 'Saúde' }, + { id: 6, name: 'Educação' }, + { id: 7, name: 'Lazer e Entretenimento' }, + { id: 8, name: 'Compras e Serviços' }, + { id: 9, name: 'Viagens' }, + { id: 10, name: 'Assinaturas e Streaming' }, + { id: 11, name: 'Presentes e Doações' }, + { id: 12, name: 'Investimentos' }, + { id: 13, name: 'Impostos e Taxas' }, + { id: 14, name: 'Animais de Estimação' }, + { id: 15, name: 'Outros' }, +]; diff --git a/frontend/src/mockData/colorsPallete/colors.js b/frontend/src/mockData/colorsPallete/colors.js new file mode 100644 index 00000000..3a9fb21d --- /dev/null +++ b/frontend/src/mockData/colorsPallete/colors.js @@ -0,0 +1,24 @@ +export const avatarBackgroundColors = [ + '#ff69b4', + '#3B82F6', // Azul médio (confiança) + '#10B981', // Verde esmeralda (estabilidade, finanças) + '#8B5CF6', // Roxo vibrante (inovação) + '#F59E0B', // Âmbar (energia, destaque) + '#EF4444', // Vermelho suave (determinação) + '#14B8A6', // Ciano (tecnologia) + '#6366F1', // Indigo (profissionalismo) + '#EC4899', // Rosa (amigável, moderno) + '#0EA5E9', // Azul claro (tranquilidade) + '#22C55E', // Verde limão (otimismo) + '#A855F7', // Roxo (criativo) + '#EAB308', // Dourado (prosperidade) + '#F43F5E', // Rosa escuro (personalidade forte) + '#475569', // Cinza azulado (neutro elegante) + '#6B7280', // Cinza médio (fallback) +]; + +export function getavatarBackgroundColorRandom() { + const index = Math.floor(Math.random() * avatarBackgroundColors.length); + const color = avatarBackgroundColors[index]; + return color; +} diff --git a/frontend/src/mockData/groupIcons/icons.jsx b/frontend/src/mockData/groupIcons/icons.jsx new file mode 100644 index 00000000..7305983f --- /dev/null +++ b/frontend/src/mockData/groupIcons/icons.jsx @@ -0,0 +1,45 @@ +import { + MdFastfood, + MdDirectionsCar, + MdHome, + MdLightbulb, + MdHealthAndSafety, + MdSchool, + MdMovie, + MdShoppingCart, + MdFlightTakeoff, + MdSubscriptions, + MdCardGiftcard, + MdTrendingUp, + MdReceiptLong, + MdPets, + MdCategory, +} from 'react-icons/md'; + +export const categoryIcons = { + alimentacao: { icon: MdFastfood, color: '#FF7043' }, + transporte: { icon: MdDirectionsCar, color: '#42A5F5' }, + moradia: { icon: MdHome, color: '#8D6E63' }, + contas_utilidades: { icon: MdLightbulb, color: '#FFD54F' }, + saude: { icon: MdHealthAndSafety, color: '#66BB6A' }, + educacao: { icon: MdSchool, color: '#5C6BC0' }, + lazer_entretenimento: { icon: MdMovie, color: '#AB47BC' }, + compras_servicos: { icon: MdShoppingCart, color: '#EC407A' }, + viagens: { icon: MdFlightTakeoff, color: '#26C6DA' }, + assinaturas_streaming: { icon: MdSubscriptions, color: '#EF5350' }, + presentes_doacoes: { icon: MdCardGiftcard, color: '#F06292' }, + investimentos: { icon: MdTrendingUp, color: '#43A047' }, + impostos_taxas: { icon: MdReceiptLong, color: '#BDBDBD' }, + animais_estimacao: { icon: MdPets, color: '#FFA726' }, + outros: { icon: MdCategory, color: '#9E9E9E' }, +}; + +export function CategoryIcon({ categoryKey, size = 24, className }) { + const entry = categoryIcons[categoryKey]; + + if (!entry) return null; // Se a chave não existir, não renderiza nada + + const { icon: Icon, color } = entry; + + return ; +} diff --git a/frontend/src/mockData/user/dashboard.data.js b/frontend/src/mockData/user/dashboard.data.js index 66c726ed..513d3208 100644 --- a/frontend/src/mockData/user/dashboard.data.js +++ b/frontend/src/mockData/user/dashboard.data.js @@ -9,7 +9,7 @@ export function createDashboardData(userData) { return { totalBalance: 312.4, // você pode calcular com base nos grupos se quiser - totalMonthlySpent: 428.9, // idem + totalMonthlySpent: 0, // idem recentExpenses: getAllRecentExpenses(groups), chartsData, }; diff --git a/frontend/src/mockData/user/groups.data.jsx b/frontend/src/mockData/user/groups.data.jsx index c3ee9ca0..0cb29c77 100644 --- a/frontend/src/mockData/user/groups.data.jsx +++ b/frontend/src/mockData/user/groups.data.jsx @@ -5,12 +5,14 @@ export function createMockGroups(userId) { { id: 'grp-bffs', name: 'BFFs da Escola 💖', + description: + 'Grupo para dividir gastos de passeios e eventos com as melhores amigas.', ownerId: userId, createdAt: '2025-07-01T10:15:00', members: [ - { name: 'Luna Martins', color: '#ff69b4' }, - { name: 'Mel', color: '#9370DB' }, - { name: 'Bia', color: '#FFB6C1' }, + { name: 'Luna Martins', color: '#ff69b4', isAdmin: true }, + { name: 'Mel', color: '#9370DB', isAdmin: false }, + { name: 'Bia', color: '#FFB6C1', isAdmin: false }, ], status: 45.0, totalBalance: 45.0, @@ -31,12 +33,14 @@ export function createMockGroups(userId) { { id: 'grp-creative', name: 'Estúdio Criativo 🎨', + description: + 'Equipe de design e desenvolvimento para projetos criativos e colaborações.', ownerId: 'user-jade', createdAt: '2025-06-20T14:30:00', members: [ - { name: 'Luna Martins', color: '#ff69b4' }, - { name: 'Noah', color: '#4682B4' }, - { name: 'Jade', color: '#8A2BE2' }, + { name: 'Luna Martins', color: '#ff69b4', isAdmin: false }, + { name: 'Noah', color: '#4682B4', isAdmin: true }, + { name: 'Jade', color: '#8A2BE2', isAdmin: false }, ], status: 100.0, totalBalance: 100.0, @@ -57,11 +61,12 @@ export function createMockGroups(userId) { { id: 'grp-cafe', name: 'Mood Café ☕', + description: 'Para organizar encontros e gastos no nosso café favorito.', ownerId: userId, createdAt: '2025-07-15T09:00:00', members: [ - { name: 'Luna Martins', color: '#ff69b4' }, - { name: 'Tati', color: '#D2691E' }, + { name: 'Luna Martins', color: '#ff69b4', isAdmin: true }, + { name: 'Tati', color: '#D2691E', isAdmin: false }, ], status: 0.0, totalBalance: 0.0, @@ -80,13 +85,14 @@ export function createMockGroups(userId) { { id: 'grp-fam', name: 'Família Carvalho 🏡', + description: 'Gerenciar despesas e compras coletivas da família.', ownerId: 'user-mae', createdAt: '2025-05-10T16:45:00', members: [ - { name: 'Luna Martins', color: '#ff69b4' }, - { name: 'Mãe', color: '#DA70D6' }, - { name: 'Papai', color: '#4169E1' }, - { name: 'Sofia', color: '#FFD700' }, + { name: 'Luna Martins', color: '#ff69b4', isAdmin: false }, + { name: 'Mãe', color: '#DA70D6', isAdmin: true }, + { name: 'Papai', color: '#4169E1', isAdmin: false }, + { name: 'Sofia', color: '#FFD700', isAdmin: false }, ], status: 0.0, totalBalance: 0.0, diff --git a/frontend/src/pages/Expenses/ExpenseDetails/index.jsx b/frontend/src/pages/Expenses/ExpenseDetails/index.jsx new file mode 100644 index 00000000..341c32cc --- /dev/null +++ b/frontend/src/pages/Expenses/ExpenseDetails/index.jsx @@ -0,0 +1,382 @@ +import { useState, useEffect, useMemo } from 'react'; +import { TbPigMoney } from 'react-icons/tb'; +import { + EXPENSE_STATUS, + STATUS_COLORS, +} from '../../Groups/GroupDetails/statusExpense'; +import { Form, useLoaderData, useNavigate, useParams } from 'react-router'; +import { formatBRL, formatDateBR } from '../../../utils/formatters'; +import InputUI from '../../../components/ui/Input'; +import TextareaUI from '../../../components/ui/Textarea'; +import ButtonUI from '../../../components/ui/Button'; +import SelectUI from '../../../components/ui/Select'; +import { useGroupExpenseByIdQuery } from '../../../hooks/ReactQuery/Queries/useGroupExpenseByIdQuery'; +import { useAllGroupExpenseCategoriesQuery } from '../../../hooks/ReactQuery/Queries/useAllGroupExpenseCategoriesQuery'; +import { useUpdateStatusExpensePartialValueMutation } from '../../../hooks/ReactQuery/Mutations/useUpdateStatusExpensePartialValueMutation'; +import { useUpdateGroupExpenseByIdMutation } from '../../../hooks/ReactQuery/Mutations/useUpdateGroupExpenseByIdMutation'; +import { useDeleteExpenseByIdMutation } from '../../../hooks/ReactQuery/Mutations/useDeleteExpenseByIdMutation'; +import { getCurrentDate } from '../../../utils/helpers'; +import { ConfirmModal } from '../../../components/Modal'; +import { validateExpensePayload } from '../../../schemas/createNewExpense/updateExpenseForm'; +import { customToast } from '../../../components/CustomToast'; +import { MdOutlineFactCheck } from 'react-icons/md'; +import { NotFoundError } from '../../../utils/errors'; +import { usePermissions } from '../../Groups/GroupDetails/usePermissions'; +export default function ExpenseDetails() { + const [isOpenConfirmModal, setIsOpenConfirmModal] = useState(false); + const navigate = useNavigate(); + const { group_id, expense_id } = useParams(); + const loaderData = useLoaderData(); + const authority = loaderData; + const { canEditExpenses, canChangeStatusExpenses, canDeleteExpenses } = + usePermissions(authority); + const { + data: expense, + isLoading: isLoadingExpense, + isError, + } = useGroupExpenseByIdQuery(group_id, expense_id); + + if (isError) { + throw new NotFoundError('A despesa solicitada não foi encontrada.'); + } + + const { data: categories } = useAllGroupExpenseCategoriesQuery(); + const { mutateAsync: mutateStatus, isPending: isLoadingStatus } = + useUpdateStatusExpensePartialValueMutation(); + const { mutateAsync: updateExpense, isPending } = + useUpdateGroupExpenseByIdMutation(); + const { mutateAsync: deleteExpense, isPending: isDeleting } = + useDeleteExpenseByIdMutation(); + + const [formData, setFormData] = useState({ + title: '', + description: '', + categoryId: '', + deadlineDate: '', + }); + + useEffect(() => { + if (expense) { + setFormData({ + title: expense.title ?? '', + description: expense.description ?? '', + categoryId: expense.categoryId ?? '', + deadlineDate: expense.deadlineDate ?? '', + }); + } + }, [expense]); + + const handleChange = e => { + const { name, value } = e.target; + setFormData(prev => ({ ...prev, [name]: value })); + }; + + // Verifica se há alterações em relação à despesa original + const hasChanges = useMemo(() => { + if (!expense) return false; + return ( + formData.title.trim() !== '' && // título obrigatório + (formData.title !== expense.title || + formData.description !== (expense.description ?? '') || + formData.categoryId !== expense.categoryId || + formData.deadlineDate !== expense.deadlineDate) + ); + }, [formData, expense]); + + const handleSubmit = async e => { + e.preventDefault(); + if (!canEditExpenses || !hasChanges) return; + + try { + const data = { + title: formData.title, + description: formData.description, + categoryId: formData.categoryId, + deadlineDate: formData.deadlineDate, + }; + const validation = validateExpensePayload(data); + if (!validation.success) { + Object.entries(validation.errors).forEach(([title, message]) => + customToast(title, message, 'error') + ); + return; + } + await updateExpense({ group_id, expense_id, data }); + } catch (error) { + console.error(error); + } + }; + + const handleChangeStatusExpensePartialValue = async member_id => { + if (!canChangeStatusExpenses) return; + try { + await mutateStatus({ group_id, expense_id, member_id }); + } catch (error) { + console.error(error); + } + }; + + if (isLoadingExpense) return Carregado...; + + const color = STATUS_COLORS[expense?.status] || STATUS_COLORS.DEFAULT; + + return ( +
    + {/* Cabeçalho */} +
    +
    +
    + +
    +
    +
    +

    {expense.title}

    + + {EXPENSE_STATUS[expense.status] || 'DESCONHECIDO'} + +
    + {expense.memberList?.length > 0 && + (() => { + const totalPago = expense.memberList + .filter(m => m.status === 'PAID') + .reduce((acc, m) => acc + m.partialValue, 0); + const total = expense.total || 0; + const porcentagem = total > 0 ? (totalPago / total) * 100 : 0; + const allPaid = totalPago >= total; + + return ( +
    +
    + + {formatBRL(totalPago)} / {formatBRL(total)} + + + {allPaid + ? 'Pago totalmente ✅' + : `${Math.floor(porcentagem)}% pago`} + +
    +
    +
    +
    +
    + ); + })()} +
    +
    +

    + Criado em:{' '} + + {formatDateBR(expense.createdAt)} + +

    +
    + + {/* Informações */} +
    +

    + Informações da Despesa +

    +
    +
    +

    Grupo

    +

    + {expense.groupName} +

    +
    +
    +

    Total

    +

    + {formatBRL(expense.total)} +

    +
    +
    +

    Categoria Atual

    +

    + {expense.categoryName} +

    +
    +
    +
    + + {/* Campos editáveis */} + {canEditExpenses && ( +
    +

    + ✏️ Editar informações da despesa +

    +
    + + +
    +
    + + +
    +
    + ({ + value: c.id, + label: c.name, + }))} + > + {categories.map(({ id, name }) => ( + + ))} + +
    +
    + +
    + + {isPending ? 'Enviando...' : 'Salvar alterações'} + + + {canDeleteExpenses && ( + setIsOpenConfirmModal(true)} + className="px-6 py-2 font-semibold rounded-lg shadow-md bg-error text-white hover:bg-red-500 transition-all hover:scale-[1.02] active:scale-95 cursor-pointer" + > + {isDeleting ? 'Excluindo...' : 'Excluir despesa'} + + )} + + setIsOpenConfirmModal(false)} + onConfirm={async () => { + try { + await deleteExpense( + { group_id, expense_id }, + { onSuccess: () => navigate(`/groups/${group_id}`) } + ); + } catch (error) { + console.error(error); + } + }} + cancelLabel="Voltar" + confirmLabel="Excluir" + message="Tem certeza que deseja excluir esta despesa? Esta ação não pode ser desfeita." + /> +
    +
    +
    + )} + + {/* Participantes */} +
    +
    +

    + 👥 Participantes +

    + + Status da Despesa + + +
    + +
    + {expense.memberList?.map(member => { + const statusMap = { + PAID: { label: 'Pago' }, + PENDING: { label: 'Aguardando pagamento' }, + UNPAID: { label: 'Não pago' }, + }; + const currentStatus = statusMap[member.status] || statusMap.UNPAID; + + return ( +
    +
    +

    {member.userName}

    +

    + {formatBRL(member.partialValue)} —{' '} + + {currentStatus.label} + +

    +
    + + + handleChangeStatusExpensePartialValue(member.userId) + } + disabled={!canChangeStatusExpenses} + className={`flex min-w-[130px] disabled:cursor-auto cursor-pointer justify-center items-center gap-2 px-5 py-2 rounded-lg font-medium shadow-sm transition-all text-white ${STATUS_COLORS[member.status].bg}`} + type="button" + > + {isLoadingStatus + ? 'ALTERANDO...' + : EXPENSE_STATUS[member.status]} + +
    + ); + })} +
    +
    +
    + ); +} diff --git a/frontend/src/pages/Expenses/index.jsx b/frontend/src/pages/Expenses/index.jsx index baf699e9..96d7cb98 100644 --- a/frontend/src/pages/Expenses/index.jsx +++ b/frontend/src/pages/Expenses/index.jsx @@ -1,10 +1,9 @@ import ExpenseForm from '../../components/forms/ExpenseForm'; -export default function Expenses() { +export default function Expenses({ groupData }) { return ( -
    -

    Nova Despesa

    - +
    +
    ); } diff --git a/frontend/src/pages/Groups/GroupDetails/AddMember.jsx b/frontend/src/pages/Groups/GroupDetails/AddMember.jsx new file mode 100644 index 00000000..a3a4441b --- /dev/null +++ b/frontend/src/pages/Groups/GroupDetails/AddMember.jsx @@ -0,0 +1,94 @@ +import { useState } from 'react'; +import ButtonUI from '../../../components/ui/Button'; +import { Form } from 'react-router'; +import InputUI from '../../../components/ui/Input'; +import SelectUI from '../../../components/ui/Select'; + +export default function AddMemberForm({ onSubmit, onCancel }) { + const [email, setEmail] = useState(''); + const [authorization, setAuthorization] = useState('USER'); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleSubmit = async e => { + e.preventDefault(); + + setIsSubmitting(true); + + try { + await onSubmit({ email, authorization }); + /* setEmail(''); */ + setAuthorization('USER'); + } finally { + setIsSubmitting(false); + } + }; + + const isSubmitDisabled = !email || isSubmitting; + + return ( +
    +

    + Adicionar Membro +

    + +
    + + setEmail(e.target.value)} + placeholder="email@exemplo.com" + className="border border-border rounded-lg px-4 py-2 text-text focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary transition-shadow shadow-sm" + /> +
    + +
    + + setAuthorization(e.target.value)} + className="border border-border rounded-lg px-4 py-2 text-text focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary transition-shadow shadow-sm cursor-pointer" + > + + + +
    + +
    + {onCancel && ( + + Cancelar + + )} + + {isSubmitting ? 'Adicionando...' : 'Adicionar'} + +
    +
    + ); +} diff --git a/frontend/src/pages/Groups/GroupDetails/ExpensesList.jsx b/frontend/src/pages/Groups/GroupDetails/ExpensesList.jsx index 496e9462..aeb2284e 100644 --- a/frontend/src/pages/Groups/GroupDetails/ExpensesList.jsx +++ b/frontend/src/pages/Groups/GroupDetails/ExpensesList.jsx @@ -1,43 +1,200 @@ +import { useState, useMemo } from 'react'; +import { TbPigMoney } from 'react-icons/tb'; import { formatBRL } from '../../../utils/formatters'; +import { Link } from 'react-router'; -export default function ExpensesList({ group }) { +import SelectUI from '../../../components/ui/Select'; +import CheckboxUI from '../../../components/ui/Checkbox'; + +import Pagination from '../../../components/PaginationController'; + +import { usePermissions } from './usePermissions'; +import { useGroupExpensesQuery } from '../../../hooks/ReactQuery/Queries/useGroupExpensesQuery'; +import { EXPENSE_STATUS, STATUS_COLORS } from './statusExpense'; + +export default function ExpensesList({ groupID, authorization }) { + const [page, setPage] = useState(0); + const [status, setStatus] = useState('PENDING'); + const [allMembers, setAllMembers] = useState(false); + + const { canViewAllExpenses } = usePermissions(authorization); + + // 🧩 Filtros estabilizados para o React Query + const queryFilters = useMemo(() => { + const filters = { status }; + if (canViewAllExpenses && allMembers) + filters.allGroupMembersExpenses = true; + return filters; + }, [status, allMembers, canViewAllExpenses]); + + // 🔄 Query de despesas + const { data, isLoading } = useGroupExpensesQuery( + groupID, + page, + queryFilters + ); + + const expenses = data?.expenses ?? []; + const totalPages = data?.totalPages ?? 0; + const expensesLength = data?.expensesLength ?? 0; + + // 🪄 Handlers + const handleStatusChange = e => { + setStatus(e.target.value); + setPage(0); + }; + + const handleAllMembersChange = checked => { + setAllMembers(checked); + setPage(0); + }; + + // 🧾 UI return (
    -

    - Últimas despesas -

    -
      - -
    -
    - ); -} + {/* 🧭 Cabeçalho */} +
    +
    +
    + +
    -function ListExpensesItens({ group }) { - return ( - <> - {group.recentExpenses.map(expense => ( -
  • - - {expense.description} - - - - {formatBRL(expense.amount)} - -
  • - ))} - {group.recentExpenses.length === 0 && ( -

    Nenhuma despesa recente.

    +
    +

    + Despesas do Grupo +

    +

    + {allMembers ? 'De todos os membros' : 'Suas despesas'} +

    +
    +
    + + {/* Espaço opcional para botões, filtros, etc. */} + {/* Nova despesa */} +
    + + {/* 🔍 Filtros */} +
    +
    + + + + + +
    + + {canViewAllExpenses && ( +
    + +
    + )} +
    + + {/* 📜 Lista */} + {isLoading ? ( +

    + Carregando despesas... +

    + ) : expensesLength > 0 ? ( +
      + {expenses.map(expense => { + const color = + STATUS_COLORS[expense.status] || STATUS_COLORS.DEFAULT; + + return ( +
    • + + {/* 🏷️ Título e status */} +
      + + {expense.title} + {expense.status && ( + + {EXPENSE_STATUS[expense.status]} + + )} + +
      + + {/* 💰 Valor e informações */} +
      +
      + {/* Identificador da despesa */} + {expense.total ? ( + + Valor total da despesa + + ) : ( + + Sua parte da despesa + + )} +
      + {/* Valor principal */} + + {formatBRL( + allMembers ? expense.total : expense.partialValue + )} + + + {/* Prazo */} + + Prazo final:{' '} + {expense.deadlineDate + ? new Date(expense.deadlineDate).toLocaleDateString( + 'pt-BR' + ) + : 'Sem prazo'} + +
      + +
    • + ); + })} +
    + ) : ( +

    + Nenhuma despesa encontrada para os filtros selecionados. +

    )} - + + {/* 📄 Paginação */} + {totalPages > 1 && ( +
    + setPage(p => (p + 1 < totalPages ? p + 1 : p))} + onPrev={() => setPage(p => Math.max(p - 1, 0))} + /> +
    + )} +
    ); } diff --git a/frontend/src/pages/Groups/GroupDetails/GroupSettings/MembersManager.jsx b/frontend/src/pages/Groups/GroupDetails/GroupSettings/MembersManager.jsx new file mode 100644 index 00000000..63f67f0b --- /dev/null +++ b/frontend/src/pages/Groups/GroupDetails/GroupSettings/MembersManager.jsx @@ -0,0 +1,248 @@ +import { FiTrash2, FiChevronDown, FiUser, FiStar } from 'react-icons/fi'; +import { BiSolidCrown } from 'react-icons/bi'; +import { Menu, MenuButton, MenuItems } from '@headlessui/react'; +import { useState, useDeferredValue } from 'react'; +import InputUI from '../../../../components/ui/Input'; +import ButtonUI from '../../../../components/ui/Button'; +import Pagination from '../../../../components/PaginationController'; +import { useMembersQuery } from '../../../../hooks/ReactQuery/Queries/useMembersQuery'; +import { ConfirmModal } from '../../../../components/Modal'; +import { useUpdateRoleGroupMemberMutation } from '../../../../hooks/ReactQuery/Mutations/useUpdateGroupMemberRoleMutation'; +import { useDeleteGroupMemberMutation } from '../../../../hooks/ReactQuery/Mutations/useDeleteGroupMemberMutation'; + +export function MembersManager({ group }) { + const [confirmModal, setConfirmModal] = useState({ + isOpen: false, + message: '', + memberId: null, + newRole: null, + }); + + const [page, setPage] = useState(0); + const [search, setSearch] = useState(''); + const { mutateAsync } = useUpdateRoleGroupMemberMutation( + group.id, + page, + search + ); + const { mutateAsync: deleteMutateAsync } = useDeleteGroupMemberMutation(); + const deferredSearch = useDeferredValue(search); + + const { data, isLoading } = useMembersQuery(group?.id, page, deferredSearch); + + // Extrai dados da query + const membersRaw = data?.members ?? []; + const totalPages = data?.totalPages ?? 0; + const size = data?.size ?? 0; + + // ✅ Coloca o dono do grupo no topo da lista + const members = [...membersRaw].sort((a, b) => { + if (a.authority === 'OWNER') return -1; + if (b.authority === 'OWNER') return 1; + return 0; + }); + + // Atualiza o cargo do membro no servidor + const handleChangeRole = async (memberId, newRole) => { + try { + const payload = { group_id: group?.id, member_id: memberId, newRole }; + await mutateAsync(payload); + } catch (err) { + console.error(err); + } + }; + + // Confirma a troca de cargo + const handleConfirmChangeRole = async () => { + if (!confirmModal.newRole) { + await handleRemoveMember(confirmModal.memberId); + setConfirmModal({ + isOpen: false, + message: '', + memberId: null, + newRole: null, + }); + } + if (confirmModal.memberId && confirmModal.newRole) { + await handleChangeRole(confirmModal.memberId, confirmModal.newRole); + setConfirmModal({ + isOpen: false, + message: '', + memberId: null, + newRole: null, + }); + } + }; + + // Remove membro + const handleRemoveMember = async memberId => { + try { + const payload = { group_id: group.id, member_id: memberId }; + await deleteMutateAsync(payload); + } catch (err) { + console.error(err); + } + }; + + // Paginação + const handleNextPage = () => { + if (page + 1 < totalPages) setPage(prev => prev + 1); + }; + const handlePrevPage = () => { + if (page > 0) setPage(prev => prev - 1); + }; + + if (isLoading) { + return
    Carregando...
    ; + } + + return ( +
    +
    +

    Gerenciar membros

    +

    + Promova, rebaixe ou remova membros do grupo. +

    +
    + + setSearch(e.target.value)} + className="mb-6 rounded-xl focus:ring-2 focus:ring-primary/40" + /> + + {members.length === 0 ? ( +

    Nenhum membro encontrado.

    + ) : ( +
      + {members.map(member => { + const roleOptions = { + OWNER: [ + { value: 'ADMIN', label: 'Administrador', icon: }, + { value: 'USER', label: 'Membro', icon: }, + ], + ADMIN: [ + { value: 'OWNER', label: 'Dono', icon: }, + { value: 'USER', label: 'Membro', icon: }, + ], + USER: [ + { value: 'OWNER', label: 'Dono', icon: }, + { value: 'ADMIN', label: 'Administrador', icon: }, + ], + }[member.authority]; + + return ( +
    • +
      +
      + {member.name[0]} +
      +
      +

      + {member.name} + {member.authority === 'OWNER' && ( + + )} +

      +

      + {member.authority === 'OWNER' + ? 'Dono' + : member.authority === 'ADMIN' + ? 'Administrador' + : 'Membro'} +

      +
      +
      + +
      + + + + + + + {roleOptions.map(option => ( + + setConfirmModal({ + isOpen: true, + message: `Tem certeza que deseja definir ${member.name} como ${option.label}?`, + memberId: member.id, + newRole: option.value, + }) + } + className="w-full cursor-pointer text-left px-4 py-2 text-sm hover:bg-white/10 flex items-center gap-2" + > + {option.icon} {option.label} + + ))} + + + + {member.authority !== 'OWNER' && ( + { + setConfirmModal({ + isOpen: true, + message: `Tem certeza que deseja remover ${member.name} do grupo?`, + memberId: member.id, + newRole: null, + }); + }} + className="p-2 cursor-pointer rounded-full bg-red-100/80 hover:bg-red-200 text-red-600 transition" + title="Remover membro" + > + + + )} +
      +
    • + ); + })} +
    + )} + + {members.length > size && ( +
    + +
    + )} + + {/* Modal de confirmação */} + + setConfirmModal({ + isOpen: false, + message: '', + memberId: null, + newRole: null, + }) + } + onConfirm={handleConfirmChangeRole} + cancelLabel="Não" + confirmLabel="Sim" + message={confirmModal.message} + /> +
    + ); +} diff --git a/frontend/src/pages/Groups/GroupDetails/groupDetailsLoader.js b/frontend/src/pages/Groups/GroupDetails/GroupSettings/groupSettingsLoader.js similarity index 64% rename from frontend/src/pages/Groups/GroupDetails/groupDetailsLoader.js rename to frontend/src/pages/Groups/GroupDetails/GroupSettings/groupSettingsLoader.js index 10d4fdc4..a60073f0 100644 --- a/frontend/src/pages/Groups/GroupDetails/groupDetailsLoader.js +++ b/frontend/src/pages/Groups/GroupDetails/GroupSettings/groupSettingsLoader.js @@ -1,9 +1,8 @@ -import userData from '../../../mockData/user/user.data'; +import userData from '../../../../mockData/user/user.data'; -export async function groupDetailsLoader({ params }) { +export async function groupSettingsLoader({ params }) { const groupId = params['group-id']; const group = userData.groups.find(({ id }) => id === groupId); - if (!group) { throw new Response('Grupo não encontrado', { status: 404 }); } diff --git a/frontend/src/pages/Groups/GroupDetails/GroupSettings/index.jsx b/frontend/src/pages/Groups/GroupDetails/GroupSettings/index.jsx new file mode 100644 index 00000000..0c912df1 --- /dev/null +++ b/frontend/src/pages/Groups/GroupDetails/GroupSettings/index.jsx @@ -0,0 +1,200 @@ +import { useState, useEffect } from 'react'; +import { Link, useParams } from 'react-router'; +import { + FiArrowLeft, + FiEdit3, + FiFileText, + FiSave, + FiUserPlus, + FiUsers, +} from 'react-icons/fi'; + +import InputUI from '../../../../components/ui/Input'; +import TextareaUI from '../../../../components/ui/Textarea'; +import ButtonUI from '../../../../components/ui/Button'; +import Modal from '../../../../components/Modal'; +import { MembersManager } from './MembersManager'; +import { useGroupByIdQuery } from '../../../../hooks/ReactQuery/Queries/useGroupsQuery'; +import { useUpdateGroupMutation } from '../../../../hooks/ReactQuery/Mutations/useUpdateGroupMutation'; +import { CategoryIcon } from '../../../../mockData/groupIcons/icons'; + +export default function GroupSettings() { + const { group_id } = useParams(); + const [isOpen, setIsOpen] = useState(false); + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [isChanged, setIsChanged] = useState(false); + + const { data: group, isLoading } = useGroupByIdQuery(group_id); + const { mutateAsync: updateGroup, isPending } = useUpdateGroupMutation(); + + // Inicializa os campos com os valores do grupo + useEffect(() => { + if (group) { + setName(group.name); + setDescription(group.description); + setIsChanged(false); // Inicialmente não há alterações + } + }, [group]); + + // Atualiza o estado de alteração + useEffect(() => { + if (!group) return; + const changed = + name.trim() !== group.name || description.trim() !== group.description; + setIsChanged(changed && name.trim() !== ''); + }, [name, description, group]); + + const handleSubmit = async e => { + e.preventDefault(); + if (!isChanged) return; // Segurança extra + + try { + await updateGroup({ + group_id, + data: { name: name.trim(), description: description.trim() }, + }); + setIsChanged(false); // Reset após salvar + } catch (error) { + console.error('Erro ao atualizar grupo:', error); + } + }; + + if (isLoading || !group) { + return ( +
    + Carregando informações do grupo... +
    + ); + } + + return ( +
    +
    + {/* Cabeçalho */} +
    +
    +
    +
    + +
    +

    + Configurações de{' '} + {group.name} +

    +

    + Defina as permissões, ajuste as regras e tenha controle total + sobre seu grupo. +

    +
    +
    + + {/* Formulário principal */} +
    + {/* Informações básicas */} +
    + } + > + setName(e.target.value)} + placeholder="Digite um novo nome" + className="transition-all focus:ring-2 focus:ring-primary/40 rounded-xl focus:shadow-md" + /> + + + } + > + setDescription(e.target.value)} + rows={4} + placeholder="Adicione uma breve descrição..." + className="w-full min-h-[100px] rounded-lg border border-border bg-background px-3 py-2 text-text transition-all duration-200 focus:border-primary focus:ring-2 focus:ring-primary/30 placeholder:text-muted resize-none focus:shadow-md" + /> + +
    + + {/* Gerenciamento de membros */} +
    + +

    + Gerenciar membros +

    +

    + Adicione, remova ou altere permissões dos membros do grupo. +

    + setIsOpen(true)} + className="flex cursor-pointer items-center justify-center gap-2 mt-4 px-5 py-3 rounded-xl bg-linear-to-tr from-primary/70 to-primary/50 text-white hover:from-primary/80 hover:to-primary/60 hover:scale-[1.03] transition-all " + > + + Abrir Gerenciador + + + setIsOpen(false)} + setIsOpen={setIsOpen} + autoCloseOnSuccess={false} + > + + +
    + + {/* Botões */} +
    + + + Voltar + + + + + {isPending ? 'Salvando...' : 'Salvar'} + +
    +
    +
    +
    + ); +} + +/* ---------- SUBCOMPONENTES ---------- */ +function FieldSection({ id, label, icon, children }) { + return ( +
    + + {children} +
    + ); +} diff --git a/frontend/src/pages/Groups/GroupDetails/index.jsx b/frontend/src/pages/Groups/GroupDetails/index.jsx index e658f461..df627a91 100644 --- a/frontend/src/pages/Groups/GroupDetails/index.jsx +++ b/frontend/src/pages/Groups/GroupDetails/index.jsx @@ -1,83 +1,243 @@ -import { useLoaderData } from 'react-router'; +import { useParams } from 'react-router'; import { useState } from 'react'; -import ButtonUI from '../../../components/ui/Button'; -import BalancesList from './BalancesList'; +import { + FiSettings, + FiEdit2, + FiTrash2, + FiLogOut, + FiUserPlus, +} from 'react-icons/fi'; +import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/react'; import ExpensesList from './ExpensesList'; -import ModalButton from '../../../components/ModalButton'; + import Expenses from '../../Expenses'; -import { formatBRL } from '../../../utils/formatters'; + +import { CategoryIcon } from '../../../mockData/groupIcons/icons'; +import Modal, { ConfirmModal } from '../../../components/Modal'; + +import { usePermissions } from './usePermissions'; +import { useGroupByIdQuery } from '../../../hooks/ReactQuery/Queries/useGroupsQuery'; +import { useDeleteGroupMutation } from '../../../hooks/ReactQuery/Mutations/useDeleteGroupMutation'; +import GroupFinancialStatus from '../GrupoFinancialStatus'; +import ButtonUI from '../../../components/ui/Button'; +import { NotFoundError } from '../../../utils/errors'; +import { useDeleteMeGroupByIdMutation } from '../../../hooks/ReactQuery/Mutations/useDeleteMeGroupByIdMutation'; +import AddMemberForm from './AddMember'; +import { addMemberGroupMutation } from '../../../hooks/ReactQuery/Mutations/addMemberGroupMutation'; export default function GroupDetails() { - const [showBalances, setShowBalances] = useState(true); - const group = useLoaderData(); + const [groupModalIsOpen, setGroupModalIsOpen] = useState(false); + const [showMemberModal, setShowMemberModal] = useState(false); + const { group_id } = useParams(); + const { data: group, isError } = useGroupByIdQuery(group_id); + const { mutateAsync: leaveGroupMutation } = + useDeleteMeGroupByIdMutation(group_id); + const { mutateAsync: addMemberMutation } = addMemberGroupMutation(group_id); + // Estado genérico do ConfirmModal + const [confirmModal, setConfirmModal] = useState({ + isOpen: false, + message: '', + confirmLabel: 'Confirmar', + onConfirm: () => {}, + }); + + if (isError) { + throw new NotFoundError( + 'O grupo que você tentou acessar não existe.', + 404, + { + label: 'Voltar para os grupos', + path: '/groups', + } + ); + } + + const { authorization } = group; + const { + canEditGroupInfo, + canAddMemberGroup, + canDeleteGroup, + canCreateExpenses, + } = usePermissions(authorization); + const deleteGroup = useDeleteGroupMutation(group_id); - const activeBtn = - 'bg-primary text-white hover:bg-primary/90 focus:ring-primary'; - const inactiveBtn = - 'bg-neutral text-text hover:bg-neutral/80 focus:ring-muted'; - const baseBtn = - 'cursor-pointer font-medium py-2 px-4 sm:px-6 text-sm sm:text-base rounded-full transition duration-200 focus:outline-none focus:ring-2 focus:ring-opacity-50'; + function openConfirmModal({ message, confirmLabel, onConfirm }) { + setConfirmModal({ isOpen: true, message, confirmLabel, onConfirm }); + } + + async function handleLeaveGroup() { + try { + await leaveGroupMutation(); + } catch {} + } return ( -
    +
    -
    -

    - {group.name} -

    -
    + {/* Modal genérico */} + setConfirmModal(prev => ({ ...prev, isOpen: false }))} + onConfirm={() => { + confirmModal.onConfirm(); + setConfirmModal(prev => ({ ...prev, isOpen: false })); + }} + confirmLabel={confirmModal.confirmLabel} + message={confirmModal.message} + /> -
    -

    - Total do grupo -

    - -
    -

    +

    +

    - {formatBRL(group.totalBalance)} + + {group?.name} +

    +

    + Visualize os saldos entre membros e acompanhe as despesas do + grupo.

    -

    acumulado do mês

    -
    - setShowBalances(true)} - title="Saldos" - className={`${baseBtn} ${showBalances ? activeBtn : inactiveBtn}`} - aria-label="Ver saldos entre os membros" - /> - setShowBalances(false)} - title="Despesas" - className={`${baseBtn} ${ - !showBalances ? activeBtn : inactiveBtn - }`} - aria-label="Ver despesas recentes do grupo" - /> -
    -
    + {/* Menu de ações */} + + + + + + + {canEditGroupInfo && ( + + {({ active }) => ( + + + Configurações + + )} + + )} + + {canAddMemberGroup && ( + + {({ active }) => ( + + )} + + )} + + {canDeleteGroup && ( + + {({ active }) => ( + + )} + + )} + + + {({ active }) => ( + + )} + + + + + + - {showBalances ? ( - - ) : ( - - )} + {/* Conteúdo principal */} +
    + +
    - } /> + {/* Modal de adicionar despesa */} + {canCreateExpenses && ( + <> + setGroupModalIsOpen(true)} + className="fixed z-10 cursor-pointer bottom-5 right-6 bg-primary font-bold text-3xl text-white px-4 py-2 rounded-lg shadow hover:bg-primary/90 transition" + > + + + + + setGroupModalIsOpen(false)} + isOpen={groupModalIsOpen} + setIsOpen={setGroupModalIsOpen} + children={} + /> + + )} + + {/* Modal genérico para adicionar membro (vazio por enquanto) */} + setShowMemberModal(false)} + autoCloseOnSuccess={false} + children={ + { + // Aqui você chamaria sua mutation para adicionar membro + try { + await addMemberMutation({ email, authorization }); + } catch {} + console.log({ email, authorization }); + }} + onCancel={() => setShowMemberModal(false)} + /> + } + />
    ); } diff --git a/frontend/src/pages/Groups/GroupDetails/statusExpense.js b/frontend/src/pages/Groups/GroupDetails/statusExpense.js new file mode 100644 index 00000000..0898ec98 --- /dev/null +++ b/frontend/src/pages/Groups/GroupDetails/statusExpense.js @@ -0,0 +1,24 @@ +export const EXPENSE_STATUS = { + PENDING: 'PENDENTE', + UNPAID: 'NÃO PAGO', + PAID: 'PAGO', +}; + +export const STATUS_COLORS = { + PAID: { + bg: 'bg-success text-green-900', + text: 'text-green-500', + }, + PENDING: { + bg: 'bg-warning text-yellow-900', + text: 'text-yellow-500', + }, + UNPAID: { + bg: 'bg-error text-red-900', + text: 'text-red-500', + }, + DEFAULT: { + bg: 'text-text', + text: 'text-text', + }, +}; diff --git a/frontend/src/pages/Groups/GroupDetails/usePermissions.js b/frontend/src/pages/Groups/GroupDetails/usePermissions.js new file mode 100644 index 00000000..8424ec6b --- /dev/null +++ b/frontend/src/pages/Groups/GroupDetails/usePermissions.js @@ -0,0 +1,35 @@ +import { hasPermission } from '../../../utils/constants'; + +export function usePermissions(role) { + if (!role) { + return { + canViewOwnExpenses: false, + canViewAllExpenses: false, + canCreateExpenses: false, + canEditExpenses: false, + canEditExpenseValue: false, + canDeleteExpenses: false, + canChangeStatusExpenses: false, + canManageMembers: false, + canEditGroupInfo: false, + canDeleteGroup: false, + canGenerateReports: false, + canAddMemberGroup: false, // nova permissão + }; + } + + return { + canViewOwnExpenses: hasPermission(role, 'VIEW_OWN_EXPENSES'), + canViewAllExpenses: hasPermission(role, 'VIEW_ALL_EXPENSES'), + canCreateExpenses: hasPermission(role, 'CREATE_EXPENSES'), + canEditExpenses: hasPermission(role, 'EDIT_EXPENSES'), + canEditExpenseValue: hasPermission(role, 'EDIT_EXPENSE_VALUE'), + canDeleteExpenses: hasPermission(role, 'DELETE_EXPENSES'), + canChangeStatusExpenses: hasPermission(role, 'CHANGE_STATUS_EXPENSES'), + canManageMembers: hasPermission(role, 'MANAGE_MEMBERS'), + canEditGroupInfo: hasPermission(role, 'EDIT_GROUP_INFO'), + canDeleteGroup: hasPermission(role, 'DELETE_GROUP'), + canGenerateReports: hasPermission(role, 'GENERATE_REPORTS'), + canAddMemberGroup: hasPermission(role, 'ADD_GROUP_MEMEBER'), // lógica adicionada + }; +} diff --git a/frontend/src/pages/Groups/GrupoFinancialStatus.jsx b/frontend/src/pages/Groups/GrupoFinancialStatus.jsx new file mode 100644 index 00000000..df15cfba --- /dev/null +++ b/frontend/src/pages/Groups/GrupoFinancialStatus.jsx @@ -0,0 +1,103 @@ +import { TbCurrencyDollar } from 'react-icons/tb'; + +import { TbCoin, TbUserDollar } from 'react-icons/tb'; +import { formatBRL } from '../../utils/formatters'; + +export default function GroupFinancialStatus({ group, variant = 'compact' }) { + const isPositive = group?.total <= 0; + const colorClass = isPositive ? 'text-success' : 'text-error'; + const bgClass = isPositive + ? 'bg-success/5 border-success/20' + : 'bg-error/5 border-error/20'; + + if (variant === 'compact') { + return ( +
    + + + + {formatBRL(group?.total, { + style: 'decimal', + notation: 'compact', + })} + + + + + + + {formatBRL(group?.partial_total, { + style: 'decimal', + notation: 'compact', + })} + + +
    + ); + } + + // --- DETAILED VARIANT --- + return ( +
    +

    + Total de Despesas do Grupo +

    + +
    +

    + {formatBRL(group?.total)} +

    + +

    + {' '} + Sua parte: {formatBRL(group?.partial_total ?? 0)} +

    +
    + {group.description && ( + + )} +
    + ); +} + +function GroupDescription({ description }) { + return ( +
    + {/* Cabeçalho */} +
    +
    + +
    +

    + Descrição +

    +
    + + {/* Corpo */} +
    +

    + {description} +

    +
    +
    + ); +} diff --git a/frontend/src/pages/Groups/index.jsx b/frontend/src/pages/Groups/index.jsx index 3e646866..ce74dffd 100644 --- a/frontend/src/pages/Groups/index.jsx +++ b/frontend/src/pages/Groups/index.jsx @@ -1,34 +1,86 @@ -import { useState } from 'react'; +import { useState, useMemo } from 'react'; +import { Link, useSearchParams } from 'react-router'; import GroupFilters from '../../components/Filters/Groups'; import { useFilteredGroups } from '../../components/Filters/Groups/useFilteredGroups'; import GroupForm from '../../components/forms/GroupForm'; -import ModalButton from '../../components/ModalButton'; import CardUI from '../../components/ui/Card'; -import userData from '../../mockData/user/user.data'; -import { formatBRL } from '../../utils/formatters'; +import { CategoryIcon } from '../../mockData/groupIcons/icons'; +import Pagination from '../../components/PaginationController'; +import useMeQuery from '../../hooks/ReactQuery/Queries/useMeQuery'; +import { useGroupsQuery } from '../../hooks/ReactQuery/Queries/useGroupsQuery'; +import useGroupsWithMembers from './useGroupsWithMembers'; +import ButtonUI from '../../components/ui/Button'; +import Modal from '../../components/Modal'; +import GroupFinancialStatus from './GrupoFinancialStatus'; + export default function Groups() { - const current_user = userData; + const [searchParams] = useSearchParams(); + const createModal = searchParams.has('create'); + const [groupModalIsOpen, setGroupModalIsOpen] = useState( + createModal ?? false + ); + const { data: user } = useMeQuery(); + const [page, setPage] = useState(0); + const membersLengthToShow = 4; + + const { + data: { groups, totalPages, groupsLength }, + isLoading, + isFetching, + } = useGroupsQuery(page); + + const membersMap = useGroupsWithMembers(groups); + + // filtros locais const [filters, setFilters] = useState({ search: '', onlyOwner: false, - sortOrder: 'desc', //desc ou asc + sortOrder: 'desc', }); - const filteredGroups = useFilteredGroups( - current_user.groups, - current_user.id, - filters - ); + const filteredGroups = useFilteredGroups(groups, 'OWNER', filters); + const totalGroups = groupsLength; + const totalFiltered = filteredGroups.length; + + const title = useMemo(() => { + if (filters.search || filters.onlyOwner) + return `Grupos (${totalFiltered}/${totalGroups})`; + return `Meus grupos (${totalGroups})`; + }, [filters, totalFiltered, totalGroups]); + + if (isLoading) + return
    Carregando grupos...
    ; return (
    -
    -

    - Meus grupos ({current_user.groups.length}) + {/* Cabeçalho */} +
    +

    + {title} + {isFetching && ( + + (atualizando...) + + )}

    + + setGroupModalIsOpen(true)} + className="fixed z-10 cursor-pointer bottom-5 right-6 bg-primary font-bold text-3xl text-white px-4 py-2 rounded-lg shadow hover:bg-primary/90 transition" + > + + + + + setGroupModalIsOpen(false)} + isOpen={groupModalIsOpen} + setIsOpen={setGroupModalIsOpen} + children={} + />
    + {/* Filtros */} -
    - {filteredGroups.map(group => ( - - -
    - {group.icon} -

    {group.name}

    -
    - -

    - {group.members.length} membro - {group.members.length > 1 ? 's' : ''} -

    - -
    - } /> + + {/* Nome + Ícone */} +
    + + + +

    {group.name}

    +
    + + {/* Quantidade de membros */} +

    + {membersLoading + ? 'Carregando membros...' + : `${membersLength} membro${membersLength > 1 ? 's' : ''}`} +

    + + {/* Avatares */} +
    + {!membersLoading && membersData.length > 0 && ( + <> + {membersData + .slice(0, membersLengthToShow) + .map(({ name, themeColor }, idx) => ( + + {name[0]} + + ))} + + {membersLength > membersLengthToShow && ( + + +{membersLength - membersLengthToShow} + + )} + + )} +
    + + +
    + + ); + })} +
    + + {totalPages > 1 && ( + <> + {/* Paginação */} + + setPage(prev => Math.min(prev + 1, totalPages - 1)) + } + onPrev={() => setPage(prev => Math.max(prev - 1, 0))} + /> + + )} + + ) : ( +
    + {filters.search ? ( + <> +

    Nenhum grupo encontrado

    +

    + Tente buscar por outro nome ou limpar o filtro. +

    + + ) : ( + <> +

    + Você ainda não tem grupos +

    +

    + Crie um novo grupo para começar. +

    + + )} +
    + )} +

    ); diff --git a/frontend/src/pages/Groups/useGroupsWithMembers.jsx b/frontend/src/pages/Groups/useGroupsWithMembers.jsx new file mode 100644 index 00000000..9d965791 --- /dev/null +++ b/frontend/src/pages/Groups/useGroupsWithMembers.jsx @@ -0,0 +1,34 @@ +import { useQueries } from '@tanstack/react-query'; +import { REACTQUERY_KEYS } from '../../libs/ReactQuery/keys'; +import { getGroupMembers } from '../../services/groups'; + +//resumo dos membros na lista paginada +export default function useGroupsWithMembers(groups = []) { + const membersQueries = useQueries({ + queries: groups.map(({ groupId }) => ({ + queryKey: [REACTQUERY_KEYS.GROUPS.MEMBERS, groupId], + queryFn: () => getGroupMembers({ groupId, size: 4 }), + enabled: !!groupId, + staleTime: Infinity, + })), + }); + + const membersMap = {}; + + membersQueries.forEach((q, i) => { + const groupId = groups[i]?.groupId; + if (!groupId) return; + + membersMap[groupId] = q.data + ? { + totalElements: q.data.totalElements, + members: q.data.members, + } + : { + totalElements: 0, + members: [], + }; + }); + + return membersMap; +} diff --git a/frontend/src/pages/Login/index.jsx b/frontend/src/pages/Login/index.jsx index 9002a3b1..cdc9fab6 100644 --- a/frontend/src/pages/Login/index.jsx +++ b/frontend/src/pages/Login/index.jsx @@ -6,7 +6,7 @@ export default function Login() {

    - Crie a sua conta + Acesse sua conta

    diff --git a/frontend/src/pages/Login/loginAction.js b/frontend/src/pages/Login/loginAction.js deleted file mode 100644 index 32a04243..00000000 --- a/frontend/src/pages/Login/loginAction.js +++ /dev/null @@ -1,11 +0,0 @@ -export async function loginAction({ request }) { - const formData = await request.formData(); - const username = formData.get('email'); - if (!username) { - return { - type: 'error', - message: 'Por favor, preencha o nome de usuário.', - }; - } - console.log('Logado!'); -} diff --git a/frontend/src/pages/Notfound/index.jsx b/frontend/src/pages/Notfound/index.jsx index 6d5f5dc9..531c4102 100644 --- a/frontend/src/pages/Notfound/index.jsx +++ b/frontend/src/pages/Notfound/index.jsx @@ -1,13 +1,31 @@ +import { FaPiggyBank, FaArrowLeft } from 'react-icons/fa'; +import { Link, useRouteError } from 'react-router'; +import { AppError } from '../../utils/errors'; + export default function NotFound() { + const rawError = useRouteError(); + const error = AppError.from(rawError); + return ( -
    -

    - 404 -

    -

    Página não encontrada

    -

    - A página que você está tentando acessar não existe ou foi movida. -

    +
    +
    + +

    + {error.status} +

    +

    {error.statusText}

    +

    + {error.message} +

    + + + + {error.btn.label} + +
    ); } diff --git a/frontend/src/pages/Profile/ActiveGroups/index.jsx b/frontend/src/pages/Profile/ActiveGroups/index.jsx index 46218d43..48d0c1f5 100644 --- a/frontend/src/pages/Profile/ActiveGroups/index.jsx +++ b/frontend/src/pages/Profile/ActiveGroups/index.jsx @@ -1,33 +1,84 @@ -import userData from '../../../mockData/user/user.data'; +import { CategoryIcon } from '../../../mockData/groupIcons/icons'; +import { Link } from 'react-router'; +import { FiUsers } from 'react-icons/fi'; +import { useGroupsQuery } from '../../../hooks/ReactQuery/Queries/useGroupsQuery'; export default function ActiveGroups() { - const current_user = userData; + const { + data: { groups, groupsLength }, + isLoading, + } = useGroupsQuery(); + const groupLimit = 4; - const groupsToShow = current_user.groups.slice(0, groupLimit); - const hasMoreGroups = current_user.groups.length > groupLimit; + + if (isLoading) { + return ( +
    + + Carregando grupos... +
    + ); + } + + const groupsToShow = groups.slice(0, groupLimit); + const hasMoreGroups = groupsLength > groupLimit; + + if (groups.length === 0) { + return ( +
    + +

    + Você ainda não faz parte de nenhum grupo. +

    + + Criar grupo + +
    + ); + } return ( -
    +
    {groupsToShow.map(group => ( - - {group.icon} - {group.name} - + {/* Círculo do ícone */} +
    + +
    + + {/* Nome do grupo */} + {group.name} + ))} {hasMoreGroups && ( - - +{current_user.groups.length - groupLimit} mais - + +{groupsLength - groupLimit} mais + )} -
    +
    ); } diff --git a/frontend/src/pages/Profile/ProfileContent/index.jsx b/frontend/src/pages/Profile/ProfileContent/index.jsx new file mode 100644 index 00000000..b85405f3 --- /dev/null +++ b/frontend/src/pages/Profile/ProfileContent/index.jsx @@ -0,0 +1,234 @@ +import { useState, useMemo, useEffect } from 'react'; +import ButtonUI from '../../../components/ui/Button'; +import InputUI from '../../../components/ui/Input'; +import { BlockPicker } from 'react-color'; +import { avatarBackgroundColors } from '../../../mockData/colorsPallete/colors'; +import { Form } from 'react-router'; +import { customToast } from '../../../components/CustomToast'; +import { useLogout } from '../../../hooks/useLogout'; + +import { meDataSchema } from '../../../schemas/me/request'; +import { FaLock, FaUnlock } from 'react-icons/fa'; +import useMeQuery from '../../../hooks/ReactQuery/Queries/useMeQuery'; +import { useUpdateMeMutation } from '../../../hooks/ReactQuery/Mutations/useUpdateMeMutation'; + +export default function ProfileContent({ setIsModalOpen }) { + const { data: user, isLoading } = useMeQuery(); + const updateMeMutation = useUpdateMeMutation(); + const logout = useLogout(); + + const [userData, setUserData] = useState({ + name: '', + email: { isLocked: true, value: '' }, + themeColor: '', + }); + + // Atualiza userData quando user chegar + useEffect(() => { + if (user) { + setUserData({ + name: user.name, + email: { isLocked: true, value: user.email }, + themeColor: user.themeColor, + }); + } + }, [user]); + + const [isSubmitting, setIsSubmitting] = useState(false); + + // Verifica se houve alterações + const hasChanges = useMemo(() => { + if (!user) return false; + return ( + userData.name.trim() !== user.name.trim() || + userData.email.value.trim() !== user.email.trim() || + userData.themeColor !== user.themeColor + ); + }, [userData, user]); + + const handleSubmit = async e => { + e.preventDefault(); + + if (!hasChanges) { + customToast('', 'Nenhuma alteração detectada.', 'info'); + return; + } + + const data = { + name: userData.name.trim(), + email: userData.email.value.trim(), + themeColor: userData.themeColor, + }; + + const parsed = meDataSchema.safeParse(data); + if (!parsed.success) { + parsed.error.issues.forEach(issue => { + const fieldName = issue.path[0]; + const title = meDataSchema.shape[fieldName]?.description || fieldName; + customToast(title, issue.message, 'error'); + }); + return; + } + + setIsSubmitting(true); + // Usando mutateAsync para esperar o resultado + await updateMeMutation.mutateAsync(data, { + onSuccess: () => { + customToast( + 'Perfil atualizado', + 'Perfil atualizado com sucesso!', + 'success' + ); + // Se mudou o email, força logout + if (userData.email.value !== user.email) { + logout(); + return; // tempo suficiente para o toast aparecer + } + + setIsModalOpen(false); + }, + onSettled: () => { + setIsSubmitting(false); + }, + }); + }; + + // Placeholder enquanto carrega user + if (isLoading || !user) return

    Carregando perfil...

    ; + + return ( +
    +
    + {/* Título */} +
    +

    + Editar perfil +

    +

    + Atualize suas informações pessoais e personalize seu avatar. +

    +
    + + {/* Avatar + Inputs */} +
    + {/* Avatar Preview */} +
    + {userData?.name[0] || '?'} +
    + +
    + {/* Nome */} +
    + + + setUserData(old => ({ ...old, name: value })) + } + /> +
    + + {/* Email */} +
    + +
    + + setUserData(old => ({ + ...old, + email: { ...old.email, value }, + })) + } + /> + + setUserData(old => ({ + ...old, + email: { ...old.email, isLocked: !old.email.isLocked }, + })) + } + title={ + userData.email.isLocked + ? 'Desbloquear campo e-mail' + : 'Bloquear campo e-mail' + } + className="bg-primary text-white py-2 px-4 rounded-md cursor-pointer" + > + {userData.email.isLocked ? : } + +
    + {!userData.email.isLocked && ( + + Por segurança, você precisará entrar novamente após atualizar + seu e-mail. + + )} +
    + + {/* Cor do Avatar */} +
    + +
    + + setUserData(old => ({ ...old, themeColor: hex })) + } + /> +
    +
    +
    +
    + + {/* Botões */} +
    + setIsModalOpen(false)} + type="button" + className="py-2 px-5 bg-gray-500 hover:bg-gray-600 text-white rounded-md transition-all duration-200 cursor-pointer" + > + Cancelar + + + + {isSubmitting + ? 'Salvando...' + : !hasChanges + ? 'Sem alterações' + : 'Atualizar perfil'} + +
    +
    +
    + ); +} diff --git a/frontend/src/pages/Profile/Stats/index.jsx b/frontend/src/pages/Profile/Stats/index.jsx index e2d1d9e1..ad952435 100644 --- a/frontend/src/pages/Profile/Stats/index.jsx +++ b/frontend/src/pages/Profile/Stats/index.jsx @@ -1,37 +1,46 @@ -import { MdCategory } from 'react-icons/md'; // Ícone literal de categoria - // StatBox.jsx export default function StatBox({ number, label, icon, - color = 'bg-primary/10', + color = 'bg-primary/10 border-b-4 border-primary', }) { return (
    - {icon &&
    {icon}
    } + {/* Ícone médio */} + {icon &&
    {icon}
    } + {/* Label menor, secundário */}

    {label}

    -

    {number}

    + + {/* Número principal, maior destaque */} +

    {number}

    ); } // FavoriteCategory.jsx +import { MdCategory } from 'react-icons/md'; + export function FavoriteCategory({ topCategory }) { return ( -
    -
    - +
    + {/* Ícone médio */} +
    +
    + + {/* Label menor, secundário */}

    Categoria Favorita

    -

    + + {/* Valor principal */} +

    {topCategory}

    diff --git a/frontend/src/pages/Profile/index.jsx b/frontend/src/pages/Profile/index.jsx index 602f4c62..b3309fb8 100644 --- a/frontend/src/pages/Profile/index.jsx +++ b/frontend/src/pages/Profile/index.jsx @@ -11,7 +11,33 @@ import StatBox, { FavoriteCategory } from './Stats'; import userData from '../../mockData/user/user.data'; import { formatBRL } from '../../utils/formatters'; +import { useState } from 'react'; +import ProfileContent from './ProfileContent'; +import { Link } from 'react-router'; +import Modal from '../../components/Modal'; + +import useMeQuery from '../../hooks/ReactQuery/Queries/useMeQuery'; +import { useMeDashboardQuery } from '../../hooks/ReactQuery/Queries/useMeDashboardStatsQuery'; +import { useGroupsQuery } from '../../hooks/ReactQuery/Queries/useGroupsQuery'; +import { REACTQUERY_KEYS } from '../../libs/ReactQuery/keys'; +import { getMeExpensesByMonthly } from '../../services/me'; +import { useQuery } from '@tanstack/react-query'; +import { getMonthlyExpensesSummary } from '../../components/SummaryCards/expenseUtils'; + export default function Profile() { + const { data: user } = useMeQuery(); + const { data: dashboard } = useMeDashboardQuery(); + const { data } = useGroupsQuery(); + const { data: expensesResponse } = useQuery({ + queryKey: [REACTQUERY_KEYS.USER.DASHBOARD, 'balance'], + queryFn: getMeExpensesByMonthly, + }); + + const summary = expensesResponse?.length + ? getMonthlyExpensesSummary(expensesResponse) + : { totalExpenses: 0 }; + const groupsLength = data?.groupsLength; + const [isModalOpen, setIsModalOpen] = useState(false); const current_user = userData; return ( @@ -20,33 +46,39 @@ export default function Profile() {
    - {current_user.username[0]} + {user?.name[0]}
    -

    {current_user.username}

    -

    {current_user.email}

    +

    {user?.name}

    +

    {user?.email}

    {/* Estatísticas */} -
    - } - /> +
    + + } + /> + + } - number={formatBRL(current_user.dashboard.totalMonthlySpent)} - label="Despesas" + number={formatBRL(summary.totalExpenses)} + label="Despesas por ano" color="border-b-4 border-success" + icon={} + /> + + -
    {/* Grupos ativos */} @@ -59,32 +91,34 @@ export default function Profile() {

    Configurações e Suporte

    - } - label="Editar Perfil" - /> - } - label="Ajuda e Suporte" - /> - } - label="Sobre o App" - /> + setIsModalOpen(true)} + className="flex items-center gap-3 w-full p-4 rounded-xl border-l-4 border-primary bg-background hover:bg-muted/10 cursor-pointer " + > + + Editar Perfil + + + + Ajuda e Suporte + + + + Sobre o App +
    + setIsModalOpen(false)} + isOpen={isModalOpen} + setIsOpen={setIsModalOpen} + children={ + + } + /> ); } - -function ActionButton({ icon, label }) { - return ( - - ); -} diff --git a/frontend/src/pages/Register/index.jsx b/frontend/src/pages/Register/index.jsx index bb89b587..035b389b 100644 --- a/frontend/src/pages/Register/index.jsx +++ b/frontend/src/pages/Register/index.jsx @@ -1,12 +1,20 @@ +import { Navigate } from 'react-router'; import RegisterForm from '../../components/forms/RegisterForm'; import CardUI from '../../components/ui/Card'; +import { useAuthStore } from '../../context/stores/auth'; export default function Register() { + const isAuthenticated = useAuthStore(state => !!state.token); + + if (isAuthenticated) { + return ; + } + return (

    - Cadastre a sua conta + Crie a sua conta

    diff --git a/frontend/src/pages/Register/registerAction.js b/frontend/src/pages/Register/registerAction.js deleted file mode 100644 index dac6bda7..00000000 --- a/frontend/src/pages/Register/registerAction.js +++ /dev/null @@ -1,5 +0,0 @@ -export async function registerAction({ request }) { - const formData = await request.formData(); - - console.log('Cadastrado!'); -} diff --git a/frontend/src/routes/ProtectedRoute.jsx b/frontend/src/routes/ProtectedRoute.jsx deleted file mode 100644 index 06f4bbb1..00000000 --- a/frontend/src/routes/ProtectedRoute.jsx +++ /dev/null @@ -1,11 +0,0 @@ -// src/components/routes/ProtectedRoute.tsx -import { Navigate, Outlet } from 'react-router'; - -// Exemplo: verifica se há token salvo (ajuste conforme sua lógica real) -const isAuthenticated = () => true; //Boolean(localStorage.getItem('token')); - -const ProtectedRoute = () => { - return isAuthenticated() ? : ; -}; - -export default ProtectedRoute; diff --git a/frontend/src/routes/routes.jsx b/frontend/src/routes/routes.jsx index ce9a00fa..ba7c60ad 100644 --- a/frontend/src/routes/routes.jsx +++ b/frontend/src/routes/routes.jsx @@ -1,49 +1,68 @@ -import ProtectedRoute from './ProtectedRoute'; import { createBrowserRouter } from 'react-router'; import { lazy } from 'react'; -import { groupDetailsLoader } from '../pages/Groups/GroupDetails/groupDetailsLoader'; -import { loginAction } from '../pages/Login/loginAction'; -import { registerAction } from '../pages/Register/registerAction'; -const App = lazy(() => import('../App')); -const Layout = lazy(() => import('../components/Layout')); +import { loginAction } from '../actions/loginAction'; +import { registerAction } from '../actions/registerAction'; +import { protectRoutersLoader } from '../loaders/protectRoutersLoader'; + +// Layout e App carregados normalmente +import Layout from '../components/Layout'; +import App from '../App'; +import ExpenseDetails from '../pages/Expenses/ExpenseDetails'; +import { redirectIfAuthenticatedLoader } from '../loaders/redirectIfAuthenticatedLoader'; +import NotFound from '../pages/Notfound'; +import { expenseLoader } from '../loaders/expenseLoader'; + const Login = lazy(() => import('../pages/Login')); const Register = lazy(() => import('../pages/Register')); -const Dashboard = lazy(() => import('../pages/Dashboard')); const Groups = lazy(() => import('../pages/Groups')); const GroupDetails = lazy(() => import('../pages/Groups/GroupDetails')); const Profile = lazy(() => import('../pages/Profile')); -const NotFound = lazy(() => import('../pages/Notfound')); -export const routes = createBrowserRouter([ - // Rotas públicas (login e registro) +const GroupSettings = lazy( + () => import('../pages/Groups/GroupDetails/GroupSettings') +); + +// Export router diretamente (não como função) +export const appRouter = createBrowserRouter([ { element: , + errorElement: , // <-- Captura qualquer erro de rota + loader: redirectIfAuthenticatedLoader, children: [ - { path: '/login', element: , action: loginAction }, - { path: '/register', element: , action: registerAction }, - ], - }, - // Rotas privadas (todas as outras) - { - path: '/', - element: , // <== Aqui é onde protegemos tudo - errorElement: , - children: [ { - element: , + path: '/login', + element: , + action: loginAction, + }, + { + path: '/register', + element: , + action: registerAction, + }, + { + path: '/', + loader: protectRoutersLoader, children: [ - { index: true, element: }, // Redirecionamento ou página inicial - { path: 'dashboard', element: }, + { index: true, element: }, { path: 'groups', children: [ { index: true, element: }, { - path: ':group-id', + path: ':group_id', element: , - loader: groupDetailsLoader, + }, + { + path: ':group_id/expenses/:expense_id', + element: , + loader: expenseLoader, + }, + { + path: ':group_id/settings', + element: , }, ], }, + { path: 'profile', element: }, ], }, diff --git a/frontend/src/schemas/createGroup/form.js b/frontend/src/schemas/createGroup/form.js index 18e9ff46..e256f766 100644 --- a/frontend/src/schemas/createGroup/form.js +++ b/frontend/src/schemas/createGroup/form.js @@ -1,6 +1,29 @@ -import { z } from 'zod'; +import { z, ZodError } from 'zod'; +import { customToast } from '../../components/CustomToast'; export const createGroupFormSchema = z.object({ - name: z.string().min(3, 'O nome do grupo deve ter pelo menos 3 caracteres'), + name: z + .string() + .min(3, 'O nome do grupo deve ter pelo menos 3 caracteres') + .describe('Nome do grupo'), + icon: z.string(), description: z.string().optional(), }); + +export function validatorGroupForm(formData) { + const { success, error } = createGroupFormSchema.safeParse(formData); + if (success) return success; + + const errors = {}; + if (error instanceof ZodError) { + error.issues.forEach(issue => { + const fieldName = issue.path[0]; + const title = + createGroupFormSchema.shape[fieldName]?.description || fieldName; + customToast(title, issue.message, 'error'); + }); + } else { + errors.global = 'Ocorreu um erro inesperado.'; + } + return success; +} diff --git a/frontend/src/schemas/createNewExpense/form.js b/frontend/src/schemas/createNewExpense/form.js new file mode 100644 index 00000000..cd2aa2cc --- /dev/null +++ b/frontend/src/schemas/createNewExpense/form.js @@ -0,0 +1,48 @@ +import { z, ZodError } from 'zod'; +import { customToast } from '../../components/CustomToast'; + +export const createNewExpenseFormSchema = z.object({ + title: z.string().min(3, 'Título deve ter ao menos 3 caracteres'), + expenseValue: z + .number({ + required_error: 'O valor da despesa é obrigatório', + invalid_type_error: 'Valor inválido', + }) + .positive('A despesa deve ter um valor maior que zero'), + + categoryId: z + .number({ + required_error: 'Categoria é obrigatória', + invalid_type_error: 'Categoria inválida', + }) + .int() + .positive(), + description: z.string().optional(), + deadlineDate: z.string(), // normalmente vem como string ISO de input date + + expenseDivision: z + .array( + z.object({ + id: z.number().int().positive(), + value: z.number().nonnegative(), + }) + ) + .min(1, 'A divisão da despesa é obrigatória'), +}); + +export function validatorCreateNewExpense(formData) { + const { success, error } = createNewExpenseFormSchema.safeParse(formData); + if (success) return success; + const errors = {}; + if (error instanceof ZodError) { + error.issues.forEach(issue => { + const fieldName = issue.path[0]; + const title = + createNewExpenseFormSchema.shape[fieldName]?.description || fieldName; + customToast(title, issue.message, 'error'); + }); + } else { + errors.global = 'Ocorreu um erro inesperado.'; + } + return success; +} diff --git a/frontend/src/schemas/createNewExpense/updateExpenseForm.js b/frontend/src/schemas/createNewExpense/updateExpenseForm.js new file mode 100644 index 00000000..d898c850 --- /dev/null +++ b/frontend/src/schemas/createNewExpense/updateExpenseForm.js @@ -0,0 +1,59 @@ +import { z } from 'zod'; // ✅ precisa ser importado assim (com chaves) + +const ExpenseSchema = z.object({ + title: z + .string() + .min(3, 'Título deve ter pelo menos 3 caracteres') + .max(100, 'Título deve ter no máximo 100 caracteres'), + description: z + .string() + .max(2000, 'Descrição deve ter no máximo 2000 caracteres') + .optional(), + + // ✅ Validação e conversão de data + deadlineDate: z + .preprocess( + val => { + if (!val) return undefined; // permite undefined/null + return val instanceof Date ? val : new Date(val); + }, + z + .date({ invalid_type_error: 'Data inválida' }) + .refine(d => !isNaN(d.getTime()), { message: 'Data inválida' }) + ) + .optional(), + + // ✅ categoryId: pode ser número positivo ou string numérica + categoryId: z + .union([ + z + .string() + .regex(/^\d+$/, 'categoryId numérico inválido') + .transform(n => Number(n)), + z.number().int().positive(), + ]) + .refine(val => !!val, { message: 'categoryId obrigatório' }), +}); + +/** + * Valida e normaliza os dados do formulário. + * Retorna: + * { success: true, data } → se estiver válido + * { success: false, errors } → se houver erros + */ +export function validateExpensePayload(payload) { + const result = ExpenseSchema.safeParse(payload); + + if (result.success) { + return { success: true, data: result.data }; + } + + // Transforma erros do Zod em um objeto { campo: mensagem } + const errors = {}; + for (const issue of result.error.issues) { + const field = issue.path.join('.') || '_form'; + if (!errors[field]) errors[field] = issue.message; + } + + return { success: false, errors }; +} diff --git a/frontend/src/schemas/loginUser/form.js b/frontend/src/schemas/loginUser/form.js index a566ab7f..50c78569 100644 --- a/frontend/src/schemas/loginUser/form.js +++ b/frontend/src/schemas/loginUser/form.js @@ -1,6 +1,10 @@ import { z } from 'zod'; export const loginUserFormSchema = z.object({ - email: z.email('E-mail inválido'), - password: z.string().min(6, 'A senha deve ter no mínimo 6 caracteres'), + email: z.email('O formato do e-mail é inválido.').describe('E-mail'), + password: z + .string({ + required_error: 'A senha é obrigatória.', + }) + .describe('Senha'), }); diff --git a/frontend/src/schemas/me/request.js b/frontend/src/schemas/me/request.js new file mode 100644 index 00000000..ec1eaefb --- /dev/null +++ b/frontend/src/schemas/me/request.js @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +export const meDataSchema = z.object({ + name: z + .string() + .min(3, { message: 'O nome de usuário deve ter no mínimo 3 caracteres' }) + .max(30, { message: 'O nome de usuário deve ter no máximo 30 caracteres' }) + .describe('Nome de usuário'), + email: z.email('O formato do e-mail é inválido.').describe('E-mail'), + themeColor: z.string().regex(/^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/, { + message: 'Cor hexadecimal inválida', + }), +}); diff --git a/frontend/src/schemas/registerUser/form.js b/frontend/src/schemas/registerUser/form.js index e95c4152..2b1d139a 100644 --- a/frontend/src/schemas/registerUser/form.js +++ b/frontend/src/schemas/registerUser/form.js @@ -1,18 +1,32 @@ -import * as z from 'zod'; +import { z } from 'zod'; export const registerUserFormSchema = z .object({ - fullName: z + name: z .string() .min(3, { message: 'O nome completo deve ter no mínimo 3 caracteres' }) - .max(50, { message: 'O nome completo deve ter no máximo 50 caracteres' }), - email: z.email({ message: 'E-mail inválido' }), + .max(30, { message: 'O nome completo deve ter no máximo 30 caracteres' }) + .describe('Nome completo'), + email: z.email({ message: 'E-mail inválido' }).describe('E-mail'), password: z .string() - .min(6, { message: 'A senha deve ter no mínimo 6 caracteres' }), - confirmPassword: z.string(), + .min(6, { message: 'A senha deve ter no mínimo 6 caracteres' }) + .describe('Senha'), + confirmPassword: z + .string() + .min(1, { message: 'Confirme sua senha' }) + .describe('Confirmar senha'), + terms: z + .literal('on', { error: 'Você deve aceitar os termos' }) + .transform(val => val === 'on') + .describe('Termos de uso'), }) - .refine(data => data.password === data.confirmPassword, { - message: 'As senhas não coincidem', - path: ['confirmPassword'], // Aponta o erro para o campo correto + .superRefine(({ password, confirmPassword }, ctx) => { + if (password !== confirmPassword) { + ctx.addIssue({ + code: z.custom, + message: 'As senhas não coincidem', + path: ['confirmPassword'], + }); + } }); diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index e69de29b..078e9eba 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -0,0 +1,42 @@ +import axios from 'axios'; +import createAuthRefreshInterceptor from '@esmkit/axios-auth-refresh'; +import { useAuthStore } from '../context/stores/auth'; +import { refreshAuthLogic } from './refreshTokenInterceptor'; + +export let BASEURL = + import.meta.env.MODE === 'development' + ? import.meta.env.VITE_BASEURL_DEV + : import.meta.env.VITE_BASEURL_PROD; + +const CREDENTIALS = import.meta.env.VITE_CLIENT_CREDENTIALS_BASE64; + +// Cria uma instância do Axios com a URL base da API para autenticação +export const apiAuthentication = axios.create({ + baseURL: BASEURL, + withCredentials: true, + headers: { + Authorization: `Basic ${CREDENTIALS}`, + }, +}); + +/* ---------------------------------------------------------- */ + +//API para obter dados durante o uso do app + +export const apiApplication = axios.create({ + baseURL: BASEURL, + withCredentials: true, +}); + +apiApplication.interceptors.request.use(config => { + const token = useAuthStore.getState().token; + if (token) { + config.headers.Authorization = token; + } + + return config; +}); + +createAuthRefreshInterceptor(apiApplication, failedRequest => + refreshAuthLogic(failedRequest).catch(() => (window.location.href = '/login')) +); diff --git a/frontend/src/services/auth.js b/frontend/src/services/auth.js index e69de29b..a271ab8e 100644 --- a/frontend/src/services/auth.js +++ b/frontend/src/services/auth.js @@ -0,0 +1,140 @@ +import { isAxiosError } from 'axios'; +import { apiAuthentication } from './api'; + +// Função de login que envia email e senha para a API +export const login = ({ username, password }) => { + const config = { + headers: { + // Cabeçalho para o corpo da requisição + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }; + return apiAuthentication + .post( + 'oauth2/token', + { username, password, grant_type: 'password' }, + config + ) + .then(({ data }) => { + return { value: data }; + }) + .catch(error => { + let title = 'Erro'; + let message = 'Ocorreu um erro inesperado.'; + + if (isAxiosError(error)) { + // --- Caso 1: O servidor respondeu com status 4xx ou 5xx --- + if (error.response) { + const status = error.response.status; + const serverMessage = + error.response.data?.message || + error.response.data?.error || + error.message; + + title = `Erro ${status}`; + message = serverMessage || 'Erro ao processar sua solicitação.'; + + // --- Caso 2: Requisição feita, mas sem resposta (problema de rede, timeout, CORS) --- + } else if (error.request) { + title = 'Erro de Conexão'; + message = + 'Não foi possível se conectar ao servidor. Verifique sua internet e tente novamente.'; + + // --- Caso 3: Algo deu errado na configuração da requisição --- + } else { + title = 'Erro Interno'; + message = + 'Houve um problema ao preparar a requisição. Tente novamente mais tarde.'; + } + + // --- Caso 4: Erro não relacionado ao Axios --- + } else { + title = 'Erro Desconhecido'; + message = (error && error.message) || 'Ocorreu um erro inesperado.'; + } + + // Retorna de forma padronizada + throw { + title, + error: message, + }; + }); +}; + +// Registro de usuário que envia nome, email e senha +export const register = async data => { + try { + const { data: result } = await apiAuthentication.post('/user', data); + return { value: result }; + } catch (error) { + let title = 'Erro'; + let message = 'Ocorreu um erro inesperado.'; + + if (isAxiosError(error)) { + // --- Caso 1: O servidor respondeu com status 4xx ou 5xx --- + if (error.response) { + const status = error.response.status; + const serverMessage = + error.response.data?.message || + error.response.data?.error || + error.message; + + title = `Erro ${status}`; + message = serverMessage || 'Erro ao processar sua solicitação.'; + + // --- Caso 2: Requisição feita, mas sem resposta (problema de rede, timeout, CORS) --- + } else if (error.request) { + title = 'Erro de Conexão'; + message = + 'Não foi possível se conectar ao servidor. Verifique sua internet e tente novamente.'; + + // --- Caso 3: Algo deu errado na configuração da requisição --- + } else { + title = 'Erro Interno'; + message = + 'Houve um problema ao preparar a requisição. Tente novamente mais tarde.'; + } + + // --- Caso 4: Erro não relacionado ao Axios --- + } else { + title = 'Erro Desconhecido'; + message = (error && error.message) || 'Ocorreu um erro inesperado.'; + } + + // Retorna de forma padronizada + throw { + title, + error: message, + }; + } +}; + +// Encerra a sessão do usuário +export const logout = async () => { + const response = await apiAuthentication.post('/auth/logout'); + + return response.data; +}; + +// Atualiza o token de autenticação +export const getRefreshToken = async refresh_token => { + try { + const config = { + headers: { + // Cabeçalho para o corpo da requisição + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }; + const response = await apiAuthentication.post( + '/oauth2/token', + { + refresh_token, + grant_type: 'refresh_token', + }, + config + ); + return response.data; + } catch (error) { + throw error; + } +}; diff --git a/frontend/src/services/expenses.js b/frontend/src/services/expenses.js index e69de29b..9a4b96bf 100644 --- a/frontend/src/services/expenses.js +++ b/frontend/src/services/expenses.js @@ -0,0 +1,35 @@ +import { apiApplication } from './api'; + +// Busca todas as despesas do usuário +export const getExpenses = async () => { + const response = await api.get('/expenses'); + + return response.data; +}; + +// Cria uma nova despesa +export const createExpense = async (groupID, expense) => { + try { + const response = await apiApplication.post( + `groups/${groupID}/expenses`, + expense + ); + return response.data; + } catch (error) { + throw error; + } +}; + +// Atualiza uma despesa existente pelo id +export const updateExpense = async (id, expense) => { + const response = await api.put(`/expenses/${id}`, expense); + + return response.data; +}; + +// Remove uma despesa pelo id +export const deleteExpense = async id => { + const response = await api.delete(`/expenses/${id}`); + + return response.data; +}; diff --git a/frontend/src/services/groups.js b/frontend/src/services/groups.js index e69de29b..718445d6 100644 --- a/frontend/src/services/groups.js +++ b/frontend/src/services/groups.js @@ -0,0 +1,232 @@ +import { apiApplication } from './api'; + +export const getGroups = async (page, size) => { + try { + const params = new URLSearchParams(); + + // adiciona parâmetros apenas se existirem + if (page !== undefined) params.append('page', page); + if (size !== undefined) params.append('size', size); + + const response = await apiApplication.get(`/groups?${params.toString()}`); + + return { + groups: response.data.content, + totalPages: response.data.totalPages, + groupsLength: response.data.totalElements, + }; + } catch (error) { + throw error; + } +}; + +//Obtém os membros de um grupo +export const getGroupMembers = async ({ groupId, page, size, search } = {}) => { + const query = new URLSearchParams(); + if (page) query.append('page', page); + if (size) query.append('size', size); + if (search) query.append('search', search); + + const { data } = await apiApplication.get( + `/groups/${groupId}/members?${query.toString()}` + ); + + return { + members: data.content, + totalPages: data.totalPages, + totalElements: data.totalElements, + size: data.size, + }; +}; + +// Cria um novo grupo +export const createGroup = async group => { + try { + const response = await apiApplication.post('/groups', group); + return response.data; + } catch (error) { + console.error(error); + throw new Error('Erro ao criar um grupo'); + } +}; + +//Obter um grupo por id +export const getGroupById = async id => { + try { + const response = await apiApplication.get(`/groups/${id}`); + /* .then(async ({ data: group }) => { + const { + data: { content: members }, + } = await apiApplication.get(`groups/${group.id}/members`); + group.members = members; + return group; + }); */ + return response.data; + } catch (error) { + console.error(error); + throw error; + } +}; +//Obter todas as categorias de despesa de um grupo +export const getAllGroupsExpenseCategories = async () => { + try { + const response = await apiApplication.get('/groups/expenses/categories'); + return response.data; + } catch (error) { + console.error(error); + throw new Error('Erro ao obter todas as categorias de despesa.'); + } +}; + +// Atualiza um grupo existente pelo id +export const updateGroup = async (id, group) => { + try { + const response = await apiApplication.put(`/groups/${id}`, group); + return response.data; + } catch (error) { + throw error; + } +}; + +// Remove um grupo pelo id +export const deleteGroup = async id => { + try { + const response = await apiApplication.delete(`/groups/${id}`); + return response.data; + } catch (error) { + throw error; + } +}; + +export const getGroupExpenses = async ({ groupID, page, filter = null }) => { + try { + const query = new URLSearchParams({ size: 6, page: page.toString() }); + if (filter !== null) { + if (filter) { + Object.entries(filter).forEach(([key, value]) => { + if (value !== null && value !== undefined && value !== '') { + query.append(key, value.toString()); + } + }); + } + } + + const response = await apiApplication.get( + `/groups/${groupID}/expenses?${query.toString()}` + ); + return { + expenses: response.data.content, + totalPages: response.data.totalPages, + expensesLength: response.data.totalElements, + }; + } catch (error) { + throw error; + } +}; + +export const getGroupExpenseById = async (group_id, expense_id) => { + try { + const response = await apiApplication.get( + `/groups/${group_id}/expenses/${expense_id}` + ); + + return response.data; + } catch (error) { + console.error(error); + throw error; + } +}; + +export const updatePartialValueExpenseStatus = async ( + group_id, + expense_id, + member_id +) => { + try { + const response = await apiApplication.patch( + `/groups/${group_id}/expenses/${expense_id}/member/${member_id}` + ); + return response.data; + } catch (error) { + throw error; + } +}; + +export const updateExpenseDetails = async (group_id, expense_id, data) => { + try { + const response = await apiApplication.put( + `/groups/${group_id}/expenses/${expense_id}`, + data + ); + return response.data; + } catch (error) { + throw error; + } +}; + +export const deleteGroupExpense = async (group_id, expense_id) => { + try { + const response = await apiApplication.delete( + `/groups/${group_id}/expenses/${expense_id}` + ); + return response.data; + } catch (error) { + throw error; + } +}; +export const deleteGroupMember = async (group_id, member_id) => { + try { + const response = await apiApplication.delete( + `/groups/${group_id}/members/${member_id}` + ); + return response.data; + } catch (error) { + throw error; + } +}; + +export const updateRoleGroupMember = async (group_id, member_id, newRole) => { + try { + const data = { + setAuthority: newRole, + }; + const response = await apiApplication.put( + `/groups/${group_id}/members/${member_id}/transfer-ownership`, + data + ); + + return response.data; + } catch (error) { + throw error; + } +}; + +export const getGroupUserAuthenticatedAuthority = async (group_id, userId) => { + try { + const response = await apiApplication.get( + `/groups/${group_id}/members/${userId}/authority` + ); + return response.data.authority; + } catch (error) { + throw error; + } +}; +export const leaveGroup = async group_id => { + try { + const response = await apiApplication.delete(`/groups/${group_id}/members`); + return response.data; + } catch (error) { + throw error; + } +}; +export const addMemberGroup = async (group_id, data) => { + try { + const response = await apiApplication.post( + `/groups/${group_id}/members`, + data + ); + return response.data; + } catch (error) { + throw error; + } +}; diff --git a/frontend/src/services/me.js b/frontend/src/services/me.js new file mode 100644 index 00000000..e9ec253f --- /dev/null +++ b/frontend/src/services/me.js @@ -0,0 +1,83 @@ +import { REACTQUERY_KEYS } from '../libs/ReactQuery/keys'; +import { queryClient } from '../libs/ReactQuery/queryClient'; +import { apiApplication } from './api'; + +export const getMe = async () => { + try { + let response = await apiApplication.get('user/me'); + return response.data; + } catch (error) { + throw new Error('Erro ao obter dados do usuário'); + } +}; + +// A CHAVE [REACTQUERY_KEYS.USER.ME] deve ser onde a query de usuário está. +// Aqui, assumimos que a busca de usuário JÁ FOI FEITA. + +export const getMeExpenses = async (page, size = 5) => { + // 1. Obter o userId do cache da query de usuário + // Assumimos que o array de chaves do usuário é [REACTQUERY_KEYS.USER.ME] + const user = queryClient.getQueryData([REACTQUERY_KEYS.USER.ME]); + const userId = user?.id; // Usar optional chaining para segurança + + if (!userId) { + // Isso deve ser impossível se você usar consultas dependentes (enabled: !!userId) + throw new Error( + 'ID do usuário não encontrado no cache. A query de despesas foi executada muito cedo.' + ); + } + + try { + // 2. Chamada de API para obter as despesas + const response = await apiApplication.get( + `user/me/expenses?page=${page}&size=${size}` + ); + const expensesList = response.data.content; + + // 3. NORMALIZAÇÃO: Injetar o dado na chave separada, que agora tem o ID + // CHAVE: [REACTQUERY_KEYS.USER.EXPENSES, userId] + queryClient.setQueryData( + [REACTQUERY_KEYS.USER.EXPENSES, { userId, page, size }], + expensesList + ); + + // 4. Retornar o dado (para a query principal) + // OBS: Você pode retornar apenas a lista aqui, pois o setQueryData já lidou com o cache. + return { + expenses: expensesList, + totalPages: response.data.totalPages, + expensesLength: response.data.totalElements, + }; + } catch (error) { + // É uma boa prática lançar o erro do próprio objeto de erro para que o TanStack Query o capture + throw error; + } +}; + +export const updateMe = async data => { + try { + await apiApplication.put('/user', data); + } catch (error) { + throw new Error('Erro ao atualizar os dados do usuário'); + } +}; + +export const getMeDashboardStats = async () => { + try { + const categories = await apiApplication.get( + 'user/me/classify-spending-by-category' + ); + return categories.data; + } catch (error) { + throw new Error('Erro ao obter dados das despesas'); + } +}; + +export const getMeExpensesByMonthly = async () => { + try { + const response = await apiApplication.get('user/me/monthly-expenses'); + return response.data; + } catch (error) { + throw error; + } +}; diff --git a/frontend/src/services/refreshTokenInterceptor.js b/frontend/src/services/refreshTokenInterceptor.js new file mode 100644 index 00000000..8a0185db --- /dev/null +++ b/frontend/src/services/refreshTokenInterceptor.js @@ -0,0 +1,67 @@ +import { useAuthStore } from '../context/stores/auth'; +import { + localStoragePersistor, + queryClient, +} from '../libs/ReactQuery/queryClient'; +import { getRefreshToken } from './auth'; + +export const refreshAuthLogic = async failedRequest => { + const { + refreshToken, + setRefreshToken, + reset: resetStore, + } = useAuthStore.getState(); + try { + // 🔒 Verifica se há um refresh token salvo + if (!refreshToken) { + throw new Error('Refresh Token não disponível.'); + } + + // ⚙️ Chama a função que solicita um novo token de acesso + const newCredentials = await getRefreshToken(refreshToken); + setRefreshToken(newCredentials); + + console.log('🔄 Token de acesso renovado com sucesso'); + return failedRequest; // Retorna a requisição original para retry + } catch (error) { + console.error('🚨 Erro no refreshAuthLogic:', error); + + // ⚠️ Caso o erro venha da API + if (error.response) { + const { status, data } = error.response; + + if (status === 401 || status === 403) { + console.warn('❌ Token expirado ou inválido. Deslogando usuário...'); + } else { + console.warn( + `❗ Erro de API (${status}):`, + data?.message || 'Erro desconhecido' + ); + } + } + // 🕸️ Caso seja erro de rede + else if (error.request) { + console.error( + 'Error', + '🌐 Falha na comunicação com o servidor. Verifique a conexão.', + 'error' + ); + } + // 🧩 Erros inesperados (ex: código malformado) + else { + console.error( + 'Error', + `⚠️ Erro inesperado: ${error.message || error}`, + 'error' + ); + } + + // Sempre retorna a requisição com o erro original tratado + return Promise.reject(error).finally(async () => { + resetStore?.(); + queryClient.clear(); + await localStoragePersistor.removeClient(); + window.location.href = '/login'; + }); + } +}; diff --git a/frontend/src/services/users.js b/frontend/src/services/users.js index e69de29b..2206e33b 100644 --- a/frontend/src/services/users.js +++ b/frontend/src/services/users.js @@ -0,0 +1,29 @@ +import api from "./api"; + +// Busca um usuário pelo ID// Busca um usuário pelo id +export const getUser = async (id) => { + const response = await api.get(`/users/${id}`); + + return response.data; +}; + +// Atualiza os dados de um usuário +export const updateUser = async (id, user) => { + const response = await api.put(`/users/${id}`, user); + + return response.data; +}; + +// Remove um usuário pelo id +export const deleteUser = async (id) => { + const response = await api.delete(`/users/${id}`); + + return response.data; +}; + +// Busca todos os usuários +export const getAllUsers = async () => { + const response = await api.get("/users"); + + return response.data; +}; diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 47390b22..8a6c25d1 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -18,7 +18,7 @@ html { @custom-variant dark (html:where(.dark,.dark *)); .dark { - --color-primary: #4a90e2; /* Azul médio – ainda confiável e suave */ + --color-primary: #51a1fd; /* Azul médio – ainda confiável e suave */ --color-secondary: #5bc0f8; /* Azul claro vibrante – destaque */ --color-success: #2fd8a3; /* Verde claro – entradas */ --color-error: #ff7a6b; /* Vermelho suave – saídas */ diff --git a/frontend/src/styles/custom-toast.css b/frontend/src/styles/custom-toast.css new file mode 100644 index 00000000..e7320e5e --- /dev/null +++ b/frontend/src/styles/custom-toast.css @@ -0,0 +1,37 @@ +/* Cores da caixa e mensagem */ +@theme { + --color-error: rgba(220, 53, 69); /* Vermelho suave */ + --color-warning: rgba(255, 193, 7); /* Amarelo ouro */ + --color-success: rgba(40, 167, 69); /* Verde suave */ + --color-info: rgba(23, 162, 184); /* Azul petróleo */ + + /* Animação de entrada */ + --animate-enter: enter-toast 1s ease forwards; + + /* Corrigido: animação de entrada */ + @keyframes enter-toast { + from { + opacity: 0; + right: -100%; + } + to { + opacity: 1; + right: 30px; /* antes estava apenas "20", faltava unidade */ + } + } + + /* Animação de saída */ + --animate-out: out-toast 1s ease forwards; + + /* Corrigido: animação de saída */ + @keyframes out-toast { + from { + opacity: 1; + right: 30px; + } + to { + opacity: 0; + right: -100%; + } + } +} diff --git a/frontend/src/styles/message-box.css b/frontend/src/styles/message-box.css deleted file mode 100644 index 18e2b817..00000000 --- a/frontend/src/styles/message-box.css +++ /dev/null @@ -1,7 +0,0 @@ -/* Cores da caixa e mensagem */ -@theme { - --color-error: rgba(220, 53, 69); /* Vermelho suave, menos agressivo */ - --color-warning: rgba(255, 193, 7); /* Amarelo ouro, vibrante mas elegante */ - --color-success: rgba(40, 167, 69); /* Verde vivo, mas suave */ - --color-info: rgba(23, 162, 184); /* Azul petróleo, moderno e calmo */ -} diff --git a/frontend/src/utils/constants.js b/frontend/src/utils/constants.js index cb0ff5c3..4933e032 100644 --- a/frontend/src/utils/constants.js +++ b/frontend/src/utils/constants.js @@ -1 +1,31 @@ -export {}; +const ROLES = { + user: 'USER', + admin: 'ADMIN', + owner: 'OWNER', +}; + +const PERMISSIONS = { + // 🧾 Despesas + VIEW_OWN_EXPENSES: [ROLES.user, ROLES.admin, ROLES.owner], + VIEW_ALL_EXPENSES: [ROLES.admin, ROLES.owner], + EDIT_EXPENSES: [ROLES.admin, ROLES.owner], + EDIT_EXPENSE_VALUE: [ROLES.owner], + DELETE_EXPENSES: [ROLES.owner], + CREATE_EXPENSES: [ROLES.admin, ROLES.owner], + CHANGE_STATUS_EXPENSES: [ROLES.owner], + + // 👥 Membros + MANAGE_MEMBERS: [ROLES.admin, ROLES.owner], + + // 🏠 Grupo + EDIT_GROUP_INFO: [ROLES.owner], + DELETE_GROUP: [ROLES.owner], + ADD_GROUP_MEMEBER: [ROLES.owner, ROLES.admin], + + // 📊 Extras + GENERATE_REPORTS: [ROLES.owner], +}; + +export function hasPermission(role, action) { + return PERMISSIONS[action].includes(role); +} diff --git a/frontend/src/utils/errors.js b/frontend/src/utils/errors.js new file mode 100644 index 00000000..38160bee --- /dev/null +++ b/frontend/src/utils/errors.js @@ -0,0 +1,115 @@ +// Classe base para erros de aplicação +export class AppError extends Error { + constructor( + message, + status = 500, + statusText = 'Erro inesperado', + btn = { label: 'Voltar ao início', path: '/' } + ) { + super(message); + this.name = 'AppError'; + this.status = status; + this.statusText = statusText; + this.btn = btn; + } + + // 🔁 Método estático para interpretar diferentes tipos de erro + static from(error) { + // Caso 1 — Erro já convertido + if (error instanceof AppError) return error; + + // Caso 2 — Erro do React Router + if (error?.status) { + switch (error.status) { + case 400: + return new AppError( + 'A solicitação não pôde ser processada. Verifique os dados enviados.', + 400, + 'Requisição inválida' + ); + case 401: + return new UnauthorizedError(); + case 403: + return new ForbiddenError(); + case 404: + return new NotFoundError(); + case 500: + return new ServerError(); + default: + return new AppError( + error.data?.message || + 'Algo deu errado. Tente novamente mais tarde.', + error.status, + error.statusText || 'Erro inesperado' + ); + } + } + + // Caso 3 — Erros com formato Axios-like + if (error?.response?.data) { + const { status, title, message, btn } = error.response.data; + return new AppError( + message || 'Ocorreu um erro inesperado.', + status || 500, + title || 'Erro inesperado', + btn || { label: 'Voltar ao início', path: '/' } + ); + } + + // Caso 4 — Erros JS comuns + if (error instanceof Error) { + return new AppError(error.message); + } + + // Fallback total + return new AppError('Algo deu errado. Tente novamente mais tarde.'); + } +} + +// 404 - Não encontrado +export class NotFoundError extends AppError { + constructor( + message = 'Recurso não encontrado.', + status = 404, + btn = { label: 'Voltar ao início', path: '/' } + ) { + super(message, status, 'Página não encontrada', btn); + this.name = 'NotFoundError'; + } +} + +// 403 - Proibido +export class ForbiddenError extends AppError { + constructor( + message = 'Você não tem autorização para visualizar esta página.', + status = 403, + btn = { label: 'Voltar à página anterior', path: '/' } + ) { + super(message, status, 'Acesso negado!', btn); + this.name = 'ForbiddenError'; + } +} + +// 401 - Não autorizado +export class UnauthorizedError extends AppError { + constructor( + message = 'Você precisa estar autenticado para acessar esta página.', + status = 401, + btn = { label: 'Ir para login', path: '/' } + ) { + super(message, status, 'Não autorizado', btn); + this.name = 'UnauthorizedError'; + } +} + +// 500 - Erro interno do servidor +export class ServerError extends AppError { + constructor( + message = 'Ocorreu um problema inesperado no servidor. Tente novamente mais tarde.', + status = 500, + btn = { label: 'Voltar ao início', path: '/' } + ) { + super(message, status, 'Erro interno do servidor', btn); + this.name = 'ServerError'; + } +} diff --git a/frontend/src/utils/formatters.js b/frontend/src/utils/formatters.js index 6d5e8d39..3ade0224 100644 --- a/frontend/src/utils/formatters.js +++ b/frontend/src/utils/formatters.js @@ -1,6 +1,97 @@ -export function formatBRL(value) { - return new Intl.NumberFormat('pt-BR', { - style: 'currency', - currency: 'BRL', - }).format(value); +// Converte de string formatada (R$ 1.234,56) para número (1234.56) +export const parseBRL = value => { + if (!value) return ''; + return parseFloat(value) || 0; +}; + +export const formatBRL = ( + value, + { + locale = 'pt-BR', + currency = 'BRL', + style = 'currency', + notation = 'standard', + compactDisplay = undefined, + minimumFractionDigits = 2, + maximumFractionDigits = 2, + } = {} +) => { + let numericValue; + + if (typeof value === 'bigint') { + numericValue = Number(value); + } else if (typeof value === 'string') { + numericValue = parseFloat(value.replace(',', '.')); + } else { + numericValue = value; + } + + if (isNaN(numericValue)) numericValue = 0; + + return new Intl.NumberFormat(locale, { + style, + currency, + notation, + compactDisplay, + minimumFractionDigits, + maximumFractionDigits, + }).format(numericValue); +}; + +export function formatDateBR(dateString) { + if (!dateString) { + return 'Data Inválida'; + } + + const date = new Date(dateString); + + if (isNaN(date.getTime())) { + return 'Data Inválida'; + } + + // **MUDANÇA CHAVE:** Adicionar timeZone: 'UTC' + // Isso força o formatador a usar os componentes da data (dia 17) + // sem aplicar o deslocamento do fuso horário local (que subtrai 3 horas). + return new Intl.DateTimeFormat('pt-BR', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + timeZone: 'UTC', // <-- SOLUÇÃO + }).format(date); } + +/** + * Converte uma data para um formato relativo (ex: "ontem", "há 2 dias"). + * @param {string | Date} dateString A data a ser formatada. + * @param {string} lang A linguagem para a formatação (padrão: 'pt-BR'). + * @returns {string} A string de tempo relativo. + */ +export const formatRelativeDate = (dateString, lang = 'pt-BR') => { + if (!dateString) return 'Data Indisponível'; + + const date = new Date(dateString); + const now = new Date(); + const diffInMilliseconds = date - now; + + // Definições de corte de tempo (em milissegundos) + const cutoffs = [60000, 3600000, 86400000, 2592000000, 31536000000, Infinity]; + const units = ['minute', 'hour', 'day', 'month', 'year']; + + // Determinar a unidade + const unitIndex = cutoffs.findIndex( + cutoff => Math.abs(diffInMilliseconds) < cutoff + ); + const unit = units[unitIndex]; + const divisor = unitIndex > 0 ? cutoffs[unitIndex - 1] : 1000; + const value = Math.round(diffInMilliseconds / divisor); + + // Usa Intl.RelativeTimeFormat com 'numeric: "auto"' para "ontem", "amanhã" + const rtf = new Intl.RelativeTimeFormat(lang, { numeric: 'auto' }); + + // Para segundos (caso especial: diffInMilliseconds < 60000) + if (unit === 'minute' && Math.abs(diffInMilliseconds) < 60000) { + return 'há poucos segundos'; + } + + return rtf.format(value, unit); +}; diff --git a/frontend/src/utils/helpers.js b/frontend/src/utils/helpers.js index cb0ff5c3..99b2f695 100644 --- a/frontend/src/utils/helpers.js +++ b/frontend/src/utils/helpers.js @@ -1 +1,84 @@ -export {}; +export const getCurrentDate = () => { + const today = new Date(); + return new Intl.DateTimeFormat('en-CA').format(today); // 'en-CA' => formato YYYY-MM-DD +}; + +// Divide igualmente e distribui o resto para os primeiros membros +export const calculateEqualShares = (amount, members = []) => { + if (!amount || members.length === 0) return {}; + + const equalShare = Math.floor((amount / members.length) * 100) / 100; + let remainder = + Math.round((amount - equalShare * members.length) * 100) / 100; + + const division = {}; + + members.forEach(m => { + let finalShare = equalShare; + if (remainder > 0.009) { + // garante precisão + finalShare += 0.01; + remainder = Math.round((remainder - 0.01) * 100) / 100; + } + + division[m.id] = { + float: finalShare, + formatted: formatCurrencyValue(finalShare), + value: finalShare.toFixed(2).replace('.', ','), + }; + }); + + return division; +}; + +// Corrige diferenças restantes automaticamente entre membros +export const autoAdjustDifference = (total, divisionAmount = {}) => { + const members = Object.keys(divisionAmount); + if (members.length === 0) return divisionAmount; + + let sum = Object.values(divisionAmount).reduce( + (acc, d) => acc + (parseFloat(d?.float) || 0), + 0 + ); + let diff = Math.round((total - sum) * 100) / 100; + + if (diff === 0) return divisionAmount; // já está equilibrado + + const adjusted = { ...divisionAmount }; + + // Corrige centavos (para cima ou para baixo) + let index = 0; + const step = diff > 0 ? 0.01 : -0.01; + + while (Math.abs(diff) >= 0.01) { + const memberId = members[index % members.length]; + const current = adjusted[memberId].float + step; + + adjusted[memberId] = { + float: Math.round(current * 100) / 100, + formatted: formatCurrencyValue(current), + value: current.toFixed(2).replace('.', ','), + }; + + diff = Math.round((diff - step) * 100) / 100; + index++; + } + + return adjusted; +}; + +// Calcula diferença restante (quanto falta ou sobra) +export const calculateRemainingDifference = (total, divisionAmount) => { + const sum = Object.values(divisionAmount).reduce( + (acc, d) => acc + (parseFloat(d?.float) || 0), + 0 + ); + return Math.round((total - sum) * 100) / 100; +}; + +// Formata para moeda BRL +export const formatCurrencyValue = value => + new Intl.NumberFormat('pt-BR', { + style: 'currency', + currency: 'BRL', + }).format(value); diff --git a/frontend/src/utils/localStorageEncryption.js b/frontend/src/utils/localStorageEncryption.js new file mode 100644 index 00000000..d5cf1e18 --- /dev/null +++ b/frontend/src/utils/localStorageEncryption.js @@ -0,0 +1,7 @@ +import SLS from 'secure-ls'; +const encryptionSecret = import.meta.env.VITE_SECURE_LS_ENCRYPTION_KEY; +export const SecureLS = new SLS({ + encodingType: 'aes', + isCompression: true, + encryptionSecret, +}); diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 7a494aa5..2684f176 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -1,13 +1,69 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react-swc'; import tailwindcss from '@tailwindcss/vite'; +import { VitePWA } from 'vite-plugin-pwa'; export default defineConfig({ - plugins: [react(), tailwindcss()], + plugins: [ + react(), + tailwindcss(), + VitePWA({ + registerType: 'autoUpdate', + devOptions: { + enabled: true // Habilita PWA em desenvolvimento + }, + workbox: { + globPatterns: ['**/*.{js,css,html,ico,png,svg,jpg,jpeg}'] + }, + includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'mask-icon.svg'], + manifest: { + name: 'FinBoost Plus', + short_name: 'FinBoost+', + description: 'Aplicativo de gestão financeira pessoal', + theme_color: '#ffffff', + background_color: '#ffffff', + display: 'standalone', + orientation: 'portrait', + scope: '/', + start_url: '/', + icons: [ + { + src: 'icon-192x192.png', + sizes: '192x192', + type: 'image/png' + }, + { + src: 'icon-512x512.png', + sizes: '512x512', + type: 'image/png' + }, + { + src: 'icon-512x512.png', + sizes: '512x512', + type: 'image/png', + purpose: 'any maskable' + } + ] + } + }) + ], test: { globals: true, environment: 'jsdom', setupFiles: './__tests__/setup.js', // <- descomente e ajuste o caminho include: ['**/*.{test,spec}.{js,jsx}'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html', 'lcov'], + reportsDirectory: './coverage', + exclude: [ + 'node_modules/', + '__tests__/', + '**/*.config.js', + '**/*.config.ts', + 'dist/', + 'build/', + ], + }, }, }); diff --git a/mkdocs.yml b/mkdocs.yml index 6dc6d72f..f82dbc5b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,18 +1,20 @@ site_name: FinBoost+ Documentation -site_description: Documentação completa da aplicação FinBoost+ (Frontend e Backend) +site_description: Documentação completa da aplicação FinBoost+ site_author: Equipe FinBoost+ -repo_url: https://github.com/seu-usuario/finboostplus-app +repo_url: https://github.com/Finboostplus/finboostplus-app repo_name: finboostplus-app docs_dir: docs site_dir: site theme: name: material + language: pt-BR features: - navigation.tabs - navigation.sections - navigation.expand - navigation.top + - navigation.footer - search.highlight - search.suggest - content.code.copy @@ -31,7 +33,8 @@ theme: name: Switch to light mode plugins: - - search + - search: + lang: pt - mermaid2 markdown_extensions: @@ -51,32 +54,50 @@ markdown_extensions: - attr_list - md_in_html +extra: + social: + - icon: fontawesome/brands/github + link: https://github.com/Finboostplus + generator: false + +copyright: FinBoost+ Team - Controle Financeiro e Compartilhamento de Despesas + nav: - - Home: index.md - - Projeto: + - Início: index.md + - Guia do Usuário: + - Introdução: user_guide/intro.md + - Instalação e Setup: user_guide/setup.md + - Como Usar: user_guide/usage.md + - FAQ: user_guide/faq.md + - Sobre o Projeto: - Visão Geral: project/overview.md - - Requisitos: project/requirements.md - - User Stories: project/user-stories.md - - Diagramas: project/diagrams.md - - Personas: project/personas.md + - MVP (Resumo): project/mvp_summary.md + - Funcionalidades: project/features.md + - Personas Principais: project/personas_key.md + - Equipe: project/team.md + - Documentação Completa: project/full_docs.md + - Arquitetura e API: + - Visão da Arquitetura: technical/architecture.md + - API - Documentação e Guia: technical/api_documentation.md + - Banco de Dados: technical/database.md + - Autenticação: technical/auth.md + - Estratégia de Testes: technical/testing_strategy.md - Frontend: - - Visão Geral: frontend/index.md - - Getting Started: frontend/getting-started.md - - Arquitetura: frontend/architecture.md - - Componentes: frontend/components.md - - Páginas: frontend/pages.md - - Hooks: frontend/hooks.md - - Serviços: frontend/services.md - - Styling: frontend/styling.md - - Testes: frontend/testing.md - - Deploy: frontend/deployment.md - - API Reference: frontend/api-reference.md + - Estrutura e Padrões: frontend/structure.md + - Componentes Principais: frontend/components.md + - Hooks Customizados: frontend/hooks.md + - Testes Frontend: frontend/testing.md - Backend: - - Visão Geral: backend/index.md - - Getting Started: backend/getting-started.md - - Arquitetura: backend/architecture.md - - API Endpoints: backend/api-endpoints.md - - Database: backend/database.md - - Autenticação: backend/authentication.md - - Testes: backend/testing.md - - Deploy: backend/deployment.md + - Estrutura e Padrões: backend/structure.md + - Modelos e Schemas: backend/models.md + - Testes Backend: backend/testing.md + - Uso de IA: + - Como Usamos IA: ai_docs/ai_usage.md + - Frontend - Uso de IA: frontend/ai_usage.md + - Frontend - Registro de Prompts: frontend/ai_prompts_log.md + - Backend - Uso de IA: backend/ai_usage.md + - Backend - Registro de Prompts: backend/ai_prompts_log.md + - Para Desenvolvedores: + - Configuração de Ambiente: dev/environment.md + - Links Úteis: dev/useful_links.md + - Wiki de Desenvolvimento: "https://github.com/Finboostplus/finboostplus-app/wiki" \ No newline at end of file diff --git a/project_docs/README.md b/project_docs/README.md new file mode 100644 index 00000000..d5161215 --- /dev/null +++ b/project_docs/README.md @@ -0,0 +1,119 @@ +# 🗂️ Project Docs - FinBoost+ + +
    + Planejamento + Arquitetura + Status +
    + +Repositório de artefatos de planejamento e apoio ao desenvolvimento: MVP, personas, user stories; diagramas (arquitetura, ER, casos de uso, etc.) e wireframes. Serve como fonte dos documentos de alto nível que embasam a documentação técnica em `docs/`. + +--- + +## 📚 Sumário + +
    +Clique para expandir + +- [🗂️ Project Docs - FinBoost+](#️-project-docs---finboost) + - [📚 Sumário](#-sumário) + - [🚀 Visão Geral](#-visão-geral) + - [📁 Estrutura](#-estrutura) + - [🧩 Conteúdos](#-conteúdos) + - [📝 Boas práticas de versionamento](#-boas-práticas-de-versionamento) + - [🔄 Fluxo entre project\_docs → docs](#-fluxo-entre-project_docs--docs) + - [🤝 Contribuindo](#-contribuindo) + - [📎 Créditos \& Licenças](#-créditos--licenças) + - [📞 Suporte](#-suporte) +
    + +--- + +## 🚀 Visão Geral + +- Consolidar documentos de produto/planejamento e artefatos visuais +- Servir de base única para conteúdos consumidos e publicados em `docs/` +- Manter histórico e versões editáveis (originais) dos diagramas + +--- + +## 📁 Estrutura + +``` +project_docs/ +├── documentos/ # Conteúdo de planejamento (texto) +│ ├── mvp.md +│ ├── personas.md +│ ├── user_stories.md +│ ├── documento_requisitos.md +│ └── api_contract.md # (espelho/rascunho do contrato) +├── diagramas/ # Imagens exportadas (PNG/SVG) +│ ├── diagrama_classes.png +│ ├── diagrama_ER.png +│ └── diagrama_uses_cases.png +└── prototipos/ # Wireframes/mockups + ├── finboostplus-wireframe_01.jpg + └── ... +``` + +--- + +## 🧩 Conteúdos + +- MVP: escopo mínimo funcional e critérios de aceitação +- Personas: perfis de usuários e principais necessidades +- User Stories: histórias priorizadas (pode incluir Gherkin) +- Requisitos: funcionais e não-funcionais +- Contrato de API: rascunhos e decisões +- Diagramas: visão de arquitetura, ER, casos de uso e classes +- Prototipação: wireframes e fluxos principais + +--- + +## 📝 Boas práticas de versionamento + +- Prefira arquivos de texto (Markdown) quando possível +- Diagramas: mantenha o arquivo editável + PNG/SVG exportado +- Nomeie arquivos em `kebab-case` e com sufixos de versão quando relevante +- Evite binários grandes em PRs sem necessidade; compacte se possível + +--- + +## 🔄 Fluxo entre project_docs → docs + +1) Crie/atualize o artefato fonte em `project_docs/` +2) Exporte imagens otimizadas (PNG/SVG) para `docs/assets/images/` quando forem usadas na documentação +3) Atualize as páginas em `docs/` que referenciam o material (ex.: `docs/project/diagrams.md`) +4) Ajuste a navegação em `mkdocs.yml` se adicionar novas páginas + +Dica: use nomes consistentes para facilitar a referência (ex.: `diagrama-er.png`). + +--- + +## 🤝 Contribuindo + +- Siga as convenções do repositório (`CONTRIBUTING.md`) +- Garanta que alterações em artefatos aqui refletirão em `docs/` quando apropriado +- Adicione contexto nas PRs (print dos diagramas, link para páginas afetadas) + +--- + +## 📎 Créditos & Licenças + +- Verifique `LICENSE` do repositório para termos de uso +- Ao utilizar imagens/recursos de terceiros, inclua referência e licença + +--- + +## 📞 Suporte + +- Issues: https://github.com/Finboostplus/finboostplus-app/issues +- Docs publicadas: https://finboostplus.github.io/finboostplus-app/ +- Email: finboostplus@gmail.com + +--- + +
    + 🗂️ Documentos do Projeto - FinBoost+
    + Desenvolvido pelo Grupo 7 - +Prati & Codifica +
    diff --git a/project_docs/diagramas/Diagrama de Casos de Uso.png b/project_docs/diagramas/Diagrama de Casos de Uso.png deleted file mode 100644 index b5d98032..00000000 Binary files a/project_docs/diagramas/Diagrama de Casos de Uso.png and /dev/null differ diff --git a/project_docs/diagramas/diagrama_ER.png b/project_docs/diagramas/diagrama_ER.png index 54200521..ded3c5a2 100644 Binary files a/project_docs/diagramas/diagrama_ER.png and b/project_docs/diagramas/diagrama_ER.png differ diff --git a/project_docs/diagramas/diagrama_classes.png b/project_docs/diagramas/diagrama_classes.png index 2d3b3834..800cd896 100644 Binary files a/project_docs/diagramas/diagrama_classes.png and b/project_docs/diagramas/diagrama_classes.png differ diff --git a/project_docs/diagramas/diagrama_uses_cases.png b/project_docs/diagramas/diagrama_uses_cases.png new file mode 100644 index 00000000..f8cd59ab Binary files /dev/null and b/project_docs/diagramas/diagrama_uses_cases.png differ diff --git a/project_docs/documentos/.gitignore b/project_docs/documentos/.gitignore deleted file mode 100644 index b626118d..00000000 --- a/project_docs/documentos/.gitignore +++ /dev/null @@ -1,36 +0,0 @@ -# Arquivos temporários e de backup -*~ -*.tmp -*.bak -*.swp -*.swo -*.swn -*.tmp -*.temp -*.old - -# Arquivos do Microsoft Office temporários -~$*.doc* -~$*.xls* -~$*.ppt* - -# Arquivos ocultos de sistemas operacionais -.DS_Store -Thumbs.db - -# Arquivos ocultos de editores -.idea/ -.vscode/ -*.iml - -# Arquivos de exportação automática -*.pdf.bak -*.docx.bak -*.xlsx.bak - -# Arquivos de lock de editores de texto -*.lock - -# Arquivos de revisão do Word/LibreOffice -*.wbk -*.~lock.* diff --git a/project_docs/documentos/api_contract.md b/project_docs/documentos/api_contract.md index 4c0301ad..0525eb8f 100644 --- a/project_docs/documentos/api_contract.md +++ b/project_docs/documentos/api_contract.md @@ -516,3 +516,50 @@ - Upload de comprovantes de despesas - Relatórios em PDF - Integração com sistemas de pagamento + +--- + +## Histórico de Versões + +| Versão | Data | Autor | Justificativa das Mudanças | +|--------|--------------------------|-------|---------------------------| +| **v1.0** | **02 de Agosto de 2025** | **Equipe FinBoost+** | **Versão inicial do contrato de API** - Definição completa dos endpoints da API RESTful para o sistema de controle financeiro compartilhado. Inclui especificações para autenticação (JWT + OAuth2), gestão de usuários, grupos, despesas e dashboard. Estrutura baseada nos requisitos funcionais do MVP com foco em documentação técnica clara para desenvolvimento e integração. | + +### **Características da Versão v1.0** + +**Endpoints Principais:** +- Autenticação completa (login, registro, recuperação de senha) +- CRUD de usuários com perfil e preferências +- Gestão completa de grupos e membros +- Sistema robusto de despesas compartilhadas +- Dashboard com métricas e resumos + +**Padrões Técnicos:** +- API RESTful com JSON +- Autenticação via JWT Bearer Token +- Códigos de status HTTP padronizados +- Estrutura de erro consistente +- Paginação em listagens + +**Requisitos de Segurança:** +- Headers de autenticação obrigatórios +- Validação de permissões por grupo +- Sanitização de entrada de dados +- Proteção contra ataques comuns + +### **Notas de Implementação** + +**Desenvolvimento**: Implementação seguindo as melhores práticas de REST e segurança +**Documentação Interativa:** Scalar (quando implementado) + +--- + +### **Metadados do Documento** + +**Documento:** Contrato de API - FinBoost+ Sistema de Controle Financeiro +**Versão Atual:** v1.0 +**Data da Última Atualização:** 15 de Agosto de 2025 +**Próxima Revisão Prevista:** Durante o desenvolvimento da API +**Status:** Baseline para implementação +**Tipo:** Documentação Técnica de API +**Público:** Equipe de desenvolvimento (Backend + Frontend) diff --git a/project_docs/documentos/personas.md b/project_docs/documentos/personas.md index e56a9170..5b9d2cfa 100644 --- a/project_docs/documentos/personas.md +++ b/project_docs/documentos/personas.md @@ -191,3 +191,54 @@ Criação de eventos com controle de despesas, divisão proporcional entre parti “Fiquei no prejuízo porque ninguém me pagou o que ficou devendo no churrasco.” --- + +## Histórico de Versões + +| Versão | Data | Autor | Justificativa das Mudanças | +|--------|-------------------|-------|---------------------------| +| **v1.0** | **Julho de 2025** | **Equipe FinBoost+** | **Versão inicial das personas** - Definição das 4 personas principais para o MVP (Marina - Gastadora Impulsiva, Gabriel - Estudante Consciente, João e Letícia - Casal Organizado, Marcos e Vanessa - Família) baseadas em pesquisa de usuários e validação das necessidades reais. Inclui também 4 personas futuras para expansão do produto. Estrutura focada em resolver problemas de controle financeiro compartilhado com contextos de vida realistas e objetivos mensuráveis. | + +### **Características da Versão v1.0** + +**Personas Principais (MVP):** +- **Marina, 26 anos** - Freelancer com renda instável e gastos impulsivos +- **Gabriel, 22 anos** - Estudante/estagiário consciente mas com pouco tempo +- **João e Letícia** - Casal organizado que divide despesas domésticas +- **Marcos e Vanessa** - Família com filha e orçamento variável + +**Personas Futuras:** +- Carlos (Profissional Autônomo) +- Rafael (Planejador Financeiro) +- Equipe Casa Conectada (República) +- Organização de Eventos + +**Metodologia Aplicada:** +- Validação através de user stories específicas +- Contextos diversos (individual, casal, família) +- Foco em dores financeiras compartilhadas + +### **Validação das Personas** + +**Critérios de Representatividade:** +- Diferentes perfis econômicos e idades +- Variação de contextos de uso (individual/grupal) +- Problemas financeiros distintos mas relacionados +- Objetivos mensuráveis e alcançáveis pelo MVP + +**Alinhamento com MVP:** +- Todas as 4 personas principais são atendidas pelas funcionalidades essenciais +- User stories derivadas diretamente das dores identificadas +- Cenários de uso cobrem os principais fluxos do sistema +- Personas futuras orientam roadmap de expansão + +--- + +### **Metadados do Documento** + +**Documento:** Personas - FinBoost+ Sistema de Controle Financeiro Compartilhado +**Versão Atual:** v1.0 +**Data da Última Atualização:** 15 de Agosto de 2025 +**Próxima Revisão Prevista:** Após testes de usabilidade com usuários reais +**Status:** Baseline para desenvolvimento de funcionalidades +**Tipo:** Documentação de UX/Produto +**Público:** Equipe completa (Produto, UX, Desenvolvimento) diff --git a/project_docs/documentos/user_stories.md b/project_docs/documentos/user_stories.md index 23378457..e871e200 100644 --- a/project_docs/documentos/user_stories.md +++ b/project_docs/documentos/user_stories.md @@ -41,3 +41,50 @@ Este documento contém user stories derivadas das personas definidas para o MVP. - Como Marcos e Vanessa, queremos cadastrar despesas fixas da casa, para manter o controle mensal. - Como Marcos e Vanessa, queremos visualizar quanto cada um contribuiu, para manter a divisão justa. - Como Marcos e Vanessa, queremos definir uma meta de poupança familiar, para nos prepararmos para imprevistos. + +--- + +## Histórico de Versões + +| Versão | Data | Autor | Justificativa das Mudanças | +|--------|-------------------|-------|---------------------------| +| **v1.0** | **Julho de 2025** | **Equipe FinBoost+** | **Versão inicial das user stories** - Definição das histórias de usuário derivadas das 4 personas principais do MVP (Marina, Gabriel, João e Letícia, Marcos e Vanessa). Stories focadas nas necessidades reais de controle financeiro compartilhado, seguindo o formato padrão "Como [persona], quero [ação], para [benefício]". Cobertura completa dos cenários de uso identificados na pesquisa de usuários e alinhamento direto com os requisitos funcionais do sistema. | + +### **Características da Versão v1.0** + +**Stories por Persona:** +- **Marina (Gastadora Impulsiva)**: 4 stories focadas em controle de gastos e alertas +- **Gabriel (Estudante Consciente)**: 3 stories focadas em registro rápido e metas +- **João e Letícia (Casal)**: 3 stories focadas em divisão justa de despesas +- **Marcos e Vanessa (Família)**: 3 stories focadas em controle doméstico e poupança + +**Metodologia Aplicada:** +- Derivação direta das dores identificadas nas personas +- Formato padrão: Como [persona], quero [ação], para [benefício] +- Foco em necessidades mensuráveis e implementáveis +- Cobertura de diferentes contextos de uso (individual, casal, família) + +**Validação e Rastreabilidade:** +- Cada story conectada a problemas específicos das personas +- Alinhamento com requisitos funcionais do MVP +- Base para definição de funcionalidades do sistema + +### **Cobertura de Funcionalidades** + +**Funcionalidades Essenciais Cobertas:** +- Cadastro e controle de despesas pessoais ou compartilhadas +- Divisão de despesas compartilhadas +- Dashboard com resumos e gráficos +- Sistema de categorização de despesas + +--- + +### **Metadados do Documento** + +**Documento:** User Stories - FinBoost+ Sistema de Controle Financeiro Compartilhado +**Versão Atual:** v1.0 +**Data da Última Atualização:** 15 de Agosto de 2025 +**Próxima Revisão Prevista:** Durante refinamento de backlog +**Status:** Baseline para desenvolvimento de funcionalidades +**Tipo:** Documentação de Produto/Agile +**Público:** Product Owner, Scrum Master, Equipe de Desenvolvimento diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 00000000..7d0bdfdf --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,29 @@ +# SonarCloud Configuration for FinBoostPlus +sonar.projectKey=Finboostplus_finboostplus-app +sonar.organization=finboostplus + +# Project Information +sonar.projectName=FinBoostPlus App +sonar.projectVersion=1.0 + +# Source code configuration +sonar.sources=frontend/src,backend/finboostplus_teste/src/main +sonar.tests=frontend/__tests__,backend/finboostplus_teste/src/test + +# Frontend specific configurations +sonar.javascript.lcov.reportPaths=frontend/coverage/lcov.info +sonar.typescript.lcov.reportPaths=frontend/coverage/lcov.info + +# Backend specific configurations +sonar.java.source=21 +sonar.java.binaries=backend/finboostplus_teste/target/classes +sonar.java.test.binaries=backend/finboostplus_teste/target/test-classes +sonar.java.libraries=backend/finboostplus_teste/target/dependency/*.jar +sonar.coverage.jacoco.xmlReportPaths=backend/finboostplus_teste/target/site/jacoco/jacoco.xml + +# Exclusions +sonar.exclusions=**/node_modules/**,**/target/**,**/build/**,**/*.min.js,**/vendor/** +sonar.test.exclusions=**/node_modules/**,**/target/** + +# Encoding +sonar.sourceEncoding=UTF-8