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" ) var ( ErrNoSettings = errors.New("no settings found") ErrInvalidWorkdaysMask = errors.New("workdays_mask must be between 1 and 127") ErrInvalidHours = errors.New("hours_per_week must be > 0") ErrSettingsNotFound = errors.New("settings row not found") ErrLastSettingsRow = errors.New("cannot delete the only settings row") ) // SettingsService manages settings with effective-from history. type SettingsService struct { store *store.SettingsStore } func NewSettingsService(s *store.SettingsStore) *SettingsService { return &SettingsService{store: s} } // Current returns settings effective as of today. func (s *SettingsService) Current(ctx context.Context) (*domain.Settings, error) { today := time.Now().UTC().Format("2006-01-02") set, err := s.store.Current(ctx, today) if err != nil { return nil, ErrNoSettings } return set, nil } // AsOf returns settings effective on the given day key (YYYY-MM-DD). func (s *SettingsService) AsOf(ctx context.Context, dayKey string) (*domain.Settings, error) { set, err := s.store.Current(ctx, dayKey) if err != nil { return nil, ErrNoSettings } return set, nil } // History returns all settings rows, newest first. func (s *SettingsService) History(ctx context.Context) ([]*domain.Settings, error) { return s.store.History(ctx) } // UpsertInput is the payload for creating a new settings version. type UpsertSettingsInput struct { EffectiveFrom string HoursPerWeek float64 WorkdaysMask int Timezone string } // Upsert creates a new settings row (always inserts; effective_from allows retroactive changes). func (s *SettingsService) Upsert(ctx context.Context, input UpsertSettingsInput) (*domain.Settings, error) { if input.HoursPerWeek <= 0 { return nil, ErrInvalidHours } if input.WorkdaysMask < 1 || input.WorkdaysMask > 127 { return nil, ErrInvalidWorkdaysMask } if input.Timezone == "" { input.Timezone = "UTC" } if _, err := time.LoadLocation(input.Timezone); err != nil { return nil, fmt.Errorf("invalid timezone: %w", err) } if _, err := time.Parse("2006-01-02", input.EffectiveFrom); err != nil { return nil, fmt.Errorf("invalid effective_from: %w", err) } now := time.Now().UnixMilli() set := &domain.Settings{ ID: uuid.New().String(), EffectiveFrom: input.EffectiveFrom, HoursPerWeek: input.HoursPerWeek, WorkdaysMask: input.WorkdaysMask, Timezone: input.Timezone, CreatedAt: now, UpdatedAt: now, } if err := s.store.Insert(ctx, set); err != nil { return nil, err } return set, nil } // UpdateSettingsInput is the payload for editing an existing settings row. type UpdateSettingsInput struct { EffectiveFrom string HoursPerWeek float64 WorkdaysMask int Timezone string } // UpdateSettings edits an existing settings row in-place. func (s *SettingsService) UpdateSettings(ctx context.Context, id string, input UpdateSettingsInput) (*domain.Settings, error) { if input.HoursPerWeek <= 0 { return nil, ErrInvalidHours } if input.WorkdaysMask < 1 || input.WorkdaysMask > 127 { return nil, ErrInvalidWorkdaysMask } if input.Timezone == "" { input.Timezone = "UTC" } if _, err := time.LoadLocation(input.Timezone); err != nil { return nil, fmt.Errorf("invalid timezone: %w", err) } if _, err := time.Parse("2006-01-02", input.EffectiveFrom); err != nil { return nil, fmt.Errorf("invalid effective_from: %w", err) } set, err := s.store.GetByID(ctx, id) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrSettingsNotFound } return nil, err } set.EffectiveFrom = input.EffectiveFrom set.HoursPerWeek = input.HoursPerWeek set.WorkdaysMask = input.WorkdaysMask set.Timezone = input.Timezone set.UpdatedAt = time.Now().UnixMilli() if err := s.store.Update(ctx, set); err != nil { return nil, err } return set, nil } // DeleteSettings removes a settings row. Refuses if it is the only row. func (s *SettingsService) DeleteSettings(ctx context.Context, id string) error { count, err := s.store.Count(ctx) if err != nil { return err } if count <= 1 { return ErrLastSettingsRow } // Confirm row exists before deleting. if _, err := s.store.GetByID(ctx, id); err != nil { if errors.Is(err, sql.ErrNoRows) { return ErrSettingsNotFound } return err } return s.store.Delete(ctx, id) }