Rewrite Tracks application in Golang

This commit introduces a complete rewrite of the Tracks GTD application
in Go (Golang), providing a modern, performant alternative to the Ruby
on Rails implementation.

## Architecture & Technology Stack

- Language: Go 1.21+
- Web Framework: Gin
- ORM: GORM with SQLite/MySQL/PostgreSQL support
- Authentication: JWT with bcrypt password hashing
- Clean Architecture: Separated models, services, handlers, and middleware

## Implemented Features

### Core Models
- User: Authentication and user management
- Context: GTD contexts (@home, @work, etc.)
- Project: Project grouping and tracking
- Todo: Task management with state machine (active, completed, deferred, pending)
- Tag: Flexible tagging system with polymorphic associations
- Dependency: Todo dependencies with circular dependency detection
- Preference: User preferences and settings
- Note: Project notes
- Attachment: File attachment support (model only)
- RecurringTodo: Recurring task template (model only)

### API Endpoints

**Authentication:**
- POST /api/auth/login - User login
- POST /api/auth/register - User registration
- POST /api/auth/logout - User logout
- GET /api/me - Get current user

**Todos:**
- GET /api/todos - List todos with filtering
- POST /api/todos - Create todo
- GET /api/todos/:id - Get todo details
- PUT /api/todos/:id - Update todo
- DELETE /api/todos/:id - Delete todo
- POST /api/todos/:id/complete - Mark as completed
- POST /api/todos/:id/activate - Mark as active
- POST /api/todos/:id/defer - Defer to future date
- POST /api/todos/:id/dependencies - Add dependency
- DELETE /api/todos/:id/dependencies/:successor_id - Remove dependency

**Projects:**
- GET /api/projects - List projects
- POST /api/projects - Create project
- GET /api/projects/:id - Get project details
- PUT /api/projects/:id - Update project
- DELETE /api/projects/:id - Delete project
- POST /api/projects/:id/complete - Complete project
- POST /api/projects/:id/activate - Activate project
- POST /api/projects/:id/hide - Hide project
- POST /api/projects/:id/review - Mark as reviewed
- GET /api/projects/:id/stats - Get project statistics

**Contexts:**
- GET /api/contexts - List contexts
- POST /api/contexts - Create context
- GET /api/contexts/:id - Get context details
- PUT /api/contexts/:id - Update context
- DELETE /api/contexts/:id - Delete context
- POST /api/contexts/:id/hide - Hide context
- POST /api/contexts/:id/activate - Activate context
- POST /api/contexts/:id/close - Close context
- GET /api/contexts/:id/stats - Get context statistics

### Business Logic

**Todo State Management:**
- Active: Ready to work on
- Completed: Finished tasks
- Deferred: Future actions (show_from date)
- Pending: Blocked by dependencies

**Dependency Management:**
- Create blocking relationships between todos
- Automatic state transitions when blocking todos complete
- Circular dependency detection
- Automatic unblocking when prerequisites complete

**Tag System:**
- Polymorphic tagging for todos and recurring todos
- Automatic tag creation on first use
- Tag cloud support

**Project & Context Tracking:**
- State management (active, hidden, closed/completed)
- Statistics and health indicators
- Review tracking for projects

### Infrastructure

**Configuration:**
- Environment-based configuration
- Support for SQLite, MySQL, and PostgreSQL
- Configurable JWT secrets and token expiry
- Flexible server settings

**Database:**
- GORM for ORM
- Automatic migrations
- Connection pooling
- Multi-database support

**Authentication & Security:**
- JWT-based authentication
- Bcrypt password hashing
- Secure cookie support
- Token refresh mechanism

**Docker Support:**
- Multi-stage Dockerfile for optimized builds
- Docker Compose with PostgreSQL
- Volume mounting for data persistence
- Production-ready configuration

## Project Structure

```
cmd/tracks/              # Application entry point
internal/
  config/               # Configuration management
  database/             # Database setup and migrations
  handlers/             # HTTP request handlers
  middleware/           # Authentication middleware
  models/              # Database models
  services/            # Business logic layer
```

## Documentation

