From bf0c7288185a0a2f85e4cd1326ff9a8ab3bda92a Mon Sep 17 00:00:00 2001 From: Andreas Schneider Date: Thu, 30 Apr 2026 19:09:22 +0200 Subject: [PATCH] fix(entries): reject create/update of intervals in the future MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- internal/handler/entry_handler.go | 4 ++ internal/service/entry_service.go | 16 +++++++- internal/service/entry_service_test.go | 52 ++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 2 deletions(-) diff --git a/internal/handler/entry_handler.go b/internal/handler/entry_handler.go index 92703a2..8c4b4fc 100644 --- a/internal/handler/entry_handler.go +++ b/internal/handler/entry_handler.go @@ -52,6 +52,8 @@ func (h *EntryHandler) CreateInterval(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusUnprocessableEntity, err.Error()) case errors.Is(err, service.ErrDayAlreadyClosed): writeError(w, http.StatusConflict, err.Error()) + case errors.Is(err, service.ErrFutureDay): + writeError(w, http.StatusBadRequest, err.Error()) default: writeError(w, http.StatusInternalServerError, err.Error()) } @@ -145,6 +147,8 @@ func (h *EntryHandler) Update(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusNotFound, err.Error()) case errors.Is(err, service.ErrCrossesMidnight): writeError(w, http.StatusUnprocessableEntity, err.Error()) + case errors.Is(err, service.ErrFutureDay): + writeError(w, http.StatusBadRequest, err.Error()) default: writeError(w, http.StatusInternalServerError, err.Error()) } diff --git a/internal/service/entry_service.go b/internal/service/entry_service.go index 76e0dfd..1474700 100644 --- a/internal/service/entry_service.go +++ b/internal/service/entry_service.go @@ -21,6 +21,7 @@ var ( 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. @@ -186,8 +187,13 @@ 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 + } e.StartTime = *input.StartTime - e.DayKey = s.dayKeyForMs(e.StartTime) + e.DayKey = newDayKey } if input.EndTime != nil { // Validate same-day @@ -220,7 +226,7 @@ type CreateIntervalInput struct { } // CreateInterval adds a completed interval with explicit start and end times. -// Rules: same-day, end > start, day not closed, no currently running entry on that day (check running globally). +// 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") @@ -232,6 +238,12 @@ 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) { diff --git a/internal/service/entry_service_test.go b/internal/service/entry_service_test.go index c6c4e0e..e272851 100644 --- a/internal/service/entry_service_test.go +++ b/internal/service/entry_service_test.go @@ -231,3 +231,55 @@ func TestUpdateRejectsClosedDay(t *testing.T) { t.Fatalf("expected ErrDayAlreadyClosed, got %v", err) } } + +func TestCreateIntervalRejectsFutureDay(t *testing.T) { + ctx := context.Background() + svc := newTestServices(t) + + // Build a start_time that is tomorrow. + tomorrow := time.Now().UTC().AddDate(0, 0, 1) + startMs := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 9, 0, 0, 0, time.UTC).UnixMilli() + endMs := startMs + 3_600_000 // +1h + + _, err := svc.CreateInterval(ctx, service.CreateIntervalInput{ + StartTime: startMs, + EndTime: endMs, + Note: "future", + }) + if err == nil { + t.Fatal("expected error creating interval in the future") + } + if !errors.Is(err, service.ErrFutureDay) { + t.Fatalf("expected ErrFutureDay, got %v", err) + } +} + +func TestUpdateRejectsMoveToFutureDay(t *testing.T) { + ctx := context.Background() + svc := newTestServices(t) + + // Create a valid interval for today. + now := time.Now().UTC() + startMs := time.Date(now.Year(), now.Month(), now.Day(), 8, 0, 0, 0, time.UTC).UnixMilli() + endMs := startMs + 3_600_000 + entry, err := svc.CreateInterval(ctx, service.CreateIntervalInput{ + StartTime: startMs, + EndTime: endMs, + }) + if err != nil { + t.Fatalf("CreateInterval: %v", err) + } + + // Try to move start_time to tomorrow. + tomorrow := now.AddDate(0, 0, 1) + futureStart := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 8, 0, 0, 0, time.UTC).UnixMilli() + _, err = svc.Update(ctx, entry.ID, service.UpdateEntryInput{ + StartTime: &futureStart, + }) + if err == nil { + t.Fatal("expected error moving entry to future day") + } + if !errors.Is(err, service.ErrFutureDay) { + t.Fatalf("expected ErrFutureDay, got %v", err) + } +}