3973e4058d
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>
456 lines
14 KiB
Go
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
|
|
}
|