package api import ( "context" "encoding/json" "errors" "log/slog" "net/http" "strings" "time" "github.com/google/uuid" "github.com/alchemistkay/guestguard/internal/auth" "github.com/alchemistkay/guestguard/internal/domain" "github.com/alchemistkay/guestguard/internal/natspub" "github.com/alchemistkay/guestguard/internal/storage" ) type accessPublisher interface { PublishAccessAttempted(ctx context.Context, evt natspub.AccessAttempted) error } type invitationPublisher interface { PublishInvitationSend(ctx context.Context, evt natspub.InvitationSend) error } type tokenHandler struct { logger *slog.Logger guests *storage.GuestRepo tokens *storage.TokenRepo events *storage.EventRepo users *storage.UserRepo accessLogs *storage.AccessLogRepo gen *auth.Generator ttl time.Duration pub accessPublisher invitations invitationPublisher publicBaseURL string } type issueTokenResponse struct { Token string `json:"token"` TokenID uuid.UUID `json:"token_id"` Meta *domain.Token `json:"meta"` InvitationQueued bool `json:"invitation_queued"` InvitationLink string `json:"invitation_link"` } // POST /events/{id}/guests/{guest_id}/tokens — issue a token for the guest. func (h *tokenHandler) issue(w http.ResponseWriter, r *http.Request) { hostID, ok := hostFromContext(w, r) if !ok { return } eventID, ok := parseIDParam(w, r, "id") if !ok { return } guestID, ok := parseIDParam(w, r, "guest_id") if !ok { return } event, ok := requireEventOwner(w, r, h.events, eventID, hostID) if !ok { return } guest, err := h.guests.Get(r.Context(), guestID) if err != nil { if errors.Is(err, domain.ErrGuestNotFound) { writeError(w, http.StatusNotFound, "guest not found") return } writeError(w, http.StatusInternalServerError, "failed to load guest") return } if guest.EventID != eventID { writeError(w, http.StatusNotFound, "guest not found in event") return } raw, hash, err := h.gen.Generate() if err != nil { writeError(w, http.StatusInternalServerError, "failed to generate token") return } tk, err := h.tokens.Create(r.Context(), storage.CreateTokenParams{ GuestID: guestID, TokenHash: hash, ExpiresAt: time.Now().UTC().Add(h.ttl), }) if err != nil { writeError(w, http.StatusConflict, err.Error()) return } link := h.invitationLink(raw) invitationQueued := h.queueInvitation(r.Context(), event, guest, tk, hostID, raw) writeJSON(w, http.StatusCreated, issueTokenResponse{ Token: raw, TokenID: tk.ID, Meta: tk, InvitationQueued: invitationQueued, InvitationLink: link, }) } // queueInvitation publishes an invitation.send event so the notifier can // dispatch a branded email. Best-effort: if any step fails we log and // return false rather than failing the whole token-issue request — the // host still has the raw URL in the response and can re-trigger sending. func (h *tokenHandler) queueInvitation( ctx context.Context, event *domain.Event, guest *domain.Guest, tk *domain.Token, hostID uuid.UUID, rawToken string, ) bool { if h.invitations == nil { return false } if guest.Email == nil || *guest.Email == "" { // Phone-only / nameless guests get no email — host shares the link // manually. Show that on the UI so it's not a silent surprise. return false } hostName := "" if h.users != nil { if host, err := h.users.GetByID(ctx, hostID); err == nil && host != nil { hostName = host.Name } } evt := natspub.InvitationSend{ EventID: event.ID, GuestID: guest.ID, TokenID: tk.ID, GuestName: guest.Name, GuestEmail: *guest.Email, HostName: hostName, EventName: event.Name, Venue: event.Venue, EventDate: event.EventDate, Link: h.invitationLink(rawToken), IssuedAt: time.Now().UTC(), } if err := h.invitations.PublishInvitationSend(ctx, evt); err != nil { h.logger.Warn("publish invitation.send (continuing)", "err", err, "guest_id", guest.ID) return false } return true } // invitationLink renders the public RSVP URL the guest clicks from their // inbox. publicBaseURL is the externally-reachable host (set via // GG_PUBLIC_BASE_URL); access via /rsvp/ is intentional — the // frontend page rsvp/[token].vue catches the raw token. func (h *tokenHandler) invitationLink(raw string) string { base := h.publicBaseURL if base == "" { base = "http://localhost:3000" } return base + "/rsvp/" + raw } // bulkIssueRequest is the optional JSON body for the bulk-invite call. // An empty body (or missing GuestIDs) means "every guest on the event // who doesn't already have a token". type bulkIssueRequest struct { GuestIDs []string `json:"guest_ids"` } type bulkIssueItemError struct { GuestID string `json:"guest_id"` Reason string `json:"reason"` } // bulkIssueToken is one minted invitation. The raw token is returned so // the host's UI can offer a "copy link" affordance after a bulk send // (especially for guests with no email on file) without making another // round-trip. Same data the per-guest issue endpoint already exposes, // scoped to the host who owns the event. type bulkIssueToken struct { GuestID string `json:"guest_id"` Token string `json:"token"` InvitationQueued bool `json:"invitation_queued"` InvitationLink string `json:"invitation_link"` } type bulkIssueResponse struct { Issued int `json:"issued"` Queued int `json:"queued"` SkippedExisting int `json:"skipped_existing"` SkippedNoEmail int `json:"skipped_no_email"` Tokens []bulkIssueToken `json:"tokens,omitempty"` Errors []bulkIssueItemError `json:"errors,omitempty"` } // POST /events/{id}/guests/invitations/bulk — generate tokens for every // eligible guest (or the explicit subset) on the event and queue an // invitation email for those with an address. Best-effort: any per-guest // error is reported in the response and doesn't abort the rest. func (h *tokenHandler) bulkIssue(w http.ResponseWriter, r *http.Request) { hostID, ok := hostFromContext(w, r) if !ok { return } eventID, ok := parseIDParam(w, r, "id") if !ok { return } event, ok := requireEventOwner(w, r, h.events, eventID, hostID) if !ok { return } var req bulkIssueRequest if r.ContentLength > 0 { // Body is optional; only decode when something was actually sent. if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid json") return } } var onlyIDs []uuid.UUID if len(req.GuestIDs) > 0 { onlyIDs = make([]uuid.UUID, 0, len(req.GuestIDs)) for _, raw := range req.GuestIDs { id, err := uuid.Parse(raw) if err != nil { writeError(w, http.StatusBadRequest, "invalid guest id: "+raw) return } onlyIDs = append(onlyIDs, id) } } guests, err := h.guests.ListGuestsForInvitation(r.Context(), eventID, onlyIDs) if err != nil { writeError(w, http.StatusInternalServerError, "failed to load guests") return } hostName := "" if h.users != nil { if host, err := h.users.GetByID(r.Context(), hostID); err == nil && host != nil { hostName = host.Name } } resp := bulkIssueResponse{} for _, g := range guests { if g.HasToken { resp.SkippedExisting++ continue } raw, hash, err := h.gen.Generate() if err != nil { resp.Errors = append(resp.Errors, bulkIssueItemError{GuestID: g.ID.String(), Reason: "mint token failed"}) continue } tk, err := h.tokens.Create(r.Context(), storage.CreateTokenParams{ GuestID: g.ID, TokenHash: hash, ExpiresAt: time.Now().UTC().Add(h.ttl), }) if err != nil { // Likely a race against the unique constraint (someone else // issued in parallel) — surface but don't fail the batch. resp.Errors = append(resp.Errors, bulkIssueItemError{GuestID: g.ID.String(), Reason: err.Error()}) continue } resp.Issued++ link := h.invitationLink(raw) tokenInfo := bulkIssueToken{ GuestID: g.ID.String(), Token: raw, InvitationLink: link, } if g.Email == "" { resp.SkippedNoEmail++ resp.Tokens = append(resp.Tokens, tokenInfo) continue } evt := natspub.InvitationSend{ EventID: event.ID, GuestID: g.ID, TokenID: tk.ID, GuestName: g.Name, GuestEmail: g.Email, HostName: hostName, EventName: event.Name, Venue: event.Venue, EventDate: event.EventDate, Link: h.invitationLink(raw), IssuedAt: time.Now().UTC(), } if h.invitations == nil { resp.Tokens = append(resp.Tokens, tokenInfo) continue } if err := h.invitations.PublishInvitationSend(r.Context(), evt); err != nil { h.logger.Warn("publish invitation.send (bulk, continuing)", "err", err, "guest_id", g.ID) resp.Errors = append(resp.Errors, bulkIssueItemError{GuestID: g.ID.String(), Reason: "publish failed"}) resp.Tokens = append(resp.Tokens, tokenInfo) continue } resp.Queued++ tokenInfo.InvitationQueued = true resp.Tokens = append(resp.Tokens, tokenInfo) } writeJSON(w, http.StatusOK, resp) } type rotateTokenRequest struct { // SendEmail asks the notifier to re-deliver the invitation. False // means "just give me a fresh link" — typical for phone-only guests // where the host shares the new URL via SMS. SendEmail bool `json:"send_email"` } // POST /events/{id}/guests/{guest_id}/tokens/rotate — invalidate the // guest's existing invitation link and mint a fresh one. Optionally // re-publishes invitation.send so the notifier re-delivers via email. // The old URL stops working immediately. func (h *tokenHandler) rotate(w http.ResponseWriter, r *http.Request) { hostID, ok := hostFromContext(w, r) if !ok { return } eventID, ok := parseIDParam(w, r, "id") if !ok { return } guestID, ok := parseIDParam(w, r, "guest_id") if !ok { return } event, ok := requireEventOwner(w, r, h.events, eventID, hostID) if !ok { return } guest, err := h.guests.Get(r.Context(), guestID) if err != nil { if errors.Is(err, domain.ErrGuestNotFound) { writeError(w, http.StatusNotFound, "guest not found") return } writeError(w, http.StatusInternalServerError, "failed to load guest") return } if guest.EventID != eventID { writeError(w, http.StatusNotFound, "guest not found in event") return } var req rotateTokenRequest if r.ContentLength > 0 { if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid json") return } } raw, hash, err := h.gen.Generate() if err != nil { writeError(w, http.StatusInternalServerError, "failed to generate token") return } tk, err := h.tokens.RotateForGuest(r.Context(), storage.CreateTokenParams{ GuestID: guestID, TokenHash: hash, ExpiresAt: time.Now().UTC().Add(h.ttl), }) if err != nil { h.logger.Error("rotate token", "err", err, "guest_id", guestID) writeError(w, http.StatusInternalServerError, "failed to rotate token") return } link := h.invitationLink(raw) invitationQueued := false if req.SendEmail { invitationQueued = h.queueInvitation(r.Context(), event, guest, tk, hostID, raw) } writeJSON(w, http.StatusOK, issueTokenResponse{ Token: raw, TokenID: tk.ID, Meta: tk, InvitationQueued: invitationQueued, InvitationLink: link, }) } type accessResponse struct { Guest *domain.Guest `json:"guest"` Event *domain.Event `json:"event"` Token *domain.Token `json:"token"` AccessLog uuid.UUID `json:"access_log_id"` } // GET /access/{token} — validate token, log the access attempt, publish to NATS. func (h *tokenHandler) access(w http.ResponseWriter, r *http.Request) { raw := r.PathValue("token") if err := auth.ValidateFormat(raw); err != nil { writeError(w, http.StatusBadRequest, "malformed token") return } tk, err := h.tokens.GetByHash(r.Context(), auth.HashToken(raw)) if err != nil { if errors.Is(err, domain.ErrTokenNotFound) { writeError(w, http.StatusNotFound, "token not found") return } writeError(w, http.StatusInternalServerError, "failed to load token") return } if err := tk.IsValid(time.Now().UTC()); err != nil { writeError(w, http.StatusGone, err.Error()) return } guest, err := h.guests.Get(r.Context(), tk.GuestID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to load guest") return } event, err := h.events.Get(r.Context(), guest.EventID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to load event") return } fingerprint := collectFingerprint(r) ip := clientIP(r) accessLogID, err := h.accessLogs.Create(r.Context(), storage.CreateAccessLogParams{ GuestID: guest.ID, TokenID: tk.ID, Fingerprint: fingerprint, IPAddress: ip, }) if err != nil { h.logger.Error("create access log", "err", err) } go func(evt natspub.AccessAttempted) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := h.pub.PublishAccessAttempted(ctx, evt); err != nil { h.logger.Error("publish access.attempted", "err", err, "guest_id", evt.GuestID) } }(natspub.AccessAttempted{ EventID: event.ID, GuestID: guest.ID, TokenID: tk.ID, AccessLogID: accessLogID, Fingerprint: fingerprint, IPAddress: ip, UserAgent: r.UserAgent(), Referrer: r.Referer(), OccurredAt: time.Now().UTC(), }) writeJSON(w, http.StatusOK, accessResponse{ Guest: guest, Event: event, Token: tk, AccessLog: accessLogID, }) } func collectFingerprint(r *http.Request) map[string]any { fp := map[string]any{ "user_agent": r.UserAgent(), "accept_language": r.Header.Get("Accept-Language"), "accept_encoding": r.Header.Get("Accept-Encoding"), } if v := r.Header.Get("Sec-CH-UA-Platform"); v != "" { fp["platform"] = v } if v := r.Header.Get("X-Device-Fingerprint"); v != "" { fp["client_fingerprint"] = v } return fp } func clientIP(r *http.Request) string { if xff := r.Header.Get("X-Forwarded-For"); xff != "" { if i := strings.IndexByte(xff, ','); i > 0 { return strings.TrimSpace(xff[:i]) } return strings.TrimSpace(xff) } if xr := r.Header.Get("X-Real-IP"); xr != "" { return strings.TrimSpace(xr) } host := r.RemoteAddr if i := strings.LastIndexByte(host, ':'); i > 0 { host = host[:i] } return host }