Rewrite Tracks application in Golang

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

## Architecture & Technology Stack

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

## Implemented Features

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

### API Endpoints

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

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

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

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

### Business Logic

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

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

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

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

### Infrastructure

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

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

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

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

## Project Structure

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

## Documentation

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

## Future Work

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

## Benefits Over Rails Version

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

## Testing

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

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

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

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

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

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

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