mirror of
https://github.com/TracksApp/tracks.git
synced 2025-12-16 15:20:13 +01:00
This commit fixes the tag loading issue and adds comprehensive testing. ## Bug Fix: Polymorphic Tag Loading Fixed issue with many-to-many tag relationships not working correctly with polymorphic associations. The problem was that GORM doesn't support using `many2many` with polymorphic relationships directly. **Changes:** - Modified `internal/models/todo.go`: Changed Tags field to use `gorm:"-"` to skip GORM handling - Modified `internal/models/recurring_todo.go`: Same fix for recurring todos - Modified `internal/services/todo_service.go`: Added `loadTodoTags()` helper function to manually load tags through the taggings join table **How it works now:** 1. Tags are no longer automatically loaded by GORM 2. Manual loading via JOIN query: `tags JOIN taggings ON tag_id WHERE taggable_id AND taggable_type` 3. Called after loading todos in both `GetTodo()` and `GetTodos()` ## Testing Added `test_api.sh` - comprehensive integration test script that tests: 1. Health check 2. User registration 3. Authentication 4. Context creation 5. Project creation 6. Todo creation with tags 7. Listing todos with filters 8. Completing todos 9. Project statistics All tests pass successfully! ## Files Changed - `internal/models/todo.go`: Fix tag relationship - `internal/models/recurring_todo.go`: Fix tag relationship - `internal/services/todo_service.go`: Add manual tag loading - `test_api.sh`: New integration test script - `go.sum`: Updated with exact dependency versions
526 lines
13 KiB
Go
526 lines
13 KiB
Go
package services
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/TracksApp/tracks/internal/database"
|
|
"github.com/TracksApp/tracks/internal/models"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// TodoService handles todo business logic
|
|
type TodoService struct{}
|
|
|
|
// NewTodoService creates a new TodoService
|
|
func NewTodoService() *TodoService {
|
|
return &TodoService{}
|
|
}
|
|
|
|
// CreateTodoRequest represents a todo creation request
|
|
type CreateTodoRequest struct {
|
|
Description string `json:"description" binding:"required"`
|
|
Notes string `json:"notes"`
|
|
ContextID uint `json:"context_id" binding:"required"`
|
|
ProjectID *uint `json:"project_id"`
|
|
DueDate *time.Time `json:"due_date"`
|
|
ShowFrom *time.Time `json:"show_from"`
|
|
Starred bool `json:"starred"`
|
|
TagNames []string `json:"tags"`
|
|
}
|
|
|
|
// UpdateTodoRequest represents a todo update request
|
|
type UpdateTodoRequest struct {
|
|
Description *string `json:"description"`
|
|
Notes *string `json:"notes"`
|
|
ContextID *uint `json:"context_id"`
|
|
ProjectID *uint `json:"project_id"`
|
|
DueDate *time.Time `json:"due_date"`
|
|
ShowFrom *time.Time `json:"show_from"`
|
|
Starred *bool `json:"starred"`
|
|
TagNames []string `json:"tags"`
|
|
}
|
|
|
|
// ListTodosFilter represents filters for listing todos
|
|
type ListTodosFilter struct {
|
|
State models.TodoState
|
|
ContextID *uint
|
|
ProjectID *uint
|
|
TagName *string
|
|
Starred *bool
|
|
Overdue *bool
|
|
DueToday *bool
|
|
ShowFrom *bool // If true, only show todos where show_from has passed
|
|
IncludeTags bool
|
|
}
|
|
|
|
// GetTodos returns todos for a user with optional filters
|
|
func (s *TodoService) GetTodos(userID uint, filter ListTodosFilter) ([]models.Todo, error) {
|
|
var todos []models.Todo
|
|
|
|
query := database.DB.Where("user_id = ?", userID)
|
|
|
|
// Apply filters
|
|
if filter.State != "" {
|
|
query = query.Where("state = ?", filter.State)
|
|
}
|
|
|
|
if filter.ContextID != nil {
|
|
query = query.Where("context_id = ?", *filter.ContextID)
|
|
}
|
|
|
|
if filter.ProjectID != nil {
|
|
query = query.Where("project_id = ?", *filter.ProjectID)
|
|
}
|
|
|
|
if filter.Starred != nil {
|
|
query = query.Where("starred = ?", *filter.Starred)
|
|
}
|
|
|
|
if filter.Overdue != nil && *filter.Overdue {
|
|
query = query.Where("due_date < ? AND state = ?", time.Now(), models.TodoStateActive)
|
|
}
|
|
|
|
if filter.DueToday != nil && *filter.DueToday {
|
|
today := time.Now().Truncate(24 * time.Hour)
|
|
tomorrow := today.Add(24 * time.Hour)
|
|
query = query.Where("due_date >= ? AND due_date < ?", today, tomorrow)
|
|
}
|
|
|
|
if filter.ShowFrom != nil && *filter.ShowFrom {
|
|
now := time.Now()
|
|
query = query.Where("show_from IS NULL OR show_from <= ?", now)
|
|
}
|
|
|
|
// Preload associations
|
|
query = query.Preload("Context").Preload("Project")
|
|
|
|
// Filter by tag
|
|
if filter.TagName != nil {
|
|
query = query.Joins("JOIN taggings ON taggings.taggable_id = todos.id AND taggings.taggable_type = ?", "Todo").
|
|
Joins("JOIN tags ON tags.id = taggings.tag_id").
|
|
Where("tags.name = ? AND tags.user_id = ?", *filter.TagName, userID)
|
|
}
|
|
|
|
// Order by created_at
|
|
query = query.Order("created_at ASC")
|
|
|
|
if err := query.Find(&todos).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Load tags if requested
|
|
if filter.IncludeTags {
|
|
for i := range todos {
|
|
if err := s.loadTodoTags(&todos[i]); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
|
|
return todos, nil
|
|
}
|
|
|
|
// GetTodo returns a single todo by ID
|
|
func (s *TodoService) GetTodo(userID, todoID uint) (*models.Todo, error) {
|
|
var todo models.Todo
|
|
|
|
if err := database.DB.
|
|
Where("id = ? AND user_id = ?", todoID, userID).
|
|
Preload("Context").
|
|
Preload("Project").
|
|
Preload("Attachments").
|
|
Preload("Predecessors.Predecessor").
|
|
Preload("Successors.Successor").
|
|
First(&todo).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, fmt.Errorf("todo not found")
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
// Load tags manually through taggings
|
|
if err := s.loadTodoTags(&todo); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &todo, nil
|
|
}
|
|
|
|
// loadTodoTags loads tags for a todo through the polymorphic taggings
|
|
func (s *TodoService) loadTodoTags(todo *models.Todo) error {
|
|
var tags []models.Tag
|
|
|
|
err := database.DB.
|
|
Joins("JOIN taggings ON taggings.tag_id = tags.id").
|
|
Where("taggings.taggable_id = ? AND taggings.taggable_type = ?", todo.ID, "Todo").
|
|
Find(&tags).Error
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
todo.Tags = tags
|
|
return nil
|
|
}
|
|
|
|
// CreateTodo creates a new todo
|
|
func (s *TodoService) CreateTodo(userID uint, req CreateTodoRequest) (*models.Todo, error) {
|
|
// Verify context belongs to user
|
|
var context models.Context
|
|
if err := database.DB.Where("id = ? AND user_id = ?", req.ContextID, userID).First(&context).Error; err != nil {
|
|
return nil, fmt.Errorf("context not found")
|
|
}
|
|
|
|
// Verify project belongs to user if provided
|
|
if req.ProjectID != nil {
|
|
var project models.Project
|
|
if err := database.DB.Where("id = ? AND user_id = ?", *req.ProjectID, userID).First(&project).Error; err != nil {
|
|
return nil, fmt.Errorf("project not found")
|
|
}
|
|
}
|
|
|
|
// Determine initial state
|
|
state := models.TodoStateActive
|
|
if req.ShowFrom != nil && req.ShowFrom.After(time.Now()) {
|
|
state = models.TodoStateDeferred
|
|
}
|
|
|
|
// Create todo
|
|
todo := models.Todo{
|
|
UserID: userID,
|
|
Description: req.Description,
|
|
Notes: req.Notes,
|
|
ContextID: req.ContextID,
|
|
ProjectID: req.ProjectID,
|
|
DueDate: req.DueDate,
|
|
ShowFrom: req.ShowFrom,
|
|
Starred: req.Starred,
|
|
State: state,
|
|
}
|
|
|
|
// Begin transaction
|
|
tx := database.DB.Begin()
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
tx.Rollback()
|
|
}
|
|
}()
|
|
|
|
if err := tx.Create(&todo).Error; err != nil {
|
|
tx.Rollback()
|
|
return nil, err
|
|
}
|
|
|
|
// Handle tags
|
|
if len(req.TagNames) > 0 {
|
|
tagService := NewTagService()
|
|
if err := tagService.SetTodoTags(tx, userID, todo.ID, req.TagNames); err != nil {
|
|
tx.Rollback()
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if err := tx.Commit().Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Reload with associations
|
|
return s.GetTodo(userID, todo.ID)
|
|
}
|
|
|
|
// UpdateTodo updates a todo
|
|
func (s *TodoService) UpdateTodo(userID, todoID uint, req UpdateTodoRequest) (*models.Todo, error) {
|
|
todo, err := s.GetTodo(userID, todoID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Begin transaction
|
|
tx := database.DB.Begin()
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
tx.Rollback()
|
|
}
|
|
}()
|
|
|
|
// Update fields
|
|
if req.Description != nil {
|
|
todo.Description = *req.Description
|
|
}
|
|
if req.Notes != nil {
|
|
todo.Notes = *req.Notes
|
|
}
|
|
if req.ContextID != nil {
|
|
// Verify context
|
|
var context models.Context
|
|
if err := tx.Where("id = ? AND user_id = ?", *req.ContextID, userID).First(&context).Error; err != nil {
|
|
tx.Rollback()
|
|
return nil, fmt.Errorf("context not found")
|
|
}
|
|
todo.ContextID = *req.ContextID
|
|
}
|
|
if req.ProjectID != nil {
|
|
// Verify project
|
|
var project models.Project
|
|
if err := tx.Where("id = ? AND user_id = ?", *req.ProjectID, userID).First(&project).Error; err != nil {
|
|
tx.Rollback()
|
|
return nil, fmt.Errorf("project not found")
|
|
}
|
|
todo.ProjectID = req.ProjectID
|
|
}
|
|
if req.DueDate != nil {
|
|
todo.DueDate = req.DueDate
|
|
}
|
|
if req.ShowFrom != nil {
|
|
todo.ShowFrom = req.ShowFrom
|
|
}
|
|
if req.Starred != nil {
|
|
todo.Starred = *req.Starred
|
|
}
|
|
|
|
if err := tx.Save(&todo).Error; err != nil {
|
|
tx.Rollback()
|
|
return nil, err
|
|
}
|
|
|
|
// Handle tags
|
|
if req.TagNames != nil {
|
|
tagService := NewTagService()
|
|
if err := tagService.SetTodoTags(tx, userID, todo.ID, req.TagNames); err != nil {
|
|
tx.Rollback()
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if err := tx.Commit().Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Reload with associations
|
|
return s.GetTodo(userID, todoID)
|
|
}
|
|
|
|
// DeleteTodo deletes a todo
|
|
func (s *TodoService) DeleteTodo(userID, todoID uint) error {
|
|
todo, err := s.GetTodo(userID, todoID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return database.DB.Delete(&todo).Error
|
|
}
|
|
|
|
// CompleteTodo marks a todo as completed
|
|
func (s *TodoService) CompleteTodo(userID, todoID uint) (*models.Todo, error) {
|
|
todo, err := s.GetTodo(userID, todoID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := todo.Complete(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
tx := database.DB.Begin()
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
tx.Rollback()
|
|
}
|
|
}()
|
|
|
|
if err := tx.Save(&todo).Error; err != nil {
|
|
tx.Rollback()
|
|
return nil, err
|
|
}
|
|
|
|
// Unblock any pending successors
|
|
if err := s.checkAndUnblockSuccessors(tx, todoID); err != nil {
|
|
tx.Rollback()
|
|
return nil, err
|
|
}
|
|
|
|
if err := tx.Commit().Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return s.GetTodo(userID, todoID)
|
|
}
|
|
|
|
// ActivateTodo marks a todo as active
|
|
func (s *TodoService) ActivateTodo(userID, todoID uint) (*models.Todo, error) {
|
|
todo, err := s.GetTodo(userID, todoID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Check if there are incomplete predecessors
|
|
hasIncompletePredecessors, err := s.hasIncompletePredecessors(todoID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if hasIncompletePredecessors {
|
|
return nil, fmt.Errorf("cannot activate todo with incomplete predecessors")
|
|
}
|
|
|
|
if err := todo.Activate(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := database.DB.Save(&todo).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return s.GetTodo(userID, todoID)
|
|
}
|
|
|
|
// DeferTodo marks a todo as deferred
|
|
func (s *TodoService) DeferTodo(userID, todoID uint, showFrom time.Time) (*models.Todo, error) {
|
|
todo, err := s.GetTodo(userID, todoID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := todo.Defer(showFrom); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := database.DB.Save(&todo).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return s.GetTodo(userID, todoID)
|
|
}
|
|
|
|
// AddDependency adds a dependency between two todos
|
|
func (s *TodoService) AddDependency(userID, predecessorID, successorID uint) error {
|
|
// Verify both todos belong to user
|
|
if _, err := s.GetTodo(userID, predecessorID); err != nil {
|
|
return fmt.Errorf("predecessor not found")
|
|
}
|
|
if _, err := s.GetTodo(userID, successorID); err != nil {
|
|
return fmt.Errorf("successor not found")
|
|
}
|
|
|
|
// Check for circular dependencies
|
|
if err := s.checkCircularDependency(predecessorID, successorID); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Create dependency
|
|
dependency := models.Dependency{
|
|
PredecessorID: predecessorID,
|
|
SuccessorID: successorID,
|
|
RelationshipType: models.DependencyTypeBlocks,
|
|
}
|
|
|
|
if err := database.DB.Create(&dependency).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
// Block the successor if predecessor is not complete
|
|
successor, _ := s.GetTodo(userID, successorID)
|
|
predecessor, _ := s.GetTodo(userID, predecessorID)
|
|
|
|
if !predecessor.IsCompleted() && !successor.IsPending() {
|
|
successor.Block()
|
|
database.DB.Save(successor)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// RemoveDependency removes a dependency
|
|
func (s *TodoService) RemoveDependency(userID, predecessorID, successorID uint) error {
|
|
// Verify both todos belong to user
|
|
if _, err := s.GetTodo(userID, predecessorID); err != nil {
|
|
return err
|
|
}
|
|
if _, err := s.GetTodo(userID, successorID); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := database.DB.
|
|
Where("predecessor_id = ? AND successor_id = ?", predecessorID, successorID).
|
|
Delete(&models.Dependency{}).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
// Check if successor should be unblocked
|
|
s.checkAndUnblockSuccessors(database.DB, successorID)
|
|
|
|
return nil
|
|
}
|
|
|
|
// Helper functions
|
|
|
|
func (s *TodoService) hasIncompletePredecessors(todoID uint) (bool, error) {
|
|
var count int64
|
|
err := database.DB.Model(&models.Dependency{}).
|
|
Joins("JOIN todos ON todos.id = dependencies.predecessor_id").
|
|
Where("dependencies.successor_id = ? AND todos.state != ?", todoID, models.TodoStateCompleted).
|
|
Count(&count).Error
|
|
|
|
return count > 0, err
|
|
}
|
|
|
|
func (s *TodoService) checkAndUnblockSuccessors(tx *gorm.DB, todoID uint) error {
|
|
var successors []models.Todo
|
|
|
|
// Get all successors
|
|
if err := tx.
|
|
Joins("JOIN dependencies ON dependencies.successor_id = todos.id").
|
|
Where("dependencies.predecessor_id = ? AND todos.state = ?", todoID, models.TodoStatePending).
|
|
Find(&successors).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
// For each successor, check if all predecessors are complete
|
|
for _, successor := range successors {
|
|
hasIncompletePredecessors, err := s.hasIncompletePredecessors(successor.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if !hasIncompletePredecessors {
|
|
successor.Unblock()
|
|
if err := tx.Save(&successor).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *TodoService) checkCircularDependency(predecessorID, successorID uint) error {
|
|
// Simple check: ensure successor is not already a predecessor of the predecessor
|
|
visited := make(map[uint]bool)
|
|
return s.dfsCheckCircular(successorID, predecessorID, visited)
|
|
}
|
|
|
|
func (s *TodoService) dfsCheckCircular(currentID, targetID uint, visited map[uint]bool) error {
|
|
if currentID == targetID {
|
|
return fmt.Errorf("circular dependency detected")
|
|
}
|
|
|
|
if visited[currentID] {
|
|
return nil
|
|
}
|
|
|
|
visited[currentID] = true
|
|
|
|
var successors []models.Dependency
|
|
if err := database.DB.Where("predecessor_id = ?", currentID).Find(&successors).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, dep := range successors {
|
|
if err := s.dfsCheckCircular(dep.SuccessorID, targetID, visited); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|