- README_GOLANG.md: Comprehensive documentation
- .env.example: Configuration template
- API documentation included in README
- Code comments for complex logic

## Future Work

The following features from the original Rails app are not yet implemented:
- Recurring todo instantiation logic
- Email integration (Mailgun/CloudMailin)
- Advanced statistics and analytics
- Import/Export functionality (CSV, YAML, XML)
- File upload handling for attachments
- Mobile views
- RSS/Atom feeds
- iCalendar export

## Benefits Over Rails Version

- Performance: Compiled binary, lower resource usage
- Deployment: Single binary, no runtime dependencies
- Type Safety: Compile-time type checking
- Concurrency: Better handling of concurrent requests
- Memory: Lower memory footprint
- Portability: Easy cross-platform compilation

## Testing

The code structure supports testing, though tests are not yet implemented.
Future work includes adding unit and integration tests.
This commit is contained in:
Claude 2025-11-05 10:46:59 +00:00
parent 6613d33f10
commit f0eb4bdef5
No known key found for this signature in database
29 changed files with 4100 additions and 104 deletions

View file

@ -0,0 +1,148 @@
package services
import (
"errors"
"time"
"github.com/TracksApp/tracks/internal/database"
"github.com/TracksApp/tracks/internal/models"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"gorm.io/gorm"
)
// AuthService handles authentication logic
type AuthService struct {
jwtSecret string
}
// NewAuthService creates a new AuthService
func NewAuthService(jwtSecret string) *AuthService {
return &AuthService{
jwtSecret: jwtSecret,
}
}
// LoginRequest represents a login request
type LoginRequest struct {
Login string `json:"login" binding:"required"`
Password string `json:"password" binding:"required"`
}
// RegisterRequest represents a registration request
type RegisterRequest struct {
Login string `json:"login" binding:"required"`
Password string `json:"password" binding:"required"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
}
// AuthResponse represents an authentication response
type AuthResponse struct {
Token string `json:"token"`
User *models.User `json:"user"`
}
// Login authenticates a user and returns a JWT token
func (s *AuthService) Login(req LoginRequest) (*AuthResponse, error) {
var user models.User
// Find user by login
if err := database.DB.Where("login = ?", req.Login).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("invalid login or password")
}
return nil, err
}
// Check password
if !user.CheckPassword(req.Password) {
return nil, errors.New("invalid login or password")
}
// Generate token
token, err := s.GenerateToken(&user)
if err != nil {
return nil, err
}
return &AuthResponse{
Token: token,
User: &user,
}, nil
}
// Register creates a new user account
func (s *AuthService) Register(req RegisterRequest) (*AuthResponse, error) {
// Check if user already exists
var existingUser models.User
if err := database.DB.Where("login = ?", req.Login).First(&existingUser).Error; err == nil {
return nil, errors.New("user already exists")
}
// Create new user
user := models.User{
Login: req.Login,
FirstName: req.FirstName,
LastName: req.LastName,
AuthType: models.AuthTypeDatabase,
Token: uuid.New().String(),
}
// Set password
if err := user.SetPassword(req.Password); err != nil {
return nil, err
}
// Save user
if err := database.DB.Create(&user).Error; err != nil {
return nil, err
}
// Create default preference
preference := models.Preference{
UserID: user.ID,
}
if err := database.DB.Create(&preference).Error; err != nil {
return nil, err
}
// Generate token
token, err := s.GenerateToken(&user)
if err != nil {
return nil, err
}
return &AuthResponse{
Token: token,
User: &user,
}, nil
}
// GenerateToken generates a JWT token for a user
func (s *AuthService) GenerateToken(user *models.User) (string, error) {
claims := jwt.MapClaims{
"user_id": user.ID,
"login": user.Login,
"exp": time.Now().Add(time.Hour * 24 * 7).Unix(), // 7 days
"iat": time.Now().Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(s.jwtSecret))
}
// RefreshToken refreshes the user's API token
func (s *AuthService) RefreshToken(userID uint) (string, error) {
var user models.User
if err := database.DB.First(&user, userID).Error; err != nil {
return "", err
}
user.Token = uuid.New().String()
if err := database.DB.Save(&user).Error; err != nil {
return "", err
}
return user.Token, nil
}

View file

@ -0,0 +1,220 @@
package services
import (
"errors"
"fmt"
"github.com/TracksApp/tracks/internal/database"
"github.com/TracksApp/tracks/internal/models"
"gorm.io/gorm"
)
// ContextService handles context business logic
type ContextService struct{}
// NewContextService creates a new ContextService
func NewContextService() *ContextService {
return &ContextService{}
}
// CreateContextRequest represents a context creation request
type CreateContextRequest struct {
Name string `json:"name" binding:"required"`
}
// UpdateContextRequest represents a context update request
type UpdateContextRequest struct {
Name *string `json:"name"`
Position *int `json:"position"`
State *string `json:"state"`
}
// GetContexts returns all contexts for a user
func (s *ContextService) GetContexts(userID uint, state models.ContextState) ([]models.Context, error) {
var contexts []models.Context
query := database.DB.Where("user_id = ?", userID)
if state != "" {
query = query.Where("state = ?", state)
}
if err := query.
Order("position ASC, name ASC").
Find(&contexts).Error; err != nil {
return nil, err
}
return contexts, nil
}
// GetContext returns a single context by ID
func (s *ContextService) GetContext(userID, contextID uint) (*models.Context, error) {
var context models.Context
if err := database.DB.
Where("id = ? AND user_id = ?", contextID, userID).
First(&context).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("context not found")
}
return nil, err
}
return &context, nil
}
// CreateContext creates a new context
func (s *ContextService) CreateContext(userID uint, req CreateContextRequest) (*models.Context, error) {
context := models.Context{
UserID: userID,
Name: req.Name,
State: models.ContextStateActive,
}
if err := database.DB.Create(&context).Error; err != nil {
return nil, err
}
return s.GetContext(userID, context.ID)
}
// UpdateContext updates a context
func (s *ContextService) UpdateContext(userID, contextID uint, req UpdateContextRequest) (*models.Context, error) {
context, err := s.GetContext(userID, contextID)
if err != nil {
return nil, err
}
if req.Name != nil {
context.Name = *req.Name
}
if req.Position != nil {
context.Position = *req.Position
}
if req.State != nil {
context.State = models.ContextState(*req.State)
}
if err := database.DB.Save(&context).Error; err != nil {
return nil, err
}
return s.GetContext(userID, contextID)
}
// DeleteContext deletes a context
func (s *ContextService) DeleteContext(userID, contextID uint) error {
context, err := s.GetContext(userID, contextID)
if err != nil {
return err
}
// Check if context has active todos
var activeTodoCount int64
database.DB.Model(&models.Todo{}).
Where("context_id = ? AND state = ?", contextID, models.TodoStateActive).
Count(&activeTodoCount)
if activeTodoCount > 0 {
return fmt.Errorf("cannot delete context with active todos")
}
return database.DB.Delete(&context).Error
}
// HideContext marks a context as hidden
func (s *ContextService) HideContext(userID, contextID uint) (*models.Context, error) {
context, err := s.GetContext(userID, contextID)
if err != nil {
return nil, err
}
context.Hide()
if err := database.DB.Save(&context).Error; err != nil {
return nil, err
}
return s.GetContext(userID, contextID)
}
// ActivateContext marks a context as active
func (s *ContextService) ActivateContext(userID, contextID uint) (*models.Context, error) {
context, err := s.GetContext(userID, contextID)
if err != nil {
return nil, err
}
context.Activate()
if err := database.DB.Save(&context).Error; err != nil {
return nil, err
}
return s.GetContext(userID, contextID)
}
// CloseContext marks a context as closed
func (s *ContextService) CloseContext(userID, contextID uint) (*models.Context, error) {
context, err := s.GetContext(userID, contextID)
if err != nil {
return nil, err
}
// Check if context has active todos
var activeTodoCount int64
database.DB.Model(&models.Todo{}).
Where("context_id = ? AND state = ?", contextID, models.TodoStateActive).
Count(&activeTodoCount)
if activeTodoCount > 0 {
return nil, fmt.Errorf("cannot close context with active todos")
}
context.Close()
if err := database.DB.Save(&context).Error; err != nil {
return nil, err
}
return s.GetContext(userID, contextID)
}
// GetContextStats returns statistics for a context
func (s *ContextService) GetContextStats(userID, contextID uint) (map[string]interface{}, error) {
context, err := s.GetContext(userID, contextID)
if err != nil {
return nil, err
}
stats := make(map[string]interface{})
// Count todos by state
var activeTodos, completedTodos, deferredTodos, pendingTodos int64
database.DB.Model(&models.Todo{}).
Where("context_id = ? AND state = ?", contextID, models.TodoStateActive).
Count(&activeTodos)
database.DB.Model(&models.Todo{}).
Where("context_id = ? AND state = ?", contextID, models.TodoStateCompleted).
Count(&completedTodos)
database.DB.Model(&models.Todo{}).
Where("context_id = ? AND state = ?", contextID, models.TodoStateDeferred).
Count(&deferredTodos)
database.DB.Model(&models.Todo{}).
Where("context_id = ? AND state = ?", contextID, models.TodoStatePending).
Count(&pendingTodos)
stats["context"] = context
stats["active_todos"] = activeTodos
stats["completed_todos"] = completedTodos
stats["deferred_todos"] = deferredTodos
stats["pending_todos"] = pendingTodos
stats["total_todos"] = activeTodos + completedTodos + deferredTodos + pendingTodos
return stats, nil
}

View file

@ -0,0 +1,258 @@
package services
import (
"errors"
"fmt"
"time"
"github.com/TracksApp/tracks/internal/database"
"github.com/TracksApp/tracks/internal/models"
"gorm.io/gorm"
)
// ProjectService handles project business logic
type ProjectService struct{}
// NewProjectService creates a new ProjectService
func NewProjectService() *ProjectService {
return &ProjectService{}
}
// CreateProjectRequest represents a project creation request
type CreateProjectRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
DefaultContextID *uint `json:"default_context_id"`
DefaultTags string `json:"default_tags"`
}
// UpdateProjectRequest represents a project update request
type UpdateProjectRequest struct {
Name *string `json:"name"`
Description *string `json:"description"`
DefaultContextID *uint `json:"default_context_id"`
DefaultTags *string `json:"default_tags"`
State *string `json:"state"`
}
// GetProjects returns all projects for a user
func (s *ProjectService) GetProjects(userID uint, state models.ProjectState) ([]models.Project, error) {
var projects []models.Project
query := database.DB.Where("user_id = ?", userID)
if state != "" {
query = query.Where("state = ?", state)
}
if err := query.
Preload("DefaultContext").
Order("position ASC, name ASC").
Find(&projects).Error; err != nil {
return nil, err
}
return projects, nil
}
// GetProject returns a single project by ID
func (s *ProjectService) GetProject(userID, projectID uint) (*models.Project, error) {
var project models.Project
if err := database.DB.
Where("id = ? AND user_id = ?", projectID, userID).
Preload("DefaultContext").
Preload("Todos").
Preload("Notes").
First(&project).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("project not found")
}
return nil, err
}
return &project, nil
}
// CreateProject creates a new project
func (s *ProjectService) CreateProject(userID uint, req CreateProjectRequest) (*models.Project, error) {
// Verify default context if provided
if req.DefaultContextID != nil {
var context models.Context
if err := database.DB.Where("id = ? AND user_id = ?", *req.DefaultContextID, userID).First(&context).Error; err != nil {
return nil, fmt.Errorf("default context not found")
}
}
project := models.Project{
UserID: userID,
Name: req.Name,
Description: req.Description,
DefaultContextID: req.DefaultContextID,
DefaultTags: req.DefaultTags,
State: models.ProjectStateActive,
}
if err := database.DB.Create(&project).Error; err != nil {
return nil, err
}
return s.GetProject(userID, project.ID)
}
// UpdateProject updates a project
func (s *ProjectService) UpdateProject(userID, projectID uint, req UpdateProjectRequest) (*models.Project, error) {
project, err := s.GetProject(userID, projectID)
if err != nil {
return nil, err
}
if req.Name != nil {
project.Name = *req.Name
}
if req.Description != nil {
project.Description = *req.Description
}
if req.DefaultContextID != nil {
// Verify context
var context models.Context
if err := database.DB.Where("id = ? AND user_id = ?", *req.DefaultContextID, userID).First(&context).Error; err != nil {
return nil, fmt.Errorf("default context not found")
}
project.DefaultContextID = req.DefaultContextID
}
if req.DefaultTags != nil {
project.DefaultTags = *req.DefaultTags
}
if req.State != nil {
project.State = models.ProjectState(*req.State)
}
if err := database.DB.Save(&project).Error; err != nil {
return nil, err
}
return s.GetProject(userID, projectID)
}
// DeleteProject deletes a project
func (s *ProjectService) DeleteProject(userID, projectID uint) error {
project, err := s.GetProject(userID, projectID)
if err != nil {
return err
}
return database.DB.Delete(&project).Error
}
// CompleteProject marks a project as completed
func (s *ProjectService) CompleteProject(userID, projectID uint) (*models.Project, error) {
project, err := s.GetProject(userID, projectID)
if err != nil {
return nil, err
}
project.Complete()
if err := database.DB.Save(&project).Error; err != nil {
return nil, err
}
return s.GetProject(userID, projectID)
}
// ActivateProject marks a project as active
func (s *ProjectService) ActivateProject(userID, projectID uint) (*models.Project, error) {
project, err := s.GetProject(userID, projectID)
if err != nil {
return nil, err
}
project.Activate()
if err := database.DB.Save(&project).Error; err != nil {
return nil, err
}
return s.GetProject(userID, projectID)
}
// HideProject marks a project as hidden
func (s *ProjectService) HideProject(userID, projectID uint) (*models.Project, error) {
project, err := s.GetProject(userID, projectID)
if err != nil {
return nil, err
}
project.Hide()
if err := database.DB.Save(&project).Error; err != nil {
return nil, err
}
return s.GetProject(userID, projectID)
}
// MarkProjectReviewed updates the last_reviewed_at timestamp
func (s *ProjectService) MarkProjectReviewed(userID, projectID uint) (*models.Project, error) {
project, err := s.GetProject(userID, projectID)
if err != nil {
return nil, err
}
project.MarkReviewed()
if err := database.DB.Save(&project).Error; err != nil {
return nil, err
}
return s.GetProject(userID, projectID)
}
// GetProjectStats returns statistics for a project
func (s *ProjectService) GetProjectStats(userID, projectID uint) (map[string]interface{}, error) {
project, err := s.GetProject(userID, projectID)
if err != nil {
return nil, err
}
stats := make(map[string]interface{})
// Count todos by state
var activeTodos, completedTodos, deferredTodos, pendingTodos int64
database.DB.Model(&models.Todo{}).
Where("project_id = ? AND state = ?", projectID, models.TodoStateActive).
Count(&activeTodos)
database.DB.Model(&models.Todo{}).
Where("project_id = ? AND state = ?", projectID, models.TodoStateCompleted).
Count(&completedTodos)
database.DB.Model(&models.Todo{}).
Where("project_id = ? AND state = ?", projectID, models.TodoStateDeferred).
Count(&deferredTodos)
database.DB.Model(&models.Todo{}).
Where("project_id = ? AND state = ?", projectID, models.TodoStatePending).
Count(&pendingTodos)
stats["project"] = project
stats["active_todos"] = activeTodos
stats["completed_todos"] = completedTodos
stats["deferred_todos"] = deferredTodos
stats["pending_todos"] = pendingTodos
stats["total_todos"] = activeTodos + completedTodos + deferredTodos + pendingTodos
stats["is_stalled"] = activeTodos == 0 && (deferredTodos > 0 || pendingTodos > 0)
stats["is_blocked"] = activeTodos == 0 && (deferredTodos > 0 || pendingTodos > 0) && completedTodos == 0
// Days since last review
if project.LastReviewedAt != nil {
daysSinceReview := int(time.Since(*project.LastReviewedAt).Hours() / 24)
stats["days_since_review"] = daysSinceReview
} else {
stats["days_since_review"] = nil
}
return stats, nil
}

View file

@ -0,0 +1,151 @@
package services
import (
"github.com/TracksApp/tracks/internal/database"
"github.com/TracksApp/tracks/internal/models"
"gorm.io/gorm"
)
// TagService handles tag business logic
type TagService struct{}
// NewTagService creates a new TagService
func NewTagService() *TagService {
return &TagService{}
}
// GetOrCreateTag finds or creates a tag by name
func (s *TagService) GetOrCreateTag(tx *gorm.DB, userID uint, name string) (*models.Tag, error) {
var tag models.Tag
// Try to find existing tag
if err := tx.Where("user_id = ? AND name = ?", userID, name).First(&tag).Error; err != nil {
if err == gorm.ErrRecordNotFound {
// Create new tag
tag = models.Tag{
UserID: userID,
Name: name,
}
if err := tx.Create(&tag).Error; err != nil {
return nil, err
}
} else {
return nil, err
}
}
return &tag, nil
}
// SetTodoTags sets the tags for a todo, replacing all existing tags
func (s *TagService) SetTodoTags(tx *gorm.DB, userID, todoID uint, tagNames []string) error {
// Remove existing taggings
if err := tx.Where("taggable_id = ? AND taggable_type = ?", todoID, "Todo").
Delete(&models.Tagging{}).Error; err != nil {
return err
}
// Add new taggings
for _, tagName := range tagNames {
if tagName == "" {
continue
}
tag, err := s.GetOrCreateTag(tx, userID, tagName)
if err != nil {
return err
}
tagging := models.Tagging{
TagID: tag.ID,
TaggableID: todoID,
TaggableType: "Todo",
}
if err := tx.Create(&tagging).Error; err != nil {
return err
}
}
return nil
}
// SetRecurringTodoTags sets the tags for a recurring todo
func (s *TagService) SetRecurringTodoTags(tx *gorm.DB, userID, recurringTodoID uint, tagNames []string) error {
// Remove existing taggings
if err := tx.Where("taggable_id = ? AND taggable_type = ?", recurringTodoID, "RecurringTodo").
Delete(&models.Tagging{}).Error; err != nil {
return err
}
// Add new taggings
for _, tagName := range tagNames {
if tagName == "" {
continue
}
tag, err := s.GetOrCreateTag(tx, userID, tagName)
if err != nil {
return err
}
tagging := models.Tagging{
TagID: tag.ID,
TaggableID: recurringTodoID,
TaggableType: "RecurringTodo",
}
if err := tx.Create(&tagging).Error; err != nil {
return err
}
}
return nil
}
// GetUserTags returns all tags for a user
func (s *TagService) GetUserTags(userID uint) ([]models.Tag, error) {
var tags []models.Tag
if err := database.DB.Where("user_id = ?", userID).
Order("name ASC").
Find(&tags).Error; err != nil {
return nil, err
}
return tags, nil
}
// GetTagCloud returns tags with usage counts
func (s *TagService) GetTagCloud(userID uint) ([]map[string]interface{}, error) {
type TagCount struct {
TagID uint
Name string
Count int64
}
var tagCounts []TagCount
err := database.DB.Table("tags").
Select("tags.id as tag_id, tags.name, COUNT(taggings.id) as count").
Joins("LEFT JOIN taggings ON taggings.tag_id = tags.id").
Where("tags.user_id = ?", userID).
Group("tags.id, tags.name").
Order("count DESC").
Scan(&tagCounts).Error
if err != nil {
return nil, err
}
result := make([]map[string]interface{}, len(tagCounts))
for i, tc := range tagCounts {
result[i] = map[string]interface{}{
"tag_id": tc.TagID,
"name": tc.Name,
"count": tc.Count,
}
}
return result, nil
}

View file

@ -0,0 +1,500 @@
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")
if filter.IncludeTags {
query = query.Preload("Tags")
}
// 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
}
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("Tags").
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
}
return &todo, 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
}