package api import ( "context" "encoding/json" "errors" "log/slog" "net/http" "strings" "time" "github.com/google/uuid" "github.com/alchemistkay/guestguard/internal/audit" "github.com/alchemistkay/guestguard/internal/auth" "github.com/alchemistkay/guestguard/internal/domain" "github.com/alchemistkay/guestguard/internal/storage" ) // collaboratorHandler powers the per-event Team endpoints and the public // invite-accept flow. Tier 2 Block C. type collaboratorHandler struct { logger *slog.Logger events *storage.EventRepo users *storage.UserRepo collabs *storage.CollaboratorRepo invites *storage.InviteRepo emails auth.EmailSender publicBaseURL string inviteTTL time.Duration audit *audit.Recorder enforcer *tierEnforcer } // --- responses --- type collaboratorView struct { UserID uuid.UUID `json:"user_id"` Name string `json:"name"` Email string `json:"email"` Role domain.Role `json:"role"` InvitedAt time.Time `json:"invited_at"` AcceptedAt *time.Time `json:"accepted_at,omitempty"` } type pendingInviteView struct { Email string `json:"email"` Role domain.Role `json:"role"` InvitedBy uuid.UUID `json:"invited_by"` ExpiresAt time.Time `json:"expires_at"` CreatedAt time.Time `json:"created_at"` } type listCollaboratorsResponse struct { Collaborators []collaboratorView `json:"collaborators"` Pending []pendingInviteView `json:"pending"` // YourRole tells the UI which actions to hide. Echoed from requireRole // so the client doesn't need a separate /me lookup per event view. YourRole domain.Role `json:"your_role"` } // GET /events/{id}/collaborators — viewer+ can see the team list. Pending // invites are exposed too so editors can chase up unaccepted invitations, // not just owners. func (h *collaboratorHandler) list(w http.ResponseWriter, r *http.Request) { hostID, ok := hostFromContext(w, r) if !ok { return } eventID, ok := parseIDParam(w, r, "id") if !ok { return } _, role, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleViewer) if !ok { return } members, err := h.collabs.List(r.Context(), eventID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list collaborators") return } views := make([]collaboratorView, 0, len(members)) for _, m := range members { views = append(views, collaboratorView{ UserID: m.UserID, Name: m.Name, Email: m.Email, Role: m.Role, InvitedAt: m.InvitedAt, AcceptedAt: m.AcceptedAt, }) } pending, err := h.invites.ListPendingForEvent(r.Context(), eventID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list pending invites") return } pendingViews := make([]pendingInviteView, 0, len(pending)) for _, p := range pending { pendingViews = append(pendingViews, pendingInviteView{ Email: p.Email, Role: p.Role, InvitedBy: p.InvitedBy, ExpiresAt: p.ExpiresAt, CreatedAt: p.CreatedAt, }) } writeJSON(w, http.StatusOK, listCollaboratorsResponse{ Collaborators: views, Pending: pendingViews, YourRole: role, }) } type inviteRequest struct { Email string `json:"email"` Role domain.Role `json:"role"` } type inviteResponse struct { Email string `json:"email"` Role domain.Role `json:"role"` ExpiresAt time.Time `json:"expires_at"` // Sent reports whether the email send succeeded. The invite is still // usable from the host's side either way — they can resend. Sent bool `json:"sent"` } // POST /events/{id}/collaborators — owner-only. Creates an invitation // token, emails it to the recipient. The recipient can accept whether or // not they already have a GuestGuard account. func (h *collaboratorHandler) invite(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 := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleOwner) if !ok { return } var req inviteRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid json") return } email := strings.ToLower(strings.TrimSpace(req.Email)) if email == "" || !strings.Contains(email, "@") { writeError(w, http.StatusBadRequest, "valid email required") return } if !req.Role.Valid() { writeError(w, http.StatusBadRequest, "role must be owner|editor|viewer") return } // Tier gate. Free plans cap at 0 shared collaborators; Pro at 5; // Business unlimited. We deny BEFORE minting the invite so we // don't email someone an invite that the host won't be able to // honour. if !h.enforcer.allowCollaboratorInvite(w, r, hostID, eventID) { return } // If the invitee already has an account AND is already a collaborator, // short-circuit with a friendly 409 — no email, no DB churn. if existing, err := h.users.GetByEmail(r.Context(), email); err == nil && existing != nil { if _, already, err := h.collabs.RoleFor(r.Context(), eventID, existing.ID); err == nil && already { writeError(w, http.StatusConflict, "user is already a collaborator on this event") return } } raw, hash, err := auth.NewOpaqueToken() if err != nil { writeError(w, http.StatusInternalServerError, "failed to mint invite") return } expiresAt := time.Now().UTC().Add(h.ttl()) if err := h.invites.Create(r.Context(), storage.CreateInviteParams{ EventID: eventID, Email: email, Role: req.Role, InvitedBy: hostID, TokenHash: hash, ExpiresAt: expiresAt, }); err != nil { h.logger.Error("create invite", "err", err, "event_id", eventID) writeError(w, http.StatusInternalServerError, "failed to create invite") return } // Email send is best-effort. Failure still leaves the invite in the DB // so a Resend action from the UI can retry; we report `sent: false` // so the host knows. sent := h.emailInvite(r.Context(), email, hostID, event.Name, req.Role, raw) h.audit.Record(r.Context(), audit.Params{ UserID: &hostID, EventID: &eventID, Action: "collaborator.invite", EntityType: "collaborator_invite", Metadata: map[string]any{"email": email, "role": req.Role, "email_sent": sent}, }) writeJSON(w, http.StatusCreated, inviteResponse{ Email: email, Role: req.Role, ExpiresAt: expiresAt, Sent: sent, }) } // PATCH /events/{id}/collaborators/{user_id} — owner-only. Change another // collaborator's role. Demoting the last owner returns 400. type updateRoleRequest struct { Role domain.Role `json:"role"` } func (h *collaboratorHandler) updateRole(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.RoleOwner); !ok { return } userID, ok := parseIDParam(w, r, "user_id") if !ok { return } var req updateRoleRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid json") return } if !req.Role.Valid() { writeError(w, http.StatusBadRequest, "role must be owner|editor|viewer") return } if err := h.collabs.UpdateRole(r.Context(), eventID, userID, req.Role); err != nil { switch { case errors.Is(err, domain.ErrCollaboratorNotFound): writeError(w, http.StatusNotFound, "collaborator not found") case errors.Is(err, domain.ErrLastOwner): writeError(w, http.StatusBadRequest, "cannot demote the last owner — promote someone else first") default: h.logger.Error("update role", "err", err) writeError(w, http.StatusInternalServerError, "failed to update role") } return } h.audit.Record(r.Context(), audit.Params{ UserID: &hostID, EventID: &eventID, Action: "collaborator.role_change", EntityType: "collaborator", TargetID: &userID, Metadata: map[string]any{"role": req.Role}, }) w.WriteHeader(http.StatusNoContent) } // DELETE /events/{id}/collaborators/{user_id} — owner-only. Removing the // last owner returns 400. func (h *collaboratorHandler) remove(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.RoleOwner); !ok { return } userID, ok := parseIDParam(w, r, "user_id") if !ok { return } if err := h.collabs.Remove(r.Context(), eventID, userID); err != nil { switch { case errors.Is(err, domain.ErrCollaboratorNotFound): writeError(w, http.StatusNotFound, "collaborator not found") case errors.Is(err, domain.ErrLastOwner): writeError(w, http.StatusBadRequest, "cannot remove the last owner — promote someone else first") default: h.logger.Error("remove collaborator", "err", err) writeError(w, http.StatusInternalServerError, "failed to remove collaborator") } return } h.audit.Record(r.Context(), audit.Params{ UserID: &hostID, EventID: &eventID, Action: "collaborator.remove", EntityType: "collaborator", TargetID: &userID, }) w.WriteHeader(http.StatusNoContent) } // DELETE /events/{id}/collaborators/pending — owner-only. Cancels a // still-unconsumed invite for the given email. Idempotent: no rows is a 204. func (h *collaboratorHandler) cancelInvite(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.RoleOwner); !ok { return } email := strings.TrimSpace(r.URL.Query().Get("email")) if email == "" { writeError(w, http.StatusBadRequest, "email query parameter required") return } if err := h.invites.DeletePendingByEmail(r.Context(), eventID, email); err != nil { writeError(w, http.StatusInternalServerError, "failed to cancel invite") return } w.WriteHeader(http.StatusNoContent) } // --- "your pending invites" (self-service inbox) --- // GET /me/invites — authed. Lists invites addressed to the caller's email // so the dashboard can show a one-click Accept banner without requiring // the user to re-click the email link. Crucial for the signup → verify- // email → login flow where the email click opens a new tab and the // original invite tab is forgotten. func (h *collaboratorHandler) myInvites(w http.ResponseWriter, r *http.Request) { userID, ok := hostFromContext(w, r) if !ok { return } user, err := h.users.GetByID(r.Context(), userID) if err != nil || user == nil { writeError(w, http.StatusUnauthorized, "unauthenticated") return } pending, err := h.invites.ListPendingForEmail(r.Context(), user.Email) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list invites") return } writeJSON(w, http.StatusOK, map[string]any{"invites": pending}) } // POST /me/invites/{event_id}/accept — authed. Accepts the latest pending // invite for (caller.email, event_id). Same effect as POST /invites/{token}/ // accept but doesn't require the raw token; the caller's verified email is // the identity signal instead. func (h *collaboratorHandler) acceptForEvent(w http.ResponseWriter, r *http.Request) { userID, ok := hostFromContext(w, r) if !ok { return } user, err := h.users.GetByID(r.Context(), userID) if err != nil || user == nil { writeError(w, http.StatusUnauthorized, "unauthenticated") return } eventID, ok := parseIDParam(w, r, "event_id") if !ok { return } role, err := h.collabs.AcceptByEventAndEmail(r.Context(), eventID, user.Email, userID) if err != nil { switch { case errors.Is(err, domain.ErrInviteNotFound): writeError(w, http.StatusNotFound, "no pending invitation for this event") default: h.logger.Error("accept by email", "err", err) writeError(w, http.StatusInternalServerError, "failed to accept invitation") } return } writeJSON(w, http.StatusOK, acceptResponse{EventID: eventID, Role: role}) } // --- public invite-accept flow --- type inviteSummary struct { EventID uuid.UUID `json:"event_id"` EventName string `json:"event_name"` Role domain.Role `json:"role"` Email string `json:"email"` ExpiresAt time.Time `json:"expires_at"` } // GET /invites/{token} — preview the invitation. Used by the frontend // accept page to render "Foo invited you to Bar as Editor" before the user // hits Accept. Doesn't require auth — the caller might not have an account // yet. Returns the same error codes accept does so the UI can branch // cleanly. func (h *collaboratorHandler) previewInvite(w http.ResponseWriter, r *http.Request) { inv, event, ok := h.loadInvite(w, r) if !ok { return } writeJSON(w, http.StatusOK, inviteSummary{ EventID: inv.EventID, EventName: event.Name, Role: inv.Role, Email: inv.Email, ExpiresAt: inv.ExpiresAt, }) } type acceptResponse struct { EventID uuid.UUID `json:"event_id"` Role domain.Role `json:"role"` } // POST /invites/{token}/accept — authed: the caller must be logged in as // the invitee. If the email on the invite doesn't match the caller's // account, we 403 (mirrors how multi-tenant tools handle "this link was // sent to someone else"). Single-use; token is consumed atomically with // the collaborator insert. func (h *collaboratorHandler) acceptInvite(w http.ResponseWriter, r *http.Request) { userID, ok := hostFromContext(w, r) if !ok { return } caller, err := h.users.GetByID(r.Context(), userID) if err != nil || caller == nil { writeError(w, http.StatusUnauthorized, "unauthenticated") return } inv, event, ok := h.loadInvite(w, r) if !ok { return } if !strings.EqualFold(strings.TrimSpace(caller.Email), strings.TrimSpace(inv.Email)) { writeError(w, http.StatusForbidden, "invitation was sent to a different email") return } tokenHash := auth.HashOpaque(r.PathValue("token")) if err := h.collabs.AcceptInvite(r.Context(), tokenHash, userID, inv.EventID, inv.InvitedBy, inv.Role); err != nil { switch { case errors.Is(err, domain.ErrInviteAlreadyConsumed): writeError(w, http.StatusGone, "invitation already used") case errors.Is(err, domain.ErrCollaboratorExists): // Already on the event — treat as success (idempotent accept). writeJSON(w, http.StatusOK, acceptResponse{EventID: inv.EventID, Role: inv.Role}) default: h.logger.Error("accept invite", "err", err) writeError(w, http.StatusInternalServerError, "failed to accept invite") } return } _ = event writeJSON(w, http.StatusOK, acceptResponse{EventID: inv.EventID, Role: inv.Role}) } // loadInvite validates the path token, fetches the invite (rejecting // expired/consumed/missing with the right status), and resolves the // associated event. Returns the invite + event on success. func (h *collaboratorHandler) loadInvite(w http.ResponseWriter, r *http.Request) (*domain.CollaboratorInvite, *domain.Event, bool) { raw := r.PathValue("token") if raw == "" { writeError(w, http.StatusBadRequest, "missing invite token") return nil, nil, false } inv, err := h.invites.Get(r.Context(), auth.HashOpaque(raw)) if err != nil { switch { case errors.Is(err, domain.ErrInviteNotFound): writeError(w, http.StatusNotFound, "invitation not found") case errors.Is(err, domain.ErrInviteExpired): writeError(w, http.StatusGone, "invitation expired") case errors.Is(err, domain.ErrInviteAlreadyConsumed): writeError(w, http.StatusGone, "invitation already used") default: writeError(w, http.StatusInternalServerError, "failed to load invite") } return nil, nil, false } event, err := h.events.Get(r.Context(), inv.EventID) if err != nil { if errors.Is(err, domain.ErrEventNotFound) { writeError(w, http.StatusNotFound, "event no longer exists") return nil, nil, false } writeError(w, http.StatusInternalServerError, "failed to load event") return nil, nil, false } return inv, event, true } // emailInvite dispatches the invite email via the configured sender. // Best-effort: a failure is logged + returns false so the response can flag // it; the invitation row is preserved in either case. func (h *collaboratorHandler) emailInvite(ctx context.Context, to string, inviterID uuid.UUID, eventName string, role domain.Role, raw string) bool { if h.emails == nil { return false } inviterName := "" if inv, err := h.users.GetByID(ctx, inviterID); err == nil && inv != nil { inviterName = inv.Name } link := h.acceptLink(raw) if err := h.emails.SendCollaboratorInvite(ctx, to, inviterName, eventName, string(role), link); err != nil { h.logger.Warn("send collaborator invite (continuing)", "err", err, "to", to) return false } return true } func (h *collaboratorHandler) acceptLink(raw string) string { base := h.publicBaseURL if base == "" { base = "http://localhost:3000" } return base + "/invites/" + raw } func (h *collaboratorHandler) ttl() time.Duration { if h.inviteTTL > 0 { return h.inviteTTL } return domain.DefaultInviteTTL }