package repository import ( "fmt" "sort" "strings" "sync" "time" ) // MemoryRepository implements Repository with process-local memory only. // It is intended for web-only proof-of-concept deployments where data should // disappear periodically and never be written to disk. type MemoryRepository struct { mu sync.RWMutex nextCompanyID int64 nextAssignmentID int64 nextTaskID int64 nextSessionID int64 nextInvoiceID int64 companies map[int64]Company assignments map[int64]Assignment tasks map[int64]Task sessions map[int64]WorkSession invoices map[int64]Invoice } func NewMemoryRepository() *MemoryRepository { repo := &MemoryRepository{} repo.Reset() return repo } // Reset wipes every in-memory record and restarts all ids at 1. func (repo *MemoryRepository) Reset() { repo.mu.Lock() defer repo.mu.Unlock() repo.nextCompanyID = 1 repo.nextAssignmentID = 1 repo.nextTaskID = 1 repo.nextSessionID = 1 repo.nextInvoiceID = 1 repo.companies = make(map[int64]Company) repo.assignments = make(map[int64]Assignment) repo.tasks = make(map[int64]Task) repo.sessions = make(map[int64]WorkSession) repo.invoices = make(map[int64]Invoice) } func (repo *MemoryRepository) Migrate() error { return nil } // ── Companies ────────────────────────────────────────────────────────────────── func (repo *MemoryRepository) InsertCompany(c Company) (*Company, error) { repo.mu.Lock() defer repo.mu.Unlock() c.ID = repo.nextCompanyID repo.nextCompanyID++ c.CreatedAt = time.Now() repo.companies[c.ID] = c return copyCompany(c), nil } func (repo *MemoryRepository) AllCompanies() ([]Company, error) { repo.mu.RLock() defer repo.mu.RUnlock() all := make([]Company, 0, len(repo.companies)) for _, c := range repo.companies { all = append(all, c) } sort.Slice(all, func(i, j int) bool { return strings.ToLower(all[i].Name) < strings.ToLower(all[j].Name) }) return all, nil } func (repo *MemoryRepository) GetCompanyByID(id int64) (*Company, error) { repo.mu.RLock() defer repo.mu.RUnlock() c, ok := repo.companies[id] if !ok { return nil, ErrNotFound } return copyCompany(c), nil } func (repo *MemoryRepository) UpdateCompany(id int64, updated Company) error { repo.mu.Lock() defer repo.mu.Unlock() c, ok := repo.companies[id] if !ok || id == 0 { return ErrUpdateFailed } c.Name = updated.Name c.Address = updated.Address c.ContactEmail = updated.ContactEmail c.ContactPhone = updated.ContactPhone c.HourlyRate = updated.HourlyRate repo.companies[id] = c for assignmentID, a := range repo.assignments { if a.CompanyID == id { a.CompanyName = c.Name repo.assignments[assignmentID] = a } } return nil } func (repo *MemoryRepository) DeleteCompany(id int64) error { repo.mu.Lock() defer repo.mu.Unlock() for _, a := range repo.assignments { if a.CompanyID == id { return ErrHasChildren } } if _, ok := repo.companies[id]; !ok { return ErrDeleteFailed } delete(repo.companies, id) return nil } // ── Assignments ──────────────────────────────────────────────────────────────── func (repo *MemoryRepository) InsertAssignment(a Assignment) (*Assignment, error) { repo.mu.Lock() defer repo.mu.Unlock() company, ok := repo.companies[a.CompanyID] if !ok { return nil, ErrNotFound } a.ID = repo.nextAssignmentID repo.nextAssignmentID++ a.CompanyName = company.Name if a.Status == "" { a.Status = "active" } a.CreatedAt = time.Now() repo.assignments[a.ID] = a return copyAssignment(a), nil } func (repo *MemoryRepository) AssignmentsByCompany(companyID int64) ([]Assignment, error) { repo.mu.RLock() defer repo.mu.RUnlock() all := make([]Assignment, 0) for _, a := range repo.assignments { if a.CompanyID == companyID { all = append(all, a) } } sort.Slice(all, func(i, j int) bool { return all[i].CreatedAt.After(all[j].CreatedAt) }) return all, nil } func (repo *MemoryRepository) AllActiveAssignments() ([]Assignment, error) { repo.mu.RLock() defer repo.mu.RUnlock() all := make([]Assignment, 0) for _, a := range repo.assignments { if a.Status == "active" { all = append(all, a) } } sort.Slice(all, func(i, j int) bool { if strings.ToLower(all[i].CompanyName) == strings.ToLower(all[j].CompanyName) { return strings.ToLower(all[i].Name) < strings.ToLower(all[j].Name) } return strings.ToLower(all[i].CompanyName) < strings.ToLower(all[j].CompanyName) }) return all, nil } func (repo *MemoryRepository) GetAssignmentByID(id int64) (*Assignment, error) { repo.mu.RLock() defer repo.mu.RUnlock() a, ok := repo.assignments[id] if !ok { return nil, ErrNotFound } return copyAssignment(a), nil } func (repo *MemoryRepository) UpdateAssignment(id int64, updated Assignment) error { repo.mu.Lock() defer repo.mu.Unlock() a, ok := repo.assignments[id] if !ok || id == 0 { return ErrUpdateFailed } a.Name = updated.Name a.Description = updated.Description a.HourlyRate = updated.HourlyRate if updated.Status != "" { a.Status = updated.Status } repo.assignments[id] = a return nil } func (repo *MemoryRepository) DeleteAssignment(id int64) error { repo.mu.Lock() defer repo.mu.Unlock() for _, t := range repo.tasks { if t.AssignmentID == id { return ErrHasChildren } } if _, ok := repo.assignments[id]; !ok { return ErrDeleteFailed } for sessionID, s := range repo.sessions { if s.AssignmentID == id { delete(repo.sessions, sessionID) } } delete(repo.assignments, id) return nil } // ── Tasks ────────────────────────────────────────────────────────────────────── func (repo *MemoryRepository) InsertTask(t Task) (*Task, error) { repo.mu.Lock() defer repo.mu.Unlock() if _, ok := repo.assignments[t.AssignmentID]; !ok { return nil, ErrNotFound } maxNum := 0 for _, existing := range repo.tasks { if existing.AssignmentID == t.AssignmentID && existing.Number > maxNum { maxNum = existing.Number } } t.ID = repo.nextTaskID repo.nextTaskID++ t.Number = maxNum + 1 repo.tasks[t.ID] = t withTotals := repo.taskWithTotalLocked(t) return copyTask(withTotals), nil } func (repo *MemoryRepository) TasksByAssignment(assignmentID int64) ([]Task, error) { repo.mu.RLock() defer repo.mu.RUnlock() all := make([]Task, 0) for _, t := range repo.tasks { if t.AssignmentID == assignmentID { all = append(all, repo.taskWithTotalLocked(t)) } } sort.Slice(all, func(i, j int) bool { return all[i].Number < all[j].Number }) return all, nil } func (repo *MemoryRepository) GetTaskByID(id int64) (*Task, error) { repo.mu.RLock() defer repo.mu.RUnlock() t, ok := repo.tasks[id] if !ok { return nil, ErrNotFound } withTotals := repo.taskWithTotalLocked(t) return copyTask(withTotals), nil } func (repo *MemoryRepository) UpdateTask(id int64, name string) error { repo.mu.Lock() defer repo.mu.Unlock() t, ok := repo.tasks[id] if !ok || id == 0 || name == "" { return ErrUpdateFailed } t.Name = name repo.tasks[id] = t newDesc := fmt.Sprintf("%d - %s", t.Number, t.Name) for sessionID, s := range repo.sessions { if s.TaskID == id { s.TaskDescription = newDesc repo.sessions[sessionID] = s } } return nil } func (repo *MemoryRepository) DeleteTask(id int64) error { repo.mu.Lock() defer repo.mu.Unlock() for _, s := range repo.sessions { if s.TaskID == id { return ErrHasChildren } } if _, ok := repo.tasks[id]; !ok { return ErrDeleteFailed } delete(repo.tasks, id) return nil } func (repo *MemoryRepository) TotalMinutesForTask(taskID int64) (float64, error) { repo.mu.RLock() defer repo.mu.RUnlock() if _, ok := repo.tasks[taskID]; !ok { return 0, ErrNotFound } return repo.totalMinutesForTaskLocked(taskID), nil } // ── Work Sessions ────────────────────────────────────────────────────────────── func (repo *MemoryRepository) InsertWorkSession(s WorkSession) (*WorkSession, error) { repo.mu.Lock() defer repo.mu.Unlock() if _, ok := repo.assignments[s.AssignmentID]; !ok { return nil, ErrNotFound } if _, ok := repo.tasks[s.TaskID]; !ok { return nil, ErrNotFound } s.ID = repo.nextSessionID repo.nextSessionID++ s.StartTime = time.Now() s.EndTime = nil s.DurationMinutes = 0 repo.sessions[s.ID] = s return copyWorkSession(s), nil } func (repo *MemoryRepository) StopWorkSession(id int64) error { repo.mu.Lock() defer repo.mu.Unlock() s, ok := repo.sessions[id] if !ok { return ErrUpdateFailed } now := time.Now() s.EndTime = &now s.DurationMinutes = now.Sub(s.StartTime).Minutes() repo.sessions[id] = s return nil } func (repo *MemoryRepository) InsertManualWorkSession(s WorkSession) (*WorkSession, error) { repo.mu.Lock() defer repo.mu.Unlock() if s.EndTime == nil { return nil, ErrUpdateFailed } if _, ok := repo.assignments[s.AssignmentID]; !ok { return nil, ErrNotFound } if _, ok := repo.tasks[s.TaskID]; !ok { return nil, ErrNotFound } s.ID = repo.nextSessionID repo.nextSessionID++ end := *s.EndTime s.EndTime = &end s.DurationMinutes = end.Sub(s.StartTime).Minutes() repo.sessions[s.ID] = s return copyWorkSession(s), nil } func (repo *MemoryRepository) UpdateWorkSession(id int64, updated WorkSession) error { repo.mu.Lock() defer repo.mu.Unlock() s, ok := repo.sessions[id] if !ok || id == 0 { return ErrUpdateFailed } if _, ok := repo.tasks[updated.TaskID]; !ok { return ErrNotFound } s.TaskID = updated.TaskID s.TaskDescription = updated.TaskDescription s.StartTime = updated.StartTime if updated.EndTime != nil { end := *updated.EndTime s.EndTime = &end s.DurationMinutes = end.Sub(updated.StartTime).Minutes() } else { s.EndTime = nil s.DurationMinutes = 0 } repo.sessions[id] = s return nil } func (repo *MemoryRepository) DeleteWorkSession(id int64) error { repo.mu.Lock() defer repo.mu.Unlock() if _, ok := repo.sessions[id]; !ok { return ErrDeleteFailed } delete(repo.sessions, id) return nil } func (repo *MemoryRepository) WorkSessionsByAssignment(assignmentID int64, limit int) ([]WorkSession, error) { repo.mu.RLock() defer repo.mu.RUnlock() all := make([]WorkSession, 0) for _, s := range repo.sessions { if s.AssignmentID == assignmentID { all = append(all, *copyWorkSession(s)) } } sort.Slice(all, func(i, j int) bool { return all[i].StartTime.After(all[j].StartTime) }) if limit > 0 && len(all) > limit { all = all[:limit] } return all, nil } func (repo *MemoryRepository) TotalHoursForAssignment(assignmentID int64) (float64, error) { repo.mu.RLock() defer repo.mu.RUnlock() if _, ok := repo.assignments[assignmentID]; !ok { return 0, ErrNotFound } var minutes float64 for _, s := range repo.sessions { if s.AssignmentID == assignmentID && s.EndTime != nil { minutes += s.DurationMinutes } } return minutes / 60.0, nil } // ── Invoices ─────────────────────────────────────────────────────────────────── func (repo *MemoryRepository) NextInvoiceNumber(date time.Time) (string, error) { repo.mu.RLock() defer repo.mu.RUnlock() prefix := date.Format("2006-01-02") count := 0 for _, inv := range repo.invoices { if strings.HasPrefix(inv.InvoiceNumber, prefix+"_") { count++ } } return fmt.Sprintf("%s_%d", prefix, count+1), nil } func (repo *MemoryRepository) InsertInvoice(inv Invoice) (*Invoice, error) { repo.mu.Lock() defer repo.mu.Unlock() if _, ok := repo.assignments[inv.AssignmentID]; !ok { return nil, ErrNotFound } for _, existing := range repo.invoices { if existing.InvoiceNumber == inv.InvoiceNumber { return nil, fmt.Errorf("invoice number already exists") } } inv.ID = repo.nextInvoiceID repo.nextInvoiceID++ inv.GeneratedAt = time.Now() repo.invoices[inv.ID] = inv return copyInvoice(inv), nil } // ── Helpers ──────────────────────────────────────────────────────────────────── func (repo *MemoryRepository) taskWithTotalLocked(t Task) Task { t.TotalMinutes = repo.totalMinutesForTaskLocked(t.ID) return t } func (repo *MemoryRepository) totalMinutesForTaskLocked(taskID int64) float64 { var total float64 for _, s := range repo.sessions { if s.TaskID == taskID && s.EndTime != nil { total += s.DurationMinutes } } return total } func copyCompany(c Company) *Company { v := c return &v } func copyAssignment(a Assignment) *Assignment { v := a return &v } func copyTask(t Task) *Task { v := t return &v } func copyWorkSession(s WorkSession) *WorkSession { v := s if s.EndTime != nil { end := *s.EndTime v.EndTime = &end } return &v } func copyInvoice(inv Invoice) *Invoice { v := inv return &v }