544 lines
13 KiB
Go
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
|
|
}
|