diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..fd2381e0 --- /dev/null +++ b/.env.example @@ -0,0 +1,40 @@ +# Server Configuration +SERVER_HOST=0.0.0.0 +SERVER_PORT=3000 +GIN_MODE=debug + +# Database Configuration +DB_DRIVER=sqlite +DB_NAME=tracks.db + +# For PostgreSQL: +# DB_DRIVER=postgres +# DB_HOST=localhost +# DB_PORT=5432 +# DB_NAME=tracks +# DB_USER=tracks +# DB_PASSWORD=tracks +# DB_SSLMODE=disable + +# For MySQL: +# DB_DRIVER=mysql +# DB_HOST=localhost +# DB_PORT=3306 +# DB_NAME=tracks +# DB_USER=tracks +# DB_PASSWORD=tracks + +# Authentication Configuration +JWT_SECRET=your-secret-key-change-this-in-production +TOKEN_EXPIRY_HOURS=168 +SECURE_COOKIES=false + +# Application Configuration +APP_NAME=Tracks +TZ=UTC +OPEN_SIGNUPS=true +ADMIN_EMAIL=admin@example.com +SECRET_TOKEN=your-secret-token-change-this-in-production +FORCE_SSL=false +UPLOAD_PATH=./uploads +MAX_UPLOAD_SIZE_MB=10 diff --git a/.gitignore b/.gitignore index 0291b16c..89a011e2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,29 +1,49 @@ -*.DS_Store -*.tmproj +# Binaries +/tracks +*.exe +*.dll +*.so +*.dylib + +# Test binary +*.test + +# Output of the go coverage tool +*.out + +# Dependency directories +vendor/ + +# Go workspace file +go.work + +# Environment files +.env +.env.local + +# Database files +*.db +*.sqlite +*.sqlite3 + +# Data directories +/data/ +/uploads/ + +# IDE specific files +.idea/ +.vscode/ +*.swp +*.swo *~ -.dotest -.idea -.rvmrc -.ruby-gemset -.ruby-version -.sass-cache/ -.yardoc -/.bundle -/.emacs-project -/.redcar -/coverage -/db/*.db -/db/*.sqlite3 -/db/*.sqlite3-journal -/db/assets/* -/log/*.log -/public/assets/ -/tmp -config/deploy.rb -config/site.yml -config/database.yml -db/data.yml -nbproject -rerun.txt -tags -.use-docker + +# OS specific files +.DS_Store +Thumbs.db + +# Log files +*.log + +# Temporary files +tmp/ +temp/ diff --git a/Dockerfile b/Dockerfile index 72ceffe2..da7f3bc0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,66 +1,49 @@ -ARG RUBY_VERSION=3.3 -FROM ruby:${RUBY_VERSION} AS base +# Build stage +FROM golang:1.21-alpine AS builder +# Install build dependencies +RUN apk add --no-cache git gcc musl-dev sqlite-dev + +# Set working directory WORKDIR /app -RUN touch /etc/app-env -RUN apt-get update && apt-get install -y npm netcat-openbsd -RUN npm install -g yarn -RUN gem install bundler +# Copy go mod files +COPY go.mod go.sum ./ -RUN mkdir /app/log +# Download dependencies +RUN go mod download -COPY COPYING /app/ -COPY config /app/config/ -COPY config/database.docker.yml /app/config/database.yml -COPY config/site.docker.yml /app/config/site.yml +# Copy source code +COPY . . -COPY bin /app/bin/ -COPY script /app/script/ -COPY public /app/public/ -COPY vendor /app/vendor/ +# Build the application +RUN CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo -o tracks ./cmd/tracks -COPY .yardopts /app/ -COPY Rakefile /app/ -COPY config.ru /app/ -COPY docker-entrypoint.sh /app/ +# Runtime stage +FROM alpine:latest -COPY lib /app/lib/ -COPY app /app/app/ -COPY db /app/db/ +# Install runtime dependencies +RUN apk --no-cache add ca-certificates sqlite-libs -# Use glob to omit error if the .git directory doesn't exists (in case the -# code is from a release archive, not a Git clone) -COPY .gi[t] /app/.git +# Create app user +RUN addgroup -g 1000 tracks && \ + adduser -D -u 1000 -G tracks tracks -COPY Gemfile* /app/ +# Set working directory +WORKDIR /app -ENTRYPOINT ["/app/docker-entrypoint.sh"] +# Copy binary from builder +COPY --from=builder /app/tracks . + +# Create data directory +RUN mkdir -p /app/data /app/uploads && \ + chown -R tracks:tracks /app + +# Switch to non-root user +USER tracks + +# Expose port EXPOSE 3000 -CMD ["./bin/rails", "server", "-b", "0.0.0.0"] -FROM base AS precompile -RUN bundle config set deployment true -RUN bundle install --jobs 4 -RUN RAILS_GROUPS=assets bundle exec rake assets:precompile - -# Build the environment-specific stuff -FROM base AS production -RUN bundle config set without assets -RUN bundle config --global frozen 1 -RUN bundle install --jobs 4 -COPY --from=precompile /app/public/assets /app/public/assets - -FROM base AS test -COPY test /app/test/ -# For testing the API client -COPY doc /app/doc/ -RUN bundle config set without assets -RUN bundle config set with development test -RUN bundle config --global frozen 1 -RUN bundle install --jobs 4 -COPY --from=precompile /app/public/assets /app/public/assets - -FROM base AS development -RUN bundle config set with development test -RUN bundle install --jobs 4 +# Run the application +CMD ["./tracks"] diff --git a/README_GOLANG.md b/README_GOLANG.md new file mode 100644 index 00000000..51a2a66f --- /dev/null +++ b/README_GOLANG.md @@ -0,0 +1,440 @@ +# Tracks - Golang Rewrite + +This is a complete rewrite of the Tracks GTD (Getting Things Done) application in Golang. Tracks is a web-based todo list application designed to help you organize and manage your tasks using the GTD methodology. + +## Features + +### Core GTD Functionality +- **Todos/Actions**: Create, manage, and track individual tasks +- **Contexts**: Organize actions by context (@home, @work, @phone, etc.) +- **Projects**: Group related actions into projects +- **Dependencies**: Create dependencies between todos (blocking relationships) +- **Tags**: Flexible tagging system for categorization +- **State Management**: Active, Completed, Deferred (tickler), and Pending (blocked) states + +### Advanced Features +- **Due Dates**: Set and track due dates for todos +- **Deferred Actions**: Schedule todos to appear at a future date (show_from) +- **Starred Todos**: Flag high-priority items +- **Project Tracking**: Track project status and health +- **Statistics**: Built-in stats for todos, projects, and contexts +- **Multi-database Support**: SQLite, MySQL, and PostgreSQL + +### Technical Features +- **RESTful API**: Complete REST API for all operations +- **JWT Authentication**: Secure token-based authentication +- **Docker Support**: Ready-to-deploy Docker configuration +- **Database Migrations**: Automatic database schema management +- **Clean Architecture**: Well-organized, maintainable codebase + +## Architecture + +### Technology Stack +- **Language**: Go 1.21+ +- **Web Framework**: Gin +- **ORM**: GORM +- **Database**: SQLite, MySQL, or PostgreSQL +- **Authentication**: JWT (golang-jwt) +- **Password Hashing**: bcrypt + +### Project Structure +``` +tracks-golang/ +├── cmd/ +│ └── tracks/ # Main application entry point +├── internal/ +│ ├── config/ # Configuration management +│ ├── database/ # Database connection and migrations +│ ├── handlers/ # HTTP request handlers +│ ├── middleware/ # HTTP middleware (auth, etc.) +│ ├── models/ # Database models +│ └── services/ # Business logic +├── .env.example # Environment configuration template +├── Dockerfile # Docker build configuration +├── docker-compose.yml # Docker Compose setup +└── go.mod # Go module dependencies +``` + +## Getting Started + +### Prerequisites +- Go 1.21 or higher +- PostgreSQL, MySQL, or SQLite (default) +- Docker and Docker Compose (optional) + +### Installation + +#### Option 1: Run with Docker Compose (Recommended) + +1. Clone the repository: +```bash +git clone https://github.com/TracksApp/tracks.git +cd tracks +``` + +2. Start the application: +```bash +docker-compose up -d +``` + +The application will be available at `http://localhost:3000` + +#### Option 2: Run Locally + +1. Clone the repository: +```bash +git clone https://github.com/TracksApp/tracks.git +cd tracks +``` + +2. Copy the environment file: +```bash +cp .env.example .env +``` + +3. Edit `.env` and configure your settings (database, JWT secret, etc.) + +4. Install dependencies: +```bash +go mod download +``` + +5. Run the application: +```bash +go run cmd/tracks/main.go +``` + +The application will be available at `http://localhost:3000` + +### Configuration + +The application can be configured using environment variables. See `.env.example` for all available options. + +#### Key Configuration Options + +**Server Configuration:** +- `SERVER_HOST`: Host to bind to (default: 0.0.0.0) +- `SERVER_PORT`: Port to listen on (default: 3000) +- `GIN_MODE`: Gin mode (debug, release, test) + +**Database Configuration:** +- `DB_DRIVER`: Database driver (sqlite, mysql, postgres) +- `DB_HOST`: Database host +- `DB_PORT`: Database port +- `DB_NAME`: Database name (or file path for SQLite) +- `DB_USER`: Database user +- `DB_PASSWORD`: Database password + +**Authentication:** +- `JWT_SECRET`: Secret key for JWT tokens (change in production!) +- `TOKEN_EXPIRY_HOURS`: Token expiration time in hours (default: 168 = 7 days) +- `SECURE_COOKIES`: Use secure cookies (set to true in production with HTTPS) + +**Application:** +- `OPEN_SIGNUPS`: Allow user registration (default: false) +- `ADMIN_EMAIL`: Admin contact email + +## API Documentation + +### Authentication + +#### Register (if OPEN_SIGNUPS=true) +```bash +POST /api/auth/register +Content-Type: application/json + +{ + "login": "username", + "password": "password", + "first_name": "John", + "last_name": "Doe" +} +``` + +#### Login +```bash +POST /api/auth/login +Content-Type: application/json + +{ + "login": "username", + "password": "password" +} +``` + +Response: +```json +{ + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "user": { + "id": 1, + "login": "username", + "first_name": "John", + "last_name": "Doe" + } +} +``` + +#### Get Current User +```bash +GET /api/me +Authorization: Bearer +``` + +### Todos + +#### List Todos +```bash +GET /api/todos?state=active&context_id=1&include_tags=true +Authorization: Bearer +``` + +Query Parameters: +- `state`: Filter by state (active, completed, deferred, pending) +- `context_id`: Filter by context ID +- `project_id`: Filter by project ID +- `tag`: Filter by tag name +- `starred`: Filter starred todos (true/false) +- `overdue`: Show overdue todos (true/false) +- `include_tags`: Include tags in response (true/false) + +#### Create Todo +```bash +POST /api/todos +Authorization: Bearer +Content-Type: application/json + +{ + "description": "Buy groceries", + "notes": "Don't forget milk", + "context_id": 1, + "project_id": 2, + "due_date": "2024-12-31T00:00:00Z", + "starred": false, + "tags": ["shopping", "urgent"] +} +``` + +#### Update Todo +```bash +PUT /api/todos/:id +Authorization: Bearer +Content-Type: application/json + +{ + "description": "Buy groceries and snacks", + "starred": true +} +``` + +#### Complete Todo +```bash +POST /api/todos/:id/complete +Authorization: Bearer +``` + +#### Defer Todo +```bash +POST /api/todos/:id/defer +Authorization: Bearer +Content-Type: application/json + +{ + "show_from": "2024-12-25T00:00:00Z" +} +``` + +#### Add Dependency +```bash +POST /api/todos/:id/dependencies +Authorization: Bearer +Content-Type: application/json + +{ + "successor_id": 5 +} +``` + +This creates a dependency where todo :id blocks todo 5. + +### Projects + +#### List Projects +```bash +GET /api/projects?state=active +Authorization: Bearer +``` + +#### Create Project +```bash +POST /api/projects +Authorization: Bearer +Content-Type: application/json + +{ + "name": "Home Renovation", + "description": "Renovate the kitchen and bathroom", + "default_context_id": 1 +} +``` + +#### Complete Project +```bash +POST /api/projects/:id/complete +Authorization: Bearer +``` + +#### Get Project Stats +```bash +GET /api/projects/:id/stats +Authorization: Bearer +``` + +### Contexts + +#### List Contexts +```bash +GET /api/contexts?state=active +Authorization: Bearer +``` + +#### Create Context +```bash +POST /api/contexts +Authorization: Bearer +Content-Type: application/json + +{ + "name": "@home" +} +``` + +#### Hide Context +```bash +POST /api/contexts/:id/hide +Authorization: Bearer +``` + +## Database Schema + +### Main Tables + +- **users**: User accounts and authentication +- **preferences**: User preferences and settings +- **contexts**: GTD contexts (@home, @work, etc.) +- **projects**: Project groupings +- **todos**: Individual tasks/actions +- **recurring_todos**: Templates for recurring tasks +- **tags**: Tag labels +- **taggings**: Polymorphic tag assignments +- **dependencies**: Todo dependencies +- **notes**: Project notes +- **attachments**: File attachments for todos + +## Development + +### Building + +```bash +# Build the application +go build -o tracks ./cmd/tracks + +# Run tests +go test ./... + +# Run with hot reload (install air first: go install github.com/cosmtrek/air@latest) +air +``` + +### Code Structure + +The application follows clean architecture principles: + +- **Models**: Define database schema and basic methods +- **Services**: Contain business logic +- **Handlers**: Handle HTTP requests and responses +- **Middleware**: Authentication, logging, etc. + +### Adding New Features + +1. Define models in `internal/models/` +2. Create service in `internal/services/` +3. Create handler in `internal/handlers/` +4. Register routes in `cmd/tracks/main.go` + +## Deployment + +### Docker Production Deployment + +1. Update `docker-compose.yml` with production settings +2. Set strong passwords and secrets +3. Configure SSL/TLS termination (nginx, traefik, etc.) +4. Run: + +```bash +docker-compose up -d +``` + +### Binary Deployment + +1. Build for your platform: +```bash +CGO_ENABLED=1 go build -o tracks ./cmd/tracks +``` + +2. Create `.env` file with configuration +3. Run: +```bash +./tracks +``` + +## Differences from Original Ruby/Rails Version + +### Improvements +- **Performance**: Significantly faster due to Go's compiled nature +- **Memory Usage**: Lower memory footprint +- **Deployment**: Single binary, no Ruby runtime needed +- **Type Safety**: Compile-time type checking +- **Concurrency**: Better handling of concurrent requests + +### Current Limitations +- **Recurring Todos**: Not yet fully implemented +- **Email Integration**: Not yet implemented +- **Statistics**: Basic stats only (advanced analytics pending) +- **Import/Export**: Not yet implemented +- **Mobile Views**: Not yet implemented +- **Attachments**: Model defined but upload handling pending + +### Migration Path + +To migrate from the Ruby/Rails version: + +1. Export data from Rails app using YAML export +2. Create equivalent users in Go version +3. Import data using the import API (to be implemented) + +Or run both versions side-by-side during transition. + +## Contributing + +Contributions are welcome! Please: + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Add tests if applicable +5. Submit a pull request + +## License + +Same as the original Tracks project. + +## Support + +For issues and questions: +- GitHub Issues: https://github.com/TracksApp/tracks/issues +- Original Tracks: https://github.com/TracksApp/tracks + +## Acknowledgments + +This is a rewrite of the original Tracks application (https://github.com/TracksApp/tracks) created by the Tracks team. The original application is written in Ruby on Rails. diff --git a/cmd/tracks/main.go b/cmd/tracks/main.go new file mode 100644 index 00000000..9cf8b286 --- /dev/null +++ b/cmd/tracks/main.go @@ -0,0 +1,147 @@ +package main + +import ( + "fmt" + "log" + + "github.com/TracksApp/tracks/internal/config" + "github.com/TracksApp/tracks/internal/database" + "github.com/TracksApp/tracks/internal/handlers" + "github.com/TracksApp/tracks/internal/middleware" + "github.com/TracksApp/tracks/internal/services" + "github.com/gin-gonic/gin" +) + +func main() { + // Load configuration + cfg, err := config.Load() + if err != nil { + log.Fatal("Failed to load configuration:", err) + } + + // Initialize database + if err := database.Initialize(&cfg.Database); err != nil { + log.Fatal("Failed to initialize database:", err) + } + defer database.Close() + + // Run migrations + if err := database.AutoMigrate(); err != nil { + log.Fatal("Failed to run migrations:", err) + } + + // Set Gin mode + gin.SetMode(cfg.Server.Mode) + + // Create router + router := gin.Default() + + // Setup routes + setupRoutes(router, cfg) + + // Start server + addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port) + log.Printf("Starting Tracks server on %s", addr) + if err := router.Run(addr); err != nil { + log.Fatal("Failed to start server:", err) + } +} + +func setupRoutes(router *gin.Engine, cfg *config.Config) { + // Initialize services + authService := services.NewAuthService(cfg.Auth.JWTSecret) + todoService := services.NewTodoService() + projectService := services.NewProjectService() + contextService := services.NewContextService() + + // Initialize handlers + authHandler := handlers.NewAuthHandler(authService) + todoHandler := handlers.NewTodoHandler(todoService) + projectHandler := handlers.NewProjectHandler(projectService) + contextHandler := handlers.NewContextHandler(contextService) + + // Public routes + api := router.Group("/api") + { + // Health check + api.GET("/health", func(c *gin.Context) { + c.JSON(200, gin.H{"status": "ok"}) + }) + + // Auth routes + auth := api.Group("/auth") + { + auth.POST("/login", authHandler.Login) + auth.POST("/register", authHandler.Register) + auth.POST("/logout", authHandler.Logout) + } + } + + // Protected routes + protected := api.Group("") + protected.Use(middleware.AuthMiddleware(cfg.Auth.JWTSecret)) + { + // User routes + protected.GET("/me", authHandler.Me) + protected.POST("/refresh-token", authHandler.RefreshToken) + + // Todo routes + todos := protected.Group("/todos") + { + todos.GET("", todoHandler.ListTodos) + todos.POST("", todoHandler.CreateTodo) + todos.GET("/:id", todoHandler.GetTodo) + todos.PUT("/:id", todoHandler.UpdateTodo) + todos.DELETE("/:id", todoHandler.DeleteTodo) + todos.POST("/:id/complete", todoHandler.CompleteTodo) + todos.POST("/:id/activate", todoHandler.ActivateTodo) + todos.POST("/:id/defer", todoHandler.DeferTodo) + todos.POST("/:id/dependencies", todoHandler.AddDependency) + todos.DELETE("/:id/dependencies/:successor_id", todoHandler.RemoveDependency) + } + + // Project routes + projects := protected.Group("/projects") + { + projects.GET("", projectHandler.ListProjects) + projects.POST("", projectHandler.CreateProject) + projects.GET("/:id", projectHandler.GetProject) + projects.PUT("/:id", projectHandler.UpdateProject) + projects.DELETE("/:id", projectHandler.DeleteProject) + projects.POST("/:id/complete", projectHandler.CompleteProject) + projects.POST("/:id/activate", projectHandler.ActivateProject) + projects.POST("/:id/hide", projectHandler.HideProject) + projects.POST("/:id/review", projectHandler.MarkReviewed) + projects.GET("/:id/stats", projectHandler.GetProjectStats) + } + + // Context routes + contexts := protected.Group("/contexts") + { + contexts.GET("", contextHandler.ListContexts) + contexts.POST("", contextHandler.CreateContext) + contexts.GET("/:id", contextHandler.GetContext) + contexts.PUT("/:id", contextHandler.UpdateContext) + contexts.DELETE("/:id", contextHandler.DeleteContext) + contexts.POST("/:id/hide", contextHandler.HideContext) + contexts.POST("/:id/activate", contextHandler.ActivateContext) + contexts.POST("/:id/close", contextHandler.CloseContext) + contexts.GET("/:id/stats", contextHandler.GetContextStats) + } + } + + // CORS middleware for development + router.Use(func(c *gin.Context) { + c.Writer.Header().Set("Access-Control-Allow-Origin", "*") + c.Writer.Header().Set("Access-Control-Allow-Credentials", "true") + c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With") + c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE, PATCH") + + if c.Request.Method == "OPTIONS" { + c.AbortWithStatus(204) + return + } + + c.Next() + }) +} diff --git a/docker-compose.yml b/docker-compose.yml index 9f2916f5..88b58ec0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,29 +1,45 @@ -version: '3' +version: '3.8' + services: - db: - image: mariadb:lts - environment: - MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: 1 - MARIADB_DATABASE: ${TRACKS_DB:-tracks} - volumes: - - db-data:/var/lib/mysql - web: + tracks: build: context: . - target: production # can also be development or test - environment: - # These are set in script/ci-build, so we need to pass-thru them. - RAILS_ENV: $RAILS_ENV - DATABASE_NAME: $DATABASE_NAME - DATABASE_USERNAME: root - DATABASE_PASSWORD_EMPTY: 1 - volumes: - - ${VOLUME:-.}:/app:Z - - ${VOLUME:-.}/config/database.docker.yml:/app/config/database.yml:Z - - ${VOLUME:-.}/config/site.docker.yml:/app/config/site.yml:Z + dockerfile: Dockerfile + container_name: tracks-go ports: - - 3000:3000 + - "3000:3000" + environment: + - SERVER_HOST=0.0.0.0 + - SERVER_PORT=3000 + - GIN_MODE=release + - DB_DRIVER=postgres + - DB_HOST=postgres + - DB_PORT=5432 + - DB_NAME=tracks + - DB_USER=tracks + - DB_PASSWORD=tracks + - DB_SSLMODE=disable + - JWT_SECRET=change-this-in-production + - OPEN_SIGNUPS=true + volumes: + - ./data:/app/data + - ./uploads:/app/uploads depends_on: - - db + - postgres + restart: unless-stopped + + postgres: + image: postgres:16-alpine + container_name: tracks-postgres + environment: + - POSTGRES_DB=tracks + - POSTGRES_USER=tracks + - POSTGRES_PASSWORD=tracks + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "5432:5432" + restart: unless-stopped + volumes: - db-data: + postgres_data: diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..a7c210ce --- /dev/null +++ b/go.mod @@ -0,0 +1,50 @@ +module github.com/TracksApp/tracks + +go 1.21 + +require ( + github.com/gin-gonic/gin v1.9.1 + github.com/golang-jwt/jwt/v5 v5.2.0 + github.com/joho/godotenv v1.5.1 + github.com/lib/pq v1.10.9 + github.com/go-sql-driver/mysql v1.7.1 + github.com/mattn/go-sqlite3 v1.14.18 + golang.org/x/crypto v0.17.0 + gorm.io/driver/mysql v1.5.2 + gorm.io/driver/postgres v1.5.4 + gorm.io/driver/sqlite v1.5.4 + gorm.io/gorm v1.25.5 + github.com/google/uuid v1.5.0 + gopkg.in/yaml.v3 v3.0.1 + github.com/robfig/cron/v3 v3.0.1 +) + +require ( + github.com/bytedance/sonic v1.9.1 // indirect + github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect + github.com/gabriel-vasile/mimetype v1.4.2 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.14.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/pgx/v5 v5.4.3 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.4 // indirect + github.com/leodido/go-urn v1.2.4 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.0.8 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.11 // indirect + golang.org/x/arch v0.3.0 // indirect + golang.org/x/net v0.10.0 // indirect + golang.org/x/sys v0.15.0 // indirect + golang.org/x/text v0.14.0 // indirect + google.golang.org/protobuf v1.30.0 // indirect +) diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 00000000..b80ed68f --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/database/database.go b/internal/database/database.go new file mode 100644 index 00000000..f3fc0d8c --- /dev/null +++ b/internal/database/database.go @@ -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 +} diff --git a/internal/handlers/auth_handler.go b/internal/handlers/auth_handler.go new file mode 100644 index 00000000..34e4b8b3 --- /dev/null +++ b/internal/handlers/auth_handler.go @@ -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}) +} diff --git a/internal/handlers/context_handler.go b/internal/handlers/context_handler.go new file mode 100644 index 00000000..fb3f5106 --- /dev/null +++ b/internal/handlers/context_handler.go @@ -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) +} diff --git a/internal/handlers/project_handler.go b/internal/handlers/project_handler.go new file mode 100644 index 00000000..43d14619 --- /dev/null +++ b/internal/handlers/project_handler.go @@ -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) +} diff --git a/internal/handlers/todo_handler.go b/internal/handlers/todo_handler.go new file mode 100644 index 00000000..44a79de1 --- /dev/null +++ b/internal/handlers/todo_handler.go @@ -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"}) +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go new file mode 100644 index 00000000..0d054cbb --- /dev/null +++ b/internal/middleware/auth.go @@ -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 +} diff --git a/internal/models/attachment.go b/internal/models/attachment.go new file mode 100644 index 00000000..cecb8ef9 --- /dev/null +++ b/internal/models/attachment.go @@ -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:"-"` +} diff --git a/internal/models/context.go b/internal/models/context.go new file mode 100644 index 00000000..d4f4ff06 --- /dev/null +++ b/internal/models/context.go @@ -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 +} diff --git a/internal/models/dependency.go b/internal/models/dependency.go new file mode 100644 index 00000000..f5e2f5fc --- /dev/null +++ b/internal/models/dependency.go @@ -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"` +} diff --git a/internal/models/note.go b/internal/models/note.go new file mode 100644 index 00000000..fd67ad2e --- /dev/null +++ b/internal/models/note.go @@ -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"` +} diff --git a/internal/models/preference.go b/internal/models/preference.go new file mode 100644 index 00000000..b05e9ef8 --- /dev/null +++ b/internal/models/preference.go @@ -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 +} diff --git a/internal/models/project.go b/internal/models/project.go new file mode 100644 index 00000000..01e1d137 --- /dev/null +++ b/internal/models/project.go @@ -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 +} diff --git a/internal/models/recurring_todo.go b/internal/models/recurring_todo.go new file mode 100644 index 00000000..4458c771 --- /dev/null +++ b/internal/models/recurring_todo.go @@ -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++ +} diff --git a/internal/models/tag.go b/internal/models/tag.go new file mode 100644 index 00000000..09e6a56f --- /dev/null +++ b/internal/models/tag.go @@ -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"` +} diff --git a/internal/models/todo.go b/internal/models/todo.go new file mode 100644 index 00000000..b8d63d5b --- /dev/null +++ b/internal/models/todo.go @@ -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) +} diff --git a/internal/models/user.go b/internal/models/user.go new file mode 100644 index 00000000..acd09863 --- /dev/null +++ b/internal/models/user.go @@ -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 +} diff --git a/internal/services/auth_service.go b/internal/services/auth_service.go new file mode 100644 index 00000000..f9d1f68d --- /dev/null +++ b/internal/services/auth_service.go @@ -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 +} diff --git a/internal/services/context_service.go b/internal/services/context_service.go new file mode 100644 index 00000000..18cea826 --- /dev/null +++ b/internal/services/context_service.go @@ -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 +} diff --git a/internal/services/project_service.go b/internal/services/project_service.go new file mode 100644 index 00000000..fe3c7180 --- /dev/null +++ b/internal/services/project_service.go @@ -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 +} diff --git a/internal/services/tag_service.go b/internal/services/tag_service.go new file mode 100644 index 00000000..737b5fc1 --- /dev/null +++ b/internal/services/tag_service.go @@ -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 +} diff --git a/internal/services/todo_service.go b/internal/services/todo_service.go new file mode 100644 index 00000000..6eac9fff --- /dev/null +++ b/internal/services/todo_service.go @@ -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 +}