Files
2026-06-18 21:41:47 +01:00

544 lines
13 KiB
Go

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
}