mirror of
https://github.com/TracksApp/tracks.git
synced 2025-12-16 15:20:13 +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
|
||||
*.tmproj
|
||||
# Binaries
|
||||
/tracks
|
||||
*.exe
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool
|
||||
*.out
|
||||
|
||||
# Dependency directories
|
||||
vendor/
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Database files
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Data directories
|
||||
/data/
|
||||
/uploads/
|
||||
|
||||
# IDE specific files
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.dotest
|
||||
.idea
|
||||
.rvmrc
|
||||
.ruby-gemset
|
||||
.ruby-version
|
||||
.sass-cache/
|
||||
.yardoc
|
||||
/.bundle
|
||||
/.emacs-project
|
||||
/.redcar
|
||||
/coverage
|
||||
/db/*.db
|
||||
/db/*.sqlite3
|
||||
/db/*.sqlite3-journal
|
||||
/db/assets/*
|
||||
/log/*.log
|
||||
/public/assets/
|
||||
/tmp
|
||||
config/deploy.rb
|
||||
config/site.yml
|
||||
config/database.yml
|
||||
db/data.yml
|
||||
nbproject
|
||||
rerun.txt
|
||||
tags
|
||||
.use-docker
|
||||
|
||||
# OS specific files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Log files
|
||||
*.log
|
||||
|
||||
# Temporary files
|
||||
tmp/
|
||||
temp/
|
||||
|
|
|
|||
89
Dockerfile
89
Dockerfile
|
|
@ -1,66 +1,49 @@
|
|||
ARG RUBY_VERSION=3.3
|
||||
FROM ruby:${RUBY_VERSION} AS base
|
||||
# Build stage
|
||||
FROM golang:1.21-alpine AS builder
|
||||
|
||||
# Install build dependencies
|
||||
RUN apk add --no-cache git gcc musl-dev sqlite-dev
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
RUN touch /etc/app-env
|
||||
|
||||
RUN apt-get update && apt-get install -y npm netcat-openbsd
|
||||
RUN npm install -g yarn
|
||||
RUN gem install bundler
|
||||
# Copy go mod files
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
RUN mkdir /app/log
|
||||
# Download dependencies
|
||||
RUN go mod download
|
||||
|
||||
COPY COPYING /app/
|
||||
COPY config /app/config/
|
||||
COPY config/database.docker.yml /app/config/database.yml
|
||||
COPY config/site.docker.yml /app/config/site.yml
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
COPY bin /app/bin/
|
||||
COPY script /app/script/
|
||||
COPY public /app/public/
|
||||
COPY vendor /app/vendor/
|
||||
# Build the application
|
||||
RUN CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo -o tracks ./cmd/tracks
|
||||
|
||||
COPY .yardopts /app/
|
||||
COPY Rakefile /app/
|
||||
COPY config.ru /app/
|
||||
COPY docker-entrypoint.sh /app/
|
||||
# Runtime stage
|
||||
FROM alpine:latest
|
||||
|
||||
COPY lib /app/lib/
|
||||
COPY app /app/app/
|
||||
COPY db /app/db/
|
||||
# Install runtime dependencies
|
||||
RUN apk --no-cache add ca-certificates sqlite-libs
|
||||
|
||||
# Use glob to omit error if the .git directory doesn't exists (in case the
|
||||
# code is from a release archive, not a Git clone)
|
||||
COPY .gi[t] /app/.git
|
||||
# Create app user
|
||||
RUN addgroup -g 1000 tracks && \
|
||||
adduser -D -u 1000 -G tracks tracks
|
||||
|
||||
COPY Gemfile* /app/
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
ENTRYPOINT ["/app/docker-entrypoint.sh"]
|
||||
# Copy binary from builder
|
||||
COPY --from=builder /app/tracks .
|
||||
|
||||
# Create data directory
|
||||
RUN mkdir -p /app/data /app/uploads && \
|
||||
chown -R tracks:tracks /app
|
||||
|
||||
# Switch to non-root user
|
||||
USER tracks
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
CMD ["./bin/rails", "server", "-b", "0.0.0.0"]
|
||||
|
||||
FROM base AS precompile
|
||||
RUN bundle config set deployment true
|
||||
RUN bundle install --jobs 4
|
||||
RUN RAILS_GROUPS=assets bundle exec rake assets:precompile
|
||||
|
||||
# Build the environment-specific stuff
|
||||
FROM base AS production
|
||||
RUN bundle config set without assets
|
||||
RUN bundle config --global frozen 1
|
||||
RUN bundle install --jobs 4
|
||||
COPY --from=precompile /app/public/assets /app/public/assets
|
||||
|
||||
FROM base AS test
|
||||
COPY test /app/test/
|
||||
# For testing the API client
|
||||
COPY doc /app/doc/
|
||||
RUN bundle config set without assets
|
||||
RUN bundle config set with development test
|
||||
RUN bundle config --global frozen 1
|
||||
RUN bundle install --jobs 4
|
||||
COPY --from=precompile /app/public/assets /app/public/assets
|
||||
|
||||
FROM base AS development
|
||||
RUN bundle config set with development test
|
||||
RUN bundle install --jobs 4
|
||||
# Run the application
|
||||
CMD ["./tracks"]
|
||||
|
|
|
|||
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:
|
||||
db:
|
||||
image: mariadb:lts
|
||||
environment:
|
||||
MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: 1
|
||||
MARIADB_DATABASE: ${TRACKS_DB:-tracks}
|
||||
volumes:
|
||||
- db-data:/var/lib/mysql
|
||||
web:
|
||||
tracks:
|
||||
build:
|
||||
context: .
|
||||
target: production # can also be development or test
|
||||
environment:
|
||||
# These are set in script/ci-build, so we need to pass-thru them.
|
||||
RAILS_ENV: $RAILS_ENV
|
||||
DATABASE_NAME: $DATABASE_NAME
|
||||
DATABASE_USERNAME: root
|
||||
DATABASE_PASSWORD_EMPTY: 1
|
||||
volumes:
|
||||
- ${VOLUME:-.}:/app:Z
|
||||
- ${VOLUME:-.}/config/database.docker.yml:/app/config/database.yml:Z
|
||||
- ${VOLUME:-.}/config/site.docker.yml:/app/config/site.yml:Z
|
||||
dockerfile: Dockerfile
|
||||
container_name: tracks-go
|
||||
ports:
|
||||
- 3000:3000
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- SERVER_HOST=0.0.0.0
|
||||
- SERVER_PORT=3000
|
||||
- GIN_MODE=release
|
||||
- DB_DRIVER=postgres
|
||||
- DB_HOST=postgres
|
||||
- DB_PORT=5432
|
||||
- DB_NAME=tracks
|
||||
- DB_USER=tracks
|
||||
- DB_PASSWORD=tracks
|
||||
- DB_SSLMODE=disable
|
||||
- JWT_SECRET=change-this-in-production
|
||||
- OPEN_SIGNUPS=true
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./uploads:/app/uploads
|
||||
depends_on:
|
||||
- db
|
||||
- postgres
|
||||
restart: unless-stopped
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: tracks-postgres
|
||||
environment:
|
||||
- POSTGRES_DB=tracks
|
||||
- POSTGRES_USER=tracks
|
||||
- POSTGRES_PASSWORD=tracks
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
db-data:
|
||||
postgres_data:
|
||||
|
|
|
|||
50
go.mod
Normal file
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