Skip to content

timeentry: single-bound PATCH with teEndedAt < existing teStartedAt silently stores teMinutes=null (#130 follow-up) #155

@CryptoJones

Description

@CryptoJones

Problem

#130 added a zod .refine() on createTimeEntryBody and updateTimeEntryBody that rejects teEndedAt < teStartedAt when both bounds are in the request body. The PR called out the single-bound PATCH case as intentionally NOT covered there:

The single-bound PATCH case (only teEndedAt, validated against the row's existing teStartedAt) can't be enforced at the schema layer without a DB read. Leave that on the controller layer for a follow-up.

This is that follow-up. The flow that bypasses the schema check:

PATCH /v1/timeentry/7
authKey: …
{
  "teEndedAt": "2026-05-15T09:00:00Z"
}

# DB row 7 has:
#   teStartedAt = 2026-05-15T10:00:00Z
#   teEndedAt   = NULL
#
# After merge: ended < started by an hour → controller's
# computeMinutes returns null → row gets:
#   teStartedAt = 10:00, teEndedAt = 09:00, teMinutes = NULL
#
# 200 OK

The schema-layer refinement can't see the existing row, so the controller's computeMinutes is the only thing that notices the inversion — and it currently noticed silently, mapping to teMinutes = null. The client thinks it patched a legitimate time entry; the DB row says the worker clocked out before clocking in with no duration.

Proposed fix

In the PATCH handler, after building mergedStart / mergedEnd from (updates || existing), check whether the merged interval is inverted. If yes, return 400 { message: 'teEndedAt must be at or after teStartedAt.' } — same body shape as the schema-layer refinement when both bounds are in the request.

Factor the check into a small isInvertedRange(startedAt, endedAt) helper for unit-testability and so the same shape can be reused if other entities pick up start/end pairs (invoice's invDate/invDueDate already had its same-pattern PR in #146).

Equality stays allowed (zero-minute "Due on Receipt"-style entries). Unparseable input returns false from the helper because computeMinutes' own NaN guard already null-tags those rows.

Proudly Made in Nebraska. Go Big Red! 🌽 https://xkcd.com/2347/

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions