package api import ( "context" "encoding/base64" "encoding/json" "errors" "log/slog" "net/http" "net/url" "strings" "time" "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/flags" "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 scannerSigner *auth.ScannerJWTSigner publicBaseURL string hub *Hub enforcer *tierEnforcer flags *flags.Store } // --- preview a check-in (POST /events/{id}/check-in/preview) --- // // The scanner submits the decoded QR string; we validate the JWT and // return the guest plus what we already know about them — name, // expected party size, and whether they've already been recorded. // No DB writes. The frontend uses this to render the // "Just them / +1 / +2 / Other" confirmation step before committing // the check-in, matching how Eventbrite / Lu.ma scanners handle // plus-ones at the door. type previewCheckInRequest struct { QRPayload string `json:"qr_payload"` } type previewCheckInResponse struct { Guest *domain.Guest `json:"guest"` ExpectedPartySize int `json:"expected_party_size"` AlreadyCheckedIn bool `json:"already_checked_in"` ExistingCheckIn *domain.CheckIn `json:"existing_check_in,omitempty"` } func (h *checkInHandler) preview(w http.ResponseWriter, r *http.Request) { hostID, ok := hostFromContext(w, r) if !ok { return } eventID, ok := parseIDParam(w, r, "id") if !ok { return } if !requireScannerEventMatch(w, r, eventID) { return } if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleEditor); !ok { return } var req previewCheckInRequest 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, status, msg := h.parseCheckInQR(r.Context(), req.QRPayload, eventID) if status != 0 { writeError(w, status, msg) return } guest, gerr := h.guests.Get(r.Context(), claims.GuestID) if gerr != nil || guest == nil { writeError(w, http.StatusNotFound, "guest not found") return } // arrival_count counts the guest plus their plus-ones; the door // volunteer most often presses the matching number, so this is what // drives the default-highlight in the UI. expected := guest.PlusOnes + 1 existing, _ := h.repo.GetByGuest(r.Context(), guest.ID) resp := previewCheckInResponse{ Guest: guest, ExpectedPartySize: expected, AlreadyCheckedIn: existing != nil, ExistingCheckIn: existing, } writeJSON(w, http.StatusOK, resp) } // parseCheckInQR centralises the JWT validation + event-binding + // guest-belongs-to-event checks that both preview and record need. // Returns (claims, 0, "") on success or (nil, statusCode, message) on // the recoverable error paths so the caller writes a single response. func (h *checkInHandler) parseCheckInQR(ctx context.Context, payload string, eventID uuid.UUID) (*auth.CheckInQR, int, string) { claims, err := h.qrSigner.Parse(payload) if err != nil { if errors.Is(err, auth.ErrExpiredJWT) { return nil, http.StatusGone, "this QR has expired" } return nil, http.StatusBadRequest, "invalid QR" } if claims.EventID != eventID { return nil, http.StatusBadRequest, "this QR belongs to a different event" } belongs, err := h.repo.GuestBelongsToEvent(ctx, claims.GuestID, eventID) if err != nil { return nil, http.StatusInternalServerError, "failed to verify guest" } if !belongs { return nil, http.StatusNotFound, "guest is no longer on this event" } return claims, 0, "" } // --- 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 !requireScannerEventMatch(w, r, eventID) { 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, status, msg := h.parseCheckInQR(r.Context(), req.QRPayload, eventID) if status != 0 { writeError(w, status, msg) 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 !requireScannerEventMatch(w, r, eventID) { 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 !requireScannerEventMatch(w, r, eventID) { 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 } // --- scanner magic-link (POST /events/{id}/scanner-ticket) --- type scannerTicketResponse struct { Token string `json:"token"` URL string `json:"url"` QRImage string `json:"qr_image"` // data: URL PNG ExpiresAt time.Time `json:"expires_at"` } // issueScannerTicket mints a short-lived, event-scoped JWT that a host // can render into a QR code on their desktop event-detail page. The // door volunteer scans that QR with their phone camera, the scanner // page reads ?token= out of the URL, and uses it as a Bearer for // the three check-in endpoints — no separate phone login required. // // Editor+ on the event. Rate-limited at the route level. Tickets last // four hours by default (see NewScannerJWTSigner), long enough for a // full event without the host having to re-mint mid-night. func (h *checkInHandler) issueScannerTicket(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 } // Kill switch — ops can disable scanner ticket minting without a // redeploy if the magic-link UX needs to be pulled back. if !h.flags.Enabled("checkin_pwa", hostID) { writeError(w, http.StatusServiceUnavailable, "the scanner is temporarily disabled") return } // The day-of scanner is a Pro+ feature. Free-tier hosts can still // see arrivals (the headline widget on the overview reads // /check-ins which is viewer+) but minting a magic link for a // volunteer's phone is an upsell lever. if !h.enforcer.allowFeature(w, r, hostID, "scanner", "The day-of check-in scanner is a Pro feature.") { return } if h.scannerSigner == nil { writeError(w, http.StatusServiceUnavailable, "scanner tickets are not configured") return } tok, exp, err := h.scannerSigner.Issue(hostID, eventID, time.Now()) if err != nil { h.logger.Error("issue scanner ticket", "err", err, "event_id", eventID) writeError(w, http.StatusInternalServerError, "failed to issue ticket") return } // publicBaseURL is the front-of-house origin (e.g. https://guestguard.k4scloud.com). // We embed the event id as well as the token so the scanner page can // load the right event metadata before any check-in roundtrip. base := strings.TrimRight(h.publicBaseURL, "/") if base == "" { base = "" } scannerURL := base + "/scanner?token=" + url.QueryEscape(tok) + "&event=" + url.QueryEscape(eventID.String()) qrImage, err := renderQRPNG(scannerURL) if err != nil { h.logger.Error("render scanner QR", "err", err) writeError(w, http.StatusInternalServerError, "failed to render QR") return } writeJSON(w, http.StatusOK, scannerTicketResponse{ Token: tok, URL: scannerURL, QRImage: qrImage, ExpiresAt: exp, }) } // 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 }