Skip to content
Merged
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
6671338
docs: P4.1 check-in loop design spec
Jul 17, 2026
901871b
docs: P4.1 check-in loop implementation plan
Jul 17, 2026
b9c9438
feat(backend): migration 000019, check-in settings endpoints
Jul 17, 2026
2d6e66b
feat(backend): check-in station registration, heartbeat, list
Jul 17, 2026
db36d24
fix(backend): return 400 not 500 for non-existent checkin-station zon…
Jul 17, 2026
b6b15fc
feat(backend): idempotent station check-in, undo, actions feed
Jul 17, 2026
715d4d0
feat(backend): log reprint as a checkin_actions row
Jul 17, 2026
12c4e49
fix(backend): validate reprint event_id/station_id belong to the atte…
Jul 17, 2026
257094a
feat(panel): check-in data layer + settings types
Jul 17, 2026
02e99c8
fix(panel): seed check-in settings cache with raw response, not pre-s…
Jul 17, 2026
16e18f2
feat(panel): check-in verdict mapping and flow hook
Jul 17, 2026
6ad501c
feat(panel): scan input modes (wedge, scanner, manual)
Jul 17, 2026
500dd7b
feat(panel): check-in station route, split layout, verdict card
Jul 17, 2026
d35c2a3
feat(panel): recent-scans rail with reprint/undo/details
Jul 17, 2026
06a4a15
fix(panel): prevent concurrent reprint/undo dialogs on the recent-sca…
Jul 17, 2026
aa4bd23
feat(panel): station degraded mode (banner, read-only search, recovery)
Jul 17, 2026
257b4d2
feat(panel): launch ceremony, station registration, workspace launch CTA
Jul 17, 2026
3e3045b
feat(panel): station heartbeat lifecycle
Jul 17, 2026
f4ae8f8
chore(panel): P4.1 final verification sweep
Jul 17, 2026
7681e29
fix(panel): wire manual_search_enabled and stop implicit prints from …
Jul 17, 2026
14d077e
fix(backend): PR #77 bot-review round — blocked-race guard, undo devi…
Jul 18, 2026
4e9cca2
fix(panel): PR #77 bot-review round — scan-loss handling, station-id …
Jul 18, 2026
ac8d757
fix(backend): PR #77 bot-review round 2 — fallback-retry gap, device-…
Jul 18, 2026
b5c8bff
fix(panel): PR #77 bot-review round 2 — printer/font readiness gating…
Jul 18, 2026
6f8b506
fix(panel): widen CI-flaky dismissal-guard test delays to 300ms
Jul 18, 2026
5f64792
fix(panel): PR #77 bot-review round 3 — per-scope settings/flow reset…
Jul 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 119 additions & 1 deletion backend/internal/handler/attendee_printed.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package handler

import (
"errors"
"log"
"net/http"

"idento/backend/internal/store"
Expand All @@ -16,6 +17,17 @@ type MarkAttendeePrintedResponse struct {
PrintedCount int `json:"printed_count"`
}

// markAttendeePrintedRequest is the OPTIONAL request body (P4.1 Task 4):
// when EventID is present, MarkAttendeePrinted also logs a checkin_actions
// ('reprint') feed row after the counter increment succeeds. Both fields
// are plain strings (not uuid.UUID) so a present-but-invalid value can be
// distinguished from an absent one and reported as its own 400, rather
// than failing json.Unmarshal itself.
type markAttendeePrintedRequest struct {
EventID *string `json:"event_id"`
StationID *string `json:"station_id"`
}

// MarkAttendeePrinted increments an attendee's printed_count by one and
// returns the new count. This backs the attendees table's existing
// "Printed" pill (models.Attendee.PrintedCount) — see reconciliation #6 in
Expand All @@ -25,6 +37,40 @@ type MarkAttendeePrintedResponse struct {
// rows, no dedupe/job-status tracking; the spec's "server-side print
// journal is out of scope" clause targets audit/dedupe journals, not this
// pre-existing counter.
//
// P4.1 Task 4 adds an OPTIONAL JSON body ({event_id?, station_id?}): when
// event_id is present, AFTER the counter increment succeeds, the handler
// also logs a checkin_actions ('reprint') row via store.InsertCheckinAction
// — this is how the station's recent-scans rail picks up a reprint. A
// body-less call (the pre-existing badge-editor bulk print path) stays
// counter-only, exactly as before. The body is parsed leniently: an absent
// body, an empty body, and a syntactically malformed body are ALL treated
// as "no context" (unknown fields are ignored by plain encoding/json
// decoding too) — the counter still increments in every case. A present
// event_id/station_id value is rejected with 400 in FOUR cases, ALL before
// the counter increments so a rejected request never partially applies:
// (1) station_id is present but event_id is absent — a caller supplying
// station_id clearly intended it to be logged, so silently discarding it
// (the reprint-logging path is gated on event_id != nil) would hide a
// client-side mistake rather than surface it (PR #77 bot-review round,
// Finding D; checked FIRST, before the event_id-parsing cases below); (2)
// event_id fails uuid.Parse, or (3) it parses but doesn't belong to the
// attendee's own event — event_id is NEVER trusted as the source of truth
// for which event the feed row belongs to (fix round 1: the body used to
// be passed straight to InsertCheckinAction, letting an authenticated
// caller who legitimately owns the attendee log a 'reprint' row into an
// arbitrary OTHER event's/tenant's checkin_actions feed). This mirrors the
// same-file precedent set by StationCheckin/UndoCheckin (checkin.go) and
// BadgeZPL (badge_zpl.go), which all 400 with "Attendee does not belong to
// this event" on an attendee/event scope mismatch rather than silently
// substituting the correct event. (4) station_id, when present alongside a
// valid event_id, is validated the same way its siblings do — via
// resolveCheckinStation, 400ing "Station not found in event" for a station
// belonging to a different event. Once all four checks pass, logging
// itself is best-effort: the counter has already committed by the time
// logging is attempted, so a failure resolving staff claims or writing the
// feed row is logged server-side and never turns the response into an
// error or changes its shape.
func (h *Handler) MarkAttendeePrinted(c echo.Context) error {
attendeeID, err := uuid.Parse(c.Param("attendee_id"))
if err != nil {
Expand All @@ -33,10 +79,68 @@ func (h *Handler) MarkAttendeePrinted(c echo.Context) error {

// Existence/ownership established FIRST (house convention: 404-masks a
// missing attendee identically to a foreign one — no existence oracle).
if _, err := h.requireAttendeeOwnership(c, attendeeID); err != nil {
// The returned attendee is kept (not discarded) — its EventID is the
// ONLY trustworthy event context for the feed row below; the request
// body's event_id is validated against it, never used on its own.
attendee, err := h.requireAttendeeOwnership(c, attendeeID)
if err != nil {
return writeErr(c, err)
}

// The optional print-context body: a bind error (empty body, or
// syntactically malformed JSON) is swallowed here — req simply stays
// its zero value (both fields nil), which the logic below treats
// identically to "no body at all" (lenient, back-compat).
var req markAttendeePrintedRequest
if err := c.Bind(&req); err != nil {
req = markAttendeePrintedRequest{}
}

// station_id is only ever meaningful alongside event_id (it's used
// solely by the feed-row insert below, gated on eventID != nil) — a
// caller supplying station_id without event_id clearly intended it to
// be logged, so silently discarding it would hide a client-side
// mistake rather than surface it (PR #77 bot-review round, Finding D).
// This check runs BEFORE the event_id-mismatch-with-attendee
// validation below so a malformed combination never partially applies.
if req.StationID != nil && req.EventID == nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "event_id is required when station_id is supplied"})
}

var eventID *uuid.UUID
if req.EventID != nil {
parsed, err := uuid.Parse(*req.EventID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid event_id"})
}
// A same-tenant attendee whose REAL event differs from the body's
// event_id is a 400, not a silent substitution — same treatment
// StationCheckin/UndoCheckin/BadgeZPL give an attendee/event scope
// mismatch (checkin.go, badge_zpl.go).
if parsed != attendee.EventID {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Attendee does not belong to this event"})
}
eventID = &parsed
}
var stationID *uuid.UUID
if req.StationID != nil {
parsed, err := uuid.Parse(*req.StationID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid station_id"})
}
stationID = &parsed
}
// station_id is only meaningful alongside a validated event_id (it's
// only ever used by the feed-row insert below, gated on eventID != nil)
// — reuse the exact same-package check StationCheckin/UndoCheckin use
// (checkin.go:76-88) rather than re-implementing "does this station
// belong to this event".
if eventID != nil && stationID != nil {
if _, err := h.resolveCheckinStation(c, *eventID, stationID); err != nil {
return writeErr(c, err)
}
}
Comment thread
thevladbog marked this conversation as resolved.

newCount, err := h.Store.IncrementAttendeePrintedCount(c.Request().Context(), attendeeID)
if err != nil {
// ErrAttendeeNotFound is reachable only via the soft-delete race:
Expand All @@ -51,5 +155,19 @@ func (h *Handler) MarkAttendeePrinted(c echo.Context) error {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to update printed count"})
}

// Reprint-logging is best-effort and only attempted when event_id was
// supplied — the counter above has ALREADY committed, so nothing here
// can turn a successful print-count bump into an error response.
if eventID != nil {
claims, err := claimsFromContext(c)
if err != nil {
log.Printf("mark attendee printed: skip reprint log, no claims: %v", err)
} else if staffUserID, err := uuid.Parse(claims.UserID); err != nil {
log.Printf("mark attendee printed: skip reprint log, invalid staff user id: %v", err)
} else if err := h.Store.InsertCheckinAction(c.Request().Context(), *eventID, attendeeID, "reprint", stationID, staffUserID); err != nil {
log.Printf("mark attendee printed: failed to log reprint checkin_actions row: %v", err)
}
}

return c.JSON(http.StatusOK, MarkAttendeePrintedResponse{PrintedCount: newCount})
}
Loading
Loading