From a8a4ea0d4ffa12ff17e9601542e3b2ffa616167c Mon Sep 17 00:00:00 2001 From: Andreas Schneider Date: Thu, 30 Apr 2026 22:57:02 +0200 Subject: [PATCH] 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 --- cmd/wotra/main.go | 6 ++-- internal/service/day_service.go | 12 +++++++- internal/service/day_service_test.go | 7 +++-- internal/service/entry_service.go | 40 ++++++++++++++++++-------- internal/service/entry_service_test.go | 7 +++-- internal/service/settings_service.go | 15 +++++++--- internal/service/week_service.go | 7 ++++- internal/service/week_service_test.go | 6 ++-- internal/store/sync_store.go | 12 ++++++++ 9 files changed, 82 insertions(+), 30 deletions(-) diff --git a/cmd/wotra/main.go b/cmd/wotra/main.go index 6164e4c..7cfd9e6 100644 --- a/cmd/wotra/main.go +++ b/cmd/wotra/main.go @@ -46,9 +46,9 @@ func main() { settingsStore := store.NewSettingsStore(db) syncStore := store.NewSyncStore(db) - entrySvc := service.NewEntryService(entryStore, closedDayStore, settingsStore, tz) - daySvc := service.NewDayService(entryStore, closedDayStore, closedWeekStore, settingsStore, tz) - settingsSvc := service.NewSettingsService(settingsStore) + entrySvc := service.NewEntryService(entryStore, closedDayStore, settingsStore, syncStore, tz) + daySvc := service.NewDayService(entryStore, closedDayStore, closedWeekStore, settingsStore, syncStore, tz) + settingsSvc := service.NewSettingsService(settingsStore, syncStore) weekSvc := service.NewWeekService(closedDayStore, closedWeekStore, entryStore, settingsStore, adjustmentStore, syncStore, db, tz) // Background goroutine: auto-stop entries that cross midnight diff --git a/internal/service/day_service.go b/internal/service/day_service.go index 84f7bd5..dc689a2 100644 --- a/internal/service/day_service.go +++ b/internal/service/day_service.go @@ -22,6 +22,7 @@ type DayService struct { closedDays *store.ClosedDayStore closedWeeks *store.ClosedWeekStore settings *store.SettingsStore + syncStore *store.SyncStore tz *time.Location } @@ -30,6 +31,7 @@ func NewDayService( closedDays *store.ClosedDayStore, closedWeeks *store.ClosedWeekStore, settings *store.SettingsStore, + syncStore *store.SyncStore, tz *time.Location, ) *DayService { return &DayService{ @@ -37,6 +39,7 @@ func NewDayService( closedDays: closedDays, closedWeeks: closedWeeks, settings: settings, + syncStore: syncStore, tz: tz, } } @@ -74,7 +77,11 @@ func (s *DayService) recomputeWeek(ctx context.Context, dayKey string) error { cw.WorkedMs = totalWorkedMs cw.DeltaMs = totalWorkedMs - cw.ExpectedMs cw.UpdatedAt = time.Now().UnixMilli() - return s.closedWeeks.Upsert(ctx, cw) + if err := s.closedWeeks.Upsert(ctx, cw); err != nil { + return err + } + _ = s.syncStore.LogClosedWeek(ctx, cw) + return nil } // CloseDay merges all completed entries for the given day key into a ClosedDay. @@ -136,6 +143,7 @@ func (s *DayService) CloseDay(ctx context.Context, dayKey string) (*domain.Close if err := s.closedDays.Upsert(ctx, cd); err != nil { return nil, err } + _ = s.syncStore.LogClosedDay(ctx, cd) if err := s.recomputeWeek(ctx, dayKey); err != nil { return nil, err } @@ -175,6 +183,7 @@ func (s *DayService) MarkDay(ctx context.Context, dayKey string, kind domain.Day if err := s.closedDays.Upsert(ctx, cd); err != nil { return nil, err } + _ = s.syncStore.LogClosedDay(ctx, cd) if err := s.recomputeWeek(ctx, dayKey); err != nil { return nil, err } @@ -196,6 +205,7 @@ func (s *DayService) ReopenDay(ctx context.Context, dayKey string) error { if err := s.closedDays.Delete(ctx, dayKey); err != nil { return err } + _ = s.syncStore.LogClosedDayDelete(ctx, dayKey) return s.recomputeWeek(ctx, dayKey) } diff --git a/internal/service/day_service_test.go b/internal/service/day_service_test.go index 98916b6..8aa3531 100644 --- a/internal/service/day_service_test.go +++ b/internal/service/day_service_test.go @@ -22,11 +22,12 @@ func newTestDayServices(t *testing.T) (*service.EntryService, *service.DayServic closedDayStore := store.NewClosedDayStore(db) closedWeekStore := store.NewClosedWeekStore(db) settingsStore := store.NewSettingsStore(db) + syncStore := store.NewSyncStore(db) tz, _ := time.LoadLocation("UTC") - entrySvc := service.NewEntryService(entryStore, closedDayStore, settingsStore, tz) - daySvc := service.NewDayService(entryStore, closedDayStore, closedWeekStore, settingsStore, tz) - settingsSvc := service.NewSettingsService(settingsStore) + entrySvc := service.NewEntryService(entryStore, closedDayStore, settingsStore, syncStore, tz) + daySvc := service.NewDayService(entryStore, closedDayStore, closedWeekStore, settingsStore, syncStore, tz) + settingsSvc := service.NewSettingsService(settingsStore, syncStore) return entrySvc, daySvc, settingsSvc } diff --git a/internal/service/entry_service.go b/internal/service/entry_service.go index 1474700..35739b2 100644 --- a/internal/service/entry_service.go +++ b/internal/service/entry_service.go @@ -26,22 +26,25 @@ var ( // EntryService handles business logic for time entries. type EntryService struct { - entries *store.EntryStore - closedDays *store.ClosedDayStore - settings *store.SettingsStore - tz *time.Location + 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, } } @@ -94,6 +97,7 @@ func (s *EntryService) Start(ctx context.Context, note string) (*domain.Entry, e if err := s.entries.Create(ctx, e); err != nil { return nil, err } + _ = s.syncStore.LogEntry(ctx, e) return e, nil } @@ -132,7 +136,6 @@ func (s *EntryService) stopEntry(ctx context.Context, e *domain.Entry, autoStopp startDayKey := s.dayKeyForMs(e.StartTime) endDayKey := s.dayKeyForMs(endMs) if endDayKey != startDayKey { - // Cross-midnight: cap at end of start day endMs = s.midnightEndMs(e.StartTime) autoStopped = true } @@ -144,6 +147,7 @@ func (s *EntryService) stopEntry(ctx context.Context, e *domain.Entry, autoStopp if err := s.entries.Update(ctx, e); err != nil { return nil, err } + _ = s.syncStore.LogEntry(ctx, e) return e, nil } @@ -188,7 +192,6 @@ func (s *EntryService) Update(ctx context.Context, id string, input UpdateEntryI if input.StartTime != nil { newDayKey := s.dayKeyForMs(*input.StartTime) - // Reject if the new start_time moves the entry into the future. if newDayKey > s.dayKeyForMs(s.nowMs()) { return nil, ErrFutureDay } @@ -196,7 +199,6 @@ func (s *EntryService) Update(ctx context.Context, id string, input UpdateEntryI e.DayKey = newDayKey } if input.EndTime != nil { - // Validate same-day startDayKey := s.dayKeyForMs(e.StartTime) endDayKey := s.dayKeyForMs(*input.EndTime) if startDayKey != endDayKey { @@ -215,6 +217,7 @@ func (s *EntryService) Update(ctx context.Context, id string, input UpdateEntryI if err := s.entries.Update(ctx, e); err != nil { return nil, err } + _ = s.syncStore.LogEntry(ctx, e) return e, nil } @@ -226,7 +229,6 @@ type CreateIntervalInput struct { } // CreateInterval adds a completed interval with explicit start and end times. -// Rules: same-day, end > start, day not closed, day not in the future. 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") @@ -238,13 +240,11 @@ func (s *EntryService) CreateInterval(ctx context.Context, input CreateIntervalI return nil, ErrCrossesMidnight } - // Reject future intervals. todayKey := s.dayKeyForMs(s.nowMs()) if startDayKey > todayKey { return nil, ErrFutureDay } - // Check day is not closed. closed, err := s.closedDays.GetByDayKey(ctx, startDayKey) if err != nil && !errors.Is(err, sql.ErrNoRows) { return nil, err @@ -265,18 +265,34 @@ func (s *EntryService) CreateInterval(ctx context.Context, input CreateIntervalI 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() - return s.entries.SoftDelete(ctx, id, 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()) - return s.entries.StopAllRunningBefore(ctx, today, s.nowMs(), 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 } diff --git a/internal/service/entry_service_test.go b/internal/service/entry_service_test.go index e272851..db34be7 100644 --- a/internal/service/entry_service_test.go +++ b/internal/service/entry_service_test.go @@ -23,7 +23,7 @@ func newTestServices(t *testing.T) *service.EntryService { settingsStore := store.NewSettingsStore(db) tz, _ := time.LoadLocation("UTC") - return service.NewEntryService(entryStore, closedDayStore, settingsStore, tz) + return service.NewEntryService(entryStore, closedDayStore, settingsStore, store.NewSyncStore(db), tz) } func TestStartStop(t *testing.T) { @@ -210,9 +210,10 @@ func TestUpdateRejectsClosedDay(t *testing.T) { closedDayStore := store.NewClosedDayStore(db) closedWeekStore := store.NewClosedWeekStore(db) settingsStore := store.NewSettingsStore(db) + syncStore := store.NewSyncStore(db) tz, _ := time.LoadLocation("UTC") - svc := service.NewEntryService(entryStore, closedDayStore, settingsStore, tz) - daySvc := service.NewDayService(entryStore, closedDayStore, closedWeekStore, settingsStore, tz) + svc := service.NewEntryService(entryStore, closedDayStore, settingsStore, syncStore, tz) + daySvc := service.NewDayService(entryStore, closedDayStore, closedWeekStore, settingsStore, syncStore, tz) entry, _ := svc.Start(ctx, "") svc.Stop(ctx) diff --git a/internal/service/settings_service.go b/internal/service/settings_service.go index 6f9994b..d802f0a 100644 --- a/internal/service/settings_service.go +++ b/internal/service/settings_service.go @@ -22,11 +22,12 @@ var ( // SettingsService manages settings with effective-from history. type SettingsService struct { - store *store.SettingsStore + store *store.SettingsStore + syncStore *store.SyncStore } -func NewSettingsService(s *store.SettingsStore) *SettingsService { - return &SettingsService{store: s} +func NewSettingsService(s *store.SettingsStore, syncStore *store.SyncStore) *SettingsService { + return &SettingsService{store: s, syncStore: syncStore} } // Current returns settings effective as of today. @@ -92,6 +93,7 @@ func (s *SettingsService) Upsert(ctx context.Context, input UpsertSettingsInput) if err := s.store.Insert(ctx, set); err != nil { return nil, err } + _ = s.syncStore.LogSettings(ctx, set) return set, nil } @@ -138,6 +140,7 @@ func (s *SettingsService) UpdateSettings(ctx context.Context, id string, input U if err := s.store.Update(ctx, set); err != nil { return nil, err } + _ = s.syncStore.LogSettings(ctx, set) return set, nil } @@ -157,5 +160,9 @@ func (s *SettingsService) DeleteSettings(ctx context.Context, id string) error { } return err } - return s.store.Delete(ctx, id) + if err := s.store.Delete(ctx, id); err != nil { + return err + } + _ = s.syncStore.LogSettingsDelete(ctx, id) + return nil } diff --git a/internal/service/week_service.go b/internal/service/week_service.go index f0ff77b..951e655 100644 --- a/internal/service/week_service.go +++ b/internal/service/week_service.go @@ -238,6 +238,7 @@ func (s *WeekService) CloseWeek(ctx context.Context, weekKey string) (*domain.Cl if err := s.closedWeeks.Upsert(ctx, cw); err != nil { return nil, err } + _ = s.syncStore.LogClosedWeek(ctx, cw) return cw, nil } @@ -254,7 +255,11 @@ func (s *WeekService) ReopenWeek(ctx context.Context, weekKey string) error { if existing == nil { return ErrWeekNotClosed } - return s.closedWeeks.Delete(ctx, weekKey) + if err := s.closedWeeks.Delete(ctx, weekKey); err != nil { + return err + } + _ = s.syncStore.LogClosedWeekDelete(ctx, weekKey) + return nil } // ListWeeks returns closed weeks within a range. diff --git a/internal/service/week_service_test.go b/internal/service/week_service_test.go index c4db370..bd06f7a 100644 --- a/internal/service/week_service_test.go +++ b/internal/service/week_service_test.go @@ -27,10 +27,10 @@ func newFullServices(t *testing.T) (*service.EntryService, *service.DayService, settingsStore := store.NewSettingsStore(db) tz, _ := time.LoadLocation("UTC") - entrySvc := service.NewEntryService(entryStore, closedDayStore, settingsStore, tz) - daySvc := service.NewDayService(entryStore, closedDayStore, closedWeekStore, settingsStore, tz) + entrySvc := service.NewEntryService(entryStore, closedDayStore, settingsStore, syncStore, tz) + daySvc := service.NewDayService(entryStore, closedDayStore, closedWeekStore, settingsStore, syncStore, tz) weekSvc := service.NewWeekService(closedDayStore, closedWeekStore, entryStore, settingsStore, adjustmentStore, syncStore, db, tz) - settingsSvc := service.NewSettingsService(settingsStore) + settingsSvc := service.NewSettingsService(settingsStore, syncStore) return entrySvc, daySvc, weekSvc, settingsSvc } diff --git a/internal/store/sync_store.go b/internal/store/sync_store.go index f4bcec6..a4e9d49 100644 --- a/internal/store/sync_store.go +++ b/internal/store/sync_store.go @@ -150,6 +150,12 @@ func (s *SyncStore) LogClosedDay(ctx context.Context, d *domain.ClosedDay) error return s.log(ctx, "closed_days", d.DayKey, "upsert", string(payload)) } +// LogClosedDayDelete appends a closed_day delete to the sync log. +func (s *SyncStore) LogClosedDayDelete(ctx context.Context, dayKey string) error { + payload := fmt.Sprintf(`{"day_key":%q}`, dayKey) + return s.log(ctx, "closed_days", dayKey, "delete", payload) +} + // LogClosedWeek appends a closed_week upsert to the sync log. func (s *SyncStore) LogClosedWeek(ctx context.Context, w *domain.ClosedWeek) error { payload, err := json.Marshal(w) @@ -159,6 +165,12 @@ func (s *SyncStore) LogClosedWeek(ctx context.Context, w *domain.ClosedWeek) err return s.log(ctx, "closed_weeks", w.WeekKey, "upsert", string(payload)) } +// LogClosedWeekDelete appends a closed_week delete to the sync log. +func (s *SyncStore) LogClosedWeekDelete(ctx context.Context, weekKey string) error { + payload := fmt.Sprintf(`{"week_key":%q}`, weekKey) + return s.log(ctx, "closed_weeks", weekKey, "delete", payload) +} + // LogSettings appends a settings upsert to the sync log. func (s *SyncStore) LogSettings(ctx context.Context, set *domain.Settings) error { payload, err := json.Marshal(set)