tracks/internal/services/project_service.go
Claude f0eb4bdef5
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.
2025-11-05 10:46:59 +00:00

258 lines
6.9 KiB
Go

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
}