Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39cbe80582 | ||
|
|
5fec30fea0 | ||
|
|
a35c037f20 |
@@ -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
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -0,0 +1,14 @@
|
||||
# Skill: Review Code Quality
|
||||
|
||||
## Focus Areas
|
||||
|
||||
- readability
|
||||
- maintainability
|
||||
- consistency
|
||||
- architectural alignment
|
||||
|
||||
## Avoid
|
||||
|
||||
- unnecessary nitpicks
|
||||
- perfectionism
|
||||
- speculative suggestions
|
||||
@@ -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
|
||||
@@ -0,0 +1,14 @@
|
||||
# Skill: Write Tests
|
||||
|
||||
## Prioritize
|
||||
|
||||
- critical workflows
|
||||
- expected behavior
|
||||
- important edge cases
|
||||
|
||||
## Philosophy
|
||||
|
||||
Testing should improve:
|
||||
- confidence
|
||||
- reliability
|
||||
- maintainability
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -0,0 +1,43 @@
|
||||
# Spec: <name>
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,16 @@
|
||||
# Orchestration Prompt
|
||||
|
||||
Using the orchestrator workflow defined at `ai/orchestration/orchestrator.md`,
|
||||
execute the first spec file, 001-add-multiple-tags.md.
|
||||
|
||||
Respect:
|
||||
- architecture principles
|
||||
- role responsibilities
|
||||
- context policies
|
||||
- handoff rules
|
||||
|
||||
Keep implementation:
|
||||
- simple
|
||||
- maintainable
|
||||
- incremental
|
||||
- production-like
|
||||
@@ -0,0 +1,22 @@
|
||||
# Spec for a feature prompt example
|
||||
|
||||
Based on the project context, defined at `ai/context/*.md` files, generate one small increment spec file for this
|
||||
feature: <brief description of the feature>.
|
||||
|
||||
Requirements:
|
||||
- small scope
|
||||
- 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
|
||||
- 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/`.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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(<ModalMarkdown {...props} />);
|
||||
|
||||
@@ -84,15 +90,29 @@ describe('ModalMarkdown Component', () => {
|
||||
});
|
||||
|
||||
it('should show "Copied!" text after Copy button is clicked', async () => {
|
||||
vi.useFakeTimers();
|
||||
render(<ModalMarkdown {...props} />);
|
||||
|
||||
const copyButton = screen.getByTestId('modal-copy-button');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId('modal-copy-button'));
|
||||
fireEvent.click(copyButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('modal-copy-button').textContent).toBe('Copied!');
|
||||
// Resolve microtasks for the clipboard promise
|
||||
await act(async () => {
|
||||
await vi.runAllTicks();
|
||||
});
|
||||
|
||||
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', () => {
|
||||
|
||||
@@ -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(<TaskTag tag="important" lastUpdate="2025-02-20" />);
|
||||
expect(getByText('#important')).toBeDefined();
|
||||
const { getByText } = render(<TaskTag tags={['important']} lastUpdate="2025-02-20" taskOrNote="task" />);
|
||||
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(<TaskTag lastUpdate="2025-02-20" />);
|
||||
expect(getByText('#untagged')).toBeDefined();
|
||||
const { getByText } = render(<TaskTag lastUpdate="2025-02-20" taskOrNote="task" />);
|
||||
expect(getByText('#untagged task')).toBeDefined();
|
||||
expect(getByText('2025-02-20')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -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<typeof vi.fn>).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<typeof vi.fn>).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' });
|
||||
@@ -154,7 +152,7 @@ describe('NoteAdd Component', () => {
|
||||
title: 'New Note',
|
||||
description: 'Note content',
|
||||
url: '',
|
||||
tag: '',
|
||||
tags: [],
|
||||
lastUpdate: '',
|
||||
shared: false,
|
||||
shareToken: null
|
||||
@@ -185,8 +183,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 +211,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);
|
||||
|
||||
@@ -130,7 +130,7 @@ describe('TaskAdd Component', () => {
|
||||
description: 'New Task',
|
||||
dueDate: '',
|
||||
highPriority: false,
|
||||
tag: '',
|
||||
tags: [],
|
||||
urls: []
|
||||
}
|
||||
expect(api.postJSON).toHaveBeenCalledWith(ApiConfig.tasksUrl, newTask);
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -63,7 +63,7 @@ function FormInput(props: React.PropsWithChildren<Props>): React.ReactNode {
|
||||
)}
|
||||
</Form.Label>
|
||||
|
||||
<InputGroup className="mb-3">
|
||||
<InputGroup className="mb-3 flex-nowrap">
|
||||
<InputGroup.Text>
|
||||
<Icon />
|
||||
</InputGroup.Text>
|
||||
|
||||
@@ -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<Element, 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<Props>): 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 (
|
||||
<Row>
|
||||
<Col className="d-inline-block text-muted card-tag poppins-regular">
|
||||
{tagText}
|
||||
{tagContent}
|
||||
{' '}
|
||||
{props.taskOrNote}
|
||||
{props.taskOrNote === 'note' && (
|
||||
|
||||
@@ -3,7 +3,7 @@ type NoteResponse = {
|
||||
title: string;
|
||||
description: string;
|
||||
url: string | null;
|
||||
tag: string;
|
||||
tags: string[];
|
||||
lastUpdate: string;
|
||||
shared: boolean;
|
||||
shareToken: string | null;
|
||||
|
||||
@@ -4,7 +4,7 @@ type TaskNoteRequest = {
|
||||
urls?: string[];
|
||||
dueDate?: string;
|
||||
highPriority?: boolean;
|
||||
tag: string;
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
export default TaskNoteRequest;
|
||||
|
||||
@@ -6,7 +6,7 @@ type TaskResponse = {
|
||||
dueDate: string;
|
||||
dueDateFmt: string;
|
||||
lastUpdate: string;
|
||||
tag: string;
|
||||
tags: string[];
|
||||
urls: string[];
|
||||
};
|
||||
|
||||
|
||||
@@ -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 {
|
||||
</Card.Body>
|
||||
<Card.Footer className="task-card-footer">
|
||||
<TaskTag
|
||||
tag={task.tag}
|
||||
tags={task.tags}
|
||||
lastUpdate={task.lastUpdate}
|
||||
taskOrNote="task"
|
||||
/>
|
||||
@@ -593,7 +593,7 @@ function Home(): React.ReactNode {
|
||||
</Card.Body>
|
||||
<Card.Footer className="task-card-footer">
|
||||
<TaskTag
|
||||
tag={note.tag}
|
||||
tags={note.tags}
|
||||
lastUpdate={note.lastUpdate}
|
||||
taskOrNote="note"
|
||||
onClick={(e: React.MouseEvent<Element, MouseEvent>) => {
|
||||
|
||||
@@ -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<string>('');
|
||||
const [noteContent, setNoteContent] = useState<string>('');
|
||||
const [noteUrl, setNoteUrl] = useState<string>('');
|
||||
const [noteTag, setNoteTag] = useState<string>('');
|
||||
const [currentTag, setCurrentTag] = useState<string>('');
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
const [showTagDropdown, setShowTagDropdown] = useState<boolean>(false);
|
||||
const [action, setAction] = useState<NoteAction>('add');
|
||||
@@ -71,8 +73,8 @@ function NoteAdd(): React.ReactNode {
|
||||
/**
|
||||
* Adds a new note.
|
||||
*
|
||||
* @param {TaskNoteRequest} payload - The task data to add.
|
||||
* @returns {Promise<boolean>} True if the task was added successfully, false otherwise.
|
||||
* @param {NoteResponse} payload - The note data to add.
|
||||
* @returns {Promise<boolean>} True if the note was added successfully, false otherwise.
|
||||
*/
|
||||
const addNote = async (payload: NoteResponse): Promise<boolean> => {
|
||||
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<boolean>} True if the task was edited successfully, false otherwise.
|
||||
* @param {NoteResponse} payload - The note data to edit.
|
||||
* @returns {Promise<boolean>} True if the note was edited successfully, false otherwise.
|
||||
*/
|
||||
const submitEditNote = async (payload: NoteResponse): Promise<boolean> => {
|
||||
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<void> => {
|
||||
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,7 @@ function NoteAdd(): React.ReactNode {
|
||||
<Col xs={12} xl={3}>
|
||||
{/* Tag with suggestion dropdown */}
|
||||
<Form.Group className="mb-3" ref={tagContainerRef} style={{ position: 'relative' }}>
|
||||
<Form.Label>Tag</Form.Label>
|
||||
<Form.Label>Tags</Form.Label>
|
||||
<InputGroup className="mb-3">
|
||||
<InputGroup.Text>
|
||||
<Hash />
|
||||
@@ -327,16 +352,39 @@ function NoteAdd(): React.ReactNode {
|
||||
type="text"
|
||||
name="tag"
|
||||
placeholder="my-tag (Optional)"
|
||||
value={noteTag}
|
||||
value={currentTag}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setNoteTag(e.target.value);
|
||||
setCurrentTag(e.target.value);
|
||||
setShowTagDropdown(true);
|
||||
}}
|
||||
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' && currentTag.trim()) {
|
||||
e.preventDefault();
|
||||
addTag(currentTag);
|
||||
}
|
||||
}}
|
||||
onFocus={() => setShowTagDropdown(true)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</InputGroup>
|
||||
{showTagDropdown && tags.filter(t => t.toLowerCase().includes(noteTag.toLowerCase())).length > 0 && (
|
||||
<div className="mb-2 d-flex flex-wrap gap-1">
|
||||
{selectedTags.map(t => (
|
||||
<Badge
|
||||
key={t}
|
||||
bg="warning"
|
||||
text="dark"
|
||||
className="p-2"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => removeTag(t)}
|
||||
>
|
||||
#
|
||||
{t}
|
||||
{' '}
|
||||
×
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
{showTagDropdown && tags.filter(t => t.toLowerCase().includes(currentTag.toLowerCase())).length > 0 && (
|
||||
<ListGroup
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@@ -344,20 +392,18 @@ function NoteAdd(): React.ReactNode {
|
||||
width: '100%',
|
||||
maxHeight: '200px',
|
||||
overflowY: 'auto',
|
||||
top: '100%',
|
||||
left: 0
|
||||
}}
|
||||
>
|
||||
{tags
|
||||
.filter(t => t.toLowerCase().includes(noteTag.toLowerCase()))
|
||||
.filter(t => t.toLowerCase().includes(currentTag.toLowerCase()))
|
||||
.map(t => (
|
||||
<ListGroup.Item
|
||||
key={t}
|
||||
action
|
||||
onMouseDown={(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setNoteTag(t);
|
||||
setShowTagDropdown(false);
|
||||
addTag(t);
|
||||
}}
|
||||
>
|
||||
#
|
||||
|
||||
@@ -72,10 +72,9 @@ function SharedNote(): React.ReactNode {
|
||||
<Card>
|
||||
<Card.Header className="d-flex justify-content-between align-items-center">
|
||||
<small className="text-muted">TaskNote · Shared Note (Read only)</small>
|
||||
{note.tag && (
|
||||
{note.tags && note.tags.length > 0 && (
|
||||
<small className="text-muted">
|
||||
#
|
||||
{note.tag}
|
||||
{note.tags.map(t => `#${t}`).join(' ')}
|
||||
</small>
|
||||
)}
|
||||
</Card.Header>
|
||||
|
||||
@@ -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<TaskAction>('add');
|
||||
const [dueDate, setDueDate] = useState<Date | null>(null);
|
||||
const [highPriority, setHighPriority] = useState<boolean>(false);
|
||||
const [tag, setTag] = useState<string>('');
|
||||
const [currentTag, setCurrentTag] = useState<string>('');
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
const [showTagDropdown, setShowTagDropdown] = useState<boolean>(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');
|
||||
}
|
||||
@@ -279,7 +304,7 @@ function TaskAdd(): React.ReactNode {
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={6} xxl={3}>
|
||||
<Col xs={12} sm={6} xxl={6}>
|
||||
{/* Due date */}
|
||||
<FormInput
|
||||
labelText={t('task_form_duedate_label')}
|
||||
@@ -294,10 +319,10 @@ function TaskAdd(): React.ReactNode {
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={6} xxl={3}>
|
||||
<Col xs={12} sm={12} xxl={12}>
|
||||
{/* Tag with suggestion dropdown */}
|
||||
<Form.Group className="mb-3" ref={tagContainerRef} style={{ position: 'relative' }}>
|
||||
<Form.Label>Tag</Form.Label>
|
||||
<Form.Label>Tags</Form.Label>
|
||||
<InputGroup className="mb-3">
|
||||
<InputGroup.Text>
|
||||
<Hash />
|
||||
@@ -306,16 +331,39 @@ function TaskAdd(): React.ReactNode {
|
||||
type="text"
|
||||
name="tag"
|
||||
placeholder="my-tag (Optional)"
|
||||
value={tag}
|
||||
value={currentTag}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setTag(e.target.value);
|
||||
setCurrentTag(e.target.value);
|
||||
setShowTagDropdown(true);
|
||||
}}
|
||||
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' && currentTag.trim()) {
|
||||
e.preventDefault();
|
||||
addTag(currentTag);
|
||||
}
|
||||
}}
|
||||
onFocus={() => setShowTagDropdown(true)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</InputGroup>
|
||||
{showTagDropdown && tags.filter(t => t.toLowerCase().includes(tag.toLowerCase())).length > 0 && (
|
||||
<div className="mb-2 d-flex flex-wrap gap-1">
|
||||
{selectedTags.map(t => (
|
||||
<Badge
|
||||
key={t}
|
||||
bg="warning"
|
||||
text="dark"
|
||||
className="p-2"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => removeTag(t)}
|
||||
>
|
||||
#
|
||||
{t}
|
||||
{' '}
|
||||
×
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
{showTagDropdown && tags.filter(t => t.toLowerCase().includes(currentTag.toLowerCase())).length > 0 && (
|
||||
<ListGroup
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@@ -323,20 +371,18 @@ function TaskAdd(): React.ReactNode {
|
||||
width: '100%',
|
||||
maxHeight: '200px',
|
||||
overflowY: 'auto',
|
||||
top: '100%',
|
||||
left: 0
|
||||
}}
|
||||
>
|
||||
{tags
|
||||
.filter(t => t.toLowerCase().includes(tag.toLowerCase()))
|
||||
.filter(t => t.toLowerCase().includes(currentTag.toLowerCase()))
|
||||
.map(t => (
|
||||
<ListGroup.Item
|
||||
key={t}
|
||||
action
|
||||
onMouseDown={(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setTag(t);
|
||||
setShowTagDropdown(false);
|
||||
addTag(t);
|
||||
}}
|
||||
>
|
||||
#
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@
|
||||
|
||||
<groupId>br.com.tasknoteapp</groupId>
|
||||
<artifactId>server</artifactId>
|
||||
<version>26</version>
|
||||
<version>28</version>
|
||||
<name>tasknote-api</name>
|
||||
<description>Java backend REST API to serve TaskNote frontend client</description>
|
||||
|
||||
|
||||
@@ -7,9 +7,13 @@ import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.JoinTable;
|
||||
import jakarta.persistence.ManyToMany;
|
||||
import jakarta.persistence.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<TagEntity> tags = new HashSet<>();
|
||||
|
||||
@Column(name = "last_update")
|
||||
private LocalDateTime lastUpdate;
|
||||
@@ -73,12 +81,12 @@ public class NoteEntity {
|
||||
this.user = user;
|
||||
}
|
||||
|
||||
public String getTag() {
|
||||
return tag;
|
||||
public Set<TagEntity> getTags() {
|
||||
return tags;
|
||||
}
|
||||
|
||||
public void setTag(String tag) {
|
||||
this.tag = tag;
|
||||
public void setTags(Set<TagEntity> tags) {
|
||||
this.tags = tags;
|
||||
}
|
||||
|
||||
public LocalDateTime getLastUpdate() {
|
||||
@@ -133,9 +141,8 @@ public class NoteEntity {
|
||||
+ ", description='"
|
||||
+ description
|
||||
+ '\''
|
||||
+ ", tag='"
|
||||
+ tag
|
||||
+ '\''
|
||||
+ ", tags="
|
||||
+ tags
|
||||
+ ", lastUpdate="
|
||||
+ lastUpdate
|
||||
+ '}';
|
||||
|
||||
@@ -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 + '\'' + '}';
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,14 @@ import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.JoinTable;
|
||||
import jakarta.persistence.ManyToMany;
|
||||
import jakarta.persistence.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<TagEntity> tags = new HashSet<>();
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
@@ -98,12 +106,12 @@ public class TaskEntity {
|
||||
this.highPriority = highPriority;
|
||||
}
|
||||
|
||||
public String getTag() {
|
||||
return tag;
|
||||
public Set<TagEntity> getTags() {
|
||||
return tags;
|
||||
}
|
||||
|
||||
public void setTag(String tag) {
|
||||
this.tag = tag;
|
||||
public void setTags(Set<TagEntity> tags) {
|
||||
this.tags = tags;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -139,9 +147,8 @@ public class TaskEntity {
|
||||
+ dueDate
|
||||
+ ", highPriority="
|
||||
+ highPriority
|
||||
+ ", tag='"
|
||||
+ tag
|
||||
+ '\''
|
||||
+ ", tags="
|
||||
+ tags
|
||||
+ '}';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import java.util.List;
|
||||
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<NoteEntity, Long> {
|
||||
@@ -16,7 +17,16 @@ public interface NoteRepository extends JpaRepository<NoteEntity, Long> {
|
||||
Optional<NoteEntity> 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<NoteEntity> 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<NoteEntity> findAllBySearchTerm(
|
||||
@Param("searchTerm") String searchTerm, @Param("userId") Long userId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
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;
|
||||
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<TagEntity, Long> {
|
||||
|
||||
Optional<TagEntity> findByNameAndUser_id(String name, Long userId);
|
||||
|
||||
List<TagEntity> 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);
|
||||
}
|
||||
@@ -16,9 +16,10 @@ public interface TaskRepository extends JpaRepository<TaskEntity, Long> {
|
||||
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
|
||||
""")
|
||||
|
||||
@@ -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<String> tags) {}
|
||||
|
||||
@@ -2,6 +2,7 @@ package br.com.tasknoteapp.server.request;
|
||||
|
||||
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<String> tags) {}
|
||||
|
||||
@@ -15,4 +15,4 @@ public record TaskPatchRequest(
|
||||
urls,
|
||||
String dueDate,
|
||||
Boolean highPriority,
|
||||
String tag) {}
|
||||
List<String> tags) {}
|
||||
|
||||
@@ -16,4 +16,4 @@ public record TaskRequest(
|
||||
urls,
|
||||
String dueDate,
|
||||
Boolean highPriority,
|
||||
String tag) {}
|
||||
List<String> tags) {}
|
||||
|
||||
@@ -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<String> 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 record NoteResponse(
|
||||
entity.getDescription(),
|
||||
url,
|
||||
timeAgoFmt,
|
||||
entity.getTag(),
|
||||
entity.getTags().stream().map(TagEntity::getName).toList(),
|
||||
entity.isShared(),
|
||||
entity.getShareToken());
|
||||
}
|
||||
|
||||
@@ -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<String> tags,
|
||||
List<String> 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<String> urls) {
|
||||
@@ -35,7 +37,7 @@ public record TaskResponse(
|
||||
entity.getDueDate(),
|
||||
dueDateFmt,
|
||||
timeAgoFmt,
|
||||
entity.getTag(),
|
||||
entity.getTags().stream().map(TagEntity::getName).toList(),
|
||||
urls);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 class HomeService {
|
||||
* @return List of String with the tags.
|
||||
*/
|
||||
public List<String> getTopTasksTag() {
|
||||
logger.info("Getting all tags for tasks and notes");
|
||||
UserEntity user = getCurrentUser();
|
||||
logger.info("Getting all tags for user ID {}", user.getId());
|
||||
|
||||
List<String> tags =
|
||||
tagRepository.findAllByUser_idOrderByNameAsc(user.getId()).stream()
|
||||
.map(TagEntity::getName)
|
||||
.toList();
|
||||
|
||||
List<TaskResponse> tasks = taskService.getTasksByFilter("all");
|
||||
List<NoteResponse> notes = noteService.getAllNotes();
|
||||
|
||||
Set<String> 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 hasUntagged =
|
||||
tasks.stream().anyMatch(task -> task.tags().isEmpty())
|
||||
|| notes.stream().anyMatch(note -> note.tags().isEmpty());
|
||||
|
||||
boolean hasBlankTags =
|
||||
tasks.stream().anyMatch(task -> task.tag() == null || task.tag().isBlank())
|
||||
|| notes.stream().anyMatch(note -> note.tag() == null || note.tag().isBlank());
|
||||
if (hasBlankTags) {
|
||||
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<String> currentUserEmail = authUtil.getCurrentUserEmail();
|
||||
String email = currentUserEmail.orElseThrow();
|
||||
return authService.findByEmail(email).orElseThrow();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,20 +2,24 @@ package br.com.tasknoteapp.server.service;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,6 +69,7 @@ public class NoteService {
|
||||
*
|
||||
* @return {@link List} of {@link NoteResponse} with all notes or an empty list.
|
||||
*/
|
||||
@Transactional
|
||||
public List<NoteResponse> getAllNotes() {
|
||||
UserEntity user = getCurrentUser();
|
||||
|
||||
@@ -77,6 +87,7 @@ public class NoteService {
|
||||
* @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());
|
||||
@@ -100,6 +111,7 @@ public class NoteService {
|
||||
* @param noteRequest The note content.
|
||||
* @return {@link NoteResponse} with created note data.
|
||||
*/
|
||||
@Transactional
|
||||
public NoteResponse createNote(NoteRequest noteRequest) {
|
||||
UserEntity user = getCurrentUser();
|
||||
|
||||
@@ -108,7 +120,9 @@ public class NoteService {
|
||||
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);
|
||||
@@ -121,6 +135,8 @@ public class NoteService {
|
||||
savedUrl = urlEntity.getUrl();
|
||||
}
|
||||
|
||||
tagRepository.deleteOrphanedTags(user.getId());
|
||||
|
||||
logger.info("Finished note creation!");
|
||||
return NoteResponse.fromEntity(created, savedUrl);
|
||||
}
|
||||
@@ -150,8 +166,8 @@ public class NoteService {
|
||||
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());
|
||||
|
||||
@@ -168,6 +184,8 @@ public class NoteService {
|
||||
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()));
|
||||
@@ -196,6 +214,8 @@ public class NoteService {
|
||||
|
||||
noteRepository.delete(noteEntity);
|
||||
|
||||
tagRepository.deleteOrphanedTags(user.getId());
|
||||
|
||||
logger.info("Note deleted! ID {}", noteId);
|
||||
}
|
||||
|
||||
@@ -205,6 +225,7 @@ public class NoteService {
|
||||
* @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<NoteResponse> searchNotes(String searchTerm) {
|
||||
UserEntity user = getCurrentUser();
|
||||
|
||||
@@ -250,6 +271,7 @@ public class NoteService {
|
||||
* @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());
|
||||
@@ -274,6 +296,7 @@ public class NoteService {
|
||||
* @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);
|
||||
|
||||
@@ -285,6 +308,28 @@ public class NoteService {
|
||||
return NoteResponse.fromEntity(noteOpt.get(), getNoteUrl(noteOpt.get().getId()));
|
||||
}
|
||||
|
||||
private Set<TagEntity> getOrCreateTags(List<String> tagNames, UserEntity user) {
|
||||
if (Objects.isNull(tagNames) || tagNames.isEmpty()) {
|
||||
return new HashSet<>();
|
||||
}
|
||||
|
||||
Set<String> normalizedNames =
|
||||
tagNames.stream()
|
||||
.filter(name -> !Objects.isNull(name) && !name.isBlank())
|
||||
.map(name -> name.trim().toLowerCase())
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
Set<TagEntity> 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<String> currentUserEmail = authUtil.getCurrentUserEmail();
|
||||
String email = currentUserEmail.orElseThrow();
|
||||
|
||||
@@ -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.LocalDate;
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,6 +71,7 @@ public class TaskService {
|
||||
*
|
||||
* @return {@link List} of {@link TaskResponse} with all Tasks found or an empty list.
|
||||
*/
|
||||
@Transactional
|
||||
public List<TaskResponse> getAllTasks() {
|
||||
UserEntity user = getCurrentUser();
|
||||
logger.info("Get all tasks to user ID {}", user.getId());
|
||||
@@ -79,6 +90,7 @@ public class TaskService {
|
||||
* @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());
|
||||
@@ -97,6 +109,7 @@ public class TaskService {
|
||||
*
|
||||
* @param taskRequest The {@link TaskRequest} containing all task data.
|
||||
*/
|
||||
@Transactional
|
||||
public TaskResponse createTask(TaskRequest taskRequest) {
|
||||
UserEntity user = getCurrentUser();
|
||||
|
||||
@@ -111,13 +124,17 @@ public class TaskService {
|
||||
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()) {
|
||||
saveUrls(task, taskRequest.urls());
|
||||
}
|
||||
|
||||
tagRepository.deleteOrphanedTags(user.getId());
|
||||
|
||||
logger.info("Task created! ID {}", created.getId());
|
||||
return TaskResponse.fromEntity(created, getAllTasksUrls(created.getId()));
|
||||
}
|
||||
@@ -150,13 +167,11 @@ public class TaskService {
|
||||
|
||||
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());
|
||||
@@ -165,6 +180,8 @@ public class TaskService {
|
||||
|
||||
TaskEntity patchedTask = taskRepository.save(taskEntity);
|
||||
|
||||
tagRepository.deleteOrphanedTags(user.getId());
|
||||
|
||||
logger.info("Task patched! ID {}", patchedTask.getId());
|
||||
|
||||
return TaskResponse.fromEntity(patchedTask, getAllTasksUrls(taskId));
|
||||
@@ -198,6 +215,8 @@ public class TaskService {
|
||||
|
||||
taskRepository.delete(taskEntity);
|
||||
|
||||
tagRepository.deleteOrphanedTags(user.getId());
|
||||
|
||||
logger.info("Task deleted! ID {}", taskId);
|
||||
}
|
||||
|
||||
@@ -207,6 +226,7 @@ public class TaskService {
|
||||
* @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<TaskResponse> searchTasks(String searchTerm) {
|
||||
UserEntity user = getCurrentUser();
|
||||
|
||||
@@ -231,6 +251,7 @@ public class TaskService {
|
||||
* @param filter The filter to get the tasks.
|
||||
* @return {@link List} of {@link TaskResponse} with found records or an empty list.
|
||||
*/
|
||||
@Transactional
|
||||
public List<TaskResponse> getTasksByFilter(String filter) {
|
||||
UserEntity user = getCurrentUser();
|
||||
|
||||
@@ -254,17 +275,39 @@ public class TaskService {
|
||||
.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<TagEntity> getOrCreateTags(List<String> tagNames, UserEntity user) {
|
||||
if (Objects.isNull(tagNames) || tagNames.isEmpty()) {
|
||||
return new HashSet<>();
|
||||
}
|
||||
|
||||
Set<String> normalizedNames =
|
||||
tagNames.stream()
|
||||
.filter(name -> !Objects.isNull(name) && !name.isBlank())
|
||||
.map(name -> name.trim().toLowerCase())
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
Set<TagEntity> 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<String> currentUserEmail = authUtil.getCurrentUserEmail();
|
||||
String email = currentUserEmail.orElseThrow();
|
||||
@@ -291,7 +334,7 @@ public class TaskService {
|
||||
|
||||
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) {
|
||||
|
||||
@@ -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.
|
||||
@@ -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 @@ class NoteControllerTest {
|
||||
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 @@ class NoteControllerTest {
|
||||
patchRequest.description(),
|
||||
null,
|
||||
null,
|
||||
"tag",
|
||||
List.of("tag"),
|
||||
false,
|
||||
null);
|
||||
|
||||
@@ -118,7 +118,8 @@ class NoteControllerTest {
|
||||
{
|
||||
"title": "New title",
|
||||
"description": "New description",
|
||||
"url": null
|
||||
"url": null,
|
||||
"tags": ["tag"]
|
||||
}
|
||||
""";
|
||||
|
||||
@@ -143,7 +144,7 @@ class NoteControllerTest {
|
||||
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 @@ class NoteControllerTest {
|
||||
{
|
||||
"title": "New title",
|
||||
"description": "New description",
|
||||
"url": null
|
||||
"url": null,
|
||||
"tags": ["tag"]
|
||||
}
|
||||
""";
|
||||
|
||||
@@ -196,10 +198,10 @@ class NoteControllerTest {
|
||||
@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 @@ class NoteControllerTest {
|
||||
{
|
||||
"title": "Title",
|
||||
"description": "Description",
|
||||
"url": null
|
||||
"url": null,
|
||||
"tags": ["tag"]
|
||||
}
|
||||
""";
|
||||
|
||||
@@ -328,7 +331,7 @@ class NoteControllerTest {
|
||||
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 @@ class NoteControllerTest {
|
||||
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);
|
||||
|
||||
|
||||
+2
-1
@@ -9,6 +9,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
||||
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);
|
||||
|
||||
|
||||
@@ -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 @@ class TaskControllerTest {
|
||||
null,
|
||||
null,
|
||||
"Moments ago",
|
||||
"tag",
|
||||
List.of("tag"),
|
||||
List.of("http://test.com"));
|
||||
when(taskService.getTaskById(taskId)).thenReturn(taskResponse);
|
||||
|
||||
@@ -167,7 +167,7 @@ class TaskControllerTest {
|
||||
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 @@ class TaskControllerTest {
|
||||
null,
|
||||
null,
|
||||
"Moments ago",
|
||||
"tag",
|
||||
List.of("tag"),
|
||||
List.of());
|
||||
when(taskService.patchTask(taskId, patchRequest)).thenReturn(taskResponse);
|
||||
|
||||
@@ -209,7 +209,7 @@ class TaskControllerTest {
|
||||
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 @@ class TaskControllerTest {
|
||||
"done": false,
|
||||
"urls": [],
|
||||
"highPriority": true,
|
||||
"tag": "tag"
|
||||
"tags": ["tag"]
|
||||
}
|
||||
""";
|
||||
|
||||
@@ -265,11 +265,20 @@ class TaskControllerTest {
|
||||
@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 @@ class TaskControllerTest {
|
||||
"description": "Test task",
|
||||
"urls": ["https://www.url.com"],
|
||||
"highPriority": true,
|
||||
"tag": "tag"
|
||||
"tags": ["tag"]
|
||||
}
|
||||
""";
|
||||
|
||||
|
||||
@@ -2,9 +2,13 @@ package br.com.tasknoteapp.server.service;
|
||||
|
||||
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() {
|
||||
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());
|
||||
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(taskService.getTasksByFilter("all"))
|
||||
.thenReturn(List.of(task1, task2, task3, task4, task5, task6, task7, task8));
|
||||
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, List.of("tag1"), List.of());
|
||||
when(taskService.getTasksByFilter("all")).thenReturn(List.of(task1));
|
||||
when(noteService.getAllNotes()).thenReturn(List.of());
|
||||
|
||||
List<String> tags = homeService.getTopTasksTag();
|
||||
|
||||
@@ -64,7 +75,10 @@ class HomeServiceTest {
|
||||
@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<String> topTags = homeService.getTopTasksTag();
|
||||
|
||||
@@ -73,16 +87,16 @@ class HomeServiceTest {
|
||||
}
|
||||
|
||||
@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<String> topTags = homeService.getTopTasksTag();
|
||||
|
||||
@@ -90,5 +104,7 @@ class HomeServiceTest {
|
||||
Assertions.assertEquals(2, topTags.size());
|
||||
Assertions.assertTrue(topTags.contains("untagged"));
|
||||
Assertions.assertTrue(topTags.contains("tag1"));
|
||||
Assertions.assertEquals(List.of("tag1", "untagged"), topTags);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ 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;
|
||||
@@ -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 @@ class NoteServiceTest {
|
||||
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 @@ class NoteServiceTest {
|
||||
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 @@ class NoteServiceTest {
|
||||
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);
|
||||
|
||||
|
||||
@@ -7,16 +7,20 @@ import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
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 br.com.tasknoteapp.server.util.TimeAgoUtil;
|
||||
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 @@ class TaskServiceTest {
|
||||
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 @@ class TaskServiceTest {
|
||||
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 @@ class TaskServiceTest {
|
||||
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<String> 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 @@ class TaskServiceTest {
|
||||
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<String> 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 @@ class TaskServiceTest {
|
||||
userEntity.setEmail(USER_EMAIL);
|
||||
when(authService.findByEmail(USER_EMAIL)).thenReturn(Optional.of(userEntity));
|
||||
|
||||
List<String> 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 @@ class TaskServiceTest {
|
||||
userEntity.setEmail(USER_EMAIL);
|
||||
when(authService.findByEmail(USER_EMAIL)).thenReturn(Optional.of(userEntity));
|
||||
|
||||
List<String> 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 @@ class TaskServiceTest {
|
||||
userEntity.setEmail(USER_EMAIL);
|
||||
when(authService.findByEmail(USER_EMAIL)).thenReturn(Optional.of(userEntity));
|
||||
|
||||
List<String> 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 @@ class TaskServiceTest {
|
||||
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<TaskResponse> 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 @@ class TaskServiceTest {
|
||||
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 @@ class TaskServiceTest {
|
||||
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<String> 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 @@ class TaskServiceTest {
|
||||
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 @@ class TaskServiceTest {
|
||||
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 @@ class TaskServiceTest {
|
||||
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<String> 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 @@ class TaskServiceTest {
|
||||
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 @@ class TaskServiceTest {
|
||||
|
||||
when(taskRepository.findById(taskId)).thenReturn(Optional.empty());
|
||||
|
||||
List<String> 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 @@ class TaskServiceTest {
|
||||
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<String> 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 @@ class TaskServiceTest {
|
||||
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 @@ class TaskServiceTest {
|
||||
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 @@ class TaskServiceTest {
|
||||
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<TaskResponse> 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 @@ class TaskServiceTest {
|
||||
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<TaskResponse> 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 @@ class TaskServiceTest {
|
||||
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<TaskResponse> 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 @@ class TaskServiceTest {
|
||||
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<TaskResponse> responses = taskService.getTasksByFilter("tag1");
|
||||
|
||||
assertEquals(1, responses.size());
|
||||
assertEquals("tag1", responses.get(0).tag());
|
||||
assertTrue(responses.get(0).tags().contains("tag1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user