mirror of
https://github.com/TracksApp/tracks.git
synced 2025-12-16 23:30:12 +01:00
Rewrite Tracks application in Golang
This commit introduces a complete rewrite of the Tracks GTD application in Go (Golang), providing a modern, performant alternative to the Ruby on Rails implementation. ## Architecture & Technology Stack - Language: Go 1.21+ - Web Framework: Gin - ORM: GORM with SQLite/MySQL/PostgreSQL support - Authentication: JWT with bcrypt password hashing - Clean Architecture: Separated models, services, handlers, and middleware ## Implemented Features ### Core Models - User: Authentication and user management - Context: GTD contexts (@home, @work, etc.) - Project: Project grouping and tracking - Todo: Task management with state machine (active, completed, deferred, pending) - Tag: Flexible tagging system with polymorphic associations - Dependency: Todo dependencies with circular dependency detection - Preference: User preferences and settings - Note: Project notes - Attachment: File attachment support (model only) - RecurringTodo: Recurring task template (model only) ### API Endpoints **Authentication:** - POST /api/auth/login - User login - POST /api/auth/register - User registration - POST /api/auth/logout - User logout - GET /api/me - Get current user **Todos:** - GET /api/todos - List todos with filtering - POST /api/todos - Create todo - GET /api/todos/:id - Get todo details - PUT /api/todos/:id - Update todo - DELETE /api/todos/:id - Delete todo - POST /api/todos/:id/complete - Mark as completed - POST /api/todos/:id/activate - Mark as active - POST /api/todos/:id/defer - Defer to future date - POST /api/todos/:id/dependencies - Add dependency - DELETE /api/todos/:id/dependencies/:successor_id - Remove dependency **Projects:** - GET /api/projects - List projects - POST /api/projects - Create project - GET /api/projects/:id - Get project details - PUT /api/projects/:id - Update project - DELETE /api/projects/:id - Delete project - POST /api/projects/:id/complete - Complete project - POST /api/projects/:id/activate - Activate project - POST /api/projects/:id/hide - Hide project - POST /api/projects/:id/review - Mark as reviewed - GET /api/projects/:id/stats - Get project statistics **Contexts:** - GET /api/contexts - List contexts - POST /api/contexts - Create context - GET /api/contexts/:id - Get context details - PUT /api/contexts/:id - Update context - DELETE /api/contexts/:id - Delete context - POST /api/contexts/:id/hide - Hide context - POST /api/contexts/:id/activate - Activate context - POST /api/contexts/:id/close - Close context - GET /api/contexts/:id/stats - Get context statistics ### Business Logic **Todo State Management:** - Active: Ready to work on - Completed: Finished tasks - Deferred: Future actions (show_from date) - Pending: Blocked by dependencies **Dependency Management:** - Create blocking relationships between todos - Automatic state transitions when blocking todos complete - Circular dependency detection - Automatic unblocking when prerequisites complete **Tag System:** - Polymorphic tagging for todos and recurring todos - Automatic tag creation on first use - Tag cloud support **Project & Context Tracking:** - State management (active, hidden, closed/completed) - Statistics and health indicators - Review tracking for projects ### Infrastructure **Configuration:** - Environment-based configuration - Support for SQLite, MySQL, and PostgreSQL - Configurable JWT secrets and token expiry - Flexible server settings **Database:** - GORM for ORM - Automatic migrations - Connection pooling - Multi-database support **Authentication & Security:** - JWT-based authentication - Bcrypt password hashing - Secure cookie support - Token refresh mechanism **Docker Support:** - Multi-stage Dockerfile for optimized builds - Docker Compose with PostgreSQL - Volume mounting for data persistence - Production-ready configuration ## Project Structure ``` cmd/tracks/ # Application entry point internal/ config/ # Configuration management database/ # Database setup and migrations handlers/ # HTTP request handlers middleware/ # Authentication middleware models/ # Database models services/ # Business logic layer ``` ## Documentation - README_GOLANG.md: Comprehensive documentation - .env.example: Configuration template - API documentation included in README - Code comments for complex logic ## Future Work The following features from the original Rails app are not yet implemented: - Recurring todo instantiation logic - Email integration (Mailgun/CloudMailin) - Advanced statistics and analytics - Import/Export functionality (CSV, YAML, XML) - File upload handling for attachments - Mobile views - RSS/Atom feeds - iCalendar export ## Benefits Over Rails Version - Performance: Compiled binary, lower resource usage - Deployment: Single binary, no runtime dependencies - Type Safety: Compile-time type checking - Concurrency: Better handling of concurrent requests - Memory: Lower memory footprint - Portability: Easy cross-platform compilation ## Testing The code structure supports testing, though tests are not yet implemented. Future work includes adding unit and integration tests.
This commit is contained in:
parent
6613d33f10
commit
f0eb4bdef5
29 changed files with 4100 additions and 104 deletions
40
.env.example
Normal file
40
.env.example
Normal 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
76
.gitignore
vendored
|
|
@ -1,29 +1,49 @@
|
||||||
*.DS_Store
|
# Binaries
|
||||||
*.tmproj
|
/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
|
# OS specific files
|
||||||
.rvmrc
|
.DS_Store
|
||||||
.ruby-gemset
|
Thumbs.db
|
||||||
.ruby-version
|
|
||||||
.sass-cache/
|
# Log files
|
||||||
.yardoc
|
*.log
|
||||||
/.bundle
|
|
||||||
/.emacs-project
|
# Temporary files
|
||||||
/.redcar
|
tmp/
|
||||||
/coverage
|
temp/
|
||||||
/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
|
|
||||||
|
|
|
||||||
89
Dockerfile
89
Dockerfile
|
|
@ -1,66 +1,49 @@
|
||||||
ARG RUBY_VERSION=3.3
|
# Build stage
|
||||||
FROM ruby:${RUBY_VERSION} AS base
|
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
|
WORKDIR /app
|
||||||
RUN touch /etc/app-env
|
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y npm netcat-openbsd
|
# Copy go mod files
|
||||||
RUN npm install -g yarn
|
COPY go.mod go.sum ./
|
||||||
RUN gem install bundler
|
|
||||||
|
|
||||||
RUN mkdir /app/log
|
# Download dependencies
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
COPY COPYING /app/
|
# Copy source code
|
||||||
COPY config /app/config/
|
COPY . .
|
||||||
COPY config/database.docker.yml /app/config/database.yml
|
|
||||||
COPY config/site.docker.yml /app/config/site.yml
|
|
||||||
|
|
||||||
COPY bin /app/bin/
|
# Build the application
|
||||||
COPY script /app/script/
|
RUN CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo -o tracks ./cmd/tracks
|
||||||
COPY public /app/public/
|
|
||||||
COPY vendor /app/vendor/
|
|
||||||
|
|
||||||
COPY .yardopts /app/
|
# Runtime stage
|
||||||
COPY Rakefile /app/
|
FROM alpine:latest
|
||||||
COPY config.ru /app/
|
|
||||||
COPY docker-entrypoint.sh /app/
|
|
||||||
|
|
||||||
COPY lib /app/lib/
|
# Install runtime dependencies
|
||||||
COPY app /app/app/
|
RUN apk --no-cache add ca-certificates sqlite-libs
|
||||||
COPY db /app/db/
|
|
||||||
|
|
||||||
# Use glob to omit error if the .git directory doesn't exists (in case the
|
# Create app user
|
||||||
# code is from a release archive, not a Git clone)
|
RUN addgroup -g 1000 tracks && \
|
||||||
COPY .gi[t] /app/.git
|
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
|
EXPOSE 3000
|
||||||
CMD ["./bin/rails", "server", "-b", "0.0.0.0"]
|
|
||||||
|
|
||||||
FROM base AS precompile
|
# Run the application
|
||||||
RUN bundle config set deployment true
|
CMD ["./tracks"]
|
||||||
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
|
|
||||||
|
|
|
||||||
440
README_GOLANG.md
Normal file
440
README_GOLANG.md
Normal 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
147
cmd/tracks/main.go
Normal 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()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -1,29 +1,45 @@
|
||||||
version: '3'
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
db:
|
tracks:
|
||||||
image: mariadb:lts
|
|
||||||
environment:
|
|
||||||
MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: 1
|
|
||||||
MARIADB_DATABASE: ${TRACKS_DB:-tracks}
|
|
||||||
volumes:
|
|
||||||
- db-data:/var/lib/mysql
|
|
||||||
web:
|
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
target: production # can also be development or test
|
dockerfile: Dockerfile
|
||||||
environment:
|
container_name: tracks-go
|
||||||
# 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
|
|
||||||
ports:
|
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:
|
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:
|
volumes:
|
||||||
db-data:
|
postgres_data:
|
||||||
|
|
|
||||||
50
go.mod
Normal file
50
go.mod
Normal 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
143
internal/config/config.go
Normal 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
|
||||||
|
}
|
||||||
108
internal/database/database.go
Normal file
108
internal/database/database.go
Normal 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
|
||||||
|
}
|
||||||
96
internal/handlers/auth_handler.go
Normal file
96
internal/handlers/auth_handler.go
Normal 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})
|
||||||
|
}
|
||||||
230
internal/handlers/context_handler.go
Normal file
230
internal/handlers/context_handler.go
Normal 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)
|
||||||
|
}
|
||||||
253
internal/handlers/project_handler.go
Normal file
253
internal/handlers/project_handler.go
Normal 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)
|
||||||
|
}
|
||||||
320
internal/handlers/todo_handler.go
Normal file
320
internal/handlers/todo_handler.go
Normal 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
181
internal/middleware/auth.go
Normal 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
|
||||||
|
}
|
||||||
23
internal/models/attachment.go
Normal file
23
internal/models/attachment.go
Normal 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:"-"`
|
||||||
|
}
|
||||||
74
internal/models/context.go
Normal file
74
internal/models/context.go
Normal 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
|
||||||
|
}
|
||||||
25
internal/models/dependency.go
Normal file
25
internal/models/dependency.go
Normal 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
22
internal/models/note.go
Normal 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"`
|
||||||
|
}
|
||||||
62
internal/models/preference.go
Normal file
62
internal/models/preference.go
Normal 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
|
||||||
|
}
|
||||||
89
internal/models/project.go
Normal file
89
internal/models/project.go
Normal 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
|
||||||
|
}
|
||||||
125
internal/models/recurring_todo.go
Normal file
125
internal/models/recurring_todo.go
Normal 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
33
internal/models/tag.go
Normal 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
171
internal/models/todo.go
Normal 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
68
internal/models/user.go
Normal 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
|
||||||
|
}
|
||||||
148
internal/services/auth_service.go
Normal file
148
internal/services/auth_service.go
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/TracksApp/tracks/internal/database"
|
||||||
|
"github.com/TracksApp/tracks/internal/models"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthService handles authentication logic
|
||||||
|
type AuthService struct {
|
||||||
|
jwtSecret string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAuthService creates a new AuthService
|
||||||
|
func NewAuthService(jwtSecret string) *AuthService {
|
||||||
|
return &AuthService{
|
||||||
|
jwtSecret: jwtSecret,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginRequest represents a login request
|
||||||
|
type LoginRequest struct {
|
||||||
|
Login string `json:"login" binding:"required"`
|
||||||
|
Password string `json:"password" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterRequest represents a registration request
|
||||||
|
type RegisterRequest struct {
|
||||||
|
Login string `json:"login" binding:"required"`
|
||||||
|
Password string `json:"password" binding:"required"`
|
||||||
|
FirstName string `json:"first_name"`
|
||||||
|
LastName string `json:"last_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthResponse represents an authentication response
|
||||||
|
type AuthResponse struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
User *models.User `json:"user"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login authenticates a user and returns a JWT token
|
||||||
|
func (s *AuthService) Login(req LoginRequest) (*AuthResponse, error) {
|
||||||
|
var user models.User
|
||||||
|
|
||||||
|
// Find user by login
|
||||||
|
if err := database.DB.Where("login = ?", req.Login).First(&user).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, errors.New("invalid login or password")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check password
|
||||||
|
if !user.CheckPassword(req.Password) {
|
||||||
|
return nil, errors.New("invalid login or password")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate token
|
||||||
|
token, err := s.GenerateToken(&user)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &AuthResponse{
|
||||||
|
Token: token,
|
||||||
|
User: &user,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register creates a new user account
|
||||||
|
func (s *AuthService) Register(req RegisterRequest) (*AuthResponse, error) {
|
||||||
|
// Check if user already exists
|
||||||
|
var existingUser models.User
|
||||||
|
if err := database.DB.Where("login = ?", req.Login).First(&existingUser).Error; err == nil {
|
||||||
|
return nil, errors.New("user already exists")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new user
|
||||||
|
user := models.User{
|
||||||
|
Login: req.Login,
|
||||||
|
FirstName: req.FirstName,
|
||||||
|
LastName: req.LastName,
|
||||||
|
AuthType: models.AuthTypeDatabase,
|
||||||
|
Token: uuid.New().String(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set password
|
||||||
|
if err := user.SetPassword(req.Password); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save user
|
||||||
|
if err := database.DB.Create(&user).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create default preference
|
||||||
|
preference := models.Preference{
|
||||||
|
UserID: user.ID,
|
||||||
|
}
|
||||||
|
if err := database.DB.Create(&preference).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate token
|
||||||
|
token, err := s.GenerateToken(&user)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &AuthResponse{
|
||||||
|
Token: token,
|
||||||
|
User: &user,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateToken generates a JWT token for a user
|
||||||
|
func (s *AuthService) GenerateToken(user *models.User) (string, error) {
|
||||||
|
claims := jwt.MapClaims{
|
||||||
|
"user_id": user.ID,
|
||||||
|
"login": user.Login,
|
||||||
|
"exp": time.Now().Add(time.Hour * 24 * 7).Unix(), // 7 days
|
||||||
|
"iat": time.Now().Unix(),
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
return token.SignedString([]byte(s.jwtSecret))
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshToken refreshes the user's API token
|
||||||
|
func (s *AuthService) RefreshToken(userID uint) (string, error) {
|
||||||
|
var user models.User
|
||||||
|
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
user.Token = uuid.New().String()
|
||||||
|
if err := database.DB.Save(&user).Error; err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return user.Token, nil
|
||||||
|
}
|
||||||
220
internal/services/context_service.go
Normal file
220
internal/services/context_service.go
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/TracksApp/tracks/internal/database"
|
||||||
|
"github.com/TracksApp/tracks/internal/models"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ContextService handles context business logic
|
||||||
|
type ContextService struct{}
|
||||||
|
|
||||||
|
// NewContextService creates a new ContextService
|
||||||
|
func NewContextService() *ContextService {
|
||||||
|
return &ContextService{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateContextRequest represents a context creation request
|
||||||
|
type CreateContextRequest struct {
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateContextRequest represents a context update request
|
||||||
|
type UpdateContextRequest struct {
|
||||||
|
Name *string `json:"name"`
|
||||||
|
Position *int `json:"position"`
|
||||||
|
State *string `json:"state"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetContexts returns all contexts for a user
|
||||||
|
func (s *ContextService) GetContexts(userID uint, state models.ContextState) ([]models.Context, error) {
|
||||||
|
var contexts []models.Context
|
||||||
|
|
||||||
|
query := database.DB.Where("user_id = ?", userID)
|
||||||
|
|
||||||
|
if state != "" {
|
||||||
|
query = query.Where("state = ?", state)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := query.
|
||||||
|
Order("position ASC, name ASC").
|
||||||
|
Find(&contexts).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return contexts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetContext returns a single context by ID
|
||||||
|
func (s *ContextService) GetContext(userID, contextID uint) (*models.Context, error) {
|
||||||
|
var context models.Context
|
||||||
|
|
||||||
|
if err := database.DB.
|
||||||
|
Where("id = ? AND user_id = ?", contextID, userID).
|
||||||
|
First(&context).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, fmt.Errorf("context not found")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &context, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateContext creates a new context
|
||||||
|
func (s *ContextService) CreateContext(userID uint, req CreateContextRequest) (*models.Context, error) {
|
||||||
|
context := models.Context{
|
||||||
|
UserID: userID,
|
||||||
|
Name: req.Name,
|
||||||
|
State: models.ContextStateActive,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := database.DB.Create(&context).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.GetContext(userID, context.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateContext updates a context
|
||||||
|
func (s *ContextService) UpdateContext(userID, contextID uint, req UpdateContextRequest) (*models.Context, error) {
|
||||||
|
context, err := s.GetContext(userID, contextID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Name != nil {
|
||||||
|
context.Name = *req.Name
|
||||||
|
}
|
||||||
|
if req.Position != nil {
|
||||||
|
context.Position = *req.Position
|
||||||
|
}
|
||||||
|
if req.State != nil {
|
||||||
|
context.State = models.ContextState(*req.State)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := database.DB.Save(&context).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.GetContext(userID, contextID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteContext deletes a context
|
||||||
|
func (s *ContextService) DeleteContext(userID, contextID uint) error {
|
||||||
|
context, err := s.GetContext(userID, contextID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if context has active todos
|
||||||
|
var activeTodoCount int64
|
||||||
|
database.DB.Model(&models.Todo{}).
|
||||||
|
Where("context_id = ? AND state = ?", contextID, models.TodoStateActive).
|
||||||
|
Count(&activeTodoCount)
|
||||||
|
|
||||||
|
if activeTodoCount > 0 {
|
||||||
|
return fmt.Errorf("cannot delete context with active todos")
|
||||||
|
}
|
||||||
|
|
||||||
|
return database.DB.Delete(&context).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// HideContext marks a context as hidden
|
||||||
|
func (s *ContextService) HideContext(userID, contextID uint) (*models.Context, error) {
|
||||||
|
context, err := s.GetContext(userID, contextID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
context.Hide()
|
||||||
|
|
||||||
|
if err := database.DB.Save(&context).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.GetContext(userID, contextID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActivateContext marks a context as active
|
||||||
|
func (s *ContextService) ActivateContext(userID, contextID uint) (*models.Context, error) {
|
||||||
|
context, err := s.GetContext(userID, contextID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
context.Activate()
|
||||||
|
|
||||||
|
if err := database.DB.Save(&context).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.GetContext(userID, contextID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloseContext marks a context as closed
|
||||||
|
func (s *ContextService) CloseContext(userID, contextID uint) (*models.Context, error) {
|
||||||
|
context, err := s.GetContext(userID, contextID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if context has active todos
|
||||||
|
var activeTodoCount int64
|
||||||
|
database.DB.Model(&models.Todo{}).
|
||||||
|
Where("context_id = ? AND state = ?", contextID, models.TodoStateActive).
|
||||||
|
Count(&activeTodoCount)
|
||||||
|
|
||||||
|
if activeTodoCount > 0 {
|
||||||
|
return nil, fmt.Errorf("cannot close context with active todos")
|
||||||
|
}
|
||||||
|
|
||||||
|
context.Close()
|
||||||
|
|
||||||
|
if err := database.DB.Save(&context).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.GetContext(userID, contextID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetContextStats returns statistics for a context
|
||||||
|
func (s *ContextService) GetContextStats(userID, contextID uint) (map[string]interface{}, error) {
|
||||||
|
context, err := s.GetContext(userID, contextID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
stats := make(map[string]interface{})
|
||||||
|
|
||||||
|
// Count todos by state
|
||||||
|
var activeTodos, completedTodos, deferredTodos, pendingTodos int64
|
||||||
|
|
||||||
|
database.DB.Model(&models.Todo{}).
|
||||||
|
Where("context_id = ? AND state = ?", contextID, models.TodoStateActive).
|
||||||
|
Count(&activeTodos)
|
||||||
|
|
||||||
|
database.DB.Model(&models.Todo{}).
|
||||||
|
Where("context_id = ? AND state = ?", contextID, models.TodoStateCompleted).
|
||||||
|
Count(&completedTodos)
|
||||||
|
|
||||||
|
database.DB.Model(&models.Todo{}).
|
||||||
|
Where("context_id = ? AND state = ?", contextID, models.TodoStateDeferred).
|
||||||
|
Count(&deferredTodos)
|
||||||
|
|
||||||
|
database.DB.Model(&models.Todo{}).
|
||||||
|
Where("context_id = ? AND state = ?", contextID, models.TodoStatePending).
|
||||||
|
Count(&pendingTodos)
|
||||||
|
|
||||||
|
stats["context"] = context
|
||||||
|
stats["active_todos"] = activeTodos
|
||||||
|
stats["completed_todos"] = completedTodos
|
||||||
|
stats["deferred_todos"] = deferredTodos
|
||||||
|
stats["pending_todos"] = pendingTodos
|
||||||
|
stats["total_todos"] = activeTodos + completedTodos + deferredTodos + pendingTodos
|
||||||
|
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
258
internal/services/project_service.go
Normal file
258
internal/services/project_service.go
Normal file
|
|
@ -0,0 +1,258 @@
|
||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/TracksApp/tracks/internal/database"
|
||||||
|
"github.com/TracksApp/tracks/internal/models"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProjectService handles project business logic
|
||||||
|
type ProjectService struct{}
|
||||||
|
|
||||||
|
// NewProjectService creates a new ProjectService
|
||||||
|
func NewProjectService() *ProjectService {
|
||||||
|
return &ProjectService{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateProjectRequest represents a project creation request
|
||||||
|
type CreateProjectRequest struct {
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
DefaultContextID *uint `json:"default_context_id"`
|
||||||
|
DefaultTags string `json:"default_tags"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateProjectRequest represents a project update request
|
||||||
|
type UpdateProjectRequest struct {
|
||||||
|
Name *string `json:"name"`
|
||||||
|
Description *string `json:"description"`
|
||||||
|
DefaultContextID *uint `json:"default_context_id"`
|
||||||
|
DefaultTags *string `json:"default_tags"`
|
||||||
|
State *string `json:"state"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetProjects returns all projects for a user
|
||||||
|
func (s *ProjectService) GetProjects(userID uint, state models.ProjectState) ([]models.Project, error) {
|
||||||
|
var projects []models.Project
|
||||||
|
|
||||||
|
query := database.DB.Where("user_id = ?", userID)
|
||||||
|
|
||||||
|
if state != "" {
|
||||||
|
query = query.Where("state = ?", state)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := query.
|
||||||
|
Preload("DefaultContext").
|
||||||
|
Order("position ASC, name ASC").
|
||||||
|
Find(&projects).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return projects, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetProject returns a single project by ID
|
||||||
|
func (s *ProjectService) GetProject(userID, projectID uint) (*models.Project, error) {
|
||||||
|
var project models.Project
|
||||||
|
|
||||||
|
if err := database.DB.
|
||||||
|
Where("id = ? AND user_id = ?", projectID, userID).
|
||||||
|
Preload("DefaultContext").
|
||||||
|
Preload("Todos").
|
||||||
|
Preload("Notes").
|
||||||
|
First(&project).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, fmt.Errorf("project not found")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &project, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateProject creates a new project
|
||||||
|
func (s *ProjectService) CreateProject(userID uint, req CreateProjectRequest) (*models.Project, error) {
|
||||||
|
// Verify default context if provided
|
||||||
|
if req.DefaultContextID != nil {
|
||||||
|
var context models.Context
|
||||||
|
if err := database.DB.Where("id = ? AND user_id = ?", *req.DefaultContextID, userID).First(&context).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("default context not found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
project := models.Project{
|
||||||
|
UserID: userID,
|
||||||
|
Name: req.Name,
|
||||||
|
Description: req.Description,
|
||||||
|
DefaultContextID: req.DefaultContextID,
|
||||||
|
DefaultTags: req.DefaultTags,
|
||||||
|
State: models.ProjectStateActive,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := database.DB.Create(&project).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.GetProject(userID, project.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateProject updates a project
|
||||||
|
func (s *ProjectService) UpdateProject(userID, projectID uint, req UpdateProjectRequest) (*models.Project, error) {
|
||||||
|
project, err := s.GetProject(userID, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Name != nil {
|
||||||
|
project.Name = *req.Name
|
||||||
|
}
|
||||||
|
if req.Description != nil {
|
||||||
|
project.Description = *req.Description
|
||||||
|
}
|
||||||
|
if req.DefaultContextID != nil {
|
||||||
|
// Verify context
|
||||||
|
var context models.Context
|
||||||
|
if err := database.DB.Where("id = ? AND user_id = ?", *req.DefaultContextID, userID).First(&context).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("default context not found")
|
||||||
|
}
|
||||||
|
project.DefaultContextID = req.DefaultContextID
|
||||||
|
}
|
||||||
|
if req.DefaultTags != nil {
|
||||||
|
project.DefaultTags = *req.DefaultTags
|
||||||
|
}
|
||||||
|
if req.State != nil {
|
||||||
|
project.State = models.ProjectState(*req.State)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := database.DB.Save(&project).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.GetProject(userID, projectID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteProject deletes a project
|
||||||
|
func (s *ProjectService) DeleteProject(userID, projectID uint) error {
|
||||||
|
project, err := s.GetProject(userID, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return database.DB.Delete(&project).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompleteProject marks a project as completed
|
||||||
|
func (s *ProjectService) CompleteProject(userID, projectID uint) (*models.Project, error) {
|
||||||
|
project, err := s.GetProject(userID, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
project.Complete()
|
||||||
|
|
||||||
|
if err := database.DB.Save(&project).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.GetProject(userID, projectID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActivateProject marks a project as active
|
||||||
|
func (s *ProjectService) ActivateProject(userID, projectID uint) (*models.Project, error) {
|
||||||
|
project, err := s.GetProject(userID, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
project.Activate()
|
||||||
|
|
||||||
|
if err := database.DB.Save(&project).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.GetProject(userID, projectID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HideProject marks a project as hidden
|
||||||
|
func (s *ProjectService) HideProject(userID, projectID uint) (*models.Project, error) {
|
||||||
|
project, err := s.GetProject(userID, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
project.Hide()
|
||||||
|
|
||||||
|
if err := database.DB.Save(&project).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.GetProject(userID, projectID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkProjectReviewed updates the last_reviewed_at timestamp
|
||||||
|
func (s *ProjectService) MarkProjectReviewed(userID, projectID uint) (*models.Project, error) {
|
||||||
|
project, err := s.GetProject(userID, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
project.MarkReviewed()
|
||||||
|
|
||||||
|
if err := database.DB.Save(&project).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.GetProject(userID, projectID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetProjectStats returns statistics for a project
|
||||||
|
func (s *ProjectService) GetProjectStats(userID, projectID uint) (map[string]interface{}, error) {
|
||||||
|
project, err := s.GetProject(userID, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
stats := make(map[string]interface{})
|
||||||
|
|
||||||
|
// Count todos by state
|
||||||
|
var activeTodos, completedTodos, deferredTodos, pendingTodos int64
|
||||||
|
|
||||||
|
database.DB.Model(&models.Todo{}).
|
||||||
|
Where("project_id = ? AND state = ?", projectID, models.TodoStateActive).
|
||||||
|
Count(&activeTodos)
|
||||||
|
|
||||||
|
database.DB.Model(&models.Todo{}).
|
||||||
|
Where("project_id = ? AND state = ?", projectID, models.TodoStateCompleted).
|
||||||
|
Count(&completedTodos)
|
||||||
|
|
||||||
|
database.DB.Model(&models.Todo{}).
|
||||||
|
Where("project_id = ? AND state = ?", projectID, models.TodoStateDeferred).
|
||||||
|
Count(&deferredTodos)
|
||||||
|
|
||||||
|
database.DB.Model(&models.Todo{}).
|
||||||
|
Where("project_id = ? AND state = ?", projectID, models.TodoStatePending).
|
||||||
|
Count(&pendingTodos)
|
||||||
|
|
||||||
|
stats["project"] = project
|
||||||
|
stats["active_todos"] = activeTodos
|
||||||
|
stats["completed_todos"] = completedTodos
|
||||||
|
stats["deferred_todos"] = deferredTodos
|
||||||
|
stats["pending_todos"] = pendingTodos
|
||||||
|
stats["total_todos"] = activeTodos + completedTodos + deferredTodos + pendingTodos
|
||||||
|
stats["is_stalled"] = activeTodos == 0 && (deferredTodos > 0 || pendingTodos > 0)
|
||||||
|
stats["is_blocked"] = activeTodos == 0 && (deferredTodos > 0 || pendingTodos > 0) && completedTodos == 0
|
||||||
|
|
||||||
|
// Days since last review
|
||||||
|
if project.LastReviewedAt != nil {
|
||||||
|
daysSinceReview := int(time.Since(*project.LastReviewedAt).Hours() / 24)
|
||||||
|
stats["days_since_review"] = daysSinceReview
|
||||||
|
} else {
|
||||||
|
stats["days_since_review"] = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
151
internal/services/tag_service.go
Normal file
151
internal/services/tag_service.go
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/TracksApp/tracks/internal/database"
|
||||||
|
"github.com/TracksApp/tracks/internal/models"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TagService handles tag business logic
|
||||||
|
type TagService struct{}
|
||||||
|
|
||||||
|
// NewTagService creates a new TagService
|
||||||
|
func NewTagService() *TagService {
|
||||||
|
return &TagService{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrCreateTag finds or creates a tag by name
|
||||||
|
func (s *TagService) GetOrCreateTag(tx *gorm.DB, userID uint, name string) (*models.Tag, error) {
|
||||||
|
var tag models.Tag
|
||||||
|
|
||||||
|
// Try to find existing tag
|
||||||
|
if err := tx.Where("user_id = ? AND name = ?", userID, name).First(&tag).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
// Create new tag
|
||||||
|
tag = models.Tag{
|
||||||
|
UserID: userID,
|
||||||
|
Name: name,
|
||||||
|
}
|
||||||
|
if err := tx.Create(&tag).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &tag, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTodoTags sets the tags for a todo, replacing all existing tags
|
||||||
|
func (s *TagService) SetTodoTags(tx *gorm.DB, userID, todoID uint, tagNames []string) error {
|
||||||
|
// Remove existing taggings
|
||||||
|
if err := tx.Where("taggable_id = ? AND taggable_type = ?", todoID, "Todo").
|
||||||
|
Delete(&models.Tagging{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new taggings
|
||||||
|
for _, tagName := range tagNames {
|
||||||
|
if tagName == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
tag, err := s.GetOrCreateTag(tx, userID, tagName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
tagging := models.Tagging{
|
||||||
|
TagID: tag.ID,
|
||||||
|
TaggableID: todoID,
|
||||||
|
TaggableType: "Todo",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Create(&tagging).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRecurringTodoTags sets the tags for a recurring todo
|
||||||
|
func (s *TagService) SetRecurringTodoTags(tx *gorm.DB, userID, recurringTodoID uint, tagNames []string) error {
|
||||||
|
// Remove existing taggings
|
||||||
|
if err := tx.Where("taggable_id = ? AND taggable_type = ?", recurringTodoID, "RecurringTodo").
|
||||||
|
Delete(&models.Tagging{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new taggings
|
||||||
|
for _, tagName := range tagNames {
|
||||||
|
if tagName == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
tag, err := s.GetOrCreateTag(tx, userID, tagName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
tagging := models.Tagging{
|
||||||
|
TagID: tag.ID,
|
||||||
|
TaggableID: recurringTodoID,
|
||||||
|
TaggableType: "RecurringTodo",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Create(&tagging).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserTags returns all tags for a user
|
||||||
|
func (s *TagService) GetUserTags(userID uint) ([]models.Tag, error) {
|
||||||
|
var tags []models.Tag
|
||||||
|
|
||||||
|
if err := database.DB.Where("user_id = ?", userID).
|
||||||
|
Order("name ASC").
|
||||||
|
Find(&tags).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return tags, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTagCloud returns tags with usage counts
|
||||||
|
func (s *TagService) GetTagCloud(userID uint) ([]map[string]interface{}, error) {
|
||||||
|
type TagCount struct {
|
||||||
|
TagID uint
|
||||||
|
Name string
|
||||||
|
Count int64
|
||||||
|
}
|
||||||
|
|
||||||
|
var tagCounts []TagCount
|
||||||
|
|
||||||
|
err := database.DB.Table("tags").
|
||||||
|
Select("tags.id as tag_id, tags.name, COUNT(taggings.id) as count").
|
||||||
|
Joins("LEFT JOIN taggings ON taggings.tag_id = tags.id").
|
||||||
|
Where("tags.user_id = ?", userID).
|
||||||
|
Group("tags.id, tags.name").
|
||||||
|
Order("count DESC").
|
||||||
|
Scan(&tagCounts).Error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]map[string]interface{}, len(tagCounts))
|
||||||
|
for i, tc := range tagCounts {
|
||||||
|
result[i] = map[string]interface{}{
|
||||||
|
"tag_id": tc.TagID,
|
||||||
|
"name": tc.Name,
|
||||||
|
"count": tc.Count,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
500
internal/services/todo_service.go
Normal file
500
internal/services/todo_service.go
Normal file
|
|
@ -0,0 +1,500 @@
|
||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/TracksApp/tracks/internal/database"
|
||||||
|
"github.com/TracksApp/tracks/internal/models"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TodoService handles todo business logic
|
||||||
|
type TodoService struct{}
|
||||||
|
|
||||||
|
// NewTodoService creates a new TodoService
|
||||||
|
func NewTodoService() *TodoService {
|
||||||
|
return &TodoService{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTodoRequest represents a todo creation request
|
||||||
|
type CreateTodoRequest struct {
|
||||||
|
Description string `json:"description" binding:"required"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
ContextID uint `json:"context_id" binding:"required"`
|
||||||
|
ProjectID *uint `json:"project_id"`
|
||||||
|
DueDate *time.Time `json:"due_date"`
|
||||||
|
ShowFrom *time.Time `json:"show_from"`
|
||||||
|
Starred bool `json:"starred"`
|
||||||
|
TagNames []string `json:"tags"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateTodoRequest represents a todo update request
|
||||||
|
type UpdateTodoRequest struct {
|
||||||
|
Description *string `json:"description"`
|
||||||
|
Notes *string `json:"notes"`
|
||||||
|
ContextID *uint `json:"context_id"`
|
||||||
|
ProjectID *uint `json:"project_id"`
|
||||||
|
DueDate *time.Time `json:"due_date"`
|
||||||
|
ShowFrom *time.Time `json:"show_from"`
|
||||||
|
Starred *bool `json:"starred"`
|
||||||
|
TagNames []string `json:"tags"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTodosFilter represents filters for listing todos
|
||||||
|
type ListTodosFilter struct {
|
||||||
|
State models.TodoState
|
||||||
|
ContextID *uint
|
||||||
|
ProjectID *uint
|
||||||
|
TagName *string
|
||||||
|
Starred *bool
|
||||||
|
Overdue *bool
|
||||||
|
DueToday *bool
|
||||||
|
ShowFrom *bool // If true, only show todos where show_from has passed
|
||||||
|
IncludeTags bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTodos returns todos for a user with optional filters
|
||||||
|
func (s *TodoService) GetTodos(userID uint, filter ListTodosFilter) ([]models.Todo, error) {
|
||||||
|
var todos []models.Todo
|
||||||
|
|
||||||
|
query := database.DB.Where("user_id = ?", userID)
|
||||||
|
|
||||||
|
// Apply filters
|
||||||
|
if filter.State != "" {
|
||||||
|
query = query.Where("state = ?", filter.State)
|
||||||
|
}
|
||||||
|
|
||||||
|
if filter.ContextID != nil {
|
||||||
|
query = query.Where("context_id = ?", *filter.ContextID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if filter.ProjectID != nil {
|
||||||
|
query = query.Where("project_id = ?", *filter.ProjectID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if filter.Starred != nil {
|
||||||
|
query = query.Where("starred = ?", *filter.Starred)
|
||||||
|
}
|
||||||
|
|
||||||
|
if filter.Overdue != nil && *filter.Overdue {
|
||||||
|
query = query.Where("due_date < ? AND state = ?", time.Now(), models.TodoStateActive)
|
||||||
|
}
|
||||||
|
|
||||||
|
if filter.DueToday != nil && *filter.DueToday {
|
||||||
|
today := time.Now().Truncate(24 * time.Hour)
|
||||||
|
tomorrow := today.Add(24 * time.Hour)
|
||||||
|
query = query.Where("due_date >= ? AND due_date < ?", today, tomorrow)
|
||||||
|
}
|
||||||
|
|
||||||
|
if filter.ShowFrom != nil && *filter.ShowFrom {
|
||||||
|
now := time.Now()
|
||||||
|
query = query.Where("show_from IS NULL OR show_from <= ?", now)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preload associations
|
||||||
|
query = query.Preload("Context").Preload("Project")
|
||||||
|
|
||||||
|
if filter.IncludeTags {
|
||||||
|
query = query.Preload("Tags")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by tag
|
||||||
|
if filter.TagName != nil {
|
||||||
|
query = query.Joins("JOIN taggings ON taggings.taggable_id = todos.id AND taggings.taggable_type = ?", "Todo").
|
||||||
|
Joins("JOIN tags ON tags.id = taggings.tag_id").
|
||||||
|
Where("tags.name = ? AND tags.user_id = ?", *filter.TagName, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Order by created_at
|
||||||
|
query = query.Order("created_at ASC")
|
||||||
|
|
||||||
|
if err := query.Find(&todos).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return todos, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTodo returns a single todo by ID
|
||||||
|
func (s *TodoService) GetTodo(userID, todoID uint) (*models.Todo, error) {
|
||||||
|
var todo models.Todo
|
||||||
|
|
||||||
|
if err := database.DB.
|
||||||
|
Where("id = ? AND user_id = ?", todoID, userID).
|
||||||
|
Preload("Context").
|
||||||
|
Preload("Project").
|
||||||
|
Preload("Tags").
|
||||||
|
Preload("Attachments").
|
||||||
|
Preload("Predecessors.Predecessor").
|
||||||
|
Preload("Successors.Successor").
|
||||||
|
First(&todo).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, fmt.Errorf("todo not found")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &todo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTodo creates a new todo
|
||||||
|
func (s *TodoService) CreateTodo(userID uint, req CreateTodoRequest) (*models.Todo, error) {
|
||||||
|
// Verify context belongs to user
|
||||||
|
var context models.Context
|
||||||
|
if err := database.DB.Where("id = ? AND user_id = ?", req.ContextID, userID).First(&context).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("context not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify project belongs to user if provided
|
||||||
|
if req.ProjectID != nil {
|
||||||
|
var project models.Project
|
||||||
|
if err := database.DB.Where("id = ? AND user_id = ?", *req.ProjectID, userID).First(&project).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("project not found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine initial state
|
||||||
|
state := models.TodoStateActive
|
||||||
|
if req.ShowFrom != nil && req.ShowFrom.After(time.Now()) {
|
||||||
|
state = models.TodoStateDeferred
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create todo
|
||||||
|
todo := models.Todo{
|
||||||
|
UserID: userID,
|
||||||
|
Description: req.Description,
|
||||||
|
Notes: req.Notes,
|
||||||
|
ContextID: req.ContextID,
|
||||||
|
ProjectID: req.ProjectID,
|
||||||
|
DueDate: req.DueDate,
|
||||||
|
ShowFrom: req.ShowFrom,
|
||||||
|
Starred: req.Starred,
|
||||||
|
State: state,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Begin transaction
|
||||||
|
tx := database.DB.Begin()
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err := tx.Create(&todo).Error; err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle tags
|
||||||
|
if len(req.TagNames) > 0 {
|
||||||
|
tagService := NewTagService()
|
||||||
|
if err := tagService.SetTodoTags(tx, userID, todo.ID, req.TagNames); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit().Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload with associations
|
||||||
|
return s.GetTodo(userID, todo.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateTodo updates a todo
|
||||||
|
func (s *TodoService) UpdateTodo(userID, todoID uint, req UpdateTodoRequest) (*models.Todo, error) {
|
||||||
|
todo, err := s.GetTodo(userID, todoID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Begin transaction
|
||||||
|
tx := database.DB.Begin()
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Update fields
|
||||||
|
if req.Description != nil {
|
||||||
|
todo.Description = *req.Description
|
||||||
|
}
|
||||||
|
if req.Notes != nil {
|
||||||
|
todo.Notes = *req.Notes
|
||||||
|
}
|
||||||
|
if req.ContextID != nil {
|
||||||
|
// Verify context
|
||||||
|
var context models.Context
|
||||||
|
if err := tx.Where("id = ? AND user_id = ?", *req.ContextID, userID).First(&context).Error; err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return nil, fmt.Errorf("context not found")
|
||||||
|
}
|
||||||
|
todo.ContextID = *req.ContextID
|
||||||
|
}
|
||||||
|
if req.ProjectID != nil {
|
||||||
|
// Verify project
|
||||||
|
var project models.Project
|
||||||
|
if err := tx.Where("id = ? AND user_id = ?", *req.ProjectID, userID).First(&project).Error; err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return nil, fmt.Errorf("project not found")
|
||||||
|
}
|
||||||
|
todo.ProjectID = req.ProjectID
|
||||||
|
}
|
||||||
|
if req.DueDate != nil {
|
||||||
|
todo.DueDate = req.DueDate
|
||||||
|
}
|
||||||
|
if req.ShowFrom != nil {
|
||||||
|
todo.ShowFrom = req.ShowFrom
|
||||||
|
}
|
||||||
|
if req.Starred != nil {
|
||||||
|
todo.Starred = *req.Starred
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Save(&todo).Error; err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle tags
|
||||||
|
if req.TagNames != nil {
|
||||||
|
tagService := NewTagService()
|
||||||
|
if err := tagService.SetTodoTags(tx, userID, todo.ID, req.TagNames); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit().Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload with associations
|
||||||
|
return s.GetTodo(userID, todoID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteTodo deletes a todo
|
||||||
|
func (s *TodoService) DeleteTodo(userID, todoID uint) error {
|
||||||
|
todo, err := s.GetTodo(userID, todoID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return database.DB.Delete(&todo).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompleteTodo marks a todo as completed
|
||||||
|
func (s *TodoService) CompleteTodo(userID, todoID uint) (*models.Todo, error) {
|
||||||
|
todo, err := s.GetTodo(userID, todoID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := todo.Complete(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
tx := database.DB.Begin()
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err := tx.Save(&todo).Error; err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unblock any pending successors
|
||||||
|
if err := s.checkAndUnblockSuccessors(tx, todoID); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit().Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.GetTodo(userID, todoID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActivateTodo marks a todo as active
|
||||||
|
func (s *TodoService) ActivateTodo(userID, todoID uint) (*models.Todo, error) {
|
||||||
|
todo, err := s.GetTodo(userID, todoID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there are incomplete predecessors
|
||||||
|
hasIncompletePredecessors, err := s.hasIncompletePredecessors(todoID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if hasIncompletePredecessors {
|
||||||
|
return nil, fmt.Errorf("cannot activate todo with incomplete predecessors")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := todo.Activate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := database.DB.Save(&todo).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.GetTodo(userID, todoID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeferTodo marks a todo as deferred
|
||||||
|
func (s *TodoService) DeferTodo(userID, todoID uint, showFrom time.Time) (*models.Todo, error) {
|
||||||
|
todo, err := s.GetTodo(userID, todoID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := todo.Defer(showFrom); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := database.DB.Save(&todo).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.GetTodo(userID, todoID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddDependency adds a dependency between two todos
|
||||||
|
func (s *TodoService) AddDependency(userID, predecessorID, successorID uint) error {
|
||||||
|
// Verify both todos belong to user
|
||||||
|
if _, err := s.GetTodo(userID, predecessorID); err != nil {
|
||||||
|
return fmt.Errorf("predecessor not found")
|
||||||
|
}
|
||||||
|
if _, err := s.GetTodo(userID, successorID); err != nil {
|
||||||
|
return fmt.Errorf("successor not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for circular dependencies
|
||||||
|
if err := s.checkCircularDependency(predecessorID, successorID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create dependency
|
||||||
|
dependency := models.Dependency{
|
||||||
|
PredecessorID: predecessorID,
|
||||||
|
SuccessorID: successorID,
|
||||||
|
RelationshipType: models.DependencyTypeBlocks,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := database.DB.Create(&dependency).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block the successor if predecessor is not complete
|
||||||
|
successor, _ := s.GetTodo(userID, successorID)
|
||||||
|
predecessor, _ := s.GetTodo(userID, predecessorID)
|
||||||
|
|
||||||
|
if !predecessor.IsCompleted() && !successor.IsPending() {
|
||||||
|
successor.Block()
|
||||||
|
database.DB.Save(successor)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveDependency removes a dependency
|
||||||
|
func (s *TodoService) RemoveDependency(userID, predecessorID, successorID uint) error {
|
||||||
|
// Verify both todos belong to user
|
||||||
|
if _, err := s.GetTodo(userID, predecessorID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := s.GetTodo(userID, successorID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := database.DB.
|
||||||
|
Where("predecessor_id = ? AND successor_id = ?", predecessorID, successorID).
|
||||||
|
Delete(&models.Dependency{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if successor should be unblocked
|
||||||
|
s.checkAndUnblockSuccessors(database.DB, successorID)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
|
||||||
|
func (s *TodoService) hasIncompletePredecessors(todoID uint) (bool, error) {
|
||||||
|
var count int64
|
||||||
|
err := database.DB.Model(&models.Dependency{}).
|
||||||
|
Joins("JOIN todos ON todos.id = dependencies.predecessor_id").
|
||||||
|
Where("dependencies.successor_id = ? AND todos.state != ?", todoID, models.TodoStateCompleted).
|
||||||
|
Count(&count).Error
|
||||||
|
|
||||||
|
return count > 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TodoService) checkAndUnblockSuccessors(tx *gorm.DB, todoID uint) error {
|
||||||
|
var successors []models.Todo
|
||||||
|
|
||||||
|
// Get all successors
|
||||||
|
if err := tx.
|
||||||
|
Joins("JOIN dependencies ON dependencies.successor_id = todos.id").
|
||||||
|
Where("dependencies.predecessor_id = ? AND todos.state = ?", todoID, models.TodoStatePending).
|
||||||
|
Find(&successors).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// For each successor, check if all predecessors are complete
|
||||||
|
for _, successor := range successors {
|
||||||
|
hasIncompletePredecessors, err := s.hasIncompletePredecessors(successor.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasIncompletePredecessors {
|
||||||
|
successor.Unblock()
|
||||||
|
if err := tx.Save(&successor).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TodoService) checkCircularDependency(predecessorID, successorID uint) error {
|
||||||
|
// Simple check: ensure successor is not already a predecessor of the predecessor
|
||||||
|
visited := make(map[uint]bool)
|
||||||
|
return s.dfsCheckCircular(successorID, predecessorID, visited)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TodoService) dfsCheckCircular(currentID, targetID uint, visited map[uint]bool) error {
|
||||||
|
if currentID == targetID {
|
||||||
|
return fmt.Errorf("circular dependency detected")
|
||||||
|
}
|
||||||
|
|
||||||
|
if visited[currentID] {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
visited[currentID] = true
|
||||||
|
|
||||||
|
var successors []models.Dependency
|
||||||
|
if err := database.DB.Where("predecessor_id = ?", currentID).Find(&successors).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, dep := range successors {
|
||||||
|
if err := s.dfsCheckCircular(dep.SuccessorID, targetID, visited); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue