mirror of
https://github.com/TracksApp/tracks.git
synced 2026-02-03 14:31:47 +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
148
internal/services/auth_service.go
Normal file
148
internal/services/auth_service.go
Normal 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
|
||||
}
|
||||
220
internal/services/context_service.go
Normal file
220
internal/services/context_service.go
Normal 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
|
||||
}
|
||||
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
|
||||
}
|
||||
151
internal/services/tag_service.go
Normal file
151
internal/services/tag_service.go
Normal 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
|
||||
}
|
||||
500
internal/services/todo_service.go
Normal file
500
internal/services/todo_service.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue