- Add ErrFutureDay sentinel error - CreateInterval: rejects if startDayKey > todayKey (400) - Update: rejects if new start_time moves entry to a future day (400) - Handler maps ErrFutureDay → 400 Bad Request for both endpoints - Add TestCreateIntervalRejectsFutureDay - Add TestUpdateRejectsMoveToFutureDay - UI already gates this via dayCapabilities (canAddInterval=false, canEditEntries=false for future days), but server now enforces it too
283 lines
7.5 KiB
Go
283 lines
7.5 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
|
|
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
|
|
}
|
|
|
|
// 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)
|
|
// Reject if the new start_time moves the entry into the future.
|
|
if newDayKey > s.dayKeyForMs(s.nowMs()) {
|
|
return nil, ErrFutureDay
|
|
}
|
|
e.StartTime = *input.StartTime
|
|
e.DayKey = newDayKey
|
|
}
|
|
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
|
|
}
|
|
|
|
// 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.
|
|
// 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")
|
|
}
|
|
|
|
startDayKey := s.dayKeyForMs(input.StartTime)
|
|
endDayKey := s.dayKeyForMs(input.EndTime)
|
|
if startDayKey != endDayKey {
|
|
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
|
|
}
|
|
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
|
|
}
|
|
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())
|
|
}
|