Files
guestguard/internal/api/collaborators.go
T
Kwaku Danso 3973e4058d feat(tier2): multi-host / collaborators — Block C
Events can now have multiple users with distinct roles:
  owner   — manage collaborators, delete event, full access
  editor  — manage guests, tokens, CSV import, patch event
  viewer  — read-only access to everything

Schema (migration 0008)
- collaborator_role ENUM + event_collaborators + collaborator_invites
- Backfill: every existing events.host_id becomes an owner row
- EventRepo.Create seeds the owner row in the same transaction so
  no future event can exist without one

Authz
- New requireRole(eventID, userID, minRole) helper. Non-members 404;
  insufficient role 403. Replaces requireEventOwner across every
  shared-role handler (events.get/update, guests CRUD, tokens issue/
  rotate/bulk, csv preview/commit/template, activity, ws-ticket)
- events.delete + collaborator management stay owner-only
- GET /events lists every event the user has any role on
- /events/{id} response now embeds your_role for UI branching

Collaborator endpoints
- GET    /events/{id}/collaborators           (viewer+)
- POST   /events/{id}/collaborators           (owner)  — sends invite email
- PATCH  /events/{id}/collaborators/{user_id} (owner)  — role change
- DELETE /events/{id}/collaborators/{user_id} (owner)  — refuses last owner
- DELETE /events/{id}/collaborators/pending   (owner)  — cancel invite
- GET    /invites/{token}                     (public) — preview summary
- POST   /invites/{token}/accept              (authed) — atomic accept

Invitations
- SHA-256 hashed in DB; raw value only lives in the email link
- 7-day TTL, single-use, email-bound (caller's email must match)
- New SendCollaboratorInvite on auth.EmailSender + Resend/SMTP/SES
  senders + log stub; collaborator_invite.html/txt branded template

Frontend
- TeamCard.vue on the event detail page: lists collaborators with
  inline role-change + remove, pending-invites with cancel, invite
  modal (email + role). Owner-only actions hidden for editors/viewers
- /invites/[token] accept page: shows invite summary, prompts signup
  or sign-in with pre-filled email, refuses mismatched accounts

Tests (all 6 pass on the existing testcontainers harness)
- backfill: legacy host gets owner role
- role enforcement: viewer can read, editor can write guests but not
  delete/manage team, non-member 404s everywhere
- last-owner removal refused (400)
- shared events show up in collaborator's /events list
- invite flow: create → preview → accept → role granted → replay 410
- email mismatch on accept returns 403
- expired invite returns 410

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 22:14:50 +01:00

456 lines
14 KiB
Go

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/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
}
// --- 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
}
// 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)
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
}
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
}
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)
}
// --- 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
}