Compare commits

..
Author SHA1 Message Date
github-actions[bot] 39cbe80582 chore: bump api version to 28 [skip ci] 2026-05-26 14:58:43 +00:00
rmcamposandGitHub 5fec30fea0 feat: add SDD and DDD filer for AI based development and Spec 001 implementation (#60)
* feat: add initial files and spec 1

* feat: add spec for rich text feature

* chore: restore file from broken AI session

* feat: add project constraints to ai

* chore: update spec feature prompt

* feat: add multiple tags support - wip

* test: fix test cases

* chore: move tag list to below the inputs

* chore: add ngrok files and updates

* fix: session issues and query using HQL

* test: fix test cases
2026-05-26 11:58:04 -03:00
github-actions[bot] a35c037f20 chore: bump api version to 27 [skip ci] 2026-05-19 01:27:08 +00:00
73 changed files with 2004 additions and 274 deletions
+30
View File
@@ -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
+17
View File
@@ -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.
+31
View File
@@ -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;
+44
View File
@@ -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.
+34
View File
@@ -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
+56
View File
@@ -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.
+39
View File
@@ -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.
+40
View File
@@ -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
+71
View File
@@ -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.
+53
View File
@@ -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.
+49
View File
@@ -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
+40
View File
@@ -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
+36
View File
@@ -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.
+36
View File
@@ -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.
+14
View File
@@ -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
+14
View File
@@ -0,0 +1,14 @@
# Skill: Review Code Quality
## Focus Areas
- readability
- maintainability
- consistency
- architectural alignment
## Avoid
- unnecessary nitpicks
- perfectionism
- speculative suggestions
+19
View File
@@ -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
+14
View File
@@ -0,0 +1,14 @@
# Skill: Write Tests
## Prioritize
- critical workflows
- expected behavior
- important edge cases
## Philosophy
Testing should improve:
- confidence
- reliability
- maintainability
+58
View File
@@ -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.
+33
View File
@@ -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
+43
View File
@@ -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.
+16
View File
@@ -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
+22
View File
@@ -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/`.
+18
View File
@@ -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
+9 -4
View File
@@ -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
+7 -2
View File
@@ -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
+7 -2
View File
@@ -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();
});
});
+10 -6
View File
@@ -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
}
];
+18 -16
View File
@@ -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);
+1 -1
View File
@@ -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%;
+1 -1
View File
@@ -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>
+7 -5
View File
@@ -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' && (
+1 -1
View File
@@ -3,7 +3,7 @@ type NoteResponse = {
title: string;
description: string;
url: string | null;
tag: string;
tags: string[];
lastUpdate: string;
shared: boolean;
shareToken: string | null;
+1 -1
View File
@@ -4,7 +4,7 @@ type TaskNoteRequest = {
urls?: string[];
dueDate?: string;
highPriority?: boolean;
tag: string;
tags: string[];
};
export default TaskNoteRequest;
+1 -1
View File
@@ -6,7 +6,7 @@ type TaskResponse = {
dueDate: string;
dueDateFmt: string;
lastUpdate: string;
tag: string;
tags: string[];
urls: string[];
};
+8 -8
View File
@@ -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>) => {
+66 -20
View File
@@ -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}
{' '}
&times;
</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);
}}
>
#
+2 -3
View File
@@ -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>
+62 -16
View File
@@ -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}
{' '}
&times;
</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);
}}
>
#
+92
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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);
@@ -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'