diff --git a/backend/internal/handler/attendee_printed.go b/backend/internal/handler/attendee_printed.go index 3c996f67..1ff15eb7 100644 --- a/backend/internal/handler/attendee_printed.go +++ b/backend/internal/handler/attendee_printed.go @@ -2,6 +2,7 @@ package handler import ( "errors" + "log" "net/http" "idento/backend/internal/store" @@ -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 @@ -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 { @@ -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) + } + } + newCount, err := h.Store.IncrementAttendeePrintedCount(c.Request().Context(), attendeeID) if err != nil { // ErrAttendeeNotFound is reachable only via the soft-delete race: @@ -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}) } diff --git a/backend/internal/handler/checkin.go b/backend/internal/handler/checkin.go new file mode 100644 index 00000000..de874370 --- /dev/null +++ b/backend/internal/handler/checkin.go @@ -0,0 +1,270 @@ +package handler + +import ( + "errors" + "net/http" + "strconv" + "time" + + "idento/backend/internal/models" + "idento/backend/internal/store" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/labstack/echo/v4" +) + +// checkinActionsDefaultLimit is both the DEFAULT and the MAX for GET +// /api/events/{event_id}/checkin-actions' limit query param — the station's +// recent-scans rail (P4.1 board 2c) only ever shows the last 50, so a +// caller-supplied limit is clamped down to this rather than rejected. +const checkinActionsDefaultLimit = 50 + +// StationCheckinRequest is the request body for POST +// /api/events/{event_id}/checkin (P4.1 Task 3). station_id, when present, +// must belong to the same event (400 "Station not found in event" +// otherwise) — it is optional because a station-less panel check-in (no +// checkin_stations row registered) is still valid. +type StationCheckinRequest struct { + AttendeeID uuid.UUID `json:"attendee_id"` + StationID *uuid.UUID `json:"station_id,omitempty"` +} + +// CheckinInfo is the "checkin" block of StationCheckinResponse — the +// first-scan metadata. For outcome "checked_in" it is THIS scan; for +// "already_checked_in" it is the ORIGINAL scan, never overwritten. It is +// nil for outcome "blocked" (the station renders block_reason from +// attendee instead). +type CheckinInfo struct { + At time.Time `json:"at"` + ByEmail string `json:"by_email"` + PointName *string `json:"point_name,omitempty"` +} + +// StationCheckinResponse is the response for POST +// /api/events/{event_id}/checkin. +type StationCheckinResponse struct { + Outcome string `json:"outcome"` + Attendee *models.Attendee `json:"attendee"` + Checkin *CheckinInfo `json:"checkin"` +} + +// UndoCheckinRequest is the request body for POST +// /api/events/{event_id}/checkin/undo. +type UndoCheckinRequest struct { + AttendeeID uuid.UUID `json:"attendee_id"` + StationID *uuid.UUID `json:"station_id,omitempty"` +} + +// UndoCheckinResponse is the response for POST +// /api/events/{event_id}/checkin/undo. +type UndoCheckinResponse struct { + Attendee *models.Attendee `json:"attendee"` +} + +// CheckinActionsResponse is the response envelope for GET +// /api/events/{event_id}/checkin-actions. +type CheckinActionsResponse struct { + Actions []store.CheckinActionRow `json:"actions"` +} + +// resolveCheckinStation validates a caller-supplied station_id (when +// present) against eventID and returns its display name — used both to +// populate checked_in_point_name (stationCheckin) and to reject a foreign +// station_id (400), shared by stationCheckin and undoCheckin. A nil +// stationID is valid (station-less check-in) and returns ("", nil). +func (h *Handler) resolveCheckinStation(c echo.Context, eventID uuid.UUID, stationID *uuid.UUID) (string, error) { + if stationID == nil { + return "", nil + } + station, err := h.Store.GetCheckinStationByID(c.Request().Context(), *stationID) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return "", newHTTPError(http.StatusInternalServerError, "Failed to verify station") + } + if station == nil || station.EventID != eventID { + return "", newHTTPError(http.StatusBadRequest, "Station not found in event") + } + return station.Name, nil +} + +// StationCheckin performs one station's idempotent single-scan check-in +// (P4.1 Task 3) — the zero-double-checkin guarantee at the source. Handler +// order: parse → requireEventOwnership → fetch the attendee via +// requireAttendeeOwnership (404-masked) → if attendee.Blocked, return the +// distinct "blocked" outcome WITHOUT ever attempting a check-in → else +// resolve/validate station_id (400 if foreign) and call +// store.CheckInAttendee. Never touches printed_count and never prints — +// printing is a separate client step gated on the "checked_in" outcome. +func (h *Handler) StationCheckin(c echo.Context) error { + eventID, err := uuid.Parse(c.Param("event_id")) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid event ID"}) + } + if _, err := h.requireEventOwnership(c, eventID); err != nil { + return writeErr(c, err) + } + + var req StationCheckinRequest + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request body"}) + } + if req.AttendeeID == uuid.Nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "attendee_id is required"}) + } + + // Ownership/existence established before anything else (house + // convention: 404-masks a missing attendee identically to a foreign + // one — no existence oracle). A same-tenant attendee belonging to a + // DIFFERENT event than the path's event_id is a 400, not a 404 — it + // genuinely exists, it's just the wrong scope (badge_zpl.go precedent). + attendee, err := h.requireAttendeeOwnership(c, req.AttendeeID) + if err != nil { + return writeErr(c, err) + } + if attendee.EventID != eventID { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Attendee does not belong to this event"}) + } + + // Blocked short-circuits BEFORE any station validation or store call — + // a blocked attendee is never checked in, regardless of station_id. + if attendee.Blocked { + return c.JSON(http.StatusOK, StationCheckinResponse{Outcome: "blocked", Attendee: attendee, Checkin: nil}) + } + + stationName, err := h.resolveCheckinStation(c, eventID, req.StationID) + if err != nil { + return writeErr(c, err) + } + + claims, err := claimsFromContext(c) + if err != nil { + return writeErr(c, err) + } + staffUserID, err := uuid.Parse(claims.UserID) + if err != nil { + return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid token"}) + } + staffUser, err := h.Store.GetUserByID(c.Request().Context(), staffUserID) + if err != nil || staffUser == nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to resolve staff user"}) + } + + outcome, updated, err := h.Store.CheckInAttendee(c.Request().Context(), eventID, req.AttendeeID, req.StationID, staffUserID, staffUser.Email, stationName) + if err != nil { + // ErrAttendeeNotFound is reachable only via the soft-delete race: + // the ownership pre-check above passed, then a concurrent DELETE + // set deleted_at before the guarded UPDATE ran (attendee_printed.go + // precedent) — map it to the same 404 masking, not a 500. + if errors.Is(err, store.ErrAttendeeNotFound) { + return c.JSON(http.StatusNotFound, map[string]string{"error": "Attendee not found"}) + } + // ErrCheckinConflict (PR #77 bot-review round 2, Finding 1) is the + // store's bounded-retry exhaustion: the guarded UPDATE and its ONE + // retry both landed on "neither checked in nor blocked" — an + // extremely narrow, transient race. 409, mirroring + // store.ErrVersionConflict's precedent (badge_template.go), signals + // the caller (the station) should retry the scan. + if errors.Is(err, store.ErrCheckinConflict) { + return c.JSON(http.StatusConflict, map[string]string{"error": "Check-in conflict, please retry"}) + } + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to check in attendee"}) + } + + var checkin *CheckinInfo + if updated.CheckedInAt != nil { + byEmail := "" + if updated.CheckedInByEmail != nil { + byEmail = *updated.CheckedInByEmail + } + checkin = &CheckinInfo{At: *updated.CheckedInAt, ByEmail: byEmail, PointName: updated.CheckedInPointName} + } + + return c.JSON(http.StatusOK, StationCheckinResponse{Outcome: outcome, Attendee: updated, Checkin: checkin}) +} + +// UndoCheckin clears a check-in (P4.1 Task 3) — idempotent: undoing an +// attendee who is already not checked in still returns 200 with no feed +// row written (store.UndoCheckin's contract). +func (h *Handler) UndoCheckin(c echo.Context) error { + eventID, err := uuid.Parse(c.Param("event_id")) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid event ID"}) + } + if _, err := h.requireEventOwnership(c, eventID); err != nil { + return writeErr(c, err) + } + + var req UndoCheckinRequest + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request body"}) + } + if req.AttendeeID == uuid.Nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "attendee_id is required"}) + } + + attendee, err := h.requireAttendeeOwnership(c, req.AttendeeID) + if err != nil { + return writeErr(c, err) + } + if attendee.EventID != eventID { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Attendee does not belong to this event"}) + } + + if _, err := h.resolveCheckinStation(c, eventID, req.StationID); err != nil { + return writeErr(c, err) + } + + claims, err := claimsFromContext(c) + if err != nil { + return writeErr(c, err) + } + staffUserID, err := uuid.Parse(claims.UserID) + if err != nil { + return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid token"}) + } + + updated, err := h.Store.UndoCheckin(c.Request().Context(), eventID, req.AttendeeID, req.StationID, staffUserID) + if err != nil { + if errors.Is(err, store.ErrAttendeeNotFound) { + return c.JSON(http.StatusNotFound, map[string]string{"error": "Attendee not found"}) + } + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to undo check-in"}) + } + + return c.JSON(http.StatusOK, UndoCheckinResponse{Attendee: updated}) +} + +// GetCheckinActions returns an event's check-in/undo/reprint feed, newest +// first (P4.1 Task 3) — backs the station's recent-scans rail. limit +// defaults to and is clamped to checkinActionsDefaultLimit; an +// invalid/non-positive limit query param is ignored (falls back to the +// default) rather than 400ing a read-only feed endpoint. +func (h *Handler) GetCheckinActions(c echo.Context) error { + eventID, err := uuid.Parse(c.Param("event_id")) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid event ID"}) + } + if _, err := h.requireEventOwnership(c, eventID); err != nil { + return writeErr(c, err) + } + + limit := checkinActionsDefaultLimit + if lp := c.QueryParam("limit"); lp != "" { + if n, err := strconv.Atoi(lp); err == nil && n > 0 { + limit = n + } + } + if limit > checkinActionsDefaultLimit { + limit = checkinActionsDefaultLimit + } + + actions, err := h.Store.GetCheckinActions(c.Request().Context(), eventID, limit) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to fetch check-in actions"}) + } + if actions == nil { + actions = []store.CheckinActionRow{} + } + + return c.JSON(http.StatusOK, CheckinActionsResponse{Actions: actions}) +} diff --git a/backend/internal/handler/checkin_settings.go b/backend/internal/handler/checkin_settings.go new file mode 100644 index 00000000..6f01d09f --- /dev/null +++ b/backend/internal/handler/checkin_settings.go @@ -0,0 +1,156 @@ +package handler + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "net/http" + + "idento/backend/internal/store" + + "github.com/google/uuid" + "github.com/labstack/echo/v4" +) + +// CheckinSettingsResponse is the response body for GET/PUT +// /api/events/{id}/checkin-settings. Settings is nil (serializes to JSON +// null) when the event has no check-in settings saved yet — mirrors +// BadgeTemplateResponse's null-until-saved contract, minus the version +// (check-in settings are operator-only config with no concurrent-editor +// conflict class to guard against). +type CheckinSettingsResponse struct { + Settings json.RawMessage `json:"settings"` +} + +// CheckinSettingsPutRequest is the request body for PUT +// /api/events/{id}/checkin-settings. Settings is stored verbatim (the +// exact raw bytes the client sent) after being validated against a parsed +// COPY — see validateCheckinSettings. +type CheckinSettingsPutRequest struct { + Settings json.RawMessage `json:"settings"` +} + +// checkinSettingsShape is the strict shape CheckinSettingsPutRequest.Settings +// must decode into: all four fields required (pointers so a missing key is +// distinguishable from an explicit zero value), unknown fields rejected — +// mirrors the openapi.yaml CheckinSettings schema's +// `additionalProperties: false`. It exists purely for validation; the raw +// request bytes (not a re-marshaling of this struct) are what gets +// persisted, matching PutBadgeTemplate's verbatim-storage contract. +type checkinSettingsShape struct { + PrintOnCheckin *bool `json:"print_on_checkin"` + VerdictAutoDismissSec *int `json:"verdict_auto_dismiss_sec"` + ScanInput *string `json:"scan_input"` + ManualSearchEnabled *bool `json:"manual_search_enabled"` +} + +// validCheckinScanInputs enumerates the only accepted values of +// checkinSettingsShape.ScanInput (openapi.yaml CheckinSettings.scan_input +// enum). +var validCheckinScanInputs = map[string]bool{ + "wedge": true, + "scanner": true, + "manual": true, +} + +// validateCheckinSettings decodes raw into checkinSettingsShape (rejecting +// unknown fields) and checks field-level constraints: all four fields +// present, verdict_auto_dismiss_sec in [1, 30], scan_input one of +// wedge/scanner/manual. Returns a non-nil, human-readable error on the +// first violation found. +func validateCheckinSettings(raw json.RawMessage) error { + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + var shape checkinSettingsShape + if err := dec.Decode(&shape); err != nil { + return fmt.Errorf("invalid settings: %w", err) + } + if shape.PrintOnCheckin == nil { + return errors.New("print_on_checkin is required") + } + if shape.VerdictAutoDismissSec == nil { + return errors.New("verdict_auto_dismiss_sec is required") + } + if *shape.VerdictAutoDismissSec < 1 || *shape.VerdictAutoDismissSec > 30 { + return errors.New("verdict_auto_dismiss_sec must be between 1 and 30") + } + if shape.ScanInput == nil { + return errors.New("scan_input is required") + } + if !validCheckinScanInputs[*shape.ScanInput] { + return errors.New("scan_input must be one of wedge, scanner, manual") + } + if shape.ManualSearchEnabled == nil { + return errors.New("manual_search_enabled is required") + } + return nil +} + +// GetCheckinSettings returns the event's check-in settings (verbatim +// JSON). Settings is null when the event has never had settings saved. +func (h *Handler) GetCheckinSettings(c echo.Context) error { + eventID, err := uuid.Parse(c.Param("id")) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid event ID"}) + } + + // Ownership must be established before any store call (BadgeTemplate + // precedent): GetCheckinSettings collapses "no such event" and "no + // settings yet" into the same nil value, so calling it first would + // mask a foreign/deleted event as an empty-settings 200 instead of 404. + if _, err := h.requireEventOwnership(c, eventID); err != nil { + return writeErr(c, err) + } + + settings, err := h.Store.GetCheckinSettings(c.Request().Context(), eventID) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load check-in settings"}) + } + + return c.JSON(http.StatusOK, CheckinSettingsResponse{Settings: settings}) +} + +// PutCheckinSettings validates and saves the event's check-in settings. +// Storage is verbatim: the persisted bytes are the request's raw +// "settings" JSON, untouched — only a parsed COPY is validated via +// validateCheckinSettings. +func (h *Handler) PutCheckinSettings(c echo.Context) error { + eventID, err := uuid.Parse(c.Param("id")) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid event ID"}) + } + + // Ownership first (see GetCheckinSettings's comment): a deleted/foreign + // event must be caught here as a 404, not surfaced later as a + // misleading 500 from the guarded UPDATE's silent no-op. + if _, err := h.requireEventOwnership(c, eventID); err != nil { + return writeErr(c, err) + } + + var req CheckinSettingsPutRequest + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request body"}) + } + if len(req.Settings) == 0 { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "settings is required"}) + } + if err := validateCheckinSettings(req.Settings); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + + if err := h.Store.UpdateCheckinSettings(c.Request().Context(), eventID, req.Settings); err != nil { + // ErrEventNotFound is reachable only via the soft-delete race + // (PR #77 bot-review round, Finding C): the requireEventOwnership + // pre-check above passed, then a concurrent DELETE soft-deleted the + // event before the guarded UPDATE ran — map it to the same 404 + // masking (and wording) as requireEventOwnership, not a fabricated + // 200 with settings that were never actually persisted. + if errors.Is(err, store.ErrEventNotFound) { + return c.JSON(http.StatusNotFound, map[string]string{"error": "Event not found"}) + } + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to save check-in settings"}) + } + + return c.JSON(http.StatusOK, CheckinSettingsResponse(req)) +} diff --git a/backend/internal/handler/checkin_stations.go b/backend/internal/handler/checkin_stations.go new file mode 100644 index 00000000..5dc95323 --- /dev/null +++ b/backend/internal/handler/checkin_stations.go @@ -0,0 +1,142 @@ +package handler + +import ( + "errors" + "net/http" + "strings" + + "idento/backend/internal/models" + "idento/backend/internal/store" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/labstack/echo/v4" +) + +// CheckinStationRegisterRequest is the request body for POST +// /api/events/{event_id}/checkin-stations. Name identifies the station +// (UNIQUE per event, enforced by the checkin_stations table) — +// registering the SAME name again is an upsert (see +// store.UpsertCheckinStation), never a duplicate. ZoneID, when present, +// must belong to the same event (validated against +// GetEventZoneByID before the store call). +type CheckinStationRegisterRequest struct { + Name string `json:"name"` + ZoneID *uuid.UUID `json:"zone_id,omitempty"` +} + +// CheckinStationResponse is the response envelope for POST +// /api/events/{event_id}/checkin-stations. +type CheckinStationResponse struct { + Station *models.CheckinStation `json:"station"` +} + +// CheckinStationListResponse is the response envelope for GET +// /api/events/{event_id}/checkin-stations. +type CheckinStationListResponse struct { + Stations []*models.CheckinStation `json:"stations"` +} + +// RegisterCheckinStation upserts a named check-in station for an event +// (P4.1 Task 2): a fresh name creates a new station; re-registering the +// SAME name updates its zone binding and refreshes last_seen_at rather +// than erroring or creating a duplicate row. +func (h *Handler) RegisterCheckinStation(c echo.Context) error { + eventID, err := uuid.Parse(c.Param("event_id")) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid event ID"}) + } + + // Ownership first (badge_template/checkin_settings precedent): a + // deleted/foreign event must be a 404, not a misleading 500 or a + // station silently registered against someone else's event. + if _, err := h.requireEventOwnership(c, eventID); err != nil { + return writeErr(c, err) + } + + var req CheckinStationRegisterRequest + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request body"}) + } + // Trim before both the emptiness check and persistence (super_admin.go + // CreateTenant precedent): the UNIQUE(event_id, name) upsert must key on + // the same name regardless of incidental leading/trailing whitespace — + // otherwise " Main Entrance" and "Main Entrance" would silently create + // two stations instead of one being re-registered. + name := strings.TrimSpace(req.Name) + if name == "" { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "name is required"}) + } + + if req.ZoneID != nil { + // A non-existent zone_id surfaces pgx.ErrNoRows from the store (it + // does not normalize no-rows to (nil, nil)) — fold that into the + // same 400 "not found" branch as a real row belonging to a + // different event (checkins_override.go / checkins_batch.go + // precedent), while still surfacing a genuine unexpected DB error + // as 500. + zone, err := h.Store.GetEventZoneByID(c.Request().Context(), *req.ZoneID) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to verify zone"}) + } + if zone == nil || zone.EventID != eventID { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Zone not found in event"}) + } + } + + station, err := h.Store.UpsertCheckinStation(c.Request().Context(), eventID, name, req.ZoneID) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to register check-in station"}) + } + + return c.JSON(http.StatusOK, CheckinStationResponse{Station: station}) +} + +// HeartbeatCheckinStation refreshes a check-in station's last_seen_at +// (P4.1 Task 2), scoped to the event in the path so a station id from a +// different event can never be touched. +func (h *Handler) HeartbeatCheckinStation(c echo.Context) error { + eventID, err := uuid.Parse(c.Param("event_id")) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid event ID"}) + } + if _, err := h.requireEventOwnership(c, eventID); err != nil { + return writeErr(c, err) + } + + stationID, err := uuid.Parse(c.Param("id")) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid station ID"}) + } + + if err := h.Store.HeartbeatCheckinStation(c.Request().Context(), eventID, stationID); err != nil { + if errors.Is(err, store.ErrCheckinStationNotFound) { + return c.JSON(http.StatusNotFound, map[string]string{"error": "Check-in station not found"}) + } + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to update check-in station"}) + } + + return c.NoContent(http.StatusNoContent) +} + +// ListCheckinStations returns every check-in station registered for an +// event (P4.1 Task 2). +func (h *Handler) ListCheckinStations(c echo.Context) error { + eventID, err := uuid.Parse(c.Param("event_id")) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid event ID"}) + } + if _, err := h.requireEventOwnership(c, eventID); err != nil { + return writeErr(c, err) + } + + stations, err := h.Store.ListCheckinStations(c.Request().Context(), eventID) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to list check-in stations"}) + } + if stations == nil { + stations = []*models.CheckinStation{} + } + + return c.JSON(http.StatusOK, CheckinStationListResponse{Stations: stations}) +} diff --git a/backend/internal/handler/handler.go b/backend/internal/handler/handler.go index f288b9e2..45c45aad 100644 --- a/backend/internal/handler/handler.go +++ b/backend/internal/handler/handler.go @@ -78,6 +78,14 @@ func (h *Handler) RegisterRoutes(e *echo.Echo, mode string) { api.POST("/events/:id/badge-zpl", h.BadgeZPL) api.GET("/events/:id/badge-template", h.GetBadgeTemplate) api.PUT("/events/:id/badge-template", h.PutBadgeTemplate) + api.GET("/events/:id/checkin-settings", h.GetCheckinSettings) + api.PUT("/events/:id/checkin-settings", h.PutCheckinSettings) + api.POST("/events/:event_id/checkin-stations", h.RegisterCheckinStation) + api.GET("/events/:event_id/checkin-stations", h.ListCheckinStations) + api.POST("/events/:event_id/checkin-stations/:id/heartbeat", h.HeartbeatCheckinStation) + api.POST("/events/:event_id/checkin", h.StationCheckin) + api.POST("/events/:event_id/checkin/undo", h.UndoCheckin) + api.GET("/events/:event_id/checkin-actions", h.GetCheckinActions) api.GET("/events/:id/readiness", h.GetEventReadiness) api.GET("/events/:event_id/stats", h.GetEventStats) api.GET("/events/:event_id/staff", h.GetEventStaff) diff --git a/backend/internal/handler/openapi_contract_attendee_printed_p4_test.go b/backend/internal/handler/openapi_contract_attendee_printed_p4_test.go new file mode 100644 index 00000000..79ea3c75 --- /dev/null +++ b/backend/internal/handler/openapi_contract_attendee_printed_p4_test.go @@ -0,0 +1,529 @@ +package handler + +import ( + "errors" + "net/http" + "testing" + "time" + + "idento/backend/internal/models" + "idento/backend/internal/store" + + "github.com/google/uuid" + "github.com/labstack/echo/v4" +) + +// --- P4.1 Task 4: reprint feed row on /printed --- +// +// These tests cover the OPTIONAL {event_id?, station_id?} body added to the +// pre-existing markAttendeePrinted endpoint (its unconditional-counter +// tests live in openapi_contract_attendee_printed_p3_test.go, untouched by +// this task). Judgment call (documented per the task brief): the body is +// parsed leniently — unknown fields and syntactically malformed JSON are +// both swallowed (the counter still increments, no body context is +// derived) — the ONLY 400 this body can trigger is a present event_id/ +// station_id value that fails uuid.Parse. + +// newMarkPrintedHandler wires a fakeStore for markAttendeePrinted + +// getCheckinActions sharing ONE in-memory `actions` slice — insertCheckinAction +// appends to it, getCheckinActions reads it back — so a test can prove a +// reprint row landed by calling GetCheckinActions on the SAME handler/store +// afterward (the brief's prescribed proof, reusing Task 3's feed list +// rather than inspecting fakeStore internals directly). getCheckinStationByID +// defaults to "any requested station_id belongs to this event" — fix round +// 1 added a resolveCheckinStation call gated on event_id/station_id both +// being present, so any test driving a station_id through this helper needs +// a station lookup wired up even if it isn't the thing under test. +func newMarkPrintedHandler(t *testing.T, event *models.Event, attendee *models.Attendee, incrementCount int) (*Handler, *[]store.CheckinActionRow) { + t.Helper() + actions := []store.CheckinActionRow{} + h := New(&fakeStore{ + getEventByID: func(uuid.UUID) (*models.Event, error) { return event, nil }, + getAttendeeByID: func(uuid.UUID) (*models.Attendee, error) { return attendee, nil }, + getCheckinStationByID: func(id uuid.UUID) (*models.CheckinStation, error) { + if event == nil { + return nil, nil + } + return &models.CheckinStation{ID: id, EventID: event.ID, Name: "Main Entrance"}, nil + }, + incrementAttendeePrintedCount: func(attendeeID uuid.UUID) (int, error) { + if attendee != nil && attendeeID != attendee.ID { + t.Fatalf("IncrementAttendeePrintedCount called with %s, want %s", attendeeID, attendee.ID) + } + return incrementCount, nil + }, + insertCheckinAction: func(eventID, attendeeID uuid.UUID, action string, stationID *uuid.UUID, staffUserID uuid.UUID) error { + actions = append(actions, store.CheckinActionRow{ + ID: uuid.New(), + Action: action, + StationID: stationID, + CreatedAt: time.Now(), + Attendee: store.CheckinActionAttendee{ID: attendeeID}, + }) + return nil + }, + getCheckinActions: func(uuid.UUID, int) ([]store.CheckinActionRow, error) { + return actions, nil + }, + }) + return h, &actions +} + +func markPrintedPath(attendeeID uuid.UUID) string { + return "/api/attendees/" + attendeeID.String() + "/printed" +} + +func setMarkPrintedPathParams(c echo.Context, attendeeID uuid.UUID) { + c.SetPath("/api/attendees/:attendee_id/printed") + c.SetParamNames("attendee_id") + c.SetParamValues(attendeeID.String()) +} + +// TestOpenAPIContract_MarkAttendeePrinted_ReprintBodyLogsFeedRow proves the +// core P4.1 Task 4 behavior end-to-end: a body carrying {event_id, +// station_id} bumps the counter AND logs a 'reprint' checkin_actions row — +// verified by calling GetCheckinActions on the SAME fakeStore afterward +// (the actions list, not a fakeStore-internal spy). +func TestOpenAPIContract_MarkAttendeePrinted_ReprintBodyLogsFeedRow(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + attendee := contractAttendee(event.ID) + stationID := uuid.New() + + h, _ := newMarkPrintedHandler(t, event, attendee, 3) + e := echo.New() + path := markPrintedPath(attendee.ID) + body := `{"event_id":"` + event.ID.String() + `","station_id":"` + stationID.String() + `"}` + + c, rec := newAuthedContext(e, http.MethodPost, path, body, tenantID.String(), "admin") + setMarkPrintedPathParams(c, attendee.ID) + if err := h.MarkAttendeePrinted(c); err != nil { + t.Fatalf("MarkAttendeePrinted: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d, body=%s", rec.Code, rec.Body.String()) + } + var got MarkAttendeePrintedResponse + if err := jsonUnmarshalBody(rec, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got.PrintedCount != 3 { + t.Fatalf("printed_count = %d, want 3", got.PrintedCount) + } + validateResponse(t, http.MethodPost, path, rec) + + // Prove the feed row via GetCheckinActions on the same handler/store. + actionsPath := checkinActionsPath(event.ID) + c2, rec2 := newAuthedContext(e, http.MethodGet, actionsPath, "", tenantID.String(), "admin") + setCheckinActionsPathParams(c2, event.ID) + if err := h.GetCheckinActions(c2); err != nil { + t.Fatalf("GetCheckinActions: %v", err) + } + var actionsResp CheckinActionsResponse + if err := jsonUnmarshalBody(rec2, &actionsResp); err != nil { + t.Fatalf("unmarshal actions: %v", err) + } + if len(actionsResp.Actions) != 1 { + t.Fatalf("len(actions) = %d, want 1", len(actionsResp.Actions)) + } + row := actionsResp.Actions[0] + if row.Action != "reprint" { + t.Errorf("action = %q, want reprint", row.Action) + } + if row.StationID == nil || *row.StationID != stationID { + t.Errorf("station_id = %v, want %s", row.StationID, stationID) + } + if row.Attendee.ID != attendee.ID { + t.Errorf("attendee.id = %s, want %s", row.Attendee.ID, attendee.ID) + } +} + +// TestOpenAPIContract_MarkAttendeePrinted_NoBodyCounterOnlyNoFeedRow proves +// back-compat: the pre-existing badge-editor bulk print caller sends no +// body at all — the counter still bumps, but insertCheckinAction is never +// called (a call would fail the test via t.Fatal in the fake). +func TestOpenAPIContract_MarkAttendeePrinted_NoBodyCounterOnlyNoFeedRow(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + attendee := contractAttendee(event.ID) + + h := New(&fakeStore{ + getAttendeeByID: func(uuid.UUID) (*models.Attendee, error) { return attendee, nil }, + getEventByID: func(uuid.UUID) (*models.Event, error) { return event, nil }, + incrementAttendeePrintedCount: func(uuid.UUID) (int, error) { + return 1, nil + }, + insertCheckinAction: func(uuid.UUID, uuid.UUID, string, *uuid.UUID, uuid.UUID) error { + t.Fatal("InsertCheckinAction should not be called when no event_id was supplied") + return nil + }, + }) + e := echo.New() + path := markPrintedPath(attendee.ID) + + c, rec := newAuthedContext(e, http.MethodPost, path, "", tenantID.String(), "admin") + setMarkPrintedPathParams(c, attendee.ID) + if err := h.MarkAttendeePrinted(c); err != nil { + t.Fatalf("MarkAttendeePrinted: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d, body=%s", rec.Code, rec.Body.String()) + } + var got MarkAttendeePrintedResponse + if err := jsonUnmarshalBody(rec, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got.PrintedCount != 1 { + t.Fatalf("printed_count = %d, want 1", got.PrintedCount) + } + validateResponse(t, http.MethodPost, path, rec) +} + +// TestOpenAPIContract_MarkAttendeePrinted_MalformedBodyStillCounts covers +// the implementer's chosen leniency: syntactically broken JSON is +// swallowed (not 400) and the counter still increments, exactly like no +// body at all — the print flow must never be blocked by a bad print-context +// body. +func TestOpenAPIContract_MarkAttendeePrinted_MalformedBodyStillCounts(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + attendee := contractAttendee(event.ID) + + h := New(&fakeStore{ + getAttendeeByID: func(uuid.UUID) (*models.Attendee, error) { return attendee, nil }, + getEventByID: func(uuid.UUID) (*models.Event, error) { return event, nil }, + incrementAttendeePrintedCount: func(uuid.UUID) (int, error) { + return 7, nil + }, + insertCheckinAction: func(uuid.UUID, uuid.UUID, string, *uuid.UUID, uuid.UUID) error { + t.Fatal("InsertCheckinAction should not be called for a malformed body (no event_id could be parsed)") + return nil + }, + }) + e := echo.New() + path := markPrintedPath(attendee.ID) + + c, rec := newAuthedContext(e, http.MethodPost, path, `{"event_id": not-json-at-all`, tenantID.String(), "admin") + setMarkPrintedPathParams(c, attendee.ID) + if err := h.MarkAttendeePrinted(c); err != nil { + t.Fatalf("MarkAttendeePrinted: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("want 200 (malformed body is lenient, not 400), got %d, body=%s", rec.Code, rec.Body.String()) + } + var got MarkAttendeePrintedResponse + if err := jsonUnmarshalBody(rec, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got.PrintedCount != 7 { + t.Fatalf("printed_count = %d, want 7", got.PrintedCount) + } + validateResponse(t, http.MethodPost, path, rec) +} + +// TestOpenAPIContract_MarkAttendeePrinted_UnknownFieldsIgnored proves the +// other half of the leniency choice: EXTRA/unknown fields in an otherwise +// valid body don't 400 — they're ignored, and a valid event_id alongside +// them still logs the reprint row. +func TestOpenAPIContract_MarkAttendeePrinted_UnknownFieldsIgnored(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + attendee := contractAttendee(event.ID) + + h, actions := newMarkPrintedHandler(t, event, attendee, 2) + e := echo.New() + path := markPrintedPath(attendee.ID) + body := `{"event_id":"` + event.ID.String() + `","printer_name":"Zebra ZD420","unexpected":42}` + + c, rec := newAuthedContext(e, http.MethodPost, path, body, tenantID.String(), "admin") + setMarkPrintedPathParams(c, attendee.ID) + if err := h.MarkAttendeePrinted(c); err != nil { + t.Fatalf("MarkAttendeePrinted: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d, body=%s", rec.Code, rec.Body.String()) + } + validateResponse(t, http.MethodPost, path, rec) + if len(*actions) != 1 { + t.Fatalf("len(actions) = %d, want 1 (unknown fields must not block valid event_id from logging)", len(*actions)) + } +} + +// TestOpenAPIContract_MarkAttendeePrinted_PresentButInvalidEventID400s +// covers the ONE case the implementer's leniency choice still rejects: a +// present event_id key whose value fails uuid.Parse. This must 400 BEFORE +// the counter increments (never called), matching the brief's preferred +// "lenient-ignore of unknown, 400 only on a present-but-bad uuid". +func TestOpenAPIContract_MarkAttendeePrinted_PresentButInvalidEventID400s(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + attendee := contractAttendee(event.ID) + + h := New(&fakeStore{ + getAttendeeByID: func(uuid.UUID) (*models.Attendee, error) { return attendee, nil }, + getEventByID: func(uuid.UUID) (*models.Event, error) { return event, nil }, + incrementAttendeePrintedCount: func(uuid.UUID) (int, error) { + t.Fatal("IncrementAttendeePrintedCount should not be called when the body 400s") + return 0, nil + }, + insertCheckinAction: func(uuid.UUID, uuid.UUID, string, *uuid.UUID, uuid.UUID) error { + t.Fatal("InsertCheckinAction should not be called when the body 400s") + return nil + }, + }) + e := echo.New() + path := markPrintedPath(attendee.ID) + + c, rec := newAuthedContext(e, http.MethodPost, path, `{"event_id":"not-a-uuid"}`, tenantID.String(), "admin") + setMarkPrintedPathParams(c, attendee.ID) + if err := h.MarkAttendeePrinted(c); err != nil { + t.Fatalf("MarkAttendeePrinted: %v", err) + } + if rec.Code != http.StatusBadRequest { + t.Fatalf("want 400, got %d, body=%s", rec.Code, rec.Body.String()) + } + validateResponse(t, http.MethodPost, path, rec) +} + +// TestOpenAPIContract_MarkAttendeePrinted_StationIDWithoutEventID400s covers +// PR #77 bot-review round Finding D: station_id present but event_id +// absent used to be silently accepted — station_id was simply discarded +// (the reprint-logging path is gated on event_id != nil), hiding a +// client-side mistake (a caller supplying station_id clearly intended it to +// be logged). This must now 400 BEFORE the counter increments, checked +// before the existing event_id-mismatch-with-attendee validation — neither +// the counter nor the feed insert may be reached. +func TestOpenAPIContract_MarkAttendeePrinted_StationIDWithoutEventID400s(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + attendee := contractAttendee(event.ID) + stationID := uuid.New() + + h := New(&fakeStore{ + getAttendeeByID: func(uuid.UUID) (*models.Attendee, error) { return attendee, nil }, + getEventByID: func(uuid.UUID) (*models.Event, error) { return event, nil }, + incrementAttendeePrintedCount: func(uuid.UUID) (int, error) { + t.Fatal("IncrementAttendeePrintedCount should not be called when station_id is supplied without event_id") + return 0, nil + }, + insertCheckinAction: func(uuid.UUID, uuid.UUID, string, *uuid.UUID, uuid.UUID) error { + t.Fatal("InsertCheckinAction should not be called when station_id is supplied without event_id") + return nil + }, + }) + e := echo.New() + path := markPrintedPath(attendee.ID) + body := `{"station_id":"` + stationID.String() + `"}` + + c, rec := newAuthedContext(e, http.MethodPost, path, body, tenantID.String(), "admin") + setMarkPrintedPathParams(c, attendee.ID) + if err := h.MarkAttendeePrinted(c); err != nil { + t.Fatalf("MarkAttendeePrinted: %v", err) + } + if rec.Code != http.StatusBadRequest { + t.Fatalf("want 400, got %d, body=%s", rec.Code, rec.Body.String()) + } + var got map[string]string + if err := jsonUnmarshalBody(rec, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got["error"] != "event_id is required when station_id is supplied" { + t.Errorf("error = %q, want %q", got["error"], "event_id is required when station_id is supplied") + } + validateResponse(t, http.MethodPost, path, rec) +} + +// TestOpenAPIContract_MarkAttendeePrinted_PresentButInvalidStationID400s +// mirrors the event_id case for station_id — a valid event_id alongside an +// invalid station_id must still 400 before incrementing. +func TestOpenAPIContract_MarkAttendeePrinted_PresentButInvalidStationID400s(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + attendee := contractAttendee(event.ID) + + h := New(&fakeStore{ + getAttendeeByID: func(uuid.UUID) (*models.Attendee, error) { return attendee, nil }, + getEventByID: func(uuid.UUID) (*models.Event, error) { return event, nil }, + incrementAttendeePrintedCount: func(uuid.UUID) (int, error) { + t.Fatal("IncrementAttendeePrintedCount should not be called when the body 400s") + return 0, nil + }, + insertCheckinAction: func(uuid.UUID, uuid.UUID, string, *uuid.UUID, uuid.UUID) error { + t.Fatal("InsertCheckinAction should not be called when the body 400s") + return nil + }, + }) + e := echo.New() + path := markPrintedPath(attendee.ID) + body := `{"event_id":"` + event.ID.String() + `","station_id":"not-a-uuid"}` + + c, rec := newAuthedContext(e, http.MethodPost, path, body, tenantID.String(), "admin") + setMarkPrintedPathParams(c, attendee.ID) + if err := h.MarkAttendeePrinted(c); err != nil { + t.Fatalf("MarkAttendeePrinted: %v", err) + } + if rec.Code != http.StatusBadRequest { + t.Fatalf("want 400, got %d, body=%s", rec.Code, rec.Body.String()) + } + validateResponse(t, http.MethodPost, path, rec) +} + +// TestOpenAPIContract_MarkAttendeePrinted_ForeignEventIDRejected is the fix +// round 1 regression test: the body's event_id is parseable but does NOT +// match the attendee's own event (fetched via requireAttendeeOwnership, the +// only trustworthy event context). Before the fix, this event_id was passed +// straight to InsertCheckinAction — an authenticated caller who legitimately +// owns the ATTENDEE could get a 'reprint' row logged into an arbitrary +// OTHER event's/tenant's checkin_actions feed, since GetCheckinActions has +// no tenant scoping. This must 400 BEFORE the counter increments (mirrors +// StationCheckin/UndoCheckin/BadgeZPL's "Attendee does not belong to this +// event" treatment of the same mismatch), and neither the counter nor the +// feed insert may be reached. +func TestOpenAPIContract_MarkAttendeePrinted_ForeignEventIDRejected(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + attendee := contractAttendee(event.ID) + foreignEvent := contractEvent(uuid.New(), "Other Tenant's Conference") + + h := New(&fakeStore{ + getAttendeeByID: func(uuid.UUID) (*models.Attendee, error) { return attendee, nil }, + getEventByID: func(uuid.UUID) (*models.Event, error) { return event, nil }, + incrementAttendeePrintedCount: func(uuid.UUID) (int, error) { + t.Fatal("IncrementAttendeePrintedCount should not be called when event_id doesn't match the attendee's own event") + return 0, nil + }, + insertCheckinAction: func(uuid.UUID, uuid.UUID, string, *uuid.UUID, uuid.UUID) error { + t.Fatal("InsertCheckinAction should not be called when event_id doesn't match the attendee's own event") + return nil + }, + }) + e := echo.New() + path := markPrintedPath(attendee.ID) + body := `{"event_id":"` + foreignEvent.ID.String() + `"}` + + c, rec := newAuthedContext(e, http.MethodPost, path, body, tenantID.String(), "admin") + setMarkPrintedPathParams(c, attendee.ID) + if err := h.MarkAttendeePrinted(c); err != nil { + t.Fatalf("MarkAttendeePrinted: %v", err) + } + if rec.Code != http.StatusBadRequest { + t.Fatalf("want 400, got %d, body=%s", rec.Code, rec.Body.String()) + } + validateResponse(t, http.MethodPost, path, rec) +} + +// TestOpenAPIContract_MarkAttendeePrinted_ForeignStationIDRejected covers +// fix round 1's second half: event_id matches the attendee's own event, but +// station_id belongs to a DIFFERENT event. resolveCheckinStation (shared +// with StationCheckin/UndoCheckin, checkin.go:76-88) must reject this the +// same way it already rejects a foreign station_id there — 400 before the +// counter increments, no feed row attempted. +func TestOpenAPIContract_MarkAttendeePrinted_ForeignStationIDRejected(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + attendee := contractAttendee(event.ID) + foreignStationID := uuid.New() + + h := New(&fakeStore{ + getAttendeeByID: func(uuid.UUID) (*models.Attendee, error) { return attendee, nil }, + getEventByID: func(uuid.UUID) (*models.Event, error) { return event, nil }, + getCheckinStationByID: func(id uuid.UUID) (*models.CheckinStation, error) { + // The station exists, but is registered to a DIFFERENT event + // than the one in the (matching) request body. + return &models.CheckinStation{ID: id, EventID: uuid.New(), Name: "Foreign Station"}, nil + }, + incrementAttendeePrintedCount: func(uuid.UUID) (int, error) { + t.Fatal("IncrementAttendeePrintedCount should not be called when station_id doesn't belong to the event") + return 0, nil + }, + insertCheckinAction: func(uuid.UUID, uuid.UUID, string, *uuid.UUID, uuid.UUID) error { + t.Fatal("InsertCheckinAction should not be called when station_id doesn't belong to the event") + return nil + }, + }) + e := echo.New() + path := markPrintedPath(attendee.ID) + body := `{"event_id":"` + event.ID.String() + `","station_id":"` + foreignStationID.String() + `"}` + + c, rec := newAuthedContext(e, http.MethodPost, path, body, tenantID.String(), "admin") + setMarkPrintedPathParams(c, attendee.ID) + if err := h.MarkAttendeePrinted(c); err != nil { + t.Fatalf("MarkAttendeePrinted: %v", err) + } + if rec.Code != http.StatusBadRequest { + t.Fatalf("want 400, got %d, body=%s", rec.Code, rec.Body.String()) + } + validateResponse(t, http.MethodPost, path, rec) +} + +// TestOpenAPIContract_MarkAttendeePrinted_EventIDNoStationLogsNilStation +// proves a station-less reprint (event_id present, station_id absent) logs +// the feed row with a nil station_id rather than defaulting it to +// something fabricated. +func TestOpenAPIContract_MarkAttendeePrinted_EventIDNoStationLogsNilStation(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + attendee := contractAttendee(event.ID) + + h, actions := newMarkPrintedHandler(t, event, attendee, 5) + e := echo.New() + path := markPrintedPath(attendee.ID) + body := `{"event_id":"` + event.ID.String() + `"}` + + c, rec := newAuthedContext(e, http.MethodPost, path, body, tenantID.String(), "admin") + setMarkPrintedPathParams(c, attendee.ID) + if err := h.MarkAttendeePrinted(c); err != nil { + t.Fatalf("MarkAttendeePrinted: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d, body=%s", rec.Code, rec.Body.String()) + } + validateResponse(t, http.MethodPost, path, rec) + if len(*actions) != 1 { + t.Fatalf("len(actions) = %d, want 1", len(*actions)) + } + if (*actions)[0].StationID != nil { + t.Errorf("StationID = %v, want nil", (*actions)[0].StationID) + } +} + +// TestOpenAPIContract_MarkAttendeePrinted_ReprintLogFailureStillReturns200 +// proves reprint-logging is best-effort: InsertCheckinAction failing must +// NOT change the response — the counter increment already committed by the +// time logging is attempted. +func TestOpenAPIContract_MarkAttendeePrinted_ReprintLogFailureStillReturns200(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + attendee := contractAttendee(event.ID) + + h := New(&fakeStore{ + getAttendeeByID: func(uuid.UUID) (*models.Attendee, error) { return attendee, nil }, + getEventByID: func(uuid.UUID) (*models.Event, error) { return event, nil }, + incrementAttendeePrintedCount: func(uuid.UUID) (int, error) { + return 4, nil + }, + insertCheckinAction: func(uuid.UUID, uuid.UUID, string, *uuid.UUID, uuid.UUID) error { + return errors.New("boom") + }, + }) + e := echo.New() + path := markPrintedPath(attendee.ID) + body := `{"event_id":"` + event.ID.String() + `"}` + + c, rec := newAuthedContext(e, http.MethodPost, path, body, tenantID.String(), "admin") + setMarkPrintedPathParams(c, attendee.ID) + if err := h.MarkAttendeePrinted(c); err != nil { + t.Fatalf("MarkAttendeePrinted: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("want 200 (reprint-logging failure must not fail the request), got %d, body=%s", rec.Code, rec.Body.String()) + } + var got MarkAttendeePrintedResponse + if err := jsonUnmarshalBody(rec, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got.PrintedCount != 4 { + t.Fatalf("printed_count = %d, want 4", got.PrintedCount) + } + validateResponse(t, http.MethodPost, path, rec) +} diff --git a/backend/internal/handler/openapi_contract_checkin_p4_test.go b/backend/internal/handler/openapi_contract_checkin_p4_test.go new file mode 100644 index 00000000..40100008 --- /dev/null +++ b/backend/internal/handler/openapi_contract_checkin_p4_test.go @@ -0,0 +1,1324 @@ +package handler + +import ( + "bytes" + "encoding/json" + "net/http" + "strconv" + "testing" + "time" + + "idento/backend/internal/models" + "idento/backend/internal/store" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/labstack/echo/v4" +) + +// newCheckinSettingsHandler builds a Handler whose event store returns +// event for any GetEventByID(ForTenant) lookup, wired to the given +// check-in-settings fake read/write functions. +func newCheckinSettingsHandler( + event *models.Event, + get func(eventID uuid.UUID) (json.RawMessage, error), + update func(eventID uuid.UUID, settings json.RawMessage) error, +) *Handler { + return New(&fakeStore{ + getEventByID: func(uuid.UUID) (*models.Event, error) { return event, nil }, + getCheckinSettings: get, + updateCheckinSettings: update, + }) +} + +func checkinSettingsPath(eventID uuid.UUID) string { + return "/api/events/" + eventID.String() + "/checkin-settings" +} + +func setCheckinSettingsPathParams(c echo.Context, eventID uuid.UUID) { + c.SetPath("/api/events/:id/checkin-settings") + c.SetParamNames("id") + c.SetParamValues(eventID.String()) +} + +// GET with NULL column (no settings saved yet) → {settings: null}. +func TestOpenAPIContract_GetCheckinSettings_NullColumn(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + h := newCheckinSettingsHandler(event, + func(uuid.UUID) (json.RawMessage, error) { return nil, nil }, + nil, + ) + e := echo.New() + path := checkinSettingsPath(event.ID) + c, rec := newAuthedContext(e, http.MethodGet, path, "", tenantID.String(), "admin") + setCheckinSettingsPathParams(c, event.ID) + + if err := h.GetCheckinSettings(c); err != nil { + t.Fatalf("GetCheckinSettings: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d, body=%s", rec.Code, rec.Body.String()) + } + var got CheckinSettingsResponse + if err := jsonUnmarshalBody(rec, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + // encoding/json invokes json.RawMessage.UnmarshalJSON even for a + // literal null, storing the 4 bytes "null" rather than leaving the + // field nil — so the correct "is it null" check is on the decoded + // text, not a nil comparison. + if string(bytes.TrimSpace(got.Settings)) != "null" { + t.Fatalf("got settings=%s, want null", got.Settings) + } + validateResponse(t, http.MethodGet, path, rec) +} + +// GET after seeding → the stored object, echoed as-is. +func TestOpenAPIContract_GetCheckinSettings_Seeded(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + stored := json.RawMessage(`{"print_on_checkin":true,"verdict_auto_dismiss_sec":5,"scan_input":"wedge","manual_search_enabled":false}`) + h := newCheckinSettingsHandler(event, + func(uuid.UUID) (json.RawMessage, error) { return stored, nil }, + nil, + ) + e := echo.New() + path := checkinSettingsPath(event.ID) + c, rec := newAuthedContext(e, http.MethodGet, path, "", tenantID.String(), "admin") + setCheckinSettingsPathParams(c, event.ID) + + if err := h.GetCheckinSettings(c); err != nil { + t.Fatalf("GetCheckinSettings: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d, body=%s", rec.Code, rec.Body.String()) + } + var got CheckinSettingsResponse + if err := jsonUnmarshalBody(rec, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if !bytes.Equal(bytes.TrimSpace(got.Settings), stored) { + t.Fatalf("got settings=%s, want %s", got.Settings, stored) + } + validateResponse(t, http.MethodGet, path, rec) +} + +// PUT happy path → 200, AND the fakeStore captured settings bytes are +// byte-identical to the request's raw "settings" bytes — the +// verbatim-storage proof (badge_template precedent). +func TestOpenAPIContract_PutCheckinSettings_HappyPathIsVerbatim(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + + var captured json.RawMessage + h := newCheckinSettingsHandler(event, + func(uuid.UUID) (json.RawMessage, error) { return nil, nil }, + func(_ uuid.UUID, settings json.RawMessage) error { + captured = append(json.RawMessage(nil), settings...) + return nil + }, + ) + + requestBody := `{"settings":{"print_on_checkin":true,"verdict_auto_dismiss_sec":5,"scan_input":"wedge","manual_search_enabled":false}}` + rawSettingsBytes := json.RawMessage(`{"print_on_checkin":true,"verdict_auto_dismiss_sec":5,"scan_input":"wedge","manual_search_enabled":false}`) + + e := echo.New() + path := checkinSettingsPath(event.ID) + c, rec := newAuthedContext(e, http.MethodPut, path, requestBody, tenantID.String(), "admin") + setCheckinSettingsPathParams(c, event.ID) + + if err := h.PutCheckinSettings(c); err != nil { + t.Fatalf("PutCheckinSettings: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d, body=%s", rec.Code, rec.Body.String()) + } + if !bytes.Equal(captured, rawSettingsBytes) { + t.Fatalf("store captured settings = %s, want byte-identical to %s (verbatim storage broken)", captured, rawSettingsBytes) + } + + var got CheckinSettingsResponse + if err := jsonUnmarshalBody(rec, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if !bytes.Equal(bytes.TrimSpace(got.Settings), rawSettingsBytes) { + t.Fatalf("response settings = %s, want byte-identical to %s", got.Settings, rawSettingsBytes) + } + validateResponse(t, http.MethodPut, path, rec) +} + +// TestOpenAPIContract_PutCheckinSettings_SoftDeleteRace404 is the PR #77 +// bot-review round Finding C regression test: requireEventOwnership's +// pre-check passed (this is a legitimately-authorized request), but the +// store's guarded UPDATE affects 0 rows anyway — the store maps that to +// store.ErrEventNotFound, and the handler must map it to a 404, not a +// fabricated 200 with settings that were never actually persisted. +func TestOpenAPIContract_PutCheckinSettings_SoftDeleteRace404(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + + h := newCheckinSettingsHandler(event, + nil, + func(uuid.UUID, json.RawMessage) error { return store.ErrEventNotFound }, + ) + + requestBody := `{"settings":{"print_on_checkin":true,"verdict_auto_dismiss_sec":5,"scan_input":"wedge","manual_search_enabled":false}}` + + e := echo.New() + path := checkinSettingsPath(event.ID) + c, rec := newAuthedContext(e, http.MethodPut, path, requestBody, tenantID.String(), "admin") + setCheckinSettingsPathParams(c, event.ID) + + if err := h.PutCheckinSettings(c); err != nil { + t.Fatalf("PutCheckinSettings: %v", err) + } + if rec.Code != http.StatusNotFound { + t.Fatalf("want 404, got %d, body=%s", rec.Code, rec.Body.String()) + } + validateResponse(t, http.MethodPut, path, rec) +} + +// PUT with verdict_auto_dismiss_sec outside [1, 30] → 400, for both the +// low and high boundary violations. +func TestOpenAPIContract_PutCheckinSettings_VerdictAutoDismissSecOutOfRange400(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + newHandler := func() *Handler { + return newCheckinSettingsHandler(event, + func(uuid.UUID) (json.RawMessage, error) { return nil, nil }, + func(uuid.UUID, json.RawMessage) error { + t.Fatalf("UpdateCheckinSettings should not be called when verdict_auto_dismiss_sec is out of range") + return nil + }, + ) + } + e := echo.New() + path := checkinSettingsPath(event.ID) + + for name, sec := range map[string]int{"zero": 0, "thirty_one": 31} { + h := newHandler() + body := `{"settings":{"print_on_checkin":true,"verdict_auto_dismiss_sec":` + + strconv.Itoa(sec) + `,"scan_input":"wedge","manual_search_enabled":false}}` + c, rec := newAuthedContext(e, http.MethodPut, path, body, tenantID.String(), "admin") + setCheckinSettingsPathParams(c, event.ID) + if err := h.PutCheckinSettings(c); err != nil { + t.Fatalf("%s: PutCheckinSettings: %v", name, err) + } + if rec.Code != http.StatusBadRequest { + t.Fatalf("%s: want 400, got %d, body=%s", name, rec.Code, rec.Body.String()) + } + validateResponse(t, http.MethodPut, path, rec) + } +} + +// PUT with an unrecognized scan_input value → 400. +func TestOpenAPIContract_PutCheckinSettings_InvalidScanInput400(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + h := newCheckinSettingsHandler(event, + func(uuid.UUID) (json.RawMessage, error) { return nil, nil }, + func(uuid.UUID, json.RawMessage) error { + t.Fatalf("UpdateCheckinSettings should not be called when scan_input is invalid") + return nil + }, + ) + e := echo.New() + path := checkinSettingsPath(event.ID) + body := `{"settings":{"print_on_checkin":true,"verdict_auto_dismiss_sec":5,"scan_input":"camera","manual_search_enabled":false}}` + c, rec := newAuthedContext(e, http.MethodPut, path, body, tenantID.String(), "admin") + setCheckinSettingsPathParams(c, event.ID) + + if err := h.PutCheckinSettings(c); err != nil { + t.Fatalf("PutCheckinSettings: %v", err) + } + if rec.Code != http.StatusBadRequest { + t.Fatalf("want 400, got %d, body=%s", rec.Code, rec.Body.String()) + } + var got struct { + Error string `json:"error"` + } + if err := jsonUnmarshalBody(rec, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got.Error == "" { + t.Fatalf("want a non-empty error message") + } + validateResponse(t, http.MethodPut, path, rec) +} + +// GET and PUT on a foreign event (different tenant) both 404 — +// requireEventOwnership masks "foreign" as "missing", checked before any +// store call. +func TestOpenAPIContract_CheckinSettings_ForeignEvent404(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + foreignTenantID := uuid.New() + path := checkinSettingsPath(event.ID) + + t.Run("GET", func(t *testing.T) { + h := newCheckinSettingsHandler(event, + func(uuid.UUID) (json.RawMessage, error) { + t.Fatalf("GetCheckinSettings should not be called for a foreign event") + return nil, nil + }, + nil, + ) + e := echo.New() + c, rec := newAuthedContext(e, http.MethodGet, path, "", foreignTenantID.String(), "admin") + setCheckinSettingsPathParams(c, event.ID) + if err := h.GetCheckinSettings(c); err != nil { + t.Fatalf("GetCheckinSettings: %v", err) + } + if rec.Code != http.StatusNotFound { + t.Fatalf("want 404, got %d, body=%s", rec.Code, rec.Body.String()) + } + validateResponse(t, http.MethodGet, path, rec) + }) + + t.Run("PUT", func(t *testing.T) { + h := newCheckinSettingsHandler(event, + func(uuid.UUID) (json.RawMessage, error) { + t.Fatalf("GetCheckinSettings should not be called for a foreign event") + return nil, nil + }, + func(uuid.UUID, json.RawMessage) error { + t.Fatalf("UpdateCheckinSettings should not be called for a foreign event") + return nil + }, + ) + e := echo.New() + body := `{"settings":{"print_on_checkin":true,"verdict_auto_dismiss_sec":5,"scan_input":"wedge","manual_search_enabled":false}}` + c, rec := newAuthedContext(e, http.MethodPut, path, body, foreignTenantID.String(), "admin") + setCheckinSettingsPathParams(c, event.ID) + if err := h.PutCheckinSettings(c); err != nil { + t.Fatalf("PutCheckinSettings: %v", err) + } + if rec.Code != http.StatusNotFound { + t.Fatalf("want 404, got %d, body=%s", rec.Code, rec.Body.String()) + } + validateResponse(t, http.MethodPut, path, rec) + }) +} + +// GET/PUT with a malformed event id (not a UUID) → 400, checked before +// ownership/body parsing. +func TestOpenAPIContract_CheckinSettings_InvalidEventID400(t *testing.T) { + tenantID := uuid.New() + e := echo.New() + badPath := "/api/events/not-a-uuid/checkin-settings" + + h := New(&fakeStore{ + getEventByID: func(uuid.UUID) (*models.Event, error) { + t.Fatalf("GetEventByID should not be called when event id fails to parse") + return nil, nil + }, + }) + + c, rec := newAuthedContext(e, http.MethodGet, badPath, "", tenantID.String(), "admin") + c.SetPath("/api/events/:id/checkin-settings") + c.SetParamNames("id") + c.SetParamValues("not-a-uuid") + if err := h.GetCheckinSettings(c); err != nil { + t.Fatalf("GetCheckinSettings: %v", err) + } + if rec.Code != http.StatusBadRequest { + t.Fatalf("GET: want 400, got %d, body=%s", rec.Code, rec.Body.String()) + } + validateResponse(t, http.MethodGet, badPath, rec) + + body := `{"settings":{"print_on_checkin":true,"verdict_auto_dismiss_sec":5,"scan_input":"wedge","manual_search_enabled":false}}` + c, rec = newAuthedContext(e, http.MethodPut, badPath, body, tenantID.String(), "admin") + c.SetPath("/api/events/:id/checkin-settings") + c.SetParamNames("id") + c.SetParamValues("not-a-uuid") + if err := h.PutCheckinSettings(c); err != nil { + t.Fatalf("PutCheckinSettings: %v", err) + } + if rec.Code != http.StatusBadRequest { + t.Fatalf("PUT: want 400, got %d, body=%s", rec.Code, rec.Body.String()) + } + validateResponse(t, http.MethodPut, badPath, rec) +} + +// --- Task 2: check-in station register / heartbeat / list --- + +// newCheckinStationHandler builds a Handler whose event store returns +// event for any GetEventByID(ForTenant) lookup, wired to the given +// check-in-station fake functions. Any argument left nil panics if the +// corresponding handler path is exercised — surfacing an unexpected call. +func newCheckinStationHandler( + event *models.Event, + getZone func(id uuid.UUID) (*models.EventZone, error), + upsert func(eventID uuid.UUID, name string, zoneID *uuid.UUID) (*models.CheckinStation, error), + heartbeat func(eventID, stationID uuid.UUID) error, + list func(eventID uuid.UUID) ([]*models.CheckinStation, error), +) *Handler { + return New(&fakeStore{ + getEventByID: func(uuid.UUID) (*models.Event, error) { return event, nil }, + getEventZoneByID: getZone, + upsertCheckinStation: upsert, + heartbeatCheckinStation: heartbeat, + listCheckinStations: list, + }) +} + +func checkinStationsPath(eventID uuid.UUID) string { + return "/api/events/" + eventID.String() + "/checkin-stations" +} + +func checkinStationHeartbeatPath(eventID, stationID uuid.UUID) string { + return checkinStationsPath(eventID) + "/" + stationID.String() + "/heartbeat" +} + +func setCheckinStationsPathParams(c echo.Context, eventID uuid.UUID) { + c.SetPath("/api/events/:event_id/checkin-stations") + c.SetParamNames("event_id") + c.SetParamValues(eventID.String()) +} + +func setCheckinStationHeartbeatPathParams(c echo.Context, eventID, stationID uuid.UUID) { + c.SetPath("/api/events/:event_id/checkin-stations/:id/heartbeat") + c.SetParamNames("event_id", "id") + c.SetParamValues(eventID.String(), stationID.String()) +} + +// Register a station under a brand-new name → 200 with the station. +func TestOpenAPIContract_RegisterCheckinStation_NewName(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + stationID := uuid.New() + now := time.Now() + + h := newCheckinStationHandler(event, + func(uuid.UUID) (*models.EventZone, error) { + t.Fatalf("GetEventZoneByID should not be called when zone_id is absent") + return nil, nil + }, + func(eventID uuid.UUID, name string, zoneID *uuid.UUID) (*models.CheckinStation, error) { + if name != "Main Entrance" { + t.Fatalf("name = %q, want Main Entrance", name) + } + if zoneID != nil { + t.Fatalf("zoneID = %v, want nil", zoneID) + } + return &models.CheckinStation{ID: stationID, EventID: eventID, Name: name, LastSeenAt: now, CreatedAt: now}, nil + }, + nil, nil, + ) + + e := echo.New() + path := checkinStationsPath(event.ID) + body := `{"name":"Main Entrance"}` + c, rec := newAuthedContext(e, http.MethodPost, path, body, tenantID.String(), "admin") + setCheckinStationsPathParams(c, event.ID) + + if err := h.RegisterCheckinStation(c); err != nil { + t.Fatalf("RegisterCheckinStation: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d, body=%s", rec.Code, rec.Body.String()) + } + var got CheckinStationResponse + if err := jsonUnmarshalBody(rec, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got.Station == nil || got.Station.ID != stationID { + t.Fatalf("got station=%+v, want id=%s", got.Station, stationID) + } + validateResponse(t, http.MethodPost, path, rec) +} + +// Registering the SAME name twice, with a DIFFERENT zone_id the second +// time, must come back as the SAME station id with the zone updated — +// the upsert proof (store.UpsertCheckinStation's ON CONFLICT semantics), +// exercised here via a stateful fake standing in for the real upsert. +func TestOpenAPIContract_RegisterCheckinStation_UpsertSameNameUpdatesZone(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + zoneA := &models.EventZone{ID: uuid.New(), EventID: event.ID} + zoneB := &models.EventZone{ID: uuid.New(), EventID: event.ID} + stationID := uuid.New() + now := time.Now() + zones := map[uuid.UUID]*models.EventZone{zoneA.ID: zoneA, zoneB.ID: zoneB} + + current := &models.CheckinStation{ID: stationID, EventID: event.ID, Name: "Main Entrance", LastSeenAt: now, CreatedAt: now} + h := newCheckinStationHandler(event, + func(id uuid.UUID) (*models.EventZone, error) { return zones[id], nil }, + func(eventID uuid.UUID, name string, zoneID *uuid.UUID) (*models.CheckinStation, error) { + current.ZoneID = zoneID + return current, nil + }, + nil, nil, + ) + + e := echo.New() + path := checkinStationsPath(event.ID) + + body1 := `{"name":"Main Entrance","zone_id":"` + zoneA.ID.String() + `"}` + c1, rec1 := newAuthedContext(e, http.MethodPost, path, body1, tenantID.String(), "admin") + setCheckinStationsPathParams(c1, event.ID) + if err := h.RegisterCheckinStation(c1); err != nil { + t.Fatalf("first register: %v", err) + } + if rec1.Code != http.StatusOK { + t.Fatalf("first register: want 200, got %d, body=%s", rec1.Code, rec1.Body.String()) + } + var got1 CheckinStationResponse + if err := jsonUnmarshalBody(rec1, &got1); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got1.Station.ZoneID == nil || *got1.Station.ZoneID != zoneA.ID { + t.Fatalf("first register zone_id = %v, want %s", got1.Station.ZoneID, zoneA.ID) + } + validateResponse(t, http.MethodPost, path, rec1) + + body2 := `{"name":"Main Entrance","zone_id":"` + zoneB.ID.String() + `"}` + c2, rec2 := newAuthedContext(e, http.MethodPost, path, body2, tenantID.String(), "admin") + setCheckinStationsPathParams(c2, event.ID) + if err := h.RegisterCheckinStation(c2); err != nil { + t.Fatalf("second register: %v", err) + } + if rec2.Code != http.StatusOK { + t.Fatalf("second register: want 200, got %d, body=%s", rec2.Code, rec2.Body.String()) + } + var got2 CheckinStationResponse + if err := jsonUnmarshalBody(rec2, &got2); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got2.Station.ID != stationID { + t.Fatalf("second register id = %s, want SAME id %s (upsert proof)", got2.Station.ID, stationID) + } + if got2.Station.ZoneID == nil || *got2.Station.ZoneID != zoneB.ID { + t.Fatalf("second register zone_id = %v, want %s (zone updated)", got2.Station.ZoneID, zoneB.ID) + } + validateResponse(t, http.MethodPost, path, rec2) +} + +// A missing/empty name never reaches the store. +func TestOpenAPIContract_RegisterCheckinStation_EmptyName400(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + h := newCheckinStationHandler(event, nil, + func(uuid.UUID, string, *uuid.UUID) (*models.CheckinStation, error) { + t.Fatalf("UpsertCheckinStation should not be called when name is empty") + return nil, nil + }, + nil, nil, + ) + e := echo.New() + path := checkinStationsPath(event.ID) + body := `{"name":" "}` + c, rec := newAuthedContext(e, http.MethodPost, path, body, tenantID.String(), "admin") + setCheckinStationsPathParams(c, event.ID) + + if err := h.RegisterCheckinStation(c); err != nil { + t.Fatalf("RegisterCheckinStation: %v", err) + } + if rec.Code != http.StatusBadRequest { + t.Fatalf("want 400, got %d, body=%s", rec.Code, rec.Body.String()) + } + validateResponse(t, http.MethodPost, path, rec) +} + +// A zone_id that belongs to a DIFFERENT event is a 400, never a store call. +func TestOpenAPIContract_RegisterCheckinStation_ForeignZone400(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + foreignZone := &models.EventZone{ID: uuid.New(), EventID: uuid.New()} + h := newCheckinStationHandler(event, + func(uuid.UUID) (*models.EventZone, error) { return foreignZone, nil }, + func(uuid.UUID, string, *uuid.UUID) (*models.CheckinStation, error) { + t.Fatalf("UpsertCheckinStation should not be called for a foreign zone") + return nil, nil + }, + nil, nil, + ) + e := echo.New() + path := checkinStationsPath(event.ID) + body := `{"name":"Main Entrance","zone_id":"` + foreignZone.ID.String() + `"}` + c, rec := newAuthedContext(e, http.MethodPost, path, body, tenantID.String(), "admin") + setCheckinStationsPathParams(c, event.ID) + + if err := h.RegisterCheckinStation(c); err != nil { + t.Fatalf("RegisterCheckinStation: %v", err) + } + if rec.Code != http.StatusBadRequest { + t.Fatalf("want 400, got %d, body=%s", rec.Code, rec.Body.String()) + } + validateResponse(t, http.MethodPost, path, rec) +} + +// A zone_id that doesn't exist at all is also a 400 (same as foreign). +func TestOpenAPIContract_RegisterCheckinStation_UnknownZone400(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + h := newCheckinStationHandler(event, + // The real PGStore.GetEventZoneByID does not normalize a no-rows + // result to (nil, nil) — it surfaces the raw pgx.ErrNoRows from + // Scan. Matching that contract here is what makes this test + // actually exercise the "unknown zone" code path instead of the + // unrelated "found a nil zone" path. + func(uuid.UUID) (*models.EventZone, error) { return nil, pgx.ErrNoRows }, + func(uuid.UUID, string, *uuid.UUID) (*models.CheckinStation, error) { + t.Fatalf("UpsertCheckinStation should not be called for an unknown zone") + return nil, nil + }, + nil, nil, + ) + e := echo.New() + path := checkinStationsPath(event.ID) + body := `{"name":"Main Entrance","zone_id":"` + uuid.New().String() + `"}` + c, rec := newAuthedContext(e, http.MethodPost, path, body, tenantID.String(), "admin") + setCheckinStationsPathParams(c, event.ID) + + if err := h.RegisterCheckinStation(c); err != nil { + t.Fatalf("RegisterCheckinStation: %v", err) + } + if rec.Code != http.StatusBadRequest { + t.Fatalf("want 400, got %d, body=%s", rec.Code, rec.Body.String()) + } + validateResponse(t, http.MethodPost, path, rec) +} + +// Heartbeat on a known station → 204. +func TestOpenAPIContract_HeartbeatCheckinStation_Known204(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + stationID := uuid.New() + h := newCheckinStationHandler(event, nil, nil, + func(eventID, gotStationID uuid.UUID) error { + if eventID != event.ID || gotStationID != stationID { + t.Fatalf("heartbeat called with eventID=%s stationID=%s, want eventID=%s stationID=%s", eventID, gotStationID, event.ID, stationID) + } + return nil + }, + nil, + ) + e := echo.New() + path := checkinStationHeartbeatPath(event.ID, stationID) + c, rec := newAuthedContext(e, http.MethodPost, path, "", tenantID.String(), "admin") + setCheckinStationHeartbeatPathParams(c, event.ID, stationID) + + if err := h.HeartbeatCheckinStation(c); err != nil { + t.Fatalf("HeartbeatCheckinStation: %v", err) + } + if rec.Code != http.StatusNoContent { + t.Fatalf("want 204, got %d, body=%s", rec.Code, rec.Body.String()) + } + validateResponse(t, http.MethodPost, path, rec) +} + +// Heartbeat on an unknown/foreign station id → 404. +func TestOpenAPIContract_HeartbeatCheckinStation_Unknown404(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + stationID := uuid.New() + h := newCheckinStationHandler(event, nil, nil, + func(uuid.UUID, uuid.UUID) error { return store.ErrCheckinStationNotFound }, + nil, + ) + e := echo.New() + path := checkinStationHeartbeatPath(event.ID, stationID) + c, rec := newAuthedContext(e, http.MethodPost, path, "", tenantID.String(), "admin") + setCheckinStationHeartbeatPathParams(c, event.ID, stationID) + + if err := h.HeartbeatCheckinStation(c); err != nil { + t.Fatalf("HeartbeatCheckinStation: %v", err) + } + if rec.Code != http.StatusNotFound { + t.Fatalf("want 404, got %d, body=%s", rec.Code, rec.Body.String()) + } + validateResponse(t, http.MethodPost, path, rec) +} + +// List returns every registered station. +func TestOpenAPIContract_ListCheckinStations_ReturnsRegistered(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + now := time.Now() + stations := []*models.CheckinStation{ + {ID: uuid.New(), EventID: event.ID, Name: "Main Entrance", LastSeenAt: now, CreatedAt: now}, + {ID: uuid.New(), EventID: event.ID, Name: "Side Door", LastSeenAt: now, CreatedAt: now}, + } + h := newCheckinStationHandler(event, nil, nil, nil, + func(uuid.UUID) ([]*models.CheckinStation, error) { return stations, nil }, + ) + e := echo.New() + path := checkinStationsPath(event.ID) + c, rec := newAuthedContext(e, http.MethodGet, path, "", tenantID.String(), "admin") + setCheckinStationsPathParams(c, event.ID) + + if err := h.ListCheckinStations(c); err != nil { + t.Fatalf("ListCheckinStations: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d, body=%s", rec.Code, rec.Body.String()) + } + var got CheckinStationListResponse + if err := jsonUnmarshalBody(rec, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(got.Stations) != 2 { + t.Fatalf("got %d stations, want 2", len(got.Stations)) + } + validateResponse(t, http.MethodGet, path, rec) +} + +// Register/heartbeat/list on a foreign event (different tenant) all 404 — +// requireEventOwnership masks "foreign" as "missing", checked before any +// store call. +func TestOpenAPIContract_CheckinStations_ForeignEvent404(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + foreignTenantID := uuid.New() + stationID := uuid.New() + + t.Run("register", func(t *testing.T) { + h := newCheckinStationHandler(event, nil, + func(uuid.UUID, string, *uuid.UUID) (*models.CheckinStation, error) { + t.Fatalf("UpsertCheckinStation should not be called for a foreign event") + return nil, nil + }, + nil, nil, + ) + e := echo.New() + path := checkinStationsPath(event.ID) + body := `{"name":"Main Entrance"}` + c, rec := newAuthedContext(e, http.MethodPost, path, body, foreignTenantID.String(), "admin") + setCheckinStationsPathParams(c, event.ID) + if err := h.RegisterCheckinStation(c); err != nil { + t.Fatalf("RegisterCheckinStation: %v", err) + } + if rec.Code != http.StatusNotFound { + t.Fatalf("want 404, got %d, body=%s", rec.Code, rec.Body.String()) + } + validateResponse(t, http.MethodPost, path, rec) + }) + + t.Run("heartbeat", func(t *testing.T) { + h := newCheckinStationHandler(event, nil, nil, + func(uuid.UUID, uuid.UUID) error { + t.Fatalf("HeartbeatCheckinStation should not be called for a foreign event") + return nil + }, + nil, + ) + e := echo.New() + path := checkinStationHeartbeatPath(event.ID, stationID) + c, rec := newAuthedContext(e, http.MethodPost, path, "", foreignTenantID.String(), "admin") + setCheckinStationHeartbeatPathParams(c, event.ID, stationID) + if err := h.HeartbeatCheckinStation(c); err != nil { + t.Fatalf("HeartbeatCheckinStation: %v", err) + } + if rec.Code != http.StatusNotFound { + t.Fatalf("want 404, got %d, body=%s", rec.Code, rec.Body.String()) + } + validateResponse(t, http.MethodPost, path, rec) + }) + + t.Run("list", func(t *testing.T) { + h := newCheckinStationHandler(event, nil, nil, nil, + func(uuid.UUID) ([]*models.CheckinStation, error) { + t.Fatalf("ListCheckinStations should not be called for a foreign event") + return nil, nil + }, + ) + e := echo.New() + path := checkinStationsPath(event.ID) + c, rec := newAuthedContext(e, http.MethodGet, path, "", foreignTenantID.String(), "admin") + setCheckinStationsPathParams(c, event.ID) + if err := h.ListCheckinStations(c); err != nil { + t.Fatalf("ListCheckinStations: %v", err) + } + if rec.Code != http.StatusNotFound { + t.Fatalf("want 404, got %d, body=%s", rec.Code, rec.Body.String()) + } + validateResponse(t, http.MethodGet, path, rec) + }) +} + +// --- Task 3: idempotent check-in + undo + actions feed --- + +func checkinPath(eventID uuid.UUID) string { return "/api/events/" + eventID.String() + "/checkin" } +func checkinUndoPath(eventID uuid.UUID) string { return checkinPath(eventID) + "/undo" } +func checkinActionsPath(eventID uuid.UUID) string { + return "/api/events/" + eventID.String() + "/checkin-actions" +} + +func setCheckinPathParams(c echo.Context, eventID uuid.UUID) { + c.SetPath("/api/events/:event_id/checkin") + c.SetParamNames("event_id") + c.SetParamValues(eventID.String()) +} +func setCheckinUndoPathParams(c echo.Context, eventID uuid.UUID) { + c.SetPath("/api/events/:event_id/checkin/undo") + c.SetParamNames("event_id") + c.SetParamValues(eventID.String()) +} +func setCheckinActionsPathParams(c echo.Context, eventID uuid.UUID) { + c.SetPath("/api/events/:event_id/checkin-actions") + c.SetParamNames("event_id") + c.SetParamValues(eventID.String()) +} + +// newStationCheckinHandler builds a Handler wired for the stationCheckin / +// undoCheckin endpoints: event + attendee ownership resolve via +// getEventByID/getAttendeeByID (the same fakeStore.GetAttendeeByIDForTenant +// plumbing every other attendee-scoped contract test relies on), plus the +// station/check-in/undo/staff-user fakes each test needs. Any argument left +// nil panics if the corresponding path is exercised. +func newStationCheckinHandler( + event *models.Event, + attendee *models.Attendee, + getUser func(id uuid.UUID) (*models.User, error), + getStation func(id uuid.UUID) (*models.CheckinStation, error), + checkIn func(eventID, attendeeID uuid.UUID, stationID *uuid.UUID, staffUserID uuid.UUID, staffEmail, stationName string) (string, *models.Attendee, error), + undo func(eventID, attendeeID uuid.UUID, stationID *uuid.UUID, staffUserID uuid.UUID) (*models.Attendee, error), +) *Handler { + return New(&fakeStore{ + getEventByID: func(uuid.UUID) (*models.Event, error) { return event, nil }, + getAttendeeByID: func(uuid.UUID) (*models.Attendee, error) { return attendee, nil }, + getUserByID: getUser, + getCheckinStationByID: getStation, + checkInAttendee: checkIn, + undoCheckin: undo, + }) +} + +// TestOpenAPIContract_StationCheckin_FreshAttendeeChecksIn proves the happy +// path end-to-end: the handler resolves the staff user's email via +// GetUserByID and passes it (plus the attendee_id/station_id from the +// body) through to store.CheckInAttendee, and the 200 response carries the +// outcome/checkin block the store returned. +func TestOpenAPIContract_StationCheckin_FreshAttendeeChecksIn(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + attendee := contractAttendee(event.ID) + staffID := uuid.New() + stationID := uuid.New() + now := time.Now() + + h := newStationCheckinHandler(event, attendee, + func(id uuid.UUID) (*models.User, error) { + if id != staffID { + t.Fatalf("GetUserByID called with %s, want %s", id, staffID) + } + return &models.User{ID: staffID, Email: "staff@example.com"}, nil + }, + func(id uuid.UUID) (*models.CheckinStation, error) { + if id != stationID { + t.Fatalf("GetCheckinStationByID called with %s, want %s", id, stationID) + } + return &models.CheckinStation{ID: stationID, EventID: event.ID, Name: "Main Entrance"}, nil + }, + func(eventID, attendeeID uuid.UUID, gotStationID *uuid.UUID, gotStaffID uuid.UUID, staffEmail, stationName string) (string, *models.Attendee, error) { + if eventID != event.ID || attendeeID != attendee.ID { + t.Fatalf("CheckInAttendee called with eventID=%s attendeeID=%s, want %s/%s", eventID, attendeeID, event.ID, attendee.ID) + } + if gotStationID == nil || *gotStationID != stationID { + t.Fatalf("CheckInAttendee stationID = %v, want %s", gotStationID, stationID) + } + if gotStaffID != staffID { + t.Fatalf("CheckInAttendee staffUserID = %s, want %s", gotStaffID, staffID) + } + if staffEmail != "staff@example.com" { + t.Fatalf("CheckInAttendee staffEmail = %q, want staff@example.com", staffEmail) + } + if stationName != "Main Entrance" { + t.Fatalf("CheckInAttendee stationName = %q, want Main Entrance", stationName) + } + checkedIn := *attendee + checkedIn.CheckinStatus = true + checkedIn.CheckedInAt = &now + checkedIn.CheckedInByEmail = &staffEmail + checkedIn.CheckedInPointName = &stationName + return "checked_in", &checkedIn, nil + }, + nil, + ) + + e := echo.New() + path := checkinPath(event.ID) + body := `{"attendee_id":"` + attendee.ID.String() + `","station_id":"` + stationID.String() + `"}` + c, rec := newAuthedContextWithUserID(e, http.MethodPost, path, body, tenantID.String(), staffID, "staff") + setCheckinPathParams(c, event.ID) + + if err := h.StationCheckin(c); err != nil { + t.Fatalf("StationCheckin: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d, body=%s", rec.Code, rec.Body.String()) + } + var got StationCheckinResponse + if err := jsonUnmarshalBody(rec, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got.Outcome != "checked_in" { + t.Fatalf("outcome = %q, want checked_in", got.Outcome) + } + if got.Checkin == nil || got.Checkin.ByEmail != "staff@example.com" { + t.Fatalf("checkin = %+v, want by_email=staff@example.com", got.Checkin) + } + if got.Checkin.PointName == nil || *got.Checkin.PointName != "Main Entrance" { + t.Fatalf("checkin.point_name = %v, want Main Entrance", got.Checkin.PointName) + } + validateResponse(t, http.MethodPost, path, rec) +} + +// TestOpenAPIContract_StationCheckin_AlreadyCheckedInReturnsOriginalMetadata +// proves the repeat-scan path: the store returns outcome +// already_checked_in with the ORIGINAL first-scan metadata (a different +// staff/station than the store's canned return would prove this call did +// NOT overwrite it — CheckInAttendee is the sole source of truth here, the +// handler just relays whatever it returns). +func TestOpenAPIContract_StationCheckin_AlreadyCheckedInReturnsOriginalMetadata(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + attendee := contractAttendee(event.ID) + firstScan := time.Now().Add(-time.Hour) + originalEmail := "original.staff@example.com" + originalPoint := "Main Entrance" + + h := newStationCheckinHandler(event, attendee, + func(uuid.UUID) (*models.User, error) { return &models.User{Email: "second.staff@example.com"}, nil }, + nil, + func(uuid.UUID, uuid.UUID, *uuid.UUID, uuid.UUID, string, string) (string, *models.Attendee, error) { + existing := *attendee + existing.CheckinStatus = true + existing.CheckedInAt = &firstScan + existing.CheckedInByEmail = &originalEmail + existing.CheckedInPointName = &originalPoint + return "already_checked_in", &existing, nil + }, + nil, + ) + + e := echo.New() + path := checkinPath(event.ID) + body := `{"attendee_id":"` + attendee.ID.String() + `"}` + c, rec := newAuthedContext(e, http.MethodPost, path, body, tenantID.String(), "staff") + setCheckinPathParams(c, event.ID) + + if err := h.StationCheckin(c); err != nil { + t.Fatalf("StationCheckin: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d, body=%s", rec.Code, rec.Body.String()) + } + var got StationCheckinResponse + if err := jsonUnmarshalBody(rec, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got.Outcome != "already_checked_in" { + t.Fatalf("outcome = %q, want already_checked_in", got.Outcome) + } + if got.Checkin == nil || got.Checkin.ByEmail != originalEmail { + t.Fatalf("checkin.by_email = %v, want the ORIGINAL %s, never overwritten", got.Checkin, originalEmail) + } + if got.Checkin.PointName == nil || *got.Checkin.PointName != originalPoint { + t.Fatalf("checkin.point_name = %v, want the ORIGINAL %s, never overwritten", got.Checkin.PointName, originalPoint) + } + validateResponse(t, http.MethodPost, path, rec) +} + +// TestOpenAPIContract_StationCheckin_BlockedAttendeeNeverChecksIn proves the +// handler-level short-circuit: a blocked attendee returns outcome "blocked" +// with checkin: null and NEVER reaches store.CheckInAttendee (the nil +// checkIn func argument panics if it's called). +func TestOpenAPIContract_StationCheckin_BlockedAttendeeNeverChecksIn(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + attendee := contractAttendee(event.ID) + reason := "Denied entry — payment overdue" + attendee.Blocked = true + attendee.BlockReason = &reason + + h := newStationCheckinHandler(event, attendee, nil, nil, nil, nil) + + e := echo.New() + path := checkinPath(event.ID) + body := `{"attendee_id":"` + attendee.ID.String() + `"}` + c, rec := newAuthedContext(e, http.MethodPost, path, body, tenantID.String(), "staff") + setCheckinPathParams(c, event.ID) + + if err := h.StationCheckin(c); err != nil { + t.Fatalf("StationCheckin: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d, body=%s", rec.Code, rec.Body.String()) + } + var got StationCheckinResponse + if err := jsonUnmarshalBody(rec, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got.Outcome != "blocked" { + t.Fatalf("outcome = %q, want blocked", got.Outcome) + } + if got.Checkin != nil { + t.Fatalf("checkin = %+v, want nil for outcome blocked", got.Checkin) + } + if got.Attendee == nil || got.Attendee.BlockReason == nil || *got.Attendee.BlockReason != reason { + t.Fatalf("attendee.block_reason = %v, want %q", got.Attendee, reason) + } + validateResponse(t, http.MethodPost, path, rec) +} + +// TestOpenAPIContract_StationCheckin_UnknownAttendee404 proves an +// attendee_id with no matching row 404s (requireAttendeeOwnership masking) +// before ever reaching the store's check-in write. +func TestOpenAPIContract_StationCheckin_UnknownAttendee404(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + + h := New(&fakeStore{ + getEventByID: func(uuid.UUID) (*models.Event, error) { return event, nil }, + getAttendeeByID: func(uuid.UUID) (*models.Attendee, error) { return nil, nil }, + }) + + e := echo.New() + path := checkinPath(event.ID) + body := `{"attendee_id":"` + uuid.New().String() + `"}` + c, rec := newAuthedContext(e, http.MethodPost, path, body, tenantID.String(), "staff") + setCheckinPathParams(c, event.ID) + + if err := h.StationCheckin(c); err != nil { + t.Fatalf("StationCheckin: %v", err) + } + if rec.Code != http.StatusNotFound { + t.Fatalf("want 404, got %d, body=%s", rec.Code, rec.Body.String()) + } + validateResponse(t, http.MethodPost, path, rec) +} + +// TestOpenAPIContract_StationCheckin_AttendeeFromDifferentEvent400 proves an +// attendee that exists (same tenant) but belongs to a DIFFERENT event than +// the path's event_id is a 400, not a 404 — it genuinely exists, it's just +// out of scope for this event (badge_zpl.go's BadgeZPL handler precedent). +func TestOpenAPIContract_StationCheckin_AttendeeFromDifferentEvent400(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + otherEvent := contractEvent(tenantID, "Other Event") + attendee := contractAttendee(otherEvent.ID) + + h := New(&fakeStore{ + getEventByID: func(id uuid.UUID) (*models.Event, error) { + if id == otherEvent.ID { + return otherEvent, nil + } + return event, nil + }, + getAttendeeByID: func(uuid.UUID) (*models.Attendee, error) { return attendee, nil }, + }) + + e := echo.New() + path := checkinPath(event.ID) + body := `{"attendee_id":"` + attendee.ID.String() + `"}` + c, rec := newAuthedContext(e, http.MethodPost, path, body, tenantID.String(), "staff") + setCheckinPathParams(c, event.ID) + + if err := h.StationCheckin(c); err != nil { + t.Fatalf("StationCheckin: %v", err) + } + if rec.Code != http.StatusBadRequest { + t.Fatalf("want 400, got %d, body=%s", rec.Code, rec.Body.String()) + } + validateResponse(t, http.MethodPost, path, rec) +} + +// TestOpenAPIContract_StationCheckin_ForeignStation400 proves a station_id +// belonging to a DIFFERENT event is a 400, checked before the guarded +// check-in write (the nil checkIn argument panics if it's reached). +func TestOpenAPIContract_StationCheckin_ForeignStation400(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + attendee := contractAttendee(event.ID) + foreignStation := &models.CheckinStation{ID: uuid.New(), EventID: uuid.New(), Name: "Someone Else's Station"} + + h := newStationCheckinHandler(event, attendee, nil, + func(uuid.UUID) (*models.CheckinStation, error) { return foreignStation, nil }, + nil, nil, + ) + + e := echo.New() + path := checkinPath(event.ID) + body := `{"attendee_id":"` + attendee.ID.String() + `","station_id":"` + foreignStation.ID.String() + `"}` + c, rec := newAuthedContext(e, http.MethodPost, path, body, tenantID.String(), "staff") + setCheckinPathParams(c, event.ID) + + if err := h.StationCheckin(c); err != nil { + t.Fatalf("StationCheckin: %v", err) + } + if rec.Code != http.StatusBadRequest { + t.Fatalf("want 400, got %d, body=%s", rec.Code, rec.Body.String()) + } + validateResponse(t, http.MethodPost, path, rec) +} + +// TestOpenAPIContract_StationCheckin_MissingAttendeeID400 proves an absent +// attendee_id never reaches ownership resolution. +func TestOpenAPIContract_StationCheckin_MissingAttendeeID400(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + h := New(&fakeStore{ + getEventByID: func(uuid.UUID) (*models.Event, error) { return event, nil }, + getAttendeeByID: func(uuid.UUID) (*models.Attendee, error) { + t.Fatalf("GetAttendeeByID should not be called when attendee_id is missing") + return nil, nil + }, + }) + e := echo.New() + path := checkinPath(event.ID) + c, rec := newAuthedContext(e, http.MethodPost, path, `{}`, tenantID.String(), "staff") + setCheckinPathParams(c, event.ID) + + if err := h.StationCheckin(c); err != nil { + t.Fatalf("StationCheckin: %v", err) + } + if rec.Code != http.StatusBadRequest { + t.Fatalf("want 400, got %d, body=%s", rec.Code, rec.Body.String()) + } + validateResponse(t, http.MethodPost, path, rec) +} + +// TestOpenAPIContract_UndoCheckin_ClearsCheckedIn proves the happy path: +// the response envelope carries the (now cleared) attendee the store +// returned. +func TestOpenAPIContract_UndoCheckin_ClearsCheckedIn(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + attendee := contractAttendee(event.ID) + attendee.CheckinStatus = true + + h := newStationCheckinHandler(event, attendee, nil, nil, nil, + func(eventID, attendeeID uuid.UUID, stationID *uuid.UUID, staffUserID uuid.UUID) (*models.Attendee, error) { + if eventID != event.ID || attendeeID != attendee.ID { + t.Fatalf("UndoCheckin called with eventID=%s attendeeID=%s, want %s/%s", eventID, attendeeID, event.ID, attendee.ID) + } + cleared := *attendee + cleared.CheckinStatus = false + cleared.CheckedInAt = nil + return &cleared, nil + }, + ) + + e := echo.New() + path := checkinUndoPath(event.ID) + body := `{"attendee_id":"` + attendee.ID.String() + `"}` + c, rec := newAuthedContext(e, http.MethodPost, path, body, tenantID.String(), "staff") + setCheckinUndoPathParams(c, event.ID) + + if err := h.UndoCheckin(c); err != nil { + t.Fatalf("UndoCheckin: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d, body=%s", rec.Code, rec.Body.String()) + } + var got UndoCheckinResponse + if err := jsonUnmarshalBody(rec, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got.Attendee == nil || got.Attendee.CheckinStatus { + t.Fatalf("attendee.checkin_status = %+v, want false after undo", got.Attendee) + } + validateResponse(t, http.MethodPost, path, rec) +} + +// TestOpenAPIContract_UndoCheckin_AlreadyClearIsIdempotent200 proves +// undoing an attendee who is already not checked in is still a 200 (the +// store's idempotent no-op, relayed as-is by the handler). +func TestOpenAPIContract_UndoCheckin_AlreadyClearIsIdempotent200(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + attendee := contractAttendee(event.ID) + + h := newStationCheckinHandler(event, attendee, nil, nil, nil, + func(uuid.UUID, uuid.UUID, *uuid.UUID, uuid.UUID) (*models.Attendee, error) { + return attendee, nil + }, + ) + + e := echo.New() + path := checkinUndoPath(event.ID) + body := `{"attendee_id":"` + attendee.ID.String() + `"}` + c, rec := newAuthedContext(e, http.MethodPost, path, body, tenantID.String(), "staff") + setCheckinUndoPathParams(c, event.ID) + + if err := h.UndoCheckin(c); err != nil { + t.Fatalf("UndoCheckin: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d, body=%s", rec.Code, rec.Body.String()) + } + validateResponse(t, http.MethodPost, path, rec) +} + +// TestOpenAPIContract_UndoCheckin_UnknownAttendee404 mirrors stationCheckin's +// masking of a missing attendee. +func TestOpenAPIContract_UndoCheckin_UnknownAttendee404(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + h := New(&fakeStore{ + getEventByID: func(uuid.UUID) (*models.Event, error) { return event, nil }, + getAttendeeByID: func(uuid.UUID) (*models.Attendee, error) { return nil, nil }, + }) + e := echo.New() + path := checkinUndoPath(event.ID) + body := `{"attendee_id":"` + uuid.New().String() + `"}` + c, rec := newAuthedContext(e, http.MethodPost, path, body, tenantID.String(), "staff") + setCheckinUndoPathParams(c, event.ID) + + if err := h.UndoCheckin(c); err != nil { + t.Fatalf("UndoCheckin: %v", err) + } + if rec.Code != http.StatusNotFound { + t.Fatalf("want 404, got %d, body=%s", rec.Code, rec.Body.String()) + } + validateResponse(t, http.MethodPost, path, rec) +} + +// TestOpenAPIContract_UndoCheckin_ForeignStation400 mirrors stationCheckin's +// foreign-station validation. +func TestOpenAPIContract_UndoCheckin_ForeignStation400(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + attendee := contractAttendee(event.ID) + foreignStation := &models.CheckinStation{ID: uuid.New(), EventID: uuid.New(), Name: "Someone Else's Station"} + + h := newStationCheckinHandler(event, attendee, nil, + func(uuid.UUID) (*models.CheckinStation, error) { return foreignStation, nil }, + nil, nil, + ) + + e := echo.New() + path := checkinUndoPath(event.ID) + body := `{"attendee_id":"` + attendee.ID.String() + `","station_id":"` + foreignStation.ID.String() + `"}` + c, rec := newAuthedContext(e, http.MethodPost, path, body, tenantID.String(), "staff") + setCheckinUndoPathParams(c, event.ID) + + if err := h.UndoCheckin(c); err != nil { + t.Fatalf("UndoCheckin: %v", err) + } + if rec.Code != http.StatusBadRequest { + t.Fatalf("want 400, got %d, body=%s", rec.Code, rec.Body.String()) + } + validateResponse(t, http.MethodPost, path, rec) +} + +// TestOpenAPIContract_GetCheckinActions_ReturnsNewestFirstAndDefaultsLimit +// proves the default limit (50) is passed to the store when no ?limit is +// given, and the response envelope carries whatever rows the store +// returns, newest first. +func TestOpenAPIContract_GetCheckinActions_ReturnsNewestFirstAndDefaultsLimit(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + newer := time.Now() + older := newer.Add(-time.Minute) + rows := []store.CheckinActionRow{ + {ID: uuid.New(), Action: "checkin", CreatedAt: newer, Attendee: store.CheckinActionAttendee{ID: uuid.New(), FirstName: "Ada", LastName: "Lovelace", Code: "CODE1"}}, + {ID: uuid.New(), Action: "undo", CreatedAt: older, Attendee: store.CheckinActionAttendee{ID: uuid.New(), FirstName: "Bob", LastName: "Builder", Code: "CODE2"}}, + } + + h := New(&fakeStore{ + getEventByID: func(uuid.UUID) (*models.Event, error) { return event, nil }, + getCheckinActions: func(eventID uuid.UUID, limit int) ([]store.CheckinActionRow, error) { + if eventID != event.ID { + t.Fatalf("GetCheckinActions eventID = %s, want %s", eventID, event.ID) + } + if limit != 50 { + t.Fatalf("GetCheckinActions limit = %d, want default 50", limit) + } + return rows, nil + }, + }) + + e := echo.New() + path := checkinActionsPath(event.ID) + c, rec := newAuthedContext(e, http.MethodGet, path, "", tenantID.String(), "staff") + setCheckinActionsPathParams(c, event.ID) + + if err := h.GetCheckinActions(c); err != nil { + t.Fatalf("GetCheckinActions: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d, body=%s", rec.Code, rec.Body.String()) + } + var got CheckinActionsResponse + if err := jsonUnmarshalBody(rec, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(got.Actions) != 2 || got.Actions[0].Action != "checkin" || got.Actions[1].Action != "undo" { + t.Fatalf("got actions=%+v, want [checkin, undo] newest-first", got.Actions) + } + validateResponse(t, http.MethodGet, path, rec) +} + +// TestOpenAPIContract_GetCheckinActions_LimitHonoredAndClampedTo50 proves a +// caller-supplied ?limit is passed through to the store, and a value above +// 50 is clamped down rather than rejected. +func TestOpenAPIContract_GetCheckinActions_LimitHonoredAndClampedTo50(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + + for name, tc := range map[string]struct { + query string + wantLimit int + }{ + "below_default": {"10", 10}, + "above_max": {"999", 50}, + } { + var gotLimit int + h := New(&fakeStore{ + getEventByID: func(uuid.UUID) (*models.Event, error) { return event, nil }, + getCheckinActions: func(_ uuid.UUID, limit int) ([]store.CheckinActionRow, error) { + gotLimit = limit + return nil, nil + }, + }) + + e := echo.New() + path := checkinActionsPath(event.ID) + c, rec := newAuthedContext(e, http.MethodGet, path, "", tenantID.String(), "staff") + setCheckinActionsPathParams(c, event.ID) + c.QueryParams().Set("limit", tc.query) + + if err := h.GetCheckinActions(c); err != nil { + t.Fatalf("%s: GetCheckinActions: %v", name, err) + } + if rec.Code != http.StatusOK { + t.Fatalf("%s: want 200, got %d, body=%s", name, rec.Code, rec.Body.String()) + } + if gotLimit != tc.wantLimit { + t.Errorf("%s: limit passed to store = %d, want %d", name, gotLimit, tc.wantLimit) + } + validateResponse(t, http.MethodGet, path, rec) + } +} + +// TestOpenAPIContract_CheckinActions_ForeignEvent404 mirrors every other +// event-scoped endpoint's ownership masking. +func TestOpenAPIContract_CheckinActions_ForeignEvent404(t *testing.T) { + tenantID := uuid.New() + event := contractEvent(tenantID, "Tech Summit") + foreignTenantID := uuid.New() + + h := New(&fakeStore{ + getEventByID: func(uuid.UUID) (*models.Event, error) { return event, nil }, + getCheckinActions: func(uuid.UUID, int) ([]store.CheckinActionRow, error) { + t.Fatalf("GetCheckinActions should not be called for a foreign event") + return nil, nil + }, + }) + e := echo.New() + path := checkinActionsPath(event.ID) + c, rec := newAuthedContext(e, http.MethodGet, path, "", foreignTenantID.String(), "staff") + setCheckinActionsPathParams(c, event.ID) + + if err := h.GetCheckinActions(c); err != nil { + t.Fatalf("GetCheckinActions: %v", err) + } + if rec.Code != http.StatusNotFound { + t.Fatalf("want 404, got %d, body=%s", rec.Code, rec.Body.String()) + } + validateResponse(t, http.MethodGet, path, rec) +} diff --git a/backend/internal/handler/testsupport_test.go b/backend/internal/handler/testsupport_test.go index 7be38f78..6b5b609b 100644 --- a/backend/internal/handler/testsupport_test.go +++ b/backend/internal/handler/testsupport_test.go @@ -64,6 +64,16 @@ type fakeStore struct { getEventBadgeTemplate func(eventID uuid.UUID) (json.RawMessage, int, error) updateEventBadgeTemplate func(eventID uuid.UUID, template json.RawMessage, expectedVersion int) (int, error) syncBadgeTemplateFromLegacy func(eventID uuid.UUID, template json.RawMessage) (int, error) + getCheckinSettings func(eventID uuid.UUID) (json.RawMessage, error) + updateCheckinSettings func(eventID uuid.UUID, settings json.RawMessage) error + upsertCheckinStation func(eventID uuid.UUID, name string, zoneID *uuid.UUID) (*models.CheckinStation, error) + heartbeatCheckinStation func(eventID, stationID uuid.UUID) error + listCheckinStations func(eventID uuid.UUID) ([]*models.CheckinStation, error) + getCheckinStationByID func(id uuid.UUID) (*models.CheckinStation, error) + checkInAttendee func(eventID, attendeeID uuid.UUID, stationID *uuid.UUID, staffUserID uuid.UUID, staffEmail, stationName string) (string, *models.Attendee, error) + undoCheckin func(eventID, attendeeID uuid.UUID, stationID *uuid.UUID, staffUserID uuid.UUID) (*models.Attendee, error) + getCheckinActions func(eventID uuid.UUID, limit int) ([]store.CheckinActionRow, error) + insertCheckinAction func(eventID, attendeeID uuid.UUID, action string, stationID *uuid.UUID, staffUserID uuid.UUID) error createTenantWithDefaultSubscription func(tenant *models.Tenant) error provisionTenantWithAdmin func(tenantName, email, password string) (*models.Tenant, *models.User, error) @@ -273,6 +283,36 @@ func (f *fakeStore) UpdateEventBadgeTemplate(_ context.Context, eventID uuid.UUI func (f *fakeStore) SyncBadgeTemplateFromLegacy(_ context.Context, eventID uuid.UUID, template json.RawMessage) (int, error) { return f.syncBadgeTemplateFromLegacy(eventID, template) } +func (f *fakeStore) GetCheckinSettings(_ context.Context, eventID uuid.UUID) (json.RawMessage, error) { + return f.getCheckinSettings(eventID) +} +func (f *fakeStore) UpdateCheckinSettings(_ context.Context, eventID uuid.UUID, settings json.RawMessage) error { + return f.updateCheckinSettings(eventID, settings) +} +func (f *fakeStore) UpsertCheckinStation(_ context.Context, eventID uuid.UUID, name string, zoneID *uuid.UUID) (*models.CheckinStation, error) { + return f.upsertCheckinStation(eventID, name, zoneID) +} +func (f *fakeStore) HeartbeatCheckinStation(_ context.Context, eventID, stationID uuid.UUID) error { + return f.heartbeatCheckinStation(eventID, stationID) +} +func (f *fakeStore) ListCheckinStations(_ context.Context, eventID uuid.UUID) ([]*models.CheckinStation, error) { + return f.listCheckinStations(eventID) +} +func (f *fakeStore) GetCheckinStationByID(_ context.Context, id uuid.UUID) (*models.CheckinStation, error) { + return f.getCheckinStationByID(id) +} +func (f *fakeStore) CheckInAttendee(_ context.Context, eventID, attendeeID uuid.UUID, stationID *uuid.UUID, staffUserID uuid.UUID, staffEmail, stationName string) (string, *models.Attendee, error) { + return f.checkInAttendee(eventID, attendeeID, stationID, staffUserID, staffEmail, stationName) +} +func (f *fakeStore) UndoCheckin(_ context.Context, eventID, attendeeID uuid.UUID, stationID *uuid.UUID, staffUserID uuid.UUID) (*models.Attendee, error) { + return f.undoCheckin(eventID, attendeeID, stationID, staffUserID) +} +func (f *fakeStore) GetCheckinActions(_ context.Context, eventID uuid.UUID, limit int) ([]store.CheckinActionRow, error) { + return f.getCheckinActions(eventID, limit) +} +func (f *fakeStore) InsertCheckinAction(_ context.Context, eventID, attendeeID uuid.UUID, action string, stationID *uuid.UUID, staffUserID uuid.UUID) error { + return f.insertCheckinAction(eventID, attendeeID, action, stationID, staffUserID) +} func (f *fakeStore) CreateTenantWithDefaultSubscription(_ context.Context, tenant *models.Tenant) error { return f.createTenantWithDefaultSubscription(tenant) diff --git a/backend/internal/models/models.go b/backend/internal/models/models.go index 52330886..af844d57 100644 --- a/backend/internal/models/models.go +++ b/backend/internal/models/models.go @@ -75,9 +75,18 @@ type Event struct { // store.UpdateEventBadgeTemplate, not via the general Event CRUD paths. BadgeTemplate json.RawMessage `json:"-"` BadgeTemplateVersion int `json:"-"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt *time.Time `json:"deleted_at,omitempty"` + // CheckinSettings (P4.1) is excluded from generic event JSON (json:"-") + // the same way BadgeTemplate is above — the dedicated GET/PUT + // /api/events/{id}/checkin-settings endpoint is the only read/write + // surface. Populate via store.GetCheckinSettings / + // store.UpdateCheckinSettings, not via the general Event CRUD paths. + // Unlike BadgeTemplate, there is no version column: settings are + // operator-only config with no concurrent-editor conflict class to + // guard against. + CheckinSettings json.RawMessage `json:"-"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt *time.Time `json:"deleted_at,omitempty"` } type Attendee struct { @@ -410,3 +419,29 @@ type EventStatsResponse struct { CheckedIn int `json:"checked_in"` ZoneStats *ZoneScanStats `json:"zone_stats,omitempty"` } + +// CheckinStation is a registered check-in station (P4.1) — distinct from +// the mobile-track Station (zone/kiosk devices): a checkin_station is +// name-scoped per event (UNIQUE(event_id, name)) and optionally bound to a +// zone, with LastSeenAt updated by a heartbeat endpoint (Task 2). +type CheckinStation struct { + ID uuid.UUID `json:"id"` + EventID uuid.UUID `json:"event_id"` + Name string `json:"name"` + ZoneID *uuid.UUID `json:"zone_id,omitempty"` + LastSeenAt time.Time `json:"last_seen_at"` + CreatedAt time.Time `json:"created_at"` +} + +// CheckinAction is one row of the durable check-in/undo/reprint feed +// (P4.1) backing checkin_actions — the audit trail a station's "recent +// scans" rail (Task 9) and any reprint logging (Task 4) read from. +type CheckinAction struct { + ID uuid.UUID `json:"id"` + EventID uuid.UUID `json:"event_id"` + AttendeeID uuid.UUID `json:"attendee_id"` + StationID *uuid.UUID `json:"station_id,omitempty"` + Action string `json:"action"` // "checkin" | "undo" | "reprint" + StaffUserID *uuid.UUID `json:"staff_user_id,omitempty"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/backend/internal/store/interface.go b/backend/internal/store/interface.go index f5dbe435..314b5af6 100644 --- a/backend/internal/store/interface.go +++ b/backend/internal/store/interface.go @@ -98,6 +98,151 @@ type Store interface { // on error rather than failing the legacy PUT itself. SyncBadgeTemplateFromLegacy(ctx context.Context, eventID uuid.UUID, template json.RawMessage) (int, error) + // GetCheckinSettings reads the dedicated events.checkin_settings JSONB + // column (P4.1). Returns (nil, nil) when the column is NULL (no + // settings saved yet) or when no matching, non-deleted event exists — + // it never fabricates a settings object, mirroring + // GetEventBadgeTemplate's not-found idiom. Callers needing to + // distinguish "no settings" from "no such event" must check existence + // themselves (e.g. via requireEventOwnership). + GetCheckinSettings(ctx context.Context, eventID uuid.UUID) (json.RawMessage, error) + // UpdateCheckinSettings persists settings verbatim (raw bytes, no + // re-encoding) under a `deleted_at IS NULL` guard — the same race-class + // guard as UpdateEventBadgeTemplate/IncrementAttendeePrintedCount, but + // with no optimistic-concurrency version: check-in settings are + // operator-only config with no concurrent-editor conflict class to + // guard against. Contract: the caller must already have confirmed the + // event exists (e.g. via requireEventOwnership) before calling; a + // 0-row result (the soft-delete race) returns the exported + // ErrEventNotFound sentinel (PR #77 bot-review round, Finding C — this + // used to be a silent no-op, the same idiom as SoftDeleteEvent, which + // let the handler respond 200 with settings that were never actually + // persisted). Handlers map ErrEventNotFound to the house 404 masking. + UpdateCheckinSettings(ctx context.Context, eventID uuid.UUID, settings json.RawMessage) error + + // UpsertCheckinStation registers a check-in station (P4.1 Task 2): a + // fresh (event_id, name) pair inserts a new row; re-registering the + // SAME name is idempotent — ON CONFLICT (event_id, name) DO UPDATE + // replaces zone_id (even back to nil) and refreshes last_seen_at, + // returning the SAME row/id rather than creating a duplicate. Contract: + // the caller must already have confirmed the event exists (e.g. via + // requireEventOwnership) and, when zoneID is non-nil, that it belongs + // to the SAME event (e.g. via GetEventZoneByID) — this method does not + // re-validate either. + UpsertCheckinStation(ctx context.Context, eventID uuid.UUID, name string, zoneID *uuid.UUID) (*models.CheckinStation, error) + // HeartbeatCheckinStation refreshes a station's last_seen_at, scoped to + // eventID so a station id belonging to a different event can never be + // touched. On 0 rows (unknown id, or an id that belongs to a different + // event) this returns the exported ErrCheckinStationNotFound sentinel — + // handlers map it to a 404, never a fabricated success. + HeartbeatCheckinStation(ctx context.Context, eventID, stationID uuid.UUID) error + // ListCheckinStations returns every station registered for eventID, + // ordered by name for a deterministic listing. + ListCheckinStations(ctx context.Context, eventID uuid.UUID) ([]*models.CheckinStation, error) + // GetCheckinStationByID looks up a single check-in station by id (P4.1 + // Task 3) — used by the check-in/undo endpoints to resolve a + // caller-supplied station_id into its display name (persisted into + // checked_in_point_name) and to validate it belongs to the same event as + // the request path (a foreign station_id is a 400, decided by the + // handler comparing the returned CheckinStation.EventID). Mirrors + // GetEventZoneByID: on no matching row this surfaces the raw + // pgx.ErrNoRows rather than normalizing to (nil, nil) — callers + // distinguish "unknown id" from "found" via errors.Is(err, pgx.ErrNoRows). + GetCheckinStationByID(ctx context.Context, id uuid.UUID) (*models.CheckinStation, error) + + // CheckInAttendee performs one station's single-scan check-in + // idempotently (P4.1 Task 3) — the zero-double-checkin guarantee at the + // source, mirroring ApplyBatchCheckin's guarded-UPDATE pattern + // (pg_store_batch.go) but with a RETURNING clause so the full row comes + // back in the same round trip. In one transaction: a guarded + // `UPDATE ... WHERE checkin_status = false AND blocked = false AND + // deleted_at IS NULL` RETURNING the row — the SET clause also clears + // checked_in_device_number (PR #77 bot-review round 2, Finding 2: + // mirrors UndoCheckin's clear, so a fresh panel check-in never inherits + // a stale device number left over from an earlier mobile check-in/undo + // cycle). When it matches (this call wins the race), the outcome is + // "checked_in" and a checkin_actions ('checkin') row is inserted in the + // SAME transaction. When it matches nothing, a fallback SELECT (LEFT + // JOINed to users for checked_in_by_email, mirroring + // attendeeListColumnsSQL/scanAttendeeRow — attendees has no + // checked_in_by_email COLUMN; it is always derived from users.email via + // checked_in_by) distinguishes FOUR cases: an attendee that is genuinely + // missing (soft-deleted, or the id doesn't belong to eventID — returns + // the exported ErrAttendeeNotFound, no retry); one that is newly + // blocked (checkin_status still false, blocked now true — outcome + // "blocked", PR #77 bot-review round 1 Finding A: closes the TOCTOU + // race where another operator blocks the SAME attendee in the window + // between the HANDLER's pre-read short-circuit and this guarded UPDATE + // actually running, so the blocked = false guard above is what makes + // this path reachable at all); one that is simply already checked in + // (outcome "already_checked_in", returning its ORIGINAL first-scan + // metadata — never overwritten); or — PR #77 bot-review round 2, + // Finding 1 — one that is neither checked in NOR blocked (checkin_status + // false, blocked false): a narrow race where the guarded UPDATE lost to + // something else that then resolved before the fallback SELECT ran. + // This last case is retried ONCE more against the now-current state + // (bounded: at most 2 total attempts) rather than being misreported as + // "already_checked_in" — reporting that outcome here would be doubly + // wrong: it's factually false, AND since printing only fires on + // "checked_in", the attendee would walk through with a false verdict and + // no badge. If the retry lands in the SAME state again, this method + // returns the exported ErrCheckinConflict for the handler to map to a + // retryable 409. No feed row is written for the "blocked", + // "already_checked_in", or ErrCheckinConflict paths. Contract: the + // caller must already have confirmed the attendee exists, belongs to + // eventID, and — at the time of its own pre-read — was NOT blocked (e.g. + // via requireAttendeeOwnership + an explicit attendee.Blocked check) — + // the HANDLER's pre-read short-circuit is still the primary "blocked" + // path; this method's own blocked = false guard is the second, + // race-closing path, not a replacement for the handler's check. + // staffEmail/stationName are resolved by the caller (via GetUserByID / + // GetCheckinStationByID); on the "checked_in" outcome they are attached + // to the returned row verbatim (an empty stationName leaves + // checked_in_point_name unset, matching the nullable column). + CheckInAttendee(ctx context.Context, eventID, attendeeID uuid.UUID, stationID *uuid.UUID, staffUserID uuid.UUID, staffEmail, stationName string) (outcome string, attendee *models.Attendee, err error) + + // UndoCheckin clears a check-in idempotently (P4.1 Task 3): a guarded + // `UPDATE ... WHERE checkin_status = true AND deleted_at IS NULL` + // clearing checkin_status/checked_in_at/checked_in_by/ + // checked_in_device_number/checked_in_point_name (fixing the legacy + // UpdateAttendeeHandler path's incomplete clear, which never touched + // checked_in_point_name; PR #77 bot-review round Finding B further + // added checked_in_device_number to this clear list — an attendee + // checked in via the mobile batch path otherwise kept stale device + // metadata after a panel undo). When it matches, a + // checkin_actions ('undo') row is inserted in the SAME transaction. + // When it matches nothing, a fallback SELECT distinguishes "genuinely + // missing" (ErrAttendeeNotFound) from "already not checked in" + // (idempotent no-op — 200, no feed row written). stationID/staffUserID + // are recorded on the feed row only; they play no part in the guard. + UndoCheckin(ctx context.Context, eventID, attendeeID uuid.UUID, stationID *uuid.UUID, staffUserID uuid.UUID) (*models.Attendee, error) + + // GetCheckinActions returns the newest `limit` rows of an event's + // check-in/undo/reprint feed (P4.1 Task 3), joined to a slim attendee + // projection — backs the station's recent-scans rail. Ordered newest + // first (created_at DESC, id DESC as a deterministic tie-breaker for + // rows sharing the same timestamp — PR #77 bot-review round, Finding + // E — otherwise concurrent actions at the same created_at could be + // arbitrarily reordered or omitted across repeated calls with the same + // LIMIT). + GetCheckinActions(ctx context.Context, eventID uuid.UUID, limit int) ([]CheckinActionRow, error) + + // InsertCheckinAction records one checkin_actions feed row (P4.1 Task + // 4) — the single shared write path behind CheckInAttendee's 'checkin' + // row, UndoCheckin's 'undo' row, and the /printed endpoint's 'reprint' + // row. Called standalone (against the pool, not any existing + // transaction) by the reprint endpoint, since printed_count's + // increment and this insert are two separate store calls, not one + // atomic operation — CheckInAttendee/UndoCheckin do NOT call this + // method themselves; they run the same underlying insert directly + // against their own open tx so the feed row commits atomically with + // the state-changing UPDATE. Contract: the caller has already resolved + // staffUserID and validated a non-nil stationID belongs to the same + // event — this method does not re-validate either, and never fails + // the caller's primary operation (attendee_printed.go treats a + // failure here as best-effort/non-fatal). + InsertCheckinAction(ctx context.Context, eventID, attendeeID uuid.UUID, action string, stationID *uuid.UUID, staffUserID uuid.UUID) error + CreateAttendee(ctx context.Context, attendee *models.Attendee) error // GetAttendeesByEventID lists attendees for an event; code/search are // optional filters ("" skips the filter) — code does an exact match, @@ -254,3 +399,26 @@ type AttendeeFilter struct { Page int PerPage int } + +// CheckinActionAttendee is the slim attendee projection embedded in a +// CheckinActionRow — just enough for the station's recent-scans rail to +// render a name/code without pulling the full Attendee row. JSON tags +// match the CheckinActionAttendee openapi schema verbatim. +type CheckinActionAttendee struct { + ID uuid.UUID `json:"id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Code string `json:"code"` +} + +// CheckinActionRow is one joined row of GetCheckinActions' feed (P4.1 Task +// 3): the checkin_actions row plus its attendee's slim projection. JSON +// tags match the CheckinActionRow openapi schema verbatim (this struct is +// serialized directly by handler.GetCheckinActions). +type CheckinActionRow struct { + ID uuid.UUID `json:"id"` + Action string `json:"action"` + StationID *uuid.UUID `json:"station_id,omitempty"` + CreatedAt time.Time `json:"created_at"` + Attendee CheckinActionAttendee `json:"attendee"` +} diff --git a/backend/internal/store/pg_store.go b/backend/internal/store/pg_store.go index f323a2ce..8b10d04d 100644 --- a/backend/internal/store/pg_store.go +++ b/backend/internal/store/pg_store.go @@ -727,6 +727,505 @@ func (s *PGStore) SyncBadgeTemplateFromLegacy(ctx context.Context, eventID uuid. return newVersion, nil } +// GetCheckinSettings reads the dedicated events.checkin_settings JSONB +// column (P4.1) directly. Both "column is NULL" (no settings saved yet) +// and "no matching, non-deleted event" collapse to the same (nil, nil) +// zero value — mirrors GetEventBadgeTemplate's not-found idiom: this +// method never fabricates a settings object, and never reports +// pgx.ErrNoRows to the caller. Callers that need to distinguish a missing +// event from missing settings must check existence themselves (e.g. +// requireEventOwnership). +func (s *PGStore) GetCheckinSettings(ctx context.Context, eventID uuid.UUID) (json.RawMessage, error) { + var settingsJSON []byte + query := `SELECT checkin_settings FROM events WHERE id = $1 AND deleted_at IS NULL` + err := s.db.QueryRow(ctx, query, eventID).Scan(&settingsJSON) + if err != nil { + if err == pgx.ErrNoRows { + return nil, nil + } + return nil, err + } + if len(settingsJSON) == 0 || string(settingsJSON) == "null" { + return nil, nil + } + return json.RawMessage(settingsJSON), nil +} + +// ErrEventNotFound is returned by UpdateCheckinSettings when its guarded +// UPDATE affects 0 rows — PR #77 bot-review round, Finding C. By contract +// the caller has already confirmed the event exists (via +// requireEventOwnership) before calling, so this sentinel is reachable +// ONLY via the soft-delete race: the pre-check passes, a concurrent +// SoftDeleteEvent lands, and the `deleted_at IS NULL` guard then matches +// nothing. Mirrors ErrAttendeeNotFound/ErrVersionConflict's 0-row sentinel +// pattern (UpdateEventBadgeTemplate/IncrementAttendeePrintedCount) — before +// this, UpdateCheckinSettings silently swallowed the 0-row case (the same +// idiom as SoftDeleteEvent's genuinely-idempotent delete), which meant the +// handler responded 200 with settings that were never actually persisted. +// Handlers map it to the house 404 masking ("Event not found"), identical +// to requireEventOwnership's own wording. +var ErrEventNotFound = errors.New("event not found") + +// UpdateCheckinSettings persists settings verbatim (raw bytes, no +// re-encoding) — no optimistic-concurrency version, unlike +// UpdateEventBadgeTemplate: check-in settings are operator-only config +// with no concurrent-editor conflict class to guard against. The UPDATE +// nevertheless carries a `deleted_at IS NULL` guard (same race class as +// UpdateEventBadgeTemplate/IncrementAttendeePrintedCount): the caller's +// requireEventOwnership pre-check can pass and a concurrent soft-delete +// land before this UPDATE executes. Contract: the caller must already +// have confirmed the event exists before calling — a 0-row result (the +// soft-delete race) returns the exported ErrEventNotFound sentinel, never +// a fabricated success (PR #77 bot-review round, Finding C). +func (s *PGStore) UpdateCheckinSettings(ctx context.Context, eventID uuid.UUID, settings json.RawMessage) error { + tag, err := s.db.Exec(ctx, + `UPDATE events SET checkin_settings = $1, updated_at = now() WHERE id = $2 AND deleted_at IS NULL`, + []byte(settings), eventID) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrEventNotFound + } + return nil +} + +// ErrCheckinStationNotFound is returned by HeartbeatCheckinStation when its +// guarded UPDATE matches 0 rows — either the station id doesn't exist at +// all, or it belongs to a different event than the caller's eventID (the +// `AND event_id = $2` guard is what makes a foreign station id 404 rather +// than silently heartbeating someone else's station). Handlers map it to +// the house 404, never a fabricated success. +var ErrCheckinStationNotFound = errors.New("check-in station not found") + +// UpsertCheckinStation registers a check-in station scoped to eventID +// (P4.1 Task 2). A fresh name inserts a new row (last_seen_at defaults to +// now() from the column default); re-registering the SAME name is +// idempotent via ON CONFLICT (event_id, name) DO UPDATE — the SAME row/id +// is returned, with zone_id replaced by the newly-submitted value (even +// back to NULL) and last_seen_at refreshed, rather than erroring or +// creating a duplicate row. Contract: the caller must already have +// confirmed the event exists and, when zoneID is non-nil, that it belongs +// to the SAME event (e.g. via requireEventOwnership + GetEventZoneByID) — +// this method does not re-validate either. +func (s *PGStore) UpsertCheckinStation(ctx context.Context, eventID uuid.UUID, name string, zoneID *uuid.UUID) (*models.CheckinStation, error) { + var st models.CheckinStation + query := `INSERT INTO checkin_stations (event_id, name, zone_id) + VALUES ($1, $2, $3) + ON CONFLICT (event_id, name) DO UPDATE SET zone_id = EXCLUDED.zone_id, last_seen_at = now() + RETURNING id, event_id, name, zone_id, last_seen_at, created_at` + err := s.db.QueryRow(ctx, query, eventID, name, zoneID). + Scan(&st.ID, &st.EventID, &st.Name, &st.ZoneID, &st.LastSeenAt, &st.CreatedAt) + if err != nil { + return nil, err + } + return &st, nil +} + +// HeartbeatCheckinStation refreshes a station's last_seen_at, scoped to +// eventID so a station id belonging to a different event can never be +// touched (the same tenant-isolation shape as +// UpdateCheckinSettings/IncrementAttendeePrintedCount's guards, just on a +// foreign-event axis instead of soft-delete). On 0 rows (unknown id, or an +// id that belongs to a different event) this returns +// ErrCheckinStationNotFound. +func (s *PGStore) HeartbeatCheckinStation(ctx context.Context, eventID, stationID uuid.UUID) error { + tag, err := s.db.Exec(ctx, + `UPDATE checkin_stations SET last_seen_at = now() WHERE id = $1 AND event_id = $2`, + stationID, eventID) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrCheckinStationNotFound + } + return nil +} + +// ListCheckinStations returns every station registered for eventID, +// ordered by name for a deterministic listing (stations have no natural +// display order otherwise). +func (s *PGStore) ListCheckinStations(ctx context.Context, eventID uuid.UUID) ([]*models.CheckinStation, error) { + rows, err := s.db.Query(ctx, + `SELECT id, event_id, name, zone_id, last_seen_at, created_at FROM checkin_stations WHERE event_id = $1 ORDER BY name`, + eventID) + if err != nil { + return nil, err + } + defer rows.Close() + + var stations []*models.CheckinStation + for rows.Next() { + var st models.CheckinStation + if err := rows.Scan(&st.ID, &st.EventID, &st.Name, &st.ZoneID, &st.LastSeenAt, &st.CreatedAt); err != nil { + return nil, err + } + stations = append(stations, &st) + } + if err := rows.Err(); err != nil { + return nil, err + } + return stations, nil +} + +// GetCheckinStationByID looks up a single check-in station by id (P4.1 +// Task 3). Mirrors GetEventZoneByID: a no-match surfaces the raw +// pgx.ErrNoRows rather than a normalized (nil, nil) — callers distinguish +// "unknown id" from "found" via errors.Is(err, pgx.ErrNoRows). +func (s *PGStore) GetCheckinStationByID(ctx context.Context, id uuid.UUID) (*models.CheckinStation, error) { + var st models.CheckinStation + query := `SELECT id, event_id, name, zone_id, last_seen_at, created_at FROM checkin_stations WHERE id = $1` + err := s.db.QueryRow(ctx, query, id).Scan(&st.ID, &st.EventID, &st.Name, &st.ZoneID, &st.LastSeenAt, &st.CreatedAt) + if err != nil { + return nil, err + } + return &st, nil +} + +// checkinAttendeeColumnsSQL is the plain (non-joined) attendee column list +// (in scan order) shared by CheckInAttendee's and UndoCheckin's guarded +// UPDATE ... RETURNING clauses — the same 19 columns as +// GetAttendeeByID/GetAttendeeByCode. It deliberately excludes +// checked_in_by_email: attendees has no such COLUMN — that field is always +// derived from users.email via checked_in_by (see attendeeListColumnsSQL), +// never persisted, so a RETURNING clause can't produce it. +const checkinAttendeeColumnsSQL = `id, event_id, first_name, last_name, email, company, position, code, checkin_status, checked_in_at, checked_in_by, checked_in_device_number, checked_in_point_name, printed_count, custom_fields, blocked, block_reason, created_at, updated_at` + +// scanCheckinAttendeeRow scans one row shaped by checkinAttendeeColumnsSQL +// into a fresh *models.Attendee, unmarshaling custom_fields. +func scanCheckinAttendeeRow(row pgx.Row) (*models.Attendee, error) { + var a models.Attendee + var customFieldsJSON []byte + if err := row.Scan(&a.ID, &a.EventID, &a.FirstName, &a.LastName, &a.Email, &a.Company, &a.Position, &a.Code, + &a.CheckinStatus, &a.CheckedInAt, &a.CheckedInBy, &a.CheckedInDeviceNumber, &a.CheckedInPointName, + &a.PrintedCount, &customFieldsJSON, &a.Blocked, &a.BlockReason, &a.CreatedAt, &a.UpdatedAt); err != nil { + return nil, err + } + if len(customFieldsJSON) > 0 && string(customFieldsJSON) != "null" { + if err := json.Unmarshal(customFieldsJSON, &a.CustomFields); err != nil { + return nil, err + } + } + return &a, nil +} + +// scanAttendeeByEmailJoinRow scans one row shaped by attendeeListColumnsSQL +// (the LEFT JOIN ... users u ON a.checked_in_by = u.id shape, including the +// joined checked_in_by_email) from a pgx.Row — unlike scanAttendeeRow, which +// takes pgx.Rows (the pgx.Rows.Scan and pgx.Row.Scan signatures match, but +// QueryRow's pgx.Row does not satisfy the pgx.Rows interface, so it can't be +// passed to scanAttendeeRow directly). +func scanAttendeeByEmailJoinRow(row pgx.Row) (*models.Attendee, error) { + var a models.Attendee + var customFieldsJSON []byte + if err := row.Scan(&a.ID, &a.EventID, &a.FirstName, &a.LastName, &a.Email, &a.Company, &a.Position, &a.Code, + &a.CheckinStatus, &a.CheckedInAt, &a.CheckedInBy, &a.CheckedInDeviceNumber, &a.CheckedInPointName, + &a.PrintedCount, &customFieldsJSON, &a.Blocked, &a.BlockReason, &a.CreatedAt, &a.UpdatedAt, &a.CheckedInByEmail); err != nil { + return nil, err + } + if len(customFieldsJSON) > 0 && string(customFieldsJSON) != "null" { + if err := json.Unmarshal(customFieldsJSON, &a.CustomFields); err != nil { + return nil, err + } + } + return &a, nil +} + +// checkinActionInsertSQL is the single INSERT shared by CheckInAttendee's +// 'checkin' row, UndoCheckin's 'undo' row, and the standalone +// InsertCheckinAction Store method's 'reprint' row (P4.1 Task 4 +// extraction) — action is a bind parameter so all three call sites run +// byte-for-byte the same statement. +const checkinActionInsertSQL = `INSERT INTO checkin_actions (event_id, attendee_id, station_id, action, staff_user_id) VALUES ($1, $2, $3, $4, $5)` + +// checkinActionExecutor is the minimal subset of dbConn/pgx.Tx needed to run +// checkinActionInsertSQL — satisfied by both *pgxpool.Pool (via PGStore.db, +// InsertCheckinAction's standalone path used by the /printed reprint +// endpoint, which is NOT inside any existing transaction) and pgx.Tx (the +// open transaction CheckInAttendee/UndoCheckin already hold), so the exact +// same insert runs either standalone or nested inside an existing +// transaction. pgx.Tx does not implement dbConn itself (it has no Close +// method), which is why this is its own narrower interface rather than +// reusing dbConn. +type checkinActionExecutor interface { + Exec(ctx context.Context, sql string, arguments ...any) (pgconn.CommandTag, error) +} + +// insertCheckinAction is the shared implementation behind +// PGStore.InsertCheckinAction, CheckInAttendee's 'checkin' row, and +// UndoCheckin's 'undo' row (P4.1 Tasks 3-4) — one INSERT statement, one +// place it's issued from. CheckInAttendee/UndoCheckin call this directly +// with their own open tx (never through the InsertCheckinAction Store +// method, which always runs against the pool) so the feed row commits +// atomically with the state-changing UPDATE in the SAME transaction. +func insertCheckinAction(ctx context.Context, exec checkinActionExecutor, eventID, attendeeID uuid.UUID, action string, stationID *uuid.UUID, staffUserID uuid.UUID) error { + _, err := exec.Exec(ctx, checkinActionInsertSQL, eventID, attendeeID, stationID, action, staffUserID) + return err +} + +// InsertCheckinAction records one checkin_actions feed row standalone, +// against the pool (P4.1 Task 4) — used by the /printed endpoint's reprint +// logging, which happens as its own store call AFTER +// IncrementAttendeePrintedCount's guarded UPDATE has already committed, not +// nested inside it (there is no shared transaction to join). Contract: the +// caller has already resolved staffUserID (e.g. from JWT claims) and +// validated stationID, when non-nil, belongs to the same event — this +// method does not re-validate either. +func (s *PGStore) InsertCheckinAction(ctx context.Context, eventID, attendeeID uuid.UUID, action string, stationID *uuid.UUID, staffUserID uuid.UUID) error { + return insertCheckinAction(ctx, s.db, eventID, attendeeID, action, stationID, staffUserID) +} + +// ErrCheckinConflict is returned by CheckInAttendee when a bounded retry +// (see checkInAttendeeMaxAttempts) still can't resolve the guarded UPDATE +// to a definitive outcome — PR #77 bot-review round 2, Finding 1. It marks +// an extremely narrow, transient race (not "already checked in", not +// "blocked", not "missing"); callers should treat it as retryable rather +// than as any of those three normal outcomes. +var ErrCheckinConflict = errors.New("check-in conflict, please retry") + +// checkInAttendeeMaxAttempts bounds CheckInAttendee's retry of its own +// guarded-UPDATE-then-fallback sequence to a single extra attempt (2 total) +// when the fallback SELECT lands on the "neither checked in nor blocked" +// race window (PR #77 bot-review round 2, Finding 1) — an unbounded/ +// infinite retry loop would be wrong, but the race window this closes is +// narrow enough that a single retry against the now-current state resolves +// the vast majority of real occurrences. +const checkInAttendeeMaxAttempts = 2 + +// checkInAttendeeGuardedUpdateSQL is CheckInAttendee's exact guarded UPDATE +// — see the Store interface doc for the full guard/outcome contract. +// blocked = false closes a TOCTOU race (PR #77 bot-review round 1, Finding +// A): StationCheckin's handler reads the attendee and returns the "blocked" +// outcome BEFORE calling here — but another operator's blocked/unblocked +// toggle can land in the window between that pre-read and this UPDATE +// actually running. Without this guard, the predicate would still match a +// NOW-blocked attendee (it only checked checkin_status/deleted_at) and +// check them in anyway, violating the endpoint's "blocked attendees are +// never checked in" contract. checked_in_device_number = NULL (PR #77 +// bot-review round 2, Finding 2) mirrors UndoCheckin's clear, so a fresh +// panel check-in never inherits a stale device number left over from an +// earlier mobile check-in. +const checkInAttendeeGuardedUpdateSQL = `UPDATE attendees + SET checkin_status = true, checked_in_at = now(), checked_in_by = $1, checked_in_device_number = NULL, checked_in_point_name = $2, updated_at = now() + WHERE id = $3 AND event_id = $4 AND checkin_status = false AND blocked = false AND deleted_at IS NULL + RETURNING ` + checkinAttendeeColumnsSQL + +// checkInAttendeeAttempt runs ONE guarded-UPDATE-then-fallback sequence +// inside tx and classifies the result into one of FOUR outcomes: "checked_in" +// (this attempt's own guarded UPDATE won), "blocked" (fallback SELECT found +// checkin_status = false, blocked = true — the TOCTOU race path), "conflict" +// (fallback SELECT found checkin_status = false, blocked = false — neither +// checked in nor blocked; PR #77 bot-review round 2, Finding 1, retried by +// the caller), or "already_checked_in" (fallback SELECT found checkin_status +// = true). A missing attendee returns ErrAttendeeNotFound directly (never +// retried — see CheckInAttendee below). Does not touch checked_in_by_email +// or insert any checkin_actions row; the caller (CheckInAttendee) owns both, +// since they only apply once, after a final "checked_in" outcome. +func checkInAttendeeAttempt(ctx context.Context, tx pgx.Tx, eventID, attendeeID uuid.UUID, pointName *string, staffUserID uuid.UUID) (string, *models.Attendee, error) { + a, err := scanCheckinAttendeeRow(tx.QueryRow(ctx, checkInAttendeeGuardedUpdateSQL, staffUserID, pointName, attendeeID, eventID)) + if err == nil { + return "checked_in", a, nil + } + if !errors.Is(err, pgx.ErrNoRows) { + return "", nil, err + } + + // 0 rows: the guarded UPDATE's predicate missed for one of FOUR + // reasons — already checked in (by this or another staff + // member/station), newly blocked (the TOCTOU race the blocked = false + // guard above closes), neither checked in nor blocked (a narrower, + // retryable race — Finding 1), or genuinely missing (soft-deleted, or + // doesn't belong to eventID). The fallback SELECT below (joined to + // users, same shape as attendeeListColumnsSQL/scanAttendeeRow) + // distinguishes all four: missing rows ErrAttendeeNotFound; a row with + // checkin_status = false AND blocked = true is the newly-blocked case + // (outcome "blocked" — this call never actually checked them in, so + // their pre-existing first-scan metadata, if any, is untouched); a row + // with checkin_status = false AND blocked = false is the conflict case + // (outcome "conflict" — genuinely not checked in, so reporting + // already_checked_in would be both factually wrong and would skip the + // only outcome that triggers printing); anything else (checkin_status = + // true) is already_checked_in, returning the ORIGINAL first-scan + // metadata untouched. + selectQuery := `SELECT` + attendeeListColumnsSQL + ` + FROM attendees a + LEFT JOIN users u ON a.checked_in_by = u.id + WHERE a.id = $1 AND a.event_id = $2 AND a.deleted_at IS NULL` + existing, err := scanAttendeeByEmailJoinRow(tx.QueryRow(ctx, selectQuery, attendeeID, eventID)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return "", nil, ErrAttendeeNotFound + } + return "", nil, err + } + if !existing.CheckinStatus && existing.Blocked { + return "blocked", existing, nil + } + if !existing.CheckinStatus && !existing.Blocked { + return "conflict", existing, nil + } + return "already_checked_in", existing, nil +} + +// CheckInAttendee performs one station's single-scan check-in idempotently +// (P4.1 Task 3) — see the Store interface doc for the full outcome +// contract. This is the zero-double-checkin guarantee at the source, +// mirroring ApplyBatchCheckin's guarded-UPDATE pattern (pg_store_batch.go) +// but with a RETURNING clause so the full row comes back in the same round +// trip as the write. +func (s *PGStore) CheckInAttendee(ctx context.Context, eventID, attendeeID uuid.UUID, stationID *uuid.UUID, staffUserID uuid.UUID, staffEmail, stationName string) (string, *models.Attendee, error) { + tx, err := s.db.Begin(ctx) + if err != nil { + return "", nil, err + } + defer func() { + if rbErr := tx.Rollback(ctx); rbErr != nil && !errors.Is(rbErr, pgx.ErrTxClosed) { + log.Printf("rollback check-in: %v", rbErr) + } + }() + + var pointName *string + if stationName != "" { + pointName = &stationName + } + + // Bounded retry (PR #77 bot-review round 2, Finding 1): the vast + // majority of calls resolve on the first attempt; only the "conflict" + // outcome (neither checked in nor blocked) loops back for one more + // attempt against the now-current state, all inside the SAME + // transaction. + var outcome string + var a *models.Attendee + for attempt := 0; attempt < checkInAttendeeMaxAttempts; attempt++ { + outcome, a, err = checkInAttendeeAttempt(ctx, tx, eventID, attendeeID, pointName, staffUserID) + if err != nil { + return "", nil, err + } + if outcome != "conflict" { + break + } + } + if outcome == "conflict" { + // The retry landed on the same unresolved state again — vanishingly + // unlikely, but must not recurse/loop forever. Roll back (via the + // deferred Rollback above) and surface a retryable sentinel rather + // than misreporting "already_checked_in". + return "", nil, ErrCheckinConflict + } + + if outcome == "checked_in" { + // This call's own guarded UPDATE won the race — it just wrote + // checked_in_by = staffUserID, so its email IS staffEmail (the + // caller resolved it, e.g. via GetUserByID, before calling here). + // There is no checked_in_by_email COLUMN to read it back from. + if staffEmail != "" { + a.CheckedInByEmail = &staffEmail + } + if err := insertCheckinAction(ctx, tx, eventID, attendeeID, "checkin", stationID, staffUserID); err != nil { + return "", nil, err + } + } + if err := tx.Commit(ctx); err != nil { + return "", nil, err + } + return outcome, a, nil +} + +// UndoCheckin clears a check-in idempotently (P4.1 Task 3) — see the Store +// interface doc for the full outcome contract. Fixes the legacy +// UpdateAttendeeHandler path's incomplete clear, which never touched +// checked_in_point_name. +func (s *PGStore) UndoCheckin(ctx context.Context, eventID, attendeeID uuid.UUID, stationID *uuid.UUID, staffUserID uuid.UUID) (*models.Attendee, error) { + tx, err := s.db.Begin(ctx) + if err != nil { + return nil, err + } + defer func() { + if rbErr := tx.Rollback(ctx); rbErr != nil && !errors.Is(rbErr, pgx.ErrTxClosed) { + log.Printf("rollback undo check-in: %v", rbErr) + } + }() + + // checked_in_device_number is cleared alongside the rest (PR #77 + // bot-review round, Finding B): an attendee checked in via the mobile + // batch path (ApplyBatchCheckin, pg_store_batch.go) carries a device + // number in this column — leaving it untouched here meant a panel undo + // of a mobile check-in left stale device metadata on an otherwise + // not-checked-in row. + updateQuery := `UPDATE attendees + SET checkin_status = false, checked_in_at = NULL, checked_in_by = NULL, checked_in_device_number = NULL, checked_in_point_name = NULL, updated_at = now() + WHERE id = $1 AND event_id = $2 AND checkin_status = true AND deleted_at IS NULL + RETURNING ` + checkinAttendeeColumnsSQL + a, err := scanCheckinAttendeeRow(tx.QueryRow(ctx, updateQuery, attendeeID, eventID)) + if err == nil { + if err := insertCheckinAction(ctx, tx, eventID, attendeeID, "undo", stationID, staffUserID); err != nil { + return nil, err + } + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return a, nil + } + if !errors.Is(err, pgx.ErrNoRows) { + return nil, err + } + + // 0 rows: either already not checked in (idempotent no-op — no feed + // row), or genuinely missing. + selectQuery := `SELECT ` + checkinAttendeeColumnsSQL + ` FROM attendees WHERE id = $1 AND event_id = $2 AND deleted_at IS NULL` + existing, err := scanCheckinAttendeeRow(tx.QueryRow(ctx, selectQuery, attendeeID, eventID)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrAttendeeNotFound + } + return nil, err + } + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return existing, nil +} + +// GetCheckinActions returns the newest `limit` rows of an event's +// check-in/undo/reprint feed (P4.1 Task 3), joined to a slim attendee +// projection — backs the station's recent-scans rail. +func (s *PGStore) GetCheckinActions(ctx context.Context, eventID uuid.UUID, limit int) ([]CheckinActionRow, error) { + // ca.id DESC is a deterministic tie-breaker (PR #77 bot-review round, + // Finding E): ORDER BY created_at DESC alone can arbitrarily reorder or + // omit rows across repeated calls with the same LIMIT whenever two + // concurrent actions share the same timestamp (down to whatever + // precision created_at stores) — id is a UUID with no inherent + // ordering relationship to created_at, but it only needs to be SOME + // deterministic total order, not a meaningful one, to make the "last + // 50" feed stable. idx_checkin_actions_event_created (migration + // 000019) is defined on (event_id, created_at DESC, id DESC) to match. + rows, err := s.db.Query(ctx, ` + SELECT ca.id, ca.action, ca.station_id, ca.created_at, a.id, a.first_name, a.last_name, a.code + FROM checkin_actions ca + JOIN attendees a ON ca.attendee_id = a.id + WHERE ca.event_id = $1 + ORDER BY ca.created_at DESC, ca.id DESC + LIMIT $2`, eventID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var actions []CheckinActionRow + for rows.Next() { + var row CheckinActionRow + if err := rows.Scan(&row.ID, &row.Action, &row.StationID, &row.CreatedAt, + &row.Attendee.ID, &row.Attendee.FirstName, &row.Attendee.LastName, &row.Attendee.Code); err != nil { + return nil, err + } + actions = append(actions, row) + } + if err := rows.Err(); err != nil { + return nil, err + } + return actions, nil +} + func (s *PGStore) CreateAttendee(ctx context.Context, attendee *models.Attendee) error { var customFieldsJSON []byte var err error diff --git a/backend/internal/store/pg_store_checkin_test.go b/backend/internal/store/pg_store_checkin_test.go new file mode 100644 index 00000000..db71ee88 --- /dev/null +++ b/backend/internal/store/pg_store_checkin_test.go @@ -0,0 +1,1216 @@ +package store + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + pgxmock "github.com/pashagolub/pgxmock/v4" +) + +// getCheckinSettingsSQL matches GetCheckinSettings' exact SELECT. +const getCheckinSettingsSQL = `SELECT checkin_settings FROM events WHERE id = \$1 AND deleted_at IS NULL` + +// updateCheckinSettingsSQL matches UpdateCheckinSettings' exact UPDATE, +// including the `deleted_at IS NULL` guard (UpdateEventBadgeTemplate / +// IncrementAttendeePrintedCount precedent — same race class): the caller's +// requireEventOwnership pre-check can pass and a concurrent soft-delete +// land before this UPDATE executes. +const updateCheckinSettingsSQL = `UPDATE events SET checkin_settings = \$1, updated_at = now\(\) WHERE id = \$2 AND deleted_at IS NULL` + +func TestGetCheckinSettingsReturnsStoredJSON(t *testing.T) { + mock, err := pgxmock.NewPool() + if err != nil { + t.Fatalf("pgxmock.NewPool: %v", err) + } + defer mock.Close() + + eventID := uuid.New() + stored := []byte(`{"print_on_checkin":true,"verdict_auto_dismiss_sec":5,"scan_input":"wedge","manual_search_enabled":false}`) + mock.ExpectQuery(getCheckinSettingsSQL). + WithArgs(eventID). + WillReturnRows(pgxmock.NewRows([]string{"checkin_settings"}).AddRow(stored)) + + s := &PGStore{db: mock} + got, err := s.GetCheckinSettings(context.Background(), eventID) + if err != nil { + t.Fatalf("GetCheckinSettings: %v", err) + } + if string(got) != string(stored) { + t.Errorf("got=%s, want=%s", got, stored) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet expectations: %v", err) + } +} + +// TestGetCheckinSettingsNullColumnReturnsNilNil covers the "no settings +// saved yet" case: the column exists but is NULL for this event. The scan +// destination receives a nil/empty byte slice; GetCheckinSettings must +// collapse that to (nil, nil) rather than fabricating an empty object. +func TestGetCheckinSettingsNullColumnReturnsNilNil(t *testing.T) { + mock, err := pgxmock.NewPool() + if err != nil { + t.Fatalf("pgxmock.NewPool: %v", err) + } + defer mock.Close() + + eventID := uuid.New() + mock.ExpectQuery(getCheckinSettingsSQL). + WithArgs(eventID). + WillReturnRows(pgxmock.NewRows([]string{"checkin_settings"}).AddRow([]byte(nil))) + + s := &PGStore{db: mock} + got, err := s.GetCheckinSettings(context.Background(), eventID) + if err != nil { + t.Fatalf("GetCheckinSettings: %v", err) + } + if got != nil { + t.Errorf("got=%s, want nil", got) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet expectations: %v", err) + } +} + +// TestGetCheckinSettingsNoRowsReturnsNilNil covers "no matching, +// non-deleted event" — the SELECT's deleted_at IS NULL guard misses (or +// the id doesn't exist at all), so QueryRow surfaces pgx.ErrNoRows. +// GetCheckinSettings must map that to (nil, nil), the same not-found idiom +// as GetEventBadgeTemplate, never surfacing the raw pgx error. +func TestGetCheckinSettingsNoRowsReturnsNilNil(t *testing.T) { + mock, err := pgxmock.NewPool() + if err != nil { + t.Fatalf("pgxmock.NewPool: %v", err) + } + defer mock.Close() + + eventID := uuid.New() + mock.ExpectQuery(getCheckinSettingsSQL). + WithArgs(eventID). + WillReturnRows(pgxmock.NewRows([]string{"checkin_settings"})) // no row + + s := &PGStore{db: mock} + got, err := s.GetCheckinSettings(context.Background(), eventID) + if err != nil { + t.Fatalf("GetCheckinSettings: %v", err) + } + if got != nil { + t.Errorf("got=%s, want nil", got) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet expectations: %v", err) + } +} + +// TestUpdateCheckinSettingsIssuesGuardedUpdate proves the exact SQL text +// (P2.1 lesson: pgxmock tests must assert real SQL, not a loose matcher) +// and that the raw settings bytes are passed through verbatim as $1. +func TestUpdateCheckinSettingsIssuesGuardedUpdate(t *testing.T) { + mock, err := pgxmock.NewPool() + if err != nil { + t.Fatalf("pgxmock.NewPool: %v", err) + } + defer mock.Close() + + eventID := uuid.New() + settings := []byte(`{"print_on_checkin":true,"verdict_auto_dismiss_sec":5,"scan_input":"wedge","manual_search_enabled":false}`) + mock.ExpectExec(updateCheckinSettingsSQL). + WithArgs(settings, eventID). + WillReturnResult(pgxmock.NewResult("UPDATE", 1)) + + s := &PGStore{db: mock} + if err := s.UpdateCheckinSettings(context.Background(), eventID, settings); err != nil { + t.Fatalf("UpdateCheckinSettings: %v", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet expectations: %v", err) + } +} + +// TestUpdateCheckinSettingsSoftDeleteRaceReturnsSentinel covers the 0-row +// case (the caller's requireEventOwnership pre-check passed, then a +// concurrent soft-delete landed before this UPDATE ran) — PR #77 +// bot-review round, Finding C. This used to be a silent no-op (Exec +// succeeds regardless of RowsAffected), which meant the handler responded +// 200 with settings that were never actually persisted. Now it mirrors the +// UpdateEventBadgeTemplate/IncrementAttendeePrintedCount 0-row sentinel +// pattern: 0 RowsAffected() maps to the exported ErrEventNotFound, which +// the handler (checkin_settings.go) maps to a 404 — the same soft-delete +// race class every other guarded UPDATE in this file already reports +// honestly, instead of a fabricated success. +func TestUpdateCheckinSettingsSoftDeleteRaceReturnsSentinel(t *testing.T) { + mock, err := pgxmock.NewPool() + if err != nil { + t.Fatalf("pgxmock.NewPool: %v", err) + } + defer mock.Close() + + eventID := uuid.New() // a real event's id — soft-deleted mid-request + settings := []byte(`{"print_on_checkin":false,"verdict_auto_dismiss_sec":10,"scan_input":"manual","manual_search_enabled":true}`) + mock.ExpectExec(updateCheckinSettingsSQL). + WithArgs(settings, eventID). + WillReturnResult(pgxmock.NewResult("UPDATE", 0)) + + s := &PGStore{db: mock} + err = s.UpdateCheckinSettings(context.Background(), eventID, settings) + if !errors.Is(err, ErrEventNotFound) { + t.Fatalf("err = %v, want ErrEventNotFound", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet expectations: %v", err) + } +} + +// upsertCheckinStationSQL matches UpsertCheckinStation's exact INSERT ... +// ON CONFLICT upsert (P4.1 Task 2). +const upsertCheckinStationSQL = `INSERT INTO checkin_stations \(event_id, name, zone_id\) VALUES \(\$1, \$2, \$3\) ON CONFLICT \(event_id, name\) DO UPDATE SET zone_id = EXCLUDED\.zone_id, last_seen_at = now\(\) RETURNING id, event_id, name, zone_id, last_seen_at, created_at` + +// heartbeatCheckinStationSQL matches HeartbeatCheckinStation's exact +// guarded UPDATE (no RETURNING — the caller only needs RowsAffected). +const heartbeatCheckinStationSQL = `UPDATE checkin_stations SET last_seen_at = now\(\) WHERE id = \$1 AND event_id = \$2` + +// listCheckinStationsSQL matches ListCheckinStations' exact SELECT. +const listCheckinStationsSQL = `SELECT id, event_id, name, zone_id, last_seen_at, created_at FROM checkin_stations WHERE event_id = \$1 ORDER BY name` + +// TestUpsertCheckinStationFreshNameInserts proves a fresh (event_id, name) +// upsert issues the exact ON CONFLICT SQL, passes zoneID through as $3, +// and returns the RETURNING row scanned into a CheckinStation. +func TestUpsertCheckinStationFreshNameInserts(t *testing.T) { + mock, err := pgxmock.NewPool() + if err != nil { + t.Fatalf("pgxmock.NewPool: %v", err) + } + defer mock.Close() + + eventID := uuid.New() + zoneID := uuid.New() + stationID := uuid.New() + now := time.Now() + mock.ExpectQuery(upsertCheckinStationSQL). + WithArgs(eventID, "Main Entrance", &zoneID). + WillReturnRows(pgxmock.NewRows([]string{"id", "event_id", "name", "zone_id", "last_seen_at", "created_at"}). + AddRow(stationID, eventID, "Main Entrance", &zoneID, now, now)) + + s := &PGStore{db: mock} + got, err := s.UpsertCheckinStation(context.Background(), eventID, "Main Entrance", &zoneID) + if err != nil { + t.Fatalf("UpsertCheckinStation: %v", err) + } + if got.ID != stationID { + t.Errorf("ID = %v, want %v", got.ID, stationID) + } + if got.ZoneID == nil || *got.ZoneID != zoneID { + t.Errorf("ZoneID = %v, want %v", got.ZoneID, zoneID) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet expectations: %v", err) + } +} + +// TestUpsertCheckinStationNilZoneIDInsertsNullZone proves a nil zoneID is +// passed through as a NULL (not, say, the zero-value UUID). +func TestUpsertCheckinStationNilZoneIDInsertsNullZone(t *testing.T) { + mock, err := pgxmock.NewPool() + if err != nil { + t.Fatalf("pgxmock.NewPool: %v", err) + } + defer mock.Close() + + eventID := uuid.New() + stationID := uuid.New() + now := time.Now() + mock.ExpectQuery(upsertCheckinStationSQL). + WithArgs(eventID, "Side Door", (*uuid.UUID)(nil)). + WillReturnRows(pgxmock.NewRows([]string{"id", "event_id", "name", "zone_id", "last_seen_at", "created_at"}). + AddRow(stationID, eventID, "Side Door", nil, now, now)) + + s := &PGStore{db: mock} + got, err := s.UpsertCheckinStation(context.Background(), eventID, "Side Door", nil) + if err != nil { + t.Fatalf("UpsertCheckinStation: %v", err) + } + if got.ZoneID != nil { + t.Errorf("ZoneID = %v, want nil", got.ZoneID) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet expectations: %v", err) + } +} + +// TestUpsertCheckinStationRepeatNameSameIDZoneUpdated is the upsert proof: +// two successive calls with the SAME (event_id, name) but a DIFFERENT +// zone_id both hit the exact same ON CONFLICT SQL, and (per the mocked +// RETURNING rows, mirroring what the real constraint guarantees) the +// SAME station id comes back both times with zone_id replaced — never a +// second row. +func TestUpsertCheckinStationRepeatNameSameIDZoneUpdated(t *testing.T) { + mock, err := pgxmock.NewPool() + if err != nil { + t.Fatalf("pgxmock.NewPool: %v", err) + } + defer mock.Close() + + eventID := uuid.New() + stationID := uuid.New() + zoneA := uuid.New() + zoneB := uuid.New() + now := time.Now() + + mock.ExpectQuery(upsertCheckinStationSQL). + WithArgs(eventID, "Main Entrance", &zoneA). + WillReturnRows(pgxmock.NewRows([]string{"id", "event_id", "name", "zone_id", "last_seen_at", "created_at"}). + AddRow(stationID, eventID, "Main Entrance", &zoneA, now, now)) + mock.ExpectQuery(upsertCheckinStationSQL). + WithArgs(eventID, "Main Entrance", &zoneB). + WillReturnRows(pgxmock.NewRows([]string{"id", "event_id", "name", "zone_id", "last_seen_at", "created_at"}). + AddRow(stationID, eventID, "Main Entrance", &zoneB, now, now)) + + s := &PGStore{db: mock} + first, err := s.UpsertCheckinStation(context.Background(), eventID, "Main Entrance", &zoneA) + if err != nil { + t.Fatalf("first UpsertCheckinStation: %v", err) + } + second, err := s.UpsertCheckinStation(context.Background(), eventID, "Main Entrance", &zoneB) + if err != nil { + t.Fatalf("second UpsertCheckinStation: %v", err) + } + if first.ID != second.ID { + t.Fatalf("ids differ across upserts: first=%v second=%v (want same id)", first.ID, second.ID) + } + if second.ZoneID == nil || *second.ZoneID != zoneB { + t.Fatalf("second.ZoneID = %v, want %v (zone updated)", second.ZoneID, zoneB) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet expectations: %v", err) + } +} + +// TestHeartbeatCheckinStationKnownRefreshesLastSeen proves the exact +// guarded UPDATE SQL and that a 1-row affect returns nil. +func TestHeartbeatCheckinStationKnownRefreshesLastSeen(t *testing.T) { + mock, err := pgxmock.NewPool() + if err != nil { + t.Fatalf("pgxmock.NewPool: %v", err) + } + defer mock.Close() + + eventID := uuid.New() + stationID := uuid.New() + mock.ExpectExec(heartbeatCheckinStationSQL). + WithArgs(stationID, eventID). + WillReturnResult(pgxmock.NewResult("UPDATE", 1)) + + s := &PGStore{db: mock} + if err := s.HeartbeatCheckinStation(context.Background(), eventID, stationID); err != nil { + t.Fatalf("HeartbeatCheckinStation: %v", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet expectations: %v", err) + } +} + +// TestHeartbeatCheckinStationUnknownOrForeignReturnsSentinel covers the +// 0-row case (unknown station id, or an id belonging to a different +// event) — the store must map it to ErrCheckinStationNotFound, never a +// silent success. +func TestHeartbeatCheckinStationUnknownOrForeignReturnsSentinel(t *testing.T) { + mock, err := pgxmock.NewPool() + if err != nil { + t.Fatalf("pgxmock.NewPool: %v", err) + } + defer mock.Close() + + eventID := uuid.New() + stationID := uuid.New() + mock.ExpectExec(heartbeatCheckinStationSQL). + WithArgs(stationID, eventID). + WillReturnResult(pgxmock.NewResult("UPDATE", 0)) + + s := &PGStore{db: mock} + err = s.HeartbeatCheckinStation(context.Background(), eventID, stationID) + if !errors.Is(err, ErrCheckinStationNotFound) { + t.Fatalf("err = %v, want ErrCheckinStationNotFound", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet expectations: %v", err) + } +} + +// TestListCheckinStationsReturnsRegistered proves the exact SELECT and +// that every registered row (including one with a NULL zone_id) is +// scanned back. +func TestListCheckinStationsReturnsRegistered(t *testing.T) { + mock, err := pgxmock.NewPool() + if err != nil { + t.Fatalf("pgxmock.NewPool: %v", err) + } + defer mock.Close() + + eventID := uuid.New() + zoneID := uuid.New() + now := time.Now() + mock.ExpectQuery(listCheckinStationsSQL). + WithArgs(eventID). + WillReturnRows(pgxmock.NewRows([]string{"id", "event_id", "name", "zone_id", "last_seen_at", "created_at"}). + AddRow(uuid.New(), eventID, "Main Entrance", &zoneID, now, now). + AddRow(uuid.New(), eventID, "Side Door", nil, now, now)) + + s := &PGStore{db: mock} + got, err := s.ListCheckinStations(context.Background(), eventID) + if err != nil { + t.Fatalf("ListCheckinStations: %v", err) + } + if len(got) != 2 { + t.Fatalf("len(got) = %d, want 2", len(got)) + } + if got[0].ZoneID == nil || *got[0].ZoneID != zoneID { + t.Errorf("got[0].ZoneID = %v, want %v", got[0].ZoneID, zoneID) + } + if got[1].ZoneID != nil { + t.Errorf("got[1].ZoneID = %v, want nil", got[1].ZoneID) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet expectations: %v", err) + } +} + +// TestListCheckinStationsNoneRegisteredReturnsEmpty proves an event with +// no stations yet gets a nil/empty slice, not an error. +// getCheckinStationByIDSQL matches GetCheckinStationByID's exact SELECT +// (P4.1 Task 3). +const getCheckinStationByIDSQL = `SELECT id, event_id, name, zone_id, last_seen_at, created_at FROM checkin_stations WHERE id = \$1` + +func TestGetCheckinStationByIDFound(t *testing.T) { + mock, err := pgxmock.NewPool() + if err != nil { + t.Fatalf("pgxmock.NewPool: %v", err) + } + defer mock.Close() + + stationID := uuid.New() + eventID := uuid.New() + now := time.Now() + mock.ExpectQuery(getCheckinStationByIDSQL). + WithArgs(stationID). + WillReturnRows(pgxmock.NewRows([]string{"id", "event_id", "name", "zone_id", "last_seen_at", "created_at"}). + AddRow(stationID, eventID, "Main Entrance", nil, now, now)) + + s := &PGStore{db: mock} + got, err := s.GetCheckinStationByID(context.Background(), stationID) + if err != nil { + t.Fatalf("GetCheckinStationByID: %v", err) + } + if got.ID != stationID || got.EventID != eventID { + t.Errorf("got=%+v, want id=%s event_id=%s", got, stationID, eventID) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet expectations: %v", err) + } +} + +// TestGetCheckinStationByIDUnknownSurfacesRawErrNoRows proves an unknown id +// is NOT normalized to (nil, nil) — unlike GetCheckinSettings, this mirrors +// GetEventZoneByID's contract, which handlers rely on (see +// RegisterCheckinStation's "unknown zone" 400 branch) to distinguish +// "doesn't exist" from "found nil". +func TestGetCheckinStationByIDUnknownSurfacesRawErrNoRows(t *testing.T) { + mock, err := pgxmock.NewPool() + if err != nil { + t.Fatalf("pgxmock.NewPool: %v", err) + } + defer mock.Close() + + stationID := uuid.New() + mock.ExpectQuery(getCheckinStationByIDSQL). + WithArgs(stationID). + WillReturnRows(pgxmock.NewRows([]string{"id", "event_id", "name", "zone_id", "last_seen_at", "created_at"})) + + s := &PGStore{db: mock} + _, err = s.GetCheckinStationByID(context.Background(), stationID) + if !errors.Is(err, pgx.ErrNoRows) { + t.Fatalf("err = %v, want pgx.ErrNoRows", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet expectations: %v", err) + } +} + +// checkinAttendeeReturningColumns is checkinAttendeeColumnsSQL's names, in +// scan order (no checked_in_by_email — see pg_store.go's doc on why). +var checkinAttendeeReturningColumns = []string{ + "id", "event_id", "first_name", "last_name", "email", "company", "position", "code", + "checkin_status", "checked_in_at", "checked_in_by", "checked_in_device_number", "checked_in_point_name", + "printed_count", "custom_fields", "blocked", "block_reason", "created_at", "updated_at", +} + +// checkInAttendeeUpdateSQL matches CheckInAttendee's exact guarded UPDATE — +// including the `checkin_status = false` guard that makes this the +// zero-double-checkin write, the `blocked = false` guard (PR #77 bot-review +// round 1, Finding A) that closes the TOCTOU race where another operator +// blocks the SAME attendee between StationCheckin's pre-read and this +// UPDATE actually running — without this guard the UPDATE's predicate would +// still match a newly-blocked attendee and check them in anyway — AND the +// `checked_in_device_number = NULL` clear (PR #77 bot-review round 2, +// Finding 2) that mirrors UndoCheckin's clear, so a fresh panel check-in +// never inherits a stale device number left over from an earlier mobile +// check-in (P2.1 lesson: assert real SQL text, not a loose matcher). +const checkInAttendeeUpdateSQL = `UPDATE attendees\s+SET checkin_status = true, checked_in_at = now\(\), checked_in_by = \$1, checked_in_device_number = NULL, checked_in_point_name = \$2, updated_at = now\(\)\s+WHERE id = \$3 AND event_id = \$4 AND checkin_status = false AND blocked = false AND deleted_at IS NULL\s+RETURNING id, event_id, first_name, last_name, email, company, position, code, checkin_status, checked_in_at, checked_in_by, checked_in_device_number, checked_in_point_name, printed_count, custom_fields, blocked, block_reason, created_at, updated_at` + +// checkInAttendeeFallbackSelectSQL matches the 0-row fallback SELECT — the +// same LEFT JOIN ... users shape as attendeeListColumnsSQL/scanAttendeeRow, +// scoped to one attendee id within eventID. +const checkInAttendeeFallbackSelectSQL = `SELECT\s+a\.id, a\.event_id, a\.first_name, a\.last_name, a\.email, a\.company, a\.position, a\.code,\s+a\.checkin_status, a\.checked_in_at, a\.checked_in_by, a\.checked_in_device_number, a\.checked_in_point_name, a\.printed_count, a\.custom_fields,\s+a\.blocked, a\.block_reason, a\.created_at, a\.updated_at,\s+u\.email as checked_in_by_email\s+FROM attendees a\s+LEFT JOIN users u ON a\.checked_in_by = u\.id\s+WHERE a\.id = \$1 AND a\.event_id = \$2 AND a\.deleted_at IS NULL` + +// checkinActionsInsertSQL matches the feed row INSERT shared by +// CheckInAttendee ('checkin'), UndoCheckin ('undo'), and the standalone +// InsertCheckinAction Store method (P4.1 Task 4 extraction) — action is now +// a bind parameter ($4) rather than baked into the SQL text per call site, +// so all three call sites run the exact same statement. +const checkinActionsInsertSQL = `INSERT INTO checkin_actions \(event_id, attendee_id, station_id, action, staff_user_id\) VALUES \(\$1, \$2, \$3, \$4, \$5\)` + +// checkinActionsInsertCheckinSQL is retained as an alias so the "checked_in" +// tests below read the same as before the P4.1 Task 4 extraction. +const checkinActionsInsertCheckinSQL = checkinActionsInsertSQL + +// TestCheckInAttendeeFreshAttendeeChecksInAndLogsFeedRow proves the exact +// guarded-UPDATE SQL, that a 1-row RETURNING result yields outcome +// "checked_in", that the returned attendee carries THIS call's +// staffEmail/stationName (checked_in_by_email has no column to read back +// from), and that a checkin_actions ('checkin') row is inserted in the +// SAME transaction before commit. +func TestCheckInAttendeeFreshAttendeeChecksInAndLogsFeedRow(t *testing.T) { + mock, err := pgxmock.NewPool() + if err != nil { + t.Fatalf("pgxmock.NewPool: %v", err) + } + defer mock.Close() + + eventID, attendeeID, stationID, staffID := uuid.New(), uuid.New(), uuid.New(), uuid.New() + now := time.Now() + stationName := "Main Entrance" + + mock.ExpectBegin() + mock.ExpectQuery(checkInAttendeeUpdateSQL). + WithArgs(staffID, &stationName, attendeeID, eventID). + WillReturnRows(pgxmock.NewRows(checkinAttendeeReturningColumns). + AddRow(attendeeID, eventID, "Ada", "Lovelace", "ada@example.com", "Acme", "Eng", "CODE1", + true, &now, &staffID, nil, &stationName, 0, nil, false, nil, now, now)) + mock.ExpectExec(checkinActionsInsertCheckinSQL). + WithArgs(eventID, attendeeID, &stationID, "checkin", staffID). + WillReturnResult(pgxmock.NewResult("INSERT", 1)) + mock.ExpectCommit() + + s := &PGStore{db: mock} + outcome, a, err := s.CheckInAttendee(context.Background(), eventID, attendeeID, &stationID, staffID, "ada.staff@example.com", "Main Entrance") + if err != nil { + t.Fatalf("CheckInAttendee: %v", err) + } + if outcome != "checked_in" { + t.Errorf("outcome = %q, want checked_in", outcome) + } + if a.CheckedInByEmail == nil || *a.CheckedInByEmail != "ada.staff@example.com" { + t.Errorf("CheckedInByEmail = %v, want ada.staff@example.com", a.CheckedInByEmail) + } + if a.CheckedInPointName == nil || *a.CheckedInPointName != "Main Entrance" { + t.Errorf("CheckedInPointName = %v, want Main Entrance", a.CheckedInPointName) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet expectations: %v", err) + } +} + +// TestCheckInAttendeeNoStationLeavesPointNameNull proves an empty +// stationName is passed through as NULL (not an empty string) — a +// station-less check-in (no station_id in the request). +func TestCheckInAttendeeNoStationLeavesPointNameNull(t *testing.T) { + mock, err := pgxmock.NewPool() + if err != nil { + t.Fatalf("pgxmock.NewPool: %v", err) + } + defer mock.Close() + + eventID, attendeeID, staffID := uuid.New(), uuid.New(), uuid.New() + now := time.Now() + + mock.ExpectBegin() + mock.ExpectQuery(checkInAttendeeUpdateSQL). + WithArgs(staffID, (*string)(nil), attendeeID, eventID). + WillReturnRows(pgxmock.NewRows(checkinAttendeeReturningColumns). + AddRow(attendeeID, eventID, "Ada", "Lovelace", "ada@example.com", "Acme", "Eng", "CODE1", + true, &now, &staffID, nil, nil, 0, nil, false, nil, now, now)) + mock.ExpectExec(checkinActionsInsertCheckinSQL). + WithArgs(eventID, attendeeID, (*uuid.UUID)(nil), "checkin", staffID). + WillReturnResult(pgxmock.NewResult("INSERT", 1)) + mock.ExpectCommit() + + s := &PGStore{db: mock} + outcome, a, err := s.CheckInAttendee(context.Background(), eventID, attendeeID, nil, staffID, "ada.staff@example.com", "") + if err != nil { + t.Fatalf("CheckInAttendee: %v", err) + } + if outcome != "checked_in" { + t.Errorf("outcome = %q, want checked_in", outcome) + } + if a.CheckedInPointName != nil { + t.Errorf("CheckedInPointName = %v, want nil", a.CheckedInPointName) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet expectations: %v", err) + } +} + +// TestCheckInAttendeeClearsStaleDeviceNumber is the round-trip proof for PR +// #77 bot-review round 2, Finding 2: an attendee arriving at the guarded +// UPDATE with a pre-existing non-null checked_in_device_number (left over +// from an earlier mobile check-in, or an unbind/rebind cycle via the legacy +// PUT /api/attendees/{id} path) must have that column cleared to NULL by a +// FRESH panel check-in, exactly as UndoCheckin already clears it on undo. +// CheckInAttendee takes no device-number argument at all, so — mirroring +// TestUndoCheckinClearsDeviceNumber's reasoning — the only way the returned +// row's CheckedInDeviceNumber ends up nil is the UPDATE's own +// `checked_in_device_number = NULL` clearing it (the mocked RETURNING row +// stands in for what a real Postgres UPDATE would hand back). +func TestCheckInAttendeeClearsStaleDeviceNumber(t *testing.T) { + mock, err := pgxmock.NewPool() + if err != nil { + t.Fatalf("pgxmock.NewPool: %v", err) + } + defer mock.Close() + + eventID, attendeeID, staffID := uuid.New(), uuid.New(), uuid.New() + now := time.Now() + + mock.ExpectBegin() + mock.ExpectQuery(checkInAttendeeUpdateSQL). + WithArgs(staffID, (*string)(nil), attendeeID, eventID). + WillReturnRows(pgxmock.NewRows(checkinAttendeeReturningColumns). + AddRow(attendeeID, eventID, "Ada", "Lovelace", "ada@example.com", "Acme", "Eng", "CODE1", + true, &now, &staffID, nil, nil, 0, nil, false, nil, now, now)) + mock.ExpectExec(checkinActionsInsertCheckinSQL). + WithArgs(eventID, attendeeID, (*uuid.UUID)(nil), "checkin", staffID). + WillReturnResult(pgxmock.NewResult("INSERT", 1)) + mock.ExpectCommit() + + s := &PGStore{db: mock} + outcome, a, err := s.CheckInAttendee(context.Background(), eventID, attendeeID, nil, staffID, "ada.staff@example.com", "") + if err != nil { + t.Fatalf("CheckInAttendee: %v", err) + } + if outcome != "checked_in" { + t.Errorf("outcome = %q, want checked_in", outcome) + } + if a.CheckedInDeviceNumber != nil { + t.Errorf("CheckedInDeviceNumber = %v, want nil (a fresh panel check-in must clear stale mobile device metadata)", *a.CheckedInDeviceNumber) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet expectations: %v", err) + } +} + +// TestCheckInAttendeeAlreadyCheckedInFallsBackNoFeedRow covers the 0-row +// path: the guarded UPDATE affects nothing (already checked in), so +// CheckInAttendee falls back to the joined SELECT and returns the +// EXISTING first-scan metadata — no checkin_actions row is inserted (no +// ExpectExec is set; an unexpected call would fail ExpectationsWereMet). +func TestCheckInAttendeeAlreadyCheckedInFallsBackNoFeedRow(t *testing.T) { + mock, err := pgxmock.NewPool() + if err != nil { + t.Fatalf("pgxmock.NewPool: %v", err) + } + defer mock.Close() + + eventID, attendeeID, staffID := uuid.New(), uuid.New(), uuid.New() + originalStaff := uuid.New() + firstScan := time.Now().Add(-time.Hour) + requestedStationName := "Side Door" + originalPointName := "Main Entrance" + originalEmail := "original.staff@example.com" + + mock.ExpectBegin() + mock.ExpectQuery(checkInAttendeeUpdateSQL). + WithArgs(staffID, &requestedStationName, attendeeID, eventID). + WillReturnRows(pgxmock.NewRows(checkinAttendeeReturningColumns)) // 0 rows + mock.ExpectQuery(checkInAttendeeFallbackSelectSQL). + WithArgs(attendeeID, eventID). + WillReturnRows(pgxmock.NewRows(attendeesByEventColumns). + AddRow(attendeeID, eventID, "Ada", "Lovelace", "ada@example.com", "Acme", "Eng", "CODE1", + true, &firstScan, &originalStaff, nil, &originalPointName, 0, nil, false, nil, firstScan, firstScan, + &originalEmail)) + mock.ExpectCommit() + + s := &PGStore{db: mock} + outcome, a, err := s.CheckInAttendee(context.Background(), eventID, attendeeID, nil, staffID, "second.staff@example.com", "Side Door") + if err != nil { + t.Fatalf("CheckInAttendee: %v", err) + } + if outcome != "already_checked_in" { + t.Errorf("outcome = %q, want already_checked_in", outcome) + } + if a.CheckedInByEmail == nil || *a.CheckedInByEmail != "original.staff@example.com" { + t.Errorf("CheckedInByEmail = %v, want the ORIGINAL scanner's email (original.staff@example.com), never overwritten", a.CheckedInByEmail) + } + if a.CheckedInPointName == nil || *a.CheckedInPointName != "Main Entrance" { + t.Errorf("CheckedInPointName = %v, want the ORIGINAL Main Entrance, never overwritten by Side Door", a.CheckedInPointName) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet expectations: %v", err) + } +} + +// TestCheckInAttendeeNewlyBlockedReturnsBlockedOutcome covers the TOCTOU +// race (PR #77 bot-review round, Finding A): StationCheckin's handler +// pre-read saw attendee.Blocked == false and proceeded to call +// CheckInAttendee, but another operator's blocked/unblocked toggle landed +// in the window before the guarded UPDATE ran — so the UPDATE's now +// `blocked = false` predicate matches 0 rows even though checkin_status is +// still false (the attendee was never actually checked in). The fallback +// SELECT finds exactly that shape (checkin_status = false, blocked = true) +// and this is the second path to outcome "blocked", beyond the handler's +// own pre-read short-circuit — never "already_checked_in", and never a feed +// row (no ExpectExec is set for the checkin_actions INSERT). +func TestCheckInAttendeeNewlyBlockedReturnsBlockedOutcome(t *testing.T) { + mock, err := pgxmock.NewPool() + if err != nil { + t.Fatalf("pgxmock.NewPool: %v", err) + } + defer mock.Close() + + eventID, attendeeID, staffID := uuid.New(), uuid.New(), uuid.New() + now := time.Now() + blockReason := "Ticket refunded" + + mock.ExpectBegin() + mock.ExpectQuery(checkInAttendeeUpdateSQL). + WithArgs(staffID, (*string)(nil), attendeeID, eventID). + WillReturnRows(pgxmock.NewRows(checkinAttendeeReturningColumns)) // 0 rows: blocked = false guard missed + mock.ExpectQuery(checkInAttendeeFallbackSelectSQL). + WithArgs(attendeeID, eventID). + WillReturnRows(pgxmock.NewRows(attendeesByEventColumns). + AddRow(attendeeID, eventID, "Ada", "Lovelace", "ada@example.com", "Acme", "Eng", "CODE1", + false, nil, nil, nil, nil, 0, nil, true, &blockReason, now, now, + nil)) + mock.ExpectCommit() + + s := &PGStore{db: mock} + outcome, a, err := s.CheckInAttendee(context.Background(), eventID, attendeeID, nil, staffID, "staff@example.com", "") + if err != nil { + t.Fatalf("CheckInAttendee: %v", err) + } + if outcome != "blocked" { + t.Errorf("outcome = %q, want blocked", outcome) + } + if a == nil || !a.Blocked { + t.Fatalf("a.Blocked = %v, want true", a) + } + if a.BlockReason == nil || *a.BlockReason != blockReason { + t.Errorf("a.BlockReason = %v, want %q", a.BlockReason, blockReason) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet expectations: %v", err) + } +} + +// TestCheckInAttendeeConflictStateRetriesAndSucceeds covers PR #77 +// bot-review round 2, Finding 1: the guarded UPDATE misses (0 rows), and the +// fallback SELECT lands on a shape that is NEITHER "already checked in" NOR +// "blocked" — checkin_status = false AND blocked = false. This is reachable +// via a genuine narrow race (e.g. the UPDATE lost to a different concurrent +// attempt that itself then got undone, or a block/unblock cycle landed in +// the window between the UPDATE and this fallback SELECT) — the attendee is +// demonstrably NOT checked in, so reporting "already_checked_in" here would +// be factually false and would mean the attendee walks through the door +// with no badge (printing is gated on the "checked_in" outcome only). +// CheckInAttendee must retry the entire guarded-UPDATE-then-fallback +// sequence exactly once more (asserted here via TWO checkInAttendeeUpdateSQL +// expectations from pgxmock's ordered call tracking), and when the retry's +// guarded UPDATE succeeds, return "checked_in" normally with a +// checkin_actions row logged — never "already_checked_in", never an +// infinite retry loop. +func TestCheckInAttendeeConflictStateRetriesAndSucceeds(t *testing.T) { + mock, err := pgxmock.NewPool() + if err != nil { + t.Fatalf("pgxmock.NewPool: %v", err) + } + defer mock.Close() + + eventID, attendeeID, staffID := uuid.New(), uuid.New(), uuid.New() + now := time.Now() + + mock.ExpectBegin() + // Attempt 1: guarded UPDATE misses. + mock.ExpectQuery(checkInAttendeeUpdateSQL). + WithArgs(staffID, (*string)(nil), attendeeID, eventID). + WillReturnRows(pgxmock.NewRows(checkinAttendeeReturningColumns)) // 0 rows + // Attempt 1's fallback SELECT: neither checked in nor blocked. + mock.ExpectQuery(checkInAttendeeFallbackSelectSQL). + WithArgs(attendeeID, eventID). + WillReturnRows(addAttendeeRow(pgxmock.NewRows(attendeesByEventColumns), attendeeID, eventID, "Ada", "Lovelace", "ada@example.com", "CODE1", now)) + // Attempt 2 (the single retry): guarded UPDATE now succeeds against the + // now-current state. + mock.ExpectQuery(checkInAttendeeUpdateSQL). + WithArgs(staffID, (*string)(nil), attendeeID, eventID). + WillReturnRows(pgxmock.NewRows(checkinAttendeeReturningColumns). + AddRow(attendeeID, eventID, "Ada", "Lovelace", "ada@example.com", "Acme", "Eng", "CODE1", + true, &now, &staffID, nil, nil, 0, nil, false, nil, now, now)) + mock.ExpectExec(checkinActionsInsertCheckinSQL). + WithArgs(eventID, attendeeID, (*uuid.UUID)(nil), "checkin", staffID). + WillReturnResult(pgxmock.NewResult("INSERT", 1)) + mock.ExpectCommit() + + s := &PGStore{db: mock} + outcome, a, err := s.CheckInAttendee(context.Background(), eventID, attendeeID, nil, staffID, "ada.staff@example.com", "") + if err != nil { + t.Fatalf("CheckInAttendee: %v", err) + } + if outcome != "checked_in" { + t.Errorf("outcome = %q, want checked_in (never already_checked_in for a non-checked-in, non-blocked attendee)", outcome) + } + if a.CheckedInByEmail == nil || *a.CheckedInByEmail != "ada.staff@example.com" { + t.Errorf("CheckedInByEmail = %v, want ada.staff@example.com", a.CheckedInByEmail) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet expectations: %v", err) + } +} + +// TestCheckInAttendeeConflictStateExhaustsRetryReturnsErrCheckinConflict +// covers the vanishingly-unlikely-but-must-be-handled case where the SINGLE +// retry (PR #77 bot-review round 2, Finding 1) lands on the exact same +// "neither checked in nor blocked" shape again. This must NOT recurse or +// loop forever, and must NOT be misreported as "already_checked_in" — the +// store surfaces the exported ErrCheckinConflict sentinel (bounded: exactly +// 2 total guarded-UPDATE attempts, asserted via pgxmock's ordered +// expectations) for the handler to map to a retryable response, and the +// transaction rolls back (no ExpectCommit is set). +func TestCheckInAttendeeConflictStateExhaustsRetryReturnsErrCheckinConflict(t *testing.T) { + mock, err := pgxmock.NewPool() + if err != nil { + t.Fatalf("pgxmock.NewPool: %v", err) + } + defer mock.Close() + + eventID, attendeeID, staffID := uuid.New(), uuid.New(), uuid.New() + now := time.Now() + + mock.ExpectBegin() + // Attempt 1: guarded UPDATE misses, fallback lands on the conflict shape. + mock.ExpectQuery(checkInAttendeeUpdateSQL). + WithArgs(staffID, (*string)(nil), attendeeID, eventID). + WillReturnRows(pgxmock.NewRows(checkinAttendeeReturningColumns)) + mock.ExpectQuery(checkInAttendeeFallbackSelectSQL). + WithArgs(attendeeID, eventID). + WillReturnRows(addAttendeeRow(pgxmock.NewRows(attendeesByEventColumns), attendeeID, eventID, "Ada", "Lovelace", "ada@example.com", "CODE1", now)) + // Attempt 2 (the single retry): STILL misses, STILL lands on the same + // conflict shape. + mock.ExpectQuery(checkInAttendeeUpdateSQL). + WithArgs(staffID, (*string)(nil), attendeeID, eventID). + WillReturnRows(pgxmock.NewRows(checkinAttendeeReturningColumns)) + mock.ExpectQuery(checkInAttendeeFallbackSelectSQL). + WithArgs(attendeeID, eventID). + WillReturnRows(addAttendeeRow(pgxmock.NewRows(attendeesByEventColumns), attendeeID, eventID, "Ada", "Lovelace", "ada@example.com", "CODE1", now)) + mock.ExpectRollback() + + s := &PGStore{db: mock} + outcome, a, err := s.CheckInAttendee(context.Background(), eventID, attendeeID, nil, staffID, "ada.staff@example.com", "") + if !errors.Is(err, ErrCheckinConflict) { + t.Fatalf("err = %v, want ErrCheckinConflict", err) + } + if outcome != "" || a != nil { + t.Errorf("outcome/attendee = %q/%v, want empty/nil on ErrCheckinConflict", outcome, a) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet expectations: %v", err) + } +} + +// TestCheckInAttendeeMissingReturnsErrAttendeeNotFound covers the +// soft-delete-race / foreign-event 0-row-on-both-queries case: the guarded +// UPDATE and the fallback SELECT both match nothing, so the transaction +// rolls back (never commits) and the store surfaces ErrAttendeeNotFound. +func TestCheckInAttendeeMissingReturnsErrAttendeeNotFound(t *testing.T) { + mock, err := pgxmock.NewPool() + if err != nil { + t.Fatalf("pgxmock.NewPool: %v", err) + } + defer mock.Close() + + eventID, attendeeID, staffID := uuid.New(), uuid.New(), uuid.New() + + mock.ExpectBegin() + mock.ExpectQuery(checkInAttendeeUpdateSQL). + WithArgs(staffID, (*string)(nil), attendeeID, eventID). + WillReturnRows(pgxmock.NewRows(checkinAttendeeReturningColumns)) + mock.ExpectQuery(checkInAttendeeFallbackSelectSQL). + WithArgs(attendeeID, eventID). + WillReturnRows(pgxmock.NewRows(attendeesByEventColumns)) + mock.ExpectRollback() + + s := &PGStore{db: mock} + _, _, err = s.CheckInAttendee(context.Background(), eventID, attendeeID, nil, staffID, "staff@example.com", "") + if !errors.Is(err, ErrAttendeeNotFound) { + t.Fatalf("err = %v, want ErrAttendeeNotFound", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet expectations: %v", err) + } +} + +// undoCheckinUpdateSQL matches UndoCheckin's exact guarded UPDATE — guarded +// on checkin_status = true (the mirror-image guard of CheckInAttendee's +// checkin_status = false), and clearing checked_in_device_number alongside +// the other check-in metadata (PR #77 bot-review round, Finding B): an +// attendee checked in via the mobile batch path carries a +// checked_in_device_number that UndoCheckin used to leave stale — this +// column must be nulled out in the SAME UPDATE as the rest. +const undoCheckinUpdateSQL = `UPDATE attendees\s+SET checkin_status = false, checked_in_at = NULL, checked_in_by = NULL, checked_in_device_number = NULL, checked_in_point_name = NULL, updated_at = now\(\)\s+WHERE id = \$1 AND event_id = \$2 AND checkin_status = true AND deleted_at IS NULL\s+RETURNING id, event_id, first_name, last_name, email, company, position, code, checkin_status, checked_in_at, checked_in_by, checked_in_device_number, checked_in_point_name, printed_count, custom_fields, blocked, block_reason, created_at, updated_at` + +// undoCheckinFallbackSelectSQL matches the 0-row fallback SELECT (plain, +// non-joined — an undone/never-checked-in attendee has no email to show). +const undoCheckinFallbackSelectSQL = `SELECT id, event_id, first_name, last_name, email, company, position, code, checkin_status, checked_in_at, checked_in_by, checked_in_device_number, checked_in_point_name, printed_count, custom_fields, blocked, block_reason, created_at, updated_at FROM attendees WHERE id = \$1 AND event_id = \$2 AND deleted_at IS NULL` + +// checkinActionsInsertUndoSQL is retained as an alias so the "undo" test +// below reads the same as before the P4.1 Task 4 extraction — it's the +// SAME shared SQL as checkinActionsInsertSQL (see its doc comment). +const checkinActionsInsertUndoSQL = checkinActionsInsertSQL + +// TestUndoCheckinClearsAndLogsFeedRow proves the exact guarded-UPDATE SQL +// (checkin_status = true guard) and that a 1-row result inserts an 'undo' +// checkin_actions row in the same transaction before commit. +func TestUndoCheckinClearsAndLogsFeedRow(t *testing.T) { + mock, err := pgxmock.NewPool() + if err != nil { + t.Fatalf("pgxmock.NewPool: %v", err) + } + defer mock.Close() + + eventID, attendeeID, stationID, staffID := uuid.New(), uuid.New(), uuid.New(), uuid.New() + now := time.Now() + + mock.ExpectBegin() + mock.ExpectQuery(undoCheckinUpdateSQL). + WithArgs(attendeeID, eventID). + WillReturnRows(pgxmock.NewRows(checkinAttendeeReturningColumns). + AddRow(attendeeID, eventID, "Ada", "Lovelace", "ada@example.com", "Acme", "Eng", "CODE1", + false, nil, nil, nil, nil, 0, nil, false, nil, now, now)) + mock.ExpectExec(checkinActionsInsertUndoSQL). + WithArgs(eventID, attendeeID, &stationID, "undo", staffID). + WillReturnResult(pgxmock.NewResult("INSERT", 1)) + mock.ExpectCommit() + + s := &PGStore{db: mock} + a, err := s.UndoCheckin(context.Background(), eventID, attendeeID, &stationID, staffID) + if err != nil { + t.Fatalf("UndoCheckin: %v", err) + } + if a.CheckinStatus { + t.Errorf("CheckinStatus = true, want false after undo") + } + if a.CheckedInAt != nil || a.CheckedInBy != nil || a.CheckedInPointName != nil { + t.Errorf("undo left stale metadata: CheckedInAt=%v CheckedInBy=%v CheckedInPointName=%v", a.CheckedInAt, a.CheckedInBy, a.CheckedInPointName) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet expectations: %v", err) + } +} + +// TestUndoCheckinClearsDeviceNumber is the round-trip proof for PR #77 +// bot-review round Finding B: an attendee checked in via the mobile batch +// path (ApplyBatchCheckin) carries a non-nil checked_in_device_number. +// UndoCheckin's guarded UPDATE must clear that column in the SAME +// statement as checkin_status/checked_in_at/checked_in_by/ +// checked_in_point_name — the mocked RETURNING row (standing in for what a +// real Postgres UPDATE ... SET checked_in_device_number = NULL would hand +// back) has a nil device number, and this proves UndoCheckin scans that nil +// through untouched rather than preserving whatever the caller happened to +// pass in (UndoCheckin takes no device-number argument at all — the ONLY +// way it ends up nil on the returned row is the UPDATE itself clearing it). +func TestUndoCheckinClearsDeviceNumber(t *testing.T) { + mock, err := pgxmock.NewPool() + if err != nil { + t.Fatalf("pgxmock.NewPool: %v", err) + } + defer mock.Close() + + eventID, attendeeID, staffID := uuid.New(), uuid.New(), uuid.New() + now := time.Now() + + mock.ExpectBegin() + mock.ExpectQuery(undoCheckinUpdateSQL). + WithArgs(attendeeID, eventID). + WillReturnRows(pgxmock.NewRows(checkinAttendeeReturningColumns). + AddRow(attendeeID, eventID, "Ada", "Lovelace", "ada@example.com", "Acme", "Eng", "CODE1", + false, nil, nil, nil, nil, 0, nil, false, nil, now, now)) + mock.ExpectExec(checkinActionsInsertUndoSQL). + WithArgs(eventID, attendeeID, (*uuid.UUID)(nil), "undo", staffID). + WillReturnResult(pgxmock.NewResult("INSERT", 1)) + mock.ExpectCommit() + + s := &PGStore{db: mock} + a, err := s.UndoCheckin(context.Background(), eventID, attendeeID, nil, staffID) + if err != nil { + t.Fatalf("UndoCheckin: %v", err) + } + if a.CheckedInDeviceNumber != nil { + t.Errorf("CheckedInDeviceNumber = %v, want nil (undo must clear the mobile-checkin device number, not just leave it stale)", *a.CheckedInDeviceNumber) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet expectations: %v", err) + } +} + +// TestUndoCheckinAlreadyClearIsIdempotentNoFeedRow covers the 0-row path: +// the guarded UPDATE affects nothing (already not checked in), so +// UndoCheckin falls back to the plain SELECT and returns 200 with no feed +// row written (no ExpectExec is set for the checkin_actions INSERT). +func TestUndoCheckinAlreadyClearIsIdempotentNoFeedRow(t *testing.T) { + mock, err := pgxmock.NewPool() + if err != nil { + t.Fatalf("pgxmock.NewPool: %v", err) + } + defer mock.Close() + + eventID, attendeeID, staffID := uuid.New(), uuid.New(), uuid.New() + now := time.Now() + + mock.ExpectBegin() + mock.ExpectQuery(undoCheckinUpdateSQL). + WithArgs(attendeeID, eventID). + WillReturnRows(pgxmock.NewRows(checkinAttendeeReturningColumns)) // 0 rows + mock.ExpectQuery(undoCheckinFallbackSelectSQL). + WithArgs(attendeeID, eventID). + WillReturnRows(pgxmock.NewRows(checkinAttendeeReturningColumns). + AddRow(attendeeID, eventID, "Ada", "Lovelace", "ada@example.com", "Acme", "Eng", "CODE1", + false, nil, nil, nil, nil, 0, nil, false, nil, now, now)) + mock.ExpectCommit() + + s := &PGStore{db: mock} + a, err := s.UndoCheckin(context.Background(), eventID, attendeeID, nil, staffID) + if err != nil { + t.Fatalf("UndoCheckin: %v", err) + } + if a.CheckinStatus { + t.Errorf("CheckinStatus = true, want false") + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet expectations: %v", err) + } +} + +// TestUndoCheckinMissingReturnsErrAttendeeNotFound covers both queries +// matching nothing — the transaction rolls back and ErrAttendeeNotFound +// surfaces, the same soft-delete-race shape as CheckInAttendee's. +func TestUndoCheckinMissingReturnsErrAttendeeNotFound(t *testing.T) { + mock, err := pgxmock.NewPool() + if err != nil { + t.Fatalf("pgxmock.NewPool: %v", err) + } + defer mock.Close() + + eventID, attendeeID, staffID := uuid.New(), uuid.New(), uuid.New() + + mock.ExpectBegin() + mock.ExpectQuery(undoCheckinUpdateSQL). + WithArgs(attendeeID, eventID). + WillReturnRows(pgxmock.NewRows(checkinAttendeeReturningColumns)) + mock.ExpectQuery(undoCheckinFallbackSelectSQL). + WithArgs(attendeeID, eventID). + WillReturnRows(pgxmock.NewRows(checkinAttendeeReturningColumns)) + mock.ExpectRollback() + + s := &PGStore{db: mock} + _, err = s.UndoCheckin(context.Background(), eventID, attendeeID, nil, staffID) + if !errors.Is(err, ErrAttendeeNotFound) { + t.Fatalf("err = %v, want ErrAttendeeNotFound", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet expectations: %v", err) + } +} + +// getCheckinActionsSQL matches GetCheckinActions' exact joined SELECT — +// including the `ca.id DESC` tie-breaker (PR #77 bot-review round, Finding +// E): ORDER BY created_at DESC alone has no deterministic tie-breaker for +// concurrent actions sharing the same timestamp (down to whatever +// precision created_at stores), which matters for a "last 50" feed that's +// supposed to be stable across repeated calls with the same LIMIT. +const getCheckinActionsSQL = `SELECT ca\.id, ca\.action, ca\.station_id, ca\.created_at, a\.id, a\.first_name, a\.last_name, a\.code\s+FROM checkin_actions ca\s+JOIN attendees a ON ca\.attendee_id = a\.id\s+WHERE ca\.event_id = \$1\s+ORDER BY ca\.created_at DESC, ca\.id DESC\s+LIMIT \$2` + +// TestGetCheckinActionsReturnsNewestFirstJoinedRows proves the exact SQL +// (including LIMIT $2) and that rows scan into the joined +// CheckinActionRow/CheckinActionAttendee shape in whatever order the mock +// (standing in for the real ORDER BY created_at DESC) returns them. +func TestGetCheckinActionsReturnsNewestFirstJoinedRows(t *testing.T) { + mock, err := pgxmock.NewPool() + if err != nil { + t.Fatalf("pgxmock.NewPool: %v", err) + } + defer mock.Close() + + eventID := uuid.New() + actionID1, actionID2 := uuid.New(), uuid.New() + attendeeID1, attendeeID2 := uuid.New(), uuid.New() + stationID := uuid.New() + newer := time.Now() + older := newer.Add(-time.Minute) + + mock.ExpectQuery(getCheckinActionsSQL). + WithArgs(eventID, 50). + WillReturnRows(pgxmock.NewRows([]string{"id", "action", "station_id", "created_at", "id", "first_name", "last_name", "code"}). + AddRow(actionID1, "checkin", &stationID, newer, attendeeID1, "Ada", "Lovelace", "CODE1"). + AddRow(actionID2, "undo", nil, older, attendeeID2, "Bob", "Builder", "CODE2")) + + s := &PGStore{db: mock} + got, err := s.GetCheckinActions(context.Background(), eventID, 50) + if err != nil { + t.Fatalf("GetCheckinActions: %v", err) + } + if len(got) != 2 { + t.Fatalf("len(got) = %d, want 2", len(got)) + } + if got[0].ID != actionID1 || got[0].Action != "checkin" || got[0].StationID == nil || *got[0].StationID != stationID { + t.Errorf("got[0] = %+v, unexpected", got[0]) + } + if got[0].Attendee.ID != attendeeID1 || got[0].Attendee.FirstName != "Ada" || got[0].Attendee.Code != "CODE1" { + t.Errorf("got[0].Attendee = %+v, unexpected", got[0].Attendee) + } + if got[1].StationID != nil { + t.Errorf("got[1].StationID = %v, want nil (undo with no station)", got[1].StationID) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet expectations: %v", err) + } +} + +// TestGetCheckinActionsNoneReturnsEmpty proves an event with no feed rows +// yet gets a nil/empty slice, not an error. +func TestGetCheckinActionsNoneReturnsEmpty(t *testing.T) { + mock, err := pgxmock.NewPool() + if err != nil { + t.Fatalf("pgxmock.NewPool: %v", err) + } + defer mock.Close() + + eventID := uuid.New() + mock.ExpectQuery(getCheckinActionsSQL). + WithArgs(eventID, 50). + WillReturnRows(pgxmock.NewRows([]string{"id", "action", "station_id", "created_at", "id", "first_name", "last_name", "code"})) + + s := &PGStore{db: mock} + got, err := s.GetCheckinActions(context.Background(), eventID, 50) + if err != nil { + t.Fatalf("GetCheckinActions: %v", err) + } + if len(got) != 0 { + t.Errorf("len(got) = %d, want 0", len(got)) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet expectations: %v", err) + } +} + +func TestListCheckinStationsNoneRegisteredReturnsEmpty(t *testing.T) { + mock, err := pgxmock.NewPool() + if err != nil { + t.Fatalf("pgxmock.NewPool: %v", err) + } + defer mock.Close() + + eventID := uuid.New() + mock.ExpectQuery(listCheckinStationsSQL). + WithArgs(eventID). + WillReturnRows(pgxmock.NewRows([]string{"id", "event_id", "name", "zone_id", "last_seen_at", "created_at"})) + + s := &PGStore{db: mock} + got, err := s.ListCheckinStations(context.Background(), eventID) + if err != nil { + t.Fatalf("ListCheckinStations: %v", err) + } + if len(got) != 0 { + t.Errorf("len(got) = %d, want 0", len(got)) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet expectations: %v", err) + } +} + +// --- Task 4: InsertCheckinAction (shared with CheckInAttendee/UndoCheckin) --- + +// TestInsertCheckinActionIssuesExactInsert proves the standalone Store +// method (used by the reprint endpoint, which is NOT inside any existing +// transaction) issues the exact same SQL as the tx-scoped inserts inside +// CheckInAttendee/UndoCheckin above, with action passed as a bind +// parameter. +func TestInsertCheckinActionIssuesExactInsert(t *testing.T) { + mock, err := pgxmock.NewPool() + if err != nil { + t.Fatalf("pgxmock.NewPool: %v", err) + } + defer mock.Close() + + eventID, attendeeID, stationID, staffID := uuid.New(), uuid.New(), uuid.New(), uuid.New() + mock.ExpectExec(checkinActionsInsertSQL). + WithArgs(eventID, attendeeID, &stationID, "reprint", staffID). + WillReturnResult(pgxmock.NewResult("INSERT", 1)) + + s := &PGStore{db: mock} + if err := s.InsertCheckinAction(context.Background(), eventID, attendeeID, "reprint", &stationID, staffID); err != nil { + t.Fatalf("InsertCheckinAction: %v", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet expectations: %v", err) + } +} + +// TestInsertCheckinActionNilStationInsertsNullStation proves a nil +// stationID (a station-less reprint) is passed through as NULL, not the +// zero-value UUID — mirroring CheckInAttendee's station-less test above. +func TestInsertCheckinActionNilStationInsertsNullStation(t *testing.T) { + mock, err := pgxmock.NewPool() + if err != nil { + t.Fatalf("pgxmock.NewPool: %v", err) + } + defer mock.Close() + + eventID, attendeeID, staffID := uuid.New(), uuid.New(), uuid.New() + mock.ExpectExec(checkinActionsInsertSQL). + WithArgs(eventID, attendeeID, (*uuid.UUID)(nil), "reprint", staffID). + WillReturnResult(pgxmock.NewResult("INSERT", 1)) + + s := &PGStore{db: mock} + if err := s.InsertCheckinAction(context.Background(), eventID, attendeeID, "reprint", nil, staffID); err != nil { + t.Fatalf("InsertCheckinAction: %v", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet expectations: %v", err) + } +} + +// TestInsertCheckinActionPropagatesStoreError proves a failing INSERT +// surfaces its error rather than being swallowed by the store layer — it's +// the HANDLER's job (attendee_printed.go) to decide reprint-logging +// failures are best-effort/non-fatal, not this method's. +func TestInsertCheckinActionPropagatesStoreError(t *testing.T) { + mock, err := pgxmock.NewPool() + if err != nil { + t.Fatalf("pgxmock.NewPool: %v", err) + } + defer mock.Close() + + eventID, attendeeID, staffID := uuid.New(), uuid.New(), uuid.New() + wantErr := errors.New("boom") + mock.ExpectExec(checkinActionsInsertSQL). + WithArgs(eventID, attendeeID, (*uuid.UUID)(nil), "reprint", staffID). + WillReturnError(wantErr) + + s := &PGStore{db: mock} + if err := s.InsertCheckinAction(context.Background(), eventID, attendeeID, "reprint", nil, staffID); err == nil { + t.Fatal("InsertCheckinAction: want error, got nil") + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet expectations: %v", err) + } +} diff --git a/backend/migrations/000019_checkin_loop.down.sql b/backend/migrations/000019_checkin_loop.down.sql new file mode 100644 index 00000000..8d2aad6e --- /dev/null +++ b/backend/migrations/000019_checkin_loop.down.sql @@ -0,0 +1,4 @@ +-- backend/migrations/000019_checkin_loop.down.sql +DROP TABLE checkin_actions; +DROP TABLE checkin_stations; +ALTER TABLE events DROP COLUMN checkin_settings; diff --git a/backend/migrations/000019_checkin_loop.up.sql b/backend/migrations/000019_checkin_loop.up.sql new file mode 100644 index 00000000..3dcf504f --- /dev/null +++ b/backend/migrations/000019_checkin_loop.up.sql @@ -0,0 +1,35 @@ +-- backend/migrations/000019_checkin_loop.up.sql +-- P4.1 (check-in loop): dedicated, verbatim-JSON per-event check-in +-- settings column (mirrors the badge_template column pattern — operator- +-- only config, no optimistic-concurrency version needed), a check-in +-- station registry (name-scoped per event, optionally bound to a zone), +-- and a durable check-in/undo/reprint actions feed. + +ALTER TABLE events ADD COLUMN checkin_settings JSONB NULL; + +CREATE TABLE checkin_stations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE, + name TEXT NOT NULL, + zone_id UUID NULL REFERENCES event_zones(id) ON DELETE SET NULL, + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(event_id, name) +); + +CREATE TABLE checkin_actions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE, + attendee_id UUID NOT NULL REFERENCES attendees(id) ON DELETE CASCADE, + station_id UUID NULL REFERENCES checkin_stations(id) ON DELETE SET NULL, + action TEXT NOT NULL CHECK (action IN ('checkin', 'undo', 'reprint')), + staff_user_id UUID NULL REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- id DESC is a deterministic tie-breaker (PR #77 bot-review round, Finding +-- E): GetCheckinActions orders ORDER BY created_at DESC, id DESC so +-- concurrent actions sharing the same timestamp can't be arbitrarily +-- reordered or omitted across repeated calls with the same LIMIT — this +-- index is extended to match so the query still hits it efficiently. +CREATE INDEX idx_checkin_actions_event_created ON checkin_actions(event_id, created_at DESC, id DESC); diff --git a/backend/openapi.yaml b/backend/openapi.yaml index 10bcdbb2..662fd49a 100644 --- a/backend/openapi.yaml +++ b/backend/openapi.yaml @@ -283,6 +283,231 @@ components: current_version: { type: integer } required: [error, current_version] additionalProperties: false + CheckinSettings: + type: object + description: > + Per-event check-in station configuration (P4.1) — operator-only, + no optimistic-concurrency version (unlike BadgeTemplateResponse): + check-in settings have no concurrent-editor conflict class to + guard against. Stored verbatim in events.checkin_settings JSONB. + properties: + print_on_checkin: { type: boolean } + verdict_auto_dismiss_sec: { type: integer, minimum: 1, maximum: 30 } + scan_input: { type: string, enum: [wedge, scanner, manual] } + manual_search_enabled: { type: boolean } + required: + [ + print_on_checkin, + verdict_auto_dismiss_sec, + scan_input, + manual_search_enabled, + ] + additionalProperties: false + CheckinSettingsResponse: + type: object + description: > + GET/PUT /api/events/{id}/checkin-settings response. settings is + the stored check-in settings verbatim — whatever object was last + PUT — and is null when the event has never had settings saved. + properties: + settings: + nullable: true + allOf: + - $ref: "#/components/schemas/CheckinSettings" + required: [settings] + CheckinSettingsPutRequest: + type: object + description: > + PUT /api/events/{id}/checkin-settings request body. settings is + persisted verbatim (byte-for-byte, from the raw request bytes) + after being validated against the CheckinSettings shape. + properties: + settings: + $ref: "#/components/schemas/CheckinSettings" + required: [settings] + CheckinStation: + type: object + description: > + A registered check-in station (P4.1 Task 2) — distinct from the + mobile-track Station (zone/kiosk devices): a checkin_station is + name-scoped per event (UNIQUE(event_id, name)) and optionally + bound to a zone. last_seen_at is refreshed by POST + /api/events/{event_id}/checkin-stations/{id}/heartbeat. + properties: + id: { type: string, format: uuid } + event_id: { type: string, format: uuid } + name: { type: string } + zone_id: { type: string, format: uuid, nullable: true } + last_seen_at: { type: string, format: date-time } + created_at: { type: string, format: date-time } + required: [id, event_id, name, last_seen_at, created_at] + CheckinStationRegisterRequest: + type: object + description: > + POST /api/events/{event_id}/checkin-stations request body. name + identifies the station (UNIQUE per event) — registering the SAME + name again is an upsert: zone_id is replaced (even back to null) + and last_seen_at refreshed, never a duplicate row. zone_id, when + present, must belong to the same event (400 otherwise). + properties: + name: { type: string, minLength: 1 } + zone_id: { type: string, format: uuid, nullable: true } + required: [name] + CheckinStationResponse: + type: object + description: > + POST /api/events/{event_id}/checkin-stations' response envelope — + station is the registered (or re-registered) row. + properties: + station: { $ref: "#/components/schemas/CheckinStation" } + required: [station] + CheckinStationListResponse: + type: object + description: GET /api/events/{event_id}/checkin-stations' response envelope. + properties: + stations: + type: array + items: { $ref: "#/components/schemas/CheckinStation" } + required: [stations] + CheckinOutcome: + type: string + description: > + Server-decided outcome of POST /api/events/{event_id}/checkin + (P4.1 Task 3). "not_found" is deliberately NOT a value here — an + unresolved scanned code is a client-side outcome (the code lookup + itself returned empty) that never reaches this endpoint. + enum: [checked_in, already_checked_in, blocked] + StationCheckinRequest: + type: object + description: > + POST /api/events/{event_id}/checkin request body (P4.1 Task 3). + station_id, when present, must belong to the same event (400 + otherwise); it is optional — a station-less check-in is valid. + properties: + attendee_id: { type: string, format: uuid } + station_id: { type: string, format: uuid, nullable: true } + required: [attendee_id] + additionalProperties: false + CheckinInfo: + type: object + description: > + The first-scan metadata block of StationCheckinResponse — for + outcome checked_in this is THIS scan; for already_checked_in it is + the ORIGINAL scan, never overwritten. + properties: + at: { type: string, format: date-time } + by_email: { type: string } + point_name: { type: string, nullable: true } + required: [at, by_email] + StationCheckinResponse: + type: object + description: > + POST /api/events/{event_id}/checkin response. checkin is the + first-scan metadata for outcome checked_in/already_checked_in, and + null for outcome blocked (block_reason is read from attendee + instead — a blocked attendee is never checked in). + properties: + outcome: { $ref: "#/components/schemas/CheckinOutcome" } + attendee: { $ref: "#/components/schemas/Attendee" } + checkin: + nullable: true + allOf: + - $ref: "#/components/schemas/CheckinInfo" + required: [outcome, attendee, checkin] + UndoCheckinRequest: + type: object + description: > + POST /api/events/{event_id}/checkin/undo request body (P4.1 Task + 3). station_id, when present, must belong to the same event (400 + otherwise); it is recorded on the checkin_actions feed row only. + properties: + attendee_id: { type: string, format: uuid } + station_id: { type: string, format: uuid, nullable: true } + required: [attendee_id] + additionalProperties: false + UndoCheckinResponse: + type: object + description: > + POST /api/events/{event_id}/checkin/undo response — always 200, + idempotent: undoing an attendee who is already not checked in + still returns 200 with the (unchanged) attendee. + properties: + attendee: { $ref: "#/components/schemas/Attendee" } + required: [attendee] + CheckinActionAttendee: + type: object + description: Slim attendee projection embedded in a CheckinActionRow. + properties: + id: { type: string, format: uuid } + first_name: { type: string } + last_name: { type: string } + code: { type: string } + required: [id, first_name, last_name, code] + CheckinActionRow: + type: object + description: > + One row of GET /api/events/{event_id}/checkin-actions' feed (P4.1 + Task 3) — the durable check-in/undo/reprint audit trail backing + the station's recent-scans rail. + properties: + id: { type: string, format: uuid } + action: { type: string, enum: [checkin, undo, reprint] } + station_id: { type: string, format: uuid, nullable: true } + created_at: { type: string, format: date-time } + attendee: { $ref: "#/components/schemas/CheckinActionAttendee" } + required: [id, action, created_at, attendee] + CheckinActionsResponse: + type: object + description: GET /api/events/{event_id}/checkin-actions' response envelope. + properties: + actions: + type: array + items: { $ref: "#/components/schemas/CheckinActionRow" } + required: [actions] + MarkAttendeePrintedRequest: + type: object + description: > + POST /api/attendees/{attendee_id}/printed's OPTIONAL request body + (P4.1 Task 4). Both fields are optional, but NOT independent of + each other — the handler (attendee_printed.go) enforces two + dependency/consistency constraints the schema below cannot express + structurally (OpenAPI 3.0 has no clean native "field A requires + field B" construct), documented in prose on each field and on the + endpoint's 400 response instead: (1) station_id requires event_id + to also be present — a station_id with no event_id is rejected + with 400, not silently discarded (PR #77 bot-review round 1, + Finding D); (2) event_id, when present, must match the attendee's + actual event — a mismatched event_id is rejected with 400, never + silently substituted (checked since this endpoint's reprint-logging + was first built, Task 4). event_id is what actually gates the + reprint-logging behavior — station_id is only meaningful (recorded + on the feed row) when a validated event_id is also present. Absent + entirely (or an absent/empty body) is the pre-existing back-compat + path: counter-only, no checkin_actions row (the badge-editor's bulk + print sends no body at all). + properties: + event_id: + type: string + format: uuid + nullable: true + description: > + Optional. When present, must match the attendee's actual event + — a mismatched event_id is rejected with 400 ("Attendee does + not belong to this event"), never silently substituted. Gates + reprint-logging: only when event_id is present (and valid) does + the handler log a checkin_actions ('reprint') row after the + printed_count increment succeeds. + station_id: + type: string + format: uuid + nullable: true + description: > + Optional, but REQUIRES event_id to also be present in the same + request — station_id with no event_id is rejected with 400 + ("event_id is required when station_id is supplied"), not + silently discarded (PR #77 bot-review round 1, Finding D). When + both are present, station_id must also belong to the same + event_id (400 "Station not found in event" otherwise). CreateProvisioningTokenResponse: type: object properties: @@ -1511,6 +1736,427 @@ paths: content: application/json: schema: { $ref: "#/components/schemas/Error" } + /api/events/{id}/checkin-settings: + get: + operationId: getCheckinSettings + summary: > + The event's check-in station settings (P4.1) — reads the dedicated + events.checkin_settings column. Consumed by Task 2+ (station + registration) and the panel's check-in settings UI. + security: [{ bearerAuth: [] }] + parameters: + - name: id + in: path + required: true + schema: { type: string, format: uuid } + responses: + "200": + description: settings is null when the event has never had settings saved. + content: + application/json: + schema: { $ref: "#/components/schemas/CheckinSettingsResponse" } + "400": + description: id is not a UUID. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "403": + description: tenant_suspended from the tenant gate. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "404": + description: > + Event does not exist, or belongs to a different tenant + (requireEventOwnership masks "foreign" as "missing" — no + existence oracle). + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "500": + description: > + Store failure resolving event ownership or reading check-in + settings. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + put: + operationId: putCheckinSettings + summary: Save the event's check-in station settings. + description: > + Storage is verbatim: settings is persisted as the request's raw + JSON bytes, byte-for-byte — the handler validates a parsed COPY + against the CheckinSettings shape (all four fields required, + unknown fields rejected) but never re-serializes before + persisting. Unlike PUT /api/events/{id}/badge-template, there is + no optimistic-concurrency version: check-in settings are + operator-only config with no concurrent-editor conflict class to + guard against. + security: [{ bearerAuth: [] }] + parameters: + - name: id + in: path + required: true + schema: { type: string, format: uuid } + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/CheckinSettingsPutRequest" } + responses: + "200": + description: Saved. settings echoes the request's raw bytes verbatim. + content: + application/json: + schema: { $ref: "#/components/schemas/CheckinSettingsResponse" } + "400": + description: > + id is not a UUID, the body is malformed, settings is missing, + or the parsed settings fail the CheckinSettings shape (a + required field is missing, verdict_auto_dismiss_sec is outside + 1..30, scan_input is not one of wedge/scanner/manual, or an + unknown field is present). + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "403": + description: tenant_suspended from the tenant gate. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "404": + description: > + Event does not exist, or belongs to a different tenant + (requireEventOwnership masks "foreign" as "missing" — checked + before any store call). + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "500": + description: > + Store failure resolving event ownership or persisting check-in + settings. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + /api/events/{event_id}/checkin-stations: + post: + operationId: registerCheckinStation + summary: > + Register (or re-register) a named check-in station for an event + (P4.1 Task 2). Registering the SAME name again is an upsert — the + same station id is returned, zone_id is replaced by whatever this + call submits (even back to null), and last_seen_at is refreshed; + it never creates a duplicate row. + security: [{ bearerAuth: [] }] + parameters: + - name: event_id + in: path + required: true + schema: { type: string, format: uuid } + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/CheckinStationRegisterRequest" } + responses: + "200": + description: Registered (or re-registered) station. + content: + application/json: + schema: { $ref: "#/components/schemas/CheckinStationResponse" } + "400": + description: > + event_id is not a UUID, the body is malformed, name is missing + or empty, or zone_id is present but does not belong to this + event. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "403": + description: tenant_suspended from the tenant gate. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "404": + description: > + Event does not exist, or belongs to a different tenant + (requireEventOwnership masks "foreign" as "missing" — checked + before any store call). + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "500": + description: > + Store failure resolving event ownership, verifying zone_id, or + persisting the station. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + get: + operationId: listCheckinStations + summary: List every check-in station registered for an event (P4.1 Task 2). + security: [{ bearerAuth: [] }] + parameters: + - name: event_id + in: path + required: true + schema: { type: string, format: uuid } + responses: + "200": + description: Registered stations. + content: + application/json: + schema: { $ref: "#/components/schemas/CheckinStationListResponse" } + "400": + description: event_id is not a UUID. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "403": + description: tenant_suspended from the tenant gate. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "404": + description: > + Event does not exist, or belongs to a different tenant + (requireEventOwnership masks "foreign" as "missing"). + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "500": + description: Store failure resolving event ownership or listing stations. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + /api/events/{event_id}/checkin-stations/{id}/heartbeat: + post: + operationId: heartbeatCheckinStation + summary: > + Refresh a check-in station's last_seen_at (P4.1 Task 2) — polled + periodically by a running station so the panel can show + online/offline state (a later task). + security: [{ bearerAuth: [] }] + parameters: + - name: event_id + in: path + required: true + schema: { type: string, format: uuid } + - name: id + in: path + required: true + schema: { type: string, format: uuid } + responses: + "204": + description: last_seen_at refreshed. No body. + "400": + description: event_id or id is not a UUID. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "403": + description: tenant_suspended from the tenant gate. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "404": + description: > + Event does not exist / belongs to a different tenant, or the + station id does not exist / belongs to a different event + (store.ErrCheckinStationNotFound — both collapse to the same + 404, no existence oracle). + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "500": + description: Store failure resolving event ownership or updating the station. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + /api/events/{event_id}/checkin: + post: + operationId: stationCheckin + summary: > + Idempotent single-scan check-in (P4.1 Task 3) — the + zero-double-checkin guarantee at the source, via a guarded + `UPDATE ... WHERE checkin_status = false` in the store. Never + touches printed_count and never prints; printing is a separate + client step gated on the "checked_in" outcome only. + description: > + A blocked attendee (attendee.blocked) is never checked in — the + handler returns outcome "blocked" (with block_reason on attendee) + without attempting the guarded write. Otherwise, the guarded + UPDATE either performs the check-in (outcome "checked_in", and a + checkin_actions row is inserted in the same transaction) or, if + the attendee was already checked in, falls back to a read that + returns the ORIGINAL first-scan metadata unchanged (outcome + "already_checked_in", no new feed row). + security: [{ bearerAuth: [] }] + parameters: + - name: event_id + in: path + required: true + schema: { type: string, format: uuid } + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/StationCheckinRequest" } + responses: + "200": + description: > + checked_in, already_checked_in, or blocked — all three are 200, + never an error; the station renders each as a distinct verdict. + content: + application/json: + schema: { $ref: "#/components/schemas/StationCheckinResponse" } + "400": + description: > + event_id is not a UUID, the body is malformed, attendee_id is + missing, the attendee belongs to a different event than + event_id, or station_id is present but does not belong to this + event. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "403": + description: tenant_suspended from the tenant gate. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "404": + description: > + Event does not exist / belongs to a different tenant, or + attendee_id does not exist / belongs to a different tenant + (both requireEventOwnership and requireAttendeeOwnership mask + "foreign" as "missing" — no existence oracle), or the + attendee was concurrently soft-deleted between the ownership + check and the guarded write (store.ErrAttendeeNotFound). + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "409": + description: > + store.ErrCheckinConflict (PR #77 bot-review round 2, Finding 1): + the guarded UPDATE's fallback read found the attendee neither + checked in nor blocked — an extremely narrow, transient race + (e.g. the UPDATE lost to a different concurrent attempt that + itself then got undone, or a block/unblock cycle landed between + the UPDATE and the fallback read). The store retries this + state once internally before giving up; this 409 means both + attempts landed on it. The caller should retry the scan. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "500": + description: > + Store failure resolving ownership, verifying station_id, + resolving the staff user, or performing the check-in. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + /api/events/{event_id}/checkin/undo: + post: + operationId: undoCheckin + summary: > + Clear a check-in (P4.1 Task 3) — idempotent: undoing an attendee + who is already not checked in still returns 200 with no + checkin_actions row written. + security: [{ bearerAuth: [] }] + parameters: + - name: event_id + in: path + required: true + schema: { type: string, format: uuid } + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/UndoCheckinRequest" } + responses: + "200": + description: Check-in cleared (or already clear — idempotent). + content: + application/json: + schema: { $ref: "#/components/schemas/UndoCheckinResponse" } + "400": + description: > + event_id is not a UUID, the body is malformed, attendee_id is + missing, the attendee belongs to a different event than + event_id, or station_id is present but does not belong to this + event. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "403": + description: tenant_suspended from the tenant gate. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "404": + description: > + Event does not exist / belongs to a different tenant, or + attendee_id does not exist / belongs to a different tenant, or + the attendee was concurrently soft-deleted + (store.ErrAttendeeNotFound). + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "500": + description: Store failure resolving ownership, verifying station_id, or clearing the check-in. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + /api/events/{event_id}/checkin-actions: + get: + operationId: getCheckinActions + summary: > + The event's check-in/undo/reprint feed, newest first (P4.1 Task 3) + — backs the station's recent-scans rail (last 50). + security: [{ bearerAuth: [] }] + parameters: + - name: event_id + in: path + required: true + schema: { type: string, format: uuid } + - name: limit + in: query + required: false + description: > + Defaults to 50 and is clamped to a maximum of 50 (the rail + never shows more). An invalid or non-positive value is + ignored, falling back to the default, rather than 400ing this + read-only feed endpoint. + schema: { type: integer } + responses: + "200": + description: The newest (at most) `limit` check-in actions, newest first. + content: + application/json: + schema: { $ref: "#/components/schemas/CheckinActionsResponse" } + "400": + description: event_id is not a UUID. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "403": + description: tenant_suspended from the tenant gate. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "404": + description: > + Event does not exist, or belongs to a different tenant + (requireEventOwnership masks "foreign" as "missing"). + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "500": + description: Store failure resolving event ownership or fetching the feed. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } /api/events/{id}/readiness: get: operationId: getEventReadiness @@ -2528,7 +3174,7 @@ paths: operationId: markAttendeePrinted summary: > Increment an attendee's printed_count by one and return the new - count. No request body. [Plan-time reconciliation #6, + count. [Plan-time reconciliation #6, docs/superpowers/plans/2026-07-16-panel-p3.2-print-truth.md] printed_count had NO write path anywhere before this endpoint (no handler field, no endpoint, no client bump) — the attendees table's @@ -2537,13 +3183,39 @@ paths: successful agent print. It is deliberately NOT a print journal (no per-print audit 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. + audit/dedupe journals, not this pre-existing counter. P4.1 Task 4 + adds an OPTIONAL request body: when event_id is present, after the + counter increment succeeds, the handler ALSO inserts a + checkin_actions ('reprint') feed 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, unchanged. The body is parsed leniently: unknown + fields are ignored and a syntactically-malformed JSON body is + treated the same as no body at all (the counter still increments). + A present, well-formed body can still 400 in FOUR cases, all + checked BEFORE the counter increments so a rejected request never + partially applies — see MarkAttendeePrintedRequest's schema and its + field descriptions for the full dependency/mismatch contract: (1) + event_id or station_id is present but not a valid UUID string; (2) + station_id is present without event_id (PR #77 bot-review round 1, + Finding D); (3) event_id is present but does not match the + attendee's actual event; (4) station_id, when present alongside a + valid event_id, does not belong to that event. Reprint-logging + failures (e.g. a transient store error resolving staffUserID from + claims) never fail this endpoint — the counter has already + committed by the time logging is attempted, so it is treated as + best-effort. security: [{ bearerAuth: [] }] parameters: - name: attendee_id in: path required: true schema: { type: string, format: uuid } + requestBody: + required: false + content: + application/json: + schema: { $ref: "#/components/schemas/MarkAttendeePrintedRequest" } responses: "200": description: printed_count incremented by one; response carries the new value. @@ -2555,7 +3227,17 @@ paths: printed_count: { type: integer } required: [printed_count] "400": - description: attendee_id is not a UUID. + description: > + attendee_id is not a UUID; the optional body's event_id or + station_id is present but not a valid UUID string; station_id + is present without event_id ("event_id is required when + station_id is supplied" — PR #77 bot-review round 1, Finding + D); event_id is present but does not match the attendee's + actual event ("Attendee does not belong to this event"); or + station_id, alongside a valid event_id, does not belong to that + event ("Station not found in event"). See + MarkAttendeePrintedRequest for the full field-level dependency + contract. content: application/json: schema: { $ref: "#/components/schemas/Error" } diff --git a/docs/superpowers/plans/2026-07-17-panel-p4.1-checkin-loop.md b/docs/superpowers/plans/2026-07-17-panel-p4.1-checkin-loop.md new file mode 100644 index 00000000..438f2b56 --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-panel-p4.1-checkin-loop.md @@ -0,0 +1,246 @@ +# P4.1 — Check-in loop — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship the panel's event-day check-in loop — server check-in settings, check-in-station registration + heartbeat, a DB-idempotent single-scan check-in endpoint with undo and a durable `checkin_actions` feed, plus the launch ceremony 2a and the split-rail station 2c with its 2d desk/degraded mode. + +**Architecture:** Backend-first (Tasks 1–4: migration 000019 + settings/stations/checkin+undo+feed/reprint-log, all openapi-first + contract-tested), then the panel data layer (Task 5), then the station bottom-up (Task 6 verdict/flow → 7 scan input → 8 route+layout → 9 rail → 10 degraded), then the launch ceremony + workspace unlock (Tasks 11–12), then the final sweep (Task 13). Printing reuses P3.2's `usePrintBadge`. The station is a near-fullscreen route that escapes the workspace rail shell. + +**Tech Stack:** Go/Echo + kin-openapi harness; React 19, TanStack Router/Query, `$api`, MSW, `@idento/ui`, the P3.2 `agentClient`/`usePrintBadge`. **No new dependencies.** + +**Spec:** `docs/superpowers/specs/2026-07-17-panel-p4.1-checkin-loop-design.md`. +**Board extract (§4/§5 implementers MUST read 2a/2c/2d):** `.superpowers/sdd/p4.1-board-2a-2c-2d-extract.md`. + +## Global Constraints + +- Branch: `panel/p4.1-checkin-loop` from current `main` (create; commit this plan first). This phase is large — if the PR approaches 50 changed files, that's an accepted CodeRabbit-skip (Codex review covers it), do NOT compromise task boundaries to stay under. +- Backend tasks: openapi-first (`backend/openapi.yaml` → handler → kin-openapi contract test with `validateResponse` + coverage ledger under `OPENAPI_COVERAGE=1`) → `npm run generate:api -w panel` (committed in the task that first needs the types). Backend gates every backend task: `cd backend && OPENAPI_COVERAGE=1 go test ./... -count=1` AND `golangci-lint run ./internal/...`. pgxmock tests assert REAL SQL text (P2.1 lesson), including the guarded-UPDATE 0-row path. +- Panel: `$api` for backend; the print agent stays behind `agentClient` (P3.2); MSW for all HTTP tests (backend AND agent origins, `http://agent.test`); `getRouteApi("/_app/…")`; **router.tsx regression guard** (`/register` `beforeLoad`/`protectedBeforeLoad` byte-for-byte; only ADD routes). +- i18n EN+RU keyParity, flat keys, feature prefixes (`checkin*`, `station*`, `launch*`), real Russian; zod/validators carry message KEYS. +- No fabricated data (loading → Skeleton; error ≠ empty); WCAG 1.4.1 (verdict = icon+text+color); `@idento/ui` primitives, token classes only. **Verdict rendering reuses `@idento/ui` `verdictClasses`** — station outcomes map: `checked_in`→`allowed`, `already_checked_in`→`already_checked_in`, `blocked`→`no_access`, `not_found`→`not_registered`. Never invent verdict colors. +- Mutation hygiene: session-id refs, unconditional invalidation, exhaustive busy-gating, captured-eventId guards. **Physical-output dialogs BLOCK dismissal while in flight** (P3.2 PR-#74 lesson) — the reprint/undo confirms follow this. +- Printing reuses P3.2 `usePrintBadge(eventId).printAttendee(attendee, printerName, opts)` — never re-implement generation/agent calls. Print fires ONLY on the `checked_in` outcome (zero double-print at the source). +- **web/ is frozen** — do not touch its kiosk, its localStorage `checkin_settings`, or the legacy `PUT /api/attendees/{id}` / `POST /api/sync` writers. +- Panel verification before every commit: `npm run typecheck -w panel && npm test -w panel` + `cd panel && npx eslint .`. +- Commit after every green step. Do NOT commit `.superpowers/sdd/progress.md`. + +## Plan-time facts (verified 2026-07-17) + +1. `usePrintBadge(eventId)` (`panel/src/features/badge/zpl/usePrintBadge.ts:85`) returns `{printAttendee(attendee: Attendee, printerName: string, opts?: {skipInvalidate?: boolean}), fontsStatus}`; throws `NoTemplateError`/`MissingFontError`/`MarkPrintedError`; internally POSTs `/api/attendees/{id}/printed`. Task 4 extends that endpoint + `PrintAttendeeOptions` with an optional print-context so the station's reprint logs a feed row. +2. Attendee lookup by scanned code: `GET /api/events/{event_id}/attendees?code=` already exists (`attendees.go:116`), scalable exact-match — the station uses it; do NOT load the whole roster. +3. Query-key helpers precedent: `ATTENDEES_LIST_KEY`/`ATTENDEE_DETAIL_KEY` (`attendees/hooks.ts:65,91`), `READINESS_KEY` (`events/hooks.ts:33`). New `checkin` keys mirror the shape. +4. Router (`panel/src/app/router.tsx`): `eventWorkspaceRoute` (`/events/$eventId`, rail shell) has children overview/settings/attendees/zones/staff/badge. The station + ceremony are NEW **top-level protected routes** (siblings under `protectedLayoutRoute.addChildren`, NOT workspace children) so they render rail-less; paths `/events/$eventId/checkin` and `/events/$eventId/checkin/launch`. +5. Badge-template column pattern to mirror for settings: `events.badge_template JSONB` + `GetBadgeTemplate`/`PutBadgeTemplate` (`badge_template.go`), stored verbatim `json.RawMessage`. Settings need NO version (operator-only config). +6. Guarded-UPDATE idempotency precedent: `ApplyBatchCheckin` (`pg_store_batch.go:93`, `WHERE checkin_status=false` + `RowsAffected`) and `IncrementAttendeePrintedCount` (guarded + sentinel). Mirror both. +7. Latest migration = `000018`; next = `000019`. Contract harness: `openapi_contract_test.go`; a GET/PUT example is `openapi_contract_badge_template_p3_test.go`; pgxmock SQL-text example is `pg_store_attendee_printed_test.go`. +8. Readiness `ready` is display-only (`readiness.go` — no server gate). The launch lock is frontend-only via `useEventReadiness(eventId).data?.ready`. + +--- + +### Task 1: Backend — migration 000019 + check-in settings + +**Files:** +- Create: `backend/migrations/000019_checkin_loop.up.sql`, `.down.sql`, `backend/internal/handler/checkin_settings.go`, `backend/internal/handler/openapi_contract_checkin_p4_test.go`, `backend/internal/store/pg_store_checkin_test.go` +- Modify: `backend/internal/models/models.go` (new structs), `backend/internal/store/interface.go`, `backend/internal/store/pg_store.go`, `backend/internal/handler/handler.go` (routes), `backend/openapi.yaml`, `backend/internal/handler/testsupport_test.go` (fakeStore func-fields) + +**Interfaces:** +- Produces: **the full migration** (this one file creates everything Tasks 2–3 use): `ALTER TABLE events ADD COLUMN checkin_settings JSONB NULL;` + `CREATE TABLE checkin_stations (id UUID PK DEFAULT gen_random_uuid(), event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE, name TEXT NOT NULL, zone_id UUID NULL REFERENCES event_zones(id) ON DELETE SET NULL, last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE(event_id, name));` + `CREATE TABLE checkin_actions (id UUID PK DEFAULT gen_random_uuid(), event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE, attendee_id UUID NOT NULL REFERENCES attendees(id) ON DELETE CASCADE, station_id UUID NULL REFERENCES checkin_stations(id) ON DELETE SET NULL, action TEXT NOT NULL CHECK (action IN ('checkin','undo','reprint')), staff_user_id UUID NULL REFERENCES users(id), created_at TIMESTAMPTZ NOT NULL DEFAULT now());` + `CREATE INDEX idx_checkin_actions_event_created ON checkin_actions(event_id, created_at DESC);`. Down drops both tables + the column. +- Produces: models `CheckinStation{ID, EventID, Name, ZoneID *uuid.UUID, LastSeenAt, CreatedAt}`, `CheckinAction{ID, EventID, AttendeeID, StationID *uuid.UUID, Action string, StaffUserID *uuid.UUID, CreatedAt}` (both `json` tagged). `Event` gains `CheckinSettings json.RawMessage json:"-"`. +- Produces: store `GetCheckinSettings(ctx, eventID uuid.UUID) (json.RawMessage, error)` (nil when column NULL); `UpdateCheckinSettings(ctx, eventID uuid.UUID, settings json.RawMessage) error` (`UPDATE events SET checkin_settings=$1, updated_at=now() WHERE id=$2 AND deleted_at IS NULL`). Endpoints `GET /api/events/{id}/checkin-settings` (`getCheckinSettings`) → `{settings: object|null}`; `PUT` (`putCheckinSettings`) body `{settings: object}` → validates the shape (`{print_on_checkin: bool, verdict_auto_dismiss_sec: int 1..30, scan_input: enum wedge|scanner|manual, manual_search_enabled: bool}`, 400 on violation), stores verbatim, 200 `{settings}`. `requireEventOwnership` first. + +- [x] **Step 1: Migration files** (both DDL directions above). Confirm `GetEventByID`'s SELECT is NOT extended here (settings read via their own store method, mirroring how badge_template avoided touching GetEventByID initially). +- [x] **Step 2: openapi** — document both operations + a `CheckinSettings` schema (all four fields required, `additionalProperties: false`) + the `{settings}` envelopes; house error shapes. +- [x] **Step 3: Failing contract + pgxmock tests.** Contract: GET with NULL column → `{settings: null}`; PUT valid → 200 round-trip; PUT `verdict_auto_dismiss_sec: 0`/`31` → 400; PUT `scan_input: "camera"` → 400; foreign event → 404. pgxmock: `UpdateCheckinSettings` issues the exact guarded SQL; `GetCheckinSettings` NULL → `(nil, nil)`. +- [x] **Step 4:** Implement models + store + handler + routes. Verify both gates. +- [x] **Step 5: Commit.** `feat(backend): migration 000019, check-in settings endpoints` + +--- + +### Task 2: Backend — check-in station register / heartbeat / list + +**Files:** +- Create: `backend/internal/handler/checkin_stations.go` +- Modify: `backend/internal/store/interface.go`, `pg_store.go`, `handler.go`, `openapi.yaml`, `openapi_contract_checkin_p4_test.go`, `pg_store_checkin_test.go`, `testsupport_test.go` + +**Interfaces:** +- Consumes: Task 1's `checkin_stations` table + `CheckinStation` model. +- Produces: store `UpsertCheckinStation(ctx, eventID uuid.UUID, name string, zoneID *uuid.UUID) (*CheckinStation, error)` — `INSERT ... ON CONFLICT (event_id, name) DO UPDATE SET zone_id = EXCLUDED.zone_id, last_seen_at = now() RETURNING ...`; `HeartbeatCheckinStation(ctx, eventID, stationID uuid.UUID) error` — `UPDATE checkin_stations SET last_seen_at=now() WHERE id=$1 AND event_id=$2` (0 rows → `store.ErrCheckinStationNotFound` sentinel); `ListCheckinStations(ctx, eventID uuid.UUID) ([]*CheckinStation, error)`. Endpoints: `POST /api/events/{event_id}/checkin-stations` (`registerCheckinStation`) body `{name (required, non-empty), zone_id?}` → 200 `{station}` (upsert); `POST /api/events/{event_id}/checkin-stations/{id}/heartbeat` (`heartbeatCheckinStation`) → 204 (404 on unknown/foreign station); `GET /api/events/{event_id}/checkin-stations` (`listCheckinStations`) → `{stations: [...]}`. `requireEventOwnership`; `zone_id` when present validated against the event's zones (400 foreign). + +- [x] **Step 1: openapi** — three ops + `CheckinStation` schema. +- [x] **Step 2: Failing contract + pgxmock tests.** Contract: register new name → station; register SAME name again with a different `zone_id` → SAME id, zone updated (upsert proof); heartbeat known → 204; heartbeat unknown id → 404; list returns registered; foreign `zone_id` → 400; foreign event → 404. pgxmock: upsert issues the `ON CONFLICT (event_id, name)` SQL; heartbeat 0-row → sentinel. +- [x] **Step 3:** Implement. Verify gates. +- [x] **Step 4: Commit.** `feat(backend): check-in station registration, heartbeat, list` + +--- + +### Task 3: Backend — idempotent check-in + undo + actions feed + +**Files:** +- Create: `backend/internal/handler/checkin.go` +- Modify: `backend/internal/store/interface.go`, `pg_store.go`, `handler.go`, `openapi.yaml`, `openapi_contract_checkin_p4_test.go`, `pg_store_checkin_test.go`, `testsupport_test.go` + +**Interfaces:** +- Consumes: Task 1's `checkin_actions` table; `requireEventOwnership`/`requireAttendeeOwnership`. +- Produces: store `CheckInAttendee(ctx, eventID, attendeeID uuid.UUID, stationID *uuid.UUID, staffUserID uuid.UUID, staffEmail, stationName string) (outcome string, a *models.Attendee, err error)` — in ONE tx: guarded `UPDATE attendees SET checkin_status=true, checked_in_at=now(), checked_in_by=$staff, checked_in_by_email=$email, checked_in_point_name=$stationName, updated_at=now() WHERE id=$att AND event_id=$ev AND checkin_status=false AND deleted_at IS NULL RETURNING ...`; `RowsAffected()==1` → outcome `"checked_in"` + INSERT a `checkin_actions` (`checkin`) row; `==0` → `SELECT` the row (exists+checked-in → outcome `"already_checked_in"`, no feed row; genuinely missing/deleted → `store.ErrAttendeeNotFound`). Blocked attendees are NOT special-cased in the store (the SELECT returns `blocked`/`block_reason`; the HANDLER decides the `blocked` outcome BEFORE attempting the guarded write — a blocked attendee is never checked in). `UndoCheckin(ctx, eventID, attendeeID uuid.UUID, stationID *uuid.UUID, staffUserID uuid.UUID) (*models.Attendee, error)` — `UPDATE ... SET checkin_status=false, checked_in_at=NULL, checked_in_by=NULL, checked_in_by_email=NULL, checked_in_point_name=NULL, updated_at=now() WHERE id=$att AND event_id=$ev AND deleted_at IS NULL RETURNING ...`; when the row WAS checked in (RowsAffected on a `checkin_status=true` guard OR pre-SELECT), INSERT a `checkin_actions` (`undo`) row; idempotent (already-not-checked-in → 200, no row). `GetCheckinActions(ctx, eventID uuid.UUID, limit int) ([]CheckinActionRow, error)` where `CheckinActionRow{ID, Action, StationID *uuid.UUID, CreatedAt, Attendee: {ID, FirstName, LastName, Code}}` (joined slim projection, `ORDER BY ca.created_at DESC LIMIT $2`). +- Produces endpoints: `POST /api/events/{event_id}/checkin` (`stationCheckin`) body `{attendee_id, station_id?}` → 200 `{outcome: "checked_in"|"already_checked_in"|"blocked", attendee, checkin: {at, by_email, point_name}|null}` (the handler resolves `blocked` from the pre-fetched attendee; `checkin` block = first-scan metadata, null for `blocked`); 404 attendee-not-found; 400 foreign station. `POST /api/events/{event_id}/checkin/undo` (`undoCheckin`) body `{attendee_id, station_id?}` → 200 `{attendee}`. `GET /api/events/{event_id}/checkin-actions?limit=50` (`getCheckinActions`, default+max 50) → `{actions: [...]}`. + +- [x] **Step 1: openapi** — three ops + `CheckinOutcome` enum + `CheckinActionRow`/`CheckinActionAttendee` schemas + the `{outcome, attendee, checkin}` response. +- [x] **Step 2: Failing contract + pgxmock tests.** Contract (fakeStore-backed): fresh attendee → `checked_in` + a feed row exists via the list; repeat → `already_checked_in`, NO new feed row, `checkin` carries the ORIGINAL metadata; blocked attendee → `blocked`, never checked in; unknown attendee → 404; undo a checked-in → cleared + feed `undo` row; undo an already-clear → 200 no row; foreign station → 400; actions list newest-first + limit honored. pgxmock: the guarded check-in UPDATE (regex incl. `checkin_status = false`), the 0-row already path, the feed INSERT, the undo UPDATE. +- [x] **Step 3:** Implement (handler order: parse → `requireEventOwnership` → fetch attendee via `requireAttendeeOwnership` (404-mask) → if `attendee.blocked` return `blocked` → else `CheckInAttendee`). Verify gates. +- [x] **Step 4: Commit.** `feat(backend): idempotent station check-in, undo, actions feed` + +--- + +### Task 4: Backend — reprint feed row on /printed + +**Files:** +- Modify: `backend/internal/handler/attendee_printed.go` (+ its contract/pgxmock tests), `backend/internal/store/interface.go`, `pg_store.go`, `openapi.yaml` +- Regenerate: `panel/src/shared/api/schema.d.ts` (commit) + +**Interfaces:** +- Consumes: Task 3's `checkin_actions` insert. +- Produces: the printed endpoint gains an OPTIONAL JSON body `{event_id?: uuid, station_id?: uuid}` — when `event_id` present, after the counter increment succeeds, INSERT a `checkin_actions` (`reprint`) row (`store.InsertCheckinAction(ctx, eventID, attendeeID uuid.UUID, action string, stationID *uuid.UUID, staffUserID uuid.UUID) error`, reused by Task 3 too — extract it there and consume here). Absent `event_id` → counter-only (back-compat: the badge-editor bulk print sends no context). The 200 response is unchanged (`{printed_count}`). + +- [x] **Step 1: openapi** — document the optional body + the reprint-logging prose. +- [x] **Step 2: Failing contract tests.** POST with `{event_id, station_id}` → counter bumps AND a `reprint` feed row appears (assert via the Task 3 actions list on the same fakeStore); POST with no body → counter bumps, NO feed row (back-compat); malformed body → still counts (lenient) OR 400 (implementer picks — document; prefer lenient-ignore of unknown, 400 only on a present-but-bad uuid). +- [x] **Step 3:** Implement; regen `schema.d.ts` (commit it). Verify gates + panel typecheck. +- [x] **Step 4: Commit.** `feat(backend): log reprint as a checkin_actions row` + +--- + +### Task 5: Panel — check-in data layer + +**Files:** +- Create: `panel/src/features/checkin/hooks.ts`, `hooks.test.tsx`, `panel/src/features/checkin/settingsTypes.ts`, `settingsTypes.test.ts` +- Regenerate (if drift remains after Task 4): `panel/src/shared/api/schema.d.ts` + +**Interfaces:** +- Consumes: Tasks 1–4 generated types. +- Produces: `settingsTypes.ts`: `CheckinSettings = {print_on_checkin: boolean; verdict_auto_dismiss_sec: number; scan_input: "wedge"|"scanner"|"manual"; manual_search_enabled: boolean}`, `DEFAULT_CHECKIN_SETTINGS` (`{print_on_checkin:true, verdict_auto_dismiss_sec:4, scan_input:"wedge", manual_search_enabled:true}`), `parseCheckinSettings(raw: unknown): CheckinSettings` (null/missing → defaults, per-field). `hooks.ts`: `useCheckinSettings(eventId)` (GET; `.select` → `parseCheckinSettings`), `useSaveCheckinSettings(eventId)` (PUT), `useCheckinStations(eventId)`, `useRegisterStation(eventId)`, `useStationHeartbeat(eventId)`, `useCheckinActions(eventId, limit=50)`, `useStationCheckin(eventId)`, `useUndoCheckin(eventId)`; key helpers `CHECKIN_SETTINGS_KEY`/`CHECKIN_STATIONS_KEY`/`CHECKIN_ACTIONS_KEY(eventId)`. + +- [x] **Step 1: Failing tests.** `parseCheckinSettings`: null → defaults; partial → per-field defaults; out-of-range dismiss → clamp-or-default (document). Hooks: MSW URL/param capture per hook; actions key prefix-invalidation. +- [x] **Step 2:** Implement. Verify. **Step 3: Commit.** `feat(panel): check-in data layer + settings types` + +--- + +### Task 6: Panel — verdict rendering + check-in flow + +**Files:** +- Create: `panel/src/features/checkin/verdict.ts`, `verdict.test.ts`, `panel/src/features/checkin/useCheckinFlow.ts`, `useCheckinFlow.test.tsx` +- Modify: `panel/src/features/badge/zpl/usePrintBadge.ts` (+ its test — add `printContext` to `PrintAttendeeOptions`, forward to the `/printed` body), `panel/src/shared/i18n/en.json`, `ru.json` + +**Interfaces:** +- Consumes: `useStationCheckin` (Task 5), `usePrintBadge` (P3.2), `CheckinSettings` (Task 5), `@idento/ui` `verdictClasses`. +- Produces: `verdict.ts`: `outcomeToVerdict(outcome: "checked_in"|"already_checked_in"|"blocked"|"not_found"): Verdict` mapping (`checked_in`→`allowed`, `already_checked_in`→`already_checked_in`, `blocked`→`no_access`, `not_found`→`not_registered`) — reuse `verdictClasses`, add no colors. `useCheckinFlow({eventId, stationId, settings, printerName})` → `{state: {status: "idle"|"resolving"|"verdict"; verdict?; attendee?; checkin?}, submitCode(code), submitAttendee(attendee), clear()}` — `submitCode` looks up via `GET …/attendees?code=` (empty → `not_found` verdict), then calls `useStationCheckin`; on `checked_in` AND `settings.print_on_checkin` fires `usePrintBadge.printAttendee(attendee, printerName, {printContext:{eventId, stationId}})` (print failure surfaces but does NOT undo the check-in — the person is in); NEVER prints on `already_checked_in`/`blocked`; auto-dismiss timer = `settings.verdict_auto_dismiss_sec` → `clear()`; invalidates `CHECKIN_ACTIONS_KEY` on every check-in/undo. +- Also: extend P3.2 `PrintAttendeeOptions` with `printContext?: {eventId: string; stationId: string|null}` forwarded to the `/printed` body (Task 4's optional fields). Update `usePrintBadge`'s printed POST to include them when present. (Small P3.2 touch — cite Task 4.) + +- [x] **Step 1: Failing tests.** `outcomeToVerdict` all four. Flow: fresh code → `checked_in` verdict + print called with printContext; repeat → `already_checked_in`, print NOT called; blocked → `blocked`, no print; unknown code → `not_found`, no check-in call; `print_on_checkin:false` → no print on success; auto-dismiss returns to idle after the configured seconds (fake timers); print failure keeps the `checked_in` verdict. +- [x] **Step 2:** Implement. Verify. **Step 3: Commit.** `feat(panel): check-in verdict mapping and flow hook` + +--- + +### Task 7: Panel — scan input modes + +**Files:** +- Create: `panel/src/features/checkin/useScanInput.ts`, `useScanInput.test.tsx`, `panel/src/features/checkin/ScanInput.tsx`, `ScanInput.test.tsx` +- Modify: `panel/src/shared/i18n/en.json`, `ru.json` + +**Interfaces:** +- Consumes: `agentClient` (P3.2 — add a `getLastScan()`/`clearLastScan()` pair mirroring its existing methods, hitting the agent `/scan/last`/`/scan/clear`; if absent extend `agentClient`), `useCheckinFlow.submitCode`/`submitAttendee`, `useAttendeesPage`/`?search=` for manual search. +- Produces: `useScanInput({mode: "wedge"|"scanner"|"manual", onCode(code), enabled})` — `wedge`: a focused hidden input; on Enter, emit the buffered value + clear + refocus. `scanner`: single 200ms interval polling `agentClient.getLastScan()`; dedup by `{code, time}` last-handled (never double-consume — guard on a monotonic last-handled ref, then `clearLastScan()`); agent-unreachable → returns `{degraded: true}` so the UI can hint + fall back to manual. `manual`: no auto-input (the search box drives `submitAttendee`). `ScanInput.tsx` renders the mode-appropriate affordance + the always-present manual search box (name/email/code via `?search=`, debounced, pick → `submitAttendee`). + +- [x] **Step 1: Failing tests.** wedge: keystrokes+Enter → `onCode` once, input cleared; scanner: MSW agent `/scan/last` returns a code → `onCode` once, `/scan/clear` called, a second poll with the SAME code+time does NOT re-emit; scanner agent-error → degraded flag; manual: typing → debounced `?search=` request, pick → `submitAttendee`; manual search present in all three modes. +- [x] **Step 2:** Implement. Verify. **Step 3: Commit.** `feat(panel): scan input modes (wedge, scanner, manual)` + +--- + +### Task 8: Panel — station route + split layout + verdict panel + +**Files:** +- Create: `panel/src/features/checkin/StationPage.tsx`, `StationPage.test.tsx`, `panel/src/features/checkin/VerdictCard.tsx` +- Modify: `panel/src/app/router.tsx` (add `eventCheckinRoute` as a TOP-LEVEL protected route — sibling of `eventWorkspaceRoute` in `protectedLayoutRoute.addChildren`, path `/events/$eventId/checkin`, rail-less), `panel/src/shared/i18n/en.json`, `ru.json` + +**Interfaces:** +- Consumes: `useCheckinFlow` (Task 6), `useScanInput`/`ScanInput` (Task 7), `useCheckinSettings` (Task 5), `getRouteApi("/_app/events/$eventId/checkin")` for params + a `?station=` search param (the station id, set by the ceremony; validated), `verdictClasses`. +- Produces: the near-fullscreen split layout (main verdict panel + a placeholder rail region Task 9 fills), a top bar (event name, station name, "← Exit" back to workspace), the verdict panel via `VerdictCard` (the four outcomes through `verdictClasses`; `already_checked_in` shows first-scan metadata; auto-dismiss). Missing/invalid `?station=` → redirect to the launch ceremony (you can't run a station without registering). `router.tsx` regression guard: only the new route added. **Verify the standalone route resolves** (`/events/$eventId/checkin` must render `StationPage` rail-less, NOT fall through to `eventWorkspaceRoute`'s layout) — the routed test harness asserting the split layout renders without the workspace rail is the proof; if TanStack matches the workspace route instead, the route needs an explicit non-child registration (it is already a sibling of `eventWorkspaceRoute`, so a full-path match should win). + +- [x] **Step 1: Failing tests** (routed harness per `AttendeesPage.test.tsx`): renders split + verdict panel; a wedge scan of a known code shows the `checked_in` verdict card with the mapped `verdictClasses`; already → blue card + first-scan line; not_found → muted card; missing `?station=` → redirect to `/checkin/launch`; rail region present (placeholder). Router guard untouched. +- [x] **Step 2:** Implement. Verify. **Step 3: Commit.** `feat(panel): check-in station route, split layout, verdict card` + +--- + +### Task 9: Panel — recent-scans rail + +**Files:** +- Create: `panel/src/features/checkin/RecentScansRail.tsx`, `RecentScansRail.test.tsx` +- Modify: `StationPage.tsx` (mount), `panel/src/shared/i18n/en.json`, `ru.json` + +**Interfaces:** +- Consumes: `useCheckinActions(eventId)` (Task 5), `usePrintBadge` (reprint with `printContext`), `useUndoCheckin` (Task 5), the P3.2 agent reachability (`useAgentPrinters`) for reprint gating. +- Produces: the 296px rail: last-50 rows (attendee name + code + action label + time), per-row **Reprint** (reachability-gated; `usePrintBadge.printAttendee(attendee, printer, {printContext})`; the dialog blocks dismissal while sending — P3.2 convention), **Undo** (tier-1 confirm → `useUndoCheckin`; both invalidate `CHECKIN_ACTIONS_KEY` + `ATTENDEES_LIST_KEY`), **Details** (a compact popover: name/code/first-scan metadata — NOT the full attendee drawer, YAGNI). Rail refetches on the station's own check-in/undo/reprint (P4.2 makes it live via SSE). + +- [x] **Step 1: Failing tests.** Rail lists actions newest-first; reprint row → agent print body captured with `printContext`, dismissal blocked while sending; undo row → confirm → undo POST + both invalidations (subscribed observers); details popover shows first-scan; reprint disabled when agent disconnected. +- [x] **Step 2:** Implement. Verify. **Step 3: Commit.** `feat(panel): recent-scans rail with reprint/undo/details` + +--- + +### Task 10: Panel — degraded mode + +**Files:** +- Create: `panel/src/features/checkin/useConnectionState.ts`, `useConnectionState.test.tsx` +- Modify: `StationPage.tsx` (+test), `ScanInput.tsx` (read-only search retained), `panel/src/shared/i18n/en.json`, `ru.json` + +**Interfaces:** +- Consumes: the check-in/actions query error states + `navigator.onLine`. +- Produces: `useConnectionState(eventId)` → `{online: boolean}` derived from a lightweight signal (the actions/settings query `isError` after a retry + `navigator.onLine`, debounced to avoid flapping). Degraded: the amber "Connection is unstable" banner (board 2d copy `checkinDegradedBanner`), check-in/undo/reprint DISABLED (the verdict panel shows an explicit `checkinOfflineBlocked` state instead of silently dropping a scan), **read-only manual search of the already-loaded roster cache stays available** (look someone up, no check-in button), auto-recover + re-enable on reconnect. + +- [x] **Step 1: Failing tests.** Offline signal → banner shown, check-in submit is inert (no POST fired, an offline verdict shown instead), manual search still returns cached results (no check-in CTA), reconnect → banner gone + actions re-enabled. +- [x] **Step 2:** Implement. Verify. **Step 3: Commit.** `feat(panel): station degraded mode (banner, read-only search, recovery)` + +--- + +### Task 11: Panel — launch ceremony + workspace unlock + +**Files:** +- Create: `panel/src/features/checkin/LaunchCeremony.tsx`, `LaunchCeremony.test.tsx` +- Modify: `panel/src/app/router.tsx` (add `eventCheckinLaunchRoute` top-level protected, path `/events/$eventId/checkin/launch`, rail-less), `panel/src/features/workspace/WorkspaceRail.tsx` (+test — the launch CTA), `panel/src/features/workspace/WorkspaceOverview.tsx` (+test — launch row if present), `panel/src/shared/i18n/en.json`, `ru.json` + +**Interfaces:** +- Consumes: `useEventReadiness(eventId)` (`.data?.ready`), `useCheckinSettings`/`useSaveCheckinSettings` (Task 5), `useEventZones` (zone picker), `useRegisterStation`/`useStationHeartbeat` (Task 5), `useAgentPrinters`/`usePrintBadge` (P3.2 printer check). +- Produces: the 3-column ceremony (board 2a): col 1 confirm event + station-name input (default suggested) + zone picker; col 2 the four settings editable (scoped PUT); col 3 agent printer status + a "Test badge" action (reuse the P3.2 test-print flow against a sample/preview attendee); the "Start check-in" CTA disabled while `!ready` (explanatory copy) → on click: register the station (upsert by name → id), navigate to `/events/$eventId/checkin?station=`. Workspace 1f: the badge/attendees/etc. rail is unchanged; the launch ceremony is a pinned CTA at the rail bottom (board 1f) linking to `/checkin/launch` — enabled only when `ready`. Overview: a launch row if the board shows one (else skip — YAGNI). + +- [x] **Step 1: Failing tests.** Ceremony renders 3 columns; settings edit → PUT; CTA disabled when readiness `ready:false`, enabled when true; Start → register POST (upsert body `{name, zone_id}`) then navigation to the station with `?station=`; workspace rail launch CTA gated on `ready`. Router guard untouched. +- [x] **Step 2:** Implement. Verify. **Step 3: Commit.** `feat(panel): launch ceremony, station registration, workspace launch CTA` + +--- + +### Task 12: Panel — heartbeat lifecycle + +**Files:** +- Create: `panel/src/features/checkin/useHeartbeat.ts`, `useHeartbeat.test.tsx` +- Modify: `StationPage.tsx` (mount the heartbeat), `panel/src/shared/i18n/en.json`, `ru.json` (only if copy lands) + +**Interfaces:** +- Consumes: `useStationHeartbeat(eventId)` (Task 5), the `?station=` id. +- Produces: `useHeartbeat(eventId, stationId)` — posts a heartbeat immediately on mount then every 20s (`setInterval`), cleared on unmount; a failed heartbeat is non-fatal (retried next tick, surfaced only via the degraded signal if persistent). Mounted by `StationPage`. + +- [x] **Step 1: Failing tests** (fake timers): mount → immediate heartbeat POST; advance 20s → second POST; unmount → interval cleared (no further POST); a 500 heartbeat does not throw/unmount. +- [x] **Step 2:** Implement. Verify. **Step 3: Commit.** `feat(panel): station heartbeat lifecycle` + +--- + +### Task 13: Panel — i18n sweep + final verification + +- [x] **Step 1: i18n sweep.** Every `checkin*`/`station*`/`launch*` key referenced; en/ru parity (`keyParity`); no hardcoded user-facing strings in touched files. +- [x] **Step 2: Full gates.** `npm run typecheck -w panel && npm test -w panel`; `cd panel && npx eslint .`; `npm run build -w panel`; `npm test -w packages/ui` (untouched-green); `npm run generate:api -w panel` → zero drift beyond committed regen; `cd backend && OPENAPI_COVERAGE=1 go test ./... -count=1` + `golangci-lint run ./internal/...`. +- [x] **Step 3: Cross-checks.** `git diff main -- panel/src/app/router.tsx` shows ONLY the two new routes; `git diff main --stat -- backend/` = the P4.1 backend files only; web/ untouched (`git diff main -- web/` empty). +- [x] **Step 4: Spec walk.** §3.1–§3.4 (endpoints), §4 (station + 3 scan modes + degraded), §5 (ceremony + readiness lock + heartbeat), §6 (zero-double-print, no-scan-lost), §7 (tests) — each has a task or a documented reconciliation. Mark plan checkboxes; append the phase ledger entry. +- [x] **Step 5: Commit.** `chore(panel): P4.1 final verification sweep` + +--- + +## Self-review notes + +- Spec §3.1 → Task 3; §3.2 → Task 1; §3.3 → Task 2; §3.4 → Tasks 3 (insert helper) + 4 (reprint). §4 station → Tasks 6–10; §4 scan modes → Task 7; §4 degraded → Task 10. §5 ceremony → Task 11; readiness lock frontend-only → Task 11; heartbeat → Task 12. §6 correctness → Tasks 3 (guard) + 6 (print-only-on-checked_in). §7 tests → per-task + Task 13. +- Names consistent: `CheckInAttendee`/`UndoCheckin`/`GetCheckinActions`/`UpsertCheckinStation`/`HeartbeatCheckinStation`/`InsertCheckinAction`/`GetCheckinSettings`/`UpdateCheckinSettings`; `useCheckinFlow`/`useScanInput`/`useCheckinActions`/`useRegisterStation`/`useStationHeartbeat`/`useHeartbeat`/`outcomeToVerdict`/`parseCheckinSettings`/`DEFAULT_CHECKIN_SETTINGS`/`CHECKIN_ACTIONS_KEY`; outcomes `checked_in|already_checked_in|blocked|not_found`; `printContext` on `PrintAttendeeOptions`. +- Deliberate scope guards: no SSE (P4.2 — actions feed is polled here), no equipment/device-registry (P4.3), no camera, no offline queue, no server readiness gate, no touching web/ or the legacy check-in writers, reprint logging via one shared `InsertCheckinAction` (not a separate journal). diff --git a/docs/superpowers/specs/2026-07-17-panel-p4.1-checkin-loop-design.md b/docs/superpowers/specs/2026-07-17-panel-p4.1-checkin-loop-design.md new file mode 100644 index 00000000..6c40e476 --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-panel-p4.1-checkin-loop-design.md @@ -0,0 +1,232 @@ +# Panel P4.1 — Check-in loop: launch ceremony, station, idempotent check-in + +**Date:** 2026-07-17 · **Status:** approved +**Parent spec:** `docs/superpowers/specs/2026-07-13-panel-rewrite-design.md` (§5 P4, backend #2/#3) +**Board extract:** `.superpowers/sdd/p4.1-board-2a-2c-2d-extract.md` (screens 2a launch, 2c station split-rail WINNER, 2d desk/degraded mode, 2b verdict legend; shared 1a/1b) +**Predecessors:** P0–P3 all merged (badge editor + agent printing land in P3.1/P3.2 — this phase reuses `usePrintBadge`). + +## 1. Decomposition (P4 → three sub-cycles; this is P4.1) + +P4 ("event day") splits into three spec→plan→PR cycles (user decision): + +- **P4.1 "Check-in loop"** (this spec) — branch `panel/p4.1-checkin-loop`. Backend #2 (server check-in + settings + station registration/heartbeat) + backend #3 (idempotent check-in, undo, reprint-as-logged- + action) + the launch ceremony 2a + the check-in station 2c/2d. Station registration and the + `checkin_actions` feed land here so P4.2's monitor can consume them. +- **P4.2 "Live monitor & SSE"** — tablet monitor 7e + backend #4 (SSE stream + snapshot); upgrades the + Home live-strip from polling to SSE. Consumes P4.1's stations + actions feed. +- **P4.3 "Equipment hub"** — 5a–5d + backend #5 (device registry keyed by agent machine id). + +## 2. Constraints carried forward (locked in P0–P3, enforced in review) + +- openapi-first for every new/changed endpoint: `backend/openapi.yaml` → handler → kin-openapi contract + test (`validateResponse` + coverage ledger under `OPENAPI_COVERAGE=1`) → `npm run generate:api -w panel`, + CI drift-checks committed `schema.d.ts`. Backend gates: `OPENAPI_COVERAGE=1 go test ./... -count=1` AND + `golangci-lint run ./internal/...`. pgxmock tests assert REAL SQL text (P2.1 lesson). +- Panel: `$api` for backend; the print agent stays behind the hand-typed `agentClient` (P3.2, different + origin, Origin-allowlist path); MSW for all HTTP tests (backend AND agent origins); `getRouteApi`; + `router.tsx` regression guard (`/register` `beforeLoad`/`protectedBeforeLoad` byte-for-byte). +- i18n EN+RU keyParity, feature-prefixed flat keys, real Russian; no fabricated data (loading → Skeleton, + error ≠ empty); WCAG 1.4.1 (verdict = icon+text+color, never color alone); `@idento/ui` primitives, + token classes only. +- Verdict visual vocabulary is the existing `@idento/ui` set — `VERDICTS = ["allowed","no_access",` + `"not_registered","already_checked_in"]` + `verdictClasses` (color/icon families). Do NOT invent new + colors: the station's four entrance-check-in outcomes reuse these families — `checked_in` → `allowed` + (green), `already_checked_in` → `already_checked_in` (blue), `blocked` → `no_access` (red), + `not_found` → `not_registered` (muted). The outcome NAMES are entrance-check-in-specific (the API + returns `checked_in`/`already_checked_in`/`blocked`, plus a client `not_found`), mapped to the + vocabulary at render time. +- Print reuses P3.2's `usePrintBadge(eventId)` (raw template → generate → agent print → mark-printed). + Print honesty: agent `/print` 200 is a transport ack; success copy says "sent to printer". +- Mutation hygiene: session-id refs, unconditional invalidation, exhaustive busy-gating, captured-eventId + guards where navigation can outrun a settling call. Dialogs that drive physical output BLOCK dismissal + while in flight (the P3.2 PR-#74 lesson — one dismissal convention for print/print-adjacent dialogs). +- **web/ is frozen** — its kiosk (`CheckinFullscreen.tsx`), its localStorage `checkin_settings`, and the + legacy `PUT /api/attendees/{id}` / `POST /api/sync` writers are NOT touched. Panel builds its own path. + +## 3. Backend additions (all P4.1; openapi-first; next migration = `000019`) + +### 3.1 Idempotent check-in + +Today no DB-safe synchronous event check-in exists: `PUT /api/attendees/{id}` (the legacy path) is an +unguarded full-row UPDATE — race-prone, print-suppression client-side only. The mobile +`ApplyBatchCheckin` already carries the correct pattern; P4.1 gives the panel a **new synchronous +single-scan endpoint** with the same guard. + +- **`POST /api/events/{event_id}/checkin`** (operationId `stationCheckin`) body + `{attendee_id: uuid, station_id: uuid|null}`: + - store `CheckInAttendee(ctx, eventID, attendeeID, stationID, staffUserID) (outcome, *Attendee, error)` + — guarded `UPDATE attendees SET checkin_status = true, checked_in_at = now(),` + `checked_in_by = $staff, checked_in_by_email = $email, checked_in_point_name = $station_name,` + `updated_at = now() WHERE id = $id AND event_id = $ev AND checkin_status = false AND deleted_at IS NULL` + `RETURNING …`. `RowsAffected() == 1` → outcome `checked_in`; `== 0` → re-`SELECT` the row (still + checked in) → outcome `already_checked_in` with its EXISTING first-scan metadata (never overwritten). + Row genuinely missing → `store.ErrAttendeeNotFound`. + - response 200 `{outcome: "checked_in" | "already_checked_in", attendee: Attendee, checkin: {at, by_email,` + `point_name}}` (the `checkin` block is the first-scan metadata — for `checked_in` it's this scan; for + `already_checked_in` it's the original scan). A blocked attendee (`attendee.blocked`) → 200 with a + distinct outcome `blocked` (carries `block_reason`) — NOT an error (the station renders it as a + destructive verdict, board 2c/2d). + - `requireEventOwnership` first (404-masked); `station_id` (when present) validated against the event + (400 if foreign); ownership+existence give the row before the guarded write. + - **Never touches `printed_count`, never prints.** Printing is a separate client step (§4.1) that fires + ONLY on the `checked_in` outcome — this is the zero-double-print guarantee at the source. + - On the `checked_in` outcome the store also inserts a `checkin_actions` row (§3.4) in the same + transaction (the durable feed / audit). + +- **`POST /api/events/{event_id}/checkin/undo`** (operationId `undoCheckin`) body `{attendee_id, station_id}` + → clears `checkin_status`/`checked_in_at`/`checked_in_by`/`checked_in_by_email`/`checked_in_point_name` + (fixes the legacy path's incomplete clear — it left `point_name`), writes a `checkin_actions` row + (`action: "undo"`), returns 200 `{attendee}`. Idempotent (already-not-checked-in → 200, no-op row not + written). tier-1 confirm in the UI (board shows Undo as an explicit per-row action). + +### 3.2 Server check-in settings + +Three unsynchronized settings silos exist (web-kiosk localStorage, desktop-kiosk localStorage, +`EventSettings.custom_fields.badgeTypeField`). P4.1 introduces the server-side settings the PANEL uses, +mirroring P3.1's `badge_template` column pattern (do NOT reuse the `custom_fields` blob). + +- Migration `000019`: `events.checkin_settings JSONB NULL`. +- **`GET /api/events/{id}/checkin-settings`** → `{settings: object | null}` (null = never set; the panel + applies documented defaults). **`PUT`** body `{settings: object}` → validates the shape, stores + verbatim, 200 `{settings}`. No version/optimistic-concurrency (operator-only config, no multi-tab + conflict risk like the badge editor — YAGNI on the 409 pattern). +- Settings shape (typed both sides): `{ print_on_checkin: boolean, verdict_auto_dismiss_sec: number` + `(1–30), scan_input: "wedge" | "scanner" | "manual", manual_search_enabled: boolean }`. Defaults: + `{print_on_checkin: true, verdict_auto_dismiss_sec: 4, scan_input: "wedge", manual_search_enabled: true}` + (board 2a shows "verdict auto-dismiss 4 s", "scan input Scanner", both toggles). + +### 3.3 Check-in station registration & heartbeat + +The existing `stations` table is mobile-provisioning-specific (1:1 staff-user binding via one-time token, +no name/zone/heartbeat) — NOT reusable. New concept: + +- Migration `000019`: `CREATE TABLE checkin_stations (id UUID PK, event_id UUID FK→events ON DELETE` + `CASCADE, name TEXT NOT NULL, zone_id UUID NULL FK→event_zones ON DELETE SET NULL, last_seen_at` + `TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),` + `UNIQUE(event_id, name))`. +- **`POST /api/events/{event_id}/checkin-stations`** (operationId `registerCheckinStation`) body + `{name, zone_id?}` → **upsert by (event_id, name)** (`ON CONFLICT (event_id, name) DO UPDATE SET` + `zone_id = EXCLUDED.zone_id, last_seen_at = now()`) → 200 `{station}`. Upsert-by-name means a station + survives reload / a different operator reopening the same-named station (persistent identity, spec #2). +- **`POST /api/events/{event_id}/checkin-stations/{id}/heartbeat`** → `UPDATE … SET last_seen_at = now()` + → 204. The station page heartbeats every 20s while open. +- **`GET /api/events/{event_id}/checkin-stations`** → `{stations: [...]}` (id, name, zone_id, last_seen_at) + — this task ships the endpoint; P4.2's monitor consumes it (liveness = last_seen within a window the + monitor decides). +- `requireEventOwnership` on all; admin|manager|staff may register/heartbeat (any event-day operator). + +### 3.4 Check-in actions feed (durable recent-scans + undo/reprint audit) + +One table serves three needs: the durable source for the station's last-50 rail (survives reload / operator +change — board 2c), the "logged action" record for undo + reprint (spec #3), and P4.2's monitor +recent-scans feed. + +- Migration `000019`: `CREATE TABLE checkin_actions (id UUID PK, event_id UUID FK→events ON DELETE` + `CASCADE, attendee_id UUID FK→attendees ON DELETE CASCADE, station_id UUID NULL FK→checkin_stations` + `ON DELETE SET NULL, action TEXT NOT NULL CHECK (action IN ('checkin','undo','reprint')),` + `staff_user_id UUID NULL FK→users, created_at TIMESTAMPTZ NOT NULL DEFAULT now())`; index on + `(event_id, created_at DESC)`. +- Rows written by: the check-in endpoint (`checkin`, §3.1), the undo endpoint (`undo`, §3.1), and a + reprint action endpoint **`POST /api/attendees/{attendee_id}/printed`** — this P3.2 counter endpoint is + extended to ALSO write a `checkin_actions` row (`action: "reprint"`, with event_id/station_id from the + body) so a reprint is genuinely logged, not just counted. (Back-compat: the body's new `event_id`/ + `station_id` are optional; when absent — e.g. the badge-editor bulk print — only the counter bumps, no + feed row. The station always sends them.) +- **`GET /api/events/{event_id}/checkin-actions?limit=50`** (operationId `getCheckinActions`) → + `{actions: [{id, attendee: {id, first_name, last_name, code}, action, station_id, created_at}]}` — + newest first, joined to a slim attendee projection for the rail. The station polls this (or refetches + on its own mutations) for the last-50 rail; P4.2 upgrades it to the SSE feed. + +## 4. Check-in station UI (P4.1; board 2c winner + 2d mode) + +Route `/events/$eventId/checkin` — a focused near-fullscreen surface launched from the workspace after the +ceremony (its own layout, not the rail-shell; a "← Exit" returns to the workspace). Split layout: + +- **Main verdict panel** (left, flex): the scan-input affordance + the large verdict card. The four + outcomes render through `verdictClasses` (§2 mapping): `checked_in` → `allowed` green; `already_checked_in` + → `already_checked_in` blue with first-scan metadata ("First check-in at HH:MM · {{station}} · badge + already printed"); `blocked` → `no_access` red with `block_reason`; `not_found` (no attendee for the + scanned code — a client-side outcome, the lookup returned empty) → `not_registered` muted. Auto-dismiss + after `verdict_auto_dismiss_sec` (from settings) back to idle. Every scan resolves to a visible verdict — + never silently dropped. +- **Recent-scans rail** (right, 296px per board): last 50 from `GET …/checkin-actions` (server feed), each + row = attendee name + code + action + time; per-row **Reprint** (P3.2 `usePrintBadge`, reachability- + gated), **Undo** (tier-1 confirm → undo endpoint), **Details** (opens the attendee drawer? or a compact + popover — YAGNI: a compact popover with name/code/first-scan, no full drawer). Rail refetches on the + station's own check-in/undo/reprint mutations (P4.2 makes it live via SSE). +- **Scan input — three modes (from `settings.scan_input`), manual search always available:** + - `wedge` — a focused hidden input catches scanner keystrokes; Enter → look up by code via + `GET /api/events/{id}/attendees?code=…` (the existing scalable server exact-match — NOT the whole + roster) → check-in. Refocus discipline (the input stays focused so a scan any time works). + - `scanner` — poll the agent's `/scan/last` (agentClient pattern, dedup by a fresh-scan heuristic; + the agent has no consumed-flag, so a client-side last-seen guard + `/scan/clear` as web does, but + hardened against the 200ms double-consume — poll on a single interval, guard by scan time + a local + "last handled code+time"). Agent-unreachable → the mode degrades to manual with a hint. + - `manual` — a search box (name/email/code via `?search=`) → pick → Check in button. This is 2d's + search-first desk mode; it is ALSO the always-present fallback in the other two modes. +- **Degraded mode (2d):** on backend unreachability (a health signal — reuse the query error state / + `navigator.onLine` + a lightweight ping), show the amber "Connection is unstable" banner (board copy), + DISABLE check-in/undo/reprint actions (never silently drop a scan → the verdict panel shows a "can't + check in — offline" state instead), keep **read-only search** of the already-loaded roster cache + (look someone up), auto-recover + re-enable when connectivity returns. No offline queue (kiosks own it). + +## 5. Launch ceremony (P4.1; board 2a) + +Reached from the workspace (1f pins "launch ceremony" at the bottom; the CTA is locked until +`readiness.ready`). Board 2a = a 3-column ceremony, all-ready state: + +- **Col 1 — Confirm event & station:** the event name (live badge), a **station name** input (defaults to + a suggested name; persists), a **zone** picker (optional — the entrance zone the station is bound to). +- **Col 2 — Station settings (FROM SERVER):** the four `checkin-settings` (§3.2) rendered editable — + print-on-checkin toggle, verdict auto-dismiss (seconds), scan input (wedge/scanner/manual segmented), + manual-search toggle. Edits PUT the settings (scoped save, GeneralCard pattern). +- **Col 3 — Printer check:** the P3.2 agent status + a "Test badge" action (reuses the test-print flow / + `usePrintBadge` against the current preview attendee or a sample) — confirms the physical path before + going live. Reachability-gated. +- **CTA "Start check-in":** disabled while `readiness.ready === false` (frontend lock — the backend + `ready` flag stays display-only per its current semantics; no server gate on the check-in endpoint, + YAGNI — mobile/desktop kiosks check in without such a gate today). On click: register the station + (§3.3, upsert by name → station id), start the heartbeat, navigate to `/events/$eventId/checkin`. +- Only the all-ready state is on the board; the not-ready lock is inherited from 1f (an explanatory + disabled CTA + the readiness rail showing what's missing). + +## 6. Correctness & data flow + +- **Zero double-print** (success metric): the guarded UPDATE makes `already_checked_in` a server fact; + print fires only on the `checked_in` outcome. A repeat scan of the same badge → `already_checked_in` + → no print, no counter bump. Concurrent scans from two stations → the DB guard makes exactly one win. +- **No scan silently lost** (success metric): every scan produces a visible verdict AND (on check-in) a + feed row; degraded mode blocks with an explicit banner + offline verdict rather than dropping. +- **Print-on-checkin lifecycle:** on `checked_in`, if `settings.print_on_checkin`, call `usePrintBadge` + → on success POST `/printed` with `{event_id, station_id}` (logs the implicit print + bumps counter); + a manual "Print" affordance covers the `manual_print`-style flow. Reprint (rail) is always explicit. +- **Station heartbeat:** register on ceremony-start, heartbeat every 20s while the station page is + mounted, stop on unmount/exit. The monitor (P4.2) decides liveness windows. +- **Settings apply live:** changing `verdict_auto_dismiss_sec` / `scan_input` on the ceremony re-PUTs; + the station reads the current settings on mount. + +## 7. Testing + +- Contract tests (openapi coverage green): §3.1 check-in (checked_in / already_checked_in / blocked / + not-found / undo idempotent), §3.2 settings (GET null + defaults, PUT round-trip + shape validation), + §3.3 station register upsert-by-name + heartbeat 204 + list, §3.4 actions feed (write on checkin/undo, + reprint-with-event-id writes a row, reprint-without stays counter-only, GET limit/order). pgxmock + SQL-text for the guarded check-in UPDATE (incl. the 0-row already-checked-in path) + the upsert + + the feed insert. +- Panel: MSW per surface — the check-in state machine (all verdicts + auto-dismiss + zero-reprint on + already), the three scan-input modes (wedge keystroke→lookup, scanner poll dedup, manual search via + server param not roster), degraded mode (blocks actions, keeps read-only search, recovers), the rail + (server feed, per-row reprint/undo, refetch on own mutations), the ceremony (settings PUT, station + register→heartbeat start, readiness-gated CTA). Established race patterns on every mutation dialog. +- keyParity EN/RU; Playwright e2e (scan→verdict happy path) stays a P5 deliverable per the parent spec. + +## 8. Out of scope (P4.1) + +Live monitor + SSE (P4.2 — but station registration + the `checkin_actions` feed land here for it to +consume); equipment hub + device registry (P4.3); camera scan (backlog — the board's camera toggle is +vestigial even in web); offline check-in queue (kiosks own it — panel degrades to banner + read-only +search); touching web/'s kiosk, its localStorage silos, or the legacy `PUT /api/attendees/{id}` / +`POST /api/sync` writers (web is frozen); server-side readiness enforcement of the launch lock (frontend- +only); a full print-audit journal beyond the `checkin_actions` reprint row (the parent spec keeps journals +out of scope). diff --git a/panel/src/app/router.tsx b/panel/src/app/router.tsx index 24185352..cb277a5a 100644 --- a/panel/src/app/router.tsx +++ b/panel/src/app/router.tsx @@ -13,6 +13,9 @@ import { WorkspaceOverview } from "../features/workspace/WorkspaceOverview"; import { EventSettingsPage } from "../features/workspace/settings/EventSettingsPage"; import { BadgeEditorPage } from "../features/badge/BadgeEditorPage"; import { OrganizationPage } from "../features/organization/OrganizationPage"; +import { StationPage } from "../features/checkin/StationPage"; +import { checkinStationBeforeLoad, validateCheckinStationSearch } from "../features/checkin/searchParams"; +import { LaunchCeremony } from "../features/checkin/LaunchCeremony"; import { PlaceholderPage } from "../shared/ui/PlaceholderPage"; import { getInstance } from "../shared/api/client"; import { queryClient } from "./queryClient"; @@ -135,6 +138,38 @@ const eventBadgeRoute = createRoute({ component: BadgeEditorPage, }); +// P4.1 Task 8 -- the check-in station. A TOP-LEVEL protected route, a +// SIBLING of eventWorkspaceRoute (registered directly under +// protectedLayoutRoute.addChildren below, NOT nested inside +// eventWorkspaceRoute.addChildren) so it renders WITHOUT the workspace +// rail shell (WorkspaceRail/EventWorkspaceLayout) -- a near-fullscreen +// screen for event-day check-in, not another workspace tab. See +// features/checkin/searchParams.ts for the `?station=` validation + +// beforeLoad guard this route shares with StationPage.test.tsx's own +// routed harness. +const eventCheckinRoute = createRoute({ + getParentRoute: () => protectedLayoutRoute, + path: "/events/$eventId/checkin", + validateSearch: validateCheckinStationSearch, + beforeLoad: checkinStationBeforeLoad, + component: StationPage, +}); + +// P4.1 Task 11 -- the launch ceremony. Same TOP-LEVEL, sibling-of- +// eventWorkspaceRoute registration as eventCheckinRoute above (mirrored +// deliberately, per this task's own brief: reuse Task 8's routing pattern +// rather than re-deriving it) -- registered directly under +// protectedLayoutRoute.addChildren, NOT nested inside +// eventWorkspaceRoute.addChildren, so `/events/$eventId/checkin/launch` +// renders rail-less too (this is where an operator confirms the event/ +// station/settings/printer BEFORE eventCheckinRoute's `?station=` guard +// (searchParams.ts's checkinStationBeforeLoad) ever redirects here). +const eventCheckinLaunchRoute = createRoute({ + getParentRoute: () => protectedLayoutRoute, + path: "/events/$eventId/checkin/launch", + component: LaunchCeremony, +}); + const routeTree = rootRoute.addChildren([ protectedLayoutRoute.addChildren([ indexRoute, @@ -144,6 +179,8 @@ const routeTree = rootRoute.addChildren([ eventWorkspaceRoute.addChildren([ eventOverviewRoute, eventSettingsRoute, eventAttendeesRoute, eventZonesRoute, eventStaffRoute, eventBadgeRoute, ]), + eventCheckinRoute, + eventCheckinLaunchRoute, ]), loginRoute, registerRoute, diff --git a/panel/src/features/attendees/hooks.test.tsx b/panel/src/features/attendees/hooks.test.tsx index 19e6768a..cff228e1 100644 --- a/panel/src/features/attendees/hooks.test.tsx +++ b/panel/src/features/attendees/hooks.test.tsx @@ -141,6 +141,26 @@ describe("attendees hooks", () => { expect(result.current.data?.page).toBe(1); expect(result.current.data?.per_page).toBe(50); }); + + // P4.1 Task 7: ScanInput.tsx's manual-search fallback passes + // `enabled: false` while its search box is empty, so the check-in + // station doesn't fetch the roster's first page before the operator has + // typed anything. + it("does not fire the request at all when enabled is false", async () => { + const { result } = renderHook(() => useAttendeesPage("evt-1", { page: 1, enabled: false }), { wrapper }); + + // Give a (wrong) request a chance to fire before asserting its absence. + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(attendeesFetchCount).toBe(0); + expect(result.current.isSuccess).toBe(false); + expect(result.current.fetchStatus).toBe("idle"); + }); + + it("defaults enabled to true when omitted", async () => { + const { result } = renderHook(() => useAttendeesPage("evt-1", { page: 1 }), { wrapper }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(attendeesFetchCount).toBe(1); + }); }); describe("ATTENDEES_LIST_KEY", () => { diff --git a/panel/src/features/attendees/hooks.ts b/panel/src/features/attendees/hooks.ts index ef4458fb..1b8704e2 100644 --- a/panel/src/features/attendees/hooks.ts +++ b/panel/src/features/attendees/hooks.ts @@ -10,6 +10,13 @@ export interface UseAttendeesPageOptions { search?: string; zone?: string; status?: AttendeeStatus; + // P4.1 Task 7: lets a caller (ScanInput.tsx's manual-search fallback) + // skip firing the request at all — e.g. while its search box is still + // empty, so mounting the check-in station doesn't dump the first page of + // the whole roster before the operator has typed anything. Optional and + // defaults to `true` — every pre-existing caller (AttendeesPage.tsx etc.) + // is unaffected. + enabled?: boolean; } const DEFAULT_PER_PAGE = 50; @@ -36,7 +43,7 @@ export function useAttendeesPage(eventId: string, opts: UseAttendeesPageOptions) // (oneOf, discriminated by presence of page/per_page). This hook always // sends page/per_page, so the response is always the envelope in // practice — narrow the type accordingly. - { select: (data) => data as AttendeeListPage }, + { select: (data) => data as AttendeeListPage, enabled: opts.enabled ?? true }, ); } diff --git a/panel/src/features/badge/templateTypes.test.ts b/panel/src/features/badge/templateTypes.test.ts index d0a17e35..8bf9a838 100644 --- a/panel/src/features/badge/templateTypes.test.ts +++ b/panel/src/features/badge/templateTypes.test.ts @@ -1,6 +1,7 @@ import { editorReducer, initialEditorState } from "./editorState"; import { parseTemplateDoc, + resolveBadgeConfig, serializeTemplateDoc, ZPL_FONTS, type BadgeTemplateDoc, @@ -409,6 +410,52 @@ describe("serializeTemplateDoc — verbatim-preservation round-trip", () => { }); }); +// PR #77 bot-review round 2, Finding 4 -- the backend-parity "configless +// legacy template" fallback, extracted out of usePrintBadge.ts's own inline +// resolution so the launch ceremony's Test badge action can reuse the +// IDENTICAL logic. This is deliberately NOT parseTemplateDoc's own +// width_mm/height_mm/dpi narrowing (see that describe block above for its +// 90x55mm @ 300dpi editor default) -- these two functions intentionally +// disagree on the fallback for the exact same "field missing" input. +describe("resolveBadgeConfig", () => { + it("falls back to the backend's 50x30mm @ 203dpi for a configless legacy template (width_mm/height_mm/dpi all missing)", () => { + expect(resolveBadgeConfig({ elements: [] })).toEqual({ width_mm: 50, height_mm: 30, dpi: 203 }); + }); + + it("falls back to the same 50x30mm @ 203dpi for null/non-object raw values", () => { + expect(resolveBadgeConfig(null)).toEqual({ width_mm: 50, height_mm: 30, dpi: 203 }); + expect(resolveBadgeConfig(undefined)).toEqual({ width_mm: 50, height_mm: 30, dpi: 203 }); + expect(resolveBadgeConfig("not an object")).toEqual({ width_mm: 50, height_mm: 30, dpi: 203 }); + }); + + it("uses an explicit modern template's real width_mm/height_mm/dpi verbatim (no regression for the common case)", () => { + expect(resolveBadgeConfig({ width_mm: 90, height_mm: 55, dpi: 300, elements: [] })).toEqual({ + width_mm: 90, + height_mm: 55, + dpi: 300, + }); + }); + + it("falls back per-field independently for a partially-configured template", () => { + expect(resolveBadgeConfig({ width_mm: 100 })).toEqual({ width_mm: 100, height_mm: 30, dpi: 203 }); + expect(resolveBadgeConfig({ dpi: 300 })).toEqual({ width_mm: 50, height_mm: 30, dpi: 300 }); + }); + + it("treats a <= 0 width/height/dpi the same as missing (backend tolerates both the same way)", () => { + expect(resolveBadgeConfig({ width_mm: 0, height_mm: -5, dpi: 0 })).toEqual({ + width_mm: 50, + height_mm: 30, + dpi: 203, + }); + }); + + it("truncates a fractional dpi toward zero BEFORE its own <= 0 fallback check, mirroring the backend's int(d) cast", () => { + expect(resolveBadgeConfig({ dpi: 203.7 })).toEqual({ width_mm: 50, height_mm: 30, dpi: 203 }); + // 0.9 truncates to 0 first, THEN falls back to 203 -- not "kept" as 0. + expect(resolveBadgeConfig({ dpi: 0.9 })).toEqual({ width_mm: 50, height_mm: 30, dpi: 203 }); + }); +}); + describe("ZPL_FONTS", () => { it("lists the six ZPL font codes (scalable + five bitmap sizes) with label keys", () => { expect(ZPL_FONTS).toEqual([ diff --git a/panel/src/features/badge/templateTypes.ts b/panel/src/features/badge/templateTypes.ts index 763dc7fe..eb06e2b6 100644 --- a/panel/src/features/badge/templateTypes.ts +++ b/panel/src/features/badge/templateTypes.ts @@ -247,6 +247,32 @@ export function serializeTemplateDoc(doc: BadgeTemplateDoc, originalRaw: unknown }; } +// PR #77 bot-review round 2, Finding 4 -- the backend-compatible "configless +// legacy template" fallback (50mm x 30mm @ 203dpi), extracted out of +// usePrintBadge.ts's own inline width_mm/height_mm/dpi resolution (P3.2 +// Task 8, final-review Important fix -- see that call site's own comment for +// the full backend-parity rationale, including WHY dpi truncates before its +// own <= 0 fallback check) so BOTH the real check-in/reprint print path +// (usePrintBadge.printAttendee) and the launch ceremony's own Test badge +// action (LaunchCeremony.tsx) resolve a raw template's physical label +// config THE SAME WAY. Deliberately NOT parseTemplateDoc's own +// width_mm/height_mm/dpi narrowing above -- that function's fallback +// (NEW_DOC_DEFAULT, 90x55mm @ 300dpi) is the EDITOR's UI-only default, which +// zpl.ParseBadgeTemplate (backend/internal/zpl/zpl.go:334-364) never +// actually produces: a template that predates P3.1's explicit config (width/ +// height/dpi genuinely missing or <= 0) resolves server-side to 50x30@203, +// not 90x55@300. A caller that validates/prints against parseTemplateDoc's +// default for such a template is checking a DIFFERENT label size/DPI than +// what will actually print. +export function resolveBadgeConfig(raw: unknown): BadgeConfig { + const source = isPlainObject(raw) ? raw : {}; + const width_mm = typeof source.width_mm === "number" && source.width_mm > 0 ? source.width_mm : 50; + const height_mm = typeof source.height_mm === "number" && source.height_mm > 0 ? source.height_mm : 30; + const truncatedDpi = typeof source.dpi === "number" ? Math.trunc(source.dpi) : 0; + const dpi = truncatedDpi > 0 ? truncatedDpi : 203; + return { width_mm, height_mm, dpi }; +} + // ZPL bitmap font choices for the properties inspector's font picker // (reconciliation #6) — mirrors zpl.go's getZPLFont: "0" is the scalable // fallback used whenever fontSize doesn't exactly match one of the fixed diff --git a/panel/src/features/badge/zpl/usePrintBadge.test.tsx b/panel/src/features/badge/zpl/usePrintBadge.test.tsx index 6ddf3763..b7274c57 100644 --- a/panel/src/features/badge/zpl/usePrintBadge.test.tsx +++ b/panel/src/features/badge/zpl/usePrintBadge.test.tsx @@ -77,6 +77,11 @@ let markPrintedStatus = 200; let markPrintedHitCount = 0; let listHitCount = 0; let detailHitCount = 0; +// P4.1 Task 6 -- captures whatever body (if any) reached POST +// /attendees/{id}/printed, so tests can assert the printContext forwarding +// (or its deliberate absence for back-compat callers) byte-for-byte, not +// just the hit count. +let markPrintedBodyCapture: unknown; const server = startMswServer( http.get("http://api.test/api/events/:id/badge-template", () => HttpResponse.json(templateResponse)), @@ -89,8 +94,14 @@ const server = startMswServer( detailHitCount += 1; return HttpResponse.json(ATTENDEE); }), - http.post("http://api.test/api/attendees/:attendeeId/printed", () => { + http.post("http://api.test/api/attendees/:attendeeId/printed", async ({ request }) => { markPrintedHitCount += 1; + // A body-less call (openapi-fetch omits the body entirely when none is + // passed) has no JSON to parse -- `.text()` first lets an empty body + // resolve to `undefined` instead of `request.json()` throwing a + // SyntaxError on an empty string. + const raw = await request.text(); + markPrintedBodyCapture = raw ? JSON.parse(raw) : undefined; if (markPrintedStatus !== 200) { return HttpResponse.json({ error: "boom" }, { status: markPrintedStatus }); } @@ -146,6 +157,7 @@ describe("usePrintBadge", () => { printStatus = 200; markPrintedStatus = 200; markPrintedHitCount = 0; + markPrintedBodyCapture = undefined; listHitCount = 0; detailHitCount = 0; stubFontFaceApi(); @@ -172,6 +184,12 @@ describe("usePrintBadge", () => { expect(printCapture?.zpl).toContain("^FDAda^FS"); expect(printCapture?.zpl).not.toContain("Guest"); expect(markPrintedHitCount).toBe(1); + // P4.1 Task 6 back-compat proof: a caller that passes no `printContext` + // (the badge editor's test-print/bulk-print, unchanged by this task) + // sends the exact same body it always did -- none at all -- so + // attendee_printed.go's back-compat (counter-only, no checkin_actions + // row) path is hit byte-identically. + expect(markPrintedBodyCapture).toBeUndefined(); await waitFor(() => expect(listHitCount).toBeGreaterThan(1)); await waitFor(() => expect(detailHitCount).toBeGreaterThan(1)); @@ -377,4 +395,63 @@ describe("usePrintBadge", () => { expect(listHitCount).toBe(1); expect(detailHitCount).toBe(1); }); + + // P4.1 Task 6: extends PrintAttendeeOptions with an OPTIONAL printContext, + // forwarded verbatim as {event_id, station_id} in the /printed body -- + // this is how the station's reprint action (Task 9) logs a checkin_actions + // ('reprint') row via Task 4's already-extended endpoint. eventId is what + // gates the reprint-logging behavior server-side; station_id rides along + // and is only meaningful when event_id is present too. + it("forwards printContext as {event_id, station_id} in the /printed body when given", async () => { + const { result } = renderPrintBadge(true); + await waitFor(() => expect(result.current.fontsStatus).toBe("ready")); + await waitFor(() => expect(listHitCount).toBe(1)); + await waitFor(() => expect(detailHitCount).toBe(1)); + + await act(async () => { + await result.current.printAttendee(ATTENDEE, "Zebra_ZD421", { + printContext: { eventId: "evt-1", stationId: "st-1" }, + }); + }); + + expect(markPrintedHitCount).toBe(1); + expect(markPrintedBodyCapture).toEqual({ event_id: "evt-1", station_id: "st-1" }); + }); + + // A station-less print (the ceremony/ ad-hoc reprint with no station + // context bound yet) is a valid printContext shape per schema.d.ts's + // MarkAttendeePrintedRequest -- station_id is independently optional/ + // nullable of event_id. + it("forwards a null station_id verbatim when printContext has no station bound", async () => { + const { result } = renderPrintBadge(true); + await waitFor(() => expect(result.current.fontsStatus).toBe("ready")); + + await act(async () => { + await result.current.printAttendee(ATTENDEE, "Zebra_ZD421", { + printContext: { eventId: "evt-1", stationId: null }, + }); + }); + + expect(markPrintedBodyCapture).toEqual({ event_id: "evt-1", station_id: null }); + }); + + it("still throws MarkPrintedError (and still sent the badge) when the printed-count POST fails with a printContext present", async () => { + markPrintedStatus = 500; + const { result } = renderPrintBadge(true); + await waitFor(() => expect(result.current.fontsStatus).toBe("ready")); + + let caught: unknown; + await act(async () => { + try { + await result.current.printAttendee(ATTENDEE, "Zebra_ZD421", { + printContext: { eventId: "evt-1", stationId: "st-1" }, + }); + } catch (error) { + caught = error; + } + }); + + expect(caught).toBeInstanceOf(MarkPrintedError); + expect(printHitCount).toBe(1); + }); }); diff --git a/panel/src/features/badge/zpl/usePrintBadge.ts b/panel/src/features/badge/zpl/usePrintBadge.ts index 2a49368c..eb73a37b 100644 --- a/panel/src/features/badge/zpl/usePrintBadge.ts +++ b/panel/src/features/badge/zpl/usePrintBadge.ts @@ -18,7 +18,7 @@ import * as React from "react"; import { useQueryClient } from "@tanstack/react-query"; import { useBadgeTemplate } from "../hooks"; import { attendeeToPreviewData } from "../usePreviewAttendee"; -import type { BadgeConfig } from "../templateTypes"; +import { resolveBadgeConfig } from "../templateTypes"; import { generateZpl, type RawBadgeElement } from "./generateZpl"; import { collectMissingCustomFonts } from "./missingFonts"; import { rasterizeText } from "./canvasRasterizer"; @@ -79,6 +79,16 @@ export interface PrintAttendeeOptions { // here skips THIS call's own invalidation; the bulk dialog is responsible // for firing its own single invalidateQueries call after the loop. skipInvalidate?: boolean; + // P4.1 Task 4 extended POST /attendees/{id}/printed with an OPTIONAL + // {event_id, station_id} body: when event_id is present, the handler logs + // a checkin_actions ('reprint') row after the counter increment succeeds + // -- this is how the check-in station's recent-scans rail (Task 9) picks + // up a reprint. Absent entirely (the default, every P3.1/P3.2 caller + // unchanged by this task) is the pre-existing back-compat path: counter- + // only, no feed row. `stationId` is independently nullable (a station-less + // print context is valid per schema.d.ts's MarkAttendeePrintedRequest) -- + // it's only meaningful server-side when `eventId` is also present. + printContext?: { eventId: string; stationId: string | null }; } export interface UsePrintBadgeResult { @@ -167,20 +177,13 @@ export function usePrintBadge(eventId: string): UsePrintBadgeResult { // dpi)`) silently produces NaN -- `^PWNaN`/`^LLNaN`/`^FONaN,NaN` ZPL that // the agent still accepts and sends to the physical printer, reporting // "Sent to {{printer}}" and incrementing printed_count even though - // nothing legible printed. Mirroring the backend's exact fallback here - // keeps this path's config honest without reintroducing element-level - // narrowing. - const rawWidthMM = typeof raw.width_mm === "number" && raw.width_mm > 0 ? raw.width_mm : 50; - const rawHeightMM = typeof raw.height_mm === "number" && raw.height_mm > 0 ? raw.height_mm : 30; - // dpi mirrors the backend even more literally than width/height (which - // stay float64 there): ParseBadgeTemplate casts with Go's int(d) — - // truncation toward zero — BEFORE its <= 0 fallback check, so a - // pathological fractional dpi must truncate first (203.7 -> 203) and - // only then fall back (0.9 -> 0 -> 203), or this path's ZPL diverges - // from what the backend's own generator would emit for the same doc. - const truncatedDpi = typeof raw.dpi === "number" ? Math.trunc(raw.dpi) : 0; - const rawDpi = truncatedDpi > 0 ? truncatedDpi : 203; - const config: BadgeConfig = { width_mm: rawWidthMM, height_mm: rawHeightMM, dpi: rawDpi }; + // nothing legible printed. `resolveBadgeConfig` (templateTypes.ts) + // mirrors the backend's exact fallback -- PR #77 bot-review round 2, + // Finding 4 extracted it out of this call site so the launch ceremony's + // own "Test badge" action (LaunchCeremony.tsx) can resolve the SAME + // config for the SAME raw template, rather than validating a different + // (editor-default) label size/DPI than what actually prints here. + const config = resolveBadgeConfig(raw); const elements = Array.isArray(raw.elements) ? (raw.elements as RawBadgeElement[]) : []; // PR #74 review round Fix 8: checked AFTER fonts have reached a terminal @@ -202,7 +205,19 @@ export function usePrintBadge(eventId: string): UsePrintBadgeResult { // like the print itself failed. let markPrintedFailed = false; try { - await markPrinted.mutateAsync({ params: { path: { attendee_id: attendee.id } } }); + // No `printContext` -> no `body` key at all (not `body: undefined`), + // so a caller that never passes it (every P3.1/P3.2 surface, unchanged + // by this task) sends the exact same request it always did -- the + // backend's back-compat path (attendee_printed.go) is keyed on the + // body being genuinely absent, not merely empty-valued. + if (opts.printContext) { + await markPrinted.mutateAsync({ + params: { path: { attendee_id: attendee.id } }, + body: { event_id: opts.printContext.eventId, station_id: opts.printContext.stationId }, + }); + } else { + await markPrinted.mutateAsync({ params: { path: { attendee_id: attendee.id } } }); + } } catch { markPrintedFailed = true; } diff --git a/panel/src/features/checkin/LaunchCeremony.test.tsx b/panel/src/features/checkin/LaunchCeremony.test.tsx new file mode 100644 index 00000000..d0dd339b --- /dev/null +++ b/panel/src/features/checkin/LaunchCeremony.test.tsx @@ -0,0 +1,576 @@ +// P4.1 Task 11 -- LaunchCeremony tests. +// +// The FIRST describe block below is the routing proof this task's own +// brief asks for (reusing Task 8's StationPage.test.tsx technique rather +// than re-deriving it): app/router.tsx registers `eventCheckinLaunchRoute` +// as a TOP-LEVEL protected route, a SIBLING of `eventWorkspaceRoute` (both +// children of `protectedLayoutRoute`), so `/events/$eventId/checkin/launch` +// renders LaunchCeremony WITHOUT the workspace rail shell. Both +// registrations (sibling vs. "child of the workspace route with a relative +// path") resolve to the IDENTICAL final URL, so only the RENDERED OUTPUT +// (not the matched path string) can tell a correct sibling registration +// apart from an accidental nested one -- proven two ways: (1) a routed +// harness shaped exactly like app/router.tsx's real registration renders +// LaunchCeremony's content with none of the workspace shell's nav markers +// present, and (2) a deliberately-misregistered harness (the launch route +// nested as a CHILD of the workspace route) demonstrates the SAME assertion +// would fail if the registration were wrong. +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + Outlet, RouterProvider, createMemoryHistory, createRootRoute, createRoute, createRouter, +} from "@tanstack/react-router"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { delay, http, HttpResponse } from "msw"; +import { LaunchCeremony } from "./LaunchCeremony"; +import { startMswServer } from "../../test/msw"; +import "../../shared/i18n"; + +// Distinguishing marker text for the workspace rail shell's own nav items +// (WorkspaceRail.tsx's real English copy) -- if the launch route were +// wrongly nested under the workspace route, these would render alongside +// LaunchCeremony's own content. +function WorkspaceShellStub() { + return ( +
+ + +
+ ); +} + +// Mirrors app/router.tsx's REAL shape: an app-layout id route ("_app", +// standing in for protectedLayoutRoute) with the workspace route, the +// checkin (station) route, AND the checkin/launch route registered as +// SIBLING children -- exactly the registration this task adds. +function buildCorrectRouter(initialPath: string) { + const rootRoute = createRootRoute(); + const appLayoutRoute = createRoute({ getParentRoute: () => rootRoute, id: "_app", component: () => }); + const workspaceRoute = createRoute({ + getParentRoute: () => appLayoutRoute, + path: "/events/$eventId", + component: WorkspaceShellStub, + }); + const checkinRoute = createRoute({ + getParentRoute: () => appLayoutRoute, + path: "/events/$eventId/checkin", + validateSearch: (search: Record) => ({ station: typeof search.station === "string" ? search.station : undefined }), + component: () =>
station stub
, + }); + const launchRoute = createRoute({ + getParentRoute: () => appLayoutRoute, // sibling of workspaceRoute -- the fix under test. + path: "/events/$eventId/checkin/launch", + component: LaunchCeremony, + }); + const routeTree = rootRoute.addChildren([appLayoutRoute.addChildren([workspaceRoute, checkinRoute, launchRoute])]); + return createRouter({ routeTree, history: createMemoryHistory({ initialEntries: [initialPath] }) }); +} + +// Reproduces the bug the sibling registration above avoids: the launch +// route nested as a CHILD of the workspace route (relative path +// "/checkin/launch") resolves to the exact same final URL but renders +// wrapped inside the workspace shell's own . +function buildMisregisteredRouter(initialPath: string) { + const rootRoute = createRootRoute(); + const appLayoutRoute = createRoute({ getParentRoute: () => rootRoute, id: "_app", component: () => }); + const workspaceRoute = createRoute({ + getParentRoute: () => appLayoutRoute, + path: "/events/$eventId", + component: WorkspaceShellStub, + }); + const nestedLaunchRoute = createRoute({ + getParentRoute: () => workspaceRoute, // the mistake: a CHILD, not a sibling. + path: "/checkin/launch", + component: () =>
dummy
, + }); + const routeTree = rootRoute.addChildren([appLayoutRoute.addChildren([workspaceRoute.addChildren([nestedLaunchRoute])])]); + return createRouter({ routeTree, history: createMemoryHistory({ initialEntries: [initialPath] }) }); +} + +function renderWithRouter(router: ReturnType | ReturnType) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + {/* Cast, not @ts-expect-error: this test router's route shape differs + from the app's registered singleton -- same rationale as + StationPage.test.tsx / AttendeesPage.test.tsx. */} + + , + ); + return router; +} + +function renderCorrectAt(path: string) { + return renderWithRouter(buildCorrectRouter(path)); +} + +const EVENT = { + id: "evt-1", + tenant_id: "t1", + name: "Partner Day — Autumn", + start_date: "2026-09-03T00:00:00.000Z", + created_at: "", + updated_at: "", +}; + +const ZONES = [ + { + id: "zone-1", + event_id: "evt-1", + name: "Main Hall", + zone_type: "general", + order_index: 0, + is_registration_zone: true, + requires_registration: false, + is_active: true, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + }, +]; + +let readinessResponse: unknown = { ready: false, steps: [{ key: "attendees", status: "not_done" }] }; +let settingsResponse: unknown = { + print_on_checkin: true, + verdict_auto_dismiss_sec: 4, + scan_input: "wedge", + manual_search_enabled: true, +}; +let capturedSettingsPut: { settings: unknown } | null = null; +let capturedStationRegister: { name: string; zone_id: string | null } | null = null; +let registeredStationId = "st-new"; + +const server = startMswServer( + http.get("http://api.test/api/events/:id", () => HttpResponse.json(EVENT)), + http.get("http://api.test/api/events/:id/readiness", () => HttpResponse.json(readinessResponse)), + http.get("http://api.test/api/events/:eventId/zones", () => HttpResponse.json(ZONES)), + http.get("http://api.test/api/events/:id/checkin-settings", () => HttpResponse.json({ settings: settingsResponse })), + http.put("http://api.test/api/events/:id/checkin-settings", async ({ request }) => { + const body = (await request.json()) as { settings: unknown }; + capturedSettingsPut = body; + return HttpResponse.json({ settings: body.settings }); + }), + http.post("http://api.test/api/events/:eventId/checkin-stations", async ({ request }) => { + const body = (await request.json()) as { name: string; zone_id: string | null }; + capturedStationRegister = body; + return HttpResponse.json({ + station: { + id: registeredStationId, + event_id: "evt-1", + name: body.name, + zone_id: body.zone_id, + last_seen_at: "2026-01-01T00:00:00Z", + created_at: "2026-01-01T00:00:00Z", + }, + }); + }), + http.get("http://api.test/api/events/:id/badge-template", () => HttpResponse.json({ template: null, version: 0 })), + http.get("http://api.test/api/events/:eventId/fonts", () => HttpResponse.json([])), + http.get("http://api.test/api/events/:eventId/attendees", () => + HttpResponse.json({ attendees: [], total: 0, page: 1, per_page: 50 }), + ), + http.get("http://agent.test/health", () => new HttpResponse(null, { status: 200 })), + http.get("http://agent.test/printers", () => HttpResponse.json([])), + http.get("http://agent.test/printers/default", () => HttpResponse.json({ default: null })), +); +void server; + +describe("LaunchCeremony routing -- sibling registration proof", () => { + beforeEach(() => { + window.__ENV__ = { API_URL: "http://api.test", AGENT_URL: "http://agent.test" }; + localStorage.clear(); + localStorage.setItem("token", "jwt-test"); + readinessResponse = { ready: false, steps: [{ key: "attendees", status: "not_done" }] }; + }); + + it("renders LaunchCeremony's own content with NONE of the workspace shell's nav markers, when registered as a top-level sibling of the workspace route (app/router.tsx's real shape)", async () => { + renderCorrectAt("/events/evt-1/checkin/launch"); + + expect(await screen.findByTestId("launch-ceremony")).toBeInTheDocument(); + + expect(screen.queryByText("Overview")).not.toBeInTheDocument(); + expect(screen.queryByText("Attendees")).not.toBeInTheDocument(); + expect(screen.queryByText("Zones")).not.toBeInTheDocument(); + expect(screen.queryByText("Staff")).not.toBeInTheDocument(); + expect(screen.queryByText("Badge")).not.toBeInTheDocument(); + }); + + it("sanity check: the SAME workspace-shell-marker assertion WOULD fail if the launch route were (incorrectly) nested as a child of the workspace route -- proof the technique above actually discriminates", async () => { + const router = buildMisregisteredRouter("/events/evt-1/checkin/launch"); + renderWithRouter(router); + + expect(await screen.findByTestId("dummy-launch-page")).toBeInTheDocument(); + expect(screen.getByText("Overview")).toBeInTheDocument(); + expect(screen.getByText("Badge")).toBeInTheDocument(); + }); +}); + +describe("LaunchCeremony", () => { + beforeEach(() => { + window.__ENV__ = { API_URL: "http://api.test", AGENT_URL: "http://agent.test" }; + localStorage.clear(); + localStorage.setItem("token", "jwt-test"); + readinessResponse = { ready: false, steps: [{ key: "attendees", status: "not_done" }] }; + settingsResponse = { + print_on_checkin: true, + verdict_auto_dismiss_sec: 4, + scan_input: "wedge", + manual_search_enabled: true, + }; + capturedSettingsPut = null; + capturedStationRegister = null; + registeredStationId = "st-new"; + }); + + it("renders the 3-column ceremony: confirm event & station, check-in settings, printer check", async () => { + renderCorrectAt("/events/evt-1/checkin/launch"); + + await screen.findByTestId("launch-col-event"); + expect(screen.getByTestId("launch-col-settings")).toBeInTheDocument(); + expect(screen.getByTestId("launch-col-printer")).toBeInTheDocument(); + expect(screen.getByText("Confirm event & station")).toBeInTheDocument(); + expect(screen.getByText("Check-in settings")).toBeInTheDocument(); + expect(screen.getByText("Printer check")).toBeInTheDocument(); + }); + + it("editing a setting and saving PUTs the whole settings object", async () => { + const user = userEvent.setup(); + renderCorrectAt("/events/evt-1/checkin/launch"); + await screen.findByTestId("launch-ceremony"); + + // Wait for the settings GET to seed the form (the switch starts checked + // per settingsResponse's print_on_checkin: true). + await waitFor(() => expect(screen.getByRole("switch", { name: "Print badge on check-in" })).toBeChecked()); + + await user.click(screen.getByRole("switch", { name: "Print badge on check-in" })); + await user.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => expect(capturedSettingsPut).not.toBeNull()); + expect(capturedSettingsPut).toEqual({ + settings: { + print_on_checkin: false, + verdict_auto_dismiss_sec: 4, + scan_input: "wedge", + manual_search_enabled: true, + }, + }); + expect(await screen.findByText("Saved")).toBeInTheDocument(); + }); + + // PR #77 bot-review round, Finding N -- the SAME "ungated load effect" bug + // class as P3.1's badge editor: `settingsForm`/`settingsBaseline` start as + // the hardcoded DEFAULT_CHECKIN_SETTINGS until the real GET resolves. + // Previously nothing stopped an operator from editing (and, since editing + // makes the form diverge from a baseline that's STILL the hardcoded + // default, saving) a whole-object PUT built on those defaults while the + // real fetch was still in flight, clobbering the event's actual saved + // settings the instant they'd otherwise have arrived. + it("disables the settings form (and Save) until check-in settings have actually loaded, so a premature edit+save can't clobber real settings with defaults", async () => { + server.use( + http.get("http://api.test/api/events/:id/checkin-settings", async () => { + await delay(50); + return HttpResponse.json({ settings: settingsResponse }); + }), + ); + renderCorrectAt("/events/evt-1/checkin/launch"); + await screen.findByTestId("launch-col-settings"); + + expect(screen.getByRole("switch", { name: "Print badge on check-in" })).toBeDisabled(); + expect(screen.getByRole("switch", { name: "Allow manual search" })).toBeDisabled(); + expect(screen.getByLabelText("Verdict auto-dismiss (seconds)")).toBeDisabled(); + expect(screen.getByLabelText("Scan input")).toBeDisabled(); + expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); + + await waitFor(() => expect(screen.getByRole("switch", { name: "Print badge on check-in" })).toBeEnabled()); + expect(capturedSettingsPut).toBeNull(); + }); + + it("disables the Start check-in CTA with an explanatory reason while the event isn't ready", async () => { + readinessResponse = { ready: false, steps: [{ key: "attendees", status: "not_done" }] }; + renderCorrectAt("/events/evt-1/checkin/launch"); + await screen.findByTestId("launch-ceremony"); + + await waitFor(() => expect(screen.getByRole("button", { name: "Start check-in" })).toBeDisabled()); + expect(screen.getByText("Finish the badge and run a test print to unlock check-in.")).toBeInTheDocument(); + }); + + it("enables the Start check-in CTA once the event is ready", async () => { + readinessResponse = { ready: true, steps: [{ key: "attendees", status: "done", count: 10 }] }; + renderCorrectAt("/events/evt-1/checkin/launch"); + await screen.findByTestId("launch-ceremony"); + + await waitFor(() => expect(screen.getByRole("button", { name: "Start check-in" })).toBeEnabled()); + }); + + // PR #77 bot-review round 3, Finding 6 -- while settings are still loading + // (or have failed), the form's current values equal DEFAULT_CHECKIN_ + // SETTINGS and so does the baseline it diffs against, so `settingsDirty` + // computes to false and Start check-in could previously be clicked with + // settings that were NEVER actually confirmed from the server. Readiness + // and station name both already pass here -- only the settings query's own + // resolution state should be gating Start. + it("keeps Start check-in disabled while the settings query is still loading, even though readiness and station name already pass -- then governs it by the ordinary dirty/pending checks once settings load", async () => { + readinessResponse = { ready: true, steps: [{ key: "attendees", status: "done", count: 10 }] }; + server.use( + http.get("http://api.test/api/events/:id/checkin-settings", async () => { + await delay(50); + return HttpResponse.json({ settings: settingsResponse }); + }), + ); + renderCorrectAt("/events/evt-1/checkin/launch"); + // `launch-ceremony`'s own testid renders for BOTH the eventQuery-loading + // skeleton and the real page -- `launch-col-settings` only exists once + // the event itself has resolved, same precedent as this file's other + // settings-loading test above. + await screen.findByTestId("launch-col-settings"); + + expect(screen.getByRole("button", { name: "Start check-in" })).toBeDisabled(); + + await waitFor(() => expect(screen.getByRole("button", { name: "Start check-in" })).toBeEnabled()); + }); + + it("keeps Start check-in disabled when the settings query has failed, even though readiness and station name already pass", async () => { + readinessResponse = { ready: true, steps: [{ key: "attendees", status: "done", count: 10 }] }; + server.use( + http.get("http://api.test/api/events/:id/checkin-settings", () => new HttpResponse(null, { status: 500 })), + ); + renderCorrectAt("/events/evt-1/checkin/launch"); + await screen.findByTestId("launch-col-settings"); + + await waitFor(() => expect(screen.getByRole("button", { name: "Start check-in" })).toBeDisabled()); + }); + + // PR #77 bot-review round 2, Finding 3 -- Start check-in navigates to the + // station, which fetches the PERSISTED settings from the server -- an + // unsaved edit (or a save still in flight) here must never be silently + // discarded by that navigation. + it("disables Start check-in while a settings edit is unsaved, with an explanatory hint, and re-enables it once the edit is saved", async () => { + readinessResponse = { ready: true, steps: [{ key: "attendees", status: "done", count: 10 }] }; + const user = userEvent.setup(); + renderCorrectAt("/events/evt-1/checkin/launch"); + await screen.findByTestId("launch-ceremony"); + await waitFor(() => expect(screen.getByRole("button", { name: "Start check-in" })).toBeEnabled()); + expect(screen.queryByTestId("launch-unsaved-settings-hint")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("switch", { name: "Print badge on check-in" })); + + expect(screen.getByRole("button", { name: "Start check-in" })).toBeDisabled(); + expect(screen.getByTestId("launch-unsaved-settings-hint")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => expect(screen.getByRole("button", { name: "Start check-in" })).toBeEnabled()); + expect(screen.queryByTestId("launch-unsaved-settings-hint")).not.toBeInTheDocument(); + }); + + it("keeps Start check-in disabled while a settings save is pending (in flight), even after the request resolves successfully", async () => { + readinessResponse = { ready: true, steps: [{ key: "attendees", status: "done", count: 10 }] }; + server.use( + http.put("http://api.test/api/events/:id/checkin-settings", async ({ request }) => { + await delay(50); + const body = (await request.json()) as { settings: unknown }; + capturedSettingsPut = body; + return HttpResponse.json({ settings: body.settings }); + }), + ); + const user = userEvent.setup(); + renderCorrectAt("/events/evt-1/checkin/launch"); + await screen.findByTestId("launch-ceremony"); + await waitFor(() => expect(screen.getByRole("button", { name: "Start check-in" })).toBeEnabled()); + + await user.click(screen.getByRole("switch", { name: "Print badge on check-in" })); + await user.click(screen.getByRole("button", { name: "Save" })); + + expect(screen.getByRole("button", { name: "Start check-in" })).toBeDisabled(); + + await waitFor(() => expect(capturedSettingsPut).not.toBeNull()); + await waitFor(() => expect(screen.getByRole("button", { name: "Start check-in" })).toBeEnabled()); + }); + + it("Start check-in registers the station (upsert body {name, zone_id}) then navigates to the station with ?station=", async () => { + readinessResponse = { ready: true, steps: [{ key: "attendees", status: "done", count: 10 }] }; + registeredStationId = "st-42"; + const user = userEvent.setup(); + const router = renderCorrectAt("/events/evt-1/checkin/launch"); + await screen.findByTestId("launch-ceremony"); + await waitFor(() => expect(screen.getByRole("button", { name: "Start check-in" })).toBeEnabled()); + + const nameInput = screen.getByLabelText("Station name"); + await user.clear(nameInput); + await user.type(nameInput, "Main Door"); + await user.selectOptions(screen.getByLabelText("Zone (optional)"), "Main Hall"); + + await user.click(screen.getByRole("button", { name: "Start check-in" })); + + await waitFor(() => expect(capturedStationRegister).toEqual({ name: "Main Door", zone_id: "zone-1" })); + await waitFor(() => expect(router.state.location.pathname).toBe("/events/evt-1/checkin")); + expect(router.state.location.search).toEqual({ station: "st-42" }); + expect(await screen.findByText("station stub")).toBeInTheDocument(); + }); + + it("disables Test badge with an explanatory hint when the event has no saved badge template yet", async () => { + renderCorrectAt("/events/evt-1/checkin/launch"); + await screen.findByTestId("launch-ceremony"); + + await waitFor(() => expect(screen.getByRole("button", { name: "Test badge" })).toBeDisabled()); + expect(screen.getByText("Design a badge template first to test print it.")).toBeInTheDocument(); + }); + + it("Test badge opens the reused P3.2 test-print dialog once a template exists and the agent is connected", async () => { + server.use( + http.get("http://api.test/api/events/:id/badge-template", () => + HttpResponse.json({ template: { width_mm: 50, height_mm: 30, dpi: 203, elements: [] }, version: 1 }), + ), + http.get("http://agent.test/printers", () => HttpResponse.json([{ name: "Zebra 1", type: "system" }])), + http.get("http://agent.test/printers/default", () => HttpResponse.json({ default: "Zebra 1" })), + ); + const user = userEvent.setup(); + renderCorrectAt("/events/evt-1/checkin/launch"); + await screen.findByTestId("launch-ceremony"); + + await waitFor(() => expect(screen.getByRole("button", { name: "Test badge" })).toBeEnabled()); + await user.click(screen.getByRole("button", { name: "Test badge" })); + + expect(await screen.findByText("Printing a test badge for Sample data")).toBeInTheDocument(); + }); + + // PR #77 bot-review round 2, Finding 4 -- for an event whose saved badge + // template came from the LEGACY path (no width_mm/height_mm/dpi ever set), + // the REAL check-in/reprint print path (usePrintBadge.printAttendee) + // deliberately falls back to the backend's own 50x30mm @ 203dpi (P3.2's + // established backend-parity fallback), NOT parseTemplateDoc's editor + // default (90x55mm @ 300dpi). "Test badge" must validate the SAME + // resolution, or the test can pass while production output uses a + // different label size/DPI. jsdom has neither FontFace nor document.fonts, + // so this stubs both (same minimal mock every OTHER print-generation test + // in this codebase uses) to let TestPrintDialog's own font-readiness gate + // reach a terminal state and actually generate/send. + describe("Test badge config resolution for a configless legacy template", () => { + class MockFontFace { + constructor(_family: string, _source: unknown, _descriptors?: { weight?: string; style?: string }) {} + load(): Promise { + return Promise.resolve(this); + } + } + + beforeEach(() => { + (globalThis as unknown as { FontFace: unknown }).FontFace = MockFontFace; + Object.defineProperty(document, "fonts", { value: { add: () => {} }, configurable: true, writable: true }); + }); + + afterEach(() => { + delete (globalThis as unknown as { FontFace?: unknown }).FontFace; + // @ts-expect-error -- test-only cleanup of the jsdom `document.fonts` + // stub; real jsdom has no `fonts` property to restore. + delete document.fonts; + }); + + it("uses the backend's 50x30mm @ 203dpi fallback (^PW400/^LL240), not the editor's 90x55mm @ 300dpi default (^PW1063/^LL650), for a configless legacy template", async () => { + let printedZpl: string | null = null; + server.use( + http.get("http://api.test/api/events/:id/badge-template", () => + // Configless legacy shape -- a real saved template with no + // width_mm/height_mm/dpi keys at all, exactly what P3.1 predates. + HttpResponse.json({ template: { elements: [] }, version: 1 }), + ), + http.get("http://agent.test/printers", () => HttpResponse.json([{ name: "Zebra 1", type: "system" }])), + http.get("http://agent.test/printers/default", () => HttpResponse.json({ default: "Zebra 1" })), + http.post("http://agent.test/print", async ({ request }) => { + const body = (await request.json()) as { printer_name: string; zpl: string }; + printedZpl = body.zpl; + return HttpResponse.json({ status: "printed" }); + }), + ); + const user = userEvent.setup(); + renderCorrectAt("/events/evt-1/checkin/launch"); + await screen.findByTestId("launch-ceremony"); + + await waitFor(() => expect(screen.getByRole("button", { name: "Test badge" })).toBeEnabled()); + await user.click(screen.getByRole("button", { name: "Test badge" })); + + await waitFor(() => expect(screen.getByRole("button", { name: "Print test badge" })).toBeEnabled()); + await user.click(screen.getByRole("button", { name: "Print test badge" })); + + await waitFor(() => expect(printedZpl).not.toBeNull()); + expect(printedZpl).toContain("^PW400"); + expect(printedZpl).toContain("^LL240"); + expect(printedZpl).not.toContain("^PW1063"); + expect(printedZpl).not.toContain("^LL650"); + }); + + it("uses the template's own explicit width_mm/height_mm/dpi (^PW1063/^LL650) when it's a modern, explicitly-configured template (no regression)", async () => { + let printedZpl: string | null = null; + server.use( + http.get("http://api.test/api/events/:id/badge-template", () => + HttpResponse.json({ template: { width_mm: 90, height_mm: 55, dpi: 300, elements: [] }, version: 1 }), + ), + http.get("http://agent.test/printers", () => HttpResponse.json([{ name: "Zebra 1", type: "system" }])), + http.get("http://agent.test/printers/default", () => HttpResponse.json({ default: "Zebra 1" })), + http.post("http://agent.test/print", async ({ request }) => { + const body = (await request.json()) as { printer_name: string; zpl: string }; + printedZpl = body.zpl; + return HttpResponse.json({ status: "printed" }); + }), + ); + const user = userEvent.setup(); + renderCorrectAt("/events/evt-1/checkin/launch"); + await screen.findByTestId("launch-ceremony"); + + await waitFor(() => expect(screen.getByRole("button", { name: "Test badge" })).toBeEnabled()); + await user.click(screen.getByRole("button", { name: "Test badge" })); + + await waitFor(() => expect(screen.getByRole("button", { name: "Print test badge" })).toBeEnabled()); + await user.click(screen.getByRole("button", { name: "Print test badge" })); + + await waitFor(() => expect(printedZpl).not.toBeNull()); + expect(printedZpl).toContain("^PW1063"); + expect(printedZpl).toContain("^LL650"); + }); + }); + + // PR #77 bot-review round 3, Finding 1 -- `settingsSeededRef` was a plain + // "seeded at all, ever" boolean, never reset per event. This router does + // NOT remount LaunchCeremony across a param-only eventId change (same + // premise as BadgeEditorPage.test.tsx's own cross-event navigation + // coverage), so navigating from one event's ceremony to another's must + // re-seed the form from the NEW event's real settings rather than leaving + // it stuck on the FIRST event's. + it("re-seeds the settings form from the new event's own settings after navigating to a different event's launch ceremony (no remount)", async () => { + server.use( + http.get("http://api.test/api/events/:id/checkin-settings", ({ params }) => { + const settings = + params.id === "evt-2" + ? { print_on_checkin: false, verdict_auto_dismiss_sec: 12, scan_input: "manual", manual_search_enabled: false } + : settingsResponse; + return HttpResponse.json({ settings }); + }), + ); + const router = renderCorrectAt("/events/evt-1/checkin/launch"); + await screen.findByTestId("launch-ceremony"); + await waitFor(() => expect(screen.getByRole("switch", { name: "Print badge on check-in" })).toBeChecked()); + + await router.navigate({ to: "/events/$eventId/checkin/launch", params: { eventId: "evt-2" } }); + + await waitFor(() => expect(screen.getByRole("switch", { name: "Print badge on check-in" })).not.toBeChecked()); + expect(screen.getByLabelText("Verdict auto-dismiss (seconds)")).toHaveValue(12); + expect(screen.getByLabelText("Scan input")).toHaveValue("manual"); + expect(screen.getByRole("switch", { name: "Allow manual search" })).not.toBeChecked(); + }); + + it("sends a null zone_id when no zone is picked", async () => { + readinessResponse = { ready: true, steps: [{ key: "attendees", status: "done", count: 10 }] }; + const user = userEvent.setup(); + renderCorrectAt("/events/evt-1/checkin/launch"); + await screen.findByTestId("launch-ceremony"); + await waitFor(() => expect(screen.getByRole("button", { name: "Start check-in" })).toBeEnabled()); + + await user.click(screen.getByRole("button", { name: "Start check-in" })); + + await waitFor(() => expect(capturedStationRegister?.zone_id).toBeNull()); + }); +}); diff --git a/panel/src/features/checkin/LaunchCeremony.tsx b/panel/src/features/checkin/LaunchCeremony.tsx new file mode 100644 index 00000000..6f1360ec --- /dev/null +++ b/panel/src/features/checkin/LaunchCeremony.tsx @@ -0,0 +1,460 @@ +// P4.1 Task 11 -- the launch ceremony (board 2a). Reached from the +// workspace (EventWorkspaceLayout.tsx's header CTA, WorkspaceRail.tsx's +// pinned rail-bottom row) once the readiness rail shows enough green to be +// worth confirming, or before that -- the CTA lock is the ONLY gate, every +// other affordance here (settings, printer check) is usable regardless of +// `ready` so an operator can get everything dialed in ahead of time. +// +// Registered in app/router.tsx as `eventCheckinLaunchRoute`, a TOP-LEVEL +// protected route -- a SIBLING of `eventWorkspaceRoute` (mirrors Task 8's +// `eventCheckinRoute` registration exactly, per this task's own brief) -- +// so this page renders WITHOUT the workspace rail shell, same as the +// station itself. +// +// Three columns (board 2a): +// 1. Confirm event & station -- event name, a station-name input (a +// suggested default, freely editable), an optional zone picker. +// 2. Check-in settings -- Task 5's four `CheckinSettings` fields, editable +// with a scoped PUT (GeneralCard's baseline/dirty/Save pattern -- see +// that file's own comment -- adapted for a PUT-the-whole-object body +// rather than a partial PATCH, since CheckinSettings has no optional +// fields server-side). +// 3. Printer check -- the P3.2 agent connectivity status plus a "Test +// badge" action. This REUSES TestPrintDialog verbatim (zero +// print-generation logic duplicated, per this task's brief): the +// event's SAVED badge template (`useBadgeTemplate`, not a live editor +// doc -- there's no editor open here) is round-tripped through +// `parseTemplateDoc`/`serializeTemplateDoc` into the exact `doc`/ +// `config` shape TestPrintDialog already expects, and the preview +// data is `usePreviewAttendee`'s own real-first-attendee-or- +// SAMPLE_PERSONA resolution -- the same "sample/preview attendee" the +// badge editor's own test-print trigger uses, so a "Test badge" print +// here NEVER bumps a real attendee's printed_count (TestPrintDialog's +// own established rule: a test print isn't a badge going out the +// door). +// +// "Start check-in": disabled while `!readiness.data?.ready` (frontend-only +// lock -- plan-time fact #8, no server gate). On click: registers the +// station (upsert by name -- Task 5's useRegisterStation), then navigates +// to the station with `?station=`. Heartbeat is NOT started here -- +// Task 12's `useHeartbeat` mounts on StationPage itself once it loads, +// per the design spec's own "heartbeat every 20s while the station PAGE is +// mounted" wording. +import * as React from "react"; +import { + AgentStatus, Button, Card, CardContent, CardHeader, CardTitle, Input, Label, Skeleton, Switch, +} from "@idento/ui"; +import { Link, getRouteApi, useNavigate } from "@tanstack/react-router"; +import { useTranslation } from "react-i18next"; +import { useAgentPrinters } from "../../shared/agent/useAgentPrinters"; +import { $api } from "../../shared/api/query"; +import { useEventZones } from "../attendees/hooks"; +import { TestPrintDialog } from "../badge/TestPrintDialog"; +import { useBadgeTemplate } from "../badge/hooks"; +import { parseTemplateDoc, resolveBadgeConfig, serializeTemplateDoc } from "../badge/templateTypes"; +import { usePreviewAttendee } from "../badge/usePreviewAttendee"; +import { useEventReadiness } from "../events/hooks"; +import { zoneIdentity } from "../../shared/lib/zoneIdentity"; +import { useCheckinSettings, useRegisterStation, useSaveCheckinSettings } from "./hooks"; +import { DEFAULT_CHECKIN_SETTINGS, type CheckinSettings } from "./settingsTypes"; + +// Same getRouteApi-by-string-id rationale as StationPage.tsx -- avoids a +// circular import with app/router.tsx (which imports THIS component for +// the route's `component:` field). +const routeApi = getRouteApi("/_app/events/$eventId/checkin/launch"); + +// Native setStationName(e.target.value)} + /> + {trimmedStationName === "" ? ( +

{t("launchStationNameRequired")}

+ ) : null} + +
+ + +
+ + + + + + {t("launchColSettingsTitle")} + + + {/* PR #77 bot-review round, Finding N -- every control below stays + disabled until settingsQuery has actually resolved: editing a + form still seeded from DEFAULT_CHECKIN_SETTINGS (not yet the + event's real saved values) invites a whole-object Save that + clobbers those real values the instant they'd otherwise + arrive. `settingsQuery.isLoading` shows an explicit loading + hint in place of a silently-inert form (an error state falls + back to the SAME disabled controls -- the operator can't + usefully seed real values here either, `settingsQuery.error` + has no retry surfaced yet, so this stays simple rather than + inventing a bespoke error+retry UI this task didn't ask for). */} + {settingsQuery.isLoading ? ( +

+ {t("checkinSettingsLoading")} +

+ ) : null} +
+ + updateSetting("print_on_checkin", next)} + /> +
+
+ + updateSetting("verdict_auto_dismiss_sec", Number(e.target.value))} + /> +
+
+ + +
+
+ + updateSetting("manual_search_enabled", next)} + /> +
+ {saveSettings.isError ?

{t("settingsSaveError")}

: null} +
+ + {settingsSaved ? {t("settingsSaved")} : null} +
+
+
+ + + + {t("launchColPrinterTitle")} + + + + {!hasTemplate ?

{t("launchTestBadgeNoTemplate")}

: null} + +
+
+ + +
+ {registerStation.isError ?

{t("launchRegisterError")}

: null} + {!ready ? ( +

{t("workspaceUnlockHint")}

+ ) : null} + {/* PR #77 bot-review round 2, Finding 3 -- explains why Start is + disabled even once `ready` is true: an unsaved settings edit (or + a save still in flight) would otherwise be silently discarded by + navigating to the station, which reads the PERSISTED settings. */} + {ready && (settingsDirty || saveSettings.isPending) ? ( +

+ {t("launchUnsavedSettingsHint")} +

+ ) : null} + +
+ + + + ); +} diff --git a/panel/src/features/checkin/RecentScansRail.test.tsx b/panel/src/features/checkin/RecentScansRail.test.tsx new file mode 100644 index 00000000..c523d555 --- /dev/null +++ b/panel/src/features/checkin/RecentScansRail.test.tsx @@ -0,0 +1,683 @@ +// P4.1 Task 9 -- the check-in station's recent-scans rail. Fills +// StationPage.tsx's placeholder aside (Task 8) with the last-50 +// checkin-actions feed and its per-row Reprint/Undo/Details actions. +// +// Every test in this file mounts usePrintBadge's own useBadgeTemplate/ +// useEventFontFaces calls AND useAgentPrinters(true) unconditionally +// (same rationale as AttendeeDrawer.test.tsx's own top-of-file comment -- +// RecentScansRail's Reprint action is built on the exact same P3.2 +// usePrintBadge pipeline), so every test needs those endpoints mocked +// regardless of whether it exercises printing. +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { RouterContextProvider, createRootRoute, createRouter } from "@tanstack/react-router"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { delay, http, HttpResponse } from "msw"; +import type { ReactNode } from "react"; +import { RecentScansRail } from "./RecentScansRail"; +import { useAttendeesPage } from "../attendees/hooks"; +import { agentClient, AgentPrintTimeoutError } from "../../shared/agent/agentClient"; +import { startMswServer } from "../../test/msw"; +import type { components } from "../../shared/api/schema"; +import "../../shared/i18n"; + +type CheckinActionRow = components["schemas"]["CheckinActionRow"]; +type Attendee = components["schemas"]["Attendee"]; + +// jsdom implements neither `FontFace` nor `document.fonts` -- without this +// stub, useEventFontFaces' own status never leaves "idle" (see that hook's +// module comment), which would permanently block Reprint's confirm button +// (gated on fontsStatus reaching a terminal "ready"/"error" state). Same +// MockFontFace/stub/unstub helpers as AttendeeDrawer.test.tsx's own Task 8 +// reprint block. +class MockFontFace { + family: string; + constructor(family: string, _source: unknown, _descriptors?: { weight?: string; style?: string }) { + this.family = family; + } + load(): Promise { + return Promise.resolve(this); + } +} +function stubFontFaceApi() { + (globalThis as unknown as { FontFace: unknown }).FontFace = MockFontFace; + Object.defineProperty(document, "fonts", { value: { add: () => {} }, configurable: true, writable: true }); +} +function unstubFontFaceApi() { + delete (globalThis as unknown as { FontFace?: unknown }).FontFace; + // @ts-expect-error -- test-only cleanup of the jsdom `document.fonts` + // stub; real jsdom has no `fonts` property to restore. + delete document.fonts; +} + +const TEMPLATE_DOC = { + width_mm: 90, + height_mm: 55, + dpi: 300, + elements: [{ id: "e1", type: "text", x: 0, y: 0, fontSize: 10, source: "first_name", text: "Guest" }], +}; + +// Deliberately NOT chronological-alphabetical (Ada / Grace / Alan) and NOT +// re-sortable by name either -- proves the rail trusts the server's +// newest-first order verbatim rather than re-deriving it client-side (same +// "API order trusted" convention as AttendeeDrawer.tsx's recent-activity +// list). +const ROW_CHECKIN: CheckinActionRow = { + id: "row-1", + action: "checkin", + station_id: "st-1", + created_at: "2026-07-17T12:34:00Z", + attendee: { id: "att-1", first_name: "Ada", last_name: "Lovelace", code: "CODE1" }, +}; +const ROW_REPRINT: CheckinActionRow = { + id: "row-2", + action: "reprint", + station_id: "st-1", + created_at: "2026-07-17T12:30:00Z", + attendee: { id: "att-2", first_name: "Grace", last_name: "Hopper", code: "CODE2" }, +}; +const ROW_UNDO: CheckinActionRow = { + id: "row-3", + action: "undo", + station_id: null, + created_at: "2026-07-17T12:00:00Z", + attendee: { id: "att-3", first_name: "Alan", last_name: "Turing", code: "CODE3" }, +}; + +const ADA_FULL: Attendee = { + id: "att-1", + event_id: "evt-1", + first_name: "Ada", + last_name: "Lovelace", + email: "ada@example.com", + company: "Analytical Engines", + position: "Engineer", + code: "CODE1", + checkin_status: true, + printed_count: 0, + blocked: false, + packet_delivered: false, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", +}; + +let actionsRows: CheckinActionRow[] = [ROW_CHECKIN, ROW_REPRINT, ROW_UNDO]; +let actionsHitCount = 0; +let actionsStatus = 200; +// PR #77 bot-review round, Finding S -- tracks GET /api/events/:eventId/ +// attendees hits so a test can prove the attendees-LIST invalidation (not +// just the feed's) actually happened after Undo, mirroring how +// `actionsHitCount` is already tracked above. +let attendeesFeedHitCount = 0; + +// Disconnected by default (mirrors AttendeeDrawer.test.tsx's own baseline) -- +// individual reprint tests flip this to true. +let agentHealthOk = false; +let printersResponse: Array<{ name: string; type: string }> = []; +let defaultPrinterResponse: { default: string | null } = { default: null }; +let printCapture: { printer_name: string; zpl: string } | null = null; +let printStatus = 200; +let printDelayMs = 0; +let markPrintedHitCount = 0; +let lastMarkPrintedBody: unknown; + +let undoHitCount = 0; +let lastUndoBody: unknown; +let undoDelayMs = 0; +let undoStatus = 200; + +const server = startMswServer( + http.get("http://api.test/api/events/:eventId/checkin-actions", () => { + actionsHitCount += 1; + if (actionsStatus !== 200) return new HttpResponse(null, { status: actionsStatus }); + return HttpResponse.json({ actions: actionsRows }); + }), + http.post("http://api.test/api/events/:eventId/checkin/undo", async ({ request }) => { + undoHitCount += 1; + lastUndoBody = await request.json(); + if (undoDelayMs) await delay(undoDelayMs); + if (undoStatus !== 200) return new HttpResponse(null, { status: undoStatus }); + return HttpResponse.json({ attendee: { ...ADA_FULL, checkin_status: false } }); + }), + http.get("http://api.test/api/events/:eventId/attendees", () => { + attendeesFeedHitCount += 1; + return HttpResponse.json([]); + }), + http.get("http://api.test/api/attendees/:id", ({ params }) => { + if (params.id === "att-1") return HttpResponse.json(ADA_FULL); + return new HttpResponse(null, { status: 404 }); + }), + http.get("http://api.test/api/events/:id/badge-template", () => + HttpResponse.json({ template: TEMPLATE_DOC, version: 1 }), + ), + http.get("http://api.test/api/events/:eventId/fonts", () => HttpResponse.json([])), + http.post("http://api.test/api/attendees/:attendeeId/printed", async ({ request }) => { + markPrintedHitCount += 1; + lastMarkPrintedBody = await request.json().catch(() => undefined); + return HttpResponse.json({ printed_count: 1 }); + }), + http.get("http://agent.test/health", () => + agentHealthOk ? new HttpResponse(null, { status: 200 }) : new HttpResponse(null, { status: 503 }), + ), + http.get("http://agent.test/printers", () => HttpResponse.json(printersResponse)), + http.get("http://agent.test/printers/default", () => HttpResponse.json(defaultPrinterResponse)), + http.post("http://agent.test/print", async ({ request }) => { + const body = (await request.json()) as { printer_name: string; zpl: string }; + printCapture = body; + if (printDelayMs) await delay(printDelayMs); + if (printStatus !== 200) return HttpResponse.text("printer offline", { status: printStatus }); + return HttpResponse.json({ status: "printed" }); + }), +); +void server; + +// Mounted alongside RecentScansRail in the undo tests, exactly like +// AttendeeDrawer.test.tsx's AttendeesListObserver -- proves invalidation +// actually refetches a REAL subscribed observer elsewhere, not just an +// isolated invalidateQueries call. +function AttendeesListObserver() { + useAttendeesPage("evt-1", { page: 1 }); + return null; +} + +// The no-template/missing-font reprint errors link to the badge editor +// route, which needs a router context to resolve `Link` -- same minimal +// single-route harness as AttendeeDrawer.test.tsx's own testRouter (this +// suite exercises the rail's own rendering, not routing). +const testRouter = createRouter({ routeTree: createRootRoute({ component: () => null }) }); + +function railTree(queryClient: QueryClient, stationId: string | null, extra: ReactNode | undefined, online: boolean | undefined) { + return ( + + + {extra} + + + + ); +} + +function renderRail(stationId: string | null = "st-1", extra?: ReactNode, online?: boolean) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const utils = render(railTree(queryClient, stationId, extra, online)); + return { + queryClient, + ...utils, + // PR #77 bot-review round, Finding L -- lets a test flip `online` on an + // ALREADY-rendered rail (a dialog opened while online, THEN connectivity + // drops) without losing the queryClient/router identity `rerender` + // would otherwise require reconstructing by hand at every call site. + rerenderOnline: (nextOnline: boolean) => utils.rerender(railTree(queryClient, stationId, extra, nextOnline)), + }; +} + +describe("RecentScansRail", () => { + beforeEach(() => { + window.__ENV__ = { API_URL: "http://api.test", AGENT_URL: "http://agent.test" }; + localStorage.clear(); + localStorage.setItem("token", "jwt-test"); + actionsRows = [ROW_CHECKIN, ROW_REPRINT, ROW_UNDO]; + actionsHitCount = 0; + attendeesFeedHitCount = 0; + actionsStatus = 200; + agentHealthOk = false; + printersResponse = []; + defaultPrinterResponse = { default: null }; + printCapture = null; + printStatus = 200; + printDelayMs = 0; + markPrintedHitCount = 0; + lastMarkPrintedBody = undefined; + undoHitCount = 0; + lastUndoBody = undefined; + undoDelayMs = 0; + undoStatus = 200; + }); + + it("renders the last-50 feed newest-first (attendee name, code, action label, time), trusting the server's own order", async () => { + renderRail(); + + const rows = await screen.findAllByTestId("checkin-rail-row"); + expect(rows).toHaveLength(3); + + expect(within(rows[0]).getByText("Ada Lovelace")).toBeInTheDocument(); + expect(within(rows[0]).getByText("CODE1")).toBeInTheDocument(); + expect(within(rows[0]).getByText("Checked in")).toBeInTheDocument(); + expect(within(rows[0]).getByText("12:34")).toBeInTheDocument(); + + expect(within(rows[1]).getByText("Grace Hopper")).toBeInTheDocument(); + expect(within(rows[1]).getByText("Reprinted")).toBeInTheDocument(); + + expect(within(rows[2]).getByText("Alan Turing")).toBeInTheDocument(); + expect(within(rows[2]).getByText("Undone")).toBeInTheDocument(); + }); + + it("shows an empty state when there are no scans yet", async () => { + actionsRows = []; + renderRail(); + + expect(await screen.findByText("No scans yet.")).toBeInTheDocument(); + expect(screen.queryByTestId("checkin-rail-row")).not.toBeInTheDocument(); + }); + + it("shows an honest error state when the feed fails to load", async () => { + actionsStatus = 500; + renderRail(); + + expect(await screen.findByText("Couldn't load recent scans.")).toBeInTheDocument(); + }); + + describe("Details popover", () => { + it("shows the attendee's name, code, and (for a checkin row) the first-scan time", async () => { + const user = userEvent.setup(); + renderRail(); + const rows = await screen.findAllByTestId("checkin-rail-row"); + + await user.click(within(rows[0]).getByRole("button", { name: "Details" })); + + const popover = await screen.findByRole("menu"); + expect(within(popover).getByText("Ada Lovelace")).toBeInTheDocument(); + expect(within(popover).getByText("CODE1")).toBeInTheDocument(); + expect(within(popover).getByText("First checked in at 12:34")).toBeInTheDocument(); + }); + + it("shows an undone/reprinted-specific time line for undo/reprint rows instead of the first-scan copy", async () => { + const user = userEvent.setup(); + renderRail(); + const rows = await screen.findAllByTestId("checkin-rail-row"); + + await user.click(within(rows[2]).getByRole("button", { name: "Details" })); + const popover = await screen.findByRole("menu"); + expect(within(popover).getByText("Check-in undone at 12:00")).toBeInTheDocument(); + }); + }); + + describe("Reprint", () => { + beforeEach(() => stubFontFaceApi()); + afterEach(() => unstubFontFaceApi()); + + it("disables Reprint (with a discoverable reason) when the print agent is unreachable", async () => { + renderRail(); + const rows = await screen.findAllByTestId("checkin-rail-row"); + + const reprintButton = within(rows[0]).getByRole("button", { name: "Reprint" }); + await waitFor(() => expect(reprintButton).toBeDisabled()); + expect(reprintButton).toHaveAttribute("title", "Can't reach the local print agent."); + }); + + it("names the agent's default printer, sends on confirm with the printContext body, and refetches the feed", async () => { + agentHealthOk = true; + printersResponse = [{ name: "Zebra_ZD421", type: "system" }]; + defaultPrinterResponse = { default: "Zebra_ZD421" }; + const user = userEvent.setup(); + renderRail("st-1"); + const rows = await screen.findAllByTestId("checkin-rail-row"); + await waitFor(() => expect(actionsHitCount).toBe(1)); + + const reprintButton = within(rows[0]).getByRole("button", { name: "Reprint" }); + await waitFor(() => expect(reprintButton).toBeEnabled()); + await user.click(reprintButton); + + const dialog = await screen.findByRole("dialog", { name: "Reprint badge" }); + expect(within(dialog).getByText("Print Ada Lovelace's badge on Zebra_ZD421?")).toBeInTheDocument(); + await user.click(within(dialog).getByRole("button", { name: "Print" })); + + await waitFor(() => expect(printCapture).not.toBeNull()); + expect(printCapture?.printer_name).toBe("Zebra_ZD421"); + expect(printCapture?.zpl).toContain("^FDAda^FS"); + await waitFor(() => expect(markPrintedHitCount).toBe(1)); + expect(lastMarkPrintedBody).toEqual({ event_id: "evt-1", station_id: "st-1" }); + + await waitFor(() => expect(screen.queryByRole("dialog", { name: "Reprint badge" })).not.toBeInTheDocument()); + // The station's own reprint refetches the feed (P4.2 upgrades this to + // SSE) -- the newly-logged 'reprint' row must not wait for some + // unrelated invalidation. + await waitFor(() => expect(actionsHitCount).toBeGreaterThan(1)); + }); + + it("blocks every dismissal path (Escape, outside click, Cancel) while a print is in flight", async () => { + agentHealthOk = true; + printersResponse = [{ name: "Zebra_ZD421", type: "system" }]; + defaultPrinterResponse = { default: "Zebra_ZD421" }; + // 300ms, not the original 40ms: this test performs THREE sequential + // userEvent interactions (Cancel click, Escape keypress, + // pointerDown) plus a waitFor before its final assertion, entirely + // on real timers (this codebase deliberately avoids fake timers for + // MSW-async tests, see useHeartbeat.test.tsx/useConnectionState.test.tsx's + // own documented reasoning) -- 40ms flaked in CI (PR #77 CI run + // 29632317448) because that whole sequence can legitimately exceed + // 40ms of real wall-clock time under CI's slower/more loaded + // runners, letting the mock mutation genuinely resolve mid-sequence + // and close the dialog via its OWN success path, not because + // dismissal-blocking failed. 300ms matches this file's/this + // feature's own precedent for a similar-shaped "hold pending across + // multiple interactions" test (useCheckinFlow.test.tsx uses 400ms). + printDelayMs = 300; + const user = userEvent.setup(); + renderRail("st-1"); + const rows = await screen.findAllByTestId("checkin-rail-row"); + + const reprintButton = within(rows[0]).getByRole("button", { name: "Reprint" }); + await waitFor(() => expect(reprintButton).toBeEnabled()); + await user.click(reprintButton); + const dialog = await screen.findByRole("dialog", { name: "Reprint badge" }); + const confirmButton = within(dialog).getByRole("button", { name: "Print" }); + await user.click(confirmButton); + + await waitFor(() => expect(confirmButton).toBeDisabled()); + // Cancel is inert while sending -- the dialog stays open throughout. + await user.click(within(dialog).getByRole("button", { name: "Cancel" })); + expect(screen.getByRole("dialog", { name: "Reprint badge" })).toBe(dialog); + expect(within(dialog).getByText(/can't be cancelled/)).toBeInTheDocument(); + + // PR #77 bot-review round, Finding R -- the title above already + // promised Escape and outside click too, but only Cancel was ever + // exercised. Both are also inert while sending. + await user.keyboard("{Escape}"); + expect(screen.getByRole("dialog", { name: "Reprint badge" })).toBe(dialog); + + // Radix marks the rest of the page `pointer-events: none` while its + // Dialog is open, so a real `userEvent.click` on `document.body` + // fails jsdom's own pointer-events check before it can even simulate + // the interaction -- `fireEvent.pointerDown` dispatches the SAME + // event Radix's DismissableLayer actually listens for (its own + // `handlePointerDown`) without going through that hover/move + // pipeline. + fireEvent.pointerDown(document.body); + expect(screen.getByRole("dialog", { name: "Reprint badge" })).toBe(dialog); + + await waitFor(() => expect(screen.queryByRole("dialog", { name: "Reprint badge" })).not.toBeInTheDocument()); + }); + + it("shows the honest may-still-print timeout copy (not the raw client message) when the send times out", async () => { + agentHealthOk = true; + printersResponse = [{ name: "Zebra_ZD421", type: "system" }]; + defaultPrinterResponse = { default: "Zebra_ZD421" }; + const printSpy = vi.spyOn(agentClient, "print").mockRejectedValue(new AgentPrintTimeoutError(30_000)); + try { + const user = userEvent.setup(); + renderRail("st-1"); + const rows = await screen.findAllByTestId("checkin-rail-row"); + const reprintButton = within(rows[0]).getByRole("button", { name: "Reprint" }); + await waitFor(() => expect(reprintButton).toBeEnabled()); + await user.click(reprintButton); + const dialog = await screen.findByRole("dialog", { name: "Reprint badge" }); + await user.click(within(dialog).getByRole("button", { name: "Print" })); + + expect( + await within(dialog).findByText( + "The print agent didn't respond. The badge may still print — check the printer before retrying.", + ), + ).toBeInTheDocument(); + expect(markPrintedHitCount).toBe(0); + } finally { + printSpy.mockRestore(); + } + }); + + it("shows an honest no-template message linking the badge editor, and never calls the agent, when the event has no saved template", async () => { + agentHealthOk = true; + printersResponse = [{ name: "Zebra_ZD421", type: "system" }]; + defaultPrinterResponse = { default: "Zebra_ZD421" }; + server.use( + http.get("http://api.test/api/events/:id/badge-template", () => + HttpResponse.json({ template: null, version: 0 }), + ), + ); + const user = userEvent.setup(); + renderRail("st-1"); + const rows = await screen.findAllByTestId("checkin-rail-row"); + const reprintButton = within(rows[0]).getByRole("button", { name: "Reprint" }); + await waitFor(() => expect(reprintButton).toBeEnabled()); + await user.click(reprintButton); + const dialog = await screen.findByRole("dialog", { name: "Reprint badge" }); + await user.click(within(dialog).getByRole("button", { name: "Print" })); + + expect(await within(dialog).findByText(/doesn.t have a badge template yet/)).toBeInTheDocument(); + expect(within(dialog).getByRole("link", { name: "Open the badge editor" })).toHaveAttribute( + "href", + "/events/evt-1/badge", + ); + expect(printCapture).toBeNull(); + expect(screen.getByRole("dialog", { name: "Reprint badge" })).toBe(dialog); + }); + }); + + describe("Undo", () => { + it("confirms, POSTs the undo with {attendee_id, station_id}, and both the feed and the attendees list refetch (subscribed observers)", async () => { + const user = userEvent.setup(); + renderRail("st-1", ); + const rows = await screen.findAllByTestId("checkin-rail-row"); + await waitFor(() => expect(actionsHitCount).toBe(1)); + // PR #77 bot-review round, Finding S -- the title above claims BOTH + // the feed AND the attendees-list refetch, but only the feed's hit + // count was ever asserted; capture the attendees-list baseline too so + // its OWN increase can be proven below. + await waitFor(() => expect(attendeesFeedHitCount).toBeGreaterThan(0)); + const attendeesHitsBeforeUndo = attendeesFeedHitCount; + + await user.click(within(rows[0]).getByRole("button", { name: "Undo" })); + const dialog = await screen.findByRole("dialog", { name: "Undo check-in" }); + expect(within(dialog).getByText(/Ada Lovelace/)).toBeInTheDocument(); + await user.click(within(dialog).getByRole("button", { name: "Undo check-in" })); + + await waitFor(() => expect(undoHitCount).toBe(1)); + expect(lastUndoBody).toEqual({ attendee_id: "att-1", station_id: "st-1" }); + await waitFor(() => expect(screen.queryByRole("dialog", { name: "Undo check-in" })).not.toBeInTheDocument()); + + await waitFor(() => expect(actionsHitCount).toBeGreaterThan(1)); + await waitFor(() => expect(attendeesFeedHitCount).toBeGreaterThan(attendeesHitsBeforeUndo)); + }); + + // PR #77 bot-review round, Finding R -- previously only exercised + // Cancel; Escape and outside-click are the OTHER two dismissal paths + // this exact P3.2 convention is supposed to block too. + it("blocks dismissal while the undo is in flight (Cancel, Escape, and outside click), same convention as reprint", async () => { + // 300ms, not the original 40ms -- see the identical reasoning on + // printDelayMs above (this test has the same Cancel/Escape/ + // pointerDown/waitFor shape); this exact test flaked in CI + // (run 29632317448) at the original 40ms. + undoDelayMs = 300; + const user = userEvent.setup(); + renderRail("st-1"); + const rows = await screen.findAllByTestId("checkin-rail-row"); + + await user.click(within(rows[0]).getByRole("button", { name: "Undo" })); + const dialog = await screen.findByRole("dialog", { name: "Undo check-in" }); + const confirmButton = within(dialog).getByRole("button", { name: "Undo check-in" }); + await user.click(confirmButton); + + await waitFor(() => expect(confirmButton).toBeDisabled()); + + await user.click(within(dialog).getByRole("button", { name: "Cancel" })); + expect(screen.getByRole("dialog", { name: "Undo check-in" })).toBe(dialog); + + await user.keyboard("{Escape}"); + expect(screen.getByRole("dialog", { name: "Undo check-in" })).toBe(dialog); + + // Radix marks the rest of the page `pointer-events: none` while its + // Dialog is open, so a real `userEvent.click` on `document.body` + // fails jsdom's own pointer-events check before it can even simulate + // the interaction -- `fireEvent.pointerDown` dispatches the SAME + // event Radix's DismissableLayer actually listens for (its own + // `handlePointerDown`) without going through that hover/move + // pipeline. + fireEvent.pointerDown(document.body); + expect(screen.getByRole("dialog", { name: "Undo check-in" })).toBe(dialog); + + await waitFor(() => expect(screen.queryByRole("dialog", { name: "Undo check-in" })).not.toBeInTheDocument()); + }); + + it("keeps the confirm dialog open with an inline error when the undo fails", async () => { + undoStatus = 500; + const user = userEvent.setup(); + renderRail("st-1"); + const rows = await screen.findAllByTestId("checkin-rail-row"); + + await user.click(within(rows[0]).getByRole("button", { name: "Undo" })); + const dialog = await screen.findByRole("dialog", { name: "Undo check-in" }); + await user.click(within(dialog).getByRole("button", { name: "Undo check-in" })); + + expect(await within(dialog).findByText("Couldn't undo the check-in. Try again.")).toBeInTheDocument(); + expect(screen.getByRole("dialog", { name: "Undo check-in" })).toBe(dialog); + }); + }); + + // Regression test for a task-9 review finding: `anyMutationPending` + // (reprintPrinting || undoCheckin.isPending) is still false while a + // dialog is merely OPEN but not yet confirmed, so the per-row trigger + // buttons -- gated only on that flag -- previously let a user open BOTH + // dialog types at once (e.g. open row A's Reprint dialog, then click row + // B's still-enabled Undo trigger), each confirmable independently against + // its own pending flag. That could fire a reprint and an undo + // concurrently, potentially against the SAME attendee. The fix gates the + // trigger buttons on `reprintTarget !== null || undoTarget !== null` too, + // so once either dialog is open (confirmed or not), no other row's + // trigger can open a second, competing dialog. + describe("Dialog mutual exclusion", () => { + beforeEach(() => stubFontFaceApi()); + afterEach(() => unstubFontFaceApi()); + + it("disables every other row's Undo trigger while a Reprint dialog is open but not yet confirmed", async () => { + agentHealthOk = true; + printersResponse = [{ name: "Zebra_ZD421", type: "system" }]; + defaultPrinterResponse = { default: "Zebra_ZD421" }; + const user = userEvent.setup(); + renderRail("st-1"); + const rows = await screen.findAllByTestId("checkin-rail-row"); + + const reprintButtonRow0 = within(rows[0]).getByRole("button", { name: "Reprint" }); + await waitFor(() => expect(reprintButtonRow0).toBeEnabled()); + await user.click(reprintButtonRow0); + await screen.findByRole("dialog", { name: "Reprint badge" }); + + // Neither mutation has actually started (reprintPrinting is still + // false -- the dialog is idle, unconfirmed) -- yet row 1's Undo + // trigger must already be disabled, since confirming it would open a + // second, independent dialog concurrently with the open Reprint one. + // Radix marks the rest of the page `aria-hidden` while its Dialog is + // open, so `{ hidden: true }` is needed here to still query the + // (inert-to-screen-readers, but still DOM-present) row underneath. + const undoButtonRow1 = within(rows[1]).getByRole("button", { name: "Undo", hidden: true }); + expect(undoButtonRow1).toBeDisabled(); + }); + + it("disables every other row's Reprint trigger while an Undo dialog is open but not yet confirmed", async () => { + agentHealthOk = true; + printersResponse = [{ name: "Zebra_ZD421", type: "system" }]; + defaultPrinterResponse = { default: "Zebra_ZD421" }; + const user = userEvent.setup(); + renderRail("st-1"); + const rows = await screen.findAllByTestId("checkin-rail-row"); + + // Confirm the agent has already finished its own reachability check + // (Reprint enabled) BEFORE opening the Undo dialog, so the later + // "disabled" assertion is caused by the Undo dialog being open, not + // by Reprint's reachability check simply not having resolved yet. + const reprintButtonRow1 = within(rows[1]).getByRole("button", { name: "Reprint" }); + await waitFor(() => expect(reprintButtonRow1).toBeEnabled()); + + const undoButtonRow0 = within(rows[0]).getByRole("button", { name: "Undo" }); + await user.click(undoButtonRow0); + await screen.findByRole("dialog", { name: "Undo check-in" }); + + // undoCheckin.isPending is still false here (unconfirmed) -- row 1's + // Reprint trigger must already be disabled for the same reason as + // above, in the opposite direction. Radix marks the rest of the page + // `aria-hidden` while its Dialog is open, so `{ hidden: true }` is + // needed here to still query the row underneath. + expect(within(rows[1]).getByRole("button", { name: "Reprint", hidden: true })).toBeDisabled(); + }); + }); + + // P4.1 Task 10 -- degraded mode: StationPage forwards its own + // useConnectionState(eventId).online down to this rail so Undo/Reprint + // are DISABLED (never attempted) while the station is offline -- these + // rows don't own their own connectivity signal, they just render whatever + // `online` they're given (default `true`, so every OTHER describe block + // above -- none of which pass `online` -- is completely unaffected). + describe("online prop (degraded mode)", () => { + it("disables both Undo and Reprint when online={false}, regardless of agent/print-agent reachability", async () => { + agentHealthOk = true; + printersResponse = [{ name: "Zebra_ZD421", type: "system" }]; + defaultPrinterResponse = { default: "Zebra_ZD421" }; + renderRail("st-1", undefined, false); + const rows = await screen.findAllByTestId("checkin-rail-row"); + + const undoButton = within(rows[0]).getByRole("button", { name: "Undo" }); + const reprintButton = within(rows[0]).getByRole("button", { name: "Reprint" }); + await waitFor(() => expect(undoButton).toBeDisabled()); + expect(reprintButton).toBeDisabled(); + }); + + it("leaves Undo/Reprint gated only by the usual reasons when online is omitted (default true)", async () => { + renderRail("st-1"); + const rows = await screen.findAllByTestId("checkin-rail-row"); + + // Reprint is still disabled here (the print agent is unreachable by + // this suite's own default), but for the PRE-EXISTING reason, not a + // connectivity one -- Undo (which has no agent dependency) is enabled. + const undoButton = within(rows[0]).getByRole("button", { name: "Undo" }); + await waitFor(() => expect(undoButton).toBeEnabled()); + }); + }); + + // PR #77 bot-review round, Finding L -- `online` previously only disabled + // the per-row TRIGGER buttons that OPEN the Reprint/Undo dialogs (Task + // 10's original fix, exercised above). If connectivity drops AFTER a + // dialog is already open but BEFORE the operator confirms, the confirm + // button and its handler were still enabled/reachable -- allowing exactly + // the offline reprint/undo mutation the degraded-mode contract says must + // be blocked. + describe("connectivity drop after a dialog is already open", () => { + beforeEach(() => stubFontFaceApi()); + afterEach(() => unstubFontFaceApi()); + + it("disables the Reprint confirm button once connectivity drops while the dialog is open, and the handler sends nothing", async () => { + agentHealthOk = true; + printersResponse = [{ name: "Zebra_ZD421", type: "system" }]; + defaultPrinterResponse = { default: "Zebra_ZD421" }; + const user = userEvent.setup(); + const { rerenderOnline } = renderRail("st-1", undefined, true); + const rows = await screen.findAllByTestId("checkin-rail-row"); + + const reprintButton = within(rows[0]).getByRole("button", { name: "Reprint" }); + await waitFor(() => expect(reprintButton).toBeEnabled()); + await user.click(reprintButton); + const dialog = await screen.findByRole("dialog", { name: "Reprint badge" }); + const confirmButton = within(dialog).getByRole("button", { name: "Print" }); + await waitFor(() => expect(confirmButton).toBeEnabled()); + + rerenderOnline(false); + + await waitFor(() => expect(within(dialog).getByRole("button", { name: "Print" })).toBeDisabled()); + // Defense-in-depth: even a direct DOM click event (bypassing whatever + // pointer-events a real disabled button would suppress) must never + // reach the network -- handleReprintConfirm's own `if (!online) + // return;` guard is what actually stops it. + fireEvent.click(within(dialog).getByRole("button", { name: "Print" })); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(printCapture).toBeNull(); + }); + + it("disables the Undo confirm button once connectivity drops while the dialog is open, and the handler sends nothing", async () => { + const user = userEvent.setup(); + const { rerenderOnline } = renderRail("st-1", undefined, true); + const rows = await screen.findAllByTestId("checkin-rail-row"); + + const undoButton = within(rows[0]).getByRole("button", { name: "Undo" }); + await waitFor(() => expect(undoButton).toBeEnabled()); + await user.click(undoButton); + const dialog = await screen.findByRole("dialog", { name: "Undo check-in" }); + const confirmButton = within(dialog).getByRole("button", { name: "Undo check-in" }); + expect(confirmButton).toBeEnabled(); + + rerenderOnline(false); + + await waitFor(() => expect(within(dialog).getByRole("button", { name: "Undo check-in" })).toBeDisabled()); + fireEvent.click(within(dialog).getByRole("button", { name: "Undo check-in" })); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(undoHitCount).toBe(0); + }); + }); +}); diff --git a/panel/src/features/checkin/RecentScansRail.tsx b/panel/src/features/checkin/RecentScansRail.tsx new file mode 100644 index 00000000..7852d991 --- /dev/null +++ b/panel/src/features/checkin/RecentScansRail.tsx @@ -0,0 +1,535 @@ +// P4.1 Task 9 -- the check-in station's recent-scans rail. Fills +// StationPage.tsx's placeholder aside (Task 8) with the last-50 +// checkin-actions feed (Task 5's useCheckinActions) and its per-row +// Reprint/Undo/Details actions. +// +// CheckinActionRow's own `attendee` field is a SLIM projection (id, +// first_name, last_name, code -- schema.d.ts's CheckinActionAttendee) -- +// NOT enough to print from (usePrintBadge's attendeeToPreviewData also +// needs email/company/position/custom_fields). Reprint therefore fetches +// the FULL Attendee via GET /api/attendees/{id} (attendees/hooks.ts's own +// query key) immediately before calling printAttendee, mirroring how +// usePrintBadge itself fetches the badge template via +// queryClient.fetchQuery rather than trusting a stale/absent `.data` +// snapshot. +// +// Reprint's confirm dialog is a hand-built Dialog (not the shared +// ConfirmDialog, which has no onEscapeKeyDown/onPointerDownOutside/ +// onInteractOutside/hideClose passthrough) that BLOCKS every dismissal +// path while the send is in flight -- the exact P3.2 PR-#74 convention +// AttendeeDrawer.tsx's own Reprint dialog established (see that file's own +// comment on `handleReprintOpenChange`). The plan's global constraints call +// this out explicitly for BOTH reprint and undo here, so Undo's confirm +// dialog is hand-built the same way, even though undo itself isn't a +// physical-output action -- consistency across this rail's two mutation +// confirms beats a bespoke lighter-weight dialog for just one of them. +import * as React from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; +import { + Button, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, + DropdownMenu, DropdownMenuContent, DropdownMenuTrigger, Label, Skeleton, +} from "@idento/ui"; +import { useTranslation } from "react-i18next"; +import { useAgentPrinters } from "../../shared/agent/useAgentPrinters"; +import { AgentPrintTimeoutError } from "../../shared/agent/agentClient"; +import { $api } from "../../shared/api/query"; +import type { components } from "../../shared/api/schema"; +import { MarkPrintedError, MissingFontError, NoTemplateError, usePrintBadge } from "../badge/zpl/usePrintBadge"; +import { CHECKIN_ACTIONS_KEY, useCheckinActions, useUndoCheckin } from "./hooks"; + +type CheckinActionRow = components["schemas"]["CheckinActionRow"]; + +// Native setReprintPrinter(event.target.value)} + > + {agent.printers.length === 0 ? ( + + ) : ( + agent.printers.map((printer) => ( + + )) + )} + + + ) : null} + {reprintPrinting ? {t("printNoCancelHint")} : null} + {printBadge.fontsStatus === "error" ? ( + {t("badgeFontsNotReady")} + ) : null} + {reprintError?.kind === "no-template" ? ( + + {t("checkinReprintNoTemplate")}{" "} + + {t("checkinReprintOpenEditor")} + + + ) : null} + {reprintError?.kind === "missing-font" ? ( + + {t("checkinReprintMissingFont", { families: reprintError.families.join(", ") })}{" "} + + {t("checkinReprintOpenEditor")} + + + ) : null} + {reprintError?.kind === "generic" ? ( + {reprintError.message} + ) : null} + + ) : null; + + return ( +
+

{t("checkinRailTitle")}

+ + {actionsQuery.isLoading ? ( +
+ + + +
+ ) : actionsQuery.isError ? ( +

{t("checkinRailLoadError")}

+ ) : rows.length === 0 ? ( +

{t("checkinRailEmpty")}

+ ) : ( +
    + {rows.map((row) => ( +
  • +
    + + {row.attendee.first_name} {row.attendee.last_name} + + {formatUtcHHMM(row.created_at)} +
    +
    + {row.attendee.code} + {t(ACTION_LABEL_KEY[row.action])} +
    +
    + + + + + + + + + {row.attendee.first_name} {row.attendee.last_name} + + {row.attendee.code} + + {row.action === "checkin" + ? t("checkinFirstScanAt", { time: formatUtcHHMM(row.created_at) }) + : row.action === "undo" + ? t("checkinDetailsUndoneAt", { time: formatUtcHHMM(row.created_at) }) + : t("checkinDetailsReprintedAt", { time: formatUtcHHMM(row.created_at) })} + + + +
    +
  • + ))} +
+ )} + + {reprintSent ? ( + + {reprintSent.warning + ? t("checkinReprintMarkPrintedWarning", { printer: reprintSent.printer }) + : t("printSentTo", { printer: reprintSent.printer })} + + ) : null} + + + + + {t("checkinReprintConfirmTitle")} + {reprintDescription} + + + + + + + + + + + + {t("checkinUndoConfirmTitle")} + + {undoTarget + ? t("checkinUndoConfirmBody", { + name: `${undoTarget.attendee.first_name} ${undoTarget.attendee.last_name}`, + }) + : null} + {undoError ? {t("checkinUndoError")} : null} + + + + + + + + +
+ ); +} diff --git a/panel/src/features/checkin/ScanInput.test.tsx b/panel/src/features/checkin/ScanInput.test.tsx new file mode 100644 index 00000000..32d69ed8 --- /dev/null +++ b/panel/src/features/checkin/ScanInput.test.tsx @@ -0,0 +1,347 @@ +// P4.1 Task 7 -- ScanInput tests. Renders the real useScanInput hook (not +// mocked) against the agent MSW origin, and the real useAttendeesPage +// (Task 5, unmodified) against the backend MSW origin -- same combined- +// origin convention as usePrintBadge.test.tsx / useCheckinFlow.test.tsx. +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { http, HttpResponse } from "msw"; +import type { ReactNode } from "react"; +import { ScanInput } from "./ScanInput"; +import { startMswServer } from "../../test/msw"; +import type { components } from "../../shared/api/schema"; +import "../../shared/i18n"; + +type Attendee = components["schemas"]["Attendee"]; + +const ADA: Attendee = { + id: "att-1", + event_id: "evt-1", + first_name: "Ada", + last_name: "Lovelace", + email: "ada@example.com", + company: "Analytical Engines", + position: "Engineer", + code: "PD-0107", + checkin_status: false, + printed_count: 0, + blocked: false, + packet_delivered: false, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", +}; + +let attendeesHitCount = 0; +let lastSearchParam: string | null = null; +let attendeesShouldError = false; +let scanLastResponse: { code: string; time: string } = { code: "", time: "0001-01-01T00:00:00Z" }; +let scanLastShouldError = false; + +const server = startMswServer( + http.get("http://api.test/api/events/:eventId/attendees", ({ request }) => { + attendeesHitCount += 1; + const url = new URL(request.url); + lastSearchParam = url.searchParams.get("search"); + if (attendeesShouldError) return new HttpResponse(null, { status: 500 }); + const matches = lastSearchParam && "Ada Lovelace ada@example.com PD-0107".includes(lastSearchParam) ? [ADA] : []; + return HttpResponse.json({ attendees: matches, total: matches.length, page: 1, per_page: 8 }); + }), + http.get("http://agent.test/scan/last", () => { + if (scanLastShouldError) return new HttpResponse(null, { status: 500 }); + return HttpResponse.json(scanLastResponse); + }), + http.post("http://agent.test/scan/clear", () => HttpResponse.json({ status: "cleared" })), +); +void server; + +function renderScanInput( + overrides: { + mode?: "wedge" | "scanner" | "manual"; + enabled?: boolean; + readOnly?: boolean; + manualSearchEnabled?: boolean; + } = {}, +) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const onCode = vi.fn(); + const onPickAttendee = vi.fn(); + function wrapper({ children }: { children: ReactNode }) { + return {children}; + } + const utils = render( + , + { wrapper }, + ); + return { ...utils, onCode, onPickAttendee }; +} + +const SEARCH_PLACEHOLDER = "Search by name, email, or code…"; + +describe("ScanInput", () => { + beforeEach(() => { + window.__ENV__ = { API_URL: "http://api.test", AGENT_URL: "http://agent.test" }; + attendeesHitCount = 0; + lastSearchParam = null; + attendeesShouldError = false; + scanLastResponse = { code: "", time: "0001-01-01T00:00:00Z" }; + scanLastShouldError = false; + }); + + it.each(["wedge", "scanner", "manual"] as const)( + "renders the manual search box in %s mode when manual_search_enabled is true (the default)", + (mode) => { + renderScanInput({ mode }); + expect(screen.getByPlaceholderText(SEARCH_PLACEHOLDER)).toBeInTheDocument(); + }, + ); + + it("wedge mode: renders a focused hidden scan input and emits onCode once on Enter", async () => { + const user = userEvent.setup(); + const { onCode } = renderScanInput({ mode: "wedge" }); + + const wedgeInput = screen.getByLabelText("Badge scanner input"); + expect(wedgeInput).toHaveFocus(); + + await user.type(wedgeInput, "PD-0107{Enter}"); + + expect(onCode).toHaveBeenCalledTimes(1); + expect(onCode).toHaveBeenCalledWith("PD-0107"); + }); + + it("scanner mode: shows a waiting hint normally, and a degraded hint once the agent is unreachable", async () => { + scanLastShouldError = true; + renderScanInput({ mode: "scanner" }); + + await waitFor(() => + expect(screen.getByText("Can't reach the handheld scanner — use manual search below.")).toBeInTheDocument(), + ); + }); + + it("scanner mode: shows the waiting hint (not the degraded one) while the agent is reachable", async () => { + renderScanInput({ mode: "scanner" }); + + await waitFor(() => + expect(screen.getByText("Waiting for a scan from the handheld scanner…")).toBeInTheDocument(), + ); + expect(screen.queryByText("Can't reach the handheld scanner — use manual search below.")).not.toBeInTheDocument(); + }); + + it("manual search: typing debounces a ?search= request, and picking a result calls onPickAttendee then clears the box", async () => { + const user = userEvent.setup(); + const { onPickAttendee } = renderScanInput({ mode: "manual" }); + + const searchBox = screen.getByPlaceholderText(SEARCH_PLACEHOLDER); + await user.type(searchBox, "Ada"); + + // Debounced -- no request yet immediately after typing. + expect(lastSearchParam).toBeNull(); + + await waitFor(() => expect(lastSearchParam).toBe("Ada")); + await waitFor(() => expect(screen.getByText("Ada Lovelace")).toBeInTheDocument()); + + await user.click(screen.getByText("Ada Lovelace")); + + expect(onPickAttendee).toHaveBeenCalledTimes(1); + expect(onPickAttendee).toHaveBeenCalledWith(ADA); + await waitFor(() => expect(searchBox).toHaveValue("")); + }); + + it("manual search: shows a no-matches message when the search comes back empty", async () => { + const user = userEvent.setup(); + renderScanInput({ mode: "manual" }); + + const searchBox = screen.getByPlaceholderText(SEARCH_PLACEHOLDER); + await user.type(searchBox, "Nobody"); + + await waitFor(() => expect(lastSearchParam).toBe("Nobody")); + await waitFor(() => expect(screen.getByText("No matching attendees.")).toBeInTheDocument()); + }); + + it("does not fire a search request before any text is typed", async () => { + renderScanInput({ mode: "manual" }); + await new Promise((resolve) => setTimeout(resolve, 350)); + expect(attendeesHitCount).toBe(0); + }); + + // PR #77 bot-review round, Finding K -- a FAILED search must never be + // presented as "no matching attendees" (a false negative): the old + // `showNoMatches` derivation only checked "loading is false and results + // are empty", which a failed query also satisfies. + it("never shows the no-matches line for a search that failed (not merely came back empty)", async () => { + attendeesShouldError = true; + const user = userEvent.setup(); + renderScanInput({ mode: "manual" }); + + const searchBox = screen.getByPlaceholderText(SEARCH_PLACEHOLDER); + await user.type(searchBox, "Ada"); + + await waitFor(() => expect(attendeesHitCount).toBeGreaterThan(0)); + // Give the (wrong) no-matches line a chance to render before asserting + // its absence. + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(screen.queryByText("No matching attendees.")).not.toBeInTheDocument(); + expect(screen.queryByText("Ada Lovelace")).not.toBeInTheDocument(); + }); + + // P4.1 Task 10 -- degraded mode's "read-only manual search" requirement: + // StationPage passes `readOnly` while offline so a scan/search can still + // LOOK someone up, but there is no check-in CTA to attempt (the brief's + // "look someone up, no check-in button"). + describe("readOnly", () => { + // PR #77 bot-review round, Finding K -- previously readOnly typed a + // BRAND NEW search term and still fired a fresh (uncached) request; now + // the query is disabled while readOnly, so this test seeds the cache + // WHILE ONLINE first (matching the real degraded-mode story: readOnly + // only ever flips true AFTER StationPage's own useConnectionState goes + // offline, never from a fresh render), then flips readOnly on and + // confirms the ALREADY-cached result still renders as plain text. + it("still shows a matched result, but as plain text -- no check-in button, and clicking it calls nothing", async () => { + const user = userEvent.setup(); + const { rerender, onPickAttendee } = renderScanInput({ mode: "manual", readOnly: false }); + + const searchBox = screen.getByPlaceholderText(SEARCH_PLACEHOLDER); + await user.type(searchBox, "Ada"); + await waitFor(() => expect(screen.getByText("Ada Lovelace")).toBeInTheDocument()); + + rerender( + , + ); + + expect(screen.getByText("Ada Lovelace")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /Ada Lovelace/ })).not.toBeInTheDocument(); + + await user.click(screen.getByText("Ada Lovelace")); + expect(onPickAttendee).not.toHaveBeenCalled(); + }); + + it("renders a real check-in button (not readOnly) by default", async () => { + const user = userEvent.setup(); + renderScanInput({ mode: "manual" }); + + const searchBox = screen.getByPlaceholderText(SEARCH_PLACEHOLDER); + await user.type(searchBox, "Ada"); + await waitFor(() => expect(screen.getByRole("button", { name: /Ada Lovelace/ })).toBeInTheDocument()); + }); + + // PR #77 bot-review round, Finding K -- the "read-only CACHED roster" + // contract: a term typed WHILE ALREADY readOnly (never cached) must + // never reach the network at all, and must show neither a false match + // nor a false "no matching attendees" (the query never even attempted). + it("never fires a search request for a term typed while already read-only, and shows no result / no-matches line for it", async () => { + const user = userEvent.setup(); + renderScanInput({ mode: "manual", readOnly: true }); + + const searchBox = screen.getByPlaceholderText(SEARCH_PLACEHOLDER); + await user.type(searchBox, "Ada"); + + await new Promise((resolve) => setTimeout(resolve, 350)); + expect(attendeesHitCount).toBe(0); + expect(screen.queryByText("Ada Lovelace")).not.toBeInTheDocument(); + expect(screen.queryByText("No matching attendees.")).not.toBeInTheDocument(); + }); + }); + + // Final cross-task review finding -- `manual_search_enabled` + // (settingsTypes.ts, Task 5) previously had no consumer at all: the + // launch ceremony (Task 11) let the operator toggle it and persisted it, + // but nothing at the station read it back, so the manual search box + // stayed fully functional regardless of the setting's value. StationPage + // now threads `settings.manual_search_enabled` into ScanInput as + // `manualSearchEnabled`. + describe("manualSearchEnabled", () => { + it.each(["wedge", "scanner", "manual"] as const)( + "removes the manual search box entirely in %s mode when manualSearchEnabled is false", + (mode) => { + renderScanInput({ mode, manualSearchEnabled: false }); + expect(screen.queryByPlaceholderText(SEARCH_PLACEHOLDER)).not.toBeInTheDocument(); + }, + ); + + it("never fires a search request while disabled, even though the box can't be typed into", async () => { + renderScanInput({ mode: "manual", manualSearchEnabled: false }); + await new Promise((resolve) => setTimeout(resolve, 350)); + expect(attendeesHitCount).toBe(0); + }); + + it("does not affect the wedge scan-input mechanism -- the hidden wedge input still renders and still emits onCode", async () => { + const user = userEvent.setup(); + const { onCode } = renderScanInput({ mode: "wedge", manualSearchEnabled: false }); + + const wedgeInput = screen.getByLabelText("Badge scanner input"); + expect(wedgeInput).toHaveFocus(); + + await user.type(wedgeInput, "PD-0107{Enter}"); + + expect(onCode).toHaveBeenCalledTimes(1); + expect(onCode).toHaveBeenCalledWith("PD-0107"); + expect(screen.queryByPlaceholderText(SEARCH_PLACEHOLDER)).not.toBeInTheDocument(); + }); + + it("does not affect the scanner mode's own status hint", async () => { + renderScanInput({ mode: "scanner", manualSearchEnabled: false }); + + await waitFor(() => + expect(screen.getByText("Waiting for a scan from the handheld scanner…")).toBeInTheDocument(), + ); + expect(screen.queryByPlaceholderText(SEARCH_PLACEHOLDER)).not.toBeInTheDocument(); + }); + + // PR #77 bot-review round, Finding Q -- the scanner-outage copy tells + // the operator to "use manual search below", which is actively wrong + // when manual search is disabled outright (no search box renders in ANY + // mode, per the block above). A dedicated key with no manual-search + // reference is shown instead. + it("shows manual-search-free degraded copy when the scanner fails AND manualSearchEnabled is false", async () => { + scanLastShouldError = true; + renderScanInput({ mode: "scanner", manualSearchEnabled: false }); + + expect( + await screen.findByText( + "Can't reach the handheld scanner. Ask a colleague for help checking this attendee in.", + ), + ).toBeInTheDocument(); + expect( + screen.queryByText("Can't reach the handheld scanner — use manual search below."), + ).not.toBeInTheDocument(); + expect(screen.queryByPlaceholderText(SEARCH_PLACEHOLDER)).not.toBeInTheDocument(); + }); + }); + + // PR #77 bot-review round, Finding M -- `pick()` must honor `enabled` + // (StationPage passes `enabled={false}` while a previous scan is still + // resolving, the same signal that already disables the wedge/scanner + // input) so a manual-search pick can't start a second, competing + // check-in racing the first one's verdict/print state. + describe("enabled=false (a previous scan is still resolving)", () => { + it("renders the result row's button as disabled, and clicking it never calls onPickAttendee", async () => { + const user = userEvent.setup(); + const { onPickAttendee } = renderScanInput({ mode: "manual", enabled: false }); + + const searchBox = screen.getByPlaceholderText(SEARCH_PLACEHOLDER); + await user.type(searchBox, "Ada"); + await waitFor(() => expect(screen.getByText("Ada Lovelace")).toBeInTheDocument()); + + const resultButton = screen.getByRole("button", { name: /Ada Lovelace/ }); + expect(resultButton).toBeDisabled(); + + await user.click(resultButton); + expect(onPickAttendee).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/panel/src/features/checkin/ScanInput.tsx b/panel/src/features/checkin/ScanInput.tsx new file mode 100644 index 00000000..4cdea53e --- /dev/null +++ b/panel/src/features/checkin/ScanInput.tsx @@ -0,0 +1,252 @@ +// P4.1 Task 7 -- the check-in station's scan surface: the mode-appropriate +// affordance (wedge's hidden input / scanner's status hint / manual's plain +// hint) PLUS the always-present manual search box, per the brief ("the +// always-present manual search box (name/email/code via ?search=, +// debounced, pick -> submitAttendee)"). This component owns no check-in +// state itself -- `onCode`/`onPickAttendee` are the caller's (Task 8's +// StationPage) wiring into useCheckinFlow's submitCode/submitAttendee, kept +// generic here so this file has no dependency on that hook. +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { Button, Input } from "@idento/ui"; +import type { components } from "../../shared/api/schema"; +import { useAttendeesPage } from "../attendees/hooks"; +import { useScanInput, type ScanInputMode } from "./useScanInput"; + +type Attendee = components["schemas"]["Attendee"]; + +const SEARCH_DEBOUNCE_MS = 250; +// A short-list fallback lookup, not the full attendees table (Task 8's +// StationPage is a near-fullscreen verdict-first layout, not a data grid) -- +// enough rows to find the one attendee the operator is after without the +// dropdown becoming its own scroll surface. +const SEARCH_RESULTS_LIMIT = 8; + +export interface ScanInputProps { + eventId: string; + mode: ScanInputMode; + // Mirrors useScanInput's own `enabled` -- StationPage passes false while a + // previous scan is still resolving so a fresh scan/pick can't race it. + enabled: boolean; + // P4.1 Task 10 -- degraded mode's "read-only manual search" requirement: + // StationPage passes `true` while useConnectionState reports the station + // offline. Deliberately separate from `enabled` (which only ever reflects + // "a previous scan is still resolving", unrelated to connectivity): the + // wedge/scanner affordances stay driven by `enabled` alone even while + // offline (a physical scan must still be CAPTURED — never silently + // dropped — so StationPage can show an explicit offline verdict instead; + // see StationPage.tsx's own comment), but the manual search's pick + // affordance is a CONSCIOUS operator action, not a passive capture, so + // it's simply removed outright: search results still render (whatever the + // already-cached query returns), just without a clickable check-in CTA. + readOnly?: boolean; + // Final cross-task review finding -- the check-in settings' own + // `manual_search_enabled` (settingsTypes.ts, Task 5) previously had no + // consumer at all: the launch ceremony (Task 11) let the operator toggle + // "Allow manual search" and persisted it, but nothing at the station ever + // read it back, so the manual search box stayed fully functional + // regardless of the setting. StationPage.tsx now threads + // `settings.manual_search_enabled` in here. Defaults to `true` (matching + // DEFAULT_CHECKIN_SETTINGS.manual_search_enabled) so any other caller/test + // that doesn't know about this setting keeps the box's prior always-on + // behavior. Deliberately independent of `mode`/`readOnly`: this setting + // only controls the manual TEXT SEARCH fallback below, never the + // wedge/scanner scan-input mechanism itself (that stays driven by `mode`/ + // `enabled` alone, unaffected by this prop). + manualSearchEnabled?: boolean; + onCode(code: string): void; + onPickAttendee(attendee: Attendee): void; +} + +export function ScanInput({ + eventId, + mode, + enabled, + readOnly = false, + manualSearchEnabled = true, + onCode, + onPickAttendee, +}: ScanInputProps) { + const { t } = useTranslation(); + const { degraded, wedgeInputProps } = useScanInput({ mode, onCode, enabled }); + + // Local keystroke state gives the search box instant feedback; the actual + // query (and therefore the request) only updates SEARCH_DEBOUNCE_MS after + // the user stops typing -- same pattern as AttendeesPage.tsx's own search + // debounce. + const [searchInput, setSearchInput] = React.useState(""); + const [debouncedSearch, setDebouncedSearch] = React.useState(""); + + React.useEffect(() => { + const trimmed = searchInput.trim(); + const timeoutId = window.setTimeout(() => setDebouncedSearch(trimmed), SEARCH_DEBOUNCE_MS); + return () => window.clearTimeout(timeoutId); + }, [searchInput]); + + const hasQuery = debouncedSearch.length > 0; + + // `enabled: hasQuery` (Task 7's addition to useAttendeesPage) skips the + // request entirely while the search box is empty — mounting the station + // shouldn't dump the roster's first page before the operator has typed + // anything. `&& manualSearchEnabled` -- final cross-task review finding: + // the search box itself is unmounted below when this setting is off, but + // this also belt-and-suspenders-guards the request itself against firing + // for anything that could still reach `debouncedSearch` (e.g. this prop + // flipping false mid-typing) while the setting is disabled. `&& !readOnly` + // -- PR #77 bot-review round, Finding K: while degraded/offline, this is a + // READ-ONLY CACHED roster (this file's own header comment) -- a NEW search + // term must never issue an uncached network request that's just going to + // fail against an unreachable backend. Already-cached data for a term + // typed BEFORE going read-only stays visible regardless (react-query keeps + // serving `.data` for a disabled query from its cache), so this only stops + // FRESH fetches, never hides what's already loaded. + const searchQuery = useAttendeesPage(eventId, { + page: 1, + perPage: SEARCH_RESULTS_LIMIT, + search: debouncedSearch, + enabled: hasQuery && manualSearchEnabled && !readOnly, + }); + + const results = hasQuery ? (searchQuery.data?.attendees ?? []) : []; + // PR #77 bot-review round, Finding K: gated on an actually-SUCCESSFUL + // query, not just "loading is false and results are empty" -- the old + // condition couldn't distinguish a genuine empty result from a query that + // FAILED (or, per the `enabled` change above, never even attempted) to + // fetch, presenting either as a false "no matching attendees." + const showNoMatches = hasQuery && searchQuery.isSuccess && results.length === 0; + + function pick(attendee: Attendee) { + // PR #77 bot-review round, Finding M -- defense in depth alongside the + // result button's own `disabled` attribute below: while a previous + // scan/pick is still resolving (`enabled` false, which already disables + // the wedge/scanner input), a manual-search pick must be an equally + // inert no-op, not a second, competing check-in racing the first one's + // verdict/print state. + if (!enabled) return; + setSearchInput(""); + setDebouncedSearch(""); + onPickAttendee(attendee); + } + + return ( +
+ {mode === "wedge" ? ( + <> +

{t("checkinScanWedgeHint")}

+ {/* sr-only (not type="hidden", which never receives keystrokes): + a keyboard-wedge scanner "types" into whatever element has + focus, so this input must stay in the accessibility tree and + focusable, just visually hidden. */} + + + ) : null} + + {mode === "scanner" ? ( +

+ {degraded + ? // PR #77 bot-review round, Finding Q -- the "use manual search + // below" copy is actively wrong/misleading when + // manualSearchEnabled is false: ScanInput renders no manual + // search box in ANY mode in that configuration (the block + // below), so pointing the operator at a control that isn't + // there helps no one. + t(manualSearchEnabled ? "checkinScanScannerDegradedHint" : "checkinScanScannerDegradedHintNoManualSearch") + : t("checkinScanScannerHint")} +

+ ) : null} + + {mode === "manual" ?

{t("checkinScanManualHint")}

: null} + + {/* Final cross-task review finding -- gated on + `manual_search_enabled` (threaded from StationPage.tsx as + `manualSearchEnabled`), same one-conditional-block-per-affordance + pattern as the wedge/scanner/manual hints above: when the operator + has turned "Allow manual search" off (LaunchCeremony.tsx), this + entire text-search fallback -- box, results, hints -- is removed + outright rather than merely disabled, so the setting actually has + an effect instead of staying inert. Deliberately independent of + `mode`: the wedge/scanner scan-input mechanism above is untouched + by this setting either way. */} + {manualSearchEnabled ? ( +
+ setSearchInput(event.target.value)} + placeholder={t("checkinManualSearchPlaceholder")} + aria-label={t("checkinManualSearchPlaceholder")} + /> + + {results.length > 0 ? ( +
    + {results.map((attendee) => { + const content = ( + <> + + {attendee.first_name} {attendee.last_name} + + + {attendee.email} · {attendee.code} + + + ); + // No check-in CTA at all while offline (this task's brief: + // "look someone up, no check-in button") -- a plain, + // non-interactive row, not a disabled button (a disabled + // button would still imply "there's an action here, just + // temporarily blocked", which isn't the story: this station + // genuinely can't check anyone in from a stale, cached + // roster while offline). + return readOnly ? ( +
  • +
    {content}
    +
  • + ) : ( +
  • + {/* PR #77 bot-review round 3, Finding 2 -- the shared + @idento/ui Button (unlike the Select primitive a + DIFFERENT, already-deferred finding flagged, Button + genuinely exists) replaces the previous hand-rolled + +
  • + ); + })} +
+ ) : null} + + {results.length > 0 && readOnly ? ( +

{t("checkinManualSearchReadOnlyHint")}

+ ) : null} + + {showNoMatches ? ( +

{t("checkinManualSearchNoMatches")}

+ ) : null} +
+ ) : null} +
+ ); +} diff --git a/panel/src/features/checkin/StationPage.test.tsx b/panel/src/features/checkin/StationPage.test.tsx new file mode 100644 index 00000000..3f1b7245 --- /dev/null +++ b/panel/src/features/checkin/StationPage.test.tsx @@ -0,0 +1,656 @@ +// P4.1 Task 8 -- StationPage tests. +// +// The FIRST describe block below is the highest-risk proof for this task +// (per the brief): app/router.tsx registers `eventCheckinRoute` as a +// TOP-LEVEL protected route, a SIBLING of `eventWorkspaceRoute` (both +// children of `protectedLayoutRoute`), specifically so +// `/events/$eventId/checkin` renders StationPage WITHOUT the workspace +// rail shell (WorkspaceRail/EventWorkspaceLayout). Both registrations +// (sibling vs. "child of the workspace route with a relative path") +// resolve to the IDENTICAL final URL, so only the RENDERED OUTPUT (not the +// matched path string) can tell a correct sibling registration apart from +// an accidental nested one -- this file proves it two ways: (1) a routed +// harness shaped exactly like app/router.tsx's real registration renders +// StationPage's content with none of the workspace shell's nav markers +// present, and (2) a deliberately-misregistered harness (checkin route +// nested as a CHILD of the workspace route) demonstrates the SAME +// assertion would fail if the registration were wrong -- proof the +// technique actually discriminates, not a vacuously-passing check. +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + Outlet, RouterProvider, createMemoryHistory, createRootRoute, createRoute, createRouter, +} from "@tanstack/react-router"; +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { delay, http, HttpResponse } from "msw"; +import { verdictClasses } from "@idento/ui"; +import { StationPage } from "./StationPage"; +import { checkinStationBeforeLoad, validateCheckinStationSearch } from "./searchParams"; +import { startMswServer } from "../../test/msw"; +import type { components } from "../../shared/api/schema"; +import "../../shared/i18n"; + +type Attendee = components["schemas"]["Attendee"]; +type CheckinOutcome = "checked_in" | "already_checked_in" | "blocked"; + +// Distinguishing marker text for the workspace rail shell's own nav items +// (WorkspaceRail.tsx's real English copy: Overview/Attendees/Zones/Staff/ +// Badge) -- if the checkin route were wrongly nested under the workspace +// route, these would render alongside StationPage's own content. +function WorkspaceShellStub() { + return ( +
+ + +
+ ); +} + +// Mirrors app/router.tsx's REAL shape: an app-layout id route +// ("_app", standing in for protectedLayoutRoute) with the workspace route +// AND the checkin route registered as SIBLING children -- exactly the +// registration this task adds to the real router. +function buildCorrectRouter(initialPath: string) { + const rootRoute = createRootRoute(); + const appLayoutRoute = createRoute({ getParentRoute: () => rootRoute, id: "_app", component: () => }); + const workspaceRoute = createRoute({ + getParentRoute: () => appLayoutRoute, + path: "/events/$eventId", + component: WorkspaceShellStub, + }); + const checkinRoute = createRoute({ + getParentRoute: () => appLayoutRoute, // sibling of workspaceRoute -- the fix under test. + path: "/events/$eventId/checkin", + validateSearch: validateCheckinStationSearch, + beforeLoad: checkinStationBeforeLoad, + component: StationPage, + }); + const launchRoute = createRoute({ + getParentRoute: () => appLayoutRoute, + path: "/events/$eventId/checkin/launch", + component: () =>
launch ceremony stub
, + }); + const routeTree = rootRoute.addChildren([appLayoutRoute.addChildren([workspaceRoute, checkinRoute, launchRoute])]); + return createRouter({ routeTree, history: createMemoryHistory({ initialEntries: [initialPath] }) }); +} + +// Reproduces the bug the sibling registration above avoids: the checkin +// route nested as a CHILD of the workspace route (relative path +// "/checkin") resolves to the exact same final URL +// ("/events/$eventId/checkin") but renders wrapped inside the workspace +// shell's own . +function buildMisregisteredRouter(initialPath: string) { + const rootRoute = createRootRoute(); + const appLayoutRoute = createRoute({ getParentRoute: () => rootRoute, id: "_app", component: () => }); + const workspaceRoute = createRoute({ + getParentRoute: () => appLayoutRoute, + path: "/events/$eventId", + component: WorkspaceShellStub, + }); + const nestedCheckinRoute = createRoute({ + getParentRoute: () => workspaceRoute, // the mistake: a CHILD, not a sibling. + path: "/checkin", + component: () =>
dummy
, + }); + const routeTree = rootRoute.addChildren([appLayoutRoute.addChildren([workspaceRoute.addChildren([nestedCheckinRoute])])]); + return createRouter({ routeTree, history: createMemoryHistory({ initialEntries: [initialPath] }) }); +} + +function renderWithRouter(router: ReturnType | ReturnType) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + {/* Cast, not @ts-expect-error: this test router's route shape differs + from the app's registered singleton -- same rationale as + AttendeesPage.test.tsx / EventWorkspaceLayout.test.tsx. */} + + , + ); + return router; +} + +function renderCorrectAt(path: string) { + return renderWithRouter(buildCorrectRouter(path)); +} + +const EVENT = { + id: "evt-1", + tenant_id: "t1", + name: "Partner Day — Autumn", + start_date: "2026-09-03T00:00:00.000Z", + created_at: "", + updated_at: "", +}; + +const STATIONS = [ + { id: "11111111-1111-4111-8111-111111111111", event_id: "evt-1", name: "Main Door", last_seen_at: "2026-01-01T00:00:00Z", created_at: "2026-01-01T00:00:00Z" }, +]; + +const ATTENDEE: Attendee = { + id: "att-1", + event_id: "evt-1", + first_name: "Ada", + last_name: "Lovelace", + email: "ada@example.com", + company: "Analytical Engines", + position: "Engineer", + code: "CODE1", + checkin_status: false, + printed_count: 0, + blocked: false, + packet_delivered: false, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", +}; + +let checkinOutcome: CheckinOutcome = "checked_in"; +let checkinHitCount = 0; +let settingsOverride = { + print_on_checkin: false, + verdict_auto_dismiss_sec: 30, + scan_input: "wedge" as const, + manual_search_enabled: true, +}; + +const server = startMswServer( + http.get("http://api.test/api/events/:id", () => HttpResponse.json(EVENT)), + http.get("http://api.test/api/events/:eventId/checkin-stations", () => HttpResponse.json({ stations: STATIONS })), + http.get("http://api.test/api/events/:id/checkin-settings", () => HttpResponse.json({ settings: settingsOverride })), + http.get("http://api.test/api/events/:eventId/attendees", ({ request }) => { + const url = new URL(request.url); + const code = url.searchParams.get("code"); + // The wedge-scan lookup (useCheckinFlow.submitCode) always sends `code` + // (never `page`/`per_page`) and expects the legacy bare-array shape. + // The manual search box (ScanInput -> useAttendeesPage, Task 7) always + // sends `page`/`per_page`/`search` and expects the `{attendees, total, + // page, per_page}` envelope -- same two-shape split ScanInput.test.tsx's + // own handler documents. + if (code !== null) { + return HttpResponse.json(code === ATTENDEE.code ? [ATTENDEE] : []); + } + const search = (url.searchParams.get("search") ?? "").toLowerCase(); + const haystack = `${ATTENDEE.first_name} ${ATTENDEE.last_name} ${ATTENDEE.email} ${ATTENDEE.code}`.toLowerCase(); + const matches = search && haystack.includes(search) ? [ATTENDEE] : []; + return HttpResponse.json({ attendees: matches, total: matches.length, page: 1, per_page: 8 }); + }), + http.post("http://api.test/api/events/:eventId/checkin", async ({ request }) => { + checkinHitCount += 1; + const body = (await request.json()) as { attendee_id: string; station_id?: string | null }; + const attendee: Attendee = { + ...ATTENDEE, + id: body.attendee_id, + checkin_status: checkinOutcome !== "blocked", + blocked: checkinOutcome === "blocked", + }; + const checkin = + checkinOutcome === "blocked" + ? null + : { at: "2026-01-01T12:34:00Z", by_email: "staff@example.com", point_name: "Main Door" }; + return HttpResponse.json({ outcome: checkinOutcome, attendee, checkin }); + }), + // Task 9's RecentScansRail mounts unconditionally alongside the verdict + // panel -- its own feed query, plus usePrintBadge's own + // useBadgeTemplate/useEventFontFaces calls (reprint's print pipeline), + // need mocking here too, same as every OTHER surface that mounts + // usePrintBadge (AttendeeDrawer.test.tsx's own top-of-file comment). + http.get("http://api.test/api/events/:eventId/checkin-actions", () => HttpResponse.json({ actions: [] })), + http.get("http://api.test/api/events/:id/badge-template", () => HttpResponse.json({ template: null, version: 0 })), + http.get("http://api.test/api/events/:eventId/fonts", () => HttpResponse.json([])), + // Task 12's useHeartbeat mounts unconditionally alongside every other hook + // here and fires an immediate POST on mount -- mocked like every other + // endpoint this page hits (this suite's own top-of-block precedent) rather + // than left to MSW's onUnhandledRequest:"error". + http.post("http://api.test/api/events/:eventId/checkin-stations/:id/heartbeat", () => new HttpResponse(null, { status: 204 })), + http.get("http://agent.test/health", () => new HttpResponse(null, { status: 200 })), + http.get("http://agent.test/printers", () => HttpResponse.json([])), + http.get("http://agent.test/printers/default", () => HttpResponse.json({ default: null })), +); +void server; + +describe("StationPage routing -- sibling registration proof", () => { + beforeEach(() => { + window.__ENV__ = { API_URL: "http://api.test", AGENT_URL: "http://agent.test" }; + localStorage.clear(); + localStorage.setItem("token", "jwt-test"); + }); + + it("renders StationPage's own content with NONE of the workspace shell's nav markers, when registered as a top-level sibling of the workspace route (app/router.tsx's real shape)", async () => { + renderCorrectAt("/events/evt-1/checkin?station=11111111-1111-4111-8111-111111111111"); + + expect(await screen.findByTestId("checkin-station-page")).toBeInTheDocument(); + expect(await screen.findByTestId("checkin-recent-scans-rail")).toBeInTheDocument(); + + // None of the workspace shell's own distinguishing nav text is + // present -- if the checkin route had been (incorrectly) nested as a + // CHILD of the workspace route instead of registered as its sibling, + // these would render too (see the misregistration reproduction below). + expect(screen.queryByText("Overview")).not.toBeInTheDocument(); + expect(screen.queryByText("Attendees")).not.toBeInTheDocument(); + expect(screen.queryByText("Zones")).not.toBeInTheDocument(); + expect(screen.queryByText("Staff")).not.toBeInTheDocument(); + expect(screen.queryByText("Badge")).not.toBeInTheDocument(); + }); + + it("sanity check: the SAME workspace-shell-marker assertion WOULD fail if the checkin route were (incorrectly) nested as a child of the workspace route -- proof the technique above actually discriminates", async () => { + const router = buildMisregisteredRouter("/events/evt-1/checkin"); + renderWithRouter(router); + + expect(await screen.findByTestId("dummy-checkin-page")).toBeInTheDocument(); + // The workspace shell's nav leaks through here -- this is the exact + // bug the sibling registration in app/router.tsx avoids. + expect(screen.getByText("Overview")).toBeInTheDocument(); + expect(screen.getByText("Badge")).toBeInTheDocument(); + }); + + it("redirects to the launch ceremony when ?station= is missing (checkinStationBeforeLoad, shared with app/router.tsx's real route)", async () => { + const router = renderCorrectAt("/events/evt-1/checkin"); + + await waitFor(() => expect(router.state.location.pathname).toBe("/events/evt-1/checkin/launch")); + expect(await screen.findByText("launch ceremony stub")).toBeInTheDocument(); + }); +}); + +describe("StationPage", () => { + beforeEach(() => { + window.__ENV__ = { API_URL: "http://api.test", AGENT_URL: "http://agent.test" }; + localStorage.clear(); + localStorage.setItem("token", "jwt-test"); + checkinOutcome = "checked_in"; + checkinHitCount = 0; + settingsOverride = { + print_on_checkin: false, + verdict_auto_dismiss_sec: 30, + scan_input: "wedge", + manual_search_enabled: true, + }; + }); + + it("renders the split layout: top bar (event name, station name, Exit), the idle verdict panel, scan input, and the rail placeholder", async () => { + renderCorrectAt("/events/evt-1/checkin?station=11111111-1111-4111-8111-111111111111"); + + expect(await screen.findByRole("heading", { name: "Partner Day — Autumn" })).toBeInTheDocument(); + expect(await screen.findByText("Main Door")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /Exit/ })).toHaveAttribute("href", "/events/evt-1"); + expect(screen.getByTestId("checkin-verdict-idle")).toBeInTheDocument(); + expect(screen.getByLabelText("Badge scanner input")).toBeInTheDocument(); + expect(await screen.findByTestId("checkin-recent-scans-rail")).toBeInTheDocument(); + }); + + // Final cross-task review finding -- `settings.manual_search_enabled` + // previously had no consumer at the station at all: toggling "Allow + // manual search" off in the launch ceremony had zero effect, since + // StationPage never read the setting back and ScanInput always rendered + // the search box unconditionally. StationPage now threads + // `settings.manual_search_enabled` into ScanInput's `manualSearchEnabled` + // prop (ScanInput.test.tsx owns the exhaustive per-mode/functional + // coverage of the prop itself; this is the end-to-end proof the wiring + // from the settings response actually reaches it). + it("hides the manual search box when settings.manual_search_enabled is false, without affecting the wedge scan-input mechanism", async () => { + settingsOverride = { ...settingsOverride, manual_search_enabled: false }; + renderCorrectAt("/events/evt-1/checkin?station=11111111-1111-4111-8111-111111111111"); + await screen.findByText("Main Door"); + + expect(screen.queryByPlaceholderText("Search by name, email, or code…")).not.toBeInTheDocument(); + expect(screen.getByLabelText("Badge scanner input")).toBeInTheDocument(); + }); + + it("shows the manual search box when settings.manual_search_enabled is true (the default)", async () => { + renderCorrectAt("/events/evt-1/checkin?station=11111111-1111-4111-8111-111111111111"); + await screen.findByText("Main Door"); + + expect(screen.getByPlaceholderText("Search by name, email, or code…")).toBeInTheDocument(); + }); + + it("a wedge scan of a known code shows the checked_in verdict card through the mapped verdictClasses", async () => { + const user = userEvent.setup(); + renderCorrectAt("/events/evt-1/checkin?station=11111111-1111-4111-8111-111111111111"); + await screen.findByText("Main Door"); + + await user.type(screen.getByLabelText("Badge scanner input"), "CODE1{Enter}"); + + const card = await screen.findByTestId("checkin-verdict-card"); + expect(card).toHaveAttribute("data-verdict", "allowed"); + expect(card.className).toContain(verdictClasses.allowed.bg); + expect(within(card).getByText("Checked in").className).toContain(verdictClasses.allowed.text); + expect(within(card).getByText("Ada Lovelace", { exact: false })).toBeInTheDocument(); + expect(checkinHitCount).toBe(1); + }); + + it("a repeat scan shows the already_checked_in verdict card (the info/repeat verdictClasses) with the first-scan metadata line", async () => { + checkinOutcome = "already_checked_in"; + const user = userEvent.setup(); + renderCorrectAt("/events/evt-1/checkin?station=11111111-1111-4111-8111-111111111111"); + await screen.findByText("Main Door"); + + await user.type(screen.getByLabelText("Badge scanner input"), "CODE1{Enter}"); + + const card = await screen.findByTestId("checkin-verdict-card"); + expect(card).toHaveAttribute("data-verdict", "already_checked_in"); + expect(card.className).toContain(verdictClasses.already_checked_in.bg); + expect(within(card).getByText("Already checked in").className).toContain(verdictClasses.already_checked_in.text); + + const meta = screen.getByTestId("checkin-first-scan-meta"); + expect(meta).toHaveTextContent("12:34"); + expect(meta).toHaveTextContent("Main Door"); + }); + + // PR #77 bot-review round, Finding N -- while the real check-in settings + // are still loading, the station must not silently scan/search against + // DEFAULT_CHECKIN_SETTINGS (the P3.1 badge-editor "ungated load effect" + // bug class) -- an explicit loading state replaces the verdict/scan + // surface until settingsQuery resolves. The station's OTHER data (event + // name, station name) is unaffected -- only the settings-derived surface + // is gated. + it("shows a loading state (not defaults) for the verdict/scan surface while check-in settings are still loading, then enables scanning once they resolve", async () => { + server.use( + http.get("http://api.test/api/events/:id/checkin-settings", async () => { + await delay(50); + return HttpResponse.json({ settings: settingsOverride }); + }), + ); + renderCorrectAt("/events/evt-1/checkin?station=11111111-1111-4111-8111-111111111111"); + await screen.findByText("Main Door"); + + expect(screen.getByTestId("checkin-settings-loading")).toBeInTheDocument(); + expect(screen.queryByLabelText("Badge scanner input")).not.toBeInTheDocument(); + expect(screen.queryByTestId("checkin-verdict-idle")).not.toBeInTheDocument(); + + await waitFor(() => expect(screen.queryByTestId("checkin-settings-loading")).not.toBeInTheDocument()); + expect(screen.getByLabelText("Badge scanner input")).toBeInTheDocument(); + expect(screen.getByTestId("checkin-verdict-idle")).toBeInTheDocument(); + }); + + // PR #77 bot-review round 2, Finding 1 -- `agent.defaultPrinter` is `null` + // while the agent printer probe is still resolving/disconnected/has no + // printers, so `printerName` would fall back to `""` -- if a scan resolved + // to checked_in with print_on_checkin true during that window, the + // auto-print call would silently fail against a literal empty printer + // name. The scan surface (not check-in itself) must stay gated behind an + // explicit "waiting for printer" state until the agent actually resolves a + // usable default -- same shape as the settingsLoading gate above. + // PR #77 bot-review round 3, Finding 7 (CodeRabbit) -- a manually- + // controlled deferred promise, not a fixed real-timer delay. This test + // used to `await delay(50)` on the printer endpoints, then assert + // `checkin-printer-waiting` immediately after `findByText("Main Door")` + // resolved -- but `findByText`'s own resolution time is UNBOUNDED (unlike + // a fixed sequence of interactions), so a sufficiently slow/loaded CI + // runner could still let the 50ms delay elapse before the assertion runs, + // OR let `findByText` itself resolve slowly enough that the delay expires + // either way. Same race-condition CLASS already confirmed to flake in CI + // (RecentScansRail.test.tsx's dismissal-guard tests, run 29632317448, + // fixed in commit 6f8b506 by widening a fixed delay) -- but widening + // wouldn't reliably close THIS one, since it races an unbounded wait, not + // a bounded interaction sequence. Holding the printer endpoints open with + // a promise this test releases itself, AFTER asserting the waiting state, + // removes the race entirely instead of just shrinking the window. + it("shows a waiting-for-printer state (not a scannable surface) while print_on_checkin is true and the agent hasn't resolved a default printer yet, then enables scanning once it does", async () => { + settingsOverride = { ...settingsOverride, print_on_checkin: true }; + let resolvePrinters: (() => void) | undefined; + const printersGate = new Promise((resolve) => { + resolvePrinters = resolve; + }); + server.use( + http.get("http://agent.test/printers", async () => { + await printersGate; + return HttpResponse.json([{ name: "Zebra 1", type: "system" }]); + }), + http.get("http://agent.test/printers/default", async () => { + await printersGate; + return HttpResponse.json({ default: "Zebra 1" }); + }), + ); + renderCorrectAt("/events/evt-1/checkin?station=11111111-1111-4111-8111-111111111111"); + await screen.findByText("Main Door"); + + expect(screen.getByTestId("checkin-printer-waiting")).toBeInTheDocument(); + expect(screen.queryByLabelText("Badge scanner input")).not.toBeInTheDocument(); + expect(screen.queryByTestId("checkin-verdict-idle")).not.toBeInTheDocument(); + + resolvePrinters?.(); + + await waitFor(() => expect(screen.queryByTestId("checkin-printer-waiting")).not.toBeInTheDocument()); + expect(screen.getByLabelText("Badge scanner input")).toBeInTheDocument(); + expect(screen.getByTestId("checkin-verdict-idle")).toBeInTheDocument(); + }); + + // PR #77 bot-review round 3, Finding 3 -- `useAgentPrinters(true)` has no + // polling interval of its own, so without StationPage.tsx's own poll (see + // that file's own comment near `printerGateActive`), this gate would never + // re-check on its own once activated -- it would stay stuck until some + // UNRELATED trigger (a window focus, a manual remount) happened to cause a + // refetch. Fake timers here (unlike the test right above, and unlike most + // of this suite) -- same deviation, and the same reasoning, as + // useConnectionState.test.tsx's own dedicated fake-timer test for its + // analogous 20s health poll: waiting out a real 10s poll for real would + // make this suite unbearably slow, and `vi.advanceTimersByTimeAsync` + // (never the sync variant) flushes the pending MSW-intercepted refetch + // between simulated ticks. Deliberately triggers NO unrelated event (no + // window focus, no remount) -- the poll alone must be what lifts the gate. + it("lifts the printer-waiting gate on its own, via the periodic poll, once the agent becomes available -- no unrelated trigger", async () => { + settingsOverride = { ...settingsOverride, print_on_checkin: true }; + server.use( + http.get("http://agent.test/printers", () => HttpResponse.json([])), + http.get("http://agent.test/printers/default", () => HttpResponse.json({ default: null })), + ); + vi.useFakeTimers(); + try { + renderCorrectAt("/events/evt-1/checkin?station=11111111-1111-4111-8111-111111111111"); + // Flushes the router match + the event/stations/settings/agent + // queries' own microtask chains (none of this test's handlers use a + // real delay, so no further real-time wait is needed for the initial + // render to settle). + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(0); + + expect(screen.getByText("Main Door")).toBeInTheDocument(); + expect(screen.getByTestId("checkin-printer-waiting")).toBeInTheDocument(); + expect(screen.queryByLabelText("Badge scanner input")).not.toBeInTheDocument(); + + // The agent (and a printer) become available -- no window focus, no + // remount, nothing else that would otherwise trigger a refetch. + server.use( + http.get("http://agent.test/printers", () => HttpResponse.json([{ name: "Zebra 1", type: "system" }])), + http.get("http://agent.test/printers/default", () => HttpResponse.json({ default: "Zebra 1" })), + ); + + // Crosses the 10s poll boundary, then flushes the notifyManager batch + // (a macrotask, not a microtask) so the component actually re-renders + // from it. + await vi.advanceTimersByTimeAsync(10_000); + await vi.advanceTimersByTimeAsync(0); + + expect(screen.queryByTestId("checkin-printer-waiting")).not.toBeInTheDocument(); + expect(screen.getByLabelText("Badge scanner input")).toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } + }); + + it("keeps the scan surface enabled immediately when print_on_checkin is false, regardless of agent state (auto-print isn't happening, so nothing to wait for)", async () => { + settingsOverride = { ...settingsOverride, print_on_checkin: false }; + server.use(http.get("http://agent.test/health", () => new HttpResponse(null, { status: 500 }))); + renderCorrectAt("/events/evt-1/checkin?station=11111111-1111-4111-8111-111111111111"); + await screen.findByText("Main Door"); + + expect(screen.queryByTestId("checkin-printer-waiting")).not.toBeInTheDocument(); + expect(screen.getByLabelText("Badge scanner input")).toBeInTheDocument(); + expect(screen.getByTestId("checkin-verdict-idle")).toBeInTheDocument(); + }); + + // PR #77 bot-review round, Finding F -- flow.submitCode/submitAttendee can + // reject (the API unreachable even though the browser still reports + // itself online, or the backend rejects the request) -- previously + // neither StationPage call site had a `.catch`, producing an unhandled + // promise rejection with NO visible verdict/error, silently dropping the + // scan. This proves the operator sees something AND can immediately try + // again -- and, implicitly, that this test itself doesn't fail from an + // unhandled rejection (vitest surfaces those as failures). + it("shows a visible error and lets the operator scan again when the check-in request itself fails (not a print failure) -- no scan is silently dropped", async () => { + const user = userEvent.setup(); + server.use( + http.post("http://api.test/api/events/:eventId/checkin", () => new HttpResponse(null, { status: 500 })), + ); + renderCorrectAt("/events/evt-1/checkin?station=11111111-1111-4111-8111-111111111111"); + await screen.findByText("Main Door"); + + await user.type(screen.getByLabelText("Badge scanner input"), "CODE1{Enter}"); + + expect(await screen.findByTestId("checkin-request-error")).toBeInTheDocument(); + expect(screen.queryByTestId("checkin-verdict-card")).not.toBeInTheDocument(); + // Recovered -- the wedge input is enabled again (status reset to idle) + // and a fresh scan reaches the network normally. + expect(screen.getByLabelText("Badge scanner input")).toBeEnabled(); + + server.resetHandlers(); + await user.type(screen.getByLabelText("Badge scanner input"), "CODE1{Enter}"); + const card = await screen.findByTestId("checkin-verdict-card"); + expect(card).toHaveAttribute("data-verdict", "allowed"); + }); + + it("an unrecognized code shows the not_registered verdict card, without ever calling the check-in endpoint", async () => { + const user = userEvent.setup(); + renderCorrectAt("/events/evt-1/checkin?station=11111111-1111-4111-8111-111111111111"); + await screen.findByText("Main Door"); + + await user.type(screen.getByLabelText("Badge scanner input"), "NO-SUCH-CODE{Enter}"); + + const card = await screen.findByTestId("checkin-verdict-card"); + expect(card).toHaveAttribute("data-verdict", "not_registered"); + expect(card.className).toContain(verdictClasses.not_registered.bg); + expect(within(card).getByText("Not registered").className).toContain(verdictClasses.not_registered.text); + expect(checkinHitCount).toBe(0); + }); +}); + +// P4.1 Task 10 -- degraded mode. useConnectionState (its own dedicated unit +// tests live in useConnectionState.test.tsx) folds `navigator.onLine`/the +// window 'online'/'offline' events and the checkin-actions feed's own +// isError into one debounced `online` boolean; these tests prove StationPage +// actually WIRES that signal into the three required reactions: the amber +// banner, an inert scan (no POST, an explicit offline verdict instead of +// silently dropping it), and the manual search's check-in CTA disappearing +// (read-only against whatever's already in the query cache) -- see this +// task's brief and the spec's §4 "Degraded mode (2d)". +function goOffline() { + Object.defineProperty(window.navigator, "onLine", { value: false, writable: true, configurable: true }); + window.dispatchEvent(new Event("offline")); +} + +function goOnline() { + Object.defineProperty(window.navigator, "onLine", { value: true, writable: true, configurable: true }); + window.dispatchEvent(new Event("online")); +} + +describe("StationPage — degraded mode", () => { + beforeEach(() => { + window.__ENV__ = { API_URL: "http://api.test", AGENT_URL: "http://agent.test" }; + localStorage.clear(); + localStorage.setItem("token", "jwt-test"); + checkinOutcome = "checked_in"; + checkinHitCount = 0; + settingsOverride = { + print_on_checkin: false, + verdict_auto_dismiss_sec: 30, + scan_input: "wedge", + manual_search_enabled: true, + }; + goOnline(); + }); + + afterEach(() => { + goOnline(); + }); + + it("shows the amber 'Connection is unstable' banner while offline, and hides it again on reconnect", async () => { + renderCorrectAt("/events/evt-1/checkin?station=11111111-1111-4111-8111-111111111111"); + await screen.findByText("Main Door"); + expect(screen.queryByTestId("checkin-degraded-banner")).not.toBeInTheDocument(); + + goOffline(); + expect(await screen.findByTestId("checkin-degraded-banner")).toHaveTextContent("Connection is unstable"); + + goOnline(); + await waitFor(() => expect(screen.queryByTestId("checkin-degraded-banner")).not.toBeInTheDocument()); + }); + + it("blocks a wedge scan while offline -- no check-in POST fires, an explicit offline verdict shows instead -- then lets a scan through again once reconnected", async () => { + const user = userEvent.setup(); + renderCorrectAt("/events/evt-1/checkin?station=11111111-1111-4111-8111-111111111111"); + await screen.findByText("Main Door"); + + goOffline(); + await screen.findByTestId("checkin-degraded-banner"); + + await user.type(screen.getByLabelText("Badge scanner input"), "CODE1{Enter}"); + + expect(await screen.findByTestId("checkin-verdict-offline")).toHaveTextContent("Can't check in — offline."); + expect(checkinHitCount).toBe(0); + expect(screen.queryByTestId("checkin-verdict-card")).not.toBeInTheDocument(); + + goOnline(); + await waitFor(() => expect(screen.queryByTestId("checkin-degraded-banner")).not.toBeInTheDocument()); + + await user.type(screen.getByLabelText("Badge scanner input"), "CODE1{Enter}"); + + const card = await screen.findByTestId("checkin-verdict-card"); + expect(card).toHaveAttribute("data-verdict", "allowed"); + expect(checkinHitCount).toBe(1); + }); + + it("manual search stays read-only while offline -- the cached result still shows, but with no check-in button -- and picking it does not check anyone in", async () => { + const user = userEvent.setup(); + renderCorrectAt("/events/evt-1/checkin?station=11111111-1111-4111-8111-111111111111"); + await screen.findByText("Main Door"); + + const searchBox = screen.getByPlaceholderText("Search by name, email, or code…"); + await user.type(searchBox, "Ada"); + await waitFor(() => expect(screen.getByText("Ada Lovelace")).toBeInTheDocument()); + // Online: the result is a real check-in CTA. + expect(screen.getByRole("button", { name: /Ada Lovelace/ })).toBeInTheDocument(); + + goOffline(); + await screen.findByTestId("checkin-degraded-banner"); + + // Still visible (the already-loaded/cached result), but no longer a + // clickable check-in CTA. + expect(screen.getByText("Ada Lovelace")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /Ada Lovelace/ })).not.toBeInTheDocument(); + + await user.click(screen.getByText("Ada Lovelace")); + expect(checkinHitCount).toBe(0); + expect(screen.queryByTestId("checkin-verdict-card")).not.toBeInTheDocument(); + }); + + it("disables the recent-scans rail's Undo trigger while offline", async () => { + server.use( + http.get("http://api.test/api/events/:eventId/checkin-actions", () => + HttpResponse.json({ + actions: [ + { + id: "ca-1", + action: "checkin", + station_id: "11111111-1111-4111-8111-111111111111", + created_at: "2026-01-01T00:00:00Z", + attendee: { id: "att-1", first_name: "Ada", last_name: "Lovelace", code: "CODE1" }, + }, + ], + }), + ), + ); + renderCorrectAt("/events/evt-1/checkin?station=11111111-1111-4111-8111-111111111111"); + await screen.findByText("Main Door"); + const undoButton = await screen.findByRole("button", { name: "Undo" }); + expect(undoButton).toBeEnabled(); + + goOffline(); + await screen.findByTestId("checkin-degraded-banner"); + + await waitFor(() => expect(screen.getByRole("button", { name: "Undo" })).toBeDisabled()); + }); +}); diff --git a/panel/src/features/checkin/StationPage.tsx b/panel/src/features/checkin/StationPage.tsx new file mode 100644 index 00000000..b2f8926d --- /dev/null +++ b/panel/src/features/checkin/StationPage.tsx @@ -0,0 +1,347 @@ +// P4.1 Task 8 -- the check-in station itself. Registered in +// app/router.tsx as `eventCheckinRoute`, a TOP-LEVEL protected route that +// is a SIBLING of `eventWorkspaceRoute` (not one of its children) -- so +// this page renders WITHOUT the workspace rail shell (WorkspaceRail / +// EventWorkspaceLayout), near-fullscreen, escaping only that chrome (it is +// still wrapped by the outer AppShell/NavDrawer, same as every other +// protected route). +// +// Wires together Task 5's settings/data layer, Task 6's verdict state +// machine, Task 7's scan input modes, and Task 9's recent-scans rail: a +// top bar (event name / station name / Exit back to the workspace), the +// main verdict panel (VerdictCard + ScanInput), and the RecentScansRail +// (last-50 check-in actions feed with per-row reprint/undo/details). +// +// `?station=` (the registered station id) is validated by the route's own +// `beforeLoad` (app/router.tsx's checkinStationBeforeLoad, +// features/checkin/searchParams.ts) BEFORE this component ever mounts -- +// missing/malformed values redirect to the launch ceremony there, so by +// the time this renders, `search.station` is guaranteed to be a +// non-empty string. This component does NOT separately re-validate it +// against the actually-registered station list: Task 11 owns registering +// stations, and an unregistered-but-well-formed id is treated as a +// station this page simply can't NAME yet (falls back to the raw id in +// the top bar), not as a reason to bounce the operator back to the +// ceremony mid-shift. +import * as React from "react"; +import { Button, Skeleton } from "@idento/ui"; +import { Link, getRouteApi } from "@tanstack/react-router"; +import { ArrowLeft, Printer, WifiOff } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import type { components } from "../../shared/api/schema"; +import { useAgentPrinters } from "../../shared/agent/useAgentPrinters"; +import { $api } from "../../shared/api/query"; +import { RecentScansRail } from "./RecentScansRail"; +import { ScanInput } from "./ScanInput"; +import { VerdictCard } from "./VerdictCard"; +import { useCheckinSettings, useCheckinStations } from "./hooks"; +import { DEFAULT_CHECKIN_SETTINGS } from "./settingsTypes"; +import { useCheckinFlow } from "./useCheckinFlow"; +import { useConnectionState } from "./useConnectionState"; +import { useHeartbeat } from "./useHeartbeat"; + +type Attendee = components["schemas"]["Attendee"]; + +// Same getRouteApi-by-string-id rationale as AttendeesPage.tsx / +// EventWorkspaceLayout.tsx -- avoids a circular import with app/router.tsx +// (which imports THIS component for the route's `component:` field). +const routeApi = getRouteApi("/_app/events/$eventId/checkin"); + +// PR #77 bot-review round 3, Finding 3 -- see the printer-readiness poll +// effect below (near `printerGateActive`) for the full rationale. 10s, not +// the 20s heartbeat/connection-health cadence useHeartbeat.ts/ +// useConnectionState.ts already establish, since this poll only ever runs +// during an already-exceptional "still waiting for a printer" state, where +// an operator expects to see a just-plugged-in/started agent come online +// reasonably quickly. +const PRINTER_GATE_POLL_INTERVAL_MS = 10_000; + +export function StationPage() { + const { t } = useTranslation(); + const { eventId } = routeApi.useParams(); + const search = routeApi.useSearch(); + const stationId = search.station ?? null; + + // Task 12 -- keeps this station's last_seen_at fresh for as long as this + // page stays mounted (immediate heartbeat + every 20s, cleared on + // unmount). Mounted unconditionally alongside every other hook here (no + // early return above it) per Rules of Hooks; the hook itself no-ops + // internally when `stationId` is null. + useHeartbeat(eventId, stationId); + + const eventQuery = $api.useQuery("get", "/api/events/{id}", { params: { path: { id: eventId } } }); + const stationsQuery = useCheckinStations(eventId); + const settingsQuery = useCheckinSettings(eventId); + // Agent reachability is polled unconditionally while this station is + // mounted (same idiom as AttendeeDrawer.tsx's reprint button) -- the + // check-in flow needs SOME printer name to forward to usePrintBadge on a + // checked_in outcome, and `defaultPrinter` always resolves to something + // once any printer exists (useAgentPrinters' own "always have a + // preselection" rule). + const agent = useAgentPrinters(true); + + // Falls back to DEFAULT_CHECKIN_SETTINGS while settingsQuery is still + // loading (or if it ever errors) -- useCheckinFlow needs a fully-formed + // CheckinSettings unconditionally (Rules of Hooks: this hook, like every + // other one here, must be called on every render regardless of loading + // state), and parseCheckinSettings' own defaults are exactly what an + // event with no saved settings yet would resolve to server-side anyway. + // + // PR #77 bot-review round, Finding N -- that reasoning holds for an event + // that genuinely has never saved settings (GET returns `{settings: null}`, + // and parseCheckinSettings(null) IS the default), but not for the LOADING + // window of an event that HAS real, non-default settings that just + // haven't arrived yet -- scanning/searching against the wrong scan_input + // mode or print_on_checkin value for that brief race would be the exact + // "ungated load effect" bug class P3.1's badge editor hit (gate on + // isSuccess, don't silently operate on a fallback default while a real + // fetch is in flight). Judgment call (documented here per the task brief): + // this gates the LOADING window only (below, via `settingsQuery.isLoading` + // hiding the scan surface entirely) and deliberately does NOT also gate on + // ERROR -- unlike the badge editor's own full-page block, bouncing the + // WHOLE station to a dead end because one settings GET failed would + // violate this station's own no-scan-lost priority for what's often a + // transient/recoverable condition, and `settings.print_on_checkin`/ + // `scan_input` defaulting to the same values a never-configured event + // would already use is a defensible fallback specifically for that + // narrower case. + const settings = settingsQuery.data ?? DEFAULT_CHECKIN_SETTINGS; + const settingsLoading = settingsQuery.isLoading; + + // PR #77 bot-review round 2, Finding 1 -- `agent.defaultPrinter` is `null` + // while the agent printer probe is still `checking`, disconnected, or has + // resolved but found no printers -- `printerName` below then falls back to + // `""`, and if a scan resolves to `checked_in` with `print_on_checkin` true + // DURING that window, the auto-print call reaches the agent with a literal + // empty printer name and silently fails a print that a moment later would + // have had a real default to target. Check-in itself must NEVER be gated + // on the printer (the state machine below is untouched), so this only + // gates the SCAN SURFACE -- and only when auto-print is actually + // configured (`settings.print_on_checkin`); a station with auto-print off + // has nothing to wait for and must stay scannable immediately regardless + // of agent state. Same "don't operate on an unresolved precondition" shape + // as `settingsLoading` above (and composes additively with it below: this + // is only ever evaluated once settings themselves have already resolved). + const printerGateActive = + settings.print_on_checkin && (agent.state !== "connected" || !agent.defaultPrinter); + + // PR #77 bot-review round 3, Finding 3 -- `useAgentPrinters(true)` above + // uses `retry: false` with NO polling interval of its own, so once + // `printerGateActive` flips true it never re-checks on its own: an + // operator who opens this page BEFORE starting the local print agent app + // (or whose agent/printer connects moments after the page loads) would + // otherwise stay stuck on "waiting for printer" until some UNRELATED + // trigger (a window focus, a manual remount) happened to cause a refetch + // -- turning a transient wait into a potentially permanent block. Same + // self-managed-interval-calling-`.refetch()` pattern useConnectionState.ts + // already established for its own 20s health poll (including the + // ref-mirrors-latest-callback idiom, since `agent.refetch` is a fresh + // function reference every render) -- except only active WHILE this + // specific gate is actually blocking (not for the station's whole + // lifetime), and on a SHORTER interval: 10s, not the 20s heartbeat/ + // connection-health cadence those OTHER polls match, since an operator who + // just plugged in/started the agent expects to see it come online + // reasonably quickly, and this poll only ever runs during an already- + // exceptional "still waiting" state, not for the ordinary lifetime of the + // station page. + const agentRefetchRef = React.useRef(agent.refetch); + React.useEffect(() => { + agentRefetchRef.current = agent.refetch; + }, [agent.refetch]); + + React.useEffect(() => { + if (!printerGateActive) return; + const timer = window.setInterval(() => { + void agentRefetchRef.current(); + }, PRINTER_GATE_POLL_INTERVAL_MS); + return () => window.clearInterval(timer); + }, [printerGateActive]); + + const flow = useCheckinFlow({ + eventId, + stationId, + settings, + printerName: agent.defaultPrinter ?? "", + }); + + // P4.1 Task 10 -- degraded mode. `connection.online` folds + // navigator.onLine/the browser's online/offline events and the checkin- + // actions feed's own isError (after react-query's default retries) into + // one debounced signal (useConnectionState's own module comment). This + // task is display/UX degradation ONLY (the phase spec explicitly rules an + // offline write queue out of scope -- offline ownership stays with the + // kiosks): no scan is ever queued for later, it's either sent now or the + // operator sees an explicit "can't check in — offline" state. + const connection = useConnectionState(eventId); + + // Set only when a scan/pick was attempted WHILE offline (never on mount, + // never just because the banner is showing) -- cleared as soon as the + // connection recovers, so a stale "offline" card can't linger once + // check-ins are actually working again. + const [offlineBlocked, setOfflineBlocked] = React.useState(false); + React.useEffect(() => { + if (connection.online) setOfflineBlocked(false); + }, [connection.online]); + + // These wrap ScanInput's onCode/onPickAttendee (not useCheckinFlow + // itself, which Task 6 owns unmodified) -- the interception happens HERE, + // before either of useCheckinFlow's own network calls (submitCode's own + // GET-by-code lookup, submitAttendee's POST /checkin), so a scan attempted + // while offline never reaches the network at all. Wedge/scanner capture + // itself stays enabled regardless of connectivity (ScanInput's own + // `enabled` prop below is untouched by `connection.online`) so a real + // physical scan is always CONSUMED -- never silently dropped -- even + // while offline; this is what shows the explicit offline verdict instead. + // PR #77 bot-review round, Finding F -- flow.submitCode/submitAttendee can + // reject (the API unreachable even though `connection.online` still reads + // true, or the backend rejects the station id) -- previously NEITHER call + // site here had a `.catch`, producing an unhandled promise rejection with + // NO visible verdict/error shown to the operator, silently dropping the + // scan. useCheckinFlow.ts's own catch already resets `state.status` back + // to "idle" (so scanning/searching immediately works again) and records + // `state.requestError` (which VerdictCard's idle view renders) BEFORE + // re-throwing -- this `.catch(() => {})` exists purely to stop that + // re-thrown rejection from going unhandled; it deliberately does nothing + // else, since the actual operator-visible surfacing already happened + // inside the hook. + function handleCode(code: string) { + if (!connection.online) { + setOfflineBlocked(true); + return; + } + setOfflineBlocked(false); + void flow.submitCode(code).catch(() => {}); + } + + function handlePickAttendee(attendee: Attendee) { + if (!connection.online) { + setOfflineBlocked(true); + return; + } + setOfflineBlocked(false); + void flow.submitAttendee(attendee).catch(() => {}); + } + + if (eventQuery.isLoading) { + return ( +
+ + +
+ ); + } + + if (eventQuery.isError || !eventQuery.data) { + return ( +
+

{t("workspaceLoadError")}

+ +
+ ); + } + + const event = eventQuery.data; + const station = stationsQuery.data?.stations.find((entry) => entry.id === stationId); + const scanEnabled = flow.state.status === "idle"; + + return ( +
+
+

{event.name}

+ + {stationsQuery.isLoading ? : (station?.name ?? stationId)} + +
+ +
+
+ + {/* Task 10 -- degraded mode's amber banner (board 2d copy, + `checkinDegradedBanner`), same solid-warning treatment + ImpersonationBanner.tsx already establishes for a station-wide, + hard-to-miss connectivity notice. */} + {!connection.online ? ( +
+ + {t("checkinDegradedBanner")} +
+ ) : null} + +
+
+ {/* PR #77 bot-review round, Finding N -- while the REAL check-in + settings are still loading, this explicit loading state + replaces the verdict/scan surface outright rather than letting + a scan/search submit against DEFAULT_CHECKIN_SETTINGS (a + possibly-wrong scan_input mode or print_on_checkin value) for + that race window. The wedge/scanner capture mechanism briefly + not being mounted here is a deliberate trade against that + silent-wrong-settings risk -- this window is normally as short + as the event/settings fetches themselves. */} + {settingsLoading ? ( +
+ +

{t("checkinSettingsLoading")}

+
+ ) : printerGateActive ? ( + // PR #77 bot-review round 2, Finding 1 -- auto-print is + // configured (`settings.print_on_checkin`) but the agent hasn't + // resolved a usable default printer yet: same "explicit blocked + // state, scan surface not mounted" shape as the settingsLoading + // branch above, so a scan/search can never race an unresolved + // printer name into an auto-print call that's doomed to target + // `""`. +
+ +

{t("checkinPrinterWaiting")}

+
+ ) : offlineBlocked ? ( +
+ +

{t("checkinOfflineBlocked")}

+
+ ) : ( + + )} + {settingsLoading || printerGateActive ? null : ( + + )} +
+ + {/* Task 9's recent-scans rail -- the last-50 check-in actions feed + (reprint/undo/details). 296px per the board's own stated width. */} + +
+
+ ); +} diff --git a/panel/src/features/checkin/VerdictCard.test.tsx b/panel/src/features/checkin/VerdictCard.test.tsx new file mode 100644 index 00000000..05695cba --- /dev/null +++ b/panel/src/features/checkin/VerdictCard.test.tsx @@ -0,0 +1,151 @@ +// VerdictCard had no dedicated test file before -- coverage was only +// indirect, via StationPage.test.tsx's end-to-end scans. PR #77 bot-review +// round adds a few outcome-specific renderings (Finding H's block_reason, +// Finding I's MarkPrintedError distinction, Finding F's idle requestError) +// that are much more directly proven here, against the real component with +// a hand-built CheckinFlowState, than by round-tripping a whole station. +import { render, screen } from "@testing-library/react"; +import { verdictClasses } from "@idento/ui"; +import { VerdictCard } from "./VerdictCard"; +import type { CheckinFlowState } from "./useCheckinFlow"; +import type { components } from "../../shared/api/schema"; +import "../../shared/i18n"; + +type Attendee = components["schemas"]["Attendee"]; + +const ADA: Attendee = { + id: "att-1", + event_id: "evt-1", + first_name: "Ada", + last_name: "Lovelace", + email: "ada@example.com", + company: "Analytical Engines", + position: "Engineer", + code: "CODE1", + checkin_status: false, + printed_count: 0, + blocked: true, + packet_delivered: false, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", +}; + +describe("VerdictCard", () => { + // PR #77 bot-review round, Finding H -- the blocked outcome (mapped to the + // "no_access" verdict, verdict.ts's OUTCOME_TO_VERDICT) carries + // `attendee.block_reason`, but VerdictCard previously rendered name/code + // for every outcome uniformly -- door staff saw "Access denied" but never + // WHY, which they need to explain/resolve the denial. + describe("blocked verdict (block_reason)", () => { + it("renders attendee.block_reason for the no_access verdict", () => { + const state: CheckinFlowState = { + status: "verdict", + verdict: "no_access", + attendee: { ...ADA, block_reason: "Denied entry by organizer" }, + }; + render(); + + expect(screen.getByTestId("checkin-block-reason")).toHaveTextContent( + "Reason: Denied entry by organizer", + ); + }); + + it("renders nothing extra when block_reason is null", () => { + const state: CheckinFlowState = { + status: "verdict", + verdict: "no_access", + attendee: { ...ADA, block_reason: null }, + }; + render(); + + expect(screen.queryByTestId("checkin-block-reason")).not.toBeInTheDocument(); + }); + + it("renders nothing extra when block_reason is an empty string", () => { + const state: CheckinFlowState = { + status: "verdict", + verdict: "no_access", + attendee: { ...ADA, block_reason: "" }, + }; + render(); + + expect(screen.queryByTestId("checkin-block-reason")).not.toBeInTheDocument(); + }); + + it("never renders a block_reason line for a non-blocked verdict, even if the attendee record happens to carry one", () => { + const state: CheckinFlowState = { + status: "verdict", + verdict: "allowed", + attendee: { ...ADA, block_reason: "stale value from a prior block" }, + }; + render(); + + expect(screen.queryByTestId("checkin-block-reason")).not.toBeInTheDocument(); + }); + }); + + // PR #77 bot-review round, Finding I -- a MarkPrintedError (the badge WAS + // sent, only the /printed counter-update afterward failed) must read as a + // softer, distinct caveat from a genuine print failure -- telling the + // operator to reprint would risk an unnecessary duplicate print. + describe("print state distinction", () => { + it("shows the mark-printed warning (not the reprint-it copy) when printMarkFailed is set", () => { + const state: CheckinFlowState = { + status: "verdict", + verdict: "allowed", + attendee: ADA, + printMarkFailed: { printer: "Zebra_ZD421" }, + }; + render(); + + expect(screen.getByTestId("checkin-print-mark-warning")).toHaveTextContent( + "Sent to Zebra_ZD421, but the printed count couldn't be updated.", + ); + expect(screen.queryByText("Badge didn't print — reprint it from the recent scans list.")).not.toBeInTheDocument(); + }); + + it("still shows the reprint-it copy for a genuine printError when printMarkFailed is absent", () => { + const state: CheckinFlowState = { + status: "verdict", + verdict: "allowed", + attendee: ADA, + printError: new Error("agent unreachable"), + }; + render(); + + expect(screen.getByText("Badge didn't print — reprint it from the recent scans list.")).toBeInTheDocument(); + expect(screen.queryByTestId("checkin-print-mark-warning")).not.toBeInTheDocument(); + }); + }); + + // PR #77 bot-review round, Finding F -- a genuine check-in-request failure + // (not a print failure) must show SOMETHING to the operator instead of + // silently going quiet, per the station's core no-scan-lost requirement. + describe("idle requestError", () => { + it("shows a visible error on the idle view when requestError is set", () => { + const state: CheckinFlowState = { status: "idle", requestError: new Error("network down") }; + render(); + + expect(screen.getByTestId("checkin-request-error")).toHaveTextContent( + "Couldn't complete the check-in. Try scanning again.", + ); + }); + + it("shows the plain idle hint, no error line, when requestError is absent", () => { + const state: CheckinFlowState = { status: "idle" }; + render(); + + expect(screen.getByText("Ready for the next scan.")).toBeInTheDocument(); + expect(screen.queryByTestId("checkin-request-error")).not.toBeInTheDocument(); + }); + }); + + it("still renders the base verdict card correctly (sanity check unaffected by the additions above)", () => { + const state: CheckinFlowState = { status: "verdict", verdict: "allowed", attendee: ADA }; + render(); + + const card = screen.getByTestId("checkin-verdict-card"); + expect(card).toHaveAttribute("data-verdict", "allowed"); + expect(card.className).toContain(verdictClasses.allowed.bg); + }); +}); diff --git a/panel/src/features/checkin/VerdictCard.tsx b/panel/src/features/checkin/VerdictCard.tsx new file mode 100644 index 00000000..d87e4606 --- /dev/null +++ b/panel/src/features/checkin/VerdictCard.tsx @@ -0,0 +1,158 @@ +// P4.1 Task 8 -- the check-in station's main verdict panel. Pure, +// props-driven (like WorkspaceRail.tsx): renders whatever +// useCheckinFlow.state currently is, through @idento/ui's shared +// `verdictClasses` (plan global constraint -- "Verdict rendering reuses +// @idento/ui verdictClasses ... Never invent verdict colors"). Every +// verdict pairs its color with a real icon AND a real text label (WCAG +// 1.4.1 -- color alone never carries the meaning), same idiom +// WorkspaceRail's STEP_STATUS_ICON already establishes for readiness +// steps. +import { CheckCircle2, HelpCircle, Loader2, RotateCcw, ScanLine, XCircle } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { verdictClasses, type Verdict } from "@idento/ui"; +import type { CheckinFlowState } from "./useCheckinFlow"; + +export interface VerdictCardProps { + state: CheckinFlowState; +} + +const VERDICT_ICON: Record = { + allowed: CheckCircle2, + no_access: XCircle, + not_registered: HelpCircle, + already_checked_in: RotateCcw, +}; + +const VERDICT_LABEL_KEY: Record = { + allowed: "checkinVerdictAllowed", + no_access: "checkinVerdictNoAccess", + not_registered: "checkinVerdictNotRegistered", + already_checked_in: "checkinVerdictAlreadyCheckedIn", +}; + +// Hand-rolled UTC HH:MM formatter -- same convention as +// AttendeeDrawer.tsx's own (private, not exported) formatUtcHHMM: a +// viewer's local timezone must not shift a server-recorded check-in +// moment, so every check-in timestamp in this app renders in UTC. Small +// enough (4 lines) that duplicating it here beats a cross-feature import +// into attendees/ for one helper. +function formatUtcHHMM(iso: string): string { + const d = new Date(iso); + const hh = String(d.getUTCHours()).padStart(2, "0"); + const mm = String(d.getUTCMinutes()).padStart(2, "0"); + return `${hh}:${mm}`; +} + +export function VerdictCard({ state }: VerdictCardProps) { + const { t } = useTranslation(); + + if (state.status === "idle") { + return ( +
+ +

{t("checkinIdleHint")}

+ {/* PR #77 bot-review round, Finding F -- submitCode/submitAttendee + resets to "idle" immediately on a genuine request failure + (network error, 5xx -- not a print failure, which never reverts + status) so the operator can retry right away; this surfaces WHY + the previous attempt produced no verdict instead of going quiet. */} + {state.requestError ? ( +

+ {t("checkinRequestError")} +

+ ) : null} +
+ ); + } + + if (state.status === "resolving" || !state.verdict) { + // The `!state.verdict` fallback is defensive only -- useCheckinFlow + // never sets status "verdict" without one -- but keeps this component + // from asserting a shape its own prop type only optionally guarantees. + return ( +
+ +

{t("checkinResolvingHint")}

+
+ ); + } + + const verdict = state.verdict; + const classes = verdictClasses[verdict]; + const Icon = VERDICT_ICON[verdict]; + const attendee = state.attendee; + + return ( +
+ +

{t(VERDICT_LABEL_KEY[verdict])}

+ + {attendee ? ( +

+ {attendee.first_name} {attendee.last_name} + {attendee.code} +

+ ) : null} + + {verdict === "already_checked_in" && state.checkin ? ( +

+ {t("checkinFirstScanAt", { time: formatUtcHHMM(state.checkin.at) })} + {state.checkin.point_name ? ` · ${state.checkin.point_name}` : ""} +

+ ) : null} + + {/* PR #77 bot-review round, Finding H -- the ONLY outcome that maps to + "no_access" here is the server's own "blocked" (verdict.ts's + OUTCOME_TO_VERDICT) -- door staff see "Access denied" but not WHY + without this. Mirrors the already_checked_in block above's + per-outcome conditional-rendering pattern. Gracefully omitted when + block_reason is empty/null (schema.d.ts: `block_reason?: string | + null`) rather than rendering a blank line. */} + {verdict === "no_access" && attendee?.block_reason ? ( +

+ {t("checkinBlockReason", { reason: attendee.block_reason })} +

+ ) : null} + + {/* PR #77 bot-review round, Finding I -- a MarkPrintedError (the badge + WAS sent, only the /printed counter-update afterward failed) must + read as a softer, distinct caveat from a genuine print failure -- + telling the operator to reprint here would risk an unnecessary + duplicate print. Mirrors RecentScansRail.tsx's own MarkPrintedError + handling for the SAME distinction on that surface (reuses its + exact `checkinReprintMarkPrintedWarning` copy for consistency). */} + {/* PR #77 bot-review round 2, Finding 2 -- a checked_in scan that + resolved while event fonts were still loading skips the print + attempt entirely (useCheckinFlow's own `printFontsPending` doc + comment) rather than risking a spurious MissingFontError from a + stale font-list race. Mutually exclusive with printMarkFailed/ + printError (useCheckinFlow never sets more than one of the three), + but checked first here purely for a stable branch order -- distinct + copy from `checkinPrintFailedWarning` since NO print was attempted + at all, unlike a genuine failure. */} + {state.printMarkFailed ? ( +

+ {t("checkinReprintMarkPrintedWarning", { printer: state.printMarkFailed.printer })} +

+ ) : state.printFontsPending ? ( +

+ {t("checkinPrintFontsPendingWarning")} +

+ ) : state.printError ? ( +

+ {t("checkinPrintFailedWarning")} +

+ ) : null} +
+ ); +} diff --git a/panel/src/features/checkin/hooks.test.tsx b/panel/src/features/checkin/hooks.test.tsx new file mode 100644 index 00000000..a946f936 --- /dev/null +++ b/panel/src/features/checkin/hooks.test.tsx @@ -0,0 +1,529 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; +import type { ReactNode } from "react"; +import { startMswServer } from "../../test/msw"; +import { useAttendeesPage } from "../attendees/hooks"; +import { + CHECKIN_ACTIONS_KEY, + CHECKIN_SETTINGS_KEY, + CHECKIN_STATIONS_KEY, + useCheckinActions, + useCheckinSettings, + useCheckinStations, + useRegisterStation, + useSaveCheckinSettings, + useStationCheckin, + useStationHeartbeat, + useUndoCheckin, +} from "./hooks"; + +interface CapturedRequest { + path: string; + method: string; + params: URLSearchParams; + body?: unknown; +} + +let captured: CapturedRequest[] = []; +let settingsGetCount = 0; +let stationsGetCount = 0; +let heartbeatPostCount = 0; +let actionsGetCount = 0; +let attendeesGetCount = 0; + +let currentSettings: unknown = null; + +function makeAttendee(overrides: Partial> = {}) { + return { + id: "att-1", + event_id: "evt-1", + first_name: "Ada", + last_name: "Lovelace", + email: "ada@example.com", + company: "Analytical Engines", + position: "Engineer", + code: "CODE1", + checkin_status: false, + printed_count: 0, + blocked: false, + packet_delivered: false, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + ...overrides, + }; +} + +const server = startMswServer( + http.get("http://api.test/api/events/:id/checkin-settings", ({ request }) => { + settingsGetCount += 1; + const url = new URL(request.url); + captured.push({ path: "getCheckinSettings", method: "GET", params: url.searchParams }); + return HttpResponse.json({ settings: currentSettings }); + }), + http.put("http://api.test/api/events/:id/checkin-settings", async ({ request }) => { + const body = (await request.json()) as { settings: unknown }; + currentSettings = body.settings; + captured.push({ path: "putCheckinSettings", method: "PUT", params: new URLSearchParams(), body }); + return HttpResponse.json({ settings: currentSettings }); + }), + http.get("http://api.test/api/events/:eventId/checkin-stations", ({ request }) => { + stationsGetCount += 1; + const url = new URL(request.url); + captured.push({ path: "listCheckinStations", method: "GET", params: url.searchParams }); + return HttpResponse.json({ + stations: [ + { + id: "st-1", + event_id: "evt-1", + name: "Main Door", + last_seen_at: "2026-01-01T00:00:00Z", + created_at: "2026-01-01T00:00:00Z", + }, + ], + }); + }), + http.post("http://api.test/api/events/:eventId/checkin-stations", async ({ request }) => { + const body = (await request.json()) as { name: string; zone_id?: string | null }; + captured.push({ path: "registerCheckinStation", method: "POST", params: new URLSearchParams(), body }); + return HttpResponse.json({ + station: { + id: "st-1", + event_id: "evt-1", + name: body.name, + zone_id: body.zone_id ?? null, + last_seen_at: "2026-01-01T00:00:00Z", + created_at: "2026-01-01T00:00:00Z", + }, + }); + }), + http.post("http://api.test/api/events/:eventId/checkin-stations/:id/heartbeat", ({ params }) => { + heartbeatPostCount += 1; + captured.push({ + path: "heartbeatCheckinStation", + method: "POST", + params: new URLSearchParams({ eventId: String(params.eventId), id: String(params.id) }), + }); + return new HttpResponse(null, { status: 204 }); + }), + http.get("http://api.test/api/events/:eventId/checkin-actions", ({ request }) => { + actionsGetCount += 1; + const url = new URL(request.url); + captured.push({ path: "getCheckinActions", method: "GET", params: url.searchParams }); + return HttpResponse.json({ + actions: [ + { + id: "ca-1", + action: "checkin", + station_id: "st-1", + created_at: "2026-01-01T00:00:00Z", + attendee: { id: "att-1", first_name: "Ada", last_name: "Lovelace", code: "CODE1" }, + }, + ], + }); + }), + http.post("http://api.test/api/events/:eventId/checkin", async ({ request }) => { + const body = (await request.json()) as { attendee_id: string; station_id?: string | null }; + captured.push({ path: "stationCheckin", method: "POST", params: new URLSearchParams(), body }); + return HttpResponse.json({ + outcome: "checked_in", + attendee: makeAttendee({ id: body.attendee_id, checkin_status: true }), + checkin: { at: "2026-01-01T00:00:00Z", by_email: "staff@example.com", point_name: "Main Door" }, + }); + }), + http.post("http://api.test/api/events/:eventId/checkin/undo", async ({ request }) => { + const body = (await request.json()) as { attendee_id: string; station_id?: string | null }; + captured.push({ path: "undoCheckin", method: "POST", params: new URLSearchParams(), body }); + return HttpResponse.json({ attendee: makeAttendee({ id: body.attendee_id, checkin_status: false }) }); + }), + http.get("http://api.test/api/events/:eventId/attendees", ({ request }) => { + attendeesGetCount += 1; + const url = new URL(request.url); + captured.push({ path: "getAttendees", method: "GET", params: url.searchParams }); + return HttpResponse.json({ + attendees: [makeAttendee()], + total: 1, + page: Number(url.searchParams.get("page") ?? "1"), + per_page: Number(url.searchParams.get("per_page") ?? "50"), + }); + }), +); +void server; + +function wrapper({ children }: { children: ReactNode }) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return {children}; +} + +function makeWrapper() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return { + qc, + Wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }; +} + +describe("checkin hooks", () => { + beforeEach(() => { + captured = []; + settingsGetCount = 0; + stationsGetCount = 0; + heartbeatPostCount = 0; + actionsGetCount = 0; + attendeesGetCount = 0; + currentSettings = null; + localStorage.clear(); + localStorage.setItem("token", "jwt-test"); + window.__ENV__ = { API_URL: "http://api.test" }; + }); + + describe("useCheckinSettings", () => { + it("GETs /api/events/{id}/checkin-settings and parses null settings into defaults", async () => { + const { result } = renderHook(() => useCheckinSettings("evt-1"), { wrapper }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(settingsGetCount).toBe(1); + expect(result.current.data).toEqual({ + print_on_checkin: true, + verdict_auto_dismiss_sec: 4, + scan_input: "wedge", + manual_search_enabled: true, + }); + }); + + it("parses a partial stored settings object through parseCheckinSettings' select", async () => { + currentSettings = { scan_input: "scanner" }; + const { result } = renderHook(() => useCheckinSettings("evt-1"), { wrapper }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data?.scan_input).toBe("scanner"); + expect(result.current.data?.print_on_checkin).toBe(true); + }); + }); + + describe("useSaveCheckinSettings", () => { + it("PUTs the settings body and invalidates CHECKIN_SETTINGS_KEY so the read query refetches", async () => { + const { Wrapper } = makeWrapper(); + const { result: readResult } = renderHook(() => useCheckinSettings("evt-1"), { wrapper: Wrapper }); + await waitFor(() => expect(readResult.current.isSuccess).toBe(true)); + expect(settingsGetCount).toBe(1); + + const { result: saveResult } = renderHook(() => useSaveCheckinSettings("evt-1"), { wrapper: Wrapper }); + saveResult.current.mutate({ + params: { path: { id: "evt-1" } }, + body: { + settings: { + print_on_checkin: false, + verdict_auto_dismiss_sec: 8, + scan_input: "manual", + manual_search_enabled: false, + }, + }, + }); + await waitFor(() => expect(saveResult.current.isSuccess).toBe(true)); + + const putCall = captured.find((c) => c.path === "putCheckinSettings"); + expect((putCall?.body as { settings: unknown })?.settings).toEqual({ + print_on_checkin: false, + verdict_auto_dismiss_sec: 8, + scan_input: "manual", + manual_search_enabled: false, + }); + + await waitFor(() => expect(settingsGetCount).toBe(2)); + }); + + it("seeds a mounted useCheckinSettings observer with the just-saved values immediately on success — not DEFAULT_CHECKIN_SETTINGS", async () => { + // Regression test: onSuccess must seed the cache with the raw {settings} + // envelope (matching what GET/PUT actually return), not the + // already-`select`-ed CheckinSettings object. If it re-shapes the + // response before calling setQueryData, useCheckinSettings' own + // `select: (data) => parseCheckinSettings(data.settings)` re-runs + // against that wrongly-shaped raw value immediately, `data.settings` is + // `undefined`, and parseCheckinSettings(undefined) falls back to + // DEFAULT_CHECKIN_SETTINGS — a visible flash of hard-coded defaults + // right after the operator saved something else, self-correcting only + // once the invalidateQueries refetch resolves. + // + // To observe that in-between moment deterministically, this test + // overrides the GET handler so the SECOND request (the refetch that + // invalidateQueries kicks off) resolves after a delay — long enough + // that we can assert on the read hook's data while that refetch is + // still in flight, un-masked by its result. Without the delay, MSW's + // near-instant mock response lets the refetch complete before this + // assertion runs, which would hide the bug (the correct refetched data + // would overwrite whatever setQueryData wrote first). + // + // Also note: the render callback below explicitly destructures `data` + // (rather than returning the whole query result object untouched). + // TanStack Query's tracked-properties optimization only re-renders + // observers for fields actually read during a render; if `data` is + // never read there, the observer won't re-render on a data-only + // update and `result.current` would look permanently frozen at its + // first successful value — regardless of whether this bug is fixed. + let refetchGetCount = 0; + server.use( + http.get("http://api.test/api/events/:id/checkin-settings", async () => { + refetchGetCount += 1; + settingsGetCount += 1; + if (refetchGetCount > 1) { + await new Promise((resolve) => setTimeout(resolve, 50)); + } + return HttpResponse.json({ settings: currentSettings }); + }), + ); + + const { Wrapper } = makeWrapper(); + const { result: readResult } = renderHook( + () => { + const query = useCheckinSettings("evt-1"); + return { isSuccess: query.isSuccess, data: query.data }; + }, + { wrapper: Wrapper }, + ); + await waitFor(() => expect(readResult.current.isSuccess).toBe(true)); + expect(settingsGetCount).toBe(1); + + const { result: saveResult } = renderHook(() => useSaveCheckinSettings("evt-1"), { wrapper: Wrapper }); + saveResult.current.mutate({ + params: { path: { id: "evt-1" } }, + body: { + settings: { + print_on_checkin: false, + verdict_auto_dismiss_sec: 8, + scan_input: "manual", + manual_search_enabled: false, + }, + }, + }); + await waitFor(() => expect(saveResult.current.isSuccess).toBe(true)); + + // The refetch has been kicked off (refetchGetCount is already 2) but is + // still artificially delayed — it has NOT resolved yet, so this + // assertion is exercising only the synchronous setQueryData seed. + expect(refetchGetCount).toBe(2); + expect(readResult.current.data).toEqual({ + print_on_checkin: false, + verdict_auto_dismiss_sec: 8, + scan_input: "manual", + manual_search_enabled: false, + }); + }); + }); + + describe("CHECKIN_SETTINGS_KEY", () => { + it("matches useCheckinSettings' exact query for the given event", async () => { + const { qc, Wrapper } = makeWrapper(); + const { result } = renderHook(() => useCheckinSettings("evt-1"), { wrapper: Wrapper }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(settingsGetCount).toBe(1); + + await qc.invalidateQueries({ queryKey: CHECKIN_SETTINGS_KEY("evt-1") }); + + await waitFor(() => expect(settingsGetCount).toBe(2)); + }); + + it("does not match a different event's settings query", async () => { + const { qc, Wrapper } = makeWrapper(); + const { result: evt1 } = renderHook(() => useCheckinSettings("evt-1"), { wrapper: Wrapper }); + const { result: evt2 } = renderHook(() => useCheckinSettings("evt-2"), { wrapper: Wrapper }); + await waitFor(() => expect(evt1.current.isSuccess).toBe(true)); + await waitFor(() => expect(evt2.current.isSuccess).toBe(true)); + expect(settingsGetCount).toBe(2); + + await qc.invalidateQueries({ queryKey: CHECKIN_SETTINGS_KEY("evt-1") }); + + await waitFor(() => expect(settingsGetCount).toBe(3)); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(settingsGetCount).toBe(3); + }); + }); + + describe("useCheckinStations / useRegisterStation", () => { + it("useCheckinStations GETs the event's station list", async () => { + const { result } = renderHook(() => useCheckinStations("evt-1"), { wrapper }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(stationsGetCount).toBe(1); + expect(result.current.data?.stations).toHaveLength(1); + expect(result.current.data?.stations[0]?.name).toBe("Main Door"); + }); + + it("useRegisterStation POSTs {name, zone_id} and invalidates CHECKIN_STATIONS_KEY", async () => { + const { Wrapper } = makeWrapper(); + const { result: listResult } = renderHook(() => useCheckinStations("evt-1"), { wrapper: Wrapper }); + await waitFor(() => expect(listResult.current.isSuccess).toBe(true)); + expect(stationsGetCount).toBe(1); + + const { result: registerResult } = renderHook(() => useRegisterStation("evt-1"), { wrapper: Wrapper }); + registerResult.current.mutate({ + params: { path: { event_id: "evt-1" } }, + body: { name: "North Door", zone_id: "zone-1" }, + }); + await waitFor(() => expect(registerResult.current.isSuccess).toBe(true)); + + const registerCall = captured.find((c) => c.path === "registerCheckinStation"); + expect(registerCall?.body).toEqual({ name: "North Door", zone_id: "zone-1" }); + + await waitFor(() => expect(stationsGetCount).toBe(2)); + }); + + describe("CHECKIN_STATIONS_KEY", () => { + it("does not match a different event's stations query", async () => { + const { qc, Wrapper } = makeWrapper(); + const { result: evt1 } = renderHook(() => useCheckinStations("evt-1"), { wrapper: Wrapper }); + const { result: evt2 } = renderHook(() => useCheckinStations("evt-2"), { wrapper: Wrapper }); + await waitFor(() => expect(evt1.current.isSuccess).toBe(true)); + await waitFor(() => expect(evt2.current.isSuccess).toBe(true)); + expect(stationsGetCount).toBe(2); + + await qc.invalidateQueries({ queryKey: CHECKIN_STATIONS_KEY("evt-1") }); + + await waitFor(() => expect(stationsGetCount).toBe(3)); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(stationsGetCount).toBe(3); + }); + }); + }); + + describe("useStationHeartbeat", () => { + it("POSTs the heartbeat for the given station id with no body", async () => { + const { result } = renderHook(() => useStationHeartbeat("evt-1"), { wrapper }); + result.current.mutate({ params: { path: { event_id: "evt-1", id: "st-1" } } }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(heartbeatPostCount).toBe(1); + }); + + it("invalidates CHECKIN_STATIONS_KEY on success, so a mounted station list refetches", async () => { + const { Wrapper } = makeWrapper(); + const { result: listResult } = renderHook(() => useCheckinStations("evt-1"), { wrapper: Wrapper }); + await waitFor(() => expect(listResult.current.isSuccess).toBe(true)); + expect(stationsGetCount).toBe(1); + + const { result: heartbeatResult } = renderHook(() => useStationHeartbeat("evt-1"), { wrapper: Wrapper }); + heartbeatResult.current.mutate({ params: { path: { event_id: "evt-1", id: "st-1" } } }); + await waitFor(() => expect(heartbeatResult.current.isSuccess).toBe(true)); + + await waitFor(() => expect(stationsGetCount).toBe(2)); + }); + }); + + describe("useCheckinActions", () => { + it("GETs the actions feed, defaulting limit to 50", async () => { + const { result } = renderHook(() => useCheckinActions("evt-1"), { wrapper }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(actionsGetCount).toBe(1); + const call = captured.find((c) => c.path === "getCheckinActions"); + expect(call?.params.get("limit")).toBe("50"); + expect(result.current.data?.actions).toHaveLength(1); + expect(result.current.data?.actions[0]?.attendee.first_name).toBe("Ada"); + }); + + it("sends a custom limit when given", async () => { + const { result } = renderHook(() => useCheckinActions("evt-1", 10), { wrapper }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const call = captured.find((c) => c.path === "getCheckinActions"); + expect(call?.params.get("limit")).toBe("10"); + }); + + describe("CHECKIN_ACTIONS_KEY", () => { + it("prefix-matches regardless of limit, scoped to one event (invalidateQueries refetches it)", async () => { + const { qc, Wrapper } = makeWrapper(); + const { result: default50 } = renderHook(() => useCheckinActions("evt-1"), { wrapper: Wrapper }); + const { result: limited10 } = renderHook(() => useCheckinActions("evt-1", 10), { wrapper: Wrapper }); + await waitFor(() => expect(default50.current.isSuccess).toBe(true)); + await waitFor(() => expect(limited10.current.isSuccess).toBe(true)); + expect(actionsGetCount).toBe(2); + + await qc.invalidateQueries({ queryKey: CHECKIN_ACTIONS_KEY("evt-1") }); + + await waitFor(() => expect(actionsGetCount).toBe(4)); + }); + + it("does not match a different event's actions query", async () => { + const { qc, Wrapper } = makeWrapper(); + const { result: evt1 } = renderHook(() => useCheckinActions("evt-1"), { wrapper: Wrapper }); + const { result: evt2 } = renderHook(() => useCheckinActions("evt-2"), { wrapper: Wrapper }); + await waitFor(() => expect(evt1.current.isSuccess).toBe(true)); + await waitFor(() => expect(evt2.current.isSuccess).toBe(true)); + expect(actionsGetCount).toBe(2); + + await qc.invalidateQueries({ queryKey: CHECKIN_ACTIONS_KEY("evt-1") }); + + await waitFor(() => expect(actionsGetCount).toBe(3)); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(actionsGetCount).toBe(3); + }); + }); + }); + + describe("useStationCheckin / useUndoCheckin — actions key prefix-invalidation", () => { + it("useStationCheckin invalidates CHECKIN_ACTIONS_KEY and ATTENDEES_LIST_KEY on success", async () => { + const { Wrapper } = makeWrapper(); + const { result: actionsResult } = renderHook(() => useCheckinActions("evt-1"), { wrapper: Wrapper }); + const { result: attendeesResult } = renderHook( + () => useAttendeesPage("evt-1", { page: 1 }), + { wrapper: Wrapper }, + ); + await waitFor(() => expect(actionsResult.current.isSuccess).toBe(true)); + await waitFor(() => expect(attendeesResult.current.isSuccess).toBe(true)); + expect(actionsGetCount).toBe(1); + expect(attendeesGetCount).toBe(1); + + const { result: checkinResult } = renderHook(() => useStationCheckin("evt-1"), { wrapper: Wrapper }); + checkinResult.current.mutate({ + params: { path: { event_id: "evt-1" } }, + body: { attendee_id: "att-1", station_id: "st-1" }, + }); + await waitFor(() => expect(checkinResult.current.isSuccess).toBe(true)); + + await waitFor(() => expect(actionsGetCount).toBe(2)); + await waitFor(() => expect(attendeesGetCount).toBe(2)); + }); + + it("useUndoCheckin invalidates CHECKIN_ACTIONS_KEY and ATTENDEES_LIST_KEY on success", async () => { + const { Wrapper } = makeWrapper(); + const { result: actionsResult } = renderHook(() => useCheckinActions("evt-1"), { wrapper: Wrapper }); + const { result: attendeesResult } = renderHook( + () => useAttendeesPage("evt-1", { page: 1 }), + { wrapper: Wrapper }, + ); + await waitFor(() => expect(actionsResult.current.isSuccess).toBe(true)); + await waitFor(() => expect(attendeesResult.current.isSuccess).toBe(true)); + expect(actionsGetCount).toBe(1); + expect(attendeesGetCount).toBe(1); + + const { result: undoResult } = renderHook(() => useUndoCheckin("evt-1"), { wrapper: Wrapper }); + undoResult.current.mutate({ + params: { path: { event_id: "evt-1" } }, + body: { attendee_id: "att-1", station_id: "st-1" }, + }); + await waitFor(() => expect(undoResult.current.isSuccess).toBe(true)); + + await waitFor(() => expect(actionsGetCount).toBe(2)); + await waitFor(() => expect(attendeesGetCount).toBe(2)); + }); + + it("does not invalidate a different event's actions feed", async () => { + const { Wrapper } = makeWrapper(); + const { result: evt2Actions } = renderHook(() => useCheckinActions("evt-2"), { wrapper: Wrapper }); + await waitFor(() => expect(evt2Actions.current.isSuccess).toBe(true)); + expect(actionsGetCount).toBe(1); + + const { result: checkinResult } = renderHook(() => useStationCheckin("evt-1"), { wrapper: Wrapper }); + checkinResult.current.mutate({ + params: { path: { event_id: "evt-1" } }, + body: { attendee_id: "att-1" }, + }); + await waitFor(() => expect(checkinResult.current.isSuccess).toBe(true)); + + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(actionsGetCount).toBe(1); + }); + }); +}); diff --git a/panel/src/features/checkin/hooks.ts b/panel/src/features/checkin/hooks.ts new file mode 100644 index 00000000..038f8106 --- /dev/null +++ b/panel/src/features/checkin/hooks.ts @@ -0,0 +1,167 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { $api } from "../../shared/api/query"; +import type { components } from "../../shared/api/schema"; +import { ATTENDEES_LIST_KEY } from "../attendees/hooks"; +import { parseCheckinSettings, type CheckinSettings } from "./settingsTypes"; + +// Re-exported schema types for Task 6+ consumers (mirrors staff/hooks.ts' +// StaffUser/StaffZoneAssignment precedent) — keeps the generated schema +// index paths out of every downstream file that just needs the shape. +export type CheckinStation = components["schemas"]["CheckinStation"]; +export type CheckinActionRow = components["schemas"]["CheckinActionRow"]; +export type CheckinOutcome = components["schemas"]["CheckinOutcome"]; +export type StationCheckinResponse = components["schemas"]["StationCheckinResponse"]; + +// --------------------------------------------------------------------------- +// Check-in settings — GET/PUT /api/events/{id}/checkin-settings. Note the +// path param is `id`, NOT `event_id` (same quirk as events/hooks.ts' +// useEventReadiness and badge/hooks.ts' useBadgeTemplate — this operation's +// OpenAPI path literally spells it `{id}`, schema.d.ts:275+). +// --------------------------------------------------------------------------- + +// GET's `.select` runs parseCheckinSettings on the raw `{settings: object | +// null}` envelope so every consumer of this hook always gets a fully +// populated CheckinSettings — never null, never a partial object — without +// re-deriving defaults at each call site (settingsTypes.ts owns that logic). +export function useCheckinSettings(eventId: string) { + return $api.useQuery( + "get", + "/api/events/{id}/checkin-settings", + { params: { path: { id: eventId } } }, + { select: (data) => parseCheckinSettings(data.settings) }, + ); +} + +// Query-key for GET /api/events/{id}/checkin-settings, matching +// useCheckinSettings' exact params shape. Same verified [method, path, init] +// shape ATTENDEES_LIST_KEY documents (attendees/hooks.ts:49-67). +export function CHECKIN_SETTINGS_KEY(eventId: string) { + return ["get", "/api/events/{id}/checkin-settings", { params: { path: { id: eventId } } }] as const; +} + +// Saves the event's check-in settings. Unlike badge/useSaveTemplate.ts (which +// deliberately takes NO eventId argument, keying its cache effects off +// `variables.params.path.id` to survive a mid-save navigation to a different +// event — see that file's own comment), this hook mirrors every OTHER +// per-event hook in this module and takes `eventId` directly: check-in +// settings have no optimistic-concurrency version and are only ever edited +// from the single-event launch ceremony (Task 11), which doesn't carry the +// same stale-navigation hazard the badge editor's save-retry flow does. +export function useSaveCheckinSettings(eventId: string) { + const queryClient = useQueryClient(); + return $api.useMutation("put", "/api/events/{id}/checkin-settings", { + onSuccess: (data) => { + queryClient.setQueryData(CHECKIN_SETTINGS_KEY(eventId), data); + void queryClient.invalidateQueries({ queryKey: CHECKIN_SETTINGS_KEY(eventId) }); + }, + }); +} + +// --------------------------------------------------------------------------- +// Check-in stations — register / heartbeat / list +// (/api/events/{event_id}/checkin-stations*). +// --------------------------------------------------------------------------- + +export function useCheckinStations(eventId: string) { + return $api.useQuery("get", "/api/events/{event_id}/checkin-stations", { + params: { path: { event_id: eventId } }, + }); +} + +// Query-key for GET /api/events/{event_id}/checkin-stations, scoped to one +// event (there's only one query-param shape for this path, so this is a +// plain exact-path prefix rather than a cross-shape one — same reasoning as +// STAFF_KEY, staff/hooks.ts:29). +export function CHECKIN_STATIONS_KEY(eventId: string) { + return ["get", "/api/events/{event_id}/checkin-stations", { params: { path: { event_id: eventId } } }] as const; +} + +// Registers (or re-registers/upserts) a named station. Invalidates the +// station list unconditionally on every success — an upsert always changes +// either a brand-new row or an existing one's zone_id/last_seen_at, so +// there's no outcome where the list should stay stale. +export function useRegisterStation(eventId: string) { + const queryClient = useQueryClient(); + return $api.useMutation("post", "/api/events/{event_id}/checkin-stations", { + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: CHECKIN_STATIONS_KEY(eventId) }); + }, + }); +} + +// Refreshes a station's last_seen_at (Task 12 mounts this on a 20s +// interval). Also invalidates the station list — a later online/offline +// indicator (schema.d.ts's heartbeatCheckinStation comment: "so the panel +// can show online/offline state") reads last_seen_at off this same list, and +// invalidateQueries is a no-op refetch-wise unless that list actually has a +// mounted observer, so this costs nothing when nobody's watching yet. +export function useStationHeartbeat(eventId: string) { + const queryClient = useQueryClient(); + return $api.useMutation("post", "/api/events/{event_id}/checkin-stations/{id}/heartbeat", { + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: CHECKIN_STATIONS_KEY(eventId) }); + }, + }); +} + +// --------------------------------------------------------------------------- +// Check-in actions feed — GET /api/events/{event_id}/checkin-actions. +// --------------------------------------------------------------------------- + +export function useCheckinActions(eventId: string, limit = 50) { + return $api.useQuery("get", "/api/events/{event_id}/checkin-actions", { + params: { path: { event_id: eventId }, query: { limit } }, + }); +} + +// Query-key PREFIX for GET /api/events/{event_id}/checkin-actions — matches +// every `limit` variant for the given event (no `query` sub-key), same +// pattern as ATTENDEES_LIST_KEY (attendees/hooks.ts:65-67): TanStack Query's +// default (non-exact) invalidateQueries match walks +// `Object.keys(filterKey).every(...)` recursively, so a filter key ending in +// `{params: {path: {event_id}}}` (no `query`) matches any actual key whose +// `params.path.event_id` equals `eventId`, regardless of `params.query.limit`. +export function CHECKIN_ACTIONS_KEY(eventId: string) { + return ["get", "/api/events/{event_id}/checkin-actions", { params: { path: { event_id: eventId } } }] as const; +} + +// --------------------------------------------------------------------------- +// Station check-in / undo — POST /api/events/{event_id}/checkin[/undo]. +// --------------------------------------------------------------------------- + +// Fires the idempotent single-scan check-in. Unconditionally invalidates +// CHECKIN_ACTIONS_KEY (a checked_in outcome adds a feed row; already_ +// checked_in/blocked don't, but re-fetching an unchanged feed is harmless) +// and ATTENDEES_LIST_KEY (a checked_in outcome flips the attendee's +// checkin_status, which the attendees table/roster must reflect) — this is +// the "unconditional invalidation" mutation-hygiene rule from the phase plan +// applied at the one shared call site, so every consumer (Task 6's +// useCheckinFlow, and any other future caller) gets it for free rather than +// each having to remember it. +export function useStationCheckin(eventId: string) { + const queryClient = useQueryClient(); + return $api.useMutation("post", "/api/events/{event_id}/checkin", { + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: CHECKIN_ACTIONS_KEY(eventId) }); + void queryClient.invalidateQueries({ queryKey: ATTENDEES_LIST_KEY(eventId) }); + }, + }); +} + +// Clears a check-in (idempotent — see schema.d.ts's undoCheckin comment). +// Same unconditional-invalidation rationale as useStationCheckin above; also +// exactly what Task 9's recent-scans rail needs for its own Undo row (its +// own interface note: "both invalidate CHECKIN_ACTIONS_KEY + +// ATTENDEES_LIST_KEY" — already satisfied here, no per-caller duplication +// required). +export function useUndoCheckin(eventId: string) { + const queryClient = useQueryClient(); + return $api.useMutation("post", "/api/events/{event_id}/checkin/undo", { + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: CHECKIN_ACTIONS_KEY(eventId) }); + void queryClient.invalidateQueries({ queryKey: ATTENDEES_LIST_KEY(eventId) }); + }, + }); +} + +export type { CheckinSettings }; diff --git a/panel/src/features/checkin/searchParams.test.ts b/panel/src/features/checkin/searchParams.test.ts new file mode 100644 index 00000000..f9176a66 --- /dev/null +++ b/panel/src/features/checkin/searchParams.test.ts @@ -0,0 +1,72 @@ +// P4.1 Task 8's searchParams module had no dedicated test file -- coverage +// was only indirect, via StationPage.test.tsx's routing block. PR #77 +// bot-review round, Finding G, adds UUID-format validation to +// `validateCheckinStationSearch`; this file gives that parser (and the +// `checkinStationBeforeLoad` guard built on it) direct unit coverage. +import { describe, expect, it } from "vitest"; +import { checkinStationBeforeLoad, validateCheckinStationSearch } from "./searchParams"; + +const VALID_UUID = "11111111-1111-4111-8111-111111111111"; + +describe("validateCheckinStationSearch", () => { + it("keeps a well-formed UUID station id verbatim", () => { + expect(validateCheckinStationSearch({ station: VALID_UUID })).toEqual({ station: VALID_UUID }); + }); + + it("accepts an uppercase-hex UUID (case-insensitive)", () => { + expect(validateCheckinStationSearch({ station: VALID_UUID.toUpperCase() })).toEqual({ + station: VALID_UUID.toUpperCase(), + }); + }); + + it("resolves a missing station value to undefined", () => { + expect(validateCheckinStationSearch({})).toEqual({ station: undefined }); + }); + + it("resolves an empty string to undefined", () => { + expect(validateCheckinStationSearch({ station: "" })).toEqual({ station: undefined }); + }); + + it("resolves a non-string value to undefined", () => { + expect(validateCheckinStationSearch({ station: 42 })).toEqual({ station: undefined }); + }); + + // PR #77 bot-review round, Finding G -- a malformed (non-UUID) station + // value previously passed through verbatim, letting StationPage mount and + // send it in heartbeat/check-in requests, causing repeated 400s instead of + // the intended redirect-to-launch-ceremony behavior. A malformed value now + // collapses to `undefined`, same as missing. + it("resolves a non-UUID string to undefined", () => { + expect(validateCheckinStationSearch({ station: "not-a-uuid" })).toEqual({ station: undefined }); + expect(validateCheckinStationSearch({ station: "st-1" })).toEqual({ station: undefined }); + expect(validateCheckinStationSearch({ station: "11111111-1111-1111-1111" })).toEqual({ station: undefined }); + expect(validateCheckinStationSearch({ station: `${VALID_UUID}-extra` })).toEqual({ station: undefined }); + }); + + // Deliberately NOT rejected here -- StationPage.tsx's own file-header + // comment documents "an unregistered-but-well-formed id is treated as a + // station this page simply can't NAME yet ... not as a reason to bounce + // the operator back to the ceremony mid-shift" as a deliberate design + // decision. This module has no knowledge of the registered station list at + // all (format is the only thing it checks), so this test re-confirms the + // format check alone doesn't -- and structurally can't -- reject an + // unregistered-but-valid id. + it("keeps a well-formed but hypothetically-unregistered UUID (format-only validation, no existence/ownership check)", () => { + const unregistered = "99999999-9999-4999-8999-999999999999"; + expect(validateCheckinStationSearch({ station: unregistered })).toEqual({ station: unregistered }); + }); +}); + +describe("checkinStationBeforeLoad", () => { + it("redirects to the launch ceremony when station is undefined (missing or malformed both collapse identically upstream)", () => { + expect(() => + checkinStationBeforeLoad({ params: { eventId: "evt-1" }, search: { station: undefined } }), + ).toThrow(); + }); + + it("does not redirect when station is a well-formed UUID", () => { + expect(() => + checkinStationBeforeLoad({ params: { eventId: "evt-1" }, search: { station: VALID_UUID } }), + ).not.toThrow(); + }); +}); diff --git a/panel/src/features/checkin/searchParams.ts b/panel/src/features/checkin/searchParams.ts new file mode 100644 index 00000000..e000055a --- /dev/null +++ b/panel/src/features/checkin/searchParams.ts @@ -0,0 +1,65 @@ +// Typed search params + route guard for the /_app/events/$eventId/checkin +// route (P4.1 Task 8). `station` is the registered check-in station's id -- +// set by the launch ceremony (Task 11) when it navigates here after +// registering the station. Kept in its own module (not inlined in +// app/router.tsx), mirroring attendees/searchParams.ts's +// validateAttendeesSearch precedent exactly: both the real route +// definition (app/router.tsx) and this feature's own routed test harness +// (StationPage.test.tsx) import the SAME parsing/guard logic, so the two +// can never silently drift apart. +import { redirect } from "@tanstack/react-router"; + +export interface CheckinStationSearch { + station?: string; +} + +// PR #77 bot-review round, Finding G -- format-only validation. Station ids +// are server-generated UUIDs (backend/internal/handler/checkin_stations.go's +// own uuid.Parse); a `?station=` that isn't UUID-SHAPED can never resolve to +// a real station, so accepting it verbatim just deferred the failure to +// StationPage's own heartbeat/check-in calls, which would then 400 in a loop +// instead of the intended redirect-to-launch-ceremony. Deliberately FORMAT +// ONLY -- this module has no access to (and must never gain) the registered +// station list: a well-formed but hypothetically-unregistered UUID is NOT +// rejected here (see StationPage.tsx's own file-header comment for why that +// distinction is a deliberate design decision, not an oversight). +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +// A missing, empty, non-string, or non-UUID-shaped `station` search value +// all collapse to `undefined` here -- "missing" and "malformed" are handled +// identically by the beforeLoad guard below (checkinStationBeforeLoad), +// matching the brief's own phrasing ("Missing/invalid ?station= ... +// redirect"). +export function validateCheckinStationSearch(search: Record): CheckinStationSearch { + const raw = typeof search.station === "string" ? search.station : ""; + const station = UUID_PATTERN.test(raw) ? raw : undefined; + return { station }; +} + +// Route-level `beforeLoad` guard: you can't run a check-in station without +// having registered one first (Task 11's launch ceremony is the only thing +// that navigates here WITH a `?station=`), so an absent/invalid value +// redirects to the launch ceremony BEFORE StationPage ever mounts -- the +// component itself can then assume `search.station` is always a non-empty +// string. +// +// `href` (not `to`): `/events/$eventId/checkin/launch` isn't a registered +// route yet (Task 11 creates it) -- app/router.tsx's `Register` module +// augmentation makes every `to`/`redirect({ to })` call statically checked +// against the CURRENT route tree, so a not-yet-registered path would fail +// to typecheck. `href` is a plain string, resolved at runtime instead of +// compile time, and -- per `redirect()`'s own implementation -- only forces +// a full-document reload when it parses as an ABSOLUTE URL (`new URL(href)` +// succeeding); this relative in-app path does not, so it stays a normal SPA +// redirect. +export function checkinStationBeforeLoad({ + params, + search, +}: { + params: { eventId: string }; + search: CheckinStationSearch; +}): void { + if (!search.station) { + throw redirect({ href: `/events/${params.eventId}/checkin/launch` }); + } +} diff --git a/panel/src/features/checkin/settingsTypes.test.ts b/panel/src/features/checkin/settingsTypes.test.ts new file mode 100644 index 00000000..00344c24 --- /dev/null +++ b/panel/src/features/checkin/settingsTypes.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_CHECKIN_SETTINGS, parseCheckinSettings } from "./settingsTypes"; + +describe("DEFAULT_CHECKIN_SETTINGS", () => { + it("matches the board defaults", () => { + expect(DEFAULT_CHECKIN_SETTINGS).toEqual({ + print_on_checkin: true, + verdict_auto_dismiss_sec: 4, + scan_input: "wedge", + manual_search_enabled: true, + }); + }); +}); + +describe("parseCheckinSettings", () => { + it("returns the defaults for null (event has never had settings saved)", () => { + expect(parseCheckinSettings(null)).toEqual(DEFAULT_CHECKIN_SETTINGS); + }); + + it("returns the defaults for undefined", () => { + expect(parseCheckinSettings(undefined)).toEqual(DEFAULT_CHECKIN_SETTINGS); + }); + + it("returns the defaults for a non-object value", () => { + expect(parseCheckinSettings("nonsense")).toEqual(DEFAULT_CHECKIN_SETTINGS); + expect(parseCheckinSettings(42)).toEqual(DEFAULT_CHECKIN_SETTINGS); + expect(parseCheckinSettings([])).toEqual(DEFAULT_CHECKIN_SETTINGS); + }); + + it("returns the defaults for an empty object", () => { + expect(parseCheckinSettings({})).toEqual(DEFAULT_CHECKIN_SETTINGS); + }); + + it("fills in per-field defaults for a partial object, keeping the fields that ARE present", () => { + expect(parseCheckinSettings({ print_on_checkin: false })).toEqual({ + ...DEFAULT_CHECKIN_SETTINGS, + print_on_checkin: false, + }); + expect(parseCheckinSettings({ scan_input: "scanner" })).toEqual({ + ...DEFAULT_CHECKIN_SETTINGS, + scan_input: "scanner", + }); + expect(parseCheckinSettings({ manual_search_enabled: false })).toEqual({ + ...DEFAULT_CHECKIN_SETTINGS, + manual_search_enabled: false, + }); + expect(parseCheckinSettings({ verdict_auto_dismiss_sec: 10 })).toEqual({ + ...DEFAULT_CHECKIN_SETTINGS, + verdict_auto_dismiss_sec: 10, + }); + }); + + it("falls back to the per-field default when a field has the wrong type", () => { + expect( + parseCheckinSettings({ + print_on_checkin: "yes", + verdict_auto_dismiss_sec: "4", + scan_input: 7, + manual_search_enabled: "no", + }), + ).toEqual(DEFAULT_CHECKIN_SETTINGS); + }); + + it("falls back to the default scan_input for an unrecognized enum value", () => { + expect(parseCheckinSettings({ scan_input: "camera" })).toEqual(DEFAULT_CHECKIN_SETTINGS); + }); + + it("accepts every valid scan_input value verbatim", () => { + expect(parseCheckinSettings({ scan_input: "wedge" }).scan_input).toBe("wedge"); + expect(parseCheckinSettings({ scan_input: "scanner" }).scan_input).toBe("scanner"); + expect(parseCheckinSettings({ scan_input: "manual" }).scan_input).toBe("manual"); + }); + + // The backend's own PUT validation (openapi's putCheckinSettings 400 rule) + // enforces verdict_auto_dismiss_sec is an integer in 1..30, so an + // out-of-range value can only reach this parser via a hand-edited DB row + // or a future relaxation of that rule — still, a defensive client parser + // must not blow up or silently accept it. Judgment call (documented in + // settingsTypes.ts): CLAMP to the valid 1..30 range rather than discard to + // the default, since a clamp preserves the operator's evident intent (e.g. + // "as long as possible" for a huge value) better than silently resetting + // to 4. + it("clamps an out-of-range verdict_auto_dismiss_sec to the nearest bound (1..30)", () => { + expect(parseCheckinSettings({ verdict_auto_dismiss_sec: 0 }).verdict_auto_dismiss_sec).toBe(1); + expect(parseCheckinSettings({ verdict_auto_dismiss_sec: -5 }).verdict_auto_dismiss_sec).toBe(1); + expect(parseCheckinSettings({ verdict_auto_dismiss_sec: 31 }).verdict_auto_dismiss_sec).toBe(30); + expect(parseCheckinSettings({ verdict_auto_dismiss_sec: 1000 }).verdict_auto_dismiss_sec).toBe(30); + }); + + it("preserves an in-range verdict_auto_dismiss_sec, including the boundary values", () => { + expect(parseCheckinSettings({ verdict_auto_dismiss_sec: 1 }).verdict_auto_dismiss_sec).toBe(1); + expect(parseCheckinSettings({ verdict_auto_dismiss_sec: 30 }).verdict_auto_dismiss_sec).toBe(30); + expect(parseCheckinSettings({ verdict_auto_dismiss_sec: 15 }).verdict_auto_dismiss_sec).toBe(15); + }); + + it("falls back to the default for a non-finite verdict_auto_dismiss_sec (NaN/Infinity)", () => { + expect(parseCheckinSettings({ verdict_auto_dismiss_sec: NaN }).verdict_auto_dismiss_sec).toBe(4); + expect(parseCheckinSettings({ verdict_auto_dismiss_sec: Infinity }).verdict_auto_dismiss_sec).toBe(4); + }); + + // PR #77 bot-review round, Finding O -- the backend contract requires an + // INTEGER (openapi.yaml's putCheckinSettings 400 rule); a fractional value + // was previously accepted and merely clamped, letting it reach timer math + // (`verdict_auto_dismiss_sec * 1000` in useCheckinFlow.ts). Same fallback + // behavior as any other invalid case this parser already handles: discard + // to the default rather than round/truncate (rounding would silently + // invent a value the operator never actually set). + it("falls back to the default for a fractional verdict_auto_dismiss_sec, even when it's within the 1..30 range", () => { + expect(parseCheckinSettings({ verdict_auto_dismiss_sec: 4.5 }).verdict_auto_dismiss_sec).toBe(4); + expect(parseCheckinSettings({ verdict_auto_dismiss_sec: 1.1 }).verdict_auto_dismiss_sec).toBe(4); + expect(parseCheckinSettings({ verdict_auto_dismiss_sec: 29.9 }).verdict_auto_dismiss_sec).toBe(4); + }); + + it("still falls back to the default for a fractional AND out-of-range verdict_auto_dismiss_sec (fractional check runs before clamping)", () => { + expect(parseCheckinSettings({ verdict_auto_dismiss_sec: 0.5 }).verdict_auto_dismiss_sec).toBe(4); + expect(parseCheckinSettings({ verdict_auto_dismiss_sec: 30.5 }).verdict_auto_dismiss_sec).toBe(4); + }); + + it("ignores unknown extra fields rather than throwing", () => { + expect(parseCheckinSettings({ print_on_checkin: false, mystery: "field" })).toEqual({ + ...DEFAULT_CHECKIN_SETTINGS, + print_on_checkin: false, + }); + }); +}); diff --git a/panel/src/features/checkin/settingsTypes.ts b/panel/src/features/checkin/settingsTypes.ts new file mode 100644 index 00000000..86de3b6b --- /dev/null +++ b/panel/src/features/checkin/settingsTypes.ts @@ -0,0 +1,106 @@ +// Hand-written check-in settings type + defensive parser (P4.1 Task 5) — +// mirrors badge/templateTypes.ts's parseTemplateDoc shape (isPlainObject + +// per-field type narrowing with a default fallback), but for the much +// simpler CheckinSettings shape (schema.d.ts's CheckinSettings — all four +// fields required server-side, stored verbatim in events.checkin_settings). +// +// GET /api/events/{id}/checkin-settings returns `{settings: CheckinSettings +// | null}` — null when the event has never had settings saved (see +// CheckinSettingsResponse's own schema.d.ts comment). This parser's job is +// to turn that `settings` value (or anything else that reaches it) into a +// fully-populated CheckinSettings the rest of the panel can rely on without +// re-checking for null/partial/malformed data at every call site. + +export interface CheckinSettings { + print_on_checkin: boolean; + verdict_auto_dismiss_sec: number; + scan_input: "wedge" | "scanner" | "manual"; + manual_search_enabled: boolean; +} + +// The board's default settings for an event that has never saved any (board +// 2a) — also what the launch ceremony (Task 11) pre-fills its settings form +// with before the operator's first save. +export const DEFAULT_CHECKIN_SETTINGS: CheckinSettings = { + print_on_checkin: true, + verdict_auto_dismiss_sec: 4, + scan_input: "wedge", + manual_search_enabled: true, +}; + +const VALID_SCAN_INPUTS: ReadonlySet = new Set([ + "wedge", + "scanner", + "manual", +]); + +// Mirrors the backend's own PUT validation (openapi.yaml's putCheckinSettings +// 400 rule, schema.d.ts:2806): verdict_auto_dismiss_sec must be an integer in +// 1..30. Every value this panel itself ever PUTs already satisfies this (the +// settings form clamps its own input), so an out-of-range value reaching +// parseCheckinSettings can only come from a hand-edited DB row, a future +// relaxation of the backend rule, or a test fixture — not normal operation. +const MIN_DISMISS_SEC = 1; +const MAX_DISMISS_SEC = 30; + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +// Defensively narrows the server's `settings: object | null` (or any other +// `unknown` — this is deliberately the widest possible input type) into a +// fully-populated CheckinSettings. Every field falls back to +// DEFAULT_CHECKIN_SETTINGS' value INDEPENDENTLY of the others (a partial +// object keeps whichever fields it DOES have) — a wrong-typed or missing +// field never invalidates the fields around it. +// +// Judgment call for out-of-range verdict_auto_dismiss_sec (documented in the +// "task-5-brief.md" as "clamp-or-default, implementer's choice"): this +// parser CLAMPS to the 1..30 bound rather than discarding to the default. +// Reasoning: a clamp preserves the operator's evident intent (a value like +// 1000 clearly means "as long as possible", not "unset") strictly better +// than silently resetting to 4, and it's the same posture PUT validation +// takes server-side conceptually (reject only what can't be made sensible — +// here, "sensible" is trivial: clamp to the nearest legal bound). A +// non-finite number (NaN/Infinity) still falls back to the default, since +// there's no sensible bound to clamp NaN toward. +export function parseCheckinSettings(raw: unknown): CheckinSettings { + if (!isPlainObject(raw)) { + return { ...DEFAULT_CHECKIN_SETTINGS }; + } + + const print_on_checkin = + typeof raw.print_on_checkin === "boolean" ? raw.print_on_checkin : DEFAULT_CHECKIN_SETTINGS.print_on_checkin; + + const manual_search_enabled = + typeof raw.manual_search_enabled === "boolean" + ? raw.manual_search_enabled + : DEFAULT_CHECKIN_SETTINGS.manual_search_enabled; + + const scan_input = + typeof raw.scan_input === "string" && VALID_SCAN_INPUTS.has(raw.scan_input) + ? (raw.scan_input as CheckinSettings["scan_input"]) + : DEFAULT_CHECKIN_SETTINGS.scan_input; + + let verdict_auto_dismiss_sec = DEFAULT_CHECKIN_SETTINGS.verdict_auto_dismiss_sec; + if ( + typeof raw.verdict_auto_dismiss_sec === "number" && + Number.isFinite(raw.verdict_auto_dismiss_sec) && + // PR #77 bot-review round, Finding O -- the backend contract requires an + // INTEGER (openapi.yaml's putCheckinSettings 400 rule). A fractional + // value (e.g. 4.5, from a hand-edited DB row) previously survived the + // finite check above and was merely clamped, letting a fraction reach + // timer math (useCheckinFlow.ts's `verdict_auto_dismiss_sec * 1000`). + // Same fallback-to-default behavior as any other invalid case this + // parser already handles -- discard, don't round/truncate (rounding + // would silently invent a value the operator never actually set). + Number.isInteger(raw.verdict_auto_dismiss_sec) + ) { + verdict_auto_dismiss_sec = Math.min( + MAX_DISMISS_SEC, + Math.max(MIN_DISMISS_SEC, raw.verdict_auto_dismiss_sec), + ); + } + + return { print_on_checkin, verdict_auto_dismiss_sec, scan_input, manual_search_enabled }; +} diff --git a/panel/src/features/checkin/useCheckinFlow.test.tsx b/panel/src/features/checkin/useCheckinFlow.test.tsx new file mode 100644 index 00000000..f3b01283 --- /dev/null +++ b/panel/src/features/checkin/useCheckinFlow.test.tsx @@ -0,0 +1,548 @@ +// P4.1 Task 6 -- useCheckinFlow tests. Exercises BOTH MSW origins (the +// backend `http://api.test` AND the print agent `http://agent.test`), same +// combined-origin shape usePrintBadge.test.tsx established -- this hook +// calls the REAL usePrintBadge internally (not a mock), so a "checked_in + +// print_on_checkin" scan genuinely round-trips through the badge-template +// fetch, font loading, agent print, and mark-printed, exactly as it would in +// the running app. +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { waitFor } from "@testing-library/react"; +import { renderHook } from "@testing-library/react"; +import { delay, http, HttpResponse } from "msw"; +import type { ReactNode } from "react"; +import { startMswServer } from "../../test/msw"; +import type { components } from "../../shared/api/schema"; +import { DEFAULT_CHECKIN_SETTINGS, type CheckinSettings } from "./settingsTypes"; +import { useCheckinFlow, type UseCheckinFlowOptions } from "./useCheckinFlow"; + +type Attendee = components["schemas"]["Attendee"]; +type CheckinOutcome = "checked_in" | "already_checked_in" | "blocked"; + +const ATTENDEE: Attendee = { + id: "att-1", + event_id: "evt-1", + first_name: "Ada", + last_name: "Lovelace", + email: "ada@example.com", + company: "Analytical Engines", + position: "Engineer", + code: "CODE1", + checkin_status: false, + printed_count: 0, + blocked: false, + packet_delivered: false, +}; + +// Same minimal Latin-only, jsdom-viable fixture usePrintBadge.test.tsx uses +// -- generation correctness itself is already pinned by generateZpl.test.ts; +// this file only needs printAttendee's whole pipeline to succeed (or fail, +// for the print-failure test) so useCheckinFlow's OWN state machine can be +// observed. +const TEMPLATE_DOC = { + width_mm: 90, + height_mm: 55, + dpi: 300, + elements: [{ id: "e1", type: "text", x: 0, y: 0, fontSize: 10, source: "first_name", text: "Guest" }], +}; + +// Same FontFace/document.fonts stub usePrintBadge.test.tsx uses -- jsdom +// implements neither, and usePrintBadge (called internally by +// useCheckinFlow) needs a terminal fontsStatus before it will generate. +class MockFontFace { + family: string; + constructor(family: string, _source: unknown, _descriptors?: { weight?: string; style?: string }) { + this.family = family; + } + load(): Promise { + return Promise.resolve(this); + } +} +function stubFontFaceApi() { + (globalThis as unknown as { FontFace: unknown }).FontFace = MockFontFace; + Object.defineProperty(document, "fonts", { value: { add: () => {} }, configurable: true, writable: true }); +} +function unstubFontFaceApi() { + delete (globalThis as unknown as { FontFace?: unknown }).FontFace; + // @ts-expect-error -- test-only cleanup of the jsdom `document.fonts` + // stub; real jsdom has no `fonts` property to restore. + delete document.fonts; +} + +let checkinOutcome: CheckinOutcome = "checked_in"; +let checkinHitCount = 0; +let checkinCapturedBody: { attendee_id: string; station_id?: string | null } | null = null; +let attendeesGetCount = 0; +let lastAttendeesCodeParam: string | null = null; +let printedHitCount = 0; +let printedBodyCapture: unknown; +let agentPrintHitCount = 0; +let agentPrintStatus = 200; + +// Factored out of the default checkin POST handler below so a Finding-2 test +// can reuse the exact same response-shaping logic behind an ARTIFICIALLY +// DELAYED handler (server.use override) -- forcing a real, deterministic +// window where a checked_in scan resolves while the fonts fetch (delayed +// separately, see that test) is still in flight, rather than relying on +// incidental timing. +function buildCheckinResponse(body: { attendee_id: string; station_id?: string | null }) { + checkinHitCount += 1; + checkinCapturedBody = body; + const attendee: Attendee = { + ...ATTENDEE, + id: body.attendee_id, + checkin_status: checkinOutcome !== "blocked", + blocked: checkinOutcome === "blocked", + }; + const checkin = + checkinOutcome === "blocked" + ? null + : { at: "2026-01-01T00:00:00Z", by_email: "staff@example.com", point_name: "Main Door" }; + return { outcome: checkinOutcome, attendee, checkin }; +} + +// PR #77 bot-review round 2, Finding 2 -- useCheckinFlow's auto-print gate +// now reads `printBadge.fontsStatus` SYNCHRONOUSLY the instant a checked_in +// scan resolves (no internal wait -- see useCheckinFlow.ts's own +// `printFontsPending` doc comment for why). A scan fired the INSTANT a hook +// mounts genuinely races the (still in-flight) fonts-list fetch reaching a +// terminal status, even with an empty list and zero artificial delay -- +// react-query's own scheduling for that `useQuery` measurably lags this +// hook's two sequential raw calls (the code lookup GET, then the checkin +// POST mutation). Tests exercising the SUCCESS path settle the fonts fetch +// first, matching a REAL station where an operator's first physical scan +// happens well after mount, not the mount-instant race the dedicated +// "still loading" test below is specifically about. +async function settleFonts() { + await new Promise((resolve) => setTimeout(resolve, 150)); +} + +// Same minimal FontListItem/font-bytes fixtures useEventFontFaces.test.tsx +// uses -- only needed by the Finding-2 "fonts still loading" test below. +function fontListItem(id: string, family: string) { + return { + id, + name: family, + family, + weight: "normal", + style: "normal", + format: "opentype" as const, + size: 1000, + created_at: "2026-01-01T00:00:00Z", + }; +} +const FAKE_FONT_BYTES = new TextEncoder().encode("fake-font-bytes").buffer as ArrayBuffer; + +const server = startMswServer( + http.get("http://api.test/api/events/:id/badge-template", () => + HttpResponse.json({ template: TEMPLATE_DOC, version: 1 }), + ), + http.get("http://api.test/api/events/:eventId/fonts", () => HttpResponse.json([])), + http.get("http://api.test/api/events/:eventId/attendees", ({ request }) => { + attendeesGetCount += 1; + const url = new URL(request.url); + lastAttendeesCodeParam = url.searchParams.get("code"); + if (lastAttendeesCodeParam === ATTENDEE.code) { + return HttpResponse.json([ATTENDEE]); + } + return HttpResponse.json([]); + }), + http.post("http://api.test/api/events/:eventId/checkin", async ({ request }) => { + const body = (await request.json()) as { attendee_id: string; station_id?: string | null }; + return HttpResponse.json(buildCheckinResponse(body)); + }), + http.post("http://api.test/api/attendees/:attendeeId/printed", async ({ request }) => { + printedHitCount += 1; + const raw = await request.text(); + printedBodyCapture = raw ? JSON.parse(raw) : undefined; + return HttpResponse.json({ printed_count: printedHitCount }); + }), + http.get("http://agent.test/health", () => new HttpResponse(null, { status: 200 })), + http.post("http://agent.test/print", () => { + agentPrintHitCount += 1; + if (agentPrintStatus !== 200) return new HttpResponse("printer offline", { status: agentPrintStatus }); + return HttpResponse.json({ status: "printed" }); + }), +); +void server; + +function renderFlow(overrides: Partial = {}) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + function wrapper({ children }: { children: ReactNode }) { + return {children}; + } + const options: UseCheckinFlowOptions = { + eventId: "evt-1", + stationId: "st-1", + settings: DEFAULT_CHECKIN_SETTINGS, + printerName: "Zebra_ZD421", + ...overrides, + }; + return renderHook(() => useCheckinFlow(options), { wrapper }); +} + +describe("useCheckinFlow", () => { + beforeEach(() => { + window.__ENV__ = { API_URL: "http://api.test", AGENT_URL: "http://agent.test" }; + localStorage.clear(); + localStorage.setItem("token", "jwt-test"); + checkinOutcome = "checked_in"; + checkinHitCount = 0; + checkinCapturedBody = null; + attendeesGetCount = 0; + lastAttendeesCodeParam = null; + printedHitCount = 0; + printedBodyCapture = undefined; + agentPrintHitCount = 0; + agentPrintStatus = 200; + stubFontFaceApi(); + }); + + afterEach(() => { + unstubFontFaceApi(); + }); + + it("resolves a fresh scanned code to the checked_in (allowed) verdict and prints WITHOUT a printContext (the checkin row was already logged by the check-in call itself)", async () => { + const { result } = renderFlow(); + // PR #77 bot-review round 2, Finding 2 -- auto-print now only fires once + // `printBadge.fontsStatus` has ALREADY reached a terminal state (see that + // finding's own dedicated "still loading" test below); this settles the + // (empty, undelayed) fonts fetch to "ready" first, matching a REAL + // station where fonts finish loading well before an operator's first + // physical scan, rather than testing the mount-instant race this finding + // is specifically about. + await settleFonts(); + + void result.current.submitCode(ATTENDEE.code); + + await waitFor(() => expect(result.current.state.status).toBe("verdict")); + + expect(result.current.state.verdict).toBe("allowed"); + expect(result.current.state.attendee?.id).toBe(ATTENDEE.id); + expect(result.current.state.checkin?.point_name).toBe("Main Door"); + expect(result.current.state.printError).toBeUndefined(); + + expect(lastAttendeesCodeParam).toBe(ATTENDEE.code); + expect(checkinHitCount).toBe(1); + expect(checkinCapturedBody).toEqual({ attendee_id: ATTENDEE.id, station_id: "st-1" }); + + await waitFor(() => expect(agentPrintHitCount).toBe(1)); + await waitFor(() => expect(printedHitCount).toBe(1)); + // No printContext -- this is the IMPLICIT auto-print fulfilling a + // check-in that was already logged server-side (Task 3's + // CheckInAttendee), not a separate loggable reprint action (final + // cross-task review finding). An empty body (undefined, since the + // request itself carries no JSON payload) is the pre-existing P3.2 + // counter-only shape -- see this suite's own MSW handler for + // POST /printed above (`raw ? JSON.parse(raw) : undefined`). + expect(printedBodyCapture).toBeUndefined(); + }); + + it("shows already_checked_in for a repeat scan and never prints", async () => { + checkinOutcome = "already_checked_in"; + const { result } = renderFlow(); + + void result.current.submitCode(ATTENDEE.code); + + await waitFor(() => expect(result.current.state.status).toBe("verdict")); + expect(result.current.state.verdict).toBe("already_checked_in"); + expect(checkinHitCount).toBe(1); + + // Give a (wrong) print attempt a chance to fire before asserting its + // absence. + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(agentPrintHitCount).toBe(0); + expect(printedHitCount).toBe(0); + }); + + it("shows blocked for a blocked attendee, with no checkin metadata, and never prints", async () => { + checkinOutcome = "blocked"; + const { result } = renderFlow(); + + void result.current.submitCode(ATTENDEE.code); + + await waitFor(() => expect(result.current.state.status).toBe("verdict")); + expect(result.current.state.verdict).toBe("no_access"); + expect(result.current.state.checkin).toBeNull(); + + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(agentPrintHitCount).toBe(0); + expect(printedHitCount).toBe(0); + }); + + it("resolves an unrecognized code to not_found without ever calling the check-in endpoint", async () => { + const { result } = renderFlow(); + + void result.current.submitCode("NO-SUCH-CODE"); + + await waitFor(() => expect(result.current.state.status).toBe("verdict")); + expect(result.current.state.verdict).toBe("not_registered"); + expect(result.current.state.attendee).toBeUndefined(); + expect(checkinHitCount).toBe(0); + + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(agentPrintHitCount).toBe(0); + }); + + it("does not print a successful check-in when settings.print_on_checkin is false", async () => { + const settings: CheckinSettings = { ...DEFAULT_CHECKIN_SETTINGS, print_on_checkin: false }; + const { result } = renderFlow({ settings }); + + void result.current.submitCode(ATTENDEE.code); + + await waitFor(() => expect(result.current.state.status).toBe("verdict")); + expect(result.current.state.verdict).toBe("allowed"); + + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(agentPrintHitCount).toBe(0); + expect(printedHitCount).toBe(0); + }); + + it("submitAttendee (manual-search path) skips the code lookup and checks in directly", async () => { + const settings: CheckinSettings = { ...DEFAULT_CHECKIN_SETTINGS, print_on_checkin: false }; + const { result } = renderFlow({ settings }); + + void result.current.submitAttendee(ATTENDEE); + + await waitFor(() => expect(result.current.state.status).toBe("verdict")); + expect(result.current.state.verdict).toBe("allowed"); + expect(attendeesGetCount).toBe(0); + expect(checkinHitCount).toBe(1); + }); + + it("keeps the checked_in verdict when the print step fails -- the check-in already committed", async () => { + agentPrintStatus = 500; + const { result } = renderFlow(); + await settleFonts(); + + void result.current.submitCode(ATTENDEE.code); + + await waitFor(() => expect(result.current.state.status).toBe("verdict")); + expect(result.current.state.verdict).toBe("allowed"); + expect(result.current.state.attendee?.id).toBe(ATTENDEE.id); + expect(result.current.state.printError).toBeDefined(); + // The send itself was attempted (and failed) -- mark-printed is never + // reached because printAttendee throws before getting there. + await waitFor(() => expect(agentPrintHitCount).toBe(1)); + expect(printedHitCount).toBe(0); + }); + + // PR #77 bot-review round, Finding I -- a MarkPrintedError (the agent + // print SUCCEEDS but the LATER /printed counter-update call fails) must be + // distinguished from a genuine print failure: the badge may already be + // printing/printed, so telling the operator to reprint (printError's own + // copy) would invite an unnecessary duplicate print. + it("sets printMarkFailed (not printError) when the print succeeds but mark-printed fails", async () => { + server.use( + http.post("http://api.test/api/attendees/:attendeeId/printed", () => + HttpResponse.json({ error: "boom" }, { status: 500 }), + ), + ); + const { result } = renderFlow(); + await settleFonts(); + + void result.current.submitCode(ATTENDEE.code); + + await waitFor(() => expect(result.current.state.status).toBe("verdict")); + expect(result.current.state.verdict).toBe("allowed"); + await waitFor(() => expect(agentPrintHitCount).toBe(1)); + expect(result.current.state.printMarkFailed).toEqual({ printer: "Zebra_ZD421" }); + expect(result.current.state.printError).toBeUndefined(); + }); + + // PR #77 bot-review round 2, Finding 2 -- auto-print must not be ATTEMPTED + // at all while `printBadge.fontsStatus` hasn't reached a terminal state + // yet (`ready`/`error`) -- calling printAttendee while fonts are still + // loading risks its own internal wait resolving against a stale, + // pre-load `fontFaces.families` closure and throwing a spurious + // MissingFontError for a purely timing reason. The checkin POST is + // artificially delayed (buildCheckinResponse-based override) so the + // checked_in outcome deterministically resolves WHILE the (separately + // delayed) font-file fetch below is still in flight. + it("does not call printAttendee (and sets printFontsPending) when a checked_in scan resolves while event fonts are still loading", async () => { + server.use( + http.get("http://api.test/api/events/:eventId/fonts", () => HttpResponse.json([fontListItem("f1", "TestFont")])), + http.get("http://api.test/api/fonts/:id/file", async () => { + await delay(300); + return HttpResponse.arrayBuffer(FAKE_FONT_BYTES); + }), + http.post("http://api.test/api/events/:eventId/checkin", async ({ request }) => { + const body = (await request.json()) as { attendee_id: string; station_id?: string | null }; + await delay(100); + return HttpResponse.json(buildCheckinResponse(body)); + }), + ); + const { result } = renderFlow(); + + void result.current.submitCode(ATTENDEE.code); + + await waitFor(() => expect(result.current.state.status).toBe("verdict")); + expect(result.current.state.verdict).toBe("allowed"); + expect(result.current.state.printFontsPending).toBe(true); + expect(result.current.state.printError).toBeUndefined(); + expect(result.current.state.printMarkFailed).toBeUndefined(); + + // Give a (wrong) print attempt a chance to fire (and the delayed font + // file fetch a chance to finish) before asserting the print never + // happened -- this is a hard skip, not a deferred retry. + await new Promise((resolve) => setTimeout(resolve, 400)); + expect(agentPrintHitCount).toBe(0); + expect(printedHitCount).toBe(0); + }); + + // Regression guard for the fix above: once fonts have genuinely settled to + // "ready" (settleFonts(), matching a real station where an operator's + // first physical scan happens well after mount), auto-print must still + // proceed exactly as before. + it("still calls printAttendee when fonts are already ready by the time a checked_in scan resolves (no regression)", async () => { + const { result } = renderFlow(); + await settleFonts(); + + void result.current.submitCode(ATTENDEE.code); + + await waitFor(() => expect(result.current.state.status).toBe("verdict")); + expect(result.current.state.printFontsPending).toBeFalsy(); + await waitFor(() => expect(agentPrintHitCount).toBe(1)); + await waitFor(() => expect(printedHitCount).toBe(1)); + }); + + // PR #77 bot-review round 2, Finding 2 -- "terminal" per the OTHER print + // surfaces' own `fontsStatus !== "ready" && fontsStatus !== "error"` + // gating (AttendeeDrawer.tsx's reprintFontsBlocking / BulkBar.tsx's + // printFontsBlocking) means "error" counts as terminal too, not just + // "ready" -- an errored fonts fetch still unblocks the gate (matching + // their exact behavior) since generation proceeds native-only rather than + // waiting forever on a list that will never load. + it("still attempts auto-print when fontsStatus is 'error' (a terminal state, not loading)", async () => { + server.use( + http.get("http://api.test/api/events/:eventId/fonts", () => HttpResponse.json({ error: "boom" }, { status: 500 })), + ); + const { result } = renderFlow(); + await settleFonts(); + + void result.current.submitCode(ATTENDEE.code); + + await waitFor(() => expect(result.current.state.status).toBe("verdict")); + expect(result.current.state.printFontsPending).toBeFalsy(); + await waitFor(() => expect(agentPrintHitCount).toBe(1)); + }); + + // PR #77 bot-review round, Finding F -- a genuine failure resolving the + // check-in itself (not a print failure, which resolveCheckin already + // swallows into printError/printMarkFailed) must not leave the flow stuck + // -- it resets to idle AND records `requestError` so the caller/UI has + // something to show instead of a scan silently vanishing. + it("resets to idle and sets requestError when the check-in POST itself fails, and the promise still rejects for a caller that awaits it", async () => { + server.use( + http.post("http://api.test/api/events/:eventId/checkin", () => + HttpResponse.json({ error: "boom" }, { status: 500 }), + ), + ); + const { result } = renderFlow(); + + await expect(result.current.submitCode(ATTENDEE.code)).rejects.toBeDefined(); + + await waitFor(() => expect(result.current.state.status).toBe("idle")); + expect(result.current.state.requestError).toBeDefined(); + expect(result.current.state.verdict).toBeUndefined(); + }); + + it("clear() resets to idle and cancels a pending auto-dismiss timer", async () => { + const { result } = renderFlow(); + + void result.current.submitCode(ATTENDEE.code); + await waitFor(() => expect(result.current.state.status).toBe("verdict")); + + result.current.clear(); + await waitFor(() => expect(result.current.state.status).toBe("idle")); + expect(result.current.state.verdict).toBeUndefined(); + }); + + // PR #77 bot-review round 3, Finding 5 -- the station route can be + // navigated directly from one station's URL to another (browser back/ + // forward, a bookmarked link) without necessarily remounting the whole + // component tree -- a lingering verdict/pending auto-dismiss timer from + // the PREVIOUS station must never bleed into a DIFFERENT station's page. + it("resets to idle and clears the previous station's pending auto-dismiss timer when eventId/stationId change (no remount)", async () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + function wrapper({ children }: { children: ReactNode }) { + return {children}; + } + // A dismiss window for the FIRST station long enough that it must NOT + // have naturally elapsed by the time the tight-timeout check just below + // observes "idle" -- that check must only be satisfiable by the + // explicit navigation-reset effect, never by this timer coincidentally + // firing on its own schedule. + const stationOneSettings: CheckinSettings = { ...DEFAULT_CHECKIN_SETTINGS, verdict_auto_dismiss_sec: 0.3 }; + // A much longer window for the SECOND station -- irrelevant to this + // test's own assertions, just far enough out that it can't itself fire + // during the window being observed below. + const stationTwoSettings: CheckinSettings = { ...DEFAULT_CHECKIN_SETTINGS, verdict_auto_dismiss_sec: 5 }; + + const { result, rerender } = renderHook( + (props: UseCheckinFlowOptions) => useCheckinFlow(props), + { + wrapper, + initialProps: { + eventId: "evt-1", + stationId: "st-1", + settings: stationOneSettings, + printerName: "Zebra_ZD421", + }, + }, + ); + + void result.current.submitCode(ATTENDEE.code); + await waitFor(() => expect(result.current.state.status).toBe("verdict")); + expect(result.current.state.verdict).toBe("allowed"); + + rerender({ + eventId: "evt-2", + stationId: "st-2", + settings: stationTwoSettings, + printerName: "Zebra_ZD421", + }); + + // A DELIBERATELY tight timeout (well under station one's own 300ms + // dismiss window) -- this can only pass via the explicit + // navigation-triggered reset, never via station one's timer happening + // to elapse on its own natural schedule. + await waitFor(() => expect(result.current.state.status).toBe("idle"), { timeout: 150 }); + expect(result.current.state.verdict).toBeUndefined(); + + // A fresh scan on the NEW station, started well before station one's + // original ~300ms deadline -- proves the busy guard was also reset (a + // stale "request in flight" flag from station one must not silently + // drop this), and gives station one's still-pending timer something to + // wrongly clobber if it was never actually cleared. + void result.current.submitCode("NO-SUCH-CODE"); + await waitFor(() => expect(result.current.state.status).toBe("verdict")); + expect(result.current.state.verdict).toBe("not_registered"); + + // Past station one's original (now-stale) ~300ms dismiss deadline, but + // well before station two's own real 5s one -- if the stale timer had + // NOT been cleared, it fires here and wipes the verdict just set above + // back to idle. + await new Promise((resolve) => setTimeout(resolve, 600)); + expect(result.current.state.status).toBe("verdict"); + expect(result.current.state.verdict).toBe("not_registered"); + }); + + it("auto-dismisses back to idle after settings.verdict_auto_dismiss_sec, without an explicit clear()", async () => { + // A short-but-real interval rather than fake timers (no fake-timer + // precedent exists anywhere in this repo, and this hook's dismiss timer + // races real MSW-mediated network promises -- a real, short interval + // keeps this deterministic without risking a fake-clock/interceptor + // interaction bug). 0.1s is well under any reasonable test timeout and + // still exercises the exact `verdict_auto_dismiss_sec * 1000` math. + const settings: CheckinSettings = { ...DEFAULT_CHECKIN_SETTINGS, print_on_checkin: false, verdict_auto_dismiss_sec: 0.1 }; + const { result } = renderFlow({ settings }); + + void result.current.submitCode(ATTENDEE.code); + await waitFor(() => expect(result.current.state.status).toBe("verdict")); + + await waitFor(() => expect(result.current.state.status).toBe("idle"), { timeout: 2000 }); + expect(result.current.state.verdict).toBeUndefined(); + }); +}); diff --git a/panel/src/features/checkin/useCheckinFlow.ts b/panel/src/features/checkin/useCheckinFlow.ts new file mode 100644 index 00000000..d12065c3 --- /dev/null +++ b/panel/src/features/checkin/useCheckinFlow.ts @@ -0,0 +1,303 @@ +// P4.1 Task 6 -- the check-in station's core state machine. Resolves a +// scanned code (submitCode) or a manually-picked attendee (submitAttendee, +// Task 7's manual search) to one of the four station outcomes +// (verdict.ts's outcomeToVerdict), fires the idempotent check-in mutation +// (Task 5's useStationCheckin, which already invalidates +// CHECKIN_ACTIONS_KEY/ATTENDEES_LIST_KEY unconditionally on every call -- +// see hooks.ts's own comments), and -- ONLY on the server's own +// "checked_in" outcome, and ONLY when the event's settings say so -- fires +// the shared P3.2 print pipeline (usePrintBadge). This is the ONE place +// printing is wired into the check-in loop (plan global constraint: "Print +// fires ONLY on the checked_in outcome -- zero double-print at the +// source"); Task 7 (scan input) and Task 8 (station route) both consume +// this hook rather than re-deriving any of it. +import * as React from "react"; +import type { Verdict } from "@idento/ui"; +import { api } from "../../shared/api/http"; +import type { components } from "../../shared/api/schema"; +import { MarkPrintedError, usePrintBadge } from "../badge/zpl/usePrintBadge"; +import { useStationCheckin } from "./hooks"; +import type { CheckinSettings } from "./settingsTypes"; +import { outcomeToVerdict } from "./verdict"; + +type Attendee = components["schemas"]["Attendee"]; +type CheckinInfo = components["schemas"]["CheckinInfo"]; +type AttendeeListPage = components["schemas"]["AttendeeListPage"]; + +export interface UseCheckinFlowOptions { + eventId: string; + // The registered station this scan is happening at -- forwarded as + // `station_id` on the check-in call. `null` is a valid, + // deliberately-supported "station-less" check-in (schema.d.ts's + // StationCheckinRequest comment). NOT forwarded to the implicit + // checked_in auto-print's printAttendee call (see resolveCheckin below -- + // that call deliberately omits `printContext` entirely, since the + // check-in itself was already logged by stationCheckin.mutateAsync). + stationId: string | null; + settings: CheckinSettings; + // The printer to send a checked_in badge to. Task 8/9's callers own + // resolving this (agent default / reachability-gated selection) -- this + // hook just forwards it to usePrintBadge.printAttendee verbatim. + printerName: string; +} + +export interface CheckinFlowState { + status: "idle" | "resolving" | "verdict"; + verdict?: Verdict; + attendee?: Attendee; + // The first-scan metadata block -- present for checked_in/already_checked_in, + // `null` for blocked (schema.d.ts's StationCheckinResponse comment), + // `undefined` for the client-side not_found outcome (there was never a + // server round trip to carry it). + checkin?: CheckinInfo | null; + // The raw error caught from a best-effort print attempt, if one was made + // and it failed. Deliberately `unknown` -- exactly like every OTHER + // usePrintBadge caller in this codebase (AttendeeDrawer.tsx's + // reprintError, BulkBar.tsx): this hook never calls useTranslation()/t() + // itself (that's the render layer's job, per this codebase's own + // convention -- hooks return typed/raw data, components translate it), so + // it doesn't pre-classify the error into copy it has no business owning. + // A print failure NEVER reverts status/verdict/attendee/checkin -- the + // check-in already committed server-side; this field is purely additive + // surfacing for whatever UI (Task 8's VerdictCard) wants to show it. + printError?: unknown; + // PR #77 bot-review round, Finding I -- set instead of (never alongside) + // `printError` when the print step's failure was specifically a + // MarkPrintedError: the badge WAS sent (usePrintBadge.printAttendee's own + // doc comment -- "Non-fatal from the operator's perspective") and only the + // `/printed` counter-update afterward failed. Carries the printer name so + // the UI can reuse RecentScansRail.tsx's own MarkPrintedError copy + // verbatim (that copy is printer-name-parameterized) rather than a + // parallel printer-less message. + printMarkFailed?: { printer: string }; + // PR #77 bot-review round 2, Finding 2 -- set (instead of even ATTEMPTING + // the print, so never alongside printError/printMarkFailed) when a + // checked_in scan resolves while `printBadge.fontsStatus` hasn't reached a + // terminal state (`ready`/`error`) yet. usePrintBadge.printAttendee + // internally awaits font readiness before generating, but it does so + // through a closure captured AT CALL TIME -- calling it while fonts are + // still loading risks that closure's own `fontFaces.families` being the + // STALE (pre-load) snapshot from the render printAttendee was created in, + // even after the internal wait resolves, which can produce a spurious + // MissingFontError for a purely timing reason. Checking `fontsStatus` here + // BEFORE ever calling printAttendee (mirroring how every OTHER print + // surface -- TestPrintDialog, the drawer's reprint confirm, RecentScansRail + // -- gates its own print action on this exact terminal-state check) avoids + // the call entirely rather than trusting the internal wait. This is a + // third, distinct case from printError/printMarkFailed -- no print was + // attempted at all, so telling the operator "reprint it" (printError's + // copy) would be accurate advice but wrongly implies a genuine failure. + printFontsPending?: boolean; + // PR #77 bot-review round, Finding F -- set when submitCode/submitAttendee + // ITSELF fails (network error, 5xx on the check-in POST, or the code + // lookup GET) -- NOT a print failure, which never reverts status. `status` + // is reset to "idle" in the SAME setState call that sets this, so the + // operator can immediately scan/search again; this field lets the idle + // view explain why the previous attempt produced no verdict instead of + // silently going quiet. This hook still re-throws the error afterward + // (unchanged -- callers may want it too), so every caller must still + // `.catch()` the call (see StationPage.tsx's handleCode/handlePickAttendee) + // to avoid an unhandled promise rejection; this field is what actually + // gives the operator something visible, independent of whether a given + // caller bothers to inspect the rejected error itself. + requestError?: unknown; +} + +export interface UseCheckinFlowResult { + state: CheckinFlowState; + submitCode(code: string): Promise; + submitAttendee(attendee: Attendee): Promise; + clear(): void; +} + +const IDLE_STATE: CheckinFlowState = { status: "idle" }; + +export function useCheckinFlow({ eventId, stationId, settings, printerName }: UseCheckinFlowOptions): UseCheckinFlowResult { + const [state, setState] = React.useState(IDLE_STATE); + const stationCheckin = useStationCheckin(eventId); + const printBadge = usePrintBadge(eventId); + + const dismissTimerRef = React.useRef(undefined); + // Best-effort re-entrancy guard: a scan/manual-pick that arrives while a + // PREVIOUS one is still resolving (network round trip + a possible print) + // is dropped rather than fired concurrently -- there is no legitimate + // reason for two check-ins to be in flight for the same station at once, + // and letting a second one race the first risks two check-in POSTs for + // what a fast double-scan meant as one. A ref (not state) because it must + // be read/written synchronously at call time, not on the next render. + const busyRef = React.useRef(false); + + const clearDismissTimer = React.useCallback(() => { + window.clearTimeout(dismissTimerRef.current); + dismissTimerRef.current = undefined; + }, []); + + // Unmount safety: a pending auto-dismiss must never fire setState after + // this hook's owner (the station route) has gone away. + React.useEffect(() => clearDismissTimer, [clearDismissTimer]); + + const scheduleAutoDismiss = React.useCallback(() => { + clearDismissTimer(); + dismissTimerRef.current = window.setTimeout(() => { + setState(IDLE_STATE); + }, settings.verdict_auto_dismiss_sec * 1000); + }, [clearDismissTimer, settings.verdict_auto_dismiss_sec]); + + const clear = React.useCallback(() => { + clearDismissTimer(); + setState(IDLE_STATE); + }, [clearDismissTimer]); + + // PR #77 bot-review round 3, Finding 5 -- the station route can be + // navigated directly from one station's URL to another (browser back/ + // forward, a bookmarked link) without necessarily remounting the whole + // component tree (same route-reuse premise LaunchCeremony.tsx's own + // Finding 1 fix documents for its own route). Without this, a PREVIOUS + // station's lingering verdict -- and, critically, its still-pending + // auto-dismiss timer -- would keep rendering/firing against the NEW + // station's page until that timer happened to elapse. Resets to idle via + // the SAME `clear()` this hook's own caller-facing API already exposes + // (which itself reuses `clearDismissTimer` -- no duplicated timer- + // clearing logic), and clears the busy guard so the new station's own + // first scan isn't silently dropped by a stale "a request is still in + // flight" flag left over from whatever the previous station was doing. + React.useEffect(() => { + busyRef.current = false; + clear(); + }, [eventId, stationId, clear]); + + async function resolveCheckin(attendee: Attendee): Promise { + const response = await stationCheckin.mutateAsync({ + params: { path: { event_id: eventId } }, + body: { attendee_id: attendee.id, station_id: stationId }, + }); + + let printError: unknown; + let printMarkFailed: { printer: string } | undefined; + let printFontsPending = false; + // Zero-double-print at the source (plan global constraint): printing + // fires ONLY on the server's own "checked_in" outcome -- never + // "already_checked_in"/"blocked", regardless of settings. + if (response.outcome === "checked_in" && settings.print_on_checkin) { + // PR #77 bot-review round 2, Finding 2 -- read fresh HERE, at call + // time, not before: `printBadge.fontsStatus` reflects THIS render of + // useCheckinFlow, so gating on it before ever calling printAttendee + // means printAttendee (when it IS called) always closes over an + // already-terminal `fontFaces`/`families` snapshot -- see the + // `printFontsPending` field's own doc comment above for why that + // matters (a call made while fonts are still loading can race its own + // internal wait). "idle" counts as pending too (fonts haven't even + // started loading) -- only "ready"/"error" are terminal. + const fontsReady = printBadge.fontsStatus === "ready" || printBadge.fontsStatus === "error"; + if (!fontsReady) { + printFontsPending = true; + } else { + try { + // Deliberately NO `printContext` here. This is the IMPLICIT + // auto-print that fulfills the check-in that just happened -- + // `stationCheckin.mutateAsync` above already logged a `checkin` row + // in the same DB transaction as the state change (Task 3's + // CheckInAttendee). Passing `printContext` would make the backend's + // /printed endpoint (Task 4) log an ADDITIONAL `reprint` row for + // this same event, double-logging the feed for a single check-in + // (final cross-task review finding). Falling back to no + // `printContext` keeps this call on the pre-existing P3.2 + // counter-only behavior: bumps `printed_count`, no feed row. The + // Recent-Scans-Rail's OWN Reprint button (RecentScansRail.tsx) is + // the genuine, distinct, operator-initiated reprint action and + // correctly keeps passing `printContext` there. + await printBadge.printAttendee(response.attendee, printerName); + } catch (error) { + // The check-in already committed server-side -- a print failure + // here must never look like (or cause) an undone check-in. The + // person is in; this is surfaced separately, not as a verdict + // change. + // + // PR #77 bot-review round, Finding I -- a MarkPrintedError means the + // agent print itself SUCCEEDED and only the later /printed + // counter-update call failed -- collapsing it into the same + // `printError` VerdictCard renders as "reprint it" would invite an + // unnecessary duplicate print for a badge that may already be + // printing/printed. Kept mutually exclusive from `printError` (only + // one of the two is ever set). + if (error instanceof MarkPrintedError) { + printMarkFailed = { printer: printerName }; + } else { + printError = error; + } + } + } + } + + setState({ + status: "verdict", + verdict: outcomeToVerdict(response.outcome), + attendee: response.attendee, + checkin: response.checkin, + printError, + printMarkFailed, + printFontsPending, + }); + scheduleAutoDismiss(); + } + + async function submitCode(code: string): Promise { + if (busyRef.current) return; + busyRef.current = true; + clearDismissTimer(); + setState({ status: "resolving" }); + try { + // The existing scalable server exact-match (plan-time fact #2) -- + // deliberately NOT the whole roster. No `page`/`per_page` means the + // response is the legacy bare-array shape (getAttendees' own oneOf). + const { data } = await api.GET("/api/events/{event_id}/attendees", { + params: { path: { event_id: eventId }, query: { code } }, + }); + const matches: Attendee[] = Array.isArray(data) ? data : ((data as AttendeeListPage | undefined)?.attendees ?? []); + const attendee = matches[0]; + + if (!attendee) { + // Client-side outcome: the lookup itself came back empty, so there + // is no attendee to check in -- this never reaches + // useStationCheckin at all. + setState({ status: "verdict", verdict: outcomeToVerdict("not_found") }); + scheduleAutoDismiss(); + return; + } + + await resolveCheckin(attendee); + } catch (error) { + // A genuine failure resolving the check-in itself (network error, + // 5xx, etc. -- not a print failure, which resolveCheckin already + // swallows into printError) must not leave the flow stuck on + // "resolving" forever; reset to idle so the operator can immediately + // retry (Task 10's degraded mode owns the offline story), and record + // it as `requestError` (PR #77 Finding F) so the idle view can show + // SOMETHING rather than silently dropping the scan -- still re-thrown + // so a caller that wants the raw error can also see it, but every + // caller must `.catch()` this (StationPage.tsx's handleCode/ + // handlePickAttendee do) to avoid an unhandled rejection. + setState({ status: "idle", requestError: error }); + throw error; + } finally { + busyRef.current = false; + } + } + + async function submitAttendee(attendee: Attendee): Promise { + if (busyRef.current) return; + busyRef.current = true; + clearDismissTimer(); + setState({ status: "resolving" }); + try { + await resolveCheckin(attendee); + } catch (error) { + setState({ status: "idle", requestError: error }); + throw error; + } finally { + busyRef.current = false; + } + } + + return { state, submitCode, submitAttendee, clear }; +} diff --git a/panel/src/features/checkin/useConnectionState.test.tsx b/panel/src/features/checkin/useConnectionState.test.tsx new file mode 100644 index 00000000..047495d0 --- /dev/null +++ b/panel/src/features/checkin/useConnectionState.test.tsx @@ -0,0 +1,139 @@ +// P4.1 Task 10 -- useConnectionState tests. Real timers throughout EXCEPT +// the one PR #77 Finding J test at the bottom (the hook's DEBOUNCE_MS is +// small enough that `waitFor`'s default polling comfortably observes it +// settle with real timers, but proving the 20s health poll itself would make +// that one test unbearably slow for real -- see that test's own comment). +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; +import type { ReactNode } from "react"; +import { startMswServer } from "../../test/msw"; +import { CHECKIN_ACTIONS_KEY } from "./hooks"; +import { useConnectionState } from "./useConnectionState"; + +// Mirrors useConnectionState.ts's own (unexported) DEBOUNCE_MS -- kept as a +// literal here rather than imported, same "no shared test-only export just +// for a magic number" precedent as useHeartbeat.test.tsx's own inline 20s. +const DEBOUNCE_MS_FOR_TEST = 400; + +let actionsShouldError = false; +let actionsHitCount = 0; + +const server = startMswServer( + http.get("http://api.test/api/events/:eventId/checkin-actions", () => { + actionsHitCount += 1; + if (actionsShouldError) return new HttpResponse(null, { status: 500 }); + return HttpResponse.json({ actions: [] }); + }), +); +void server; + +function makeWrapper() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return { + qc, + Wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }; +} + +describe("useConnectionState", () => { + beforeEach(() => { + window.__ENV__ = { API_URL: "http://api.test" }; + localStorage.clear(); + localStorage.setItem("token", "jwt-test"); + actionsShouldError = false; + actionsHitCount = 0; + Object.defineProperty(window.navigator, "onLine", { value: true, writable: true, configurable: true }); + }); + + afterEach(() => { + Object.defineProperty(window.navigator, "onLine", { value: true, writable: true, configurable: true }); + }); + + it("starts online when the browser reports online and the actions feed loads fine", async () => { + const { Wrapper } = makeWrapper(); + const { result } = renderHook(() => useConnectionState("evt-1"), { wrapper: Wrapper }); + + await waitFor(() => expect(actionsHitCount).toBeGreaterThan(0)); + expect(result.current.online).toBe(true); + }); + + it("goes offline (debounced) when the browser fires the 'offline' event, and back online on 'online'", async () => { + const { Wrapper } = makeWrapper(); + const { result } = renderHook(() => useConnectionState("evt-1"), { wrapper: Wrapper }); + await waitFor(() => expect(result.current.online).toBe(true)); + + Object.defineProperty(window.navigator, "onLine", { value: false, writable: true, configurable: true }); + window.dispatchEvent(new Event("offline")); + + await waitFor(() => expect(result.current.online).toBe(false), { timeout: 2000 }); + + Object.defineProperty(window.navigator, "onLine", { value: true, writable: true, configurable: true }); + window.dispatchEvent(new Event("online")); + + await waitFor(() => expect(result.current.online).toBe(true), { timeout: 2000 }); + }); + + it("goes offline when the check-in actions feed keeps erroring, even though the browser reports online", async () => { + actionsShouldError = true; + const { Wrapper } = makeWrapper(); + const { result } = renderHook(() => useConnectionState("evt-1"), { wrapper: Wrapper }); + + await waitFor(() => expect(result.current.online).toBe(false), { timeout: 2000 }); + }); + + it("recovers once the underlying actions query itself recovers (e.g. a refetch triggered elsewhere succeeds)", async () => { + actionsShouldError = true; + const { qc, Wrapper } = makeWrapper(); + const { result } = renderHook(() => useConnectionState("evt-1"), { wrapper: Wrapper }); + await waitFor(() => expect(result.current.online).toBe(false), { timeout: 2000 }); + + actionsShouldError = false; + await qc.refetchQueries({ queryKey: CHECKIN_ACTIONS_KEY("evt-1") }); + + await waitFor(() => expect(result.current.online).toBe(true), { timeout: 2000 }); + }); + + // PR #77 bot-review round, Finding J -- without a recurring poll, `online` + // only reacts to the INITIAL fetch plus navigator.onLine events, so a + // backend that goes down mid-shift while the browser still reports itself + // online would never flip the signal unless some UNRELATED refetch (a + // window focus, another operator's mutation) happened to occur. Fake + // timers here (unlike every OTHER test in this file, which deliberately + // uses real ones) -- same deviation, and the same reasoning, as + // useHeartbeat.test.tsx's own real-20s-interval problem: waiting out a + // real 20s poll would make this suite unbearably slow, and + // `vi.advanceTimersByTimeAsync` (never the sync variant) flushes the + // pending MSW-intercepted refetch between simulated ticks. + it("transitions from online to degraded after the periodic health poll detects the backend going down mid-shift, with no unrelated trigger", async () => { + vi.useFakeTimers(); + try { + const { Wrapper } = makeWrapper(); + const { result } = renderHook(() => useConnectionState("evt-1"), { wrapper: Wrapper }); + + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(DEBOUNCE_MS_FOR_TEST); + expect(result.current.online).toBe(true); + expect(actionsHitCount).toBe(1); + + // The backend goes down mid-shift -- navigator.onLine never changes, + // and nothing else triggers a refetch. + actionsShouldError = true; + + // Crosses the 20s poll boundary, then the debounce window. One more + // zero-ms advance flushes react-query's own notifyManager batching + // (a macrotask, not a microtask -- the query's internal state DOES + // flip to "error" within the advances above, but React doesn't + // re-render `result.current` from it until this next tick). + await vi.advanceTimersByTimeAsync(20_000 + DEBOUNCE_MS_FOR_TEST); + await vi.advanceTimersByTimeAsync(0); + + expect(actionsHitCount).toBe(2); + expect(result.current.online).toBe(false); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/panel/src/features/checkin/useConnectionState.ts b/panel/src/features/checkin/useConnectionState.ts new file mode 100644 index 00000000..857c62a4 --- /dev/null +++ b/panel/src/features/checkin/useConnectionState.ts @@ -0,0 +1,112 @@ +// P4.1 Task 10 -- the check-in station's connection/degraded-mode signal. +// StationPage (this task) reads `online` to (1) show the amber "Connection +// is unstable" banner, (2) block a scan/manual-pick from ever reaching the +// network (an explicit "can't check in — offline" state instead), and (3) +// gate the recent-scans rail's Undo/Reprint triggers -- see StationPage.tsx +// and RecentScansRail.tsx's own comments for how each of those three +// reactions is wired. This hook owns ONLY the signal itself: no queueing, +// no retry-on-reconnect side effects of its own (P4.1's spec explicitly +// rules an offline write queue out of scope -- offline ownership stays with +// the kiosks) -- it just observes two existing things and folds them into +// one debounced boolean. +// +// Two independent failure modes, both meaning "a check-in POST right now +// probably won't land": +// 1. The browser itself is offline -- `navigator.onLine` for the initial +// read, then the window 'online'/'offline' events for changes (the +// exact same events TanStack Query's own default `onlineManager` +// listens for -- node_modules/@tanstack/query-core's onlineManager.ts -- +// so this hook's notion of "browser offline" tracks the one that +// already pauses this app's query fetches). +// 2. The backend is unreachable even though the browser THINKS it has a +// network path (a captive portal, a downed API host, etc.) -- the +// check-in actions feed query (Task 5's useCheckinActions, already +// mounted station-wide via Task 9's rail) already retries (react-query's +// default `retry: 3`) before its own `isError` flips true, so reading +// THAT flag is the "isError after a retry" signal the brief calls for, +// with no separate health-check endpoint to invent. +// +// Debounced (not applied instantly) so a single missed beat -- a stray +// 'offline' event firing right as a tab regains focus, or a query's error +// state settling mid-transition -- can't flap the banner on/off; only a +// signal that's still "not online" DEBOUNCE_MS later is trusted. +import * as React from "react"; +import { useCheckinActions } from "./hooks"; + +export interface UseConnectionStateResult { + online: boolean; +} + +// Not specified as an exact value by the brief ("debounced to avoid +// flapping") -- 400ms is long enough to absorb a single blip but short +// enough that a genuine outage still shows the banner promptly relative to +// a human operator's own reaction time. +const DEBOUNCE_MS = 400; + +// PR #77 bot-review round, Finding J -- without a recurring poll, `online` +// only reacts to the INITIAL useCheckinActions fetch plus navigator.onLine +// events: if the backend goes down mid-shift while the browser still +// reports itself online, the degraded banner/action-disabling never +// activates unless some UNRELATED refetch (a window focus, another +// operator's mutation invalidating this same query) happens to occur. This +// periodic `refetch()` keeps the health signal honest on its own. Matches +// useHeartbeat's own 20s precedent (this feature's established interval for +// a lightweight, non-aggressive background ping) rather than inventing a +// new cadence. +const HEALTH_POLL_INTERVAL_MS = 20_000; + +function readNavigatorOnline(): boolean { + return typeof navigator === "undefined" || typeof navigator.onLine !== "boolean" ? true : navigator.onLine; +} + +export function useConnectionState(eventId: string): UseConnectionStateResult { + // Same query Task 9's rail already mounts -- TanStack Query shares one + // cache entry per query key across every observer, so this adds no extra + // network traffic, just a second subscriber to the SAME feed's isError. + const actionsQuery = useCheckinActions(eventId); + + const [browserOnline, setBrowserOnline] = React.useState(readNavigatorOnline); + + React.useEffect(() => { + function handleOnline() { + setBrowserOnline(true); + } + function handleOffline() { + setBrowserOnline(false); + } + window.addEventListener("online", handleOnline); + window.addEventListener("offline", handleOffline); + return () => { + window.removeEventListener("online", handleOnline); + window.removeEventListener("offline", handleOffline); + }; + }, []); + + // Read the latest `refetch` on every tick without re-subscribing the + // interval effect below to its identity churn -- same ref-mirrors-latest- + // callback idiom useHeartbeat.ts/useScanInput.ts already establish in this + // feature (a fresh `useQuery` result's `refetch` is a new function + // reference on every render). + const refetchRef = React.useRef(actionsQuery.refetch); + React.useEffect(() => { + refetchRef.current = actionsQuery.refetch; + }, [actionsQuery.refetch]); + + React.useEffect(() => { + const timer = window.setInterval(() => { + void refetchRef.current(); + }, HEALTH_POLL_INTERVAL_MS); + return () => window.clearInterval(timer); + }, []); + + const rawOnline = browserOnline && !actionsQuery.isError; + + const [online, setOnline] = React.useState(rawOnline); + + React.useEffect(() => { + const timeoutId = window.setTimeout(() => setOnline(rawOnline), DEBOUNCE_MS); + return () => window.clearTimeout(timeoutId); + }, [rawOnline]); + + return { online }; +} diff --git a/panel/src/features/checkin/useHeartbeat.test.tsx b/panel/src/features/checkin/useHeartbeat.test.tsx new file mode 100644 index 00000000..ba794438 --- /dev/null +++ b/panel/src/features/checkin/useHeartbeat.test.tsx @@ -0,0 +1,122 @@ +// P4.1 Task 12 -- useHeartbeat tests. +// +// Fake timers (unlike this feature's other timer-based hooks -- +// useCheckinFlow.test.tsx's auto-dismiss test and useConnectionState.test.tsx +// both deliberately use REAL, short timers instead, citing "no fake-timer +// precedent exists anywhere in this repo" and a risk of a fake-clock/MSW +// interceptor interaction bug). This hook's own interval is a real 20s per +// the brief, though -- waiting that out with real timers would make this +// suite unbearably slow (and brittle under CI scheduling jitter), so this is +// the one hook in the feature where the fake clock is worth the risk. +// `vi.advanceTimersByTimeAsync` (never the sync `advanceTimersByTime`) is +// used throughout specifically because it flushes pending microtasks/promises +// between simulated ticks, which is what lets a REAL MSW-intercepted fetch +// (fired synchronously inside the interval callback via TanStack Query's +// `mutate()`) actually resolve while the fake clock advances. +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; +import type { ReactNode } from "react"; +import { startMswServer } from "../../test/msw"; +import { useHeartbeat } from "./useHeartbeat"; + +let heartbeatHitCount = 0; +let heartbeatShouldError = false; +let lastParams: { eventId?: string; stationId?: string } = {}; + +const server = startMswServer( + http.post("http://api.test/api/events/:eventId/checkin-stations/:id/heartbeat", ({ params }) => { + heartbeatHitCount += 1; + lastParams = { eventId: String(params.eventId), stationId: String(params.id) }; + if (heartbeatShouldError) return new HttpResponse(null, { status: 500 }); + return new HttpResponse(null, { status: 204 }); + }), +); +void server; + +function makeWrapper() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + return ({ children }: { children: ReactNode }) => {children}; +} + +// A minimal harness -- useHeartbeat has no return value (it is mounted +// purely for its side effect, per the brief: "Mounted by StationPage"), so +// the only observable surface is the network traffic it causes plus the +// fact that the harness itself keeps rendering normally (proof that a +// failed heartbeat doesn't throw/unmount its owner). +function Harness({ eventId, stationId }: { eventId: string; stationId: string | null }) { + useHeartbeat(eventId, stationId); + return
alive
; +} + +describe("useHeartbeat", () => { + beforeEach(() => { + window.__ENV__ = { API_URL: "http://api.test" }; + localStorage.clear(); + localStorage.setItem("token", "jwt-test"); + heartbeatHitCount = 0; + heartbeatShouldError = false; + lastParams = {}; + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("posts an immediate heartbeat on mount", async () => { + const Wrapper = makeWrapper(); + render(, { wrapper: Wrapper }); + + await vi.advanceTimersByTimeAsync(0); + + expect(heartbeatHitCount).toBe(1); + expect(lastParams).toEqual({ eventId: "evt-1", stationId: "st-1" }); + }); + + it("posts a second heartbeat after 20s, and not a moment before", async () => { + const Wrapper = makeWrapper(); + render(, { wrapper: Wrapper }); + await vi.advanceTimersByTimeAsync(0); + expect(heartbeatHitCount).toBe(1); + + await vi.advanceTimersByTimeAsync(19_999); + expect(heartbeatHitCount).toBe(1); + + await vi.advanceTimersByTimeAsync(1); + expect(heartbeatHitCount).toBe(2); + }); + + it("clears the interval on unmount -- no further heartbeat after unmounting", async () => { + const Wrapper = makeWrapper(); + const { unmount } = render(, { wrapper: Wrapper }); + await vi.advanceTimersByTimeAsync(0); + expect(heartbeatHitCount).toBe(1); + + unmount(); + await vi.advanceTimersByTimeAsync(60_000); + expect(heartbeatHitCount).toBe(1); + }); + + it("a failed heartbeat is non-fatal -- it neither throws nor unmounts its owner, and the next tick still retries", async () => { + heartbeatShouldError = true; + const Wrapper = makeWrapper(); + const { getByTestId } = render(, { wrapper: Wrapper }); + + await vi.advanceTimersByTimeAsync(0); + expect(heartbeatHitCount).toBe(1); + expect(getByTestId("harness")).toHaveTextContent("alive"); + + await vi.advanceTimersByTimeAsync(20_000); + expect(heartbeatHitCount).toBe(2); + expect(getByTestId("harness")).toHaveTextContent("alive"); + }); + + it("does nothing when stationId is null -- no immediate POST, no interval", async () => { + const Wrapper = makeWrapper(); + render(, { wrapper: Wrapper }); + + await vi.advanceTimersByTimeAsync(60_000); + expect(heartbeatHitCount).toBe(0); + }); +}); diff --git a/panel/src/features/checkin/useHeartbeat.ts b/panel/src/features/checkin/useHeartbeat.ts new file mode 100644 index 00000000..a5c3e4b7 --- /dev/null +++ b/panel/src/features/checkin/useHeartbeat.ts @@ -0,0 +1,62 @@ +// P4.1 Task 12 -- the check-in station's heartbeat lifecycle. Keeps a +// registered station's `last_seen_at` (Task 2's checkin_stations table) +// fresh for as long as StationPage stays mounted, so a later online/offline +// indicator (Task 2's own heartbeatCheckinStation comment: "so the panel can +// show online/offline state") can tell a live station apart from one whose +// tab was closed or crashed. +// +// Fires an immediate heartbeat on mount (a freshly-launched station should +// read as "seen" right away, not up to 20s later), then again every 20s +// (setInterval) for as long as it stays mounted; the interval is cleared on +// unmount. A failed heartbeat (station deleted server-side, a transient +// network blip, etc.) is deliberately non-fatal: this hook calls Task 5's +// useStationHeartbeat mutation via `.mutate()` (never `.mutateAsync()`), +// which -- like every other fire-and-forget `.mutate()` call in this +// codebase -- never throws or rejects into an unhandled promise; the next +// tick simply tries again. Task 10's degraded-mode signal +// (useConnectionState) is what actually surfaces a persistent failure to the +// operator; this hook has no opinion on that, it just keeps trying. +import * as React from "react"; +import { useStationHeartbeat } from "./hooks"; + +// Matches the brief verbatim ("every 20s"). +const HEARTBEAT_INTERVAL_MS = 20_000; + +export function useHeartbeat(eventId: string, stationId: string | null): void { + const heartbeat = useStationHeartbeat(eventId); + + // Read the latest mutate function on every tick without re-subscribing + // the effect below to its identity churn -- the same ref-mirrors-latest- + // callback idiom useScanInput.ts already establishes in this feature (its + // own onCodeRef) for exactly this reason: `heartbeat.mutate` is a fresh + // function reference on every render (a new useMutation instance's bound + // method), and that must not restart the interval below. + const mutateRef = React.useRef(heartbeat.mutate); + React.useEffect(() => { + mutateRef.current = heartbeat.mutate; + }, [heartbeat.mutate]); + + React.useEffect(() => { + // No station registered yet -- nothing to heartbeat for. StationPage's + // own route guard (searchParams.ts's checkinStationBeforeLoad) means + // this is never actually true by the time StationPage mounts this hook, + // but the type this feature threads through everywhere else + // (useCheckinFlow's stationId, ScanInput's) is `string | null`, so this + // hook mirrors that rather than assuming a non-null value it can't + // enforce itself. + if (!stationId) return; + // A fresh `const` right after the narrowing guard -- unlike `stationId` + // itself, TS keeps this bound to `string` inside the nested `beat` + // closure below (a `string | null` parameter's narrowing doesn't + // survive into a nested function declaration). + const activeStationId = stationId; + + function beat() { + mutateRef.current({ params: { path: { event_id: eventId, id: activeStationId } } }); + } + + beat(); + const timer = window.setInterval(beat, HEARTBEAT_INTERVAL_MS); + return () => window.clearInterval(timer); + }, [eventId, stationId]); +} diff --git a/panel/src/features/checkin/useScanInput.test.tsx b/panel/src/features/checkin/useScanInput.test.tsx new file mode 100644 index 00000000..104d97d2 --- /dev/null +++ b/panel/src/features/checkin/useScanInput.test.tsx @@ -0,0 +1,338 @@ +// P4.1 Task 7 -- useScanInput tests. Exercises the three scan-input modes +// against the REAL agent MSW origin (http://agent.test), same convention as +// agentClient.test.ts / usePrintBadge.test.tsx: the agent is a separate +// origin from the backend, never mocked via vi.mock (agentClient itself is +// exercised for real, only its HTTP layer is intercepted). +// +// Wedge mode needs a genuine focused/typed-into DOM (a keyboard- +// wedge scanner just "types" into whatever has focus + sends Enter), so +// this file renders small harness components around the hook (render/ +// userEvent from @testing-library/react) rather than only using renderHook +// -- same reasoning as this repo's other DOM-interaction hook tests. +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { http, HttpResponse } from "msw"; +import { useScanInput, type UseScanInputOptions } from "./useScanInput"; +import { startMswServer } from "../../test/msw"; + +let scanLastResponse: { code: string; time: string } = { code: "", time: "0001-01-01T00:00:00Z" }; +let scanLastHitCount = 0; +let scanLastShouldError = false; +let scanClearHitCount = 0; +// PR #77 bot-review round, Finding P -- lets a test make the NEXT +// `/scan/clear` call fail without touching `/scan/last`'s own error toggle +// above (a clear failure must not also look like the agent being +// unreachable for `getLastScan`). +let scanClearShouldFailNext = false; + +const server = startMswServer( + http.get("http://agent.test/scan/last", () => { + scanLastHitCount += 1; + if (scanLastShouldError) return new HttpResponse(null, { status: 500 }); + return HttpResponse.json(scanLastResponse); + }), + http.post("http://agent.test/scan/clear", () => { + scanClearHitCount += 1; + if (scanClearShouldFailNext) { + scanClearShouldFailNext = false; + return new HttpResponse(null, { status: 500 }); + } + return HttpResponse.json({ status: "cleared" }); + }), +); +void server; + +function WedgeHarness({ onCode, enabled = true }: { onCode: (code: string) => void; enabled?: boolean }) { + const { wedgeInputProps } = useScanInput({ mode: "wedge", onCode, enabled }); + return ; +} + +// PR #77 bot-review round 2, Finding 5 -- a stand-in for the OTHER +// focusable surfaces that actually exist alongside the wedge input on the +// real station page (ScanInput.tsx's manual search box, RecentScansRail.tsx's +// rail buttons and confirm dialogs): an unrelated plain button, a text input +// (standing in for the manual search box), and a `role="dialog"` region +// (standing in for a Reprint/Undo confirm dialog's content). +function WedgeWithOtherControlsHarness({ onCode, enabled = true }: { onCode: (code: string) => void; enabled?: boolean }) { + const { wedgeInputProps } = useScanInput({ mode: "wedge", onCode, enabled }); + return ( +
+ + + +
+ +
+
+ ); +} + +function ScannerHarness({ onCode, enabled = true }: { onCode: (code: string) => void; enabled?: boolean }) { + const { degraded } = useScanInput({ mode: "scanner", onCode, enabled }); + return
{String(degraded)}
; +} + +function ManualHarness({ onCode }: { onCode: (code: string) => void }) { + const { degraded } = useScanInput({ mode: "manual", onCode, enabled: true }); + return
{String(degraded)}
; +} + +describe("useScanInput", () => { + beforeEach(() => { + window.__ENV__ = { API_URL: "http://api.test", AGENT_URL: "http://agent.test" }; + scanLastResponse = { code: "", time: "0001-01-01T00:00:00Z" }; + scanLastHitCount = 0; + scanLastShouldError = false; + scanClearHitCount = 0; + scanClearShouldFailNext = false; + }); + + describe("wedge mode", () => { + it("autofocuses the hidden input on mount", () => { + const onCode = vi.fn(); + render(); + expect(screen.getByLabelText("wedge-input")).toHaveFocus(); + }); + + it("emits the buffered value once on Enter, then clears and refocuses the input", async () => { + const user = userEvent.setup(); + const onCode = vi.fn(); + render(); + const input = screen.getByLabelText("wedge-input"); + + await user.type(input, "PD-0107{Enter}"); + + expect(onCode).toHaveBeenCalledTimes(1); + expect(onCode).toHaveBeenCalledWith("PD-0107"); + expect(input).toHaveValue(""); + expect(input).toHaveFocus(); + }); + + it("does not emit on a bare keystroke without Enter", async () => { + const user = userEvent.setup(); + const onCode = vi.fn(); + render(); + const input = screen.getByLabelText("wedge-input"); + + await user.type(input, "PD-0107"); + + expect(onCode).not.toHaveBeenCalled(); + expect(input).toHaveValue("PD-0107"); + }); + + it("never emits an empty code on a bare Enter", async () => { + const user = userEvent.setup(); + const onCode = vi.fn(); + render(); + const input = screen.getByLabelText("wedge-input"); + + await user.type(input, "{Enter}"); + + expect(onCode).not.toHaveBeenCalled(); + }); + + it("does not autofocus (or accept input) when disabled", () => { + const onCode = vi.fn(); + render(); + const input = screen.getByLabelText("wedge-input"); + expect(input).not.toHaveFocus(); + expect(input).toBeDisabled(); + }); + + // PR #77 bot-review round 2, Finding 5 -- focus must return to the + // hidden wedge capture input after the operator clicks ANY unrelated, + // non-text focusable element, not just on a `wedgeActive` transition -- + // otherwise a physical scan typed afterward lands nowhere and `onCode` + // never fires. + it("returns focus to the wedge input a short beat after the operator clicks an unrelated, non-text focusable element", async () => { + const user = userEvent.setup(); + const onCode = vi.fn(); + render(); + expect(screen.getByLabelText("wedge-input")).toHaveFocus(); + + await user.click(screen.getByRole("button", { name: "Other button" })); + expect(screen.getByRole("button", { name: "Other button" })).toHaveFocus(); + + await waitFor(() => expect(screen.getByLabelText("wedge-input")).toHaveFocus()); + }); + + // The manual search box counterpart: focus must NOT be yanked away while + // the operator is actively using it (they clicked in specifically to + // type a name/email/code). + it("does not yank focus away from a text input the operator just clicked into (the manual search box)", async () => { + const user = userEvent.setup(); + const onCode = vi.fn(); + render(); + expect(screen.getByLabelText("wedge-input")).toHaveFocus(); + + await user.click(screen.getByLabelText("manual-search")); + expect(screen.getByLabelText("manual-search")).toHaveFocus(); + + // Give the refocus timer every chance to fire before asserting it did + // NOT -- this is the actual regression this test guards against. + await new Promise((resolve) => setTimeout(resolve, 200)); + expect(screen.getByLabelText("manual-search")).toHaveFocus(); + }); + + // Same "don't fight an active interaction" rule, but for a dialog that's + // now open (e.g. a Reprint/Undo confirm dialog) rather than a text + // field -- the operator clicking its Confirm button must not have focus + // yanked back to the (invisible) wedge input mid-interaction. + it("does not yank focus away from a control inside an open dialog", async () => { + const user = userEvent.setup(); + const onCode = vi.fn(); + render(); + expect(screen.getByLabelText("wedge-input")).toHaveFocus(); + + await user.click(screen.getByRole("button", { name: "Dialog confirm" })); + expect(screen.getByRole("button", { name: "Dialog confirm" })).toHaveFocus(); + + await new Promise((resolve) => setTimeout(resolve, 200)); + expect(screen.getByRole("button", { name: "Dialog confirm" })).toHaveFocus(); + }); + + it("does not refocus after a blur once wedge mode is no longer active (enabled flips false)", async () => { + const user = userEvent.setup(); + const onCode = vi.fn(); + const { rerender } = render(); + expect(screen.getByLabelText("wedge-input")).toHaveFocus(); + + rerender(); + await user.click(screen.getByRole("button", { name: "Other button" })); + + await new Promise((resolve) => setTimeout(resolve, 200)); + expect(screen.getByRole("button", { name: "Other button" })).toHaveFocus(); + }); + }); + + describe("scanner mode", () => { + it("polls agentClient.getLastScan, emits onCode once for a new scan, and clears the agent buffer", async () => { + scanLastResponse = { code: "PD-0107", time: "2026-07-17T10:00:00Z" }; + const onCode = vi.fn(); + render(); + + await waitFor(() => expect(onCode).toHaveBeenCalledTimes(1)); + expect(onCode).toHaveBeenCalledWith("PD-0107"); + await waitFor(() => expect(scanClearHitCount).toBe(1)); + + // A second (and third) poll cycle sees the SAME {code, time} pair + // (this mock never changes) -- must never re-emit or re-clear. + await new Promise((resolve) => setTimeout(resolve, 500)); + expect(onCode).toHaveBeenCalledTimes(1); + expect(scanClearHitCount).toBe(1); + expect(scanLastHitCount).toBeGreaterThan(1); + }, 10000); + + it("does not emit while the buffer is empty (the sentinel no-scan-yet state)", async () => { + const onCode = vi.fn(); + render(); + + await waitFor(() => expect(scanLastHitCount).toBeGreaterThan(1)); + expect(onCode).not.toHaveBeenCalled(); + }); + + it("sets degraded:true when the agent is unreachable, and clears it again once reachable", async () => { + scanLastShouldError = true; + const onCode = vi.fn(); + render(); + + await waitFor(() => expect(screen.getByTestId("degraded")).toHaveTextContent("true")); + expect(onCode).not.toHaveBeenCalled(); + + scanLastShouldError = false; + await waitFor(() => expect(screen.getByTestId("degraded")).toHaveTextContent("false")); + }, 10000); + + it("does not poll when disabled", async () => { + const onCode = vi.fn(); + render(); + + await new Promise((resolve) => setTimeout(resolve, 300)); + expect(scanLastHitCount).toBe(0); + expect(onCode).not.toHaveBeenCalled(); + }); + + // PR #77 bot-review round, Finding P -- the CURRENT code's dedup check + // (`last.code === scan.code && last.time === scan.time`) previously + // caused the poll to see the same still-uncleared pair and exit early + // WITHOUT re-attempting the clear (since it's already in + // `lastHandledRef`), leaving the agent's buffer stuck forever after a + // single transient clear failure -- even though the scan itself was + // correctly consumed exactly once (no double-emit). + it("retries a failed clearLastScan() on the next poll of the SAME scan, without re-emitting onCode", async () => { + scanLastResponse = { code: "PD-0107", time: "2026-07-17T10:00:00Z" }; + scanClearShouldFailNext = true; + const onCode = vi.fn(); + render(); + + // First poll: onCode fires once, the clear is attempted and FAILS. + await waitFor(() => expect(onCode).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(scanClearHitCount).toBe(1)); + + // A later poll sees the SAME {code, time} pair (this mock never + // changes it) -- the clear is retried (a second /scan/clear hit), + // but onCode must NOT fire again. + await waitFor(() => expect(scanClearHitCount).toBe(2), { timeout: 10000 }); + expect(onCode).toHaveBeenCalledTimes(1); + + // And once the clear finally succeeds, no FURTHER retries happen. + await new Promise((resolve) => setTimeout(resolve, 500)); + expect(scanClearHitCount).toBe(2); + expect(onCode).toHaveBeenCalledTimes(1); + }, 15000); + + // PR #77 bot-review round 3, Finding 4 -- the 200ms poll interval + // previously started a new `poll()` on every tick regardless of whether + // the PREVIOUS `getLastScan()`/`clearLastScan()` round trip had actually + // finished. A local agent that accepts a request but stalls (a real + // possibility on a loaded/slow local network) could otherwise let + // in-flight requests accumulate indefinitely. + it("does not start a new poll while the previous getLastScan() round trip is still outstanding, and resumes once it resolves", async () => { + let releaseScanLast: (() => void) | undefined; + const hang = new Promise((resolve) => { + releaseScanLast = resolve; + }); + server.use( + http.get("http://agent.test/scan/last", async () => { + scanLastHitCount += 1; + await hang; + return HttpResponse.json(scanLastResponse); + }), + ); + const onCode = vi.fn(); + render(); + + // The first poll starts (and hangs on the still-unresolved response). + await waitFor(() => expect(scanLastHitCount).toBe(1)); + + // Well past several 200ms poll intervals -- a NEW poll must never + // start while the first one is still outstanding. + await new Promise((resolve) => setTimeout(resolve, 700)); + expect(scanLastHitCount).toBe(1); + + // Releasing the hung request lets normal polling resume. + releaseScanLast?.(); + await waitFor(() => expect(scanLastHitCount).toBeGreaterThan(1)); + }, 10000); + }); + + describe("manual mode", () => { + it("never polls the agent and never emits onCode on its own", async () => { + const onCode = vi.fn(); + render(); + + await new Promise((resolve) => setTimeout(resolve, 300)); + expect(scanLastHitCount).toBe(0); + expect(onCode).not.toHaveBeenCalled(); + expect(screen.getByTestId("degraded")).toHaveTextContent("false"); + }); + }); +}); + +// Type-only sanity check: UseScanInputOptions accepts exactly the brief's +// shape. Not executed -- caught at typecheck time if the hook's signature +// ever drifts. +function _typeCheck(options: UseScanInputOptions) { + return options; +} +void _typeCheck; diff --git a/panel/src/features/checkin/useScanInput.ts b/panel/src/features/checkin/useScanInput.ts new file mode 100644 index 00000000..fbed20ff --- /dev/null +++ b/panel/src/features/checkin/useScanInput.ts @@ -0,0 +1,262 @@ +// P4.1 Task 7 -- the check-in station's three scan-input modes, unified +// behind one hook so StationPage (Task 8) just switches `mode` per the +// event's CheckinSettings.scan_input (Task 5) without re-deriving any of +// this wiring itself. +// +// wedge: a USB/keyboard-wedge scanner "types" its code into whatever has +// focus on the page, then sends Enter -- so this mode is really just "own a +// hidden, always-focused text input and treat Enter as the scan boundary". +// scanner: a handheld scanner the AGENT (not the browser) talks to over +// serial/USB -- the panel has no direct hardware access, so it polls the +// agent's last-scan buffer instead (agentClient.getLastScan/clearLastScan, +// confirmed against agent/openapi.yaml's real /scan/last + /scan/clear +// contract -- not an invented endpoint). +// manual: no auto-input at all; ScanInput.tsx's always-present search box +// is the only path to a pick in this mode. +import * as React from "react"; +import { agentClient } from "../../shared/agent/agentClient"; + +export type ScanInputMode = "wedge" | "scanner" | "manual"; + +export interface UseScanInputOptions { + mode: ScanInputMode; + onCode(code: string): void; + // Gates BOTH the wedge input's focus/typing and the scanner poll -- + // callers (Task 8) pass false while a previous scan is still resolving + // (useCheckinFlow's status !== "idle") so a scanner double-fire or a + // stray wedge keystroke can't race an in-flight check-in. + enabled: boolean; +} + +export interface WedgeInputProps { + ref: React.RefObject; + value: string; + disabled: boolean; + onChange: (event: React.ChangeEvent) => void; + onKeyDown: (event: React.KeyboardEvent) => void; + // PR #77 bot-review round 2, Finding 5 -- see WEDGE_REFOCUS_DELAY_MS's own + // comment below for what this does and why. + onBlur: () => void; +} + +export interface UseScanInputResult { + // True only in scanner mode, only once agentClient.getLastScan() itself + // has failed (agent unreachable/erroring) -- ScanInput.tsx uses this to + // hint the operator toward the always-present manual search fallback + // rather than silently doing nothing. + degraded: boolean; + // Spread onto a (visually-hidden but focusable, e.g. Tailwind `sr-only` + // -- never `type="hidden"`, which never receives keystrokes) in + // wedge mode. Harmless to spread in the other modes too (a disabled, + // unfocused, inert input), but ScanInput.tsx only renders it for "wedge". + wedgeInputProps: WedgeInputProps; +} + +// Matches the brief verbatim ("scanner: single 200ms interval polling +// agentClient.getLastScan()"). +const SCANNER_POLL_INTERVAL_MS = 200; + +// PR #77 bot-review round 2, Finding 5 -- the mount/`wedgeActive`-transition +// effect above only re-focuses the hidden wedge capture input on a +// TRANSITION into wedge mode -- once an operator clicks ANYTHING else on the +// page (the manual search box, a Details/Reprint/Undo rail button, a dialog +// control, ...) without `wedgeActive` ever changing, focus moves away and is +// NEVER returned. A keyboard-wedge scan typed after that lands nowhere (or +// in the wrong control) and `onCode` never fires -- a silently dropped scan, +// exactly the "no-scan-lost" violation this station exists to prevent. +// +// The fix: a `blur` handler on the capture input itself (spread via +// `wedgeInputProps.onBlur`) that returns focus to it a SHORT BEAT after it +// loses focus, while wedge mode is still active -- UNLESS the element that +// just gained focus is something the operator is plainly, deliberately +// using right now. Two judgment calls here, documented since this is a UX +// heuristic, not a pure correctness fix: +// +// 1. WHAT counts as "deliberately in use" (isDeliberateFocusTarget below): +// a text-entry control (the manual search box, a printer only when this is null, never silently // picking "first in the list" on the operator's behalf. configuredDefault: string | null; + // PR #77 bot-review round 3, Finding 3 -- exposed so a caller gating a UI + // surface on this hook's own connectivity/printer state (StationPage.tsx's + // printer-readiness gate) can re-probe on its own schedule while that gate + // is active, WITHOUT this hook itself having an opinion on when that + // should happen (every other consumer -- LaunchCeremony.tsx, + // AttendeeDrawer.tsx, BulkBar.tsx, RecentScansRail.tsx, TestPrintDialog.tsx + // -- already gets a fresh probe for free via `refetchOnWindowFocus`, so + // this stays additive/opt-in rather than changing this hook's own default + // polling behavior for everyone). + refetch: () => Promise>; } export const AGENT_PRINTERS_KEY = ["agent", "printers"] as const; @@ -105,5 +115,5 @@ export function useAgentPrinters(enabled: boolean): UseAgentPrintersResult { state = "checking"; } - return { state, printers, defaultPrinter, configuredDefault }; + return { state, printers, defaultPrinter, configuredDefault, refetch: query.refetch }; } diff --git a/panel/src/shared/api/schema.d.ts b/panel/src/shared/api/schema.d.ts index c900d462..c17ace41 100644 --- a/panel/src/shared/api/schema.d.ts +++ b/panel/src/shared/api/schema.d.ts @@ -272,6 +272,116 @@ export interface paths { patch?: never; trace?: never; }; + "/api/events/{id}/checkin-settings": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** The event's check-in station settings (P4.1) — reads the dedicated events.checkin_settings column. Consumed by Task 2+ (station registration) and the panel's check-in settings UI. */ + get: operations["getCheckinSettings"]; + /** + * Save the event's check-in station settings. + * @description Storage is verbatim: settings is persisted as the request's raw JSON bytes, byte-for-byte — the handler validates a parsed COPY against the CheckinSettings shape (all four fields required, unknown fields rejected) but never re-serializes before persisting. Unlike PUT /api/events/{id}/badge-template, there is no optimistic-concurrency version: check-in settings are operator-only config with no concurrent-editor conflict class to guard against. + */ + put: operations["putCheckinSettings"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/events/{event_id}/checkin-stations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List every check-in station registered for an event (P4.1 Task 2). */ + get: operations["listCheckinStations"]; + put?: never; + /** Register (or re-register) a named check-in station for an event (P4.1 Task 2). Registering the SAME name again is an upsert — the same station id is returned, zone_id is replaced by whatever this call submits (even back to null), and last_seen_at is refreshed; it never creates a duplicate row. */ + post: operations["registerCheckinStation"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/events/{event_id}/checkin-stations/{id}/heartbeat": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Refresh a check-in station's last_seen_at (P4.1 Task 2) — polled periodically by a running station so the panel can show online/offline state (a later task). */ + post: operations["heartbeatCheckinStation"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/events/{event_id}/checkin": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Idempotent single-scan check-in (P4.1 Task 3) — the zero-double-checkin guarantee at the source, via a guarded `UPDATE ... WHERE checkin_status = false` in the store. Never touches printed_count and never prints; printing is a separate client step gated on the "checked_in" outcome only. + * @description A blocked attendee (attendee.blocked) is never checked in — the handler returns outcome "blocked" (with block_reason on attendee) without attempting the guarded write. Otherwise, the guarded UPDATE either performs the check-in (outcome "checked_in", and a checkin_actions row is inserted in the same transaction) or, if the attendee was already checked in, falls back to a read that returns the ORIGINAL first-scan metadata unchanged (outcome "already_checked_in", no new feed row). + */ + post: operations["stationCheckin"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/events/{event_id}/checkin/undo": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Clear a check-in (P4.1 Task 3) — idempotent: undoing an attendee who is already not checked in still returns 200 with no checkin_actions row written. */ + post: operations["undoCheckin"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/events/{event_id}/checkin-actions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** The event's check-in/undo/reprint feed, newest first (P4.1 Task 3) — backs the station's recent-scans rail (last 50). */ + get: operations["getCheckinActions"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/events/{id}/readiness": { parameters: { query?: never; @@ -510,7 +620,7 @@ export interface paths { }; get?: never; put?: never; - /** Increment an attendee's printed_count by one and return the new count. No request body. [Plan-time reconciliation #6, docs/superpowers/plans/2026-07-16-panel-p3.2-print-truth.md] printed_count had NO write path anywhere before this endpoint (no handler field, no endpoint, no client bump) — the attendees table's Printed pill was decorative. This is the minimal increment so the pill becomes real once the panel's print flow calls it after a successful agent print. It is deliberately NOT a print journal (no per-print audit 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. */ + /** Increment an attendee's printed_count by one and return the new count. [Plan-time reconciliation #6, docs/superpowers/plans/2026-07-16-panel-p3.2-print-truth.md] printed_count had NO write path anywhere before this endpoint (no handler field, no endpoint, no client bump) — the attendees table's Printed pill was decorative. This is the minimal increment so the pill becomes real once the panel's print flow calls it after a successful agent print. It is deliberately NOT a print journal (no per-print audit 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 request body: when event_id is present, after the counter increment succeeds, the handler ALSO inserts a checkin_actions ('reprint') feed 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, unchanged. The body is parsed leniently: unknown fields are ignored and a syntactically-malformed JSON body is treated the same as no body at all (the counter still increments). A present, well-formed body can still 400 in FOUR cases, all checked BEFORE the counter increments so a rejected request never partially applies — see MarkAttendeePrintedRequest's schema and its field descriptions for the full dependency/mismatch contract: (1) event_id or station_id is present but not a valid UUID string; (2) station_id is present without event_id (PR #77 bot-review round 1, Finding D); (3) event_id is present but does not match the attendee's actual event; (4) station_id, when present alongside a valid event_id, does not belong to that event. Reprint-logging failures (e.g. a transient store error resolving staffUserID from claims) never fail this endpoint — the counter has already committed by the time logging is attempted, so it is treated as best-effort. */ post: operations["markAttendeePrinted"]; delete?: never; options?: never; @@ -1058,6 +1168,123 @@ export interface components { error: string; current_version: number; }; + /** @description Per-event check-in station configuration (P4.1) — operator-only, no optimistic-concurrency version (unlike BadgeTemplateResponse): check-in settings have no concurrent-editor conflict class to guard against. Stored verbatim in events.checkin_settings JSONB. */ + CheckinSettings: { + print_on_checkin: boolean; + verdict_auto_dismiss_sec: number; + /** @enum {string} */ + scan_input: "wedge" | "scanner" | "manual"; + manual_search_enabled: boolean; + }; + /** @description GET/PUT /api/events/{id}/checkin-settings response. settings is the stored check-in settings verbatim — whatever object was last PUT — and is null when the event has never had settings saved. */ + CheckinSettingsResponse: { + settings: components["schemas"]["CheckinSettings"] | null; + }; + /** @description PUT /api/events/{id}/checkin-settings request body. settings is persisted verbatim (byte-for-byte, from the raw request bytes) after being validated against the CheckinSettings shape. */ + CheckinSettingsPutRequest: { + settings: components["schemas"]["CheckinSettings"]; + }; + /** @description A registered check-in station (P4.1 Task 2) — distinct from the mobile-track Station (zone/kiosk devices): a checkin_station is name-scoped per event (UNIQUE(event_id, name)) and optionally bound to a zone. last_seen_at is refreshed by POST /api/events/{event_id}/checkin-stations/{id}/heartbeat. */ + CheckinStation: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + event_id: string; + name: string; + /** Format: uuid */ + zone_id?: string | null; + /** Format: date-time */ + last_seen_at: string; + /** Format: date-time */ + created_at: string; + }; + /** @description POST /api/events/{event_id}/checkin-stations request body. name identifies the station (UNIQUE per event) — registering the SAME name again is an upsert: zone_id is replaced (even back to null) and last_seen_at refreshed, never a duplicate row. zone_id, when present, must belong to the same event (400 otherwise). */ + CheckinStationRegisterRequest: { + name: string; + /** Format: uuid */ + zone_id?: string | null; + }; + /** @description POST /api/events/{event_id}/checkin-stations' response envelope — station is the registered (or re-registered) row. */ + CheckinStationResponse: { + station: components["schemas"]["CheckinStation"]; + }; + /** @description GET /api/events/{event_id}/checkin-stations' response envelope. */ + CheckinStationListResponse: { + stations: components["schemas"]["CheckinStation"][]; + }; + /** + * @description Server-decided outcome of POST /api/events/{event_id}/checkin (P4.1 Task 3). "not_found" is deliberately NOT a value here — an unresolved scanned code is a client-side outcome (the code lookup itself returned empty) that never reaches this endpoint. + * @enum {string} + */ + CheckinOutcome: "checked_in" | "already_checked_in" | "blocked"; + /** @description POST /api/events/{event_id}/checkin request body (P4.1 Task 3). station_id, when present, must belong to the same event (400 otherwise); it is optional — a station-less check-in is valid. */ + StationCheckinRequest: { + /** Format: uuid */ + attendee_id: string; + /** Format: uuid */ + station_id?: string | null; + }; + /** @description The first-scan metadata block of StationCheckinResponse — for outcome checked_in this is THIS scan; for already_checked_in it is the ORIGINAL scan, never overwritten. */ + CheckinInfo: { + /** Format: date-time */ + at: string; + by_email: string; + point_name?: string | null; + }; + /** @description POST /api/events/{event_id}/checkin response. checkin is the first-scan metadata for outcome checked_in/already_checked_in, and null for outcome blocked (block_reason is read from attendee instead — a blocked attendee is never checked in). */ + StationCheckinResponse: { + outcome: components["schemas"]["CheckinOutcome"]; + attendee: components["schemas"]["Attendee"]; + checkin: components["schemas"]["CheckinInfo"] | null; + }; + /** @description POST /api/events/{event_id}/checkin/undo request body (P4.1 Task 3). station_id, when present, must belong to the same event (400 otherwise); it is recorded on the checkin_actions feed row only. */ + UndoCheckinRequest: { + /** Format: uuid */ + attendee_id: string; + /** Format: uuid */ + station_id?: string | null; + }; + /** @description POST /api/events/{event_id}/checkin/undo response — always 200, idempotent: undoing an attendee who is already not checked in still returns 200 with the (unchanged) attendee. */ + UndoCheckinResponse: { + attendee: components["schemas"]["Attendee"]; + }; + /** @description Slim attendee projection embedded in a CheckinActionRow. */ + CheckinActionAttendee: { + /** Format: uuid */ + id: string; + first_name: string; + last_name: string; + code: string; + }; + /** @description One row of GET /api/events/{event_id}/checkin-actions' feed (P4.1 Task 3) — the durable check-in/undo/reprint audit trail backing the station's recent-scans rail. */ + CheckinActionRow: { + /** Format: uuid */ + id: string; + /** @enum {string} */ + action: "checkin" | "undo" | "reprint"; + /** Format: uuid */ + station_id?: string | null; + /** Format: date-time */ + created_at: string; + attendee: components["schemas"]["CheckinActionAttendee"]; + }; + /** @description GET /api/events/{event_id}/checkin-actions' response envelope. */ + CheckinActionsResponse: { + actions: components["schemas"]["CheckinActionRow"][]; + }; + /** @description POST /api/attendees/{attendee_id}/printed's OPTIONAL request body (P4.1 Task 4). Both fields are optional, but NOT independent of each other — the handler (attendee_printed.go) enforces two dependency/consistency constraints the schema below cannot express structurally (OpenAPI 3.0 has no clean native "field A requires field B" construct), documented in prose on each field and on the endpoint's 400 response instead: (1) station_id requires event_id to also be present — a station_id with no event_id is rejected with 400, not silently discarded (PR #77 bot-review round 1, Finding D); (2) event_id, when present, must match the attendee's actual event — a mismatched event_id is rejected with 400, never silently substituted (checked since this endpoint's reprint-logging was first built, Task 4). event_id is what actually gates the reprint-logging behavior — station_id is only meaningful (recorded on the feed row) when a validated event_id is also present. Absent entirely (or an absent/empty body) is the pre-existing back-compat path: counter-only, no checkin_actions row (the badge-editor's bulk print sends no body at all). */ + MarkAttendeePrintedRequest: { + /** + * Format: uuid + * @description Optional. When present, must match the attendee's actual event — a mismatched event_id is rejected with 400 ("Attendee does not belong to this event"), never silently substituted. Gates reprint-logging: only when event_id is present (and valid) does the handler log a checkin_actions ('reprint') row after the printed_count increment succeeds. + */ + event_id?: string | null; + /** + * Format: uuid + * @description Optional, but REQUIRES event_id to also be present in the same request — station_id with no event_id is rejected with 400 ("event_id is required when station_id is supplied"), not silently discarded (PR #77 bot-review round 1, Finding D). When both are present, station_id must also belong to the same event_id (400 "Station not found in event" otherwise). + */ + station_id?: string | null; + }; CreateProvisioningTokenResponse: { token: string; /** Format: date-time */ @@ -2500,7 +2727,7 @@ export interface operations { }; }; }; - getEventReadiness: { + getCheckinSettings: { parameters: { query?: never; header?: never; @@ -2511,13 +2738,13 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Readiness aggregate, steps in pipeline order. */ + /** @description settings is null when the event has never had settings saved. */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EventReadinessResponse"]; + "application/json": components["schemas"]["CheckinSettingsResponse"]; }; }; /** @description id is not a UUID. */ @@ -2538,7 +2765,7 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Event not found or belongs to another tenant. */ + /** @description Event does not exist, or belongs to a different tenant (requireEventOwnership masks "foreign" as "missing" — no existence oracle). */ 404: { headers: { [name: string]: unknown; @@ -2547,7 +2774,7 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Store failure resolving event ownership or computing any of the step counts. */ + /** @description Store failure resolving event ownership or reading check-in settings. */ 500: { headers: { [name: string]: unknown; @@ -2558,30 +2785,31 @@ export interface operations { }; }; }; - getEventStats: { + putCheckinSettings: { parameters: { - query?: { - /** @description If given, must be a zone belonging to this event; the response then includes zone_stats. */ - zone?: string; - }; + query?: never; header?: never; path: { - event_id: string; + id: string; }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["CheckinSettingsPutRequest"]; + }; + }; responses: { - /** @description Stats for the event (and zone, if requested). */ + /** @description Saved. settings echoes the request's raw bytes verbatim. */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EventStatsResponse"]; + "application/json": components["schemas"]["CheckinSettingsResponse"]; }; }; - /** @description event_id or zone is not a UUID. */ + /** @description id is not a UUID, the body is malformed, settings is missing, or the parsed settings fail the CheckinSettings shape (a required field is missing, verdict_auto_dismiss_sec is outside 1..30, scan_input is not one of wedge/scanner/manual, or an unknown field is present). */ 400: { headers: { [name: string]: unknown; @@ -2599,7 +2827,7 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Event does not exist (or foreign tenant), or the zone does not exist / does not belong to this event. */ + /** @description Event does not exist, or belongs to a different tenant (requireEventOwnership masks "foreign" as "missing" — checked before any store call). */ 404: { headers: { [name: string]: unknown; @@ -2608,7 +2836,7 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Store failure loading the zone or computing the stats. */ + /** @description Store failure resolving event ownership or persisting check-in settings. */ 500: { headers: { [name: string]: unknown; @@ -2619,7 +2847,7 @@ export interface operations { }; }; }; - getEventStaff: { + listCheckinStations: { parameters: { query?: never; header?: never; @@ -2630,22 +2858,22 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Assigned staff, as full User records — store.GetEventStaff joins event_staff back to users, so this is an array of User, NOT the EventStaff assignment shape (see POST on this same path, which does return EventStaff). */ + /** @description Registered stations. */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["User"][]; + "application/json": components["schemas"]["CheckinStationListResponse"]; }; }; - /** @description event_id is not a UUID (echo.NewHTTPError shape). */ + /** @description event_id is not a UUID. */ 400: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["HTTPError"]; + "application/json": components["schemas"]["Error"]; }; }; /** @description tenant_suspended from the tenant gate. */ @@ -2657,7 +2885,7 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Event does not exist, or belongs to a different tenant. */ + /** @description Event does not exist, or belongs to a different tenant (requireEventOwnership masks "foreign" as "missing"). */ 404: { headers: { [name: string]: unknown; @@ -2666,18 +2894,18 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Store failure resolving event ownership (Error, via writeErr), or fetching staff (echo.NewHTTPError shape). */ + /** @description Store failure resolving event ownership or listing stations. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"] | components["schemas"]["HTTPError"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - assignStaffToEvent: { + registerCheckinStation: { parameters: { query?: never; header?: never; @@ -2688,98 +2916,95 @@ export interface operations { }; requestBody: { content: { - "application/json": { - /** Format: uuid */ - user_id: string; - }; + "application/json": components["schemas"]["CheckinStationRegisterRequest"]; }; }; responses: { - /** @description Created assignment. */ - 201: { + /** @description Registered (or re-registered) station. */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EventStaff"]; + "application/json": components["schemas"]["CheckinStationResponse"]; }; }; - /** @description Invalid tenant/event/user ID, or a malformed request body — all via echo.NewHTTPError. */ + /** @description event_id is not a UUID, the body is malformed, name is missing or empty, or zone_id is present but does not belong to this event. */ 400: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["HTTPError"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description Caller role is not admin/manager (echo.NewHTTPError → HTTPError), or tenant_suspended from the tenant gate (→ Error). */ + /** @description tenant_suspended from the tenant gate. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["HTTPError"] | components["schemas"]["Error"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description Event does not exist / foreign tenant (Error, via requireEventOwnership+writeErr), or the target user does not exist / is not a member of the active tenant (HTTPError, "User not found", via echo.NewHTTPError). */ + /** @description Event does not exist, or belongs to a different tenant (requireEventOwnership masks "foreign" as "missing" — checked before any store call). */ 404: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"] | components["schemas"]["HTTPError"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description Store failure resolving event ownership (Error), or persisting the assignment (echo.NewHTTPError shape). */ + /** @description Store failure resolving event ownership, verifying zone_id, or persisting the station. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"] | components["schemas"]["HTTPError"]; + "application/json": components["schemas"]["Error"]; }; }; }; }; - unassignStaffFromEvent: { + heartbeatCheckinStation: { parameters: { query?: never; header?: never; path: { event_id: string; - user_id: string; + id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Staff member removed (or was not assigned to begin with). */ + /** @description last_seen_at refreshed. No body. */ 204: { headers: { [name: string]: unknown; }; content?: never; }; - /** @description event_id or user_id is not a UUID (echo.NewHTTPError shape). */ + /** @description event_id or id is not a UUID. */ 400: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["HTTPError"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description Caller role is not admin/manager (echo.NewHTTPError → HTTPError), or tenant_suspended from the tenant gate (→ Error). */ + /** @description tenant_suspended from the tenant gate. */ 403: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["HTTPError"] | components["schemas"]["Error"]; + "application/json": components["schemas"]["Error"]; }; }; - /** @description Event does not exist, or belongs to a different tenant (requireEventOwnership) — note this checks event_id only, not that user_id was ever actually assigned. */ + /** @description Event does not exist / belongs to a different tenant, or the station id does not exist / belongs to a different event (store.ErrCheckinStationNotFound — both collapse to the same 404, no existence oracle). */ 404: { headers: { [name: string]: unknown; @@ -2788,7 +3013,7 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Store failure resolving event ownership or persisting the removal. Both failures emit the standard Error shape. */ + /** @description Store failure resolving event ownership or updating the station. */ 500: { headers: { [name: string]: unknown; @@ -2799,7 +3024,7 @@ export interface operations { }; }; }; - createStationProvisioningToken: { + stationCheckin: { parameters: { query?: never; header?: never; @@ -2810,23 +3035,20 @@ export interface operations { }; requestBody: { content: { - "application/json": { - /** Format: uuid */ - staff_user_id: string; - }; + "application/json": components["schemas"]["StationCheckinRequest"]; }; }; responses: { - /** @description Token to encode as a QR code for the mobile device to scan. */ - 201: { + /** @description checked_in, already_checked_in, or blocked — all three are 200, never an error; the station renders each as a distinct verdict. */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CreateProvisioningTokenResponse"]; + "application/json": components["schemas"]["StationCheckinResponse"]; }; }; - /** @description Malformed body, event_id is not a UUID, or the target staff user's tenant-scoped role is neither staff nor manager. */ + /** @description event_id is not a UUID, the body is malformed, attendee_id is missing, the attendee belongs to a different event than event_id, or station_id is present but does not belong to this event. */ 400: { headers: { [name: string]: unknown; @@ -2835,7 +3057,7 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Caller role is not admin/manager, or tenant_suspended from the tenant gate — both render as the Error shape here (unlike AssignStaffToEvent, this handler uses c.JSON throughout, never echo.NewHTTPError). */ + /** @description tenant_suspended from the tenant gate. */ 403: { headers: { [name: string]: unknown; @@ -2844,7 +3066,7 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Event does not exist / foreign tenant, or the target staff user does not exist / is not a tenant member (uniform 404 either way — doesn't reveal cross-tenant existence). */ + /** @description Event does not exist / belongs to a different tenant, or attendee_id does not exist / belongs to a different tenant (both requireEventOwnership and requireAttendeeOwnership mask "foreign" as "missing" — no existence oracle), or the attendee was concurrently soft-deleted between the ownership check and the guarded write (store.ErrAttendeeNotFound). */ 404: { headers: { [name: string]: unknown; @@ -2853,7 +3075,16 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description Token generation or persistence failure. */ + /** @description store.ErrCheckinConflict (PR #77 bot-review round 2, Finding 1): the guarded UPDATE's fallback read found the attendee neither checked in nor blocked — an extremely narrow, transient race (e.g. the UPDATE lost to a different concurrent attempt that itself then got undone, or a block/unblock cycle landed between the UPDATE and the fallback read). The store retries this state once internally before giving up; this 409 means both attempts landed on it. The caller should retry the scan. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Store failure resolving ownership, verifying station_id, resolving the staff user, or performing the check-in. */ 500: { headers: { [name: string]: unknown; @@ -2864,37 +3095,524 @@ export interface operations { }; }; }; - getAttendees: { + undoCheckin: { parameters: { - query?: { - /** @description Exact match against Attendee.code. */ - code?: string; - /** @description Substring match across name/email/code. */ - search?: string; - /** @description 1-indexed page number. Presence of this param (or per_page) switches the response to the AttendeeListPage envelope. Defaults to 1 if only per_page is given. Must be >= 1. */ - page?: number; - /** @description Page size. Presence of this param (or page) switches the response to the AttendeeListPage envelope. Defaults to 50 if only page is given. Must be between 1 and 200 inclusive. */ - per_page?: number; - /** @description Event zone UUID. Only applied in the paginated envelope mode (page or per_page present); narrows to attendees with an explicit attendee_zone_access row for that zone with allowed=true. */ - zone?: string; - /** @description Only applied in the paginated envelope mode (page or per_page present); filters on Attendee.checkin_status. */ - status?: "checked_in" | "not_checked_in"; - }; + query?: never; header?: never; path: { event_id: string; }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["UndoCheckinRequest"]; + }; + }; responses: { - /** @description Attendees for the event. A bare array when neither page nor per_page is present (legacy shape, unfiltered by zone/status); an AttendeeListPage envelope otherwise. */ + /** @description Check-in cleared (or already clear — idempotent). */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Attendee"][] | components["schemas"]["AttendeeListPage"]; + "application/json": components["schemas"]["UndoCheckinResponse"]; + }; + }; + /** @description event_id is not a UUID, the body is malformed, attendee_id is missing, the attendee belongs to a different event than event_id, or station_id is present but does not belong to this event. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description tenant_suspended from the tenant gate. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Event does not exist / belongs to a different tenant, or attendee_id does not exist / belongs to a different tenant, or the attendee was concurrently soft-deleted (store.ErrAttendeeNotFound). */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Store failure resolving ownership, verifying station_id, or clearing the check-in. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + getCheckinActions: { + parameters: { + query?: { + /** @description Defaults to 50 and is clamped to a maximum of 50 (the rail never shows more). An invalid or non-positive value is ignored, falling back to the default, rather than 400ing this read-only feed endpoint. */ + limit?: number; + }; + header?: never; + path: { + event_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The newest (at most) `limit` check-in actions, newest first. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CheckinActionsResponse"]; + }; + }; + /** @description event_id is not a UUID. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description tenant_suspended from the tenant gate. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Event does not exist, or belongs to a different tenant (requireEventOwnership masks "foreign" as "missing"). */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Store failure resolving event ownership or fetching the feed. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + getEventReadiness: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Readiness aggregate, steps in pipeline order. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EventReadinessResponse"]; + }; + }; + /** @description id is not a UUID. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description tenant_suspended from the tenant gate. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Event not found or belongs to another tenant. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Store failure resolving event ownership or computing any of the step counts. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + getEventStats: { + parameters: { + query?: { + /** @description If given, must be a zone belonging to this event; the response then includes zone_stats. */ + zone?: string; + }; + header?: never; + path: { + event_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Stats for the event (and zone, if requested). */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EventStatsResponse"]; + }; + }; + /** @description event_id or zone is not a UUID. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description tenant_suspended from the tenant gate. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Event does not exist (or foreign tenant), or the zone does not exist / does not belong to this event. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Store failure loading the zone or computing the stats. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + getEventStaff: { + parameters: { + query?: never; + header?: never; + path: { + event_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Assigned staff, as full User records — store.GetEventStaff joins event_staff back to users, so this is an array of User, NOT the EventStaff assignment shape (see POST on this same path, which does return EventStaff). */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["User"][]; + }; + }; + /** @description event_id is not a UUID (echo.NewHTTPError shape). */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPError"]; + }; + }; + /** @description tenant_suspended from the tenant gate. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Event does not exist, or belongs to a different tenant. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Store failure resolving event ownership (Error, via writeErr), or fetching staff (echo.NewHTTPError shape). */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"] | components["schemas"]["HTTPError"]; + }; + }; + }; + }; + assignStaffToEvent: { + parameters: { + query?: never; + header?: never; + path: { + event_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** Format: uuid */ + user_id: string; + }; + }; + }; + responses: { + /** @description Created assignment. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EventStaff"]; + }; + }; + /** @description Invalid tenant/event/user ID, or a malformed request body — all via echo.NewHTTPError. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPError"]; + }; + }; + /** @description Caller role is not admin/manager (echo.NewHTTPError → HTTPError), or tenant_suspended from the tenant gate (→ Error). */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPError"] | components["schemas"]["Error"]; + }; + }; + /** @description Event does not exist / foreign tenant (Error, via requireEventOwnership+writeErr), or the target user does not exist / is not a member of the active tenant (HTTPError, "User not found", via echo.NewHTTPError). */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"] | components["schemas"]["HTTPError"]; + }; + }; + /** @description Store failure resolving event ownership (Error), or persisting the assignment (echo.NewHTTPError shape). */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"] | components["schemas"]["HTTPError"]; + }; + }; + }; + }; + unassignStaffFromEvent: { + parameters: { + query?: never; + header?: never; + path: { + event_id: string; + user_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Staff member removed (or was not assigned to begin with). */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description event_id or user_id is not a UUID (echo.NewHTTPError shape). */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPError"]; + }; + }; + /** @description Caller role is not admin/manager (echo.NewHTTPError → HTTPError), or tenant_suspended from the tenant gate (→ Error). */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPError"] | components["schemas"]["Error"]; + }; + }; + /** @description Event does not exist, or belongs to a different tenant (requireEventOwnership) — note this checks event_id only, not that user_id was ever actually assigned. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Store failure resolving event ownership or persisting the removal. Both failures emit the standard Error shape. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + createStationProvisioningToken: { + parameters: { + query?: never; + header?: never; + path: { + event_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** Format: uuid */ + staff_user_id: string; + }; + }; + }; + responses: { + /** @description Token to encode as a QR code for the mobile device to scan. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CreateProvisioningTokenResponse"]; + }; + }; + /** @description Malformed body, event_id is not a UUID, or the target staff user's tenant-scoped role is neither staff nor manager. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Caller role is not admin/manager, or tenant_suspended from the tenant gate — both render as the Error shape here (unlike AssignStaffToEvent, this handler uses c.JSON throughout, never echo.NewHTTPError). */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Event does not exist / foreign tenant, or the target staff user does not exist / is not a tenant member (uniform 404 either way — doesn't reveal cross-tenant existence). */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Token generation or persistence failure. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + getAttendees: { + parameters: { + query?: { + /** @description Exact match against Attendee.code. */ + code?: string; + /** @description Substring match across name/email/code. */ + search?: string; + /** @description 1-indexed page number. Presence of this param (or per_page) switches the response to the AttendeeListPage envelope. Defaults to 1 if only per_page is given. Must be >= 1. */ + page?: number; + /** @description Page size. Presence of this param (or page) switches the response to the AttendeeListPage envelope. Defaults to 50 if only page is given. Must be between 1 and 200 inclusive. */ + per_page?: number; + /** @description Event zone UUID. Only applied in the paginated envelope mode (page or per_page present); narrows to attendees with an explicit attendee_zone_access row for that zone with allowed=true. */ + zone?: string; + /** @description Only applied in the paginated envelope mode (page or per_page present); filters on Attendee.checkin_status. */ + status?: "checked_in" | "not_checked_in"; + }; + header?: never; + path: { + event_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Attendees for the event. A bare array when neither page nor per_page is present (legacy shape, unfiltered by zone/status); an AttendeeListPage envelope otherwise. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Attendee"][] | components["schemas"]["AttendeeListPage"]; }; }; /** @description event_id is not a UUID, or (paginated mode only) page < 1, per_page outside 1..200, zone is not a UUID, or status is neither checked_in nor not_checked_in. */ @@ -3641,7 +4359,11 @@ export interface operations { }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": components["schemas"]["MarkAttendeePrintedRequest"]; + }; + }; responses: { /** @description printed_count incremented by one; response carries the new value. */ 200: { @@ -3654,7 +4376,7 @@ export interface operations { }; }; }; - /** @description attendee_id is not a UUID. */ + /** @description attendee_id is not a UUID; the optional body's event_id or station_id is present but not a valid UUID string; station_id is present without event_id ("event_id is required when station_id is supplied" — PR #77 bot-review round 1, Finding D); event_id is present but does not match the attendee's actual event ("Attendee does not belong to this event"); or station_id, alongside a valid event_id, does not belong to that event ("Station not found in event"). See MarkAttendeePrintedRequest for the full field-level dependency contract. */ 400: { headers: { [name: string]: unknown; diff --git a/panel/src/shared/i18n/en.json b/panel/src/shared/i18n/en.json index 42097ff2..02391201 100644 --- a/panel/src/shared/i18n/en.json +++ b/panel/src/shared/i18n/en.json @@ -96,8 +96,6 @@ "workspaceStatCheckedIn": "Checked in", "workspaceUnlockHint": "Finish the badge and run a test print to unlock check-in.", "workspaceLaunchCheckin": "Launch check-in", - "workspaceLaunchComingSoonTitle": "Launch ceremony arrives with the check-in station", - "workspaceLaunchComingSoonBody": "Confirming the event, loading station settings and the printer check land in a later update.", "workspaceDialogClose": "Close", "badgeTitle": "Badge editor", "badgeLoadError": "Couldn't load the badge template.", @@ -553,5 +551,75 @@ "staffZonesDialogLoadError": "Couldn't load zones for this staff member.", "staffZonesDialogEmpty": "This event has no zones yet.", "staffZonesToggleError": "Couldn't update zone access. Try again.", - "staffZonesDialogHint": "Changes save immediately." + "staffZonesDialogHint": "Changes save immediately.", + "checkinScanWedgeHint": "Scan a badge to check in.", + "checkinScanWedgeInputLabel": "Badge scanner input", + "checkinScanScannerHint": "Waiting for a scan from the handheld scanner…", + "checkinScanScannerDegradedHint": "Can't reach the handheld scanner — use manual search below.", + "checkinScanScannerDegradedHintNoManualSearch": "Can't reach the handheld scanner. Ask a colleague for help checking this attendee in.", + "checkinScanManualHint": "Look up an attendee by name, email, or code below.", + "checkinManualSearchPlaceholder": "Search by name, email, or code…", + "checkinManualSearchNoMatches": "No matching attendees.", + "checkinExit": "Exit", + "checkinIdleHint": "Ready for the next scan.", + "checkinSettingsLoading": "Loading check-in settings…", + "checkinPrinterWaiting": "Waiting for the printer to connect before scanning can resume.", + "checkinResolvingHint": "Checking…", + "checkinVerdictAllowed": "Checked in", + "checkinVerdictNoAccess": "Access denied", + "checkinVerdictNotRegistered": "Not registered", + "checkinVerdictAlreadyCheckedIn": "Already checked in", + "checkinFirstScanAt": "First checked in at {{time}}", + "checkinPrintFailedWarning": "Badge didn't print — reprint it from the recent scans list.", + "checkinPrintFontsPendingWarning": "Badge didn't print — event fonts were still loading. Reprint it from the recent scans list.", + "checkinBlockReason": "Reason: {{reason}}", + "checkinRequestError": "Couldn't complete the check-in. Try scanning again.", + "checkinRailTitle": "Recent scans", + "checkinRailEmpty": "No scans yet.", + "checkinRailLoadError": "Couldn't load recent scans.", + "checkinRailReprint": "Reprint", + "checkinRailUndo": "Undo", + "checkinRailDetails": "Details", + "checkinRailReprintUnreachable": "Can't reach the local print agent.", + "checkinActionCheckin": "Checked in", + "checkinActionUndo": "Undone", + "checkinActionReprint": "Reprinted", + "checkinReprintConfirmTitle": "Reprint badge", + "checkinReprintConfirmBody": "Print {{name}}'s badge on {{printer}}?", + "checkinReprintConfirmBodyChoose": "Choose a printer to print {{name}}'s badge.", + "checkinReprintConfirm": "Print", + "checkinReprintNoTemplate": "This event doesn't have a badge template yet.", + "checkinReprintMissingFont": "Font {{families}} is missing — fix the badge template", + "checkinReprintOpenEditor": "Open the badge editor", + "checkinReprintError": "Couldn't send the badge to the printer. Try again.", + "checkinReprintMarkPrintedWarning": "Sent to {{printer}}, but the printed count couldn't be updated.", + "checkinUndoConfirmTitle": "Undo check-in", + "checkinUndoConfirmBody": "Clear {{name}}'s check-in? They'll need to scan in again.", + "checkinUndoConfirm": "Undo check-in", + "checkinUndoError": "Couldn't undo the check-in. Try again.", + "checkinDetailsUndoneAt": "Check-in undone at {{time}}", + "checkinDetailsReprintedAt": "Badge reprinted at {{time}}", + "checkinDegradedBanner": "Connection is unstable", + "checkinOfflineBlocked": "Can't check in — offline.", + "checkinManualSearchReadOnlyHint": "Check-in is disabled while offline.", + "launchColEventTitle": "Confirm event & station", + "launchStationNameLabel": "Station name", + "launchStationNameDefault": "Main entrance", + "launchStationNameRequired": "Give the station a name.", + "launchZoneLabel": "Zone (optional)", + "launchZoneNone": "No zone", + "launchColSettingsTitle": "Check-in settings", + "launchPrintOnCheckinLabel": "Print badge on check-in", + "launchDismissSecLabel": "Verdict auto-dismiss (seconds)", + "launchScanInputLabel": "Scan input", + "launchScanInputWedge": "Wedge scanner", + "launchScanInputScanner": "Handheld scanner", + "launchScanInputManual": "Manual search", + "launchManualSearchLabel": "Allow manual search", + "launchColPrinterTitle": "Printer check", + "launchTestBadgeButton": "Test badge", + "launchTestBadgeNoTemplate": "Design a badge template first to test print it.", + "launchStartCheckin": "Start check-in", + "launchUnsavedSettingsHint": "Save your check-in settings before starting check-in.", + "launchRegisterError": "Couldn't register the station. Try again." } diff --git a/panel/src/shared/i18n/ru.json b/panel/src/shared/i18n/ru.json index ea775fbf..76efa193 100644 --- a/panel/src/shared/i18n/ru.json +++ b/panel/src/shared/i18n/ru.json @@ -96,8 +96,6 @@ "workspaceStatCheckedIn": "Зарегистрировано", "workspaceUnlockHint": "Завершите шаблон бейджа и сделайте тестовую печать, чтобы разблокировать регистрацию.", "workspaceLaunchCheckin": "Запустить регистрацию", - "workspaceLaunchComingSoonTitle": "Церемония запуска появится вместе со станцией регистрации", - "workspaceLaunchComingSoonBody": "Подтверждение мероприятия, загрузка настроек станции и проверка принтера появятся в следующем обновлении.", "workspaceDialogClose": "Закрыть", "badgeTitle": "Редактор бейджа", "badgeLoadError": "Не удалось загрузить шаблон бейджа.", @@ -555,5 +553,75 @@ "staffZonesDialogLoadError": "Не удалось загрузить зоны для этого сотрудника.", "staffZonesDialogEmpty": "В этом мероприятии пока нет зон.", "staffZonesToggleError": "Не удалось обновить доступ к зоне. Попробуйте ещё раз.", - "staffZonesDialogHint": "Изменения сохраняются сразу." + "staffZonesDialogHint": "Изменения сохраняются сразу.", + "checkinScanWedgeHint": "Отсканируйте бейдж, чтобы зарегистрировать участника.", + "checkinScanWedgeInputLabel": "Поле ввода сканера бейджей", + "checkinScanScannerHint": "Ожидание скана с ручного сканера…", + "checkinScanScannerDegradedHint": "Не удаётся подключиться к ручному сканеру — используйте поиск вручную ниже.", + "checkinScanScannerDegradedHintNoManualSearch": "Не удаётся подключиться к ручному сканеру. Обратитесь за помощью к коллеге для регистрации участника.", + "checkinScanManualHint": "Найдите участника по имени, email или коду ниже.", + "checkinManualSearchPlaceholder": "Поиск по имени, email или коду…", + "checkinManualSearchNoMatches": "Участники не найдены.", + "checkinExit": "Выход", + "checkinIdleHint": "Готово к следующему сканированию.", + "checkinSettingsLoading": "Загрузка настроек регистрации…", + "checkinPrinterWaiting": "Ожидание подключения принтера — после этого сканирование возобновится.", + "checkinResolvingHint": "Проверка…", + "checkinVerdictAllowed": "Зарегистрирован", + "checkinVerdictNoAccess": "Доступ запрещён", + "checkinVerdictNotRegistered": "Не зарегистрирован", + "checkinVerdictAlreadyCheckedIn": "Уже зарегистрирован", + "checkinFirstScanAt": "Первый скан в {{time}}", + "checkinPrintFailedWarning": "Бейдж не был напечатан — перепечатайте его из списка последних сканирований.", + "checkinPrintFontsPendingWarning": "Бейдж не был напечатан — шрифты мероприятия ещё загружались. Перепечатайте его из списка последних сканирований.", + "checkinBlockReason": "Причина: {{reason}}", + "checkinRequestError": "Не удалось завершить регистрацию. Попробуйте отсканировать ещё раз.", + "checkinRailTitle": "Недавние сканирования", + "checkinRailEmpty": "Пока нет сканирований.", + "checkinRailLoadError": "Не удалось загрузить список сканирований.", + "checkinRailReprint": "Перепечатать", + "checkinRailUndo": "Отменить", + "checkinRailDetails": "Подробнее", + "checkinRailReprintUnreachable": "Не удаётся подключиться к локальному агенту печати.", + "checkinActionCheckin": "Зарегистрирован", + "checkinActionUndo": "Отменено", + "checkinActionReprint": "Перепечатано", + "checkinReprintConfirmTitle": "Перепечатать бейдж", + "checkinReprintConfirmBody": "Напечатать бейдж {{name}} на {{printer}}?", + "checkinReprintConfirmBodyChoose": "Выберите принтер для печати бейджа {{name}}.", + "checkinReprintConfirm": "Печать", + "checkinReprintNoTemplate": "У этого мероприятия ещё нет шаблона бейджа.", + "checkinReprintMissingFont": "Отсутствует шрифт {{families}} — исправьте шаблон бейджа", + "checkinReprintOpenEditor": "Открыть редактор бейджей", + "checkinReprintError": "Не удалось отправить бейдж на принтер. Попробуйте ещё раз.", + "checkinReprintMarkPrintedWarning": "Отправлено на {{printer}}, но не удалось обновить счётчик печати.", + "checkinUndoConfirmTitle": "Отменить регистрацию", + "checkinUndoConfirmBody": "Очистить регистрацию {{name}}? Потребуется отсканировать бейдж заново.", + "checkinUndoConfirm": "Отменить регистрацию", + "checkinUndoError": "Не удалось отменить регистрацию. Попробуйте ещё раз.", + "checkinDetailsUndoneAt": "Регистрация отменена в {{time}}", + "checkinDetailsReprintedAt": "Бейдж перепечатан в {{time}}", + "checkinDegradedBanner": "Соединение нестабильно", + "checkinOfflineBlocked": "Не удаётся зарегистрировать — нет соединения.", + "checkinManualSearchReadOnlyHint": "Регистрация недоступна, пока нет соединения.", + "launchColEventTitle": "Подтвердите мероприятие и станцию", + "launchStationNameLabel": "Название станции", + "launchStationNameDefault": "Главный вход", + "launchStationNameRequired": "Укажите название станции.", + "launchZoneLabel": "Зона (необязательно)", + "launchZoneNone": "Без зоны", + "launchColSettingsTitle": "Настройки регистрации", + "launchPrintOnCheckinLabel": "Печатать бейдж при регистрации", + "launchDismissSecLabel": "Автозакрытие результата (секунд)", + "launchScanInputLabel": "Способ сканирования", + "launchScanInputWedge": "Сканер-эмулятор клавиатуры", + "launchScanInputScanner": "Ручной сканер", + "launchScanInputManual": "Поиск вручную", + "launchManualSearchLabel": "Разрешить поиск вручную", + "launchColPrinterTitle": "Проверка принтера", + "launchTestBadgeButton": "Тестовый бейдж", + "launchTestBadgeNoTemplate": "Сначала создайте шаблон бейджа, чтобы протестировать печать.", + "launchStartCheckin": "Начать регистрацию", + "launchUnsavedSettingsHint": "Сохраните настройки регистрации перед запуском.", + "launchRegisterError": "Не удалось зарегистрировать станцию. Попробуйте ещё раз." }