mirror of
https://github.com/TracksApp/tracks.git
synced 2026-03-11 15:12:37 +01:00
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:
parent
6613d33f10
commit
f0eb4bdef5
29 changed files with 4100 additions and 104 deletions
258
internal/services/project_service.go
Normal file
258
internal/services/project_service.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue