Rewrite Tracks application in Golang

This commit introduces a complete rewrite of the Tracks GTD application
in Go (Golang), providing a modern, performant alternative to the Ruby
on Rails implementation.

## Architecture & Technology Stack

- Language: Go 1.21+
- Web Framework: Gin
- ORM: GORM with SQLite/MySQL/PostgreSQL support
- Authentication: JWT with bcrypt password hashing
- Clean Architecture: Separated models, services, handlers, and middleware

## Implemented Features

### Core Models
- User: Authentication and user management
- Context: GTD contexts (@home, @work, etc.)
- Project: Project grouping and tracking
- Todo: Task management with state machine (active, completed, deferred, pending)
- Tag: Flexible tagging system with polymorphic associations
- Dependency: Todo dependencies with circular dependency detection
- Preference: User preferences and settings
- Note: Project notes
- Attachment: File attachment support (model only)
- RecurringTodo: Recurring task template (model only)

### API Endpoints

**Authentication:**
- POST /api/auth/login - User login
- POST /api/auth/register - User registration
- POST /api/auth/logout - User logout
- GET /api/me - Get current user

**Todos:**
- GET /api/todos - List todos with filtering
- POST /api/todos - Create todo
- GET /api/todos/:id - Get todo details
- PUT /api/todos/:id - Update todo
- DELETE /api/todos/:id - Delete todo
- POST /api/todos/:id/complete - Mark as completed
- POST /api/todos/:id/activate - Mark as active
- POST /api/todos/:id/defer - Defer to future date
- POST /api/todos/:id/dependencies - Add dependency
- DELETE /api/todos/:id/dependencies/:successor_id - Remove dependency

**Projects:**
- GET /api/projects - List projects
- POST /api/projects - Create project
- GET /api/projects/:id - Get project details
- PUT /api/projects/:id - Update project
- DELETE /api/projects/:id - Delete project
- POST /api/projects/:id/complete - Complete project
- POST /api/projects/:id/activate - Activate project
- POST /api/projects/:id/hide - Hide project
- POST /api/projects/:id/review - Mark as reviewed
- GET /api/projects/:id/stats - Get project statistics

**Contexts:**
- GET /api/contexts - List contexts
- POST /api/contexts - Create context
- GET /api/contexts/:id - Get context details
- PUT /api/contexts/:id - Update context
- DELETE /api/contexts/:id - Delete context
- POST /api/contexts/:id/hide - Hide context
- POST /api/contexts/:id/activate - Activate context
- POST /api/contexts/:id/close - Close context
- GET /api/contexts/:id/stats - Get context statistics

### Business Logic

**Todo State Management:**
- Active: Ready to work on
- Completed: Finished tasks
- Deferred: Future actions (show_from date)
- Pending: Blocked by dependencies

**Dependency Management:**
- Create blocking relationships between todos
- Automatic state transitions when blocking todos complete
- Circular dependency detection
- Automatic unblocking when prerequisites complete

**Tag System:**
- Polymorphic tagging for todos and recurring todos
- Automatic tag creation on first use
- Tag cloud support

**Project & Context Tracking:**
- State management (active, hidden, closed/completed)
- Statistics and health indicators
- Review tracking for projects

### Infrastructure

**Configuration:**
- Environment-based configuration
- Support for SQLite, MySQL, and PostgreSQL
- Configurable JWT secrets and token expiry
- Flexible server settings

**Database:**
- GORM for ORM
- Automatic migrations
- Connection pooling
- Multi-database support

**Authentication & Security:**
- JWT-based authentication
- Bcrypt password hashing
- Secure cookie support
- Token refresh mechanism

**Docker Support:**
- Multi-stage Dockerfile for optimized builds
- Docker Compose with PostgreSQL
- Volume mounting for data persistence
- Production-ready configuration

## Project Structure

```
cmd/tracks/              # Application entry point
internal/
  config/               # Configuration management
  database/             # Database setup and migrations
  handlers/             # HTTP request handlers
  middleware/           # Authentication middleware
  models/              # Database models
  services/            # Business logic layer
```

## Documentation

- README_GOLANG.md: Comprehensive documentation
- .env.example: Configuration template
- API documentation included in README
- Code comments for complex logic

## Future Work

The following features from the original Rails app are not yet implemented:
- Recurring todo instantiation logic
- Email integration (Mailgun/CloudMailin)
- Advanced statistics and analytics
- Import/Export functionality (CSV, YAML, XML)
- File upload handling for attachments
- Mobile views
- RSS/Atom feeds
- iCalendar export

## Benefits Over Rails Version

- Performance: Compiled binary, lower resource usage
- Deployment: Single binary, no runtime dependencies
- Type Safety: Compile-time type checking
- Concurrency: Better handling of concurrent requests
- Memory: Lower memory footprint
- Portability: Easy cross-platform compilation

## Testing

The code structure supports testing, though tests are not yet implemented.
Future work includes adding unit and integration tests.
This commit is contained in:
Claude 2025-11-05 10:46:59 +00:00
parent 6613d33f10
commit f0eb4bdef5
No known key found for this signature in database
29 changed files with 4100 additions and 104 deletions

40
.env.example Normal file
View file

@ -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

76
.gitignore vendored
View file

@ -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/

View file

@ -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"]

440
README_GOLANG.md Normal file
View file

@ -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 <token>
```
### Todos
#### List Todos
```bash
GET /api/todos?state=active&context_id=1&include_tags=true
Authorization: Bearer <token>
```
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 <token>
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 <token>
Content-Type: application/json
{
"description": "Buy groceries and snacks",
"starred": true
}
```
#### Complete Todo
```bash
POST /api/todos/:id/complete
Authorization: Bearer <token>
```
#### Defer Todo
```bash
POST /api/todos/:id/defer
Authorization: Bearer <token>
Content-Type: application/json
{
"show_from": "2024-12-25T00:00:00Z"
}
```
#### Add Dependency
```bash
POST /api/todos/:id/dependencies
Authorization: Bearer <token>
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 <token>
```
#### Create Project
```bash
POST /api/projects
Authorization: Bearer <token>
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 <token>
```
#### Get Project Stats
```bash
GET /api/projects/:id/stats
Authorization: Bearer <token>
```
### Contexts
#### List Contexts
```bash
GET /api/contexts?state=active
Authorization: Bearer <token>
```
#### Create Context
```bash
POST /api/contexts
Authorization: Bearer <token>
Content-Type: application/json
{
"name": "@home"
}
```
#### Hide Context
```bash
POST /api/contexts/:id/hide
Authorization: Bearer <token>
```
## 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.

147
cmd/tracks/main.go Normal file
View file

@ -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()
})
}

View file

@ -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:

50
go.mod Normal file
View file

@ -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
)

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

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

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

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

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

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

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

View file

@ -0,0 +1,148 @@
package services
import (
"errors"
"time"
"github.com/TracksApp/tracks/internal/database"
"github.com/TracksApp/tracks/internal/models"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"gorm.io/gorm"
)
// AuthService handles authentication logic
type AuthService struct {
jwtSecret string
}
// NewAuthService creates a new AuthService
func NewAuthService(jwtSecret string) *AuthService {
return &AuthService{
jwtSecret: jwtSecret,
}
}
// LoginRequest represents a login request
type LoginRequest struct {
Login string `json:"login" binding:"required"`
Password string `json:"password" binding:"required"`
}
// RegisterRequest represents a registration request
type RegisterRequest struct {
Login string `json:"login" binding:"required"`
Password string `json:"password" binding:"required"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
}
// AuthResponse represents an authentication response
type AuthResponse struct {
Token string `json:"token"`
User *models.User `json:"user"`
}
// Login authenticates a user and returns a JWT token
func (s *AuthService) Login(req LoginRequest) (*AuthResponse, error) {
var user models.User
// Find user by login
if err := database.DB.Where("login = ?", req.Login).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("invalid login or password")
}
return nil, err
}
// Check password
if !user.CheckPassword(req.Password) {
return nil, errors.New("invalid login or password")
}
// Generate token
token, err := s.GenerateToken(&user)
if err != nil {
return nil, err
}
return &AuthResponse{
Token: token,
User: &user,
}, nil
}
// Register creates a new user account
func (s *AuthService) Register(req RegisterRequest) (*AuthResponse, error) {
// Check if user already exists
var existingUser models.User
if err := database.DB.Where("login = ?", req.Login).First(&existingUser).Error; err == nil {
return nil, errors.New("user already exists")
}
// Create new user
user := models.User{
Login: req.Login,
FirstName: req.FirstName,
LastName: req.LastName,
AuthType: models.AuthTypeDatabase,
Token: uuid.New().String(),
}
// Set password
if err := user.SetPassword(req.Password); err != nil {
return nil, err
}
// Save user
if err := database.DB.Create(&user).Error; err != nil {
return nil, err
}
// Create default preference
preference := models.Preference{
UserID: user.ID,
}
if err := database.DB.Create(&preference).Error; err != nil {
return nil, err
}
// Generate token
token, err := s.GenerateToken(&user)
if err != nil {
return nil, err
}
return &AuthResponse{
Token: token,
User: &user,
}, nil
}
// GenerateToken generates a JWT token for a user
func (s *AuthService) GenerateToken(user *models.User) (string, error) {
claims := jwt.MapClaims{
"user_id": user.ID,
"login": user.Login,
"exp": time.Now().Add(time.Hour * 24 * 7).Unix(), // 7 days
"iat": time.Now().Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(s.jwtSecret))
}
// RefreshToken refreshes the user's API token
func (s *AuthService) RefreshToken(userID uint) (string, error) {
var user models.User
if err := database.DB.First(&user, userID).Error; err != nil {
return "", err
}
user.Token = uuid.New().String()
if err := database.DB.Save(&user).Error; err != nil {
return "", err
}
return user.Token, nil
}

View file

@ -0,0 +1,220 @@
package services
import (
"errors"
"fmt"
"github.com/TracksApp/tracks/internal/database"
"github.com/TracksApp/tracks/internal/models"
"gorm.io/gorm"
)
// ContextService handles context business logic
type ContextService struct{}
// NewContextService creates a new ContextService
func NewContextService() *ContextService {
return &ContextService{}
}
// CreateContextRequest represents a context creation request
type CreateContextRequest struct {
Name string `json:"name" binding:"required"`
}
// UpdateContextRequest represents a context update request
type UpdateContextRequest struct {
Name *string `json:"name"`
Position *int `json:"position"`
State *string `json:"state"`
}
// GetContexts returns all contexts for a user
func (s *ContextService) GetContexts(userID uint, state models.ContextState) ([]models.Context, error) {
var contexts []models.Context
query := database.DB.Where("user_id = ?", userID)
if state != "" {
query = query.Where("state = ?", state)
}
if err := query.
Order("position ASC, name ASC").
Find(&contexts).Error; err != nil {
return nil, err
}
return contexts, nil
}
// GetContext returns a single context by ID
func (s *ContextService) GetContext(userID, contextID uint) (*models.Context, error) {
var context models.Context
if err := database.DB.
Where("id = ? AND user_id = ?", contextID, userID).
First(&context).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("context not found")
}
return nil, err
}
return &context, nil
}
// CreateContext creates a new context
func (s *ContextService) CreateContext(userID uint, req CreateContextRequest) (*models.Context, error) {
context := models.Context{
UserID: userID,
Name: req.Name,
State: models.ContextStateActive,
}
if err := database.DB.Create(&context).Error; err != nil {
return nil, err
}
return s.GetContext(userID, context.ID)
}
// UpdateContext updates a context
func (s *ContextService) UpdateContext(userID, contextID uint, req UpdateContextRequest) (*models.Context, error) {
context, err := s.GetContext(userID, contextID)
if err != nil {
return nil, err
}
if req.Name != nil {
context.Name = *req.Name
}
if req.Position != nil {
context.Position = *req.Position
}
if req.State != nil {
context.State = models.ContextState(*req.State)
}
if err := database.DB.Save(&context).Error; err != nil {
return nil, err
}
return s.GetContext(userID, contextID)
}
// DeleteContext deletes a context
func (s *ContextService) DeleteContext(userID, contextID uint) error {
context, err := s.GetContext(userID, contextID)
if err != nil {
return err
}
// Check if context has active todos
var activeTodoCount int64
database.DB.Model(&models.Todo{}).
Where("context_id = ? AND state = ?", contextID, models.TodoStateActive).
Count(&activeTodoCount)
if activeTodoCount > 0 {
return fmt.Errorf("cannot delete context with active todos")
}
return database.DB.Delete(&context).Error
}
// HideContext marks a context as hidden
func (s *ContextService) HideContext(userID, contextID uint) (*models.Context, error) {
context, err := s.GetContext(userID, contextID)
if err != nil {
return nil, err
}
context.Hide()
if err := database.DB.Save(&context).Error; err != nil {
return nil, err
}
return s.GetContext(userID, contextID)
}
// ActivateContext marks a context as active
func (s *ContextService) ActivateContext(userID, contextID uint) (*models.Context, error) {
context, err := s.GetContext(userID, contextID)
if err != nil {
return nil, err
}
context.Activate()
if err := database.DB.Save(&context).Error; err != nil {
return nil, err
}
return s.GetContext(userID, contextID)
}
// CloseContext marks a context as closed
func (s *ContextService) CloseContext(userID, contextID uint) (*models.Context, error) {
context, err := s.GetContext(userID, contextID)
if err != nil {
return nil, err
}
// Check if context has active todos
var activeTodoCount int64
database.DB.Model(&models.Todo{}).
Where("context_id = ? AND state = ?", contextID, models.TodoStateActive).
Count(&activeTodoCount)
if activeTodoCount > 0 {
return nil, fmt.Errorf("cannot close context with active todos")
}
context.Close()
if err := database.DB.Save(&context).Error; err != nil {
return nil, err
}
return s.GetContext(userID, contextID)
}
// GetContextStats returns statistics for a context
func (s *ContextService) GetContextStats(userID, contextID uint) (map[string]interface{}, error) {
context, err := s.GetContext(userID, contextID)
if err != nil {
return nil, err
}
stats := make(map[string]interface{})
// Count todos by state
var activeTodos, completedTodos, deferredTodos, pendingTodos int64
database.DB.Model(&models.Todo{}).
Where("context_id = ? AND state = ?", contextID, models.TodoStateActive).
Count(&activeTodos)
database.DB.Model(&models.Todo{}).
Where("context_id = ? AND state = ?", contextID, models.TodoStateCompleted).
Count(&completedTodos)
database.DB.Model(&models.Todo{}).
Where("context_id = ? AND state = ?", contextID, models.TodoStateDeferred).
Count(&deferredTodos)
database.DB.Model(&models.Todo{}).
Where("context_id = ? AND state = ?", contextID, models.TodoStatePending).
Count(&pendingTodos)
stats["context"] = context
stats["active_todos"] = activeTodos
stats["completed_todos"] = completedTodos
stats["deferred_todos"] = deferredTodos
stats["pending_todos"] = pendingTodos
stats["total_todos"] = activeTodos + completedTodos + deferredTodos + pendingTodos
return stats, nil
}

View file

@ -0,0 +1,258 @@
package services
import (
"errors"
"fmt"
"time"
"github.com/TracksApp/tracks/internal/database"
"github.com/TracksApp/tracks/internal/models"
"gorm.io/gorm"
)
// ProjectService handles project business logic
type ProjectService struct{}
// NewProjectService creates a new ProjectService
func NewProjectService() *ProjectService {
return &ProjectService{}
}
// CreateProjectRequest represents a project creation request
type CreateProjectRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
DefaultContextID *uint `json:"default_context_id"`
DefaultTags string `json:"default_tags"`
}
// UpdateProjectRequest represents a project update request
type UpdateProjectRequest struct {
Name *string `json:"name"`
Description *string `json:"description"`
DefaultContextID *uint `json:"default_context_id"`
DefaultTags *string `json:"default_tags"`
State *string `json:"state"`
}
// GetProjects returns all projects for a user
func (s *ProjectService) GetProjects(userID uint, state models.ProjectState) ([]models.Project, error) {
var projects []models.Project
query := database.DB.Where("user_id = ?", userID)
if state != "" {
query = query.Where("state = ?", state)
}
if err := query.
Preload("DefaultContext").
Order("position ASC, name ASC").
Find(&projects).Error; err != nil {
return nil, err
}
return projects, nil
}
// GetProject returns a single project by ID
func (s *ProjectService) GetProject(userID, projectID uint) (*models.Project, error) {
var project models.Project
if err := database.DB.
Where("id = ? AND user_id = ?", projectID, userID).
Preload("DefaultContext").
Preload("Todos").
Preload("Notes").
First(&project).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("project not found")
}
return nil, err
}
return &project, nil
}
// CreateProject creates a new project
func (s *ProjectService) CreateProject(userID uint, req CreateProjectRequest) (*models.Project, error) {
// Verify default context if provided
if req.DefaultContextID != nil {
var context models.Context
if err := database.DB.Where("id = ? AND user_id = ?", *req.DefaultContextID, userID).First(&context).Error; err != nil {
return nil, fmt.Errorf("default context not found")
}
}
project := models.Project{
UserID: userID,
Name: req.Name,
Description: req.Description,
DefaultContextID: req.DefaultContextID,
DefaultTags: req.DefaultTags,
State: models.ProjectStateActive,
}
if err := database.DB.Create(&project).Error; err != nil {
return nil, err
}
return s.GetProject(userID, project.ID)
}
// UpdateProject updates a project
func (s *ProjectService) UpdateProject(userID, projectID uint, req UpdateProjectRequest) (*models.Project, error) {
project, err := s.GetProject(userID, projectID)
if err != nil {
return nil, err
}
if req.Name != nil {
project.Name = *req.Name
}
if req.Description != nil {
project.Description = *req.Description
}
if req.DefaultContextID != nil {
// Verify context
var context models.Context
if err := database.DB.Where("id = ? AND user_id = ?", *req.DefaultContextID, userID).First(&context).Error; err != nil {
return nil, fmt.Errorf("default context not found")
}
project.DefaultContextID = req.DefaultContextID
}
if req.DefaultTags != nil {
project.DefaultTags = *req.DefaultTags
}
if req.State != nil {
project.State = models.ProjectState(*req.State)
}
if err := database.DB.Save(&project).Error; err != nil {
return nil, err
}
return s.GetProject(userID, projectID)
}
// DeleteProject deletes a project
func (s *ProjectService) DeleteProject(userID, projectID uint) error {
project, err := s.GetProject(userID, projectID)
if err != nil {
return err
}
return database.DB.Delete(&project).Error
}
// CompleteProject marks a project as completed
func (s *ProjectService) CompleteProject(userID, projectID uint) (*models.Project, error) {
project, err := s.GetProject(userID, projectID)
if err != nil {
return nil, err
}
project.Complete()
if err := database.DB.Save(&project).Error; err != nil {
return nil, err
}
return s.GetProject(userID, projectID)
}
// ActivateProject marks a project as active
func (s *ProjectService) ActivateProject(userID, projectID uint) (*models.Project, error) {
project, err := s.GetProject(userID, projectID)
if err != nil {
return nil, err
}
project.Activate()
if err := database.DB.Save(&project).Error; err != nil {
return nil, err
}
return s.GetProject(userID, projectID)
}
// HideProject marks a project as hidden
func (s *ProjectService) HideProject(userID, projectID uint) (*models.Project, error) {
project, err := s.GetProject(userID, projectID)
if err != nil {
return nil, err
}
project.Hide()
if err := database.DB.Save(&project).Error; err != nil {
return nil, err
}
return s.GetProject(userID, projectID)
}
// MarkProjectReviewed updates the last_reviewed_at timestamp
func (s *ProjectService) MarkProjectReviewed(userID, projectID uint) (*models.Project, error) {
project, err := s.GetProject(userID, projectID)
if err != nil {
return nil, err
}
project.MarkReviewed()
if err := database.DB.Save(&project).Error; err != nil {
return nil, err
}
return s.GetProject(userID, projectID)
}
// GetProjectStats returns statistics for a project
func (s *ProjectService) GetProjectStats(userID, projectID uint) (map[string]interface{}, error) {
project, err := s.GetProject(userID, projectID)
if err != nil {
return nil, err
}
stats := make(map[string]interface{})
// Count todos by state
var activeTodos, completedTodos, deferredTodos, pendingTodos int64
database.DB.Model(&models.Todo{}).
Where("project_id = ? AND state = ?", projectID, models.TodoStateActive).
Count(&activeTodos)
database.DB.Model(&models.Todo{}).
Where("project_id = ? AND state = ?", projectID, models.TodoStateCompleted).
Count(&completedTodos)
database.DB.Model(&models.Todo{}).
Where("project_id = ? AND state = ?", projectID, models.TodoStateDeferred).
Count(&deferredTodos)
database.DB.Model(&models.Todo{}).
Where("project_id = ? AND state = ?", projectID, models.TodoStatePending).
Count(&pendingTodos)
stats["project"] = project
stats["active_todos"] = activeTodos
stats["completed_todos"] = completedTodos
stats["deferred_todos"] = deferredTodos
stats["pending_todos"] = pendingTodos
stats["total_todos"] = activeTodos + completedTodos + deferredTodos + pendingTodos
stats["is_stalled"] = activeTodos == 0 && (deferredTodos > 0 || pendingTodos > 0)
stats["is_blocked"] = activeTodos == 0 && (deferredTodos > 0 || pendingTodos > 0) && completedTodos == 0
// Days since last review
if project.LastReviewedAt != nil {
daysSinceReview := int(time.Since(*project.LastReviewedAt).Hours() / 24)
stats["days_since_review"] = daysSinceReview
} else {
stats["days_since_review"] = nil
}
return stats, nil
}

View file

@ -0,0 +1,151 @@
package services
import (
"github.com/TracksApp/tracks/internal/database"
"github.com/TracksApp/tracks/internal/models"
"gorm.io/gorm"
)
// TagService handles tag business logic
type TagService struct{}
// NewTagService creates a new TagService
func NewTagService() *TagService {
return &TagService{}
}
// GetOrCreateTag finds or creates a tag by name
func (s *TagService) GetOrCreateTag(tx *gorm.DB, userID uint, name string) (*models.Tag, error) {
var tag models.Tag
// Try to find existing tag
if err := tx.Where("user_id = ? AND name = ?", userID, name).First(&tag).Error; err != nil {
if err == gorm.ErrRecordNotFound {
// Create new tag
tag = models.Tag{
UserID: userID,
Name: name,
}
if err := tx.Create(&tag).Error; err != nil {
return nil, err
}
} else {
return nil, err
}
}
return &tag, nil
}
// SetTodoTags sets the tags for a todo, replacing all existing tags
func (s *TagService) SetTodoTags(tx *gorm.DB, userID, todoID uint, tagNames []string) error {
// Remove existing taggings
if err := tx.Where("taggable_id = ? AND taggable_type = ?", todoID, "Todo").
Delete(&models.Tagging{}).Error; err != nil {
return err
}
// Add new taggings
for _, tagName := range tagNames {
if tagName == "" {
continue
}
tag, err := s.GetOrCreateTag(tx, userID, tagName)
if err != nil {
return err
}
tagging := models.Tagging{
TagID: tag.ID,
TaggableID: todoID,
TaggableType: "Todo",
}
if err := tx.Create(&tagging).Error; err != nil {
return err
}
}
return nil
}
// SetRecurringTodoTags sets the tags for a recurring todo
func (s *TagService) SetRecurringTodoTags(tx *gorm.DB, userID, recurringTodoID uint, tagNames []string) error {
// Remove existing taggings
if err := tx.Where("taggable_id = ? AND taggable_type = ?", recurringTodoID, "RecurringTodo").
Delete(&models.Tagging{}).Error; err != nil {
return err
}
// Add new taggings
for _, tagName := range tagNames {
if tagName == "" {
continue
}
tag, err := s.GetOrCreateTag(tx, userID, tagName)
if err != nil {
return err
}
tagging := models.Tagging{
TagID: tag.ID,
TaggableID: recurringTodoID,
TaggableType: "RecurringTodo",
}
if err := tx.Create(&tagging).Error; err != nil {
return err
}
}
return nil
}
// GetUserTags returns all tags for a user
func (s *TagService) GetUserTags(userID uint) ([]models.Tag, error) {
var tags []models.Tag
if err := database.DB.Where("user_id = ?", userID).
Order("name ASC").
Find(&tags).Error; err != nil {
return nil, err
}
return tags, nil
}
// GetTagCloud returns tags with usage counts
func (s *TagService) GetTagCloud(userID uint) ([]map[string]interface{}, error) {
type TagCount struct {
TagID uint
Name string
Count int64
}
var tagCounts []TagCount
err := database.DB.Table("tags").
Select("tags.id as tag_id, tags.name, COUNT(taggings.id) as count").
Joins("LEFT JOIN taggings ON taggings.tag_id = tags.id").
Where("tags.user_id = ?", userID).
Group("tags.id, tags.name").
Order("count DESC").
Scan(&tagCounts).Error
if err != nil {
return nil, err
}
result := make([]map[string]interface{}, len(tagCounts))
for i, tc := range tagCounts {
result[i] = map[string]interface{}{
"tag_id": tc.TagID,
"name": tc.Name,
"count": tc.Count,
}
}
return result, nil
}

View file

@ -0,0 +1,500 @@
package services
import (
"errors"
"fmt"
"time"
"github.com/TracksApp/tracks/internal/database"
"github.com/TracksApp/tracks/internal/models"
"gorm.io/gorm"
)
// TodoService handles todo business logic
type TodoService struct{}
// NewTodoService creates a new TodoService
func NewTodoService() *TodoService {
return &TodoService{}
}
// CreateTodoRequest represents a todo creation request
type CreateTodoRequest struct {
Description string `json:"description" binding:"required"`
Notes string `json:"notes"`
ContextID uint `json:"context_id" binding:"required"`
ProjectID *uint `json:"project_id"`
DueDate *time.Time `json:"due_date"`
ShowFrom *time.Time `json:"show_from"`
Starred bool `json:"starred"`
TagNames []string `json:"tags"`
}
// UpdateTodoRequest represents a todo update request
type UpdateTodoRequest struct {
Description *string `json:"description"`
Notes *string `json:"notes"`
ContextID *uint `json:"context_id"`
ProjectID *uint `json:"project_id"`
DueDate *time.Time `json:"due_date"`
ShowFrom *time.Time `json:"show_from"`
Starred *bool `json:"starred"`
TagNames []string `json:"tags"`
}
// ListTodosFilter represents filters for listing todos
type ListTodosFilter struct {
State models.TodoState
ContextID *uint
ProjectID *uint
TagName *string
Starred *bool
Overdue *bool
DueToday *bool
ShowFrom *bool // If true, only show todos where show_from has passed
IncludeTags bool
}
// GetTodos returns todos for a user with optional filters
func (s *TodoService) GetTodos(userID uint, filter ListTodosFilter) ([]models.Todo, error) {
var todos []models.Todo
query := database.DB.Where("user_id = ?", userID)
// Apply filters
if filter.State != "" {
query = query.Where("state = ?", filter.State)
}
if filter.ContextID != nil {
query = query.Where("context_id = ?", *filter.ContextID)
}
if filter.ProjectID != nil {
query = query.Where("project_id = ?", *filter.ProjectID)
}
if filter.Starred != nil {
query = query.Where("starred = ?", *filter.Starred)
}
if filter.Overdue != nil && *filter.Overdue {
query = query.Where("due_date < ? AND state = ?", time.Now(), models.TodoStateActive)
}
if filter.DueToday != nil && *filter.DueToday {
today := time.Now().Truncate(24 * time.Hour)
tomorrow := today.Add(24 * time.Hour)
query = query.Where("due_date >= ? AND due_date < ?", today, tomorrow)
}
if filter.ShowFrom != nil && *filter.ShowFrom {
now := time.Now()
query = query.Where("show_from IS NULL OR show_from <= ?", now)
}
// Preload associations
query = query.Preload("Context").Preload("Project")
if filter.IncludeTags {
query = query.Preload("Tags")
}
// Filter by tag
if filter.TagName != nil {
query = query.Joins("JOIN taggings ON taggings.taggable_id = todos.id AND taggings.taggable_type = ?", "Todo").
Joins("JOIN tags ON tags.id = taggings.tag_id").
Where("tags.name = ? AND tags.user_id = ?", *filter.TagName, userID)
}
// Order by created_at
query = query.Order("created_at ASC")
if err := query.Find(&todos).Error; err != nil {
return nil, err
}
return todos, nil
}
// GetTodo returns a single todo by ID
func (s *TodoService) GetTodo(userID, todoID uint) (*models.Todo, error) {
var todo models.Todo
if err := database.DB.
Where("id = ? AND user_id = ?", todoID, userID).
Preload("Context").
Preload("Project").
Preload("Tags").
Preload("Attachments").
Preload("Predecessors.Predecessor").
Preload("Successors.Successor").
First(&todo).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("todo not found")
}
return nil, err
}
return &todo, nil
}
// CreateTodo creates a new todo
func (s *TodoService) CreateTodo(userID uint, req CreateTodoRequest) (*models.Todo, error) {
// Verify context belongs to user
var context models.Context
if err := database.DB.Where("id = ? AND user_id = ?", req.ContextID, userID).First(&context).Error; err != nil {
return nil, fmt.Errorf("context not found")
}
// Verify project belongs to user if provided
if req.ProjectID != nil {
var project models.Project
if err := database.DB.Where("id = ? AND user_id = ?", *req.ProjectID, userID).First(&project).Error; err != nil {
return nil, fmt.Errorf("project not found")
}
}
// Determine initial state
state := models.TodoStateActive
if req.ShowFrom != nil && req.ShowFrom.After(time.Now()) {
state = models.TodoStateDeferred
}
// Create todo
todo := models.Todo{
UserID: userID,
Description: req.Description,
Notes: req.Notes,
ContextID: req.ContextID,
ProjectID: req.ProjectID,
DueDate: req.DueDate,
ShowFrom: req.ShowFrom,
Starred: req.Starred,
State: state,
}
// Begin transaction
tx := database.DB.Begin()
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
if err := tx.Create(&todo).Error; err != nil {
tx.Rollback()
return nil, err
}
// Handle tags
if len(req.TagNames) > 0 {
tagService := NewTagService()
if err := tagService.SetTodoTags(tx, userID, todo.ID, req.TagNames); err != nil {
tx.Rollback()
return nil, err
}
}
if err := tx.Commit().Error; err != nil {
return nil, err
}
// Reload with associations
return s.GetTodo(userID, todo.ID)
}
// UpdateTodo updates a todo
func (s *TodoService) UpdateTodo(userID, todoID uint, req UpdateTodoRequest) (*models.Todo, error) {
todo, err := s.GetTodo(userID, todoID)
if err != nil {
return nil, err
}
// Begin transaction
tx := database.DB.Begin()
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
// Update fields
if req.Description != nil {
todo.Description = *req.Description
}
if req.Notes != nil {
todo.Notes = *req.Notes
}
if req.ContextID != nil {
// Verify context
var context models.Context
if err := tx.Where("id = ? AND user_id = ?", *req.ContextID, userID).First(&context).Error; err != nil {
tx.Rollback()
return nil, fmt.Errorf("context not found")
}
todo.ContextID = *req.ContextID
}
if req.ProjectID != nil {
// Verify project
var project models.Project
if err := tx.Where("id = ? AND user_id = ?", *req.ProjectID, userID).First(&project).Error; err != nil {
tx.Rollback()
return nil, fmt.Errorf("project not found")
}
todo.ProjectID = req.ProjectID
}
if req.DueDate != nil {
todo.DueDate = req.DueDate
}
if req.ShowFrom != nil {
todo.ShowFrom = req.ShowFrom
}
if req.Starred != nil {
todo.Starred = *req.Starred
}
if err := tx.Save(&todo).Error; err != nil {
tx.Rollback()
return nil, err
}
// Handle tags
if req.TagNames != nil {
tagService := NewTagService()
if err := tagService.SetTodoTags(tx, userID, todo.ID, req.TagNames); err != nil {
tx.Rollback()
return nil, err
}
}
if err := tx.Commit().Error; err != nil {
return nil, err
}
// Reload with associations
return s.GetTodo(userID, todoID)
}
// DeleteTodo deletes a todo
func (s *TodoService) DeleteTodo(userID, todoID uint) error {
todo, err := s.GetTodo(userID, todoID)
if err != nil {
return err
}
return database.DB.Delete(&todo).Error
}
// CompleteTodo marks a todo as completed
func (s *TodoService) CompleteTodo(userID, todoID uint) (*models.Todo, error) {
todo, err := s.GetTodo(userID, todoID)
if err != nil {
return nil, err
}
if err := todo.Complete(); err != nil {
return nil, err
}
tx := database.DB.Begin()
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
if err := tx.Save(&todo).Error; err != nil {
tx.Rollback()
return nil, err
}
// Unblock any pending successors
if err := s.checkAndUnblockSuccessors(tx, todoID); err != nil {
tx.Rollback()
return nil, err
}
if err := tx.Commit().Error; err != nil {
return nil, err
}
return s.GetTodo(userID, todoID)
}
// ActivateTodo marks a todo as active
func (s *TodoService) ActivateTodo(userID, todoID uint) (*models.Todo, error) {
todo, err := s.GetTodo(userID, todoID)
if err != nil {
return nil, err
}
// Check if there are incomplete predecessors
hasIncompletePredecessors, err := s.hasIncompletePredecessors(todoID)
if err != nil {
return nil, err
}
if hasIncompletePredecessors {
return nil, fmt.Errorf("cannot activate todo with incomplete predecessors")
}
if err := todo.Activate(); err != nil {
return nil, err
}
if err := database.DB.Save(&todo).Error; err != nil {
return nil, err
}
return s.GetTodo(userID, todoID)
}
// DeferTodo marks a todo as deferred
func (s *TodoService) DeferTodo(userID, todoID uint, showFrom time.Time) (*models.Todo, error) {
todo, err := s.GetTodo(userID, todoID)
if err != nil {
return nil, err
}
if err := todo.Defer(showFrom); err != nil {
return nil, err
}
if err := database.DB.Save(&todo).Error; err != nil {
return nil, err
}
return s.GetTodo(userID, todoID)
}
// AddDependency adds a dependency between two todos
func (s *TodoService) AddDependency(userID, predecessorID, successorID uint) error {
// Verify both todos belong to user
if _, err := s.GetTodo(userID, predecessorID); err != nil {
return fmt.Errorf("predecessor not found")
}
if _, err := s.GetTodo(userID, successorID); err != nil {
return fmt.Errorf("successor not found")
}
// Check for circular dependencies
if err := s.checkCircularDependency(predecessorID, successorID); err != nil {
return err
}
// Create dependency
dependency := models.Dependency{
PredecessorID: predecessorID,
SuccessorID: successorID,
RelationshipType: models.DependencyTypeBlocks,
}
if err := database.DB.Create(&dependency).Error; err != nil {
return err
}
// Block the successor if predecessor is not complete
successor, _ := s.GetTodo(userID, successorID)
predecessor, _ := s.GetTodo(userID, predecessorID)
if !predecessor.IsCompleted() && !successor.IsPending() {
successor.Block()
database.DB.Save(successor)
}
return nil
}
// RemoveDependency removes a dependency
func (s *TodoService) RemoveDependency(userID, predecessorID, successorID uint) error {
// Verify both todos belong to user
if _, err := s.GetTodo(userID, predecessorID); err != nil {
return err
}
if _, err := s.GetTodo(userID, successorID); err != nil {
return err
}
if err := database.DB.
Where("predecessor_id = ? AND successor_id = ?", predecessorID, successorID).
Delete(&models.Dependency{}).Error; err != nil {
return err
}
// Check if successor should be unblocked
s.checkAndUnblockSuccessors(database.DB, successorID)
return nil
}
// Helper functions
func (s *TodoService) hasIncompletePredecessors(todoID uint) (bool, error) {
var count int64
err := database.DB.Model(&models.Dependency{}).
Joins("JOIN todos ON todos.id = dependencies.predecessor_id").
Where("dependencies.successor_id = ? AND todos.state != ?", todoID, models.TodoStateCompleted).
Count(&count).Error
return count > 0, err
}
func (s *TodoService) checkAndUnblockSuccessors(tx *gorm.DB, todoID uint) error {
var successors []models.Todo
// Get all successors
if err := tx.
Joins("JOIN dependencies ON dependencies.successor_id = todos.id").
Where("dependencies.predecessor_id = ? AND todos.state = ?", todoID, models.TodoStatePending).
Find(&successors).Error; err != nil {
return err
}
// For each successor, check if all predecessors are complete
for _, successor := range successors {
hasIncompletePredecessors, err := s.hasIncompletePredecessors(successor.ID)
if err != nil {
return err
}
if !hasIncompletePredecessors {
successor.Unblock()
if err := tx.Save(&successor).Error; err != nil {
return err
}
}
}
return nil
}
func (s *TodoService) checkCircularDependency(predecessorID, successorID uint) error {
// Simple check: ensure successor is not already a predecessor of the predecessor
visited := make(map[uint]bool)
return s.dfsCheckCircular(successorID, predecessorID, visited)
}
func (s *TodoService) dfsCheckCircular(currentID, targetID uint, visited map[uint]bool) error {
if currentID == targetID {
return fmt.Errorf("circular dependency detected")
}
if visited[currentID] {
return nil
}
visited[currentID] = true
var successors []models.Dependency
if err := database.DB.Where("predecessor_id = ?", currentID).Find(&successors).Error; err != nil {
return err
}
for _, dep := range successors {
if err := s.dfsCheckCircular(dep.SuccessorID, targetID, visited); err != nil {
return err
}
}
return nil
}