feat(m1): backend scaffold - entries CRUD, start/stop, auth, migrations
This commit is contained in:
217
internal/service/entry_service.go
Normal file
217
internal/service/entry_service.go
Normal file
@@ -0,0 +1,217 @@
|
||||
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")
|
||||
)
|
||||
|
||||
// EntryService handles business logic for time entries.
|
||||
type EntryService struct {
|
||||
entries *store.EntryStore
|
||||
closedDays *store.ClosedDayStore
|
||||
settings *store.SettingsStore
|
||||
tz *time.Location
|
||||
}
|
||||
|
||||
func NewEntryService(
|
||||
entries *store.EntryStore,
|
||||
closedDays *store.ClosedDayStore,
|
||||
settings *store.SettingsStore,
|
||||
tz *time.Location,
|
||||
) *EntryService {
|
||||
return &EntryService{
|
||||
entries: entries,
|
||||
closedDays: closedDays,
|
||||
settings: settings,
|
||||
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
|
||||
}
|
||||
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 {
|
||||
// Cross-midnight: cap at end of start day
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
if input.StartTime != nil {
|
||||
e.StartTime = *input.StartTime
|
||||
e.DayKey = s.dayKeyForMs(e.StartTime)
|
||||
}
|
||||
if input.EndTime != nil {
|
||||
// Validate same-day
|
||||
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
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
// 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())
|
||||
}
|
||||
136
internal/service/entry_service_test.go
Normal file
136
internal/service/entry_service_test.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package service_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/wotra/wotra/internal/service"
|
||||
"github.com/wotra/wotra/internal/store"
|
||||
)
|
||||
|
||||
func newTestServices(t *testing.T) *service.EntryService {
|
||||
t.Helper()
|
||||
db, err := store.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
entryStore := store.NewEntryStore(db)
|
||||
closedDayStore := store.NewClosedDayStore(db)
|
||||
settingsStore := store.NewSettingsStore(db)
|
||||
|
||||
tz, _ := time.LoadLocation("UTC")
|
||||
return service.NewEntryService(entryStore, closedDayStore, settingsStore, tz)
|
||||
}
|
||||
|
||||
func TestStartStop(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newTestServices(t)
|
||||
|
||||
entry, err := svc.Start(ctx, "test entry")
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
if entry.ID == "" {
|
||||
t.Fatal("expected non-empty ID")
|
||||
}
|
||||
if entry.EndTime != nil {
|
||||
t.Fatal("expected running entry (nil EndTime)")
|
||||
}
|
||||
|
||||
stopped, err := svc.Stop(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Stop: %v", err)
|
||||
}
|
||||
if stopped.EndTime == nil {
|
||||
t.Fatal("expected entry to be stopped")
|
||||
}
|
||||
if *stopped.EndTime < stopped.StartTime {
|
||||
t.Fatal("end_time before start_time")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartTwiceFails(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newTestServices(t)
|
||||
|
||||
if _, err := svc.Start(ctx, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := svc.Start(ctx, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error starting second entry")
|
||||
}
|
||||
if err != service.ErrEntryRunning {
|
||||
t.Fatalf("expected ErrEntryRunning, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopWithoutStart(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newTestServices(t)
|
||||
|
||||
_, err := svc.Stop(ctx)
|
||||
if err != service.ErrEntryNotRunning {
|
||||
t.Fatalf("expected ErrEntryNotRunning, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateEntry(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newTestServices(t)
|
||||
|
||||
entry, _ := svc.Start(ctx, "initial note")
|
||||
stopped, _ := svc.Stop(ctx)
|
||||
|
||||
note := "updated note"
|
||||
updated, err := svc.Update(ctx, stopped.ID, service.UpdateEntryInput{Note: ¬e})
|
||||
if err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
if updated.Note != "updated note" {
|
||||
t.Errorf("expected 'updated note', got %q", updated.Note)
|
||||
}
|
||||
_ = entry
|
||||
}
|
||||
|
||||
func TestDeleteEntry(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newTestServices(t)
|
||||
|
||||
entry, _ := svc.Start(ctx, "")
|
||||
svc.Stop(ctx)
|
||||
|
||||
if err := svc.Delete(ctx, entry.ID); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
|
||||
entries, err := svc.List(ctx, "0000-01-01", "9999-12-31")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
t.Fatalf("expected 0 entries after delete, got %d", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListEntries(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newTestServices(t)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
svc.Start(ctx, "")
|
||||
svc.Stop(ctx)
|
||||
}
|
||||
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
entries, err := svc.List(ctx, today, today)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 3 {
|
||||
t.Fatalf("expected 3 entries, got %d", len(entries))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user