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

143
internal/config/config.go Normal file
View file

@ -0,0 +1,143 @@
package config
import (
"fmt"
"os"
"strconv"
"github.com/joho/godotenv"
)
// Config holds the application configuration
type Config struct {
Server ServerConfig
Database DatabaseConfig
Auth AuthConfig
App AppConfig
}
// ServerConfig holds server-related configuration
type ServerConfig struct {
Host string
Port int
Mode string // debug, release, test
}
// DatabaseConfig holds database-related configuration
type DatabaseConfig struct {
Driver string // sqlite, mysql, postgres
Host string
Port int
Name string
User string
Password string
SSLMode string
}
// AuthConfig holds authentication-related configuration
type AuthConfig struct {
JWTSecret string
TokenExpiry int // in hours
SecureCookies bool
}
// AppConfig holds application-specific configuration
type AppConfig struct {
Name string
TimeZone string
OpenSignups bool
AdminEmail string
SecretToken string
ForceSSL bool
UploadPath string
MaxUploadSizeMB int64
}
// Load reads configuration from environment variables
func Load() (*Config, error) {
// Try to load .env file if it exists
_ = godotenv.Load()
cfg := &Config{
Server: ServerConfig{
Host: getEnv("SERVER_HOST", "0.0.0.0"),
Port: getEnvAsInt("SERVER_PORT", 3000),
Mode: getEnv("GIN_MODE", "debug"),
},
Database: DatabaseConfig{
Driver: getEnv("DB_DRIVER", "sqlite"),
Host: getEnv("DB_HOST", "localhost"),
Port: getEnvAsInt("DB_PORT", 5432),
Name: getEnv("DB_NAME", "tracks.db"),
User: getEnv("DB_USER", ""),
Password: getEnv("DB_PASSWORD", ""),
SSLMode: getEnv("DB_SSLMODE", "disable"),
},
Auth: AuthConfig{
JWTSecret: getEnv("JWT_SECRET", "change-me-in-production"),
TokenExpiry: getEnvAsInt("TOKEN_EXPIRY_HOURS", 24),
SecureCookies: getEnvAsBool("SECURE_COOKIES", false),
},
App: AppConfig{
Name: getEnv("APP_NAME", "Tracks"),
TimeZone: getEnv("TZ", "UTC"),
OpenSignups: getEnvAsBool("OPEN_SIGNUPS", false),
AdminEmail: getEnv("ADMIN_EMAIL", ""),
SecretToken: getEnv("SECRET_TOKEN", "change-me-in-production"),
ForceSSL: getEnvAsBool("FORCE_SSL", false),
UploadPath: getEnv("UPLOAD_PATH", "./uploads"),
MaxUploadSizeMB: getEnvAsInt64("MAX_UPLOAD_SIZE_MB", 10),
},
}
return cfg, nil
}
// GetDSN returns the database connection string
func (c *DatabaseConfig) GetDSN() string {
switch c.Driver {
case "sqlite":
return c.Name
case "mysql":
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local",
c.User, c.Password, c.Host, c.Port, c.Name)
case "postgres":
return fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
c.Host, c.Port, c.User, c.Password, c.Name, c.SSLMode)
default:
return ""
}
}
// Helper functions
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
func getEnvAsInt(key string, defaultValue int) int {
valueStr := getEnv(key, "")
if value, err := strconv.Atoi(valueStr); err == nil {
return value
}
return defaultValue
}
func getEnvAsInt64(key string, defaultValue int64) int64 {
valueStr := getEnv(key, "")
if value, err := strconv.ParseInt(valueStr, 10, 64); err == nil {
return value
}
return defaultValue
}
func getEnvAsBool(key string, defaultValue bool) bool {
valueStr := getEnv(key, "")
if value, err := strconv.ParseBool(valueStr); err == nil {
return value
}
return defaultValue
}

View file

@ -0,0 +1,108 @@
package database
import (
"fmt"
"log"
"time"
"github.com/TracksApp/tracks/internal/config"
"github.com/TracksApp/tracks/internal/models"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// DB is the global database instance
var DB *gorm.DB
// Initialize sets up the database connection
func Initialize(cfg *config.DatabaseConfig) error {
var dialector gorm.Dialector
switch cfg.Driver {
case "sqlite":
dialector = sqlite.Open(cfg.GetDSN())
case "mysql":
dialector = mysql.Open(cfg.GetDSN())
case "postgres":
dialector = postgres.Open(cfg.GetDSN())
default:
return fmt.Errorf("unsupported database driver: %s", cfg.Driver)
}
db, err := gorm.Open(dialector, &gorm.Config{
Logger: logger.Default.LogMode(logger.Info),
NowFunc: func() time.Time {
return time.Now().UTC()
},
})
if err != nil {
return fmt.Errorf("failed to connect to database: %w", err)
}
// Set connection pool settings
sqlDB, err := db.DB()
if err != nil {
return fmt.Errorf("failed to get database instance: %w", err)
}
sqlDB.SetMaxIdleConns(10)
sqlDB.SetMaxOpenConns(100)
sqlDB.SetConnMaxLifetime(time.Hour)
DB = db
log.Println("Database connection established")
return nil
}
// AutoMigrate runs database migrations
func AutoMigrate() error {
if DB == nil {
return fmt.Errorf("database not initialized")
}
log.Println("Running database migrations...")
err := DB.AutoMigrate(
&models.User{},
&models.Preference{},
&models.Context{},
&models.Project{},
&models.Todo{},
&models.RecurringTodo{},
&models.Tag{},
&models.Tagging{},
&models.Dependency{},
&models.Note{},
&models.Attachment{},
)
if err != nil {
return fmt.Errorf("failed to run migrations: %w", err)
}
log.Println("Database migrations completed")
return nil
}
// Close closes the database connection
func Close() error {
if DB == nil {
return nil
}
sqlDB, err := DB.DB()
if err != nil {
return err
}
return sqlDB.Close()
}
// GetDB returns the database instance
func GetDB() *gorm.DB {
return DB
}

View file

@ -0,0 +1,96 @@
package handlers
import (
"net/http"
"github.com/TracksApp/tracks/internal/middleware"
"github.com/TracksApp/tracks/internal/services"
"github.com/gin-gonic/gin"
)
// AuthHandler handles authentication endpoints
type AuthHandler struct {
authService *services.AuthService
}
// NewAuthHandler creates a new AuthHandler
func NewAuthHandler(authService *services.AuthService) *AuthHandler {
return &AuthHandler{
authService: authService,
}
}
// Login handles POST /api/login
func (h *AuthHandler) Login(c *gin.Context) {
var req services.LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
resp, err := h.authService.Login(req)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
// Set cookie
c.SetCookie("tracks_token", resp.Token, 60*60*24*7, "/", "", false, true)
c.JSON(http.StatusOK, resp)
}
// Register handles POST /api/register
func (h *AuthHandler) Register(c *gin.Context) {
var req services.RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
resp, err := h.authService.Register(req)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Set cookie
c.SetCookie("tracks_token", resp.Token, 60*60*24*7, "/", "", false, true)
c.JSON(http.StatusCreated, resp)
}
// Logout handles POST /api/logout
func (h *AuthHandler) Logout(c *gin.Context) {
// Clear cookie
c.SetCookie("tracks_token", "", -1, "/", "", false, true)
c.JSON(http.StatusOK, gin.H{"message": "Logged out successfully"})
}
// Me handles GET /api/me
func (h *AuthHandler) Me(c *gin.Context) {
user, err := middleware.GetCurrentUser(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"})
return
}
c.JSON(http.StatusOK, user)
}
// RefreshToken handles POST /api/refresh-token
func (h *AuthHandler) RefreshToken(c *gin.Context) {
user, err := middleware.GetCurrentUser(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"})
return
}
token, err := h.authService.RefreshToken(user.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to refresh token"})
return
}
c.JSON(http.StatusOK, gin.H{"token": token})
}

View file

@ -0,0 +1,230 @@
package handlers
import (
"net/http"
"strconv"
"github.com/TracksApp/tracks/internal/middleware"
"github.com/TracksApp/tracks/internal/models"
"github.com/TracksApp/tracks/internal/services"
"github.com/gin-gonic/gin"
)
// ContextHandler handles context endpoints
type ContextHandler struct {
contextService *services.ContextService
}
// NewContextHandler creates a new ContextHandler
func NewContextHandler(contextService *services.ContextService) *ContextHandler {
return &ContextHandler{
contextService: contextService,
}
}
// ListContexts handles GET /api/contexts
func (h *ContextHandler) ListContexts(c *gin.Context) {
userID, err := middleware.GetCurrentUserID(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"})
return
}
state := models.ContextState(c.Query("state"))
contexts, err := h.contextService.GetContexts(userID, state)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, contexts)
}
// GetContext handles GET /api/contexts/:id
func (h *ContextHandler) GetContext(c *gin.Context) {
userID, err := middleware.GetCurrentUserID(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"})
return
}
contextID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid context ID"})
return
}
context, err := h.contextService.GetContext(userID, uint(contextID))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, context)
}
// CreateContext handles POST /api/contexts
func (h *ContextHandler) CreateContext(c *gin.Context) {
userID, err := middleware.GetCurrentUserID(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"})
return
}
var req services.CreateContextRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
context, err := h.contextService.CreateContext(userID, req)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, context)
}
// UpdateContext handles PUT /api/contexts/:id
func (h *ContextHandler) UpdateContext(c *gin.Context) {
userID, err := middleware.GetCurrentUserID(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"})
return
}
contextID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid context ID"})
return
}
var req services.UpdateContextRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
context, err := h.contextService.UpdateContext(userID, uint(contextID), req)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, context)
}
// DeleteContext handles DELETE /api/contexts/:id
func (h *ContextHandler) DeleteContext(c *gin.Context) {
userID, err := middleware.GetCurrentUserID(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"})
return
}
contextID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid context ID"})
return
}
if err := h.contextService.DeleteContext(userID, uint(contextID)); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Context deleted"})
}
// HideContext handles POST /api/contexts/:id/hide
func (h *ContextHandler) HideContext(c *gin.Context) {
userID, err := middleware.GetCurrentUserID(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"})
return
}
contextID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid context ID"})
return
}
context, err := h.contextService.HideContext(userID, uint(contextID))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, context)
}
// ActivateContext handles POST /api/contexts/:id/activate
func (h *ContextHandler) ActivateContext(c *gin.Context) {
userID, err := middleware.GetCurrentUserID(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"})
return
}
contextID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid context ID"})
return
}
context, err := h.contextService.ActivateContext(userID, uint(contextID))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, context)
}
// CloseContext handles POST /api/contexts/:id/close
func (h *ContextHandler) CloseContext(c *gin.Context) {
userID, err := middleware.GetCurrentUserID(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"})
return
}
contextID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid context ID"})
return
}
context, err := h.contextService.CloseContext(userID, uint(contextID))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, context)
}
// GetContextStats handles GET /api/contexts/:id/stats
func (h *ContextHandler) GetContextStats(c *gin.Context) {
userID, err := middleware.GetCurrentUserID(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"})
return
}
contextID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid context ID"})
return
}
stats, err := h.contextService.GetContextStats(userID, uint(contextID))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, stats)
}

View file

@ -0,0 +1,253 @@
package handlers
import (
"net/http"
"strconv"
"github.com/TracksApp/tracks/internal/middleware"
"github.com/TracksApp/tracks/internal/models"
"github.com/TracksApp/tracks/internal/services"
"github.com/gin-gonic/gin"
)
// ProjectHandler handles project endpoints
type ProjectHandler struct {
projectService *services.ProjectService
}
// NewProjectHandler creates a new ProjectHandler
func NewProjectHandler(projectService *services.ProjectService) *ProjectHandler {
return &ProjectHandler{
projectService: projectService,
}
}
// ListProjects handles GET /api/projects
func (h *ProjectHandler) ListProjects(c *gin.Context) {
userID, err := middleware.GetCurrentUserID(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"})
return
}
state := models.ProjectState(c.Query("state"))
projects, err := h.projectService.GetProjects(userID, state)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, projects)
}
// GetProject handles GET /api/projects/:id
func (h *ProjectHandler) GetProject(c *gin.Context) {
userID, err := middleware.GetCurrentUserID(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"})
return
}
projectID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
return
}
project, err := h.projectService.GetProject(userID, uint(projectID))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, project)
}
// CreateProject handles POST /api/projects
func (h *ProjectHandler) CreateProject(c *gin.Context) {
userID, err := middleware.GetCurrentUserID(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"})
return
}
var req services.CreateProjectRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
project, err := h.projectService.CreateProject(userID, req)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, project)
}
// UpdateProject handles PUT /api/projects/:id
func (h *ProjectHandler) UpdateProject(c *gin.Context) {
userID, err := middleware.GetCurrentUserID(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"})
return
}
projectID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
return
}
var req services.UpdateProjectRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
project, err := h.projectService.UpdateProject(userID, uint(projectID), req)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, project)
}
// DeleteProject handles DELETE /api/projects/:id
func (h *ProjectHandler) DeleteProject(c *gin.Context) {
userID, err := middleware.GetCurrentUserID(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"})
return
}
projectID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
return
}
if err := h.projectService.DeleteProject(userID, uint(projectID)); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Project deleted"})
}
// CompleteProject handles POST /api/projects/:id/complete
func (h *ProjectHandler) CompleteProject(c *gin.Context) {
userID, err := middleware.GetCurrentUserID(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"})
return
}
projectID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
return
}
project, err := h.projectService.CompleteProject(userID, uint(projectID))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, project)
}
// ActivateProject handles POST /api/projects/:id/activate
func (h *ProjectHandler) ActivateProject(c *gin.Context) {
userID, err := middleware.GetCurrentUserID(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"})
return
}
projectID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
return
}
project, err := h.projectService.ActivateProject(userID, uint(projectID))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, project)
}
// HideProject handles POST /api/projects/:id/hide
func (h *ProjectHandler) HideProject(c *gin.Context) {
userID, err := middleware.GetCurrentUserID(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"})
return
}
projectID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
return
}
project, err := h.projectService.HideProject(userID, uint(projectID))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, project)
}
// MarkReviewed handles POST /api/projects/:id/review
func (h *ProjectHandler) MarkReviewed(c *gin.Context) {
userID, err := middleware.GetCurrentUserID(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"})
return
}
projectID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
return
}
project, err := h.projectService.MarkProjectReviewed(userID, uint(projectID))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, project)
}
// GetProjectStats handles GET /api/projects/:id/stats
func (h *ProjectHandler) GetProjectStats(c *gin.Context) {
userID, err := middleware.GetCurrentUserID(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"})
return
}
projectID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
return
}
stats, err := h.projectService.GetProjectStats(userID, uint(projectID))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, stats)
}

View file

@ -0,0 +1,320 @@
package handlers
import (
"net/http"
"strconv"
"time"
"github.com/TracksApp/tracks/internal/middleware"
"github.com/TracksApp/tracks/internal/models"
"github.com/TracksApp/tracks/internal/services"
"github.com/gin-gonic/gin"
)
// TodoHandler handles todo endpoints
type TodoHandler struct {
todoService *services.TodoService
}
// NewTodoHandler creates a new TodoHandler
func NewTodoHandler(todoService *services.TodoService) *TodoHandler {
return &TodoHandler{
todoService: todoService,
}
}
// ListTodos handles GET /api/todos
func (h *TodoHandler) ListTodos(c *gin.Context) {
userID, err := middleware.GetCurrentUserID(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"})
return
}
// Parse filters from query parameters
filter := services.ListTodosFilter{
IncludeTags: c.Query("include_tags") == "true",
}
if state := c.Query("state"); state != "" {
filter.State = models.TodoState(state)
}
if contextIDStr := c.Query("context_id"); contextIDStr != "" {
if contextID, err := strconv.ParseUint(contextIDStr, 10, 32); err == nil {
id := uint(contextID)
filter.ContextID = &id
}
}
if projectIDStr := c.Query("project_id"); projectIDStr != "" {
if projectID, err := strconv.ParseUint(projectIDStr, 10, 32); err == nil {
id := uint(projectID)
filter.ProjectID = &id
}
}
if tagName := c.Query("tag"); tagName != "" {
filter.TagName = &tagName
}
if c.Query("starred") == "true" {
starred := true
filter.Starred = &starred
}
if c.Query("overdue") == "true" {
overdue := true
filter.Overdue = &overdue
}
if c.Query("due_today") == "true" {
dueToday := true
filter.DueToday = &dueToday
}
if c.Query("show_from") == "true" {
showFrom := true
filter.ShowFrom = &showFrom
}
todos, err := h.todoService.GetTodos(userID, filter)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, todos)
}
// GetTodo handles GET /api/todos/:id
func (h *TodoHandler) GetTodo(c *gin.Context) {
userID, err := middleware.GetCurrentUserID(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"})
return
}
todoID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid todo ID"})
return
}
todo, err := h.todoService.GetTodo(userID, uint(todoID))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, todo)
}
// CreateTodo handles POST /api/todos
func (h *TodoHandler) CreateTodo(c *gin.Context) {
userID, err := middleware.GetCurrentUserID(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"})
return
}
var req services.CreateTodoRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
todo, err := h.todoService.CreateTodo(userID, req)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, todo)
}
// UpdateTodo handles PUT /api/todos/:id
func (h *TodoHandler) UpdateTodo(c *gin.Context) {
userID, err := middleware.GetCurrentUserID(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"})
return
}
todoID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid todo ID"})
return
}
var req services.UpdateTodoRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
todo, err := h.todoService.UpdateTodo(userID, uint(todoID), req)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, todo)
}
// DeleteTodo handles DELETE /api/todos/:id
func (h *TodoHandler) DeleteTodo(c *gin.Context) {
userID, err := middleware.GetCurrentUserID(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"})
return
}
todoID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid todo ID"})
return
}
if err := h.todoService.DeleteTodo(userID, uint(todoID)); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Todo deleted"})
}
// CompleteTodo handles POST /api/todos/:id/complete
func (h *TodoHandler) CompleteTodo(c *gin.Context) {
userID, err := middleware.GetCurrentUserID(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"})
return
}
todoID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid todo ID"})
return
}
todo, err := h.todoService.CompleteTodo(userID, uint(todoID))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, todo)
}
// ActivateTodo handles POST /api/todos/:id/activate
func (h *TodoHandler) ActivateTodo(c *gin.Context) {
userID, err := middleware.GetCurrentUserID(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"})
return
}
todoID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid todo ID"})
return
}
todo, err := h.todoService.ActivateTodo(userID, uint(todoID))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, todo)
}
// DeferTodo handles POST /api/todos/:id/defer
func (h *TodoHandler) DeferTodo(c *gin.Context) {
userID, err := middleware.GetCurrentUserID(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"})
return
}
todoID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid todo ID"})
return
}
var req struct {
ShowFrom time.Time `json:"show_from" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
todo, err := h.todoService.DeferTodo(userID, uint(todoID), req.ShowFrom)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, todo)
}
// AddDependency handles POST /api/todos/:id/dependencies
func (h *TodoHandler) AddDependency(c *gin.Context) {
userID, err := middleware.GetCurrentUserID(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"})
return
}
predecessorID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid todo ID"})
return
}
var req struct {
SuccessorID uint `json:"successor_id" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.todoService.AddDependency(userID, uint(predecessorID), req.SuccessorID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"message": "Dependency added"})
}
// RemoveDependency handles DELETE /api/todos/:id/dependencies/:successor_id
func (h *TodoHandler) RemoveDependency(c *gin.Context) {
userID, err := middleware.GetCurrentUserID(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"})
return
}
predecessorID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid todo ID"})
return
}
successorID, err := strconv.ParseUint(c.Param("successor_id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid successor ID"})
return
}
if err := h.todoService.RemoveDependency(userID, uint(predecessorID), uint(successorID)); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Dependency removed"})
}

181
internal/middleware/auth.go Normal file
View file

@ -0,0 +1,181 @@
package middleware
import (
"fmt"
"net/http"
"strings"
"github.com/TracksApp/tracks/internal/database"
"github.com/TracksApp/tracks/internal/models"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
)
// Claims represents the JWT claims
type Claims struct {
UserID uint `json:"user_id"`
Login string `json:"login"`
jwt.RegisteredClaims
}
// AuthMiddleware validates JWT tokens and sets the current user
func AuthMiddleware(jwtSecret string) gin.HandlerFunc {
return func(c *gin.Context) {
// Try to get token from Authorization header
authHeader := c.GetHeader("Authorization")
var tokenString string
if authHeader != "" {
// Bearer token
parts := strings.Split(authHeader, " ")
if len(parts) == 2 && parts[0] == "Bearer" {
tokenString = parts[1]
}
}
// If no Bearer token, try cookie
if tokenString == "" {
cookie, err := c.Cookie("tracks_token")
if err == nil {
tokenString = cookie
}
}
// If still no token, try query parameter (for feed tokens)
if tokenString == "" {
tokenString = c.Query("token")
}
if tokenString == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "No authentication token provided"})
c.Abort()
return
}
// Parse and validate token
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(jwtSecret), nil
})
if err != nil || !token.Valid {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"})
c.Abort()
return
}
claims, ok := token.Claims.(*Claims)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token claims"})
c.Abort()
return
}
// Load user from database
var user models.User
if err := database.DB.First(&user, claims.UserID).Error; err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not found"})
c.Abort()
return
}
// Set user in context
c.Set("user", &user)
c.Set("user_id", user.ID)
c.Next()
}
}
// OptionalAuthMiddleware attempts to authenticate but doesn't fail if no token
func OptionalAuthMiddleware(jwtSecret string) gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
var tokenString string
if authHeader != "" {
parts := strings.Split(authHeader, " ")
if len(parts) == 2 && parts[0] == "Bearer" {
tokenString = parts[1]
}
}
if tokenString == "" {
cookie, err := c.Cookie("tracks_token")
if err == nil {
tokenString = cookie
}
}
if tokenString != "" {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
return []byte(jwtSecret), nil
})
if err == nil && token.Valid {
if claims, ok := token.Claims.(*Claims); ok {
var user models.User
if err := database.DB.First(&user, claims.UserID).Error; err == nil {
c.Set("user", &user)
c.Set("user_id", user.ID)
}
}
}
}
c.Next()
}
}
// AdminMiddleware ensures the user is an admin
func AdminMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
userInterface, exists := c.Get("user")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
c.Abort()
return
}
user, ok := userInterface.(*models.User)
if !ok || !user.IsAdmin {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
c.Abort()
return
}
c.Next()
}
}
// GetCurrentUser retrieves the current user from the context
func GetCurrentUser(c *gin.Context) (*models.User, error) {
userInterface, exists := c.Get("user")
if !exists {
return nil, fmt.Errorf("user not found in context")
}
user, ok := userInterface.(*models.User)
if !ok {
return nil, fmt.Errorf("invalid user type in context")
}
return user, nil
}
// GetCurrentUserID retrieves the current user ID from the context
func GetCurrentUserID(c *gin.Context) (uint, error) {
userIDInterface, exists := c.Get("user_id")
if !exists {
return 0, fmt.Errorf("user ID not found in context")
}
userID, ok := userIDInterface.(uint)
if !ok {
return 0, fmt.Errorf("invalid user ID type in context")
}
return userID, nil
}

View file

@ -0,0 +1,23 @@
package models
import (
"time"
"gorm.io/gorm"
)
// Attachment represents a file attached to a todo
type Attachment struct {
ID uint `gorm:"primaryKey" json:"id"`
TodoID uint `gorm:"not null;index" json:"todo_id"`
FileName string `gorm:"size:255;not null" json:"file_name"`
ContentType string `gorm:"size:255" json:"content_type"`
FileSize int64 `json:"file_size"`
FilePath string `gorm:"size:500" json:"file_path"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
// Associations
Todo Todo `gorm:"foreignKey:TodoID" json:"-"`
}

View file

@ -0,0 +1,74 @@
package models
import (
"time"
"gorm.io/gorm"
)
// ContextState represents the state of a context
type ContextState string
const (
ContextStateActive ContextState = "active"
ContextStateHidden ContextState = "hidden"
ContextStateClosed ContextState = "closed"
)
// Context represents a GTD context (e.g., @home, @work, @phone)
type Context struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"not null;index" json:"user_id"`
Name string `gorm:"not null;size:255" json:"name"`
Position int `gorm:"default:1" json:"position"`
State ContextState `gorm:"type:varchar(20);default:'active'" json:"state"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
// Associations
User User `gorm:"foreignKey:UserID" json:"-"`
Todos []Todo `gorm:"foreignKey:ContextID" json:"todos,omitempty"`
RecurringTodos []RecurringTodo `gorm:"foreignKey:ContextID" json:"recurring_todos,omitempty"`
}
// BeforeCreate sets default values
func (c *Context) BeforeCreate(tx *gorm.DB) error {
if c.State == "" {
c.State = ContextStateActive
}
if c.Position == 0 {
c.Position = 1
}
return nil
}
// IsActive returns true if the context is active
func (c *Context) IsActive() bool {
return c.State == ContextStateActive
}
// IsHidden returns true if the context is hidden
func (c *Context) IsHidden() bool {
return c.State == ContextStateHidden
}
// IsClosed returns true if the context is closed
func (c *Context) IsClosed() bool {
return c.State == ContextStateClosed
}
// Hide sets the context state to hidden
func (c *Context) Hide() {
c.State = ContextStateHidden
}
// Activate sets the context state to active
func (c *Context) Activate() {
c.State = ContextStateActive
}
// Close sets the context state to closed
func (c *Context) Close() {
c.State = ContextStateClosed
}

View file

@ -0,0 +1,25 @@
package models
import (
"time"
)
// DependencyRelationshipType represents the type of dependency relationship
type DependencyRelationshipType string
const (
DependencyTypeBlocks DependencyRelationshipType = "blocks" // Predecessor blocks successor
)
// Dependency represents a dependency relationship between todos
type Dependency struct {
ID uint `gorm:"primaryKey" json:"id"`
PredecessorID uint `gorm:"not null;index" json:"predecessor_id"`
SuccessorID uint `gorm:"not null;index" json:"successor_id"`
RelationshipType DependencyRelationshipType `gorm:"type:varchar(20);default:'blocks'" json:"relationship_type"`
CreatedAt time.Time `json:"created_at"`
// Associations
Predecessor Todo `gorm:"foreignKey:PredecessorID" json:"predecessor,omitempty"`
Successor Todo `gorm:"foreignKey:SuccessorID" json:"successor,omitempty"`
}

22
internal/models/note.go Normal file
View file

@ -0,0 +1,22 @@
package models
import (
"time"
"gorm.io/gorm"
)
// Note represents a note attached to a project
type Note struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"not null;index" json:"user_id"`
ProjectID uint `gorm:"not null;index" json:"project_id"`
Body string `gorm:"type:text;not null" json:"body"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
// Associations
User User `gorm:"foreignKey:UserID" json:"-"`
Project Project `gorm:"foreignKey:ProjectID" json:"project,omitempty"`
}

View file

@ -0,0 +1,62 @@
package models
import (
"time"
"gorm.io/gorm"
)
// Preference represents user preferences and settings
type Preference struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"uniqueIndex;not null" json:"user_id"`
DateFormat string `gorm:"size:255;default:'%d/%m/%Y'" json:"date_format"`
TimeZone string `gorm:"size:255;default:'UTC'" json:"time_zone"`
WeekStartsOn int `gorm:"default:0" json:"week_starts_on"` // 0=Sunday, 1=Monday
ShowNumberCompleted int `gorm:"default:5" json:"show_number_completed"`
StalenessStartsInDays int `gorm:"default:14" json:"staleness_starts_in_days"`
DueDateStyle string `gorm:"size:255;default:'due'" json:"due_date_style"`
MobileItemsPerPage int `gorm:"default:6" json:"mobile_items_per_page"`
RefreshInterval int `gorm:"default:0" json:"refresh_interval"`
ShowProjectOnTodoLine bool `gorm:"default:true" json:"show_project_on_todo_line"`
ShowContextOnTodoLine bool `gorm:"default:true" json:"show_context_on_todo_line"`
ShowHiddenProjectsInSidebar bool `gorm:"default:true" json:"show_hidden_projects_in_sidebar"`
ShowHiddenContextsInSidebar bool `gorm:"default:true" json:"show_hidden_contexts_in_sidebar"`
ReviewPeriodInDays int `gorm:"default:28" json:"review_period_in_days"`
Theme string `gorm:"size:255;default:'light_blue'" json:"theme"`
SmsEmail string `gorm:"size:255" json:"sms_email"`
SmsContext *uint `json:"sms_context_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
// Associations
User User `gorm:"foreignKey:UserID" json:"-"`
SMSContext *Context `gorm:"foreignKey:SmsContext" json:"sms_context,omitempty"`
}
// BeforeCreate sets default values
func (p *Preference) BeforeCreate(tx *gorm.DB) error {
if p.DateFormat == "" {
p.DateFormat = "%d/%m/%Y"
}
if p.TimeZone == "" {
p.TimeZone = "UTC"
}
if p.Theme == "" {
p.Theme = "light_blue"
}
if p.ShowNumberCompleted == 0 {
p.ShowNumberCompleted = 5
}
if p.StalenessStartsInDays == 0 {
p.StalenessStartsInDays = 14
}
if p.MobileItemsPerPage == 0 {
p.MobileItemsPerPage = 6
}
if p.ReviewPeriodInDays == 0 {
p.ReviewPeriodInDays = 28
}
return nil
}

View file

@ -0,0 +1,89 @@
package models
import (
"time"
"gorm.io/gorm"
)
// ProjectState represents the state of a project
type ProjectState string
const (
ProjectStateActive ProjectState = "active"
ProjectStateHidden ProjectState = "hidden"
ProjectStateCompleted ProjectState = "completed"
)
// Project represents a GTD project
type Project struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"not null;index" json:"user_id"`
Name string `gorm:"not null;size:255" json:"name"`
Description string `gorm:"type:text" json:"description"`
Position int `gorm:"default:1" json:"position"`
State ProjectState `gorm:"type:varchar(20);default:'active'" json:"state"`
DefaultContextID *uint `json:"default_context_id"`
DefaultTags string `gorm:"type:text" json:"default_tags"`
CompletedAt *time.Time `json:"completed_at"`
LastReviewedAt *time.Time `json:"last_reviewed_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
// Associations
User User `gorm:"foreignKey:UserID" json:"-"`
DefaultContext *Context `gorm:"foreignKey:DefaultContextID" json:"default_context,omitempty"`
Todos []Todo `gorm:"foreignKey:ProjectID" json:"todos,omitempty"`
RecurringTodos []RecurringTodo `gorm:"foreignKey:ProjectID" json:"recurring_todos,omitempty"`
Notes []Note `gorm:"foreignKey:ProjectID" json:"notes,omitempty"`
}
// BeforeCreate sets default values
func (p *Project) BeforeCreate(tx *gorm.DB) error {
if p.State == "" {
p.State = ProjectStateActive
}
if p.Position == 0 {
p.Position = 1
}
return nil
}
// IsActive returns true if the project is active
func (p *Project) IsActive() bool {
return p.State == ProjectStateActive
}
// IsHidden returns true if the project is hidden
func (p *Project) IsHidden() bool {
return p.State == ProjectStateHidden
}
// IsCompleted returns true if the project is completed
func (p *Project) IsCompleted() bool {
return p.State == ProjectStateCompleted
}
// Hide sets the project state to hidden
func (p *Project) Hide() {
p.State = ProjectStateHidden
}
// Activate sets the project state to active
func (p *Project) Activate() {
p.State = ProjectStateActive
}
// Complete sets the project state to completed
func (p *Project) Complete() {
now := time.Now()
p.State = ProjectStateCompleted
p.CompletedAt = &now
}
// MarkReviewed updates the last_reviewed_at timestamp
func (p *Project) MarkReviewed() {
now := time.Now()
p.LastReviewedAt = &now
}

View file

@ -0,0 +1,125 @@
package models
import (
"time"
"gorm.io/gorm"
)
// RecurrenceType represents the type of recurrence pattern
type RecurrenceType string
const (
RecurrenceTypeDaily RecurrenceType = "daily"
RecurrenceTypeWeekly RecurrenceType = "weekly"
RecurrenceTypeMonthly RecurrenceType = "monthly"
RecurrenceTypeYearly RecurrenceType = "yearly"
)
// RecurringTodoState represents the state of a recurring todo
type RecurringTodoState string
const (
RecurringTodoStateActive RecurringTodoState = "active"
RecurringTodoStateCompleted RecurringTodoState = "completed"
)
// RecurrenceSelector represents how monthly/yearly recurrence is calculated
type RecurrenceSelector string
const (
RecurrenceSelectorDate RecurrenceSelector = "date" // e.g., 15th of month
RecurrenceSelectorDayOfWeek RecurrenceSelector = "day_of_week" // e.g., 3rd Monday
)
// RecurringTodo represents a template for recurring tasks
type RecurringTodo struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"not null;index" json:"user_id"`
ContextID uint `gorm:"not null;index" json:"context_id"`
ProjectID *uint `gorm:"index" json:"project_id"`
Description string `gorm:"not null;size:300" json:"description"`
Notes string `gorm:"type:text" json:"notes"`
State RecurringTodoState `gorm:"type:varchar(20);default:'active'" json:"state"`
RecurrenceType RecurrenceType `gorm:"type:varchar(20);not null" json:"recurrence_type"`
RecurrenceSelector RecurrenceSelector `gorm:"type:varchar(20)" json:"recurrence_selector"`
EveryN int `gorm:"default:1" json:"every_n"` // Every N days/weeks/months/years
StartFrom time.Time `json:"start_from"`
EndsOn *time.Time `json:"ends_on"`
OccurrencesCount *int `json:"occurrences_count"` // Total occurrences to create
NumberOfOccurrences int `gorm:"default:0" json:"number_of_occurrences"` // Created so far
Target string `gorm:"type:varchar(20)" json:"target"` // 'due_date' or 'show_from'
// Weekly recurrence
RecursOnMonday bool `gorm:"default:false" json:"recurs_on_monday"`
RecursOnTuesday bool `gorm:"default:false" json:"recurs_on_tuesday"`
RecursOnWednesday bool `gorm:"default:false" json:"recurs_on_wednesday"`
RecursOnThursday bool `gorm:"default:false" json:"recurs_on_thursday"`
RecursOnFriday bool `gorm:"default:false" json:"recurs_on_friday"`
RecursOnSaturday bool `gorm:"default:false" json:"recurs_on_saturday"`
RecursOnSunday bool `gorm:"default:false" json:"recurs_on_sunday"`
// Daily recurrence
OnlyWorkdays bool `gorm:"default:false" json:"only_workdays"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
// Associations
User User `gorm:"foreignKey:UserID" json:"-"`
Context Context `gorm:"foreignKey:ContextID" json:"context,omitempty"`
Project *Project `gorm:"foreignKey:ProjectID" json:"project,omitempty"`
Todos []Todo `gorm:"foreignKey:RecurringTodoID" json:"todos,omitempty"`
Taggings []Tagging `gorm:"polymorphic:Taggable" json:"-"`
Tags []Tag `gorm:"many2many:taggings;foreignKey:ID;joinForeignKey:TaggableID;References:ID;joinReferences:TagID" json:"tags,omitempty"`
}
// BeforeCreate sets default values
func (rt *RecurringTodo) BeforeCreate(tx *gorm.DB) error {
if rt.State == "" {
rt.State = RecurringTodoStateActive
}
if rt.EveryN == 0 {
rt.EveryN = 1
}
if rt.Target == "" {
rt.Target = "due_date"
}
return nil
}
// IsActive returns true if the recurring todo is active
func (rt *RecurringTodo) IsActive() bool {
return rt.State == RecurringTodoStateActive
}
// IsCompleted returns true if the recurring todo is completed
func (rt *RecurringTodo) IsCompleted() bool {
return rt.State == RecurringTodoStateCompleted
}
// Complete marks the recurring todo as completed
func (rt *RecurringTodo) Complete() {
rt.State = RecurringTodoStateCompleted
}
// ShouldComplete returns true if the recurring todo has reached its end condition
func (rt *RecurringTodo) ShouldComplete() bool {
// Check if ended by date
if rt.EndsOn != nil && time.Now().After(*rt.EndsOn) {
return true
}
// Check if ended by occurrence count
if rt.OccurrencesCount != nil && rt.NumberOfOccurrences >= *rt.OccurrencesCount {
return true
}
return false
}
// IncrementOccurrences increments the occurrence counter
func (rt *RecurringTodo) IncrementOccurrences() {
rt.NumberOfOccurrences++
}

33
internal/models/tag.go Normal file
View file

@ -0,0 +1,33 @@
package models
import (
"time"
"gorm.io/gorm"
)
// Tag represents a label that can be attached to todos and recurring todos
type Tag struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"not null;index" json:"user_id"`
Name string `gorm:"not null;size:255;index" json:"name"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
// Associations
User User `gorm:"foreignKey:UserID" json:"-"`
Taggings []Tagging `gorm:"foreignKey:TagID" json:"-"`
}
// Tagging represents the polymorphic join between tags and taggable entities
type Tagging struct {
ID uint `gorm:"primaryKey" json:"id"`
TagID uint `gorm:"not null;index" json:"tag_id"`
TaggableID uint `gorm:"not null;index" json:"taggable_id"`
TaggableType string `gorm:"not null;size:255;index" json:"taggable_type"`
CreatedAt time.Time `json:"created_at"`
// Associations
Tag Tag `gorm:"foreignKey:TagID" json:"tag,omitempty"`
}

171
internal/models/todo.go Normal file
View file

@ -0,0 +1,171 @@
package models
import (
"errors"
"time"
"gorm.io/gorm"
)
// TodoState represents the state of a todo
type TodoState string
const (
TodoStateActive TodoState = "active"
TodoStateCompleted TodoState = "completed"
TodoStateDeferred TodoState = "deferred"
TodoStatePending TodoState = "pending"
)
// Todo represents a task/action item
type Todo struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"not null;index" json:"user_id"`
ContextID uint `gorm:"not null;index" json:"context_id"`
ProjectID *uint `gorm:"index" json:"project_id"`
RecurringTodoID *uint `gorm:"index" json:"recurring_todo_id"`
Description string `gorm:"not null;size:300" json:"description"`
Notes string `gorm:"type:text" json:"notes"`
State TodoState `gorm:"type:varchar(20);default:'active';index" json:"state"`
DueDate *time.Time `json:"due_date"`
ShowFrom *time.Time `json:"show_from"`
CompletedAt *time.Time `json:"completed_at"`
Starred bool `gorm:"default:false" json:"starred"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
// Associations
User User `gorm:"foreignKey:UserID" json:"-"`
Context Context `gorm:"foreignKey:ContextID" json:"context,omitempty"`
Project *Project `gorm:"foreignKey:ProjectID" json:"project,omitempty"`
RecurringTodo *RecurringTodo `gorm:"foreignKey:RecurringTodoID" json:"recurring_todo,omitempty"`
Taggings []Tagging `gorm:"polymorphic:Taggable" json:"-"`
Tags []Tag `gorm:"many2many:taggings;foreignKey:ID;joinForeignKey:TaggableID;References:ID;joinReferences:TagID" json:"tags,omitempty"`
Attachments []Attachment `gorm:"foreignKey:TodoID" json:"attachments,omitempty"`
// Dependencies
Predecessors []Dependency `gorm:"foreignKey:SuccessorID" json:"predecessors,omitempty"`
Successors []Dependency `gorm:"foreignKey:PredecessorID" json:"successors,omitempty"`
}
// BeforeCreate sets default values
func (t *Todo) BeforeCreate(tx *gorm.DB) error {
if t.State == "" {
t.State = TodoStateActive
}
return nil
}
// IsActive returns true if the todo is active
func (t *Todo) IsActive() bool {
return t.State == TodoStateActive
}
// IsCompleted returns true if the todo is completed
func (t *Todo) IsCompleted() bool {
return t.State == TodoStateCompleted
}
// IsDeferred returns true if the todo is deferred
func (t *Todo) IsDeferred() bool {
return t.State == TodoStateDeferred
}
// IsPending returns true if the todo is pending (blocked)
func (t *Todo) IsPending() bool {
return t.State == TodoStatePending
}
// Complete transitions the todo to completed state
func (t *Todo) Complete() error {
if t.IsCompleted() {
return errors.New("todo is already completed")
}
now := time.Now()
t.State = TodoStateCompleted
t.CompletedAt = &now
return nil
}
// Activate transitions the todo to active state
func (t *Todo) Activate() error {
if t.IsActive() {
return errors.New("todo is already active")
}
// Can't activate if it has incomplete predecessors
// This check should be done by the service layer
t.State = TodoStateActive
t.CompletedAt = nil
return nil
}
// Defer transitions the todo to deferred state
func (t *Todo) Defer(showFrom time.Time) error {
if !t.IsActive() {
return errors.New("can only defer active todos")
}
t.State = TodoStateDeferred
t.ShowFrom = &showFrom
return nil
}
// Block transitions the todo to pending state
func (t *Todo) Block() error {
if t.IsCompleted() {
return errors.New("cannot block completed todo")
}
t.State = TodoStatePending
return nil
}
// Unblock transitions the todo from pending to active
func (t *Todo) Unblock() error {
if !t.IsPending() {
return errors.New("todo is not pending")
}
t.State = TodoStateActive
return nil
}
// IsDue returns true if the todo has a due date that has passed
func (t *Todo) IsDue() bool {
if t.DueDate == nil {
return false
}
return t.DueDate.Before(time.Now())
}
// IsOverdue returns true if the todo is active and past due
func (t *Todo) IsOverdue() bool {
return t.IsActive() && t.IsDue()
}
// ShouldShow returns true if the todo should be displayed (not deferred or show_from has passed)
func (t *Todo) ShouldShow() bool {
if t.ShowFrom == nil {
return true
}
return t.ShowFrom.Before(time.Now()) || t.ShowFrom.Equal(time.Now())
}
// IsStale returns true if the todo is old based on the staleness threshold
func (t *Todo) IsStale(stalenessThresholdDays int) bool {
if t.IsCompleted() {
return false
}
threshold := time.Now().AddDate(0, 0, -stalenessThresholdDays)
return t.CreatedAt.Before(threshold)
}

68
internal/models/user.go Normal file
View file

@ -0,0 +1,68 @@
package models
import (
"time"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
// AuthType represents the authentication scheme
type AuthType string
const (
AuthTypeDatabase AuthType = "database"
AuthTypeOpenID AuthType = "openid"
AuthTypeCAS AuthType = "cas"
)
// User represents a user account
type User struct {
ID uint `gorm:"primaryKey" json:"id"`
Login string `gorm:"uniqueIndex;not null;size:80" json:"login"`
CryptedPassword string `gorm:"size:255" json:"-"`
Token string `gorm:"uniqueIndex;size:255" json:"token,omitempty"`
IsAdmin bool `gorm:"default:false" json:"is_admin"`
FirstName string `gorm:"size:255" json:"first_name"`
LastName string `gorm:"size:255" json:"last_name"`
AuthType AuthType `gorm:"type:varchar(255);default:'database'" json:"auth_type"`
OpenIDUrl string `gorm:"size:255" json:"open_id_url,omitempty"`
RememberToken string `gorm:"size:255" json:"-"`
RememberExpires *time.Time `json:"-"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
// Associations
Contexts []Context `gorm:"foreignKey:UserID" json:"contexts,omitempty"`
Projects []Project `gorm:"foreignKey:UserID" json:"projects,omitempty"`
Todos []Todo `gorm:"foreignKey:UserID" json:"todos,omitempty"`
RecurringTodos []RecurringTodo `gorm:"foreignKey:UserID" json:"recurring_todos,omitempty"`
Tags []Tag `gorm:"foreignKey:UserID" json:"tags,omitempty"`
Notes []Note `gorm:"foreignKey:UserID" json:"notes,omitempty"`
Preference *Preference `gorm:"foreignKey:UserID" json:"preference,omitempty"`
}
// SetPassword hashes and sets the user's password
func (u *User) SetPassword(password string) error {
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
u.CryptedPassword = string(hashedPassword)
return nil
}
// CheckPassword verifies if the provided password matches the user's password
func (u *User) CheckPassword(password string) bool {
err := bcrypt.CompareHashAndPassword([]byte(u.CryptedPassword), []byte(password))
return err == nil
}
// BeforeCreate hook to set default values
func (u *User) BeforeCreate(tx *gorm.DB) error {
if u.AuthType == "" {
u.AuthType = AuthTypeDatabase
}
return nil
}

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
}