Files
wotra/internal/service/entry_service.go
Andreas Schneider a8a4ea0d4f M9.1: wire sync logging into all mutation paths
- Add LogClosedDayDelete and LogClosedWeekDelete to SyncStore
- Inject syncStore into EntryService; log Start, Stop, StopByID,
  Update, CreateInterval, Delete, AutoStopStalledEntries
- Inject syncStore into DayService; log CloseDay, MarkDay, ReopenDay,
  and the recomputeWeek closed-week upsert
- Inject syncStore into SettingsService; log Upsert, UpdateSettings,
  DeleteSettings
- Add LogClosedWeek/LogClosedWeekDelete calls in WeekService.CloseWeek
  and ReopenWeek
- Update main.go and all service test helpers for new constructor signatures
- All Go tests and 19 Vitest tests pass
2026-04-30 22:57:02 +02:00

299 lines
7.7 KiB
Go

package service
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/wotra/wotra/internal/domain"
"github.com/wotra/wotra/internal/store"
)
// Sentinel errors returned by the service layer.
var (
ErrEntryRunning = errors.New("an entry is already running")
ErrEntryNotRunning = errors.New("no running entry found")
ErrEntryNotFound = errors.New("entry not found")
ErrDayAlreadyClosed = errors.New("day is already closed")
ErrDayNotClosed = errors.New("day is not closed")
ErrRunningEntryOnDay = errors.New("a running entry exists for this day; stop it first")
ErrCrossesMidnight = errors.New("entry end_time must be on the same calendar day as start_time")
ErrFutureDay = errors.New("intervals cannot be created or edited in the future")
)
// EntryService handles business logic for time entries.
type EntryService struct {
entries *store.EntryStore
closedDays *store.ClosedDayStore
settings *store.SettingsStore
syncStore *store.SyncStore
tz *time.Location
}
func NewEntryService(
entries *store.EntryStore,
closedDays *store.ClosedDayStore,
settings *store.SettingsStore,
syncStore *store.SyncStore,
tz *time.Location,
) *EntryService {
return &EntryService{
entries: entries,
closedDays: closedDays,
settings: settings,
syncStore: syncStore,
tz: tz,
}
}
func (s *EntryService) nowMs() int64 {
return time.Now().UnixMilli()
}
func (s *EntryService) dayKeyForMs(ms int64) string {
t := time.UnixMilli(ms).In(s.tz)
return t.Format("2006-01-02")
}
// midnightEndMs returns the unix ms of 23:59:59.999 for the day containing ms in the configured tz.
func (s *EntryService) midnightEndMs(ms int64) int64 {
t := time.UnixMilli(ms).In(s.tz)
end := time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 999_000_000, s.tz)
return end.UnixMilli()
}
// Start creates a new running entry. Returns ErrEntryRunning if one is already active.
func (s *EntryService) Start(ctx context.Context, note string) (*domain.Entry, error) {
running, err := s.entries.RunningEntry(ctx)
if err != nil {
return nil, err
}
if running != nil {
return nil, ErrEntryRunning
}
nowMs := s.nowMs()
dayKey := s.dayKeyForMs(nowMs)
// Check day is not already closed
closed, err := s.closedDays.GetByDayKey(ctx, dayKey)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, err
}
if closed != nil {
return nil, ErrDayAlreadyClosed
}
e := &domain.Entry{
ID: uuid.New().String(),
StartTime: nowMs,
Note: note,
DayKey: dayKey,
UpdatedAt: nowMs,
}
if err := s.entries.Create(ctx, e); err != nil {
return nil, err
}
_ = s.syncStore.LogEntry(ctx, e)
return e, nil
}
// Stop stops the currently running entry. Returns ErrEntryNotRunning if none active.
func (s *EntryService) Stop(ctx context.Context) (*domain.Entry, error) {
running, err := s.entries.RunningEntry(ctx)
if err != nil {
return nil, err
}
if running == nil {
return nil, ErrEntryNotRunning
}
return s.stopEntry(ctx, running, false)
}
// StopByID stops a specific entry by ID.
func (s *EntryService) StopByID(ctx context.Context, id string) (*domain.Entry, error) {
e, err := s.entries.GetByID(ctx, id)
if err == sql.ErrNoRows {
return nil, ErrEntryNotFound
}
if err != nil {
return nil, err
}
if !e.IsRunning() {
return nil, ErrEntryNotRunning
}
return s.stopEntry(ctx, e, false)
}
func (s *EntryService) stopEntry(ctx context.Context, e *domain.Entry, autoStopped bool) (*domain.Entry, error) {
nowMs := s.nowMs()
// Enforce same-day rule: cap end_time at 23:59:59.999 of start day
endMs := nowMs
startDayKey := s.dayKeyForMs(e.StartTime)
endDayKey := s.dayKeyForMs(endMs)
if endDayKey != startDayKey {
endMs = s.midnightEndMs(e.StartTime)
autoStopped = true
}
e.EndTime = &endMs
e.AutoStopped = autoStopped
e.UpdatedAt = nowMs
if err := s.entries.Update(ctx, e); err != nil {
return nil, err
}
_ = s.syncStore.LogEntry(ctx, e)
return e, nil
}
// GetByID returns a single entry.
func (s *EntryService) GetByID(ctx context.Context, id string) (*domain.Entry, error) {
e, err := s.entries.GetByID(ctx, id)
if err == sql.ErrNoRows {
return nil, ErrEntryNotFound
}
return e, err
}
// List returns entries within a date range.
func (s *EntryService) List(ctx context.Context, fromDayKey, toDayKey string) ([]*domain.Entry, error) {
return s.entries.ListByDateRange(ctx, fromDayKey, toDayKey)
}
// UpdateEntry allows editing start/end/note for a non-running, non-closed entry.
type UpdateEntryInput struct {
StartTime *int64
EndTime *int64
Note *string
}
func (s *EntryService) Update(ctx context.Context, id string, input UpdateEntryInput) (*domain.Entry, error) {
e, err := s.entries.GetByID(ctx, id)
if err == sql.ErrNoRows {
return nil, ErrEntryNotFound
}
if err != nil {
return nil, err
}
// Disallow editing entries that belong to a closed day.
closed, err := s.closedDays.GetByDayKey(ctx, e.DayKey)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, err
}
if closed != nil {
return nil, ErrDayAlreadyClosed
}
if input.StartTime != nil {
newDayKey := s.dayKeyForMs(*input.StartTime)
if newDayKey > s.dayKeyForMs(s.nowMs()) {
return nil, ErrFutureDay
}
e.StartTime = *input.StartTime
e.DayKey = newDayKey
}
if input.EndTime != nil {
startDayKey := s.dayKeyForMs(e.StartTime)
endDayKey := s.dayKeyForMs(*input.EndTime)
if startDayKey != endDayKey {
return nil, ErrCrossesMidnight
}
if *input.EndTime < e.StartTime {
return nil, fmt.Errorf("end_time must be after start_time")
}
e.EndTime = input.EndTime
}
if input.Note != nil {
e.Note = *input.Note
}
e.UpdatedAt = s.nowMs()
if err := s.entries.Update(ctx, e); err != nil {
return nil, err
}
_ = s.syncStore.LogEntry(ctx, e)
return e, nil
}
// CreateIntervalInput holds fields for a manually created completed interval.
type CreateIntervalInput struct {
StartTime int64
EndTime int64
Note string
}
// CreateInterval adds a completed interval with explicit start and end times.
func (s *EntryService) CreateInterval(ctx context.Context, input CreateIntervalInput) (*domain.Entry, error) {
if input.EndTime <= input.StartTime {
return nil, fmt.Errorf("end_time must be after start_time")
}
startDayKey := s.dayKeyForMs(input.StartTime)
endDayKey := s.dayKeyForMs(input.EndTime)
if startDayKey != endDayKey {
return nil, ErrCrossesMidnight
}
todayKey := s.dayKeyForMs(s.nowMs())
if startDayKey > todayKey {
return nil, ErrFutureDay
}
closed, err := s.closedDays.GetByDayKey(ctx, startDayKey)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, err
}
if closed != nil {
return nil, ErrDayAlreadyClosed
}
nowMs := s.nowMs()
e := &domain.Entry{
ID: uuid.New().String(),
StartTime: input.StartTime,
EndTime: &input.EndTime,
Note: input.Note,
DayKey: startDayKey,
UpdatedAt: nowMs,
}
if err := s.entries.Create(ctx, e); err != nil {
return nil, err
}
_ = s.syncStore.LogEntry(ctx, e)
return e, nil
}
// Delete soft-deletes an entry.
func (s *EntryService) Delete(ctx context.Context, id string) error {
nowMs := s.nowMs()
if err := s.entries.SoftDelete(ctx, id, nowMs); err != nil {
return err
}
_ = s.syncStore.LogEntryDelete(ctx, id)
return nil
}
// AutoStopStalledEntries stops any running entries whose day_key is before today.
// Called by the midnight background goroutine.
func (s *EntryService) AutoStopStalledEntries(ctx context.Context) ([]string, error) {
today := s.dayKeyForMs(s.nowMs())
ids, err := s.entries.StopAllRunningBefore(ctx, today, s.nowMs(), s.nowMs())
if err != nil {
return nil, err
}
// Log each auto-stopped entry to sync.
for _, id := range ids {
e, err := s.entries.GetByID(ctx, id)
if err == nil {
_ = s.syncStore.LogEntry(ctx, e)
}
}
return ids, nil
}