mirror of
https://github.com/TracksApp/tracks.git
synced 2026-02-03 14:31:47 +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
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue