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>
This commit is contained in:
@@ -0,0 +1,455 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user