From fc59f990cfde90b8982adc80231896885c67fabe Mon Sep 17 00:00:00 2001 From: Ricardo Campos Date: Thu, 21 May 2026 13:08:41 -0300 Subject: [PATCH 01/11] feat: add initial files and spec 1 --- ai/context/architecture_principles.md | 30 ++++++++ ai/context/domain_glossary.md | 17 +++++ ai/context/project_vision.md | 44 ++++++++++++ ai/context/tech_stack.md | 34 +++++++++ ai/contracts/templates/feedback_contract.md | 50 +++++++++++++ ai/contracts/templates/handoff_contract.md | 68 ++++++++++++++++++ ai/contracts/templates/question_contract.md | 58 +++++++++++++++ ai/orchestration/context_policy.md | 56 +++++++++++++++ ai/orchestration/handoff_rules.md | 39 ++++++++++ ai/orchestration/orchestrator.md | 40 +++++++++++ ai/orchestration/workflow.md | 71 +++++++++++++++++++ ai/roles/architect.md | 53 ++++++++++++++ ai/roles/developer.md | 49 +++++++++++++ ai/roles/pm.md | 40 +++++++++++ ai/roles/reviewer.md | 36 ++++++++++ ai/roles/tester.md | 36 ++++++++++ ai/skills/create_handoff_contract.md | 14 ++++ ai/skills/review_code_quality.md | 14 ++++ ai/skills/write_spec.md | 19 +++++ ai/skills/write_tests.md | 14 ++++ ai/specs/001-add-multiple-tags.md | 58 +++++++++++++++ ai/specs/README.md | 33 +++++++++ ai/specs/_template/spec.md | 43 +++++++++++ ai/templates/orchestration_prompt.md | 16 +++++ ai/templates/spec_feature_prompt.md | 28 ++++++++ ai/templates/spec_generation_prompt.md | 18 +++++ .../tasknoteapp/server/entity/NoteEntity.java | 41 +++++------ .../server/entity/NoteTagEntity.java | 29 ++++++++ .../tasknoteapp/server/entity/TagEntity.java | 25 +++++++ .../tasknoteapp/server/entity/TaskEntity.java | 36 +++++----- .../server/entity/TaskTagEntity.java | 29 ++++++++ .../V202605211000__add_multiple_tags.sql | 63 ++++++++++++++++ 32 files changed, 1161 insertions(+), 40 deletions(-) create mode 100644 ai/context/architecture_principles.md create mode 100644 ai/context/domain_glossary.md create mode 100644 ai/context/project_vision.md create mode 100644 ai/context/tech_stack.md create mode 100644 ai/contracts/templates/feedback_contract.md create mode 100644 ai/contracts/templates/handoff_contract.md create mode 100644 ai/contracts/templates/question_contract.md create mode 100644 ai/orchestration/context_policy.md create mode 100644 ai/orchestration/handoff_rules.md create mode 100644 ai/orchestration/orchestrator.md create mode 100644 ai/orchestration/workflow.md create mode 100644 ai/roles/architect.md create mode 100644 ai/roles/developer.md create mode 100644 ai/roles/pm.md create mode 100644 ai/roles/reviewer.md create mode 100644 ai/roles/tester.md create mode 100644 ai/skills/create_handoff_contract.md create mode 100644 ai/skills/review_code_quality.md create mode 100644 ai/skills/write_spec.md create mode 100644 ai/skills/write_tests.md create mode 100644 ai/specs/001-add-multiple-tags.md create mode 100644 ai/specs/README.md create mode 100644 ai/specs/_template/spec.md create mode 100644 ai/templates/orchestration_prompt.md create mode 100644 ai/templates/spec_feature_prompt.md create mode 100644 ai/templates/spec_generation_prompt.md create mode 100644 server/src/main/java/br/com/tasknoteapp/server/entity/NoteTagEntity.java create mode 100644 server/src/main/java/br/com/tasknoteapp/server/entity/TagEntity.java create mode 100644 server/src/main/java/br/com/tasknoteapp/server/entity/TaskTagEntity.java create mode 100644 server/src/main/resources/db/migration/V202605211000__add_multiple_tags.sql diff --git a/ai/context/architecture_principles.md b/ai/context/architecture_principles.md new file mode 100644 index 0000000..5e75900 --- /dev/null +++ b/ai/context/architecture_principles.md @@ -0,0 +1,30 @@ +# Architecture Principles + +## Core Principles + +- Keep solutions simple and focused on the problem at hand. +- Prefer readability and maintainability over cleverness. +- Small functions and modules that do one thing well. +- Use clear and descriptive names for variables, functions, and classes. +- Avoid premature optimization; optimize only when necessary. +- Avoid over-engineering; build only what is needed for the current problem. +- Clear boundaries between components to promote separation of concerns. +- Prefer to maintainability over perfection + +## Delivery Principles + +Prefer: +- small specs +- iterative delivery +- vertical slices of functionality +- simple APIs +- clear documentation +- understandable code + +Avoid: +- giant upfront architecture +- premature optimization +- speculative abstractions +- over-engineering +- unnecessary complexity +- unnecessary microservices \ No newline at end of file diff --git a/ai/context/domain_glossary.md b/ai/context/domain_glossary.md new file mode 100644 index 0000000..beebe93 --- /dev/null +++ b/ai/context/domain_glossary.md @@ -0,0 +1,17 @@ +# Domain Glossary + +This glossary provides definitions for key terms and concepts related to the domain of artificial intelligence (AI). It serves as a reference for understanding the terminology used in AI research, development, and applications. + +Use this file to maintain shared terminology. + +Consistent terminology significantly improves: +- communication +- AI consistency +- implement quality + +## General Terms + +- **User**: Final application consumer, typically a human, who interacts with the AI system. +- **Developer**: Individual or team responsible for designing, building, and maintaining the system. +- **Stakeholder**: Any party with an interest in the system, including users, developers, business owners, and regulators. +- **Spec**: Incremental implementation plan for a specific feature or functionality, often documented as a set of requirements and design decisions. \ No newline at end of file diff --git a/ai/context/project_vision.md b/ai/context/project_vision.md new file mode 100644 index 0000000..3247020 --- /dev/null +++ b/ai/context/project_vision.md @@ -0,0 +1,44 @@ +# Project Vision + +## Project Name + +TaskNote + +## Problem Statement + +In today's fast-paced world, individuals and teams often struggle to keep track of their tasks, deadlines, and project +progress. Traditional task management tools can be overwhelming and lack the flexibility needed to adapt to different +workflows. This leads to decreased productivity, missed deadlines, and increased stress. + +As for notes, people often take notes in various formats and locations, such as notebooks, sticky notes, or digital +documents. This scattered approach can make it difficult to find and organize information, leading to inefficiency and +frustration. + +## Users + +The primary users of TaskNote are: + +- **Individuals**: People who want to manage their personal tasks and notes efficiently. +- **Casual**: Users who need a simple and intuitive tool for managing their tasks and notes without the complexity of traditional task management software. +- **Developers**: Individuals who want to manage their coding tasks and notes in a streamlined manner. + +## Core Features + +- **Authentication**: Allow users to create accounts and securely log in to access their tasks and notes. +- **Task Management**: Create, organize, and prioritize tasks with deadlines and reminders. +- **Note-Taking**: Capture and organize notes in a flexible format, allowing for easy retrieval and organization. +- **Collaboration**: Enable users to share notes with others for better collaboration and teamwork. +- **Search and Organization**: Provide powerful search capabilities and organizational tools to help users find and manage their tasks and notes efficiently. +- **Multi-Language Support**: Support multiple languages to cater to a global user base. + +## Constraints + +- **GraalVM Compatibility**: The application must be compatible with GraalVM to leverage its performance benefits and support for multiple languages. +- **Mobile-first UI**: The user interface should be designed with a mobile-first approach to ensure a seamless experience across devices. + +## Success Criteria + +- Users can manage their tasks and notes efficiently, leading to increased productivity and reduced stress. +- Positive user feedback and high engagement with the application. +- Specs remain under control, with a clear roadmap for future features and improvements. +- Codebase remains maintainable and scalable, allowing for easy addition of new features and improvements over time. \ No newline at end of file diff --git a/ai/context/tech_stack.md b/ai/context/tech_stack.md new file mode 100644 index 0000000..8521077 --- /dev/null +++ b/ai/context/tech_stack.md @@ -0,0 +1,34 @@ +# Tech Stack + +## Frontend + +- **React**: A popular JavaScript library for building user interfaces, particularly single-page applications. +- **TypeScript**: A superset of JavaScript that adds static typing, improving code quality and maintainability. +- **Bootstrap**: A widely used CSS framework for building responsive and mobile-first websites. +- **Fetch API**: A modern interface for making HTTP requests from the browser, used for communicating with the backend. +- **Vite**: A build tool that provides a fast development environment and optimized production builds for modern web applications. + +## Backend + +- **Java**: A widely used programming language known for its portability, performance, and extensive ecosystem. +- **Spring Boot**: A framework for building production-ready applications with Java, providing features like dependency injection, security, and data access. +- **Spring Security**: A framework for securing Java applications, providing authentication and authorization features to protect resources and manage user access. +- **GraalVM**: A high-performance runtime that supports multiple programming languages, allowing for efficient execution of Java applications and interoperability with other languages. +- **JPA (Java Persistence API)**: A specification for managing relational data in Java applications, providing a standard way to interact with databases using object-relational mapping (ORM). + +## Database +- **PostgreSQL**: A powerful, open-source relational database management system known for its reliability and performance. + +## Infrastructure + +- **Docker**: A platform for developing, shipping, and running applications in containers, providing consistency across different environments. +- **Kubernetes**: An open-source container orchestration system for automating the deployment, scaling, and management of containerized applications. +- **Terraform**: An infrastructure as code tool that allows for the provisioning and management of cloud resources in a declarative manner. +- **VPS**: Virtual Private Server, a virtualized server that provides dedicated resources and control over the hosting environment for deploying applications. + +## AI Tools + +- **Crusher**: An AI tool for code generation and assistance, helping developers write code more efficiently and accurately. +- **GitHub Copilot**: An AI-powered code completion tool that provides suggestions and helps developers write code faster by leveraging machine learning models trained on a vast amount of code from GitHub repositories. +- **Gemini**: An AI tool for natural language processing and understanding, enabling developers to build applications that can process and generate human-like text. +- **Groq**: An AI tool for optimizing and accelerating machine learning models, providing efficient execution and improved performance for AI applications. diff --git a/ai/contracts/templates/feedback_contract.md b/ai/contracts/templates/feedback_contract.md new file mode 100644 index 0000000..99cae49 --- /dev/null +++ b/ai/contracts/templates/feedback_contract.md @@ -0,0 +1,50 @@ +# Feedback Contract + +This contract is used for: +- reviews +- implementation feedback +- architecture feedback +- iterative improvement + +--- + +## 1. What Worked Well + +Highlight: + +- Good implementation choices +- Maintainability improvements +- Strong architectural decisions + +## 2. Areas For Improvement + +Identify: + +- Unclear implementation +- Maintainability concerns +- Architectural inconsistencies + +## 3. Risks + +Surface: + +- Technical debt +- Scalability concerns +- Workflow fragility + +## 4. Recommendations + +Provide: + +- Practical improvements +- Simplification opportunities +- Next iteration suggestions + +## Philosophy + +Feedback should: + +- Improve clarity +- Support maintainability +- Remain constructive +- Avoid unnecessary perfectionism diff --git a/ai/contracts/templates/handoff_contract.md b/ai/contracts/templates/handoff_contract.md new file mode 100644 index 0000000..3453ed3 --- /dev/null +++ b/ai/contracts/templates/handoff_contract.md @@ -0,0 +1,68 @@ +# Handoff Contract + +This contract is used whenever work moves between roles. + +Example: +- PM → Architect +- Architect → Developer +- Developer → Reviewer + +--- + +## 1. Summary + +Concise explanation of: + +- Completed work +- Important decisions +- Implementation status + +## 2. Completed Work + +Explicitly list: + +- Implemented features +- Completed tasks +- Validated decisions + +## 3. Pending Work + +List: + +- Unfinished work +- Blockers +- Remaining implementation + +## 4. Important Decisions + +Document: + +- Tradeoffs +- Architectural decisions +- Assumptions +- Simplifications + +## 5. Risks + +Identify: + +- Technical concerns +- Unclear requirements +- Scalability limitations +- Possible regressions + +## 6. Questions + +List: + +- Unresolved ambiguity +- Missing requirements +- Pending decisions + +## 7. Recommended Next Step + +Clearly explain: + +- What should happen next +- Which role should act next +- What should be prioritized \ No newline at end of file diff --git a/ai/contracts/templates/question_contract.md b/ai/contracts/templates/question_contract.md new file mode 100644 index 0000000..7ad55c0 --- /dev/null +++ b/ai/contracts/templates/question_contract.md @@ -0,0 +1,58 @@ +# Question Contract + +## Description + +This contract is used when: + +- Requirements are not clear +- Assumptions must be validated +- More information is needed to proceed +- Implementation direction is uncertain + +## 1. Context + +Explain: + +- The background of the problem or task +- Why it is important to clarify the requirements or assumptions +- Related specs or documentation +- Implementation area + +## 2. Questions + +Clearly state: + +- The specific questions that need to be answered +- What is unclear +- What assumptions are being made +- What decision needs to be made + +## 3. Why It Matters + +Explain: + +- The potential impact of not clarifying the questions +- How it affects the implementation +- Architectural implications +- Performance implications +- User experience implications +- Workflow consequences + +## 4. Suggested Options + +Provide: + +- Possible answers to the questions +- Pros and cons of each option +- Any relevant data or evidence to support the options + +## 5. Philosophy + +Good questions reduce: + +- Hallucinations +- Unnecessary work +- Rework +- Implementation drift +- Misalignment with requirements +- Hidden assumptions \ No newline at end of file diff --git a/ai/orchestration/context_policy.md b/ai/orchestration/context_policy.md new file mode 100644 index 0000000..618b4b4 --- /dev/null +++ b/ai/orchestration/context_policy.md @@ -0,0 +1,56 @@ +# Context Policy + +AI systems perform significantly better when context is: +- structured +- concise +- consistent +- relevant + +## Core Rules + +### 1. Read Context Before Acting + +Before: +- implementing features +- generating specs +- reviewing code +- proposing architecture + +Always review: +- project vision +- architecture principles +- glossary +- relevant specs + +--- + +### 2. Respect Shared Terminology + +Use terminology consistently. + +The glossary exists to: +- reduce ambiguity +- improve communication +- improve AI consistency + +--- + +### 3. Avoid Context Overload + +More context is NOT always better. + +Prefer: +- focused context +- relevant files +- small scoped discussions + +--- + +### 4. Surface Missing Context + +If important context is missing: +- ask questions +- document assumptions +- identify ambiguity + +Avoid silently inventing requirements. \ No newline at end of file diff --git a/ai/orchestration/handoff_rules.md b/ai/orchestration/handoff_rules.md new file mode 100644 index 0000000..ab90852 --- /dev/null +++ b/ai/orchestration/handoff_rules.md @@ -0,0 +1,39 @@ +# Handoff Rules + +Every handoff between roles should include: + +- completed work +- pending work +- risks +- important decisions +- open questions +- recommended next step + +--- + +## Goals + +Handoffs exist to: +- preserve continuity +- reduce ambiguity +- improve collaboration +- support incremental delivery + +--- + +## Good Handoffs + +Good handoffs are: +- concise +- explicit +- actionable +- context-aware + +Avoid vague summaries. + +--- + +## Important + +Hidden assumptions are one of the biggest causes +of AI inconsistency and implementation drift. \ No newline at end of file diff --git a/ai/orchestration/orchestrator.md b/ai/orchestration/orchestrator.md new file mode 100644 index 0000000..3a4aac7 --- /dev/null +++ b/ai/orchestration/orchestrator.md @@ -0,0 +1,40 @@ +# Orchestrator + +The orchestrator coordinates: +- context +- specs +- architecture +- implementation +- review workflows + +## Primary Goals + +- maintain incremental progress +- reduce ambiguity +- preserve consistency +- support maintainability + +--- + +## Orchestration Responsibilities + +- identify next executable spec +- validate dependencies +- coordinate role transitions +- ensure contracts are respected +- maintain delivery momentum + +--- + +## Workflow Philosophy + +Prefer: +- small focused steps +- explicit transitions +- structured handoffs +- iterative delivery + +Avoid: +- giant implementation phases +- uncontrolled context growth +- hidden assumptions \ No newline at end of file diff --git a/ai/orchestration/workflow.md b/ai/orchestration/workflow.md new file mode 100644 index 0000000..e7fb2b0 --- /dev/null +++ b/ai/orchestration/workflow.md @@ -0,0 +1,71 @@ +# AI Workflow + +This repository follows a lightweight AI-native engineering workflow. + +```text +Context → Specs → Architecture → Implementation → Review → Iteration +``` + +--- + +# 1. Context Definition + +Goal: create shared understanding. + +Typical artifacts: + +- Project vision +- Glossary +- Personas +- Architecture principles +- Tech stack + +Context quality directly impacts AI output quality. + +# 2. Spec Generation + +Goal: break work into small incremental deliverables. + +Good specs are: +- Small +- Focused +- Testable +- Independently understandable + +# 3. Architecture Validation + +Goal: ensure sustainable technical direction. + +Architect responsibilities: +- Validate boundaries +- Identify risks +- Reduce complexity +- Support maintainability + +# 4. Implementation + +Goal: deliver working software incrementally. + +Developer responsibilities: +- Implement clearly +- Respect architecture +- Preserve maintainability + +# 5. Testing & Review + +Goal: validate correctness and maintainability. + +Focus on: +- Spec completion +- Workflow correctness +- Architectural consistency + +# 6. Iteration + +Goal: continuously improve: +- Specs +- Workflows +- Context +- Implementation + +AI-native engineering is iterative by nature. \ No newline at end of file diff --git a/ai/roles/architect.md b/ai/roles/architect.md new file mode 100644 index 0000000..dd8ccb5 --- /dev/null +++ b/ai/roles/architect.md @@ -0,0 +1,53 @@ +# Role: Architect + +You are responsible for maintaining technical clarity, +system boundaries, and implementation simplicity. + +## Primary Responsibilities + +- Define system boundaries +- Validate architecture decisions +- Reduce unnecessary complexity +- Identify technical risks +- Support maintainability +- Guide incremental delivery + +--- + +## Prioritize + +- clarity +- maintainability +- simplicity +- developer experience +- incremental progress + +--- + +## Avoid + +- overengineering +- speculative abstractions +- premature optimization +- architecture astronautics + +--- + +## Expected Outputs + +- architecture notes +- API guidance +- data flow recommendations +- technical decisions +- risk analysis + +--- + +## Collaboration + +Work closely with: +- PM for requirement clarification +- Developers for implementation guidance +- Reviewers for consistency validation + +Your role is guidance and structure, not implementation ownership. \ No newline at end of file diff --git a/ai/roles/developer.md b/ai/roles/developer.md new file mode 100644 index 0000000..13a6950 --- /dev/null +++ b/ai/roles/developer.md @@ -0,0 +1,49 @@ +# Role: Developer + +You are responsible for implementing specs incrementally, +clearly, and maintainably. + +## Primary Responsibilities + +- Implement specs +- Respect architecture boundaries +- Keep code maintainable +- Surface blockers early +- Preserve readability + +--- + +## Engineering Philosophy + +Prefer: +- explicit logic +- composable systems +- simple implementations +- incremental delivery + +Avoid: +- unnecessary abstractions +- giant refactors +- hidden side effects +- premature optimization + +--- + +## Before Implementation + +Always review: +- project context +- architecture principles +- current spec +- related workflows + +--- + +## Expected Outputs + +- implementation code +- tests +- documentation +- migrations +- APIs +- technical notes \ No newline at end of file diff --git a/ai/roles/pm.md b/ai/roles/pm.md new file mode 100644 index 0000000..4e5a2ce --- /dev/null +++ b/ai/roles/pm.md @@ -0,0 +1,40 @@ +# Role: Product Manager + +You are responsible for: +- reducing ambiguity +- clarifying requirements +- prioritizing delivery +- maintaining user focus + +## Responsibilities + +- refine specs +- clarify user value +- define acceptance criteria +- reduce scope ambiguity +- support incremental delivery + +--- + +## Product Philosophy + +Prefer: +- small focused specs +- clear workflows +- simple user journeys +- fast iteration + +Avoid: +- oversized features +- vague requirements +- unnecessary complexity + +--- + +## Success Criteria + +A good spec should be: +- understandable +- implementable +- testable +- incremental \ No newline at end of file diff --git a/ai/roles/reviewer.md b/ai/roles/reviewer.md new file mode 100644 index 0000000..416a51c --- /dev/null +++ b/ai/roles/reviewer.md @@ -0,0 +1,36 @@ +# Role: Reviewer + +You are responsible for validating: +- quality +- maintainability +- spec completion +- architectural consistency + +## Responsibilities + +- Verify acceptance criteria +- Validate readability +- Identify technical debt +- Surface risks +- Recommend improvements + +--- + +## Review Philosophy + +Focus on: +- practical maintainability +- implementation clarity +- architecture consistency + +Avoid: +- perfectionism +- unnecessary nitpicks +- overcomplicated suggestions + +--- + +## Important + +The goal is sustainable delivery, +not theoretical perfection. \ No newline at end of file diff --git a/ai/roles/tester.md b/ai/roles/tester.md new file mode 100644 index 0000000..44dec11 --- /dev/null +++ b/ai/roles/tester.md @@ -0,0 +1,36 @@ +# Role: Tester + +You are responsible for validating: +- expected behavior +- acceptance criteria +- workflow correctness +- critical edge cases + +## Responsibilities + +- validate specs +- test workflows +- identify regressions +- surface inconsistencies + +--- + +## Testing Philosophy + +Focus on: +- critical workflows +- realistic scenarios +- maintainability +- clarity + +Avoid: +- unnecessary exhaustive testing +- unrealistic edge cases +- overcomplicated testing strategies + +--- + +## Important + +The goal is confidence and reliability, +not perfect theoretical coverage. \ No newline at end of file diff --git a/ai/skills/create_handoff_contract.md b/ai/skills/create_handoff_contract.md new file mode 100644 index 0000000..93b4aff --- /dev/null +++ b/ai/skills/create_handoff_contract.md @@ -0,0 +1,14 @@ +# Skill: Create Handoff Contract + +Good handoffs preserve: +- continuity +- implementation context +- architectural decisions + +Include: +- summary +- completed work +- pending work +- risks +- questions +- recommended next step \ No newline at end of file diff --git a/ai/skills/review_code_quality.md b/ai/skills/review_code_quality.md new file mode 100644 index 0000000..8b6657f --- /dev/null +++ b/ai/skills/review_code_quality.md @@ -0,0 +1,14 @@ +# Skill: Review Code Quality + +## Focus Areas + +- readability +- maintainability +- consistency +- architectural alignment + +## Avoid + +- unnecessary nitpicks +- perfectionism +- speculative suggestions \ No newline at end of file diff --git a/ai/skills/write_spec.md b/ai/skills/write_spec.md new file mode 100644 index 0000000..c76f9a0 --- /dev/null +++ b/ai/skills/write_spec.md @@ -0,0 +1,19 @@ +# Skill: Write Spec + +## Goal + +Create small incremental implementation specs. + +## Good Specs + +Good specs: +- have clear goals +- define acceptance criteria +- remain focused +- avoid ambiguity + +## Avoid + +- giant specs +- vague requirements +- mixing unrelated concerns \ No newline at end of file diff --git a/ai/skills/write_tests.md b/ai/skills/write_tests.md new file mode 100644 index 0000000..e820212 --- /dev/null +++ b/ai/skills/write_tests.md @@ -0,0 +1,14 @@ +# Skill: Write Tests + +## Prioritize + +- critical workflows +- expected behavior +- important edge cases + +## Philosophy + +Testing should improve: +- confidence +- reliability +- maintainability \ No newline at end of file diff --git a/ai/specs/001-add-multiple-tags.md b/ai/specs/001-add-multiple-tags.md new file mode 100644 index 0000000..bfdcb56 --- /dev/null +++ b/ai/specs/001-add-multiple-tags.md @@ -0,0 +1,58 @@ +# Spec: Support Multiple Tags for Tasks and Notes + +## Goal + +Enable users to associate multiple tags with both tasks and notes, enhancing organization and search capabilities. + +--- + +## User Value + +Users can categorize their tasks and notes more granularly, making it easier to find and filter information. This improves overall productivity and reduces the time spent searching for specific items. + +--- + +## Requirements + +- Users must be able to add multiple tags to a single task. +- Users must be able to add multiple tags to a single note. +- The system should support adding new tags and selecting existing tags. +- Display of multiple tags associated with tasks and notes in the UI. +- Backend API endpoints must be updated to support multiple tags for tasks and notes. +- Database schema must be updated to store multiple tags for tasks and notes. + +--- + +## Acceptance Criteria + +- [ ] When editing a task, a user can add more than one tag. +- [ ] When editing a note, a user can add more than one tag. +- [ ] All tags associated with a task are displayed when viewing the task. +- [ ] All tags associated with a note are displayed when viewing the note. +- [ ] Users can filter tasks by multiple tags. +- [ ] Users can filter notes by multiple tags. +- [ ] The API for tasks and notes allows for creation and update with multiple tags. +- [ ] The database correctly stores and retrieves multiple tags for tasks and notes. + +--- + +## Dependencies + +None. + +--- + +## Risks + +- **Data Migration**: Existing single-tag data will need to be migrated to the new multi-tag schema, potentially requiring a database migration script. +- **Performance Impact**: Storing and querying multiple tags might introduce performance overhead, especially for large datasets. This will need careful indexing and optimization. +- **UI Complexity**: Designing a user-friendly interface for managing multiple tags could add complexity to the frontend. +- **API backward compatibility**: Changes to the API might break existing clients if not handled carefully with versioning or clear migration paths. + +--- + +## Notes + +- Consider a many-to-many relationship between tasks/notes and tags in the database. +- Frontend tag input should allow for auto-completion of existing tags. +- The API should handle tag creation on the fly if a new tag is submitted. diff --git a/ai/specs/README.md b/ai/specs/README.md new file mode 100644 index 0000000..82f6f88 --- /dev/null +++ b/ai/specs/README.md @@ -0,0 +1,33 @@ +# Specs + +Specs are the primary implementation units in this repository. + +## Good Specs + +Good specs are: +- small +- incremental +- independently understandable +- testable +- focused on one responsibility + +--- + +## Recommended Structure + +Each spec should contain: +- spec.md +- architecture.md +- tasks.md +- optional contracts/ + +--- + +## Recommended Size + +Prefer: +- 3 to 8 specs maximum for dojo projects + +Avoid: +- giant umbrella specs +- multi-week implementation specs \ No newline at end of file diff --git a/ai/specs/_template/spec.md b/ai/specs/_template/spec.md new file mode 100644 index 0000000..aebc4d0 --- /dev/null +++ b/ai/specs/_template/spec.md @@ -0,0 +1,43 @@ +# Spec: + +## Goal + +What are we building? + +--- + +## User Value + +Why does this matter? + +--- + +## Requirements + +- Requirement 1 +- Requirement 2 + +--- + +## Acceptance Criteria + +- [ ] Criteria 1 +- [ ] Criteria 2 + +--- + +## Dependencies + +List required previous specs. + +--- + +## Risks + +Identify possible implementation risks. + +--- + +## Notes + +Additional implementation guidance. \ No newline at end of file diff --git a/ai/templates/orchestration_prompt.md b/ai/templates/orchestration_prompt.md new file mode 100644 index 0000000..6d9b622 --- /dev/null +++ b/ai/templates/orchestration_prompt.md @@ -0,0 +1,16 @@ +# Orchestration Prompt + +Using the orchestrator workflow defined at `ai/orchestration/orchestrator.md`, +execute the specs incrementally. + +Respect: +- architecture principles +- role responsibilities +- context policies +- handoff rules + +Keep implementation: +- simple +- maintainable +- incremental +- production-like diff --git a/ai/templates/spec_feature_prompt.md b/ai/templates/spec_feature_prompt.md new file mode 100644 index 0000000..be8293f --- /dev/null +++ b/ai/templates/spec_feature_prompt.md @@ -0,0 +1,28 @@ +# Spec for a feature prompt example + +Based on the project context, defined at `ai/context/*.md` files, generate one small increment spec file for a new +feature: . + +Requirements: +- small scope +- independently implementable +- clear acceptance criteria +- incremental delivery + +The spec file should include: +- goal +- user value +- requirements +- acceptance criteria +- risks + +Name the spec file as `000-short-descriptive-name.md` similar to git branch naming convention, starting with 001, 002, +003, and so on, and place it in the appropriate directory under `ai/specs/`. + + +-- + +Based on the project context, defined at ai/context/*.md files, generate one small increment spec file for a new +feature: support multiple tags for both tasks and notes. Requirements: small scope, clear acceptance criteria. The spec +file should include: goal, user value, requirements, acceptance criteria, risks. Name the spec file following this +template: 000-short-description.md similar to git branch naming convention, starting with 001, 002, 003, and so on. \ No newline at end of file diff --git a/ai/templates/spec_generation_prompt.md b/ai/templates/spec_generation_prompt.md new file mode 100644 index 0000000..054599d --- /dev/null +++ b/ai/templates/spec_generation_prompt.md @@ -0,0 +1,18 @@ +# Spec Generation Prompt + +Based on the project context, +generate 3 to 8 small incremental specs. + +Requirements: +- small scope +- independently implementable +- clear acceptance criteria +- incremental delivery +- avoid overengineering + +Each spec should include: +- goal +- user value +- requirements +- acceptance criteria +- risks \ No newline at end of file diff --git a/server/src/main/java/br/com/tasknoteapp/server/entity/NoteEntity.java b/server/src/main/java/br/com/tasknoteapp/server/entity/NoteEntity.java index f957669..f9d5708 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/entity/NoteEntity.java +++ b/server/src/main/java/br/com/tasknoteapp/server/entity/NoteEntity.java @@ -1,17 +1,20 @@ package br.com.tasknoteapp.server.entity; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -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.Table; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.HashSet; +import java.util.Set; import java.time.LocalDateTime; /** This class represents a note in the database. */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor @Entity @Table(name = "notes") public class NoteEntity { @@ -29,8 +32,7 @@ public class NoteEntity { @ManyToOne(fetch = FetchType.LAZY) private UserEntity user; - @Column(name = "tag", length = 30) - private String tag; + @Column(name = "last_update") private LocalDateTime lastUpdate; @@ -41,6 +43,9 @@ public class NoteEntity { @Column(name = "share_token", length = 36) private String shareToken; + @OneToMany(mappedBy = "note", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + private Set noteTags = new HashSet<>(); + public Long getId() { return id; } @@ -73,13 +78,7 @@ public void setUser(UserEntity user) { this.user = user; } - public String getTag() { - return tag; - } - public void setTag(String tag) { - this.tag = tag; - } public LocalDateTime getLastUpdate() { return lastUpdate; @@ -133,11 +132,13 @@ public String toString() { + ", description='" + description + '\'' - + ", tag='" - + tag - + '\'' + ", lastUpdate=" + lastUpdate + + ", shared=" + + shared + + ", shareToken='" + + shareToken + + '\'' + '}'; } } diff --git a/server/src/main/java/br/com/tasknoteapp/server/entity/NoteTagEntity.java b/server/src/main/java/br/com/tasknoteapp/server/entity/NoteTagEntity.java new file mode 100644 index 0000000..a746e38 --- /dev/null +++ b/server/src/main/java/br/com/tasknoteapp/server/entity/NoteTagEntity.java @@ -0,0 +1,29 @@ +package br.com.tasknoteapp.server.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.util.UUID; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "note_tags", schema = "tasknote") +public class NoteTagEntity implements Serializable { + + @Id + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "note_id", nullable = false) + private NoteEntity note; + + @Id + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "tag_id", nullable = false) + private TagEntity tag; +} diff --git a/server/src/main/java/br/com/tasknoteapp/server/entity/TagEntity.java b/server/src/main/java/br/com/tasknoteapp/server/entity/TagEntity.java new file mode 100644 index 0000000..598637a --- /dev/null +++ b/server/src/main/java/br/com/tasknoteapp/server/entity/TagEntity.java @@ -0,0 +1,25 @@ +package br.com.tasknoteapp.server.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "tags", schema = "tasknote") +public class TagEntity { + + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + private UUID id; + + @Column(nullable = false, unique = true, length = 30) + private String name; +} diff --git a/server/src/main/java/br/com/tasknoteapp/server/entity/TaskEntity.java b/server/src/main/java/br/com/tasknoteapp/server/entity/TaskEntity.java index 312ccd5..f89353f 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/entity/TaskEntity.java +++ b/server/src/main/java/br/com/tasknoteapp/server/entity/TaskEntity.java @@ -1,18 +1,21 @@ package br.com.tasknoteapp.server.entity; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -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.Table; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.HashSet; +import java.util.Set; import java.time.LocalDate; import java.time.LocalDateTime; /** This class represents a task in the database. */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor @Entity @Table(name = "tasks") public class TaskEntity { @@ -39,8 +42,10 @@ public class TaskEntity { @Column(name = "high_priority") private Boolean highPriority; - @Column(name = "tag", length = 30) - private String tag; + @OneToMany(mappedBy = "task", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + private Set taskTags = new HashSet<>(); + + public Long getId() { return id; @@ -98,13 +103,7 @@ public void setHighPriority(Boolean highPriority) { this.highPriority = highPriority; } - public String getTag() { - return tag; - } - public void setTag(String tag) { - this.tag = tag; - } @Override public boolean equals(Object o) { @@ -139,9 +138,6 @@ public String toString() { + dueDate + ", highPriority=" + highPriority - + ", tag='" - + tag - + '\'' + '}'; } } diff --git a/server/src/main/java/br/com/tasknoteapp/server/entity/TaskTagEntity.java b/server/src/main/java/br/com/tasknoteapp/server/entity/TaskTagEntity.java new file mode 100644 index 0000000..a29764d --- /dev/null +++ b/server/src/main/java/br/com/tasknoteapp/server/entity/TaskTagEntity.java @@ -0,0 +1,29 @@ +package br.com.tasknoteapp.server.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.util.UUID; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "task_tags", schema = "tasknote") +public class TaskTagEntity implements Serializable { + + @Id + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "task_id", nullable = false) + private TaskEntity task; + + @Id + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "tag_id", nullable = false) + private TagEntity tag; +} diff --git a/server/src/main/resources/db/migration/V202605211000__add_multiple_tags.sql b/server/src/main/resources/db/migration/V202605211000__add_multiple_tags.sql new file mode 100644 index 0000000..b2bace8 --- /dev/null +++ b/server/src/main/resources/db/migration/V202605211000__add_multiple_tags.sql @@ -0,0 +1,63 @@ +-- Create a new tags table +CREATE TABLE tasknote.tags ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(30) UNIQUE NOT NULL +); + +-- Create a junction table for tasks and tags +CREATE TABLE tasknote.task_tags ( + task_id UUID NOT NULL, + tag_id UUID NOT NULL, + PRIMARY KEY (task_id, tag_id), + CONSTRAINT fk_task + FOREIGN KEY (task_id) + REFERENCES tasknote.tasks (id) + ON DELETE CASCADE, + CONSTRAINT fk_tag_task + FOREIGN KEY (tag_id) + REFERENCES tasknote.tags (id) + ON DELETE CASCADE +); + +-- Create a junction table for notes and tags +CREATE TABLE tasknote.note_tags ( + note_id UUID NOT NULL, + tag_id UUID NOT NULL, + PRIMARY KEY (note_id, tag_id), + CONSTRAINT fk_note + FOREIGN KEY (note_id) + REFERENCES tasknote.notes (id) + ON DELETE CASCADE, + CONSTRAINT fk_tag_note + FOREIGN KEY (tag_id) + REFERENCES tasknote.tags (id) + ON DELETE CASCADE +); + +-- Migrate existing single tags to the new many-to-many structure +INSERT INTO tasknote.tags (name) +SELECT DISTINCT tag FROM tasknote.tasks WHERE tag IS NOT NULL +ON CONFLICT (name) DO NOTHING; + +INSERT INTO tasknote.tags (name) +SELECT DISTINCT tag FROM tasknote.notes WHERE tag IS NOT NULL +ON CONFLICT (name) DO NOTHING; + +INSERT INTO tasknote.task_tags (task_id, tag_id) +SELECT t.id, tg.id +FROM tasknote.tasks t +JOIN tasknote.tags tg ON t.tag = tg.name +WHERE t.tag IS NOT NULL; + +INSERT INTO tasknote.note_tags (note_id, tag_id) +SELECT n.id, tg.id +FROM tasknote.notes n +JOIN tasknote.tags tg ON n.tag = tg.name +WHERE n.tag IS NOT NULL; + +-- Remove the old 'tag' columns +ALTER TABLE tasknote.tasks + DROP COLUMN tag; + +ALTER TABLE tasknote.notes + DROP COLUMN tag; \ No newline at end of file From bbd42f36a7eca0337d8db70de7bee98d723d4329 Mon Sep 17 00:00:00 2001 From: Ricardo Campos Date: Thu, 21 May 2026 19:40:32 +0200 Subject: [PATCH 02/11] feat: add spec for rich text feature --- ai/specs/002-markdown-formatting-toolbar.md | 36 +++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 ai/specs/002-markdown-formatting-toolbar.md diff --git a/ai/specs/002-markdown-formatting-toolbar.md b/ai/specs/002-markdown-formatting-toolbar.md new file mode 100644 index 0000000..2b11a60 --- /dev/null +++ b/ai/specs/002-markdown-formatting-toolbar.md @@ -0,0 +1,36 @@ +# Spec 002: Markdown Formatting Toolbar for Notes + +## Goal +Enhance the Note editing experience by adding a Markdown formatting toolbar to the note creation and editing interface. This provides a "rich text" editing capability while preserving the existing Markdown-based storage and rendering system. + +## User Value +Users who are unfamiliar with Markdown syntax can easily format their notes (bold, italic, lists, etc.) using familiar UI controls. This reduces the cognitive load and makes the application more accessible to a broader audience without losing the power of Markdown for advanced users. + +## Requirements +- **Toolbar Placement**: A horizontal toolbar should be placed immediately above the "Content" textarea in the Note creation/editing form. +- **Formatting Actions**: The toolbar must include buttons for the following actions: + - **Bold**: Wraps selection with `**`. + - **Italic**: Wraps selection with `_`. + - **Heading**: Prepends `### ` to the current line or selection. + - **Bullet List**: Prepends `- ` to the current line or selection. + - **Link**: Inserts a Markdown link template `[text](url)`. +- **Interaction Logic**: + - If text is selected, clicking a button should wrap/prefix the selection. + - If no text is selected, clicking a button should insert the Markdown symbols at the cursor position. + - The textarea should regain focus immediately after a toolbar button is clicked. +- **Styling**: The toolbar should use standard Bootstrap components (e.g., `ButtonGroup`) and icons (e.g., `react-bootstrap-icons`) to match the existing project aesthetic. + +## Acceptance Criteria +- [ ] The toolbar is visible in the `NoteAdd` view for both adding a new note and editing an existing one. +- [ ] Clicking the **Bold** button wraps the selected text in the textarea with `**`. +- [ ] Clicking the **Italic** button wraps the selected text with `_`. +- [ ] Clicking the **Heading** button adds `### ` at the cursor or selection start. +- [ ] Clicking the **Bullet List** button adds `- ` at the start of the line. +- [ ] Clicking the **Link** button inserts `[](url)` or wraps selection as `[selection](url)`. +- [ ] The note can be saved successfully with the newly formatted content. +- [ ] The "Preview Markdown" modal correctly renders the formatted content. + +## Risks +- **Cursor Management**: Maintaining or restoring cursor position/selection after formatting might be technically challenging in a standard HTML `textarea`. +- **Mobile UX**: A long toolbar might overflow on small screens, requiring careful responsive design (e.g., horizontal scrolling or wrapping). +- **Undo/Redo**: Standard browser undo/redo might behave unexpectedly if the textarea value is manipulated programmatically. From 32d80cef450d7402e361f4bcebc293dc5643d31c Mon Sep 17 00:00:00 2001 From: Ricardo Campos Date: Thu, 21 May 2026 20:26:20 +0200 Subject: [PATCH 03/11] chore: restore file from broken AI session --- .../tasknoteapp/server/entity/NoteEntity.java | 41 ++++++------ .../server/entity/NoteTagEntity.java | 29 --------- .../tasknoteapp/server/entity/TagEntity.java | 25 -------- .../tasknoteapp/server/entity/TaskEntity.java | 36 ++++++----- .../server/entity/TaskTagEntity.java | 29 --------- .../V202605211000__add_multiple_tags.sql | 63 ------------------- 6 files changed, 40 insertions(+), 183 deletions(-) delete mode 100644 server/src/main/java/br/com/tasknoteapp/server/entity/NoteTagEntity.java delete mode 100644 server/src/main/java/br/com/tasknoteapp/server/entity/TagEntity.java delete mode 100644 server/src/main/java/br/com/tasknoteapp/server/entity/TaskTagEntity.java delete mode 100644 server/src/main/resources/db/migration/V202605211000__add_multiple_tags.sql diff --git a/server/src/main/java/br/com/tasknoteapp/server/entity/NoteEntity.java b/server/src/main/java/br/com/tasknoteapp/server/entity/NoteEntity.java index f9d5708..f957669 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/entity/NoteEntity.java +++ b/server/src/main/java/br/com/tasknoteapp/server/entity/NoteEntity.java @@ -1,20 +1,17 @@ package br.com.tasknoteapp.server.entity; -import jakarta.persistence.*; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.util.HashSet; -import java.util.Set; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +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.Table; import java.time.LocalDateTime; /** This class represents a note in the database. */ -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor @Entity @Table(name = "notes") public class NoteEntity { @@ -32,7 +29,8 @@ public class NoteEntity { @ManyToOne(fetch = FetchType.LAZY) private UserEntity user; - + @Column(name = "tag", length = 30) + private String tag; @Column(name = "last_update") private LocalDateTime lastUpdate; @@ -43,9 +41,6 @@ public class NoteEntity { @Column(name = "share_token", length = 36) private String shareToken; - @OneToMany(mappedBy = "note", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) - private Set noteTags = new HashSet<>(); - public Long getId() { return id; } @@ -78,7 +73,13 @@ public void setUser(UserEntity user) { this.user = user; } + public String getTag() { + return tag; + } + public void setTag(String tag) { + this.tag = tag; + } public LocalDateTime getLastUpdate() { return lastUpdate; @@ -132,13 +133,11 @@ public String toString() { + ", description='" + description + '\'' + + ", tag='" + + tag + + '\'' + ", lastUpdate=" + lastUpdate - + ", shared=" - + shared - + ", shareToken='" - + shareToken - + '\'' + '}'; } } diff --git a/server/src/main/java/br/com/tasknoteapp/server/entity/NoteTagEntity.java b/server/src/main/java/br/com/tasknoteapp/server/entity/NoteTagEntity.java deleted file mode 100644 index a746e38..0000000 --- a/server/src/main/java/br/com/tasknoteapp/server/entity/NoteTagEntity.java +++ /dev/null @@ -1,29 +0,0 @@ -package br.com.tasknoteapp.server.entity; - -import jakarta.persistence.*; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.io.Serializable; -import java.util.UUID; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -@Entity -@Table(name = "note_tags", schema = "tasknote") -public class NoteTagEntity implements Serializable { - - @Id - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "note_id", nullable = false) - private NoteEntity note; - - @Id - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "tag_id", nullable = false) - private TagEntity tag; -} diff --git a/server/src/main/java/br/com/tasknoteapp/server/entity/TagEntity.java b/server/src/main/java/br/com/tasknoteapp/server/entity/TagEntity.java deleted file mode 100644 index 598637a..0000000 --- a/server/src/main/java/br/com/tasknoteapp/server/entity/TagEntity.java +++ /dev/null @@ -1,25 +0,0 @@ -package br.com.tasknoteapp.server.entity; - -import jakarta.persistence.*; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.util.UUID; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -@Entity -@Table(name = "tags", schema = "tasknote") -public class TagEntity { - - @Id - @GeneratedValue(strategy = GenerationType.AUTO) - private UUID id; - - @Column(nullable = false, unique = true, length = 30) - private String name; -} diff --git a/server/src/main/java/br/com/tasknoteapp/server/entity/TaskEntity.java b/server/src/main/java/br/com/tasknoteapp/server/entity/TaskEntity.java index f89353f..312ccd5 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/entity/TaskEntity.java +++ b/server/src/main/java/br/com/tasknoteapp/server/entity/TaskEntity.java @@ -1,21 +1,18 @@ package br.com.tasknoteapp.server.entity; -import jakarta.persistence.*; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.util.HashSet; -import java.util.Set; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +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.Table; import java.time.LocalDate; import java.time.LocalDateTime; /** This class represents a task in the database. */ -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor @Entity @Table(name = "tasks") public class TaskEntity { @@ -42,10 +39,8 @@ public class TaskEntity { @Column(name = "high_priority") private Boolean highPriority; - @OneToMany(mappedBy = "task", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) - private Set taskTags = new HashSet<>(); - - + @Column(name = "tag", length = 30) + private String tag; public Long getId() { return id; @@ -103,7 +98,13 @@ public void setHighPriority(Boolean highPriority) { this.highPriority = highPriority; } + public String getTag() { + return tag; + } + public void setTag(String tag) { + this.tag = tag; + } @Override public boolean equals(Object o) { @@ -138,6 +139,9 @@ public String toString() { + dueDate + ", highPriority=" + highPriority + + ", tag='" + + tag + + '\'' + '}'; } } diff --git a/server/src/main/java/br/com/tasknoteapp/server/entity/TaskTagEntity.java b/server/src/main/java/br/com/tasknoteapp/server/entity/TaskTagEntity.java deleted file mode 100644 index a29764d..0000000 --- a/server/src/main/java/br/com/tasknoteapp/server/entity/TaskTagEntity.java +++ /dev/null @@ -1,29 +0,0 @@ -package br.com.tasknoteapp.server.entity; - -import jakarta.persistence.*; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.io.Serializable; -import java.util.UUID; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -@Entity -@Table(name = "task_tags", schema = "tasknote") -public class TaskTagEntity implements Serializable { - - @Id - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "task_id", nullable = false) - private TaskEntity task; - - @Id - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "tag_id", nullable = false) - private TagEntity tag; -} diff --git a/server/src/main/resources/db/migration/V202605211000__add_multiple_tags.sql b/server/src/main/resources/db/migration/V202605211000__add_multiple_tags.sql deleted file mode 100644 index b2bace8..0000000 --- a/server/src/main/resources/db/migration/V202605211000__add_multiple_tags.sql +++ /dev/null @@ -1,63 +0,0 @@ --- Create a new tags table -CREATE TABLE tasknote.tags ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name VARCHAR(30) UNIQUE NOT NULL -); - --- Create a junction table for tasks and tags -CREATE TABLE tasknote.task_tags ( - task_id UUID NOT NULL, - tag_id UUID NOT NULL, - PRIMARY KEY (task_id, tag_id), - CONSTRAINT fk_task - FOREIGN KEY (task_id) - REFERENCES tasknote.tasks (id) - ON DELETE CASCADE, - CONSTRAINT fk_tag_task - FOREIGN KEY (tag_id) - REFERENCES tasknote.tags (id) - ON DELETE CASCADE -); - --- Create a junction table for notes and tags -CREATE TABLE tasknote.note_tags ( - note_id UUID NOT NULL, - tag_id UUID NOT NULL, - PRIMARY KEY (note_id, tag_id), - CONSTRAINT fk_note - FOREIGN KEY (note_id) - REFERENCES tasknote.notes (id) - ON DELETE CASCADE, - CONSTRAINT fk_tag_note - FOREIGN KEY (tag_id) - REFERENCES tasknote.tags (id) - ON DELETE CASCADE -); - --- Migrate existing single tags to the new many-to-many structure -INSERT INTO tasknote.tags (name) -SELECT DISTINCT tag FROM tasknote.tasks WHERE tag IS NOT NULL -ON CONFLICT (name) DO NOTHING; - -INSERT INTO tasknote.tags (name) -SELECT DISTINCT tag FROM tasknote.notes WHERE tag IS NOT NULL -ON CONFLICT (name) DO NOTHING; - -INSERT INTO tasknote.task_tags (task_id, tag_id) -SELECT t.id, tg.id -FROM tasknote.tasks t -JOIN tasknote.tags tg ON t.tag = tg.name -WHERE t.tag IS NOT NULL; - -INSERT INTO tasknote.note_tags (note_id, tag_id) -SELECT n.id, tg.id -FROM tasknote.notes n -JOIN tasknote.tags tg ON n.tag = tg.name -WHERE n.tag IS NOT NULL; - --- Remove the old 'tag' columns -ALTER TABLE tasknote.tasks - DROP COLUMN tag; - -ALTER TABLE tasknote.notes - DROP COLUMN tag; \ No newline at end of file From 784b6163ad7aacaed050ebbfb2285e2e2b5e5b7c Mon Sep 17 00:00:00 2001 From: Ricardo Campos Date: Thu, 21 May 2026 21:02:54 +0200 Subject: [PATCH 04/11] feat: add project constraints to ai --- ai/context/project_constraints.md | 31 +++++++++++++++++++++++++++++++ ai/context/project_vision.md | 4 ++-- 2 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 ai/context/project_constraints.md diff --git a/ai/context/project_constraints.md b/ai/context/project_constraints.md new file mode 100644 index 0000000..496bbd7 --- /dev/null +++ b/ai/context/project_constraints.md @@ -0,0 +1,31 @@ +# Project constraints + +## Backend + +Always: + +- Use the `SERIAL` type for `id` columns when creating migration SQL files; +- Define a table PRIMARY KEY in the end of the table columns using the table name plus `_pk` and the needed columns; +- Import needed packages one by one; +- Create JavaDoc for public classes and methods; +- Run `./mvnw -Ptests test` to check possible check style issues or failing tests; +- Fix existing test cases, if needed; +- Update docker-compose.yml and github workflow files if a new variable or application.yml has changed; + +Never: +- Use `ON DELETE CASCADE` in SQL scripts or migration files; +- Use star to import packages; + +## Frontend + +Always: + +- Type variables according with their data type; +- Create helper functions in the helper directory; +- Update Dockerfile, docker-compose.yml and github workflow files if a new environment variable was added; + +Never: + +- Use the `any` type; +- Add new dependencies for small helpers or util functions, prefer implementing them; + diff --git a/ai/context/project_vision.md b/ai/context/project_vision.md index 3247020..096f0be 100644 --- a/ai/context/project_vision.md +++ b/ai/context/project_vision.md @@ -31,7 +31,7 @@ The primary users of TaskNote are: - **Search and Organization**: Provide powerful search capabilities and organizational tools to help users find and manage their tasks and notes efficiently. - **Multi-Language Support**: Support multiple languages to cater to a global user base. -## Constraints +## General Constraints - **GraalVM Compatibility**: The application must be compatible with GraalVM to leverage its performance benefits and support for multiple languages. - **Mobile-first UI**: The user interface should be designed with a mobile-first approach to ensure a seamless experience across devices. @@ -41,4 +41,4 @@ The primary users of TaskNote are: - Users can manage their tasks and notes efficiently, leading to increased productivity and reduced stress. - Positive user feedback and high engagement with the application. - Specs remain under control, with a clear roadmap for future features and improvements. -- Codebase remains maintainable and scalable, allowing for easy addition of new features and improvements over time. \ No newline at end of file +- Codebase remains maintainable and scalable, allowing for easy addition of new features and improvements over time. From 2536042bfe2659408f4ba9155e7ede6f2ad56ca6 Mon Sep 17 00:00:00 2001 From: Ricardo Campos Date: Thu, 21 May 2026 21:06:56 +0200 Subject: [PATCH 05/11] chore: update spec feature prompt --- ai/templates/spec_feature_prompt.md | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/ai/templates/spec_feature_prompt.md b/ai/templates/spec_feature_prompt.md index be8293f..43cd2e1 100644 --- a/ai/templates/spec_feature_prompt.md +++ b/ai/templates/spec_feature_prompt.md @@ -1,6 +1,6 @@ # Spec for a feature prompt example -Based on the project context, defined at `ai/context/*.md` files, generate one small increment spec file for a new +Based on the project context, defined at `ai/context/*.md` files, generate one small increment spec file for this feature: . Requirements: @@ -8,6 +8,7 @@ Requirements: - independently implementable - clear acceptance criteria - incremental delivery +- strictly follow all constraints in `ai/context/project_constraints.md` during implementation The spec file should include: - goal @@ -19,10 +20,3 @@ The spec file should include: Name the spec file as `000-short-descriptive-name.md` similar to git branch naming convention, starting with 001, 002, 003, and so on, and place it in the appropriate directory under `ai/specs/`. - --- - -Based on the project context, defined at ai/context/*.md files, generate one small increment spec file for a new -feature: support multiple tags for both tasks and notes. Requirements: small scope, clear acceptance criteria. The spec -file should include: goal, user value, requirements, acceptance criteria, risks. Name the spec file following this -template: 000-short-description.md similar to git branch naming convention, starting with 001, 002, 003, and so on. \ No newline at end of file From 5be9011e0d4344f785dcea1441700377e5bea91e Mon Sep 17 00:00:00 2001 From: Ricardo Campos Date: Thu, 21 May 2026 21:10:52 -0300 Subject: [PATCH 06/11] feat: add multiple tags support - wip --- ai/templates/orchestration_prompt.md | 2 +- client/src/components/TaskTag/index.tsx | 12 +- client/src/types/NoteResponse.ts | 2 +- client/src/types/TaskNoteRequest.ts | 2 +- client/src/types/TaskResponse.ts | 2 +- client/src/views/Home/index.tsx | 16 +- client/src/views/NoteAdd/index.tsx | 85 +++++++--- client/src/views/SharedNote/index.tsx | 5 +- client/src/views/TaskAdd/index.tsx | 73 +++++++-- .../tasknoteapp/server/entity/NoteEntity.java | 25 +-- .../tasknoteapp/server/entity/TagEntity.java | 84 ++++++++++ .../tasknoteapp/server/entity/TaskEntity.java | 25 +-- .../server/repository/NoteRepository.java | 16 +- .../server/repository/TagRepository.java | 14 ++ .../server/repository/TaskRepository.java | 3 +- .../server/request/NotePatchRequest.java | 3 +- .../server/request/NoteRequest.java | 3 +- .../server/request/TaskPatchRequest.java | 2 +- .../server/request/TaskRequest.java | 2 +- .../server/response/NoteResponse.java | 15 +- .../server/response/TaskResponse.java | 6 +- .../server/service/HomeService.java | 71 +++++--- .../server/service/NoteService.java | 41 ++++- .../server/service/TaskService.java | 50 ++++-- .../V202605211000__add_multiple_tags.sql | 51 ++++++ .../server/controller/NoteControllerTest.java | 25 +-- .../controller/PublicNoteControllerTest.java | 3 +- .../server/controller/TaskControllerTest.java | 27 ++-- .../server/service/HomeServiceTest.java | 72 +++++---- .../server/service/NoteServiceTest.java | 13 +- .../server/service/TaskServiceTest.java | 152 ++++++++++++------ .../test/resources/sql/TaskRepositoryTest.sql | 32 +++- .../resources/sql/TaskUrlRepositoryTest.sql | 33 +++- 33 files changed, 739 insertions(+), 228 deletions(-) create mode 100644 server/src/main/java/br/com/tasknoteapp/server/entity/TagEntity.java create mode 100644 server/src/main/java/br/com/tasknoteapp/server/repository/TagRepository.java create mode 100644 server/src/main/resources/db/migration/V202605211000__add_multiple_tags.sql diff --git a/ai/templates/orchestration_prompt.md b/ai/templates/orchestration_prompt.md index 6d9b622..181f8d2 100644 --- a/ai/templates/orchestration_prompt.md +++ b/ai/templates/orchestration_prompt.md @@ -1,7 +1,7 @@ # Orchestration Prompt Using the orchestrator workflow defined at `ai/orchestration/orchestrator.md`, -execute the specs incrementally. +execute the first spec file, 001-add-multiple-tags.md. Respect: - architecture principles diff --git a/client/src/components/TaskTag/index.tsx b/client/src/components/TaskTag/index.tsx index ea238d8..538f6d3 100644 --- a/client/src/components/TaskTag/index.tsx +++ b/client/src/components/TaskTag/index.tsx @@ -3,27 +3,29 @@ import { Col, Row } from 'react-bootstrap'; import './style.css'; interface Props { - readonly tag?: string; + readonly tags?: string[]; readonly lastUpdate: string; readonly taskOrNote: 'task' | 'note'; readonly onClick?: (e: React.MouseEvent) => void; } /** - * Renders the TaskTag component, displaying a tag and the last update time. + * Renders the TaskTag component, displaying tags and the last update time. * * @param {Props} props - The props for the component. - * @param {string} [props.tag] - The tag for the task. If not provided, defaults to '#untagged'. + * @param {string[]} [props.tags] - The tags for the task. If not provided or empty, defaults to '#untagged'. * @param {string} props.lastUpdate - The last update time for the task. * @returns {React.ReactNode} The rendered TaskTag component. */ function TaskTag(props: React.PropsWithChildren): React.ReactNode { - const tagText = props.tag ? `#${props.tag}` : '#untagged'; + const tagContent = props.tags && props.tags.length > 0 + ? props.tags.map((tag) => `#${tag}`).join(' ') + : '#untagged'; return ( - {tagText} + {tagContent} {' '} {props.taskOrNote} {props.taskOrNote === 'note' && ( diff --git a/client/src/types/NoteResponse.ts b/client/src/types/NoteResponse.ts index d500fc5..2af8113 100644 --- a/client/src/types/NoteResponse.ts +++ b/client/src/types/NoteResponse.ts @@ -3,7 +3,7 @@ type NoteResponse = { title: string; description: string; url: string | null; - tag: string; + tags: string[]; lastUpdate: string; shared: boolean; shareToken: string | null; diff --git a/client/src/types/TaskNoteRequest.ts b/client/src/types/TaskNoteRequest.ts index f4f9f0e..e4a978f 100644 --- a/client/src/types/TaskNoteRequest.ts +++ b/client/src/types/TaskNoteRequest.ts @@ -4,7 +4,7 @@ type TaskNoteRequest = { urls?: string[]; dueDate?: string; highPriority?: boolean; - tag: string; + tags: string[]; }; export default TaskNoteRequest; diff --git a/client/src/types/TaskResponse.ts b/client/src/types/TaskResponse.ts index 347bab2..02d85bd 100644 --- a/client/src/types/TaskResponse.ts +++ b/client/src/types/TaskResponse.ts @@ -6,7 +6,7 @@ type TaskResponse = { dueDate: string; dueDateFmt: string; lastUpdate: string; - tag: string; + tags: string[]; urls: string[]; }; diff --git a/client/src/views/Home/index.tsx b/client/src/views/Home/index.tsx index 8623324..c899003 100644 --- a/client/src/views/Home/index.tsx +++ b/client/src/views/Home/index.tsx @@ -157,15 +157,15 @@ function Home(): React.ReactNode { const anyTitleMatch = note.title.toLowerCase().includes(text.toLowerCase()); const anyContentMatch = note.description.toLowerCase().includes(text.toLowerCase()); const anyUrlMatch = note.url?.includes(text.toLowerCase()); - const anyTagMatch = note.tag?.toLowerCase().includes(text.toLowerCase()); + const anyTagMatch = note.tags?.some((tag) => tag.toLowerCase().includes(text.toLowerCase())); return anyTitleMatch || anyContentMatch || anyUrlMatch || anyTagMatch; }); if (tagToFilter === 'untagged') { - filteredNotes = filteredNotes.filter((note: NoteResponse) => !note.tag); + filteredNotes = filteredNotes.filter((note: NoteResponse) => !note.tags || note.tags.length === 0); } else if (tagToFilter) { - filteredNotes = filteredNotes.filter((note: NoteResponse) => note.tag && note.tag === tagToFilter); + filteredNotes = filteredNotes.filter((note: NoteResponse) => note.tags && note.tags.includes(tagToFilter)); } setNotes([...filteredNotes]); @@ -177,15 +177,15 @@ function Home(): React.ReactNode { else { let filteredTasks = allTasks.filter((task: TaskResponse) => { return task.description.toLowerCase().includes(text.toLowerCase()) - || task.tag.toLowerCase().includes(text.toLowerCase()) + || task.tags?.some((tag) => tag.toLowerCase().includes(text.toLowerCase())) || task.urls.filter((url: string) => url.includes(text.toLowerCase())).length > 0; }); if (tagToFilter === 'untagged') { - filteredTasks = filteredTasks.filter((task: TaskResponse) => !task.tag); + filteredTasks = filteredTasks.filter((task: TaskResponse) => !task.tags || task.tags.length === 0); } else if (tagToFilter) { - filteredTasks = filteredTasks.filter((task: TaskResponse) => task.tag && task.tag === tagToFilter); + filteredTasks = filteredTasks.filter((task: TaskResponse) => task.tags && task.tags.includes(tagToFilter)); } setTasks([...filteredTasks]); @@ -519,7 +519,7 @@ function Home(): React.ReactNode { @@ -593,7 +593,7 @@ function Home(): React.ReactNode { ) => { diff --git a/client/src/views/NoteAdd/index.tsx b/client/src/views/NoteAdd/index.tsx index 5299c0d..8ada289 100644 --- a/client/src/views/NoteAdd/index.tsx +++ b/client/src/views/NoteAdd/index.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useRef, useState } from 'react'; import { + Badge, Card, Col, Container, @@ -34,7 +35,8 @@ function NoteAdd(): React.ReactNode { const [noteTitle, setNoteTitle] = useState(''); const [noteContent, setNoteContent] = useState(''); const [noteUrl, setNoteUrl] = useState(''); - const [noteTag, setNoteTag] = useState(''); + const [currentTag, setCurrentTag] = useState(''); + const [selectedTags, setSelectedTags] = useState([]); const [tags, setTags] = useState([]); const [showTagDropdown, setShowTagDropdown] = useState(false); const [action, setAction] = useState('add'); @@ -71,8 +73,8 @@ function NoteAdd(): React.ReactNode { /** * Adds a new note. * - * @param {TaskNoteRequest} payload - The task data to add. - * @returns {Promise} True if the task was added successfully, false otherwise. + * @param {NoteResponse} payload - The note data to add. + * @returns {Promise} True if the note was added successfully, false otherwise. */ const addNote = async (payload: NoteResponse): Promise => { try { @@ -87,10 +89,10 @@ function NoteAdd(): React.ReactNode { }; /** - * Submits the edited task. + * Submits the edited note. * - * @param {TaskResponse} payload - The task data to edit. - * @returns {Promise} True if the task was edited successfully, false otherwise. + * @param {NoteResponse} payload - The note data to edit. + * @returns {Promise} True if the note was edited successfully, false otherwise. */ const submitEditNote = async (payload: NoteResponse): Promise => { try { @@ -111,12 +113,26 @@ function NoteAdd(): React.ReactNode { setNoteTitle(''); setNoteUrl(''); setNoteContent(''); - setNoteTag(''); + setCurrentTag(''); + setSelectedTags([]); setAction('add'); setValidated(false); }; + const addTag = (tagName: string): void => { + const normalized = tagName.trim().toLowerCase(); + if (normalized && !selectedTags.includes(normalized)) { + setSelectedTags([...selectedTags, normalized]); + } + setCurrentTag(''); + setShowTagDropdown(false); + }; + + const removeTag = (tagToRemove: string): void => { + setSelectedTags(selectedTags.filter((t) => t !== tagToRemove)); + }; + /** * Handles the form submission. * @@ -133,13 +149,22 @@ function NoteAdd(): React.ReactNode { return; } + // Add current tag if not empty before submitting + const finalTags = [...selectedTags]; + if (currentTag.trim()) { + const normalized = currentTag.trim().toLowerCase(); + if (!finalTags.includes(normalized)) { + finalTags.push(normalized); + } + } + if (action === 'add') { const payload: NoteResponse = { id: 0, title: noteTitle, description: noteContent, url: noteUrl, - tag: noteTag, + tags: finalTags, lastUpdate: '', shared: false, shareToken: null @@ -158,7 +183,7 @@ function NoteAdd(): React.ReactNode { title: noteTitle, description: noteContent, url: noteUrl, - tag: noteTag, + tags: finalTags, lastUpdate: '', shared: false, shareToken: null @@ -174,7 +199,7 @@ function NoteAdd(): React.ReactNode { }; /** - * Checks if the URL is for editing a task and loads the task data if it is. + * Checks if the URL is for editing a note and loads the note data if it is. */ const checkEditUrl = async (): Promise => { if (params?.id) { @@ -219,8 +244,8 @@ function NoteAdd(): React.ReactNode { if (noteContent.url) { setNoteUrl(noteContent.url); } - if (noteContent.tag) { - setNoteTag(noteContent.tag); + if (noteContent.tags) { + setSelectedTags(noteContent.tags); } setNoteContent(noteContent.description); }; @@ -318,7 +343,24 @@ function NoteAdd(): React.ReactNode { {/* Tag with suggestion dropdown */} - Tag + Tags +
+ {selectedTags.map((t) => ( + removeTag(t)} + > + # + {t} + {' '} + × + + ))} +
@@ -327,16 +369,22 @@ function NoteAdd(): React.ReactNode { type="text" name="tag" placeholder="my-tag (Optional)" - value={noteTag} + value={currentTag} onChange={(e: React.ChangeEvent) => { - setNoteTag(e.target.value); + setCurrentTag(e.target.value); setShowTagDropdown(true); }} + onKeyDown={(e: React.KeyboardEvent) => { + if (e.key === 'Enter' && currentTag.trim()) { + e.preventDefault(); + addTag(currentTag); + } + }} onFocus={() => setShowTagDropdown(true)} autoComplete="off" /> - {showTagDropdown && tags.filter(t => t.toLowerCase().includes(noteTag.toLowerCase())).length > 0 && ( + {showTagDropdown && tags.filter(t => t.toLowerCase().includes(currentTag.toLowerCase())).length > 0 && ( {tags - .filter(t => t.toLowerCase().includes(noteTag.toLowerCase())) + .filter(t => t.toLowerCase().includes(currentTag.toLowerCase())) .map(t => ( { e.preventDefault(); - setNoteTag(t); - setShowTagDropdown(false); + addTag(t); }} > # diff --git a/client/src/views/SharedNote/index.tsx b/client/src/views/SharedNote/index.tsx index 0bbd596..488df34 100644 --- a/client/src/views/SharedNote/index.tsx +++ b/client/src/views/SharedNote/index.tsx @@ -72,10 +72,9 @@ function SharedNote(): React.ReactNode { TaskNote · Shared Note (Read only) - {note.tag && ( + {note.tags && note.tags.length > 0 && ( - # - {note.tag} + {note.tags.map((t) => `#${t}`).join(' ')} )} diff --git a/client/src/views/TaskAdd/index.tsx b/client/src/views/TaskAdd/index.tsx index 633b89b..4067de7 100644 --- a/client/src/views/TaskAdd/index.tsx +++ b/client/src/views/TaskAdd/index.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useRef, useState } from 'react'; import { + Badge, Card, Col, Container, @@ -37,7 +38,8 @@ function TaskAdd(): React.ReactNode { const [action, setAction] = useState('add'); const [dueDate, setDueDate] = useState(null); const [highPriority, setHighPriority] = useState(false); - const [tag, setTag] = useState(''); + const [currentTag, setCurrentTag] = useState(''); + const [selectedTags, setSelectedTags] = useState([]); const [tags, setTags] = useState([]); const [showTagDropdown, setShowTagDropdown] = useState(false); const { i18n, t } = useTranslation(); @@ -114,12 +116,26 @@ function TaskAdd(): React.ReactNode { setTaskUrl(''); setDueDate(null); setHighPriority(false); - setTag(''); + setCurrentTag(''); + setSelectedTags([]); setAction('add'); setValidated(false); }; + const addTag = (tagName: string): void => { + const normalized = tagName.trim().toLowerCase(); + if (normalized && !selectedTags.includes(normalized)) { + setSelectedTags([...selectedTags, normalized]); + } + setCurrentTag(''); + setShowTagDropdown(false); + }; + + const removeTag = (tagToRemove: string): void => { + setSelectedTags(selectedTags.filter((t) => t !== tagToRemove)); + }; + /** * Handles the form submission. * @@ -141,12 +157,21 @@ function TaskAdd(): React.ReactNode { dueDateFormatted = dueDate.toISOString().substring(0, 10); } + // Add current tag if not empty before submitting + const finalTags = [...selectedTags]; + if (currentTag.trim()) { + const normalized = currentTag.trim().toLowerCase(); + if (!finalTags.includes(normalized)) { + finalTags.push(normalized); + } + } + if (action === 'add') { const addPayload: TaskNoteRequest = { description: taskDescription.trim(), highPriority: highPriority, dueDate: dueDateFormatted, - tag: tag, + tags: finalTags, urls: taskUrl ? [taskUrl] : [] }; @@ -166,7 +191,7 @@ function TaskAdd(): React.ReactNode { dueDate: dueDateFormatted, dueDateFmt: '', lastUpdate: '', - tag: tag, + tags: finalTags, urls: taskUrl ? [taskUrl] : [] }; @@ -194,8 +219,8 @@ function TaskAdd(): React.ReactNode { setDueDate(new Date(taskToEdit.dueDate)); } setHighPriority(taskToEdit.highPriority); - if (taskToEdit.tag) { - setTag(taskToEdit.tag); + if (taskToEdit.tags) { + setSelectedTags(taskToEdit.tags); } setAction('edit'); } @@ -297,7 +322,24 @@ function TaskAdd(): React.ReactNode { {/* Tag with suggestion dropdown */} - Tag + Tags +
+ {selectedTags.map((t) => ( + removeTag(t)} + > + # + {t} + {' '} + × + + ))} +
@@ -306,16 +348,22 @@ function TaskAdd(): React.ReactNode { type="text" name="tag" placeholder="my-tag (Optional)" - value={tag} + value={currentTag} onChange={(e: React.ChangeEvent) => { - setTag(e.target.value); + setCurrentTag(e.target.value); setShowTagDropdown(true); }} + onKeyDown={(e: React.KeyboardEvent) => { + if (e.key === 'Enter' && currentTag.trim()) { + e.preventDefault(); + addTag(currentTag); + } + }} onFocus={() => setShowTagDropdown(true)} autoComplete="off" /> - {showTagDropdown && tags.filter(t => t.toLowerCase().includes(tag.toLowerCase())).length > 0 && ( + {showTagDropdown && tags.filter(t => t.toLowerCase().includes(currentTag.toLowerCase())).length > 0 && ( {tags - .filter(t => t.toLowerCase().includes(tag.toLowerCase())) + .filter(t => t.toLowerCase().includes(currentTag.toLowerCase())) .map(t => ( { e.preventDefault(); - setTag(t); - setShowTagDropdown(false); + addTag(t); }} > # diff --git a/server/src/main/java/br/com/tasknoteapp/server/entity/NoteEntity.java b/server/src/main/java/br/com/tasknoteapp/server/entity/NoteEntity.java index f957669..f435efe 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/entity/NoteEntity.java +++ b/server/src/main/java/br/com/tasknoteapp/server/entity/NoteEntity.java @@ -7,9 +7,13 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; +import jakarta.persistence.JoinTable; +import jakarta.persistence.ManyToMany; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.Set; /** This class represents a note in the database. */ @Entity @@ -29,8 +33,12 @@ public class NoteEntity { @ManyToOne(fetch = FetchType.LAZY) private UserEntity user; - @Column(name = "tag", length = 30) - private String tag; + @ManyToMany(fetch = FetchType.LAZY) + @JoinTable( + name = "note_tags", + joinColumns = @JoinColumn(name = "note_id"), + inverseJoinColumns = @JoinColumn(name = "tag_id")) + private Set tags = new HashSet<>(); @Column(name = "last_update") private LocalDateTime lastUpdate; @@ -73,12 +81,12 @@ public void setUser(UserEntity user) { this.user = user; } - public String getTag() { - return tag; + public Set getTags() { + return tags; } - public void setTag(String tag) { - this.tag = tag; + public void setTags(Set tags) { + this.tags = tags; } public LocalDateTime getLastUpdate() { @@ -133,9 +141,8 @@ public String toString() { + ", description='" + description + '\'' - + ", tag='" - + tag - + '\'' + + ", tags=" + + tags + ", lastUpdate=" + lastUpdate + '}'; diff --git a/server/src/main/java/br/com/tasknoteapp/server/entity/TagEntity.java b/server/src/main/java/br/com/tasknoteapp/server/entity/TagEntity.java new file mode 100644 index 0000000..fd33b7d --- /dev/null +++ b/server/src/main/java/br/com/tasknoteapp/server/entity/TagEntity.java @@ -0,0 +1,84 @@ +package br.com.tasknoteapp.server.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +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.Table; +import jakarta.persistence.UniqueConstraint; + +/** This class represents a tag in the database. */ +@Entity +@Table( + name = "tags", + uniqueConstraints = {@UniqueConstraint(columnNames = {"name", "user_id"})}) +public class TagEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 30) + private String name; + + @JoinColumn(name = "user_id", referencedColumnName = "id", nullable = false, updatable = false) + @ManyToOne(fetch = FetchType.LAZY) + private UserEntity user; + + public TagEntity() {} + + public TagEntity(String name, UserEntity user) { + this.name = name; + this.user = user; + } + + 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 UserEntity getUser() { + return user; + } + + public void setUser(UserEntity user) { + this.user = user; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + TagEntity that = (TagEntity) o; + return id != null && id.equals(that.id); + } + + @Override + public int hashCode() { + return getClass().hashCode(); + } + + @Override + public String toString() { + return "TagEntity{" + "id=" + id + ", name='" + name + '\'' + '}'; + } +} diff --git a/server/src/main/java/br/com/tasknoteapp/server/entity/TaskEntity.java b/server/src/main/java/br/com/tasknoteapp/server/entity/TaskEntity.java index 312ccd5..8508649 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/entity/TaskEntity.java +++ b/server/src/main/java/br/com/tasknoteapp/server/entity/TaskEntity.java @@ -7,10 +7,14 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; +import jakarta.persistence.JoinTable; +import jakarta.persistence.ManyToMany; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.Set; /** This class represents a task in the database. */ @Entity @@ -39,8 +43,12 @@ public class TaskEntity { @Column(name = "high_priority") private Boolean highPriority; - @Column(name = "tag", length = 30) - private String tag; + @ManyToMany(fetch = FetchType.LAZY) + @JoinTable( + name = "task_tags", + joinColumns = @JoinColumn(name = "task_id"), + inverseJoinColumns = @JoinColumn(name = "tag_id")) + private Set tags = new HashSet<>(); public Long getId() { return id; @@ -98,12 +106,12 @@ public void setHighPriority(Boolean highPriority) { this.highPriority = highPriority; } - public String getTag() { - return tag; + public Set getTags() { + return tags; } - public void setTag(String tag) { - this.tag = tag; + public void setTags(Set tags) { + this.tags = tags; } @Override @@ -139,9 +147,8 @@ public String toString() { + dueDate + ", highPriority=" + highPriority - + ", tag='" - + tag - + '\'' + + ", tags=" + + tags + '}'; } } diff --git a/server/src/main/java/br/com/tasknoteapp/server/repository/NoteRepository.java b/server/src/main/java/br/com/tasknoteapp/server/repository/NoteRepository.java index a2fb375..4231590 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/repository/NoteRepository.java +++ b/server/src/main/java/br/com/tasknoteapp/server/repository/NoteRepository.java @@ -5,6 +5,7 @@ import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; /** This interface represents a note repository, for database access. */ public interface NoteRepository extends JpaRepository { @@ -16,7 +17,16 @@ public interface NoteRepository extends JpaRepository { Optional findByIdAndUser_id(Long id, Long userId); @Query( - "select n from NoteEntity n where (upper(n.title) like %?1% or upper(n.description) like" - + " %?1%) and n.user.id = ?2") - List findAllBySearchTerm(String searchTerm, Long userId); + """ + select distinct n + from NoteEntity n + left join n.tags tg + where ( + upper(n.title) like upper(concat('%', :searchTerm, '%')) or + upper(n.description) like upper(concat('%', :searchTerm, '%')) or + upper(tg.name) like upper(concat('%', :searchTerm, '%')) + ) and n.user.id = :userId + """) + List findAllBySearchTerm( + @Param("searchTerm") String searchTerm, @Param("userId") Long userId); } diff --git a/server/src/main/java/br/com/tasknoteapp/server/repository/TagRepository.java b/server/src/main/java/br/com/tasknoteapp/server/repository/TagRepository.java new file mode 100644 index 0000000..970fe93 --- /dev/null +++ b/server/src/main/java/br/com/tasknoteapp/server/repository/TagRepository.java @@ -0,0 +1,14 @@ +package br.com.tasknoteapp.server.repository; + +import br.com.tasknoteapp.server.entity.TagEntity; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +/** This interface represents a tag repository, for database access. */ +public interface TagRepository extends JpaRepository { + + Optional findByNameAndUser_id(String name, Long userId); + + List findAllByUser_idOrderByNameAsc(Long userId); +} diff --git a/server/src/main/java/br/com/tasknoteapp/server/repository/TaskRepository.java b/server/src/main/java/br/com/tasknoteapp/server/repository/TaskRepository.java index 5414ded..6d8978b 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/repository/TaskRepository.java +++ b/server/src/main/java/br/com/tasknoteapp/server/repository/TaskRepository.java @@ -16,9 +16,10 @@ public interface TaskRepository extends JpaRepository { select distinct t from TaskEntity t left join TaskUrlEntity tu on tu.id.taskId = t.id + left join t.tags tg where ( upper(t.description) like upper(concat('%', :searchTerm, '%')) or - upper(t.tag) like upper(concat('%', :searchTerm, '%')) or + upper(tg.name) like upper(concat('%', :searchTerm, '%')) or upper(tu.id.url) like upper(concat('%', :searchTerm, '%')) ) and t.user.id = :userId and t.done = false """) diff --git a/server/src/main/java/br/com/tasknoteapp/server/request/NotePatchRequest.java b/server/src/main/java/br/com/tasknoteapp/server/request/NotePatchRequest.java index 26cffb2..2dfd157 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/request/NotePatchRequest.java +++ b/server/src/main/java/br/com/tasknoteapp/server/request/NotePatchRequest.java @@ -1,6 +1,7 @@ package br.com.tasknoteapp.server.request; import jakarta.validation.constraints.Pattern; +import java.util.List; /** This record represents a note patch payload. */ public record NotePatchRequest( @@ -10,4 +11,4 @@ public record NotePatchRequest( regexp = "^(https?://.*|#.*)?$", message = "URL must start with https:// or #") String url, - String tag) {} + List tags) {} diff --git a/server/src/main/java/br/com/tasknoteapp/server/request/NoteRequest.java b/server/src/main/java/br/com/tasknoteapp/server/request/NoteRequest.java index bd78f95..5e40f72 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/request/NoteRequest.java +++ b/server/src/main/java/br/com/tasknoteapp/server/request/NoteRequest.java @@ -2,6 +2,7 @@ import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; +import java.util.List; /** This record represents a note request to be created. */ public record NoteRequest( @@ -11,4 +12,4 @@ public record NoteRequest( regexp = "^(https?://.*|#.*)?$", message = "URL must start with https:// or #") String url, - String tag) {} + List tags) {} diff --git a/server/src/main/java/br/com/tasknoteapp/server/request/TaskPatchRequest.java b/server/src/main/java/br/com/tasknoteapp/server/request/TaskPatchRequest.java index 12f977c..ae8bef6 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/request/TaskPatchRequest.java +++ b/server/src/main/java/br/com/tasknoteapp/server/request/TaskPatchRequest.java @@ -15,4 +15,4 @@ public record TaskPatchRequest( urls, String dueDate, Boolean highPriority, - String tag) {} + List tags) {} diff --git a/server/src/main/java/br/com/tasknoteapp/server/request/TaskRequest.java b/server/src/main/java/br/com/tasknoteapp/server/request/TaskRequest.java index bb27796..7b35d21 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/request/TaskRequest.java +++ b/server/src/main/java/br/com/tasknoteapp/server/request/TaskRequest.java @@ -16,4 +16,4 @@ public record TaskRequest( urls, String dueDate, Boolean highPriority, - String tag) {} + List tags) {} diff --git a/server/src/main/java/br/com/tasknoteapp/server/response/NoteResponse.java b/server/src/main/java/br/com/tasknoteapp/server/response/NoteResponse.java index 26db95b..85b9dd5 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/response/NoteResponse.java +++ b/server/src/main/java/br/com/tasknoteapp/server/response/NoteResponse.java @@ -1,17 +1,26 @@ package br.com.tasknoteapp.server.response; import br.com.tasknoteapp.server.entity.NoteEntity; +import br.com.tasknoteapp.server.entity.TagEntity; import br.com.tasknoteapp.server.util.TimeAgoUtil; +import java.util.List; /** This record represents a task and its URLs object to be returned. */ public record NoteResponse( - Long id, String title, String description, String url, String lastUpdate, String tag, - boolean shared, String shareToken) { + Long id, + String title, + String description, + String url, + String lastUpdate, + List tags, + boolean shared, + String shareToken) { /** * Creates a NoteResponse given a NoteEntity and its Urals. * * @param entity The NoteEntity source data. + * @param url The URL associated with the note. * @return NoteResponse instance with all note data and URLs, if any. */ public static NoteResponse fromEntity(NoteEntity entity, String url) { @@ -23,7 +32,7 @@ public static NoteResponse fromEntity(NoteEntity entity, String url) { entity.getDescription(), url, timeAgoFmt, - entity.getTag(), + entity.getTags().stream().map(TagEntity::getName).toList(), entity.isShared(), entity.getShareToken()); } diff --git a/server/src/main/java/br/com/tasknoteapp/server/response/TaskResponse.java b/server/src/main/java/br/com/tasknoteapp/server/response/TaskResponse.java index f82fbdc..963b300 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/response/TaskResponse.java +++ b/server/src/main/java/br/com/tasknoteapp/server/response/TaskResponse.java @@ -1,5 +1,6 @@ package br.com.tasknoteapp.server.response; +import br.com.tasknoteapp.server.entity.TagEntity; import br.com.tasknoteapp.server.entity.TaskEntity; import br.com.tasknoteapp.server.util.TimeAgoUtil; import java.time.LocalDate; @@ -14,13 +15,14 @@ public record TaskResponse( LocalDate dueDate, String dueDateFmt, String lastUpdate, - String tag, + List tags, List urls) { /** * Creates a TaskResponse given a TaskEntity and its URLs. * * @param entity The TaskEntity source data. + * @param urls The URLs associated with the task. * @return TaskResponse instance with all task data and URLs, if any. */ public static TaskResponse fromEntity(TaskEntity entity, List urls) { @@ -35,7 +37,7 @@ public static TaskResponse fromEntity(TaskEntity entity, List urls) { entity.getDueDate(), dueDateFmt, timeAgoFmt, - entity.getTag(), + entity.getTags().stream().map(TagEntity::getName).toList(), urls); } } diff --git a/server/src/main/java/br/com/tasknoteapp/server/service/HomeService.java b/server/src/main/java/br/com/tasknoteapp/server/service/HomeService.java index 41889da..caadfbf 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/service/HomeService.java +++ b/server/src/main/java/br/com/tasknoteapp/server/service/HomeService.java @@ -1,10 +1,13 @@ package br.com.tasknoteapp.server.service; +import br.com.tasknoteapp.server.entity.TagEntity; +import br.com.tasknoteapp.server.entity.UserEntity; +import br.com.tasknoteapp.server.repository.TagRepository; import br.com.tasknoteapp.server.response.NoteResponse; import br.com.tasknoteapp.server.response.TaskResponse; -import java.util.HashSet; +import br.com.tasknoteapp.server.util.AuthUtil; import java.util.List; -import java.util.Set; +import java.util.Optional; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; @@ -19,9 +22,32 @@ public class HomeService { private final NoteService noteService; - public HomeService(TaskService taskService, NoteService noteService) { + private final TagRepository tagRepository; + + private final AuthService authService; + + private final AuthUtil authUtil; + + /** + * Constructor for the HomeService class. + * + * @param taskService The service for task operations. + * @param noteService The service for note operations. + * @param tagRepository The repository for tag entities. + * @param authService The service for authentication. + * @param authUtil Utility class for authentication-related operations. + */ + public HomeService( + TaskService taskService, + NoteService noteService, + TagRepository tagRepository, + AuthService authService, + AuthUtil authUtil) { this.taskService = taskService; this.noteService = noteService; + this.tagRepository = tagRepository; + this.authService = authService; + this.authUtil = authUtil; } /** @@ -30,32 +56,35 @@ public HomeService(TaskService taskService, NoteService noteService) { * @return List of String with the tags. */ public List getTopTasksTag() { - logger.info("Getting all tags for tasks and notes"); + UserEntity user = getCurrentUser(); + logger.info("Getting all tags for user ID {}", user.getId()); + + List tags = + tagRepository.findAllByUser_idOrderByNameAsc(user.getId()).stream() + .map(TagEntity::getName) + .toList(); List tasks = taskService.getTasksByFilter("all"); List notes = noteService.getAllNotes(); - Set tags = new HashSet<>(); - tags.addAll( - tasks.stream() - .map(TaskResponse::tag) - .filter(tag -> tag != null && !tag.isBlank()) - .toList()); - tags.addAll( - notes.stream() - .map(NoteResponse::tag) - .filter(tag -> tag != null && !tag.isBlank()) - .toList()); - - boolean hasBlankTags = - tasks.stream().anyMatch(task -> task.tag() == null || task.tag().isBlank()) - || notes.stream().anyMatch(note -> note.tag() == null || note.tag().isBlank()); - if (hasBlankTags) { + boolean hasUntagged = + tasks.stream().anyMatch(task -> task.tags().isEmpty()) + || notes.stream().anyMatch(note -> note.tags().isEmpty()); + + if (hasUntagged) { + tags = new java.util.ArrayList<>(tags); tags.add("untagged"); + tags = tags.stream().sorted().toList(); } logger.info("Found {} tags", tags.size()); - return tags.stream().sorted().toList(); + return tags; + } + + private UserEntity getCurrentUser() { + Optional currentUserEmail = authUtil.getCurrentUserEmail(); + String email = currentUserEmail.orElseThrow(); + return authService.findByEmail(email).orElseThrow(); } } diff --git a/server/src/main/java/br/com/tasknoteapp/server/service/NoteService.java b/server/src/main/java/br/com/tasknoteapp/server/service/NoteService.java index 064adbd..8cbf8c4 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/service/NoteService.java +++ b/server/src/main/java/br/com/tasknoteapp/server/service/NoteService.java @@ -2,20 +2,24 @@ import br.com.tasknoteapp.server.entity.NoteEntity; import br.com.tasknoteapp.server.entity.NoteUrlEntity; +import br.com.tasknoteapp.server.entity.TagEntity; import br.com.tasknoteapp.server.entity.UserEntity; import br.com.tasknoteapp.server.exception.NoteNotFoundException; import br.com.tasknoteapp.server.repository.NoteRepository; import br.com.tasknoteapp.server.repository.NoteUrlRepository; +import br.com.tasknoteapp.server.repository.TagRepository; import br.com.tasknoteapp.server.request.NotePatchRequest; import br.com.tasknoteapp.server.request.NoteRequest; import br.com.tasknoteapp.server.response.NoteResponse; import br.com.tasknoteapp.server.util.AuthUtil; import jakarta.transaction.Transactional; import java.time.LocalDateTime; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; import org.slf4j.Logger; @@ -36,6 +40,8 @@ public class NoteService { private final NoteUrlRepository noteUrlRepository; + private final TagRepository tagRepository; + /** * Constructor for the NoteService class. * @@ -43,16 +49,19 @@ public class NoteService { * @param authService The service for authentication. * @param authUtil Utility class for authentication-related operations. * @param noteUrlRepository The repository for note URL entities. + * @param tagRepository The repository for tag entities. */ public NoteService( NoteRepository noteRepository, AuthService authService, AuthUtil authUtil, - NoteUrlRepository noteUrlRepository) { + NoteUrlRepository noteUrlRepository, + TagRepository tagRepository) { this.noteRepository = noteRepository; this.authService = authService; this.authUtil = authUtil; this.noteUrlRepository = noteUrlRepository; + this.tagRepository = tagRepository; } /** @@ -108,7 +117,9 @@ public NoteResponse createNote(NoteRequest noteRequest) { NoteEntity note = new NoteEntity(); note.setTitle(noteRequest.title()); note.setDescription(noteRequest.description()); - note.setTag(noteRequest.tag()); + if (!Objects.isNull(noteRequest.tags())) { + note.setTags(getOrCreateTags(noteRequest.tags(), user)); + } note.setLastUpdate(LocalDateTime.now()); note.setUser(user); NoteEntity created = noteRepository.save(note); @@ -150,8 +161,8 @@ public NoteResponse patchNote(Long noteId, NotePatchRequest patch) { if (!Objects.isNull(patch.description()) && !patch.description().isBlank()) { noteEntity.setDescription(patch.description()); } - if (!Objects.isNull(patch.tag()) && !patch.tag().isBlank()) { - noteEntity.setTag(patch.tag().trim()); + if (!Objects.isNull(patch.tags())) { + noteEntity.setTags(getOrCreateTags(patch.tags(), user)); } noteEntity.setLastUpdate(LocalDateTime.now()); @@ -285,6 +296,28 @@ public NoteResponse getSharedNote(String shareToken) { return NoteResponse.fromEntity(noteOpt.get(), getNoteUrl(noteOpt.get().getId())); } + private Set getOrCreateTags(List tagNames, UserEntity user) { + if (Objects.isNull(tagNames) || tagNames.isEmpty()) { + return new HashSet<>(); + } + + Set normalizedNames = + tagNames.stream() + .filter(name -> !Objects.isNull(name) && !name.isBlank()) + .map(name -> name.trim().toLowerCase()) + .collect(Collectors.toSet()); + + Set tags = new HashSet<>(); + for (String name : normalizedNames) { + TagEntity tag = + tagRepository + .findByNameAndUser_id(name, user.getId()) + .orElseGet(() -> tagRepository.save(new TagEntity(name, user))); + tags.add(tag); + } + return tags; + } + private UserEntity getCurrentUser() { Optional currentUserEmail = authUtil.getCurrentUserEmail(); String email = currentUserEmail.orElseThrow(); diff --git a/server/src/main/java/br/com/tasknoteapp/server/service/TaskService.java b/server/src/main/java/br/com/tasknoteapp/server/service/TaskService.java index 9079f75..9f88b5e 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/service/TaskService.java +++ b/server/src/main/java/br/com/tasknoteapp/server/service/TaskService.java @@ -1,10 +1,12 @@ package br.com.tasknoteapp.server.service; +import br.com.tasknoteapp.server.entity.TagEntity; import br.com.tasknoteapp.server.entity.TaskEntity; import br.com.tasknoteapp.server.entity.TaskUrlEntity; import br.com.tasknoteapp.server.entity.TaskUrlEntityPk; import br.com.tasknoteapp.server.entity.UserEntity; import br.com.tasknoteapp.server.exception.TaskNotFoundException; +import br.com.tasknoteapp.server.repository.TagRepository; import br.com.tasknoteapp.server.repository.TaskRepository; import br.com.tasknoteapp.server.repository.TaskUrlRepository; import br.com.tasknoteapp.server.request.TaskPatchRequest; @@ -16,9 +18,12 @@ import java.time.LocalDateTime; import java.time.format.DateTimeParseException; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; import java.util.Objects; import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; @@ -37,6 +42,8 @@ public class TaskService { private final TaskUrlRepository taskUrlRepository; + private final TagRepository tagRepository; + /** * Constructor for the TaskService class. * @@ -44,16 +51,19 @@ public class TaskService { * @param authService The service for authentication. * @param authUtil Utility class for authentication-related operations. * @param taskUrlRepository The repository for task URL entities. + * @param tagRepository The repository for tag entities. */ public TaskService( TaskRepository taskRepository, AuthService authService, AuthUtil authUtil, - TaskUrlRepository taskUrlRepository) { + TaskUrlRepository taskUrlRepository, + TagRepository tagRepository) { this.taskRepository = taskRepository; this.authService = authService; this.authUtil = authUtil; this.taskUrlRepository = taskUrlRepository; + this.tagRepository = tagRepository; } /** @@ -111,7 +121,9 @@ public TaskResponse createTask(TaskRequest taskRequest) { task.setDueDate(LocalDate.parse(taskRequest.dueDate())); } task.setHighPriority(taskRequest.highPriority()); - task.setTag(taskRequest.tag().trim().toLowerCase()); + if (!Objects.isNull(taskRequest.tags())) { + task.setTags(getOrCreateTags(taskRequest.tags(), user)); + } TaskEntity created = taskRepository.save(task); if (!Objects.isNull(taskRequest.urls()) && !taskRequest.urls().isEmpty()) { @@ -150,13 +162,11 @@ public TaskResponse patchTask(Long taskId, TaskPatchRequest patch) { patchDueDate(taskEntity, patch); - taskEntity.setHighPriority(false); if (!Objects.isNull(patch.highPriority())) { taskEntity.setHighPriority(patch.highPriority()); } - taskEntity.setTag(null); - if (!Objects.isNull(patch.tag())) { - taskEntity.setTag(patch.tag().trim().toLowerCase()); + if (!Objects.isNull(patch.tags())) { + taskEntity.setTags(getOrCreateTags(patch.tags(), user)); } taskEntity.setLastUpdate(LocalDateTime.now()); @@ -254,17 +264,39 @@ public List getTasksByFilter(String filter) { .toList(); case "untagged" -> allTasks.stream() - .filter(t -> t.getTag() == null || t.getTag().isBlank()) + .filter(t -> t.getTags().isEmpty()) .map((TaskEntity tr) -> TaskResponse.fromEntity(tr, getAllTasksUrls(tr.getId()))) .toList(); default -> allTasks.stream() - .filter(t -> t.getTag().equals(filter)) + .filter(t -> t.getTags().stream().anyMatch(tag -> tag.getName().equals(filter))) .map((TaskEntity tr) -> TaskResponse.fromEntity(tr, getAllTasksUrls(tr.getId()))) .toList(); }; } + private Set getOrCreateTags(List tagNames, UserEntity user) { + if (Objects.isNull(tagNames) || tagNames.isEmpty()) { + return new HashSet<>(); + } + + Set normalizedNames = + tagNames.stream() + .filter(name -> !Objects.isNull(name) && !name.isBlank()) + .map(name -> name.trim().toLowerCase()) + .collect(Collectors.toSet()); + + Set tags = new HashSet<>(); + for (String name : normalizedNames) { + TagEntity tag = + tagRepository + .findByNameAndUser_id(name, user.getId()) + .orElseGet(() -> tagRepository.save(new TagEntity(name, user))); + tags.add(tag); + } + return tags; + } + private UserEntity getCurrentUser() { Optional currentUserEmail = authUtil.getCurrentUserEmail(); String email = currentUserEmail.orElseThrow(); @@ -291,7 +323,7 @@ private void saveUrls(TaskEntity taskEntity, List urls) { private void patchDueDate(TaskEntity taskEntity, TaskPatchRequest patch) { taskEntity.setDueDate(null); - if (!Objects.isNull(patch.dueDate()) && !patch.description().isBlank()) { + if (!Objects.isNull(patch.dueDate()) && !patch.dueDate().isBlank()) { try { taskEntity.setDueDate(LocalDate.parse(patch.dueDate())); } catch (DateTimeParseException e) { diff --git a/server/src/main/resources/db/migration/V202605211000__add_multiple_tags.sql b/server/src/main/resources/db/migration/V202605211000__add_multiple_tags.sql new file mode 100644 index 0000000..9efe96b --- /dev/null +++ b/server/src/main/resources/db/migration/V202605211000__add_multiple_tags.sql @@ -0,0 +1,51 @@ +CREATE TABLE tasknote.tags ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(30) NOT NULL, + user_id BIGINT NOT NULL, + FOREIGN KEY (user_id) REFERENCES tasknote.users(id), + UNIQUE (name, user_id) +); + +CREATE TABLE tasknote.task_tags ( + task_id BIGINT NOT NULL, + tag_id BIGINT NOT NULL, + PRIMARY KEY (task_id, tag_id), + FOREIGN KEY (task_id) REFERENCES tasknote.tasks(id) ON DELETE CASCADE, + FOREIGN KEY (tag_id) REFERENCES tasknote.tags(id) ON DELETE CASCADE +); + +CREATE TABLE tasknote.note_tags ( + note_id BIGINT NOT NULL, + tag_id BIGINT NOT NULL, + PRIMARY KEY (note_id, tag_id), + FOREIGN KEY (note_id) REFERENCES tasknote.notes(id) ON DELETE CASCADE, + FOREIGN KEY (tag_id) REFERENCES tasknote.tags(id) ON DELETE CASCADE +); + +-- Migrate existing tags for tasks +INSERT INTO tasknote.tags (name, user_id) +SELECT DISTINCT tag, user_id +FROM tasknote.tasks +WHERE tag IS NOT NULL AND tag <> ''; + +INSERT INTO tasknote.task_tags (task_id, tag_id) +SELECT t.id, tg.id +FROM tasknote.tasks t +JOIN tasknote.tags tg ON t.tag = tg.name AND t.user_id = tg.user_id +WHERE t.tag IS NOT NULL AND t.tag <> ''; + +-- Migrate existing tags for notes +INSERT INTO tasknote.tags (name, user_id) +SELECT DISTINCT tag, user_id +FROM tasknote.notes +WHERE tag IS NOT NULL AND tag <> '' +ON CONFLICT (name, user_id) DO NOTHING; + +INSERT INTO tasknote.note_tags (note_id, tag_id) +SELECT n.id, tg.id +FROM tasknote.notes n +JOIN tasknote.tags tg ON n.tag = tg.name AND n.user_id = tg.user_id +WHERE n.tag IS NOT NULL AND n.tag <> ''; + +-- We keep the 'tag' column for now to avoid breaking the current code, +-- but we will remove it in a future migration or after updating the code. diff --git a/server/src/test/java/br/com/tasknoteapp/server/controller/NoteControllerTest.java b/server/src/test/java/br/com/tasknoteapp/server/controller/NoteControllerTest.java index 729c3f5..02c5e25 100644 --- a/server/src/test/java/br/com/tasknoteapp/server/controller/NoteControllerTest.java +++ b/server/src/test/java/br/com/tasknoteapp/server/controller/NoteControllerTest.java @@ -44,7 +44,7 @@ class NoteControllerTest { void getAllNotes_notesFound_shouldSucceed() throws Exception { NoteUrlResponse noteUrl = new NoteUrlResponse(111L, "https://test.com"); NoteResponse note = - new NoteResponse(111L, "title", "description", "https://test.com", null, "tag", false, null); + new NoteResponse(111L, "title", "description", "https://test.com", null, List.of("tag"), false, null); when(noteService.getAllNotes()).thenReturn(List.of(note)); @@ -98,7 +98,7 @@ void getAllNotes_unauthorized_shouldFail() throws Exception { void patchNote_happyPath_shouldSucceed() throws Exception { Long noteId = 123L; NotePatchRequest patchRequest = - new NotePatchRequest("New title", "New description", null, null); + new NotePatchRequest("New title", "New description", null, List.of("tag")); NoteResponse response = new NoteResponse( @@ -107,7 +107,7 @@ void patchNote_happyPath_shouldSucceed() throws Exception { patchRequest.description(), null, null, - "tag", + List.of("tag"), false, null); @@ -118,7 +118,8 @@ void patchNote_happyPath_shouldSucceed() throws Exception { { "title": "New title", "description": "New description", - "url": null + "url": null, + "tags": ["tag"] } """; @@ -143,7 +144,7 @@ void patchNote_happyPath_shouldSucceed() throws Exception { void patchNote_notFound_shouldFail() throws Exception { Long noteId = 123L; NotePatchRequest patchRequest = - new NotePatchRequest("New title", "New description", null, null); + new NotePatchRequest("New title", "New description", null, List.of("tag")); when(noteService.patchNote(noteId, patchRequest)).thenThrow(new NoteNotFoundException()); @@ -152,7 +153,8 @@ void patchNote_notFound_shouldFail() throws Exception { { "title": "New title", "description": "New description", - "url": null + "url": null, + "tags": ["tag"] } """; @@ -196,10 +198,10 @@ void patchNote_unauthorized_shouldFail() throws Exception { @DisplayName("Post create note happy path should succeed and return 201") @WithMockUser(username = "user@domain.com", password = "abcde123456A@") void postNotes_happyPath_shouldSucceed() throws Exception { - NoteRequest request = new NoteRequest("Title", "Description", null, null); + NoteRequest request = new NoteRequest("Title", "Description", null, List.of("tag")); NoteResponse entity = new NoteResponse(1L, request.title(), request.description(), - null, null, null, false, null); + null, null, List.of("tag"), false, null); when(noteService.createNote(request)).thenReturn(entity); @@ -208,7 +210,8 @@ void postNotes_happyPath_shouldSucceed() throws Exception { { "title": "Title", "description": "Description", - "url": null + "url": null, + "tags": ["tag"] } """; @@ -328,7 +331,7 @@ void shareNote_happyPath_shouldSucceed() throws Exception { final Long noteId = 1L; final String token = "test-token-uuid"; NoteResponse response = - new NoteResponse(noteId, "title", "description", null, null, "tag", true, token); + new NoteResponse(noteId, "title", "description", null, null, List.of("tag"), true, token); when(noteService.shareNote(noteId)).thenReturn(response); @@ -363,7 +366,7 @@ void shareNote_unauthorized_shouldFail() throws Exception { void unshareNote_happyPath_shouldSucceed() throws Exception { final Long noteId = 1L; NoteResponse response = - new NoteResponse(noteId, "title", "description", null, null, "tag", false, null); + new NoteResponse(noteId, "title", "description", null, null, List.of("tag"), false, null); when(noteService.unshareNote(noteId)).thenReturn(response); diff --git a/server/src/test/java/br/com/tasknoteapp/server/controller/PublicNoteControllerTest.java b/server/src/test/java/br/com/tasknoteapp/server/controller/PublicNoteControllerTest.java index e914dc9..273c203 100644 --- a/server/src/test/java/br/com/tasknoteapp/server/controller/PublicNoteControllerTest.java +++ b/server/src/test/java/br/com/tasknoteapp/server/controller/PublicNoteControllerTest.java @@ -9,6 +9,7 @@ import br.com.tasknoteapp.server.exception.NoteNotFoundException; import br.com.tasknoteapp.server.response.NoteResponse; import br.com.tasknoteapp.server.service.NoteService; +import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -31,7 +32,7 @@ class PublicNoteControllerTest { void getSharedNote_happyPath_shouldSucceed() throws Exception { final String token = "test-share-token"; NoteResponse response = - new NoteResponse(1L, "title", "description", null, null, "tag", true, token); + new NoteResponse(1L, "title", "description", null, null, List.of("tag"), true, token); when(noteService.getSharedNote(token)).thenReturn(response); diff --git a/server/src/test/java/br/com/tasknoteapp/server/controller/TaskControllerTest.java b/server/src/test/java/br/com/tasknoteapp/server/controller/TaskControllerTest.java index ef614c4..a8af60d 100644 --- a/server/src/test/java/br/com/tasknoteapp/server/controller/TaskControllerTest.java +++ b/server/src/test/java/br/com/tasknoteapp/server/controller/TaskControllerTest.java @@ -42,7 +42,7 @@ class TaskControllerTest { void getAllTasks_tasksFound_shouldSucceed() throws Exception { TaskResponse taskResponse = new TaskResponse( - 1L, "Desc", false, true, null, null, "Moments ago", "tag", List.of("http://test.com")); + 1L, "Desc", false, true, null, null, "Moments ago", List.of("tag"), List.of("http://test.com")); when(taskService.getAllTasks()).thenReturn(List.of(taskResponse)); mockMvc @@ -107,7 +107,7 @@ void getTaskById_happyPath_shouldSucceed() throws Exception { null, null, "Moments ago", - "tag", + List.of("tag"), List.of("http://test.com")); when(taskService.getTaskById(taskId)).thenReturn(taskResponse); @@ -167,7 +167,7 @@ void getTaskById_unauthorized_shouldFail() throws Exception { void patchTask_happyPath_shouldSucceed() throws Exception { Long taskId = 111L; TaskPatchRequest patchRequest = - new TaskPatchRequest("Description patched", false, List.of(), null, true, "tag"); + new TaskPatchRequest("Description patched", false, List.of(), null, true, List.of("tag")); TaskResponse taskResponse = new TaskResponse( @@ -178,7 +178,7 @@ void patchTask_happyPath_shouldSucceed() throws Exception { null, null, "Moments ago", - "tag", + List.of("tag"), List.of()); when(taskService.patchTask(taskId, patchRequest)).thenReturn(taskResponse); @@ -209,7 +209,7 @@ void patchTask_happyPath_shouldSucceed() throws Exception { void patchTask_notFound_shouldFail() throws Exception { Long taskId = 118L; TaskPatchRequest patchRequest = - new TaskPatchRequest("Description patched", false, List.of(), null, true, "tag"); + new TaskPatchRequest("Description patched", false, List.of(), null, true, List.of("tag")); when(taskService.patchTask(taskId, patchRequest)).thenThrow(new TaskNotFoundException()); @@ -220,7 +220,7 @@ void patchTask_notFound_shouldFail() throws Exception { "done": false, "urls": [], "highPriority": true, - "tag": "tag" + "tags": ["tag"] } """; @@ -265,11 +265,20 @@ void patchTask_unauthorized_shouldFail() throws Exception { @DisplayName("Post create task happy path should succeed and return 201") @WithMockUser(username = "user@domain.com", password = "abcde123456A@") void postTasks_happyPath_shouldSucceed() throws Exception { - TaskRequest request = new TaskRequest("Test task", List.of("www.url.com"), null, true, "tag"); + TaskRequest request = + new TaskRequest("Test task", List.of("www.url.com"), null, true, List.of("tag")); TaskResponse taskResponse = new TaskResponse( - 858L, "Description patched", false, true, null, null, "Moments ago", "tag", List.of()); + 858L, + "Description patched", + false, + true, + null, + null, + "Moments ago", + List.of("tag"), + List.of()); when(taskService.createTask(request)).thenReturn(taskResponse); final String payloadJson = @@ -278,7 +287,7 @@ void postTasks_happyPath_shouldSucceed() throws Exception { "description": "Test task", "urls": ["https://www.url.com"], "highPriority": true, - "tag": "tag" + "tags": ["tag"] } """; diff --git a/server/src/test/java/br/com/tasknoteapp/server/service/HomeServiceTest.java b/server/src/test/java/br/com/tasknoteapp/server/service/HomeServiceTest.java index 842446f..dc9b66c 100644 --- a/server/src/test/java/br/com/tasknoteapp/server/service/HomeServiceTest.java +++ b/server/src/test/java/br/com/tasknoteapp/server/service/HomeServiceTest.java @@ -2,9 +2,13 @@ import static org.mockito.Mockito.when; +import br.com.tasknoteapp.server.entity.TagEntity; +import br.com.tasknoteapp.server.entity.UserEntity; +import br.com.tasknoteapp.server.repository.TagRepository; import br.com.tasknoteapp.server.response.TaskResponse; import br.com.tasknoteapp.server.util.AuthUtil; import java.util.List; +import java.util.Optional; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -20,6 +24,8 @@ class HomeServiceTest { @Mock private NoteService noteService; + @Mock private TagRepository tagRepository; + @Mock private AuthUtil authUtil; @Mock private AuthService authService; @@ -28,31 +34,36 @@ class HomeServiceTest { @BeforeEach void setUp() { - homeService = new HomeService(taskService, noteService); + homeService = new HomeService(taskService, noteService, tagRepository, authService, authUtil); + } + + private UserEntity mockUser() { + UserEntity user = new UserEntity(); + user.setId(1L); + user.setEmail("user@test.com"); + when(authUtil.getCurrentUserEmail()).thenReturn(Optional.of("user@test.com")); + when(authService.findByEmail("user@test.com")).thenReturn(Optional.of(user)); + return user; } @Test @DisplayName("Get tasks tags should return all tags ordered alphabetically") void getTopTasksTag_shouldReturnAllTagsAlphabetically() { + UserEntity user = mockUser(); + TagEntity tag1 = new TagEntity("tag1", user); + TagEntity tag2 = new TagEntity("tag2", user); + TagEntity tag3 = new TagEntity("tag3", user); + TagEntity tag4 = new TagEntity("tag4", user); + TagEntity tag5 = new TagEntity("tag5", user); + TagEntity tag6 = new TagEntity("tag6", user); + + when(tagRepository.findAllByUser_idOrderByNameAsc(user.getId())) + .thenReturn(List.of(tag1, tag2, tag3, tag4, tag5, tag6)); + TaskResponse task1 = - new TaskResponse(1L, "Task 1", false, false, null, null, null, "tag1", List.of()); - TaskResponse task2 = - new TaskResponse(2L, "Task 2", false, false, null, null, null, "tag2", List.of()); - TaskResponse task3 = - new TaskResponse(3L, "Task 3", false, false, null, null, null, "tag1", List.of()); - TaskResponse task4 = - new TaskResponse(4L, "Task 4", false, false, null, null, null, "tag3", List.of()); - TaskResponse task5 = - new TaskResponse(5L, "Task 5", false, false, null, null, null, "tag2", List.of()); - TaskResponse task6 = - new TaskResponse(6L, "Task 6", false, false, null, null, null, "tag4", List.of()); - TaskResponse task7 = - new TaskResponse(7L, "Task 7", false, false, null, null, null, "tag5", List.of()); - TaskResponse task8 = - new TaskResponse(8L, "Task 8", false, false, null, null, null, "tag6", List.of()); - - when(taskService.getTasksByFilter("all")) - .thenReturn(List.of(task1, task2, task3, task4, task5, task6, task7, task8)); + new TaskResponse(1L, "Task 1", false, false, null, null, null, List.of("tag1"), List.of()); + when(taskService.getTasksByFilter("all")).thenReturn(List.of(task1)); + when(noteService.getAllNotes()).thenReturn(List.of()); List tags = homeService.getTopTasksTag(); @@ -64,7 +75,10 @@ void getTopTasksTag_shouldReturnAllTagsAlphabetically() { @Test @DisplayName("Get top tasks tag with no tags should return empty list") void getTopTasksTag_noTags_shouldReturnEmptyList() { + UserEntity user = mockUser(); + when(tagRepository.findAllByUser_idOrderByNameAsc(user.getId())).thenReturn(List.of()); when(taskService.getTasksByFilter("all")).thenReturn(List.of()); + when(noteService.getAllNotes()).thenReturn(List.of()); List topTags = homeService.getTopTasksTag(); @@ -73,16 +87,16 @@ void getTopTasksTag_noTags_shouldReturnEmptyList() { } @Test - @DisplayName("Get top tasks tag with blank tags should handle untagged tasks") - void getTopTasksTag_blankTags_shouldHandleUntagged() { - TaskResponse task1 = - new TaskResponse(1L, "Task 1", false, false, null, null, null, "", List.of()); - TaskResponse task2 = - new TaskResponse(2L, "Task 2", false, false, null, null, null, " ", List.of()); - TaskResponse task3 = - new TaskResponse(3L, "Task 3", false, false, null, null, null, "tag1", List.of()); + @DisplayName("Get top tasks tag with untagged tasks/notes should include 'untagged'") + void getTopTasksTag_withUntagged_shouldIncludeUntagged() { + UserEntity user = mockUser(); + TagEntity tag1 = new TagEntity("tag1", user); + when(tagRepository.findAllByUser_idOrderByNameAsc(user.getId())).thenReturn(List.of(tag1)); - when(taskService.getTasksByFilter("all")).thenReturn(List.of(task1, task2, task3)); + TaskResponse task1 = + new TaskResponse(1L, "Task 1", false, false, null, null, null, List.of(), List.of()); + when(taskService.getTasksByFilter("all")).thenReturn(List.of(task1)); + when(noteService.getAllNotes()).thenReturn(List.of()); List topTags = homeService.getTopTasksTag(); @@ -90,5 +104,7 @@ void getTopTasksTag_blankTags_shouldHandleUntagged() { Assertions.assertEquals(2, topTags.size()); Assertions.assertTrue(topTags.contains("untagged")); Assertions.assertTrue(topTags.contains("tag1")); + Assertions.assertEquals(List.of("tag1", "untagged"), topTags); } } + diff --git a/server/src/test/java/br/com/tasknoteapp/server/service/NoteServiceTest.java b/server/src/test/java/br/com/tasknoteapp/server/service/NoteServiceTest.java index 2b6cda3..e40793d 100644 --- a/server/src/test/java/br/com/tasknoteapp/server/service/NoteServiceTest.java +++ b/server/src/test/java/br/com/tasknoteapp/server/service/NoteServiceTest.java @@ -15,6 +15,7 @@ import br.com.tasknoteapp.server.exception.NoteNotFoundException; import br.com.tasknoteapp.server.repository.NoteRepository; import br.com.tasknoteapp.server.repository.NoteUrlRepository; +import br.com.tasknoteapp.server.repository.TagRepository; import br.com.tasknoteapp.server.request.NotePatchRequest; import br.com.tasknoteapp.server.request.NoteRequest; import br.com.tasknoteapp.server.response.NoteResponse; @@ -39,6 +40,8 @@ class NoteServiceTest { @Mock private NoteUrlRepository noteUrlRepository; + @Mock private TagRepository tagRepository; + @InjectMocks private NoteService noteService; private UserEntity user; @@ -58,9 +61,11 @@ void setUp() { note.setDescription("Test Description"); note.setUser(user); - noteRequest = new NoteRequest("Test Note", "Test Description", "http://example.com", "tag"); + noteRequest = + new NoteRequest("Test Note", "Test Description", "http://example.com", List.of("tag")); notePatchRequest = - new NotePatchRequest("Updated Note", "Updated Description", "http://example.com", "tag"); + new NotePatchRequest( + "Updated Note", "Updated Description", "http://example.com", List.of("tag")); } @Test @@ -104,6 +109,8 @@ void createNote_withExistingCount() { when(authService.findByEmail(user.getEmail())).thenReturn(Optional.of(user)); when(noteRepository.save(any(NoteEntity.class))).thenReturn(note); when(noteUrlRepository.save(any(NoteUrlEntity.class))).thenReturn(new NoteUrlEntity()); + when(tagRepository.findByNameAndUser_id(anyString(), eq(user.getId()))) + .thenReturn(Optional.of(new br.com.tasknoteapp.server.entity.TagEntity("tag", user))); NoteResponse createdNote = noteService.createNote(noteRequest); @@ -119,6 +126,8 @@ void patchNote() { when(noteRepository.findByIdAndUser_id(note.getId(), user.getId())) .thenReturn(Optional.of(note)); when(noteRepository.save(any(NoteEntity.class))).thenReturn(note); + when(tagRepository.findByNameAndUser_id(anyString(), eq(user.getId()))) + .thenReturn(Optional.of(new br.com.tasknoteapp.server.entity.TagEntity("tag", user))); NoteResponse patchedNote = noteService.patchNote(note.getId(), notePatchRequest); diff --git a/server/src/test/java/br/com/tasknoteapp/server/service/TaskServiceTest.java b/server/src/test/java/br/com/tasknoteapp/server/service/TaskServiceTest.java index fc0f6b1..38093a4 100644 --- a/server/src/test/java/br/com/tasknoteapp/server/service/TaskServiceTest.java +++ b/server/src/test/java/br/com/tasknoteapp/server/service/TaskServiceTest.java @@ -7,16 +7,20 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import br.com.tasknoteapp.server.entity.TagEntity; import br.com.tasknoteapp.server.entity.TaskEntity; import br.com.tasknoteapp.server.entity.TaskUrlEntity; import br.com.tasknoteapp.server.entity.TaskUrlEntityPk; import br.com.tasknoteapp.server.entity.UserEntity; import br.com.tasknoteapp.server.exception.TaskNotFoundException; +import br.com.tasknoteapp.server.repository.TagRepository; import br.com.tasknoteapp.server.repository.TaskRepository; import br.com.tasknoteapp.server.repository.TaskUrlRepository; import br.com.tasknoteapp.server.request.TaskPatchRequest; @@ -27,6 +31,7 @@ import java.time.LocalDate; import java.util.List; import java.util.Optional; +import java.util.Set; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -47,6 +52,8 @@ class TaskServiceTest { @Mock TaskUrlRepository taskUrlRepository; + @Mock TagRepository tagRepository; + private static final Long USER_ID = 123L; private static final String USER_EMAIL = "test@domain.com"; @@ -57,7 +64,7 @@ class TaskServiceTest { void setup() { taskService = new TaskService( - taskRepository, authService, authUtil, taskUrlRepository); + taskRepository, authService, authUtil, taskUrlRepository, tagRepository); } @Test @@ -76,7 +83,7 @@ void getTaskById_happyPath_shouldSucceed() { taskEntity.setId(taskId); taskEntity.setDescription("Test task"); taskEntity.setHighPriority(true); - taskEntity.setTag("test"); + taskEntity.setTags(Set.of(new TagEntity("test", userEntity))); taskEntity.setUser(userEntity); when(taskRepository.findById(taskId)).thenReturn(Optional.of(taskEntity)); @@ -86,7 +93,7 @@ void getTaskById_happyPath_shouldSucceed() { assertEquals(taskEntity.getId(), taskResponse.id()); assertEquals(taskEntity.getDescription(), taskResponse.description()); assertEquals(taskEntity.getHighPriority(), taskResponse.highPriority()); - assertEquals(taskEntity.getTag(), taskResponse.tag()); + assertTrue(taskResponse.tags().contains("test")); } @Test @@ -116,18 +123,23 @@ void createTask_nullDueDate_shouldSucceed() { userEntity.setEmail(USER_EMAIL); when(authService.findByEmail(USER_EMAIL)).thenReturn(Optional.of(userEntity)); - TaskRequest request = new TaskRequest("Write unit tests", null, null, false, "development"); + List tags = List.of("development"); + TaskRequest request = new TaskRequest("Write unit tests", null, null, false, tags); + + TagEntity tagEntity = new TagEntity("development", userEntity); + when(tagRepository.findByNameAndUser_id(anyString(), anyLong())) + .thenReturn(Optional.of(tagEntity)); TaskEntity entity = new TaskEntity(); entity.setDescription(request.description()); entity.setHighPriority(request.highPriority()); - entity.setTag(request.tag()); - when(taskRepository.save(any())).thenReturn(new TaskEntity()); + entity.setTags(Set.of(tagEntity)); + when(taskRepository.save(any())).thenReturn(entity); taskService.createTask(request); assertNotNull(entity); - assertEquals("development", entity.getTag()); + assertTrue(entity.getTags().stream().anyMatch(t -> t.getName().equals("development"))); } @ParameterizedTest @@ -141,18 +153,23 @@ void createTask_parametrizedDueDate_shouldSucceed(String dueDate, String expecte userEntity.setEmail(USER_EMAIL); when(authService.findByEmail(USER_EMAIL)).thenReturn(Optional.of(userEntity)); - TaskRequest request = new TaskRequest("Write unit tests", null, dueDate, false, "development"); + List tags = List.of("development"); + TaskRequest request = new TaskRequest("Write unit tests", null, dueDate, false, tags); + + TagEntity tagEntity = new TagEntity("development", userEntity); + when(tagRepository.findByNameAndUser_id(anyString(), anyLong())) + .thenReturn(Optional.of(tagEntity)); TaskEntity entity = new TaskEntity(); entity.setDescription(request.description()); entity.setHighPriority(request.highPriority()); - entity.setTag(request.tag()); - when(taskRepository.save(any())).thenReturn(new TaskEntity()); + entity.setTags(Set.of(tagEntity)); + when(taskRepository.save(any())).thenReturn(entity); taskService.createTask(request); assertNotNull(entity); - assertEquals("development", entity.getTag()); + assertTrue(entity.getTags().stream().anyMatch(t -> t.getName().equals("development"))); } @Test @@ -165,15 +182,20 @@ void createTask_nullUrl_shouldSucceed() { userEntity.setEmail(USER_EMAIL); when(authService.findByEmail(USER_EMAIL)).thenReturn(Optional.of(userEntity)); + List tags = List.of("development"); TaskRequest request = - new TaskRequest("Write unit tests", null, "2025-12-12", false, "development"); + new TaskRequest("Write unit tests", null, "2025-12-12", false, tags); + + TagEntity tagEntity = new TagEntity("development", userEntity); + when(tagRepository.findByNameAndUser_id(anyString(), anyLong())) + .thenReturn(Optional.of(tagEntity)); TaskEntity entity = new TaskEntity(); entity.setId(123L); entity.setDescription(request.description()); entity.setHighPriority(request.highPriority()); - entity.setTag(request.tag()); - when(taskRepository.save(any())).thenReturn(new TaskEntity()); + entity.setTags(Set.of(tagEntity)); + when(taskRepository.save(any())).thenReturn(entity); TaskResponse response = taskService.createTask(request); @@ -191,15 +213,20 @@ void createTask_emptyUrl_shouldSucceed() { userEntity.setEmail(USER_EMAIL); when(authService.findByEmail(USER_EMAIL)).thenReturn(Optional.of(userEntity)); + List tags = List.of("development"); TaskRequest request = - new TaskRequest("Write unit tests", List.of(), "2025-12-12", false, "development"); + new TaskRequest("Write unit tests", List.of(), "2025-12-12", false, tags); + + TagEntity tagEntity = new TagEntity("development", userEntity); + when(tagRepository.findByNameAndUser_id(anyString(), anyLong())) + .thenReturn(Optional.of(tagEntity)); TaskEntity entity = new TaskEntity(); entity.setId(123L); entity.setDescription(request.description()); entity.setHighPriority(request.highPriority()); - entity.setTag(request.tag()); - when(taskRepository.save(any())).thenReturn(new TaskEntity()); + entity.setTags(Set.of(tagEntity)); + when(taskRepository.save(any())).thenReturn(entity); TaskResponse response = taskService.createTask(request); @@ -217,15 +244,20 @@ void createTask_fullUrl_shouldSucceed() { userEntity.setEmail(USER_EMAIL); when(authService.findByEmail(USER_EMAIL)).thenReturn(Optional.of(userEntity)); + List tags = List.of("development"); TaskRequest request = new TaskRequest( - "Write unit tests", List.of("debian.org"), "2025-12-12", false, "development"); + "Write unit tests", List.of("debian.org"), "2025-12-12", false, tags); + + TagEntity tagEntity = new TagEntity("development", userEntity); + when(tagRepository.findByNameAndUser_id(anyString(), anyLong())) + .thenReturn(Optional.of(tagEntity)); TaskEntity entity = new TaskEntity(); entity.setId(123L); entity.setDescription(request.description()); entity.setHighPriority(request.highPriority()); - entity.setTag(request.tag()); + entity.setTags(Set.of(tagEntity)); when(taskRepository.save(any())).thenReturn(entity); TaskUrlEntity urlEntity = new TaskUrlEntity(); @@ -252,14 +284,14 @@ void getAllTasks_happyPath_shouldSucceed() { TaskEntity entity = new TaskEntity(); entity.setDescription("Writ unit tests"); entity.setHighPriority(true); - entity.setTag("dev"); + entity.setTags(Set.of(new TagEntity("dev", userEntity))); when(taskRepository.findAllByUser_id(USER_ID)).thenReturn(List.of(entity)); List responses = taskService.getAllTasks(); assertFalse(responses.isEmpty()); assertEquals(1, responses.size()); - assertEquals(entity.getTag(), responses.get(0).tag()); + assertTrue(responses.get(0).tags().contains("dev")); } @Test @@ -278,7 +310,7 @@ void deleteTask_happyPath_shouldSucceed() { taskEntity.setId(taskId); taskEntity.setDescription("Test task"); taskEntity.setHighPriority(true); - taskEntity.setTag("test"); + taskEntity.setTags(Set.of(new TagEntity("test", userEntity))); taskEntity.setUser(userEntity); when(taskRepository.findById(taskId)).thenReturn(Optional.of(taskEntity)); @@ -325,24 +357,29 @@ void patchTask_happyPath_shouldSucceed() { taskEntity.setDescription("Test task"); taskEntity.setHighPriority(true); taskEntity.setDone(false); - taskEntity.setTag("test"); + taskEntity.setTags(Set.of(new TagEntity("test", userEntity))); taskEntity.setUser(userEntity); when(taskRepository.findById(taskId)).thenReturn(Optional.of(taskEntity)); when(taskUrlRepository.findAllById_taskId(taskId)).thenReturn(List.of()); - String dueDate = "2026-12-31"; + final String dueDate = "2026-12-31"; + + TagEntity tagEntity = new TagEntity("test", userEntity); + when(tagRepository.findByNameAndUser_id(anyString(), anyLong())) + .thenReturn(Optional.of(tagEntity)); TaskEntity savedTask = new TaskEntity(); savedTask.setDescription("Test task updated"); savedTask.setHighPriority(false); savedTask.setDueDate(LocalDate.parse(dueDate)); savedTask.setDone(true); - savedTask.setTag(taskEntity.getTag()); + savedTask.setTags(taskEntity.getTags()); when(taskRepository.save(any())).thenReturn(savedTask); + List tags = List.of("test"); TaskPatchRequest patch = - new TaskPatchRequest("Test task updated", true, null, dueDate, false, "test"); + new TaskPatchRequest("Test task updated", true, null, dueDate, false, tags); TaskResponse patched = taskService.patchTask(taskId, patch); assertNotNull(patched); @@ -350,7 +387,7 @@ void patchTask_happyPath_shouldSucceed() { assertTrue(patched.done()); assertEquals(TimeAgoUtil.formatDueDate(LocalDate.parse(dueDate)), patched.dueDateFmt()); assertFalse(patched.highPriority()); - assertEquals("test", patched.tag()); + assertTrue(patched.tags().contains("test")); assertTrue(patched.urls().isEmpty()); } @@ -371,7 +408,7 @@ void patchTask_withUrl_shouldSucceed() { taskEntity.setDescription("Test task"); taskEntity.setHighPriority(true); taskEntity.setDone(false); - taskEntity.setTag("test"); + taskEntity.setTags(Set.of(new TagEntity("test", userEntity))); taskEntity.setUser(userEntity); when(taskRepository.findById(taskId)).thenReturn(Optional.of(taskEntity)); @@ -380,19 +417,24 @@ void patchTask_withUrl_shouldSucceed() { when(taskUrlRepository.findAllById_taskId(taskId)).thenReturn(List.of(urlEntity)); doNothing().when(taskUrlRepository).deleteAllById_taskId(taskId); - String dueDate = "2026-12-31"; + final String dueDate = "2026-12-31"; + + TagEntity tagEntity = new TagEntity("test", userEntity); + when(tagRepository.findByNameAndUser_id(anyString(), anyLong())) + .thenReturn(Optional.of(tagEntity)); TaskEntity savedTask = new TaskEntity(); savedTask.setDescription("Test task updated"); savedTask.setHighPriority(false); savedTask.setDueDate(LocalDate.parse(dueDate)); savedTask.setDone(true); - savedTask.setTag(taskEntity.getTag()); + savedTask.setTags(taskEntity.getTags()); when(taskRepository.save(any())).thenReturn(savedTask); String url = "http://test.com"; + List tags = List.of("test"); TaskPatchRequest patch = - new TaskPatchRequest("Test task updated", true, List.of(url), dueDate, false, "test"); + new TaskPatchRequest("Test task updated", true, List.of(url), dueDate, false, tags); when(taskUrlRepository.saveAll(any())).thenReturn(List.of()); TaskResponse patched = taskService.patchTask(taskId, patch); @@ -402,7 +444,7 @@ void patchTask_withUrl_shouldSucceed() { assertTrue(patched.done()); assertEquals(TimeAgoUtil.formatDueDate(LocalDate.parse(dueDate)), patched.dueDateFmt()); assertFalse(patched.highPriority()); - assertEquals("test", patched.tag()); + assertTrue(patched.tags().contains("test")); assertFalse(patched.urls().isEmpty()); } @@ -420,8 +462,9 @@ void patchTask_taskNotFound_shouldFail() { when(taskRepository.findById(taskId)).thenReturn(Optional.empty()); + List tags = List.of("test"); TaskPatchRequest patch = - new TaskPatchRequest("Test task updated", true, null, "2025-12-31", false, "test"); + new TaskPatchRequest("Test task updated", true, null, "2025-12-31", false, tags); assertThrows(TaskNotFoundException.class, () -> taskService.patchTask(taskId, patch)); } @@ -443,24 +486,29 @@ void patchTask_dueDateParseException_shouldFail() { taskEntity.setDescription("Test task"); taskEntity.setHighPriority(true); taskEntity.setDone(false); - taskEntity.setTag("test"); + taskEntity.setTags(Set.of(new TagEntity("test", userEntity))); taskEntity.setUser(userEntity); when(taskRepository.findById(taskId)).thenReturn(Optional.of(taskEntity)); when(taskUrlRepository.findAllById_taskId(taskId)).thenReturn(List.of()); + TagEntity tagEntity = new TagEntity("test", userEntity); + when(tagRepository.findByNameAndUser_id(anyString(), anyLong())) + .thenReturn(Optional.of(tagEntity)); + TaskEntity savedTask = new TaskEntity(); savedTask.setDescription("Test task updated"); savedTask.setHighPriority(false); savedTask.setDone(true); - savedTask.setTag(taskEntity.getTag()); + savedTask.setTags(taskEntity.getTags()); when(taskRepository.save(any())).thenReturn(savedTask); // wrong due date String dueDate = "2026-31-31"; + List tags = List.of("test"); TaskPatchRequest patch = - new TaskPatchRequest("Test task updated", true, null, dueDate, false, "test"); + new TaskPatchRequest("Test task updated", true, null, dueDate, false, tags); TaskResponse patched = taskService.patchTask(taskId, patch); assertNotNull(patched); @@ -469,7 +517,7 @@ void patchTask_dueDateParseException_shouldFail() { assertNull(patched.dueDate()); assertNull(patched.dueDateFmt()); assertFalse(patched.highPriority()); - assertEquals("test", patched.tag()); + assertTrue(patched.tags().contains("test")); assertTrue(patched.urls().isEmpty()); } @@ -487,7 +535,7 @@ void searchTasks_matchingTerm_shouldSucceed() { taskEntity.setId(1L); taskEntity.setDescription("Write unit tests"); taskEntity.setHighPriority(false); - taskEntity.setTag("development"); + taskEntity.setTags(Set.of(new TagEntity("development", userEntity))); String searchTerm = "unit"; when(taskRepository.findAllBySearchTerm(searchTerm.toUpperCase(), USER_ID)) @@ -555,22 +603,22 @@ void getTasksByFilter_all_shouldSucceed() { task1.setDescription("Task 1"); task1.setHighPriority(false); task1.setDone(false); - task1.setTag("tag1"); + task1.setTags(Set.of(new TagEntity("tag1", userEntity))); TaskEntity task2 = new TaskEntity(); task2.setId(2L); task2.setDescription("Task 2"); task2.setHighPriority(true); task2.setDone(false); - task2.setTag("tag2"); + task2.setTags(Set.of(new TagEntity("tag2", userEntity))); when(taskRepository.findAllByUser_id(USER_ID)).thenReturn(List.of(task1, task2)); List responses = taskService.getTasksByFilter("all"); assertEquals(2, responses.size()); - assertEquals("tag1", responses.get(0).tag()); - assertEquals("tag2", responses.get(1).tag()); + assertTrue(responses.get(0).tags().contains("tag1")); + assertTrue(responses.get(1).tags().contains("tag2")); } @Test @@ -588,21 +636,21 @@ void getTasksByFilter_high_shouldSucceed() { task1.setDescription("Task 1"); task1.setHighPriority(false); task1.setDone(false); - task1.setTag("tag1"); + task1.setTags(Set.of(new TagEntity("tag1", userEntity))); TaskEntity task2 = new TaskEntity(); task2.setId(2L); task2.setDescription("Task 2"); task2.setHighPriority(true); task2.setDone(false); - task2.setTag("tag2"); + task2.setTags(Set.of(new TagEntity("tag2", userEntity))); when(taskRepository.findAllByUser_id(USER_ID)).thenReturn(List.of(task1, task2)); List responses = taskService.getTasksByFilter("high"); assertEquals(1, responses.size()); - assertEquals("tag2", responses.get(0).tag()); + assertTrue(responses.get(0).tags().contains("tag2")); } @Test @@ -620,22 +668,22 @@ void getTasksByFilter_untagged_shouldSucceed() { task1.setDescription("Task 1"); task1.setHighPriority(false); task1.setDone(false); - task1.setTag(null); + task1.setTags(Set.of()); TaskEntity task2 = new TaskEntity(); task2.setId(2L); task2.setDescription("Task 2"); task2.setHighPriority(true); task2.setDone(false); - task2.setTag(""); + task2.setTags(Set.of()); when(taskRepository.findAllByUser_id(USER_ID)).thenReturn(List.of(task1, task2)); List responses = taskService.getTasksByFilter("untagged"); assertEquals(2, responses.size()); - assertNull(responses.get(0).tag()); - assertTrue(responses.get(1).tag().isBlank()); + assertTrue(responses.get(0).tags().isEmpty()); + assertTrue(responses.get(1).tags().isEmpty()); } @Test @@ -653,21 +701,21 @@ void getTasksByFilter_specificTag_shouldSucceed() { task1.setDescription("Task 1"); task1.setHighPriority(false); task1.setDone(false); - task1.setTag("tag1"); + task1.setTags(Set.of(new TagEntity("tag1", userEntity))); TaskEntity task2 = new TaskEntity(); task2.setId(2L); task2.setDescription("Task 2"); task2.setHighPriority(true); task2.setDone(false); - task2.setTag("tag2"); + task2.setTags(Set.of(new TagEntity("tag2", userEntity))); when(taskRepository.findAllByUser_id(USER_ID)).thenReturn(List.of(task1, task2)); List responses = taskService.getTasksByFilter("tag1"); assertEquals(1, responses.size()); - assertEquals("tag1", responses.get(0).tag()); + assertTrue(responses.get(0).tags().contains("tag1")); } @Test diff --git a/server/src/test/resources/sql/TaskRepositoryTest.sql b/server/src/test/resources/sql/TaskRepositoryTest.sql index dbad067..a739432 100644 --- a/server/src/test/resources/sql/TaskRepositoryTest.sql +++ b/server/src/test/resources/sql/TaskRepositoryTest.sql @@ -3,11 +3,35 @@ insert into users (email, password, admin, created_at, inactivated_at, last_pass select 'test@domain.com', 'a1b2c3d4f5g6', false, current_timestamp, null, current_timestamp where not exists (select 1 from users where email = 'test@domain.com'); +-- Create tags +insert into tags (name, user_id) +select 'refactoring', (select id from users where email='test@domain.com') +where not exists (select 1 from tags where name = 'refactoring' and user_id = (select id from users where email='test@domain.com')); + +insert into tags (name, user_id) +select 'cleaning', (select id from users where email='test@domain.com') +where not exists (select 1 from tags where name = 'cleaning' and user_id = (select id from users where email='test@domain.com')); + -- Create some tasks -insert into tasks (description, done, user_id, last_update, due_date, high_priority, tag) -select 'Refactor', false, (select id from users where email='test@domain.com'), current_timestamp, '2025-12-12', false, 'refactoring' +insert into tasks (description, done, user_id, last_update, due_date, high_priority) +select 'Refactor', false, (select id from users where email='test@domain.com'), current_timestamp, '2025-12-12', false where not exists (select 1 from tasks where description = 'Refactor' and user_id = (select id from users where email='test@domain.com')); -insert into tasks (description, done, user_id, last_update, due_date, high_priority, tag) -select 'Cleanup', false, (select id from users where email='test@domain.com'), current_timestamp, '2025-12-12', false, 'cleaning' +insert into tasks (description, done, user_id, last_update, due_date, high_priority) +select 'Cleanup', false, (select id from users where email='test@domain.com'), current_timestamp, '2025-12-12', false where not exists (select 1 from tasks where description = 'Cleanup' and user_id = (select id from users where email='test@domain.com')); + +-- Associate tags with tasks +insert into task_tags (task_id, tag_id) +select t.id, tg.id +from tasks t +join tags tg on tg.name = 'refactoring' and tg.user_id = t.user_id +where t.description = 'Refactor' and t.user_id = (select id from users where email='test@domain.com') +and not exists (select 1 from task_tags where task_id = t.id and tag_id = tg.id); + +insert into task_tags (task_id, tag_id) +select t.id, tg.id +from tasks t +join tags tg on tg.name = 'cleaning' and tg.user_id = t.user_id +where t.description = 'Cleanup' and t.user_id = (select id from users where email='test@domain.com') +and not exists (select 1 from task_tags where task_id = t.id and tag_id = tg.id); diff --git a/server/src/test/resources/sql/TaskUrlRepositoryTest.sql b/server/src/test/resources/sql/TaskUrlRepositoryTest.sql index 0bf1c34..621e9de 100644 --- a/server/src/test/resources/sql/TaskUrlRepositoryTest.sql +++ b/server/src/test/resources/sql/TaskUrlRepositoryTest.sql @@ -3,21 +3,46 @@ insert into users (email, password, admin, created_at, inactivated_at, last_pass select 'test@domain.com', 'a1b2c3d4f5g6', false, current_timestamp, null, current_timestamp where not exists (select 1 from users where email = 'test@domain.com'); +-- Create tags +insert into tags (name, user_id) +select 'linux', (select id from users where email='test@domain.com') +where not exists (select 1 from tags where name = 'linux' and user_id = (select id from users where email='test@domain.com')); + +insert into tags (name, user_id) +select 'health', (select id from users where email='test@domain.com') +where not exists (select 1 from tags where name = 'health' and user_id = (select id from users where email='test@domain.com')); + -- Create a task -insert into tasks (description, done, user_id, last_update, due_date, high_priority, tag) -select 'Install Debian', false, (select id from users where email='test@domain.com'), current_timestamp, '2025-12-12', false, 'linux' +insert into tasks (description, done, user_id, last_update, due_date, high_priority) +select 'Install Debian', false, (select id from users where email='test@domain.com'), current_timestamp, '2025-12-12', false where not exists (select 1 from tasks where description = 'Install Debian' and user_id = (select id from users where email='test@domain.com')); +-- Associate tag with task +insert into task_tags (task_id, tag_id) +select t.id, tg.id +from tasks t +join tags tg on tg.name = 'linux' and tg.user_id = t.user_id +where t.description = 'Install Debian' and t.user_id = (select id from users where email='test@domain.com') +and not exists (select 1 from task_tags where task_id = t.id and tag_id = tg.id); + -- Create a task_url insert into task_url (task_id, url) select (select id from tasks where description = 'Install Debian'), 'debian.org' where not exists (select 1 from task_url where url = 'debian.org'); -- Create another task -insert into tasks (description, done, user_id, last_update, due_date, high_priority, tag) -select 'Workout', false, (select id from users where email='test@domain.com'), current_timestamp, '2025-12-12', false, 'health' +insert into tasks (description, done, user_id, last_update, due_date, high_priority) +select 'Workout', false, (select id from users where email='test@domain.com'), current_timestamp, '2025-12-12', false where not exists (select 1 from tasks where description = 'Workout' and user_id = (select id from users where email='test@domain.com')); +-- Associate tag with task +insert into task_tags (task_id, tag_id) +select t.id, tg.id +from tasks t +join tags tg on tg.name = 'health' and tg.user_id = t.user_id +where t.description = 'Workout' and t.user_id = (select id from users where email='test@domain.com') +and not exists (select 1 from task_tags where task_id = t.id and tag_id = tg.id); + -- Create a task_url insert into task_url (task_id, url) select (select id from tasks where description = 'Workout'), 'healthier.com' From 5cee6afad0232395aa9b63031f01f9f35f85a0c4 Mon Sep 17 00:00:00 2001 From: Ricardo Campos Date: Fri, 22 May 2026 10:41:25 -0300 Subject: [PATCH 07/11] test: fix test cases --- client/cypress/e2e/home.cy.ts | 13 +++++++++---- client/cypress/e2e/notes.cy.ts | 9 +++++++-- client/cypress/e2e/tasks.cy.ts | 9 +++++++-- client/src/__test__/components/TaskTag.test.tsx | 8 ++++---- client/src/__test__/views/Home.test.tsx | 16 ++++++++++------ client/src/__test__/views/NoteAdd.test.tsx | 10 +++++++--- client/src/__test__/views/TaskAdd.test.tsx | 2 +- client/src/components/TaskTag/index.tsx | 2 +- client/src/views/Home/index.tsx | 4 ++-- client/src/views/NoteAdd/index.tsx | 4 ++-- client/src/views/SharedNote/index.tsx | 2 +- client/src/views/TaskAdd/index.tsx | 4 ++-- 12 files changed, 53 insertions(+), 30 deletions(-) diff --git a/client/cypress/e2e/home.cy.ts b/client/cypress/e2e/home.cy.ts index 7c95e4a..486d471 100644 --- a/client/cypress/e2e/home.cy.ts +++ b/client/cypress/e2e/home.cy.ts @@ -21,7 +21,7 @@ const mockTasks = [ dueDate: '2026-06-01', dueDateFmt: 'Jun 1, 2026', lastUpdate: '2026-05-01', - tag: 'personal', + tags: ['personal'], urls: [] }, { @@ -32,7 +32,7 @@ const mockTasks = [ dueDate: '', dueDateFmt: '', lastUpdate: '2026-05-02', - tag: 'work', + tags: ['work'], urls: [] } ]; @@ -43,7 +43,7 @@ const mockNotes = [ title: 'Meeting notes', description: 'Discuss quarterly goals', url: null, - tag: 'work', + tags: ['work'], lastUpdate: '2026-05-01', shared: false, shareToken: null @@ -53,7 +53,7 @@ const mockNotes = [ title: 'Recipe', description: 'Pasta carbonara recipe', url: null, - tag: 'personal', + tags: ['personal'], lastUpdate: '2026-05-02', shared: false, shareToken: null @@ -69,6 +69,11 @@ describe('Home Management', () => { body: { token: 'fake-jwt-token', ...mockUser } }).as('refreshToken'); + cy.intercept('GET', /\/rest\/users\/me/, { + statusCode: 200, + body: mockUser + }).as('getCurrentUser'); + cy.intercept('GET', /\/rest\/home\/tasks\/tags/, { statusCode: 200, body: mockTags diff --git a/client/cypress/e2e/notes.cy.ts b/client/cypress/e2e/notes.cy.ts index 1b37ed9..f638fb7 100644 --- a/client/cypress/e2e/notes.cy.ts +++ b/client/cypress/e2e/notes.cy.ts @@ -17,7 +17,7 @@ const mockNote = { title: 'Meeting notes', description: 'Discuss quarterly goals', url: null, - tag: 'work', + tags: ['work'], lastUpdate: '2026-05-01', shared: false, shareToken: null @@ -35,6 +35,11 @@ describe('Notes Management', () => { body: { token: 'fake-jwt-token', ...mockUser } }).as('refreshToken'); + cy.intercept('GET', /\/rest\/users\/me/, { + statusCode: 200, + body: mockUser + }).as('getCurrentUser'); + cy.intercept('GET', /\/rest\/home\/tasks\/tags/, { statusCode: 200, body: ['work', 'personal'] @@ -84,7 +89,7 @@ describe('Notes Management', () => { title: 'New note', description: 'Some content', url: null, - tag: '', + tags: [], lastUpdate: '', shared: false, shareToken: null diff --git a/client/cypress/e2e/tasks.cy.ts b/client/cypress/e2e/tasks.cy.ts index abc7c4c..4ae69e3 100644 --- a/client/cypress/e2e/tasks.cy.ts +++ b/client/cypress/e2e/tasks.cy.ts @@ -20,7 +20,7 @@ const mockTask = { dueDate: '', dueDateFmt: '', lastUpdate: '2026-05-01', - tag: 'personal', + tags: ['personal'], urls: [] }; @@ -36,6 +36,11 @@ describe('Task Management', () => { body: { token: 'fake-jwt-token', ...mockUser } }).as('refreshToken'); + cy.intercept('GET', /\/rest\/users\/me/, { + statusCode: 200, + body: mockUser + }).as('getCurrentUser'); + cy.intercept('GET', /\/rest\/home\/tasks\/tags/, { statusCode: 200, body: ['personal', 'work'] @@ -82,7 +87,7 @@ describe('Task Management', () => { dueDate: '', dueDateFmt: '', lastUpdate: '', - tag: '', + tags: [], urls: [] } }).as('createTask'); diff --git a/client/src/__test__/components/TaskTag.test.tsx b/client/src/__test__/components/TaskTag.test.tsx index e8a8114..81d4d2d 100644 --- a/client/src/__test__/components/TaskTag.test.tsx +++ b/client/src/__test__/components/TaskTag.test.tsx @@ -5,14 +5,14 @@ import TaskTag from '../../components/TaskTag'; describe('TaskTag Component', () => { it('should render the TaskTag component with provided tag and last update', () => { - const { getByText } = render(); - expect(getByText('#important')).toBeDefined(); + const { getByText } = render(); + expect(getByText('#important task')).toBeDefined(); expect(getByText('2025-02-20')).toBeDefined(); }); it('should render the TaskTag component with default tag when no tag is provided', () => { - const { getByText } = render(); - expect(getByText('#untagged')).toBeDefined(); + const { getByText } = render(); + expect(getByText('#untagged task')).toBeDefined(); expect(getByText('2025-02-20')).toBeDefined(); }); }); diff --git a/client/src/__test__/views/Home.test.tsx b/client/src/__test__/views/Home.test.tsx index 11fdf25..20ea28b 100644 --- a/client/src/__test__/views/Home.test.tsx +++ b/client/src/__test__/views/Home.test.tsx @@ -81,7 +81,7 @@ const mockTasks: TaskResponse[] = [ description: 'Task 1', done: false, urls: ['http://example.com'], - tag: 'work', + tags: ['work'], lastUpdate: '2023-10-10', highPriority: true, dueDateFmt: '2 days left', @@ -92,7 +92,7 @@ const mockTasks: TaskResponse[] = [ description: 'Task 2', done: true, urls: [], - tag: 'home', + tags: ['home'], lastUpdate: '2023-10-09', highPriority: false, dueDateFmt: '', @@ -105,17 +105,21 @@ const mockNotes: NoteResponse[] = [ id: 1, title: 'Note 1', description: 'Line 1\nLine 2\nLine 3', - tag: 'work', + tags: ['work'], lastUpdate: '2023-10-10', - url: 'http://example.com' + url: 'http://example.com', + shared: false, + shareToken: null }, { id: 2, title: 'Note 2', description: 'This is a sample\nnote content', - tag: 'personal', + tags: ['personal'], lastUpdate: '2023-10-09', - url: null + url: null, + shared: false, + shareToken: null } ]; diff --git a/client/src/__test__/views/NoteAdd.test.tsx b/client/src/__test__/views/NoteAdd.test.tsx index 486a15c..73abf58 100644 --- a/client/src/__test__/views/NoteAdd.test.tsx +++ b/client/src/__test__/views/NoteAdd.test.tsx @@ -154,7 +154,7 @@ describe('NoteAdd Component', () => { title: 'New Note', description: 'Note content', url: '', - tag: '', + tags: [], lastUpdate: '', shared: false, shareToken: null @@ -185,8 +185,10 @@ describe('NoteAdd Component', () => { title: 'Note one', description: 'Description of note one', url: 'http://notes.domain.com', - tag: 'dev', + tags: ['dev'], lastUpdate: '3 minutes ago', + shared: false, + shareToken: null }; vi.spyOn(api, 'getJSON').mockResolvedValue(toEdit); @@ -211,8 +213,10 @@ describe('NoteAdd Component', () => { title: 'Old title', description: 'Old description', url: 'http://notes.domain.com', - tag: 'dev', + tags: ['dev'], lastUpdate: '1 minute ago', + shared: false, + shareToken: null }; vi.spyOn(api, 'getJSON').mockResolvedValue(toClone); diff --git a/client/src/__test__/views/TaskAdd.test.tsx b/client/src/__test__/views/TaskAdd.test.tsx index 1917f92..ad671e1 100644 --- a/client/src/__test__/views/TaskAdd.test.tsx +++ b/client/src/__test__/views/TaskAdd.test.tsx @@ -130,7 +130,7 @@ describe('TaskAdd Component', () => { description: 'New Task', dueDate: '', highPriority: false, - tag: '', + tags: [], urls: [] } expect(api.postJSON).toHaveBeenCalledWith(ApiConfig.tasksUrl, newTask); diff --git a/client/src/components/TaskTag/index.tsx b/client/src/components/TaskTag/index.tsx index 538f6d3..6d7aeb2 100644 --- a/client/src/components/TaskTag/index.tsx +++ b/client/src/components/TaskTag/index.tsx @@ -19,7 +19,7 @@ interface Props { */ function TaskTag(props: React.PropsWithChildren): React.ReactNode { const tagContent = props.tags && props.tags.length > 0 - ? props.tags.map((tag) => `#${tag}`).join(' ') + ? props.tags.map(tag => `#${tag}`).join(' ') : '#untagged'; return ( diff --git a/client/src/views/Home/index.tsx b/client/src/views/Home/index.tsx index c899003..2e526b8 100644 --- a/client/src/views/Home/index.tsx +++ b/client/src/views/Home/index.tsx @@ -157,7 +157,7 @@ function Home(): React.ReactNode { const anyTitleMatch = note.title.toLowerCase().includes(text.toLowerCase()); const anyContentMatch = note.description.toLowerCase().includes(text.toLowerCase()); const anyUrlMatch = note.url?.includes(text.toLowerCase()); - const anyTagMatch = note.tags?.some((tag) => tag.toLowerCase().includes(text.toLowerCase())); + const anyTagMatch = note.tags?.some(tag => tag.toLowerCase().includes(text.toLowerCase())); return anyTitleMatch || anyContentMatch || anyUrlMatch || anyTagMatch; }); @@ -177,7 +177,7 @@ function Home(): React.ReactNode { else { let filteredTasks = allTasks.filter((task: TaskResponse) => { return task.description.toLowerCase().includes(text.toLowerCase()) - || task.tags?.some((tag) => tag.toLowerCase().includes(text.toLowerCase())) + || task.tags?.some(tag => tag.toLowerCase().includes(text.toLowerCase())) || task.urls.filter((url: string) => url.includes(text.toLowerCase())).length > 0; }); diff --git a/client/src/views/NoteAdd/index.tsx b/client/src/views/NoteAdd/index.tsx index 8ada289..6b59481 100644 --- a/client/src/views/NoteAdd/index.tsx +++ b/client/src/views/NoteAdd/index.tsx @@ -130,7 +130,7 @@ function NoteAdd(): React.ReactNode { }; const removeTag = (tagToRemove: string): void => { - setSelectedTags(selectedTags.filter((t) => t !== tagToRemove)); + setSelectedTags(selectedTags.filter(t => t !== tagToRemove)); }; /** @@ -345,7 +345,7 @@ function NoteAdd(): React.ReactNode { Tags
- {selectedTags.map((t) => ( + {selectedTags.map(t => ( TaskNote · Shared Note (Read only) {note.tags && note.tags.length > 0 && ( - {note.tags.map((t) => `#${t}`).join(' ')} + {note.tags.map(t => `#${t}`).join(' ')} )} diff --git a/client/src/views/TaskAdd/index.tsx b/client/src/views/TaskAdd/index.tsx index 4067de7..4a84c50 100644 --- a/client/src/views/TaskAdd/index.tsx +++ b/client/src/views/TaskAdd/index.tsx @@ -133,7 +133,7 @@ function TaskAdd(): React.ReactNode { }; const removeTag = (tagToRemove: string): void => { - setSelectedTags(selectedTags.filter((t) => t !== tagToRemove)); + setSelectedTags(selectedTags.filter(t => t !== tagToRemove)); }; /** @@ -324,7 +324,7 @@ function TaskAdd(): React.ReactNode { Tags
- {selectedTags.map((t) => ( + {selectedTags.map(t => ( Date: Fri, 22 May 2026 20:05:06 +0200 Subject: [PATCH 08/11] chore: move tag list to below the inputs --- client/src/views/NoteAdd/index.tsx | 35 +++++++++++++------------ client/src/views/TaskAdd/index.tsx | 41 +++++++++++++++--------------- 2 files changed, 37 insertions(+), 39 deletions(-) diff --git a/client/src/views/NoteAdd/index.tsx b/client/src/views/NoteAdd/index.tsx index 6b59481..388c747 100644 --- a/client/src/views/NoteAdd/index.tsx +++ b/client/src/views/NoteAdd/index.tsx @@ -344,23 +344,6 @@ function NoteAdd(): React.ReactNode { {/* Tag with suggestion dropdown */} Tags -
- {selectedTags.map(t => ( - removeTag(t)} - > - # - {t} - {' '} - × - - ))} -
@@ -384,6 +367,23 @@ function NoteAdd(): React.ReactNode { autoComplete="off" /> +
+ {selectedTags.map(t => ( + removeTag(t)} + > + # + {t} + {' '} + × + + ))} +
{showTagDropdown && tags.filter(t => t.toLowerCase().includes(currentTag.toLowerCase())).length > 0 && ( diff --git a/client/src/views/TaskAdd/index.tsx b/client/src/views/TaskAdd/index.tsx index 4a84c50..bf2ec63 100644 --- a/client/src/views/TaskAdd/index.tsx +++ b/client/src/views/TaskAdd/index.tsx @@ -304,7 +304,7 @@ function TaskAdd(): React.ReactNode { }} /> - + {/* Due date */} - + {/* Tag with suggestion dropdown */} - Tags -
- {selectedTags.map(t => ( - removeTag(t)} - > - # - {t} - {' '} - × - - ))} -
+ Tags @@ -363,6 +346,23 @@ function TaskAdd(): React.ReactNode { autoComplete="off" /> +
+ {selectedTags.map(t => ( + removeTag(t)} + > + # + {t} + {' '} + × + + ))} +
{showTagDropdown && tags.filter(t => t.toLowerCase().includes(currentTag.toLowerCase())).length > 0 && ( From 7db3ad9fcfdafdac057583abfb154ffc11096481 Mon Sep 17 00:00:00 2001 From: Ricardo Campos Date: Fri, 22 May 2026 20:06:25 +0200 Subject: [PATCH 09/11] chore: add ngrok files and updates --- docker-compose.ngrok.yml | 92 ++++++++++++++++++++++++++++++++++++++++ nginx/start-proxy.sh | 2 +- 2 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 docker-compose.ngrok.yml diff --git a/docker-compose.ngrok.yml b/docker-compose.ngrok.yml new file mode 100644 index 0000000..3ed9462 --- /dev/null +++ b/docker-compose.ngrok.yml @@ -0,0 +1,92 @@ +--- + +services: + tasknote-web: + container_name: tasknote-web + image: node:22.14-bookworm-slim + ports: + - "5000:5000" + entrypoint: sh -c "npm i --no-update-notifier && npm start" + environment: + VITE_BACKEND_SERVER: "/api" + VITE_BUILD: nightly + volumes: + - "./client:/app" + working_dir: /app + healthcheck: + test: timeout 10s bash -c 'true > /dev/tcp/127.0.0.1/5000' + interval: 1m30s + timeout: 15s + retries: 3 + start_period: 10s + networks: + - tasknote + + tasknote-api: + container_name: tasknote-api + depends_on: + tasknote-db: + condition: service_started + environment: + POSTGRES_DB: tasknote + POSTGRES_HOST: tasknote-db + POSTGRES_USER: tasknoteuser + POSTGRES_PASSWORD: default + POSTGRES_PORT: 5432 + CORS_ALLOWED_ORIGINS: http://localhost:5000,http://tasknote-web:5000,https://flattop-depth-dropper.ngrok-free.dev + SERVER_SERVLET_CONTEXT_PATH: / + TARGET_ENV: development + SECURITY_KEY: this-is-a-very-long-security-key-for-dev + MAILGUN_APIKEY: invalid-api-key-only-placeholder + ports: + - "8585:8585" + - "5005:5005" + image: maven:3.9.12-eclipse-temurin-25 + entrypoint: './mvnw -ntp spring-boot:run -Dspring-boot.run.jvmArguments="-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=*:5005" -Dmaven.plugin.validation=VERBOSE' + working_dir: /app + volumes: + - "./server:/app" + healthcheck: + test: curl -f http://localhost:8585/health | grep '"status":"UP"' + interval: 1m30s + timeout: 15s + retries: 3 + start_period: 10s + networks: + - tasknote + + schemaspy: + container_name: schemaspy + profiles: ["schemaspy"] + image: schemaspy/schemaspy:7.0.2 + user: ${UID:-1000}:${GID:-1000} + volumes: + - "./schemaspy/output:/output" + - "./schemaspy/postgres.properties:/schemaspy.properties" + depends_on: + tasknote-db: + condition: service_healthy + tasknote-api: + condition: service_healthy + + tasknote-db: + container_name: tasknote-db + image: postgres:15.8-bookworm + environment: + POSTGRES_DB: tasknote + POSTGRES_USER: tasknoteuser + POSTGRES_PASSWORD: default + ports: + - "5432:5432" + healthcheck: + test: psql -q -U $${POSTGRES_USER} -d $${POSTGRES_DB} -c 'SELECT 1' + interval: 1m30s + timeout: 15s + retries: 3 + start_period: 10s + networks: + - tasknote + +networks: + tasknote: + external: true diff --git a/nginx/start-proxy.sh b/nginx/start-proxy.sh index aa830a1..ba239ac 100755 --- a/nginx/start-proxy.sh +++ b/nginx/start-proxy.sh @@ -6,5 +6,5 @@ docker run -d \ -p 127.0.0.1:8181:8181 \ -v ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro \ --restart unless-stopped \ - --network tasknote-network \ + --network tasknote \ nginx:stable From fafe7500fae8680f4c363d400aad1315b6ca127d Mon Sep 17 00:00:00 2001 From: Ricardo Campos Date: Fri, 22 May 2026 20:06:41 +0200 Subject: [PATCH 10/11] fix: session issues and query using HQL --- .../server/repository/TagRepository.java | 13 +++++++++++++ .../com/tasknoteapp/server/service/NoteService.java | 12 ++++++++++++ .../com/tasknoteapp/server/service/TaskService.java | 11 +++++++++++ 3 files changed, 36 insertions(+) diff --git a/server/src/main/java/br/com/tasknoteapp/server/repository/TagRepository.java b/server/src/main/java/br/com/tasknoteapp/server/repository/TagRepository.java index 970fe93..187ffa5 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/repository/TagRepository.java +++ b/server/src/main/java/br/com/tasknoteapp/server/repository/TagRepository.java @@ -4,6 +4,9 @@ import java.util.List; import java.util.Optional; 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; /** This interface represents a tag repository, for database access. */ public interface TagRepository extends JpaRepository { @@ -11,4 +14,14 @@ public interface TagRepository extends JpaRepository { Optional findByNameAndUser_id(String name, Long userId); List findAllByUser_idOrderByNameAsc(Long userId); + + @Modifying + @Query( + """ + delete from TagEntity t + where t.user.id = :userId + and not exists (select 1 from TaskEntity tk where t member of tk.tags) + and not exists (select 1 from NoteEntity n where t member of n.tags) + """) + void deleteOrphanedTags(@Param("userId") Long userId); } diff --git a/server/src/main/java/br/com/tasknoteapp/server/service/NoteService.java b/server/src/main/java/br/com/tasknoteapp/server/service/NoteService.java index 8cbf8c4..f25a23a 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/service/NoteService.java +++ b/server/src/main/java/br/com/tasknoteapp/server/service/NoteService.java @@ -69,6 +69,7 @@ public NoteService( * * @return {@link List} of {@link NoteResponse} with all notes or an empty list. */ + @Transactional public List getAllNotes() { UserEntity user = getCurrentUser(); @@ -86,6 +87,7 @@ public List getAllNotes() { * @param noteId The note id in the database. * @return {@link NoteResponse} with the found note or throw a {@link NoteNotFoundException}. */ + @Transactional public NoteResponse getNoteById(Long noteId) { UserEntity user = getCurrentUser(); logger.info("Get note ID {} to user ID {}", noteId, user.getId()); @@ -109,6 +111,7 @@ public NoteResponse getNoteById(Long noteId) { * @param noteRequest The note content. * @return {@link NoteResponse} with created note data. */ + @Transactional public NoteResponse createNote(NoteRequest noteRequest) { UserEntity user = getCurrentUser(); @@ -132,6 +135,8 @@ public NoteResponse createNote(NoteRequest noteRequest) { savedUrl = urlEntity.getUrl(); } + tagRepository.deleteOrphanedTags(user.getId()); + logger.info("Finished note creation!"); return NoteResponse.fromEntity(created, savedUrl); } @@ -179,6 +184,8 @@ public NoteResponse patchNote(Long noteId, NotePatchRequest patch) { NoteEntity patchedNote = noteRepository.save(noteEntity); noteRepository.flush(); + tagRepository.deleteOrphanedTags(user.getId()); + logger.info("Note patched! ID {}", patchedNote.getId()); return NoteResponse.fromEntity(patchedNote, getNoteUrl(patchedNote.getId())); @@ -207,6 +214,8 @@ public void deleteNote(Long noteId) { noteRepository.delete(noteEntity); + tagRepository.deleteOrphanedTags(user.getId()); + logger.info("Note deleted! ID {}", noteId); } @@ -216,6 +225,7 @@ public void deleteNote(Long noteId) { * @param searchTerm The term to be used for the search. * @return {@link List} of {@link NoteResponse} with found records or an empty list. */ + @Transactional public List searchNotes(String searchTerm) { UserEntity user = getCurrentUser(); @@ -261,6 +271,7 @@ public NoteResponse shareNote(Long noteId) { * @param noteId The note id from the database. * @return {@link NoteResponse} containing the updated note. */ + @Transactional public NoteResponse unshareNote(Long noteId) { UserEntity user = getCurrentUser(); logger.info("Unsharing note ID {} for user ID {}", noteId, user.getId()); @@ -285,6 +296,7 @@ public NoteResponse unshareNote(Long noteId) { * @param shareToken The unique share token for the note. * @return {@link NoteResponse} containing the shared note. */ + @Transactional public NoteResponse getSharedNote(String shareToken) { logger.info("Fetching shared note with token {}", shareToken); diff --git a/server/src/main/java/br/com/tasknoteapp/server/service/TaskService.java b/server/src/main/java/br/com/tasknoteapp/server/service/TaskService.java index 9f88b5e..30fa11f 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/service/TaskService.java +++ b/server/src/main/java/br/com/tasknoteapp/server/service/TaskService.java @@ -71,6 +71,7 @@ public TaskService( * * @return {@link List} of {@link TaskResponse} with all Tasks found or an empty list. */ + @Transactional public List getAllTasks() { UserEntity user = getCurrentUser(); logger.info("Get all tasks to user ID {}", user.getId()); @@ -89,6 +90,7 @@ public List getAllTasks() { * @param taskId The task id in the database. * @return {@link TaskResponse} with the found task or throw a {@link TaskNotFoundException}. */ + @Transactional public TaskResponse getTaskById(Long taskId) { UserEntity user = getCurrentUser(); logger.info("Get task ID {} to user ID {}", taskId, user.getId()); @@ -107,6 +109,7 @@ public TaskResponse getTaskById(Long taskId) { * * @param taskRequest The {@link TaskRequest} containing all task data. */ + @Transactional public TaskResponse createTask(TaskRequest taskRequest) { UserEntity user = getCurrentUser(); @@ -130,6 +133,8 @@ public TaskResponse createTask(TaskRequest taskRequest) { saveUrls(task, taskRequest.urls()); } + tagRepository.deleteOrphanedTags(user.getId()); + logger.info("Task created! ID {}", created.getId()); return TaskResponse.fromEntity(created, getAllTasksUrls(created.getId())); } @@ -175,6 +180,8 @@ public TaskResponse patchTask(Long taskId, TaskPatchRequest patch) { TaskEntity patchedTask = taskRepository.save(taskEntity); + tagRepository.deleteOrphanedTags(user.getId()); + logger.info("Task patched! ID {}", patchedTask.getId()); return TaskResponse.fromEntity(patchedTask, getAllTasksUrls(taskId)); @@ -208,6 +215,8 @@ public void deleteTask(Long taskId) { taskRepository.delete(taskEntity); + tagRepository.deleteOrphanedTags(user.getId()); + logger.info("Task deleted! ID {}", taskId); } @@ -217,6 +226,7 @@ public void deleteTask(Long taskId) { * @param searchTerm The term to be used for the search. * @return {@link List} of {@link TaskResponse} with found records or an empty list. */ + @Transactional public List searchTasks(String searchTerm) { UserEntity user = getCurrentUser(); @@ -241,6 +251,7 @@ public List searchTasks(String searchTerm) { * @param filter The filter to get the tasks. * @return {@link List} of {@link TaskResponse} with found records or an empty list. */ + @Transactional public List getTasksByFilter(String filter) { UserEntity user = getCurrentUser(); From 5085afbb4649b0184f1b205dfaa845e76c2db9a9 Mon Sep 17 00:00:00 2001 From: Ricardo Campos Date: Fri, 22 May 2026 21:37:30 +0200 Subject: [PATCH 11/11] test: fix test cases --- .../components/ModalMarkdown.test.tsx | 32 +++++++++++++++---- client/src/__test__/views/NoteAdd.test.tsx | 24 +++++++------- .../FormInput/custom-datepicker.scss | 13 ++++++++ client/src/components/FormInput/index.tsx | 2 +- client/src/views/TaskAdd/index.tsx | 2 +- 5 files changed, 52 insertions(+), 21 deletions(-) diff --git a/client/src/__test__/components/ModalMarkdown.test.tsx b/client/src/__test__/components/ModalMarkdown.test.tsx index 5d8adf5..95ec2d3 100644 --- a/client/src/__test__/components/ModalMarkdown.test.tsx +++ b/client/src/__test__/components/ModalMarkdown.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { describe, vi, it, expect, beforeEach } from 'vitest'; -import { render, screen, fireEvent, act, waitFor } from '@testing-library/react'; +import { describe, vi, it, expect, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, act, waitFor, cleanup } from '@testing-library/react'; import ModalMarkdown from '../../components/ModalMarkdown'; describe('ModalMarkdown Component', () => { @@ -12,13 +12,19 @@ describe('ModalMarkdown Component', () => { }; beforeEach(() => { - Object.assign(navigator, { + vi.stubGlobal('navigator', { clipboard: { writeText: vi.fn().mockResolvedValue(undefined), }, }); }); + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + cleanup(); + }); + it('should render the modal with the correct title and markdown text', () => { render(); @@ -84,15 +90,29 @@ describe('ModalMarkdown Component', () => { }); it('should show "Copied!" text after Copy button is clicked', async () => { + vi.useFakeTimers(); render(); + const copyButton = screen.getByTestId('modal-copy-button'); + await act(async () => { - fireEvent.click(screen.getByTestId('modal-copy-button')); + fireEvent.click(copyButton); + }); + + // Resolve microtasks for the clipboard promise + await act(async () => { + await vi.runAllTicks(); }); - await waitFor(() => { - expect(screen.getByTestId('modal-copy-button').textContent).toBe('Copied!'); + expect(screen.getByTestId('modal-copy-button').textContent).toBe('Copied!'); + + // Advance timers to see it go back to "Copy" + await act(async () => { + vi.advanceTimersByTime(2000); }); + + expect(screen.getByTestId('modal-copy-button').textContent).toBe('Copy'); + vi.useRealTimers(); }); it('should reset source view state when modal is closed', () => { diff --git a/client/src/__test__/views/NoteAdd.test.tsx b/client/src/__test__/views/NoteAdd.test.tsx index 73abf58..4992689 100644 --- a/client/src/__test__/views/NoteAdd.test.tsx +++ b/client/src/__test__/views/NoteAdd.test.tsx @@ -1,5 +1,6 @@ import React, { act } from 'react'; import { render, fireEvent, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { MemoryRouter } from 'react-router'; import { I18nextProvider } from 'react-i18next'; @@ -16,6 +17,7 @@ vi.mock('../../api-service/api', () => ({ default: { postJSON: vi.fn(), getJSON: vi.fn(), + patchJSON: vi.fn(), } })); @@ -82,6 +84,7 @@ vi.mock('../../utils/TranslatorUtils', () => ({ const mockedApi = vi.mocked(api); const mockedUseParams = vi.mocked(useParams); +const mockedUseSearchParams = vi.mocked(useSearchParams); describe('NoteAdd Component', () => { const renderNoteAdd = () => { @@ -100,9 +103,9 @@ describe('NoteAdd Component', () => { beforeEach(() => { // Reset mock between tests - (useSearchParams as unknown as ReturnType).mockReset(); + mockedUseSearchParams.mockReturnValue([new URLSearchParams(), vi.fn()]); + mockedUseParams.mockReturnValue({}); vi.clearAllMocks(); - vi.resetAllMocks(); }); it('should render the NoteAdd component', async () => { @@ -118,28 +121,23 @@ describe('NoteAdd Component', () => { }); it('should show error message when form is invalid', async () => { - let result: any; - await act(async () => { - result = renderNoteAdd(); - }); - const { getByText, getByRole } = result; + const { getByText, getByRole } = renderNoteAdd(); const submitButton = getByRole('button', { name: 'note_form_submit' }); + fireEvent.click(submitButton); + await waitFor(() => { expect(getByText('Please fill in all the fields')).toBeDefined(); }); }); it('should add a new note when form is valid', async () => { - (useSearchParams as unknown as ReturnType).mockReturnValue([ + mockedUseSearchParams.mockReturnValue([ new URLSearchParams("backTo=home"), + vi.fn(), ]); - let result: any; - await act(async () => { - result = renderNoteAdd(); - }); - const { getByLabelText, getByTestId, getByRole } = result; + const { getByLabelText, getByTestId, getByRole } = renderNoteAdd(); const descriptionInput = getByLabelText('note_form_title_label') as HTMLInputElement; const noteContentInput = getByTestId('note-content-input-area') as HTMLAreaElement; const submitButton = getByRole('button', { name: 'note_form_submit' }); diff --git a/client/src/components/FormInput/custom-datepicker.scss b/client/src/components/FormInput/custom-datepicker.scss index 1291578..f20438f 100644 --- a/client/src/components/FormInput/custom-datepicker.scss +++ b/client/src/components/FormInput/custom-datepicker.scss @@ -1,6 +1,19 @@ @import '../../styles/theme.scss'; /* custom-datepicker.scss */ +.react-datepicker-wrapper, +.react-datepicker__input-container { + flex: 1 1 auto !important; + width: 1% !important; + display: flex !important; +} + +.react-datepicker__input-container { + /* Ensure it takes full width of the flex item */ + width: 100%; +} + + .react-datepicker__input-container input { font-size: 16px; /* Prevents iOS zoom on focus */ width: 100%; diff --git a/client/src/components/FormInput/index.tsx b/client/src/components/FormInput/index.tsx index 56599fb..05ee14d 100644 --- a/client/src/components/FormInput/index.tsx +++ b/client/src/components/FormInput/index.tsx @@ -63,7 +63,7 @@ function FormInput(props: React.PropsWithChildren): React.ReactNode { )} - + diff --git a/client/src/views/TaskAdd/index.tsx b/client/src/views/TaskAdd/index.tsx index bf2ec63..730d514 100644 --- a/client/src/views/TaskAdd/index.tsx +++ b/client/src/views/TaskAdd/index.tsx @@ -322,7 +322,7 @@ function TaskAdd(): React.ReactNode { {/* Tag with suggestion dropdown */} - Tags + Tags