Skip to content

feat(api): accept a result proven by two check-ins instead of an operator - #327

Closed
patrick-hermann-sva wants to merge 4 commits into
feat/rfid-fob-resolvefrom
feat/checkin-results
Closed

patrick-hermann-sva wants to merge 4 commits into
feat/rfid-fob-resolvefrom
feat/checkin-results

Conversation

@patrick-hermann-sva

@patrick-hermann-sva patrick-hermann-sva commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #326 (fob resolve, which creates the check-ins this cites). Once #326 is merged I rebase this onto main; until then the diff against main shows both.

Builds the operator-free half of #320 as ADR-0024 decides it. Auto-accept is not in here (see below).

What changes

POST /api/results takes two check-ins instead of an operator.

{"home_id": "…", "away_id": "…",
 "home_checkin_id": "…", "away_checkin_id": "…",
 "sets": [[11, 9], [11, 7]], "best_of": 3, "points_to_win": 11}

The ids are the checkin_ids that POST /api/identities/rfid/resolve returned. Accepted only if:

Rule Refusal
both ids present and different, no operator_id alongside 400
both check-ins exist 422, names the id
home's check-in is home's, away's is away's (swapped is refused) 422
the two are at different halves (which half is home's does not matter) 422
neither is older than checkin.Validity = 3 h 422
neither already carries a result 409, so a Zählwerk retrying after a lost answer learns the first copy arrived

The operator path is unchanged; without check-ins operator_id is still required.

The match is stored pending, entered_via = 'checkin', reported_by null, home_checkin_id/away_checkin_id set. Either player confirms (scoring.load already allows every participant who is not the reporter).

Schema (20261015090000, additive):

  • entered_via accepts 'checkin'
  • reported_by drops NOT NULL, with check (reported_by is not null or entered_via = 'checkin')
  • two FK columns to checkins, with a CHECK that they are set exactly on checkin rows (each one separately)
  • partial unique indexes on each

CheckinRepository.Lock reads both check-ins for update, in id order, and then asks in a separate statement whether a match cites them. A separate statement is needed because under read committed, a statement that waited for the lock still sees its old snapshot.

One trap fixed on the way. PendingFor/PendingCountFor filtered with reported_by <> $1. For a null reporter that is null, so the match would have reached neither player. It is now is distinct from.

Pending list. A match without a reporter reads „Das Zählwerk hat eingetragen: 2:1 für Anna“. The loss case now credits the opponent, not the reporter. That was also wrong for kiosk and scoreboard results, whose reporter is the operator („3:1 für “). /admin labels the origin „Zählwerk, angemeldet“.

Measurement. definition-of-done.sql gets an "of which checkin" column. The verdict already counts only entered_via = 'player', so these rows stay out of it, as ADR-0024 asks. schmetterpause_matches_open exports entered_via="checkin", zero included.

Verified

  • go test -race ./... green, golangci-lint v2.14.0 0 issues, task ci (Dagger) green locally, including verify against the built image.
  • Postgres integration tests on postgres:17-alpine: round trip, both players see the match in PendingFor, the schema refuses a reporter-less player row, a checkin row without check-ins, one check-in on a player row, and check-ins on a scoreboard row; Lock marks used ones; reuse → ErrConflict.
  • The two guards fail without their fix. With <> restored, TestACheckedInMatchHasNoReporterAndCitesItsCheckins fails (PendingFor(Anna) = 0). Without for update, TestTwoResultsCannotShareACheckin fails: three of four concurrent transactions run into the index. The test had to be made to overlap (200 ms between read and write) before it could tell the two apart. Its first version passed either way.
  • The migration's Down then Up, in a rolled-back transaction on an empty database.
  • definition-of-done.sql runs and shows the new column (empty database, so syntax and shape only).
  • End to end through the routes in internal/server/checkin_results_test.go: two fobs linked and resolved at both halves, the result posted, both players' pending lists show it, Bea confirms, the match is confirmed.
  • Screenshot of the pending entry at 390 and 1000 px, no horizontal overflow (scrollWidth = clientWidth).

Not verified: nothing ran against a real Zählwerk. zaehlwerk#92 and zaehlwerk-firmware#63 are not built, so DoD point 3 of #323 (a checked-in match on the office's instance) is still open. The migration has not run against a copy of production data. It touches no rows, but the new CHECK constraints are validated over all existing matches, and every existing row has entered_via ≠ checkin with null check-in ids, which satisfies them.

Left open, deliberately

Refs #320, #323, #326

🤖 Generated with Claude Code

patrick-hermann-sva and others added 3 commits October 14, 2026 10:59
ADR-0024: a result from a match both players checked in to has no
operator. entered_via gains 'checkin', reported_by may be null for
exactly that value, and the match points at its two check-ins, each of
which carries at most one result (partial unique indexes, plus
CheckinRepository.Lock to serialise the read-then-write).

PendingFor and PendingCountFor compare with "is distinct from": with
"<>" a null reporter hid the match from both players, the only two
people who can confirm it.

Refs #320

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ator

POST /api/results takes home_checkin_id and away_checkin_id in place of
operator_id. internal/checkin verifies them: own player each, different
halves, unused, at most three hours old. The match is stored pending
with entered_via 'checkin' and no reporter, so either player confirms.
A reused check-in is 409, so a retrying Zählwerk learns the first copy
arrived. Sending both proofs, or one check-in, is 400.

The pending list says the Zählwerk entered it and credits a loss to the
opponent rather than to the reporter, which was also wrong for kiosk and
scoreboard results, whose reporter is the operator.

Refs #320, #323

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
definition-of-done.sql gets an "of which checkin" column; the verdict
already counts only entered_via = 'player'. The unsettled-results metric
exports the new origin, at zero too. admin-and-observers.md says what a
null reported_by means.

Refs #320, #323

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The players stood at the table and know they checked in; the line now
has the same shape as "Bodo hat eingetragen".

Refs #320

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@patrick-hermann-sva

Copy link
Copy Markdown
Contributor Author

Superseded by the PR opened from the same branch against main; this one was closed automatically when its base branch was deleted.

This branch was successfully deployed

1 active deployment
linting — b5b99420 Deployed Oct 14, 2026 by patrick-hermann-sva via terraform-test / Terraform-Test #592
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant