mirror of
https://github.com/TracksApp/tracks.git
synced 2025-12-16 23:30:12 +01:00
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.
258 lines
6.9 KiB
Go
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
|
|
}
|