Skip to content

feat(timeentry): reject single-bound PATCH inversion at the controller layer (#130 follow-up)#156

Merged
CryptoJones merged 1 commit into
masterfrom
feat/timeentry-controller-rejects-merged-inverted-range
May 19, 2026
Merged

feat(timeentry): reject single-bound PATCH inversion at the controller layer (#130 follow-up)#156
CryptoJones merged 1 commit into
masterfrom
feat/timeentry-controller-rejects-merged-inverted-range

Conversation

@CryptoJones
Copy link
Copy Markdown
Owner

Closes #155.

Summary

#130 fixed the both-bounds-in-body inversion case at the schema layer but explicitly punted on the single-bound PATCH: a request body with only teEndedAt (or only teStartedAt) merges against the row's existing other bound, which the schema can't see without a DB read. Until now that path silently dropped teMinutes to null and stored a row whose clocked-out-before-clocked-in timestamps look correct but whose duration column is blank.

Add the merge-then-check in the PATCH handler. Factor it into a small isInvertedRange(startedAt, endedAt) helper exposed via _internals for unit testing. Equality stays allowed; unparseable input returns false (already null-tagged by computeMinutes).

Test plan

  • npm run lint — clean
  • npm test — 608 passed (was 604 + 4 new helper-unit-tests), 15 skipped
  • Four new tests cover: missing-bound (false), happy paths incl. equality (false), inverted (true), unparseable (false)

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

…r layer (#130 follow-up)

#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: a
PATCH that supplies only teEndedAt (or only teStartedAt) merges
against the row's existing value, which the schema layer can't
see without a DB read. Said:

> 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. After the controller computes
`mergedStart` / `mergedEnd` from the body + existing row, check
whether the merged range is inverted; reject with 400 instead of
silently dropping `teMinutes` to null (the previous behaviour,
which the PR review confirmed had real-world bookkeeping cost:
"clocked out before clocked in" rows look correct in their two
timestamp columns but have an empty duration).

Factored the check into a small `isInvertedRange(startedAt,
endedAt)` helper, exposed via `_internals` for unit testing.
Equality (zero-minute "Due on Receipt"-style entries) stays
allowed. Unparseable input returns false from the helper because
computeMinutes' own NaN guard already null-tags those rows;
flagging them at this layer too would be double-counting a
problem we'd 400 on elsewhere.

Four new unit tests on the helper cover the four classes:
missing-bound (false), happy-path including equality (false),
inverted (true), unparseable (false).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@CryptoJones CryptoJones merged commit 2533bf6 into master May 19, 2026
3 checks passed
@CryptoJones CryptoJones deleted the feat/timeentry-controller-rejects-merged-inverted-range branch May 19, 2026 07:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant