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/
Problem
#130 added a zod
.refine()oncreateTimeEntryBodyandupdateTimeEntryBodythat rejectsteEndedAt < teStartedAtwhen both bounds are in the request body. The PR called out the single-bound PATCH case as intentionally NOT covered there:This is that follow-up. The flow that bypasses the schema check:
The schema-layer refinement can't see the existing row, so the controller's
computeMinutesis the only thing that notices the inversion — and it currently noticed silently, mapping toteMinutes = 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/mergedEndfrom(updates || existing), check whether the merged interval is inverted. If yes, return400 { 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/