package api import ( "encoding/base64" "encoding/json" "errors" "log/slog" "net/http" "strings" "github.com/google/uuid" "github.com/skip2/go-qrcode" "github.com/alchemistkay/guestguard/internal/auth" "github.com/alchemistkay/guestguard/internal/domain" "github.com/alchemistkay/guestguard/internal/storage" ) // checkInHandler is the host-facing surface for Tier 2 Block H: scan QR // codes at the door, record arrivals, register walk-ins, drive the // live arrivals counter on the dashboard. type checkInHandler struct { logger *slog.Logger events *storage.EventRepo guests *storage.GuestRepo collabs *storage.CollaboratorRepo repo *storage.CheckInRepo qrSigner *auth.CheckInQRSigner hub *Hub } // --- record a check-in (QR-scanner POST) --- type recordCheckInRequest struct { QRPayload string `json:"qr_payload"` ArrivalCount int `json:"arrival_count"` Notes string `json:"notes"` } type recordCheckInResponse struct { CheckIn domain.CheckIn `json:"check_in"` Guest *domain.Guest `json:"guest"` Summary domain.CheckInSummary `json:"summary"` } // POST /events/{id}/check-in — editor+. The scanner UI submits the // decoded QR payload plus how many people in the party actually walked // in. Duplicate scans surface as 409 with a friendly "already in" // message; that's how the scanner UI knows to flash orange instead of // green. func (h *checkInHandler) record(w http.ResponseWriter, r *http.Request) { hostID, ok := hostFromContext(w, r) if !ok { return } eventID, ok := parseIDParam(w, r, "id") if !ok { return } if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleEditor); !ok { return } var req recordCheckInRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid json") return } req.QRPayload = strings.TrimSpace(req.QRPayload) if req.QRPayload == "" { writeError(w, http.StatusBadRequest, "qr_payload is required") return } claims, err := h.qrSigner.Parse(req.QRPayload) if err != nil { switch { case errors.Is(err, auth.ErrExpiredJWT): writeError(w, http.StatusGone, "this QR has expired") default: writeError(w, http.StatusBadRequest, "invalid QR") } return } if claims.EventID != eventID { // JWT was issued for a different event. The scanner may have // roamed; the host should switch event pages. writeError(w, http.StatusBadRequest, "this QR belongs to a different event") return } // Sanity: the guest still belongs to this event (host may have // removed them after issuing the QR). belongs, err := h.repo.GuestBelongsToEvent(r.Context(), claims.GuestID, eventID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to verify guest") return } if !belongs { writeError(w, http.StatusNotFound, "guest is no longer on this event") return } ci, err := h.repo.Record(r.Context(), storage.RecordCheckInParams{ GuestID: claims.GuestID, CheckedInBy: hostID, ArrivalCount: req.ArrivalCount, Notes: req.Notes, WalkIn: false, }) if err != nil { if errors.Is(err, domain.ErrAlreadyCheckedIn) { writeError(w, http.StatusConflict, "this guest is already checked in") return } h.logger.Error("record check-in", "err", err) writeError(w, http.StatusInternalServerError, "failed to record check-in") return } guest, _ := h.guests.Get(r.Context(), claims.GuestID) summary, _ := h.repo.Summary(r.Context(), eventID) // Broadcast to the live arrivals dashboard. h.broadcast(eventID, "check_in.recorded", map[string]any{ "check_in": ci, "guest_id": ci.GuestID, "guest_name": nameOf(guest), "arrived_headcount": summary.ArrivedHeadcount, "expected_headcount": summary.ExpectedHeadcount, "guests_checked_in": summary.GuestsCheckedIn, }) writeJSON(w, http.StatusOK, recordCheckInResponse{ CheckIn: *ci, Guest: guest, Summary: summary, }) } // --- walk-ins --- type walkInRequest struct { Name string `json:"name"` Email string `json:"email"` Phone string `json:"phone"` ArrivalCount int `json:"arrival_count"` Notes string `json:"notes"` } // POST /events/{id}/walk-ins — editor+. Creates the guest + check-in // row in one logical operation so the door volunteer doesn't have to // fumble between two screens for a party-crasher who was meant to be // invited. func (h *checkInHandler) walkIn(w http.ResponseWriter, r *http.Request) { hostID, ok := hostFromContext(w, r) if !ok { return } eventID, ok := parseIDParam(w, r, "id") if !ok { return } if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleEditor); !ok { return } var req walkInRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid json") return } req.Name = strings.TrimSpace(req.Name) if req.Name == "" { writeError(w, http.StatusBadRequest, "name is required") return } if req.ArrivalCount <= 0 { req.ArrivalCount = 1 } emailPtr := optStr(req.Email) phonePtr := optStr(req.Phone) guest, err := h.guests.Create(r.Context(), storage.CreateGuestParams{ EventID: eventID, Name: req.Name, Email: emailPtr, Phone: phonePtr, PlusOnes: req.ArrivalCount - 1, }) if err != nil { h.logger.Error("create walk-in guest", "err", err) writeError(w, http.StatusInternalServerError, "failed to create guest") return } ci, err := h.repo.Record(r.Context(), storage.RecordCheckInParams{ GuestID: guest.ID, CheckedInBy: hostID, ArrivalCount: req.ArrivalCount, Notes: req.Notes, WalkIn: true, }) if err != nil { h.logger.Error("record walk-in check-in", "err", err) writeError(w, http.StatusInternalServerError, "failed to record check-in") return } summary, _ := h.repo.Summary(r.Context(), eventID) h.broadcast(eventID, "check_in.recorded", map[string]any{ "check_in": ci, "guest_id": ci.GuestID, "guest_name": guest.Name, "walk_in": true, "arrived_headcount": summary.ArrivedHeadcount, "expected_headcount": summary.ExpectedHeadcount, "guests_checked_in": summary.GuestsCheckedIn, }) writeJSON(w, http.StatusCreated, recordCheckInResponse{ CheckIn: *ci, Guest: guest, Summary: summary, }) } // --- list + summary --- // GET /events/{id}/check-ins — viewer+. func (h *checkInHandler) list(w http.ResponseWriter, r *http.Request) { hostID, ok := hostFromContext(w, r) if !ok { return } eventID, ok := parseIDParam(w, r, "id") if !ok { return } if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleViewer); !ok { return } rows, err := h.repo.ListByEvent(r.Context(), eventID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list check-ins") return } summary, _ := h.repo.Summary(r.Context(), eventID) writeJSON(w, http.StatusOK, map[string]any{ "check_ins": rows, "summary": summary, }) } // --- helpers --- func (h *checkInHandler) broadcast(eventID uuid.UUID, evtType string, payload any) { if h.hub == nil { return } body, err := json.Marshal(payload) if err != nil { h.logger.Warn("marshal check-in ws event", "err", err) return } h.hub.Broadcast(WSEvent{ Type: evtType, EventID: eventID, Payload: body, }) } func optStr(s string) *string { t := strings.TrimSpace(s) if t == "" { return nil } return &t } func nameOf(g *domain.Guest) string { if g == nil { return "" } return g.Name } // renderQRPNG converts a JWT-shaped string into a base64-encoded PNG // data URL the frontend can drop straight into an . Used by // the access response so a successful RSVP comes back with the guest's // scannable code already rendered. func renderQRPNG(payload string) (string, error) { png, err := qrcode.Encode(payload, qrcode.Medium, 320) if err != nil { return "", err } return "data:image/png;base64," + base64.StdEncoding.EncodeToString(png), nil }