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:
@@ -5,6 +5,7 @@ import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/alchemistkay/guestguard/internal/domain"
|
||||
"github.com/alchemistkay/guestguard/internal/storage"
|
||||
)
|
||||
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
// who weren't watching when activity happened.
|
||||
type activityHandler struct {
|
||||
events *storage.EventRepo
|
||||
collabs *storage.CollaboratorRepo
|
||||
rsvps *storage.RSVPRepo
|
||||
accessLogs *storage.AccessLogRepo
|
||||
}
|
||||
@@ -48,7 +50,7 @@ func (h *activityHandler) list(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, ok := requireEventOwner(w, r, h.events, eventID, hostID); !ok {
|
||||
if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleViewer); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,11 @@ func hostFromContext(w http.ResponseWriter, r *http.Request) (uuid.UUID, bool) {
|
||||
// On mismatch (or missing event) it returns 404 — never 403 — so a cross-
|
||||
// tenant probe cannot tell the difference between "event doesn't exist" and
|
||||
// "exists but belongs to someone else".
|
||||
//
|
||||
// Pre-Block-C this checked the events.host_id column directly. Block C
|
||||
// preserves the same semantics for the owner-only handlers (events.Update,
|
||||
// events.Delete, collaborator management) — but for shared-role endpoints,
|
||||
// callers should use requireRole instead.
|
||||
func requireEventOwner(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
@@ -42,3 +47,47 @@ func requireEventOwner(
|
||||
}
|
||||
return ev, true
|
||||
}
|
||||
|
||||
// requireRole is the Block C authz gate. It resolves the user's role on the
|
||||
// event and confirms it's at least `min`. Missing role → 404 (avoids leaking
|
||||
// existence to outsiders). Role-too-low → 403 (the user IS on the event,
|
||||
// just not privileged enough — that's safe to surface; the UI will hide the
|
||||
// action anyway).
|
||||
//
|
||||
// On success it returns the event and the user's role so the handler can
|
||||
// branch on owner-only behaviours without a second lookup.
|
||||
func requireRole(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
events *storage.EventRepo,
|
||||
collabs *storage.CollaboratorRepo,
|
||||
eventID, userID uuid.UUID,
|
||||
min domain.Role,
|
||||
) (*domain.Event, domain.Role, bool) {
|
||||
role, ok, err := collabs.RoleFor(r.Context(), eventID, userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to resolve role")
|
||||
return nil, "", false
|
||||
}
|
||||
if !ok {
|
||||
// Not a collaborator. Treat the same way the legacy host_id check
|
||||
// did: 404, no leak.
|
||||
writeError(w, http.StatusNotFound, "event not found")
|
||||
return nil, "", false
|
||||
}
|
||||
if !role.AtLeast(min) {
|
||||
writeError(w, http.StatusForbidden, "insufficient role for this action")
|
||||
return nil, "", false
|
||||
}
|
||||
ev, err := events.Get(r.Context(), eventID)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrEventNotFound) {
|
||||
// Race: the event was deleted between the role lookup and now.
|
||||
writeError(w, http.StatusNotFound, "event not found")
|
||||
return nil, "", false
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "failed to load event")
|
||||
return nil, "", false
|
||||
}
|
||||
return ev, role, true
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/alchemistkay/guestguard/internal/csvimport"
|
||||
"github.com/alchemistkay/guestguard/internal/domain"
|
||||
"github.com/alchemistkay/guestguard/internal/storage"
|
||||
)
|
||||
|
||||
@@ -18,6 +19,7 @@ const (
|
||||
type csvImportHandler struct {
|
||||
guests *storage.GuestRepo
|
||||
events *storage.EventRepo
|
||||
collabs *storage.CollaboratorRepo
|
||||
enforcer *tierEnforcer
|
||||
}
|
||||
|
||||
@@ -46,7 +48,7 @@ func (h *csvImportHandler) preview(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, ok := requireEventOwner(w, r, h.events, eventID, hostID); !ok {
|
||||
if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleEditor); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -80,7 +82,7 @@ func (h *csvImportHandler) commit(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, ok := requireEventOwner(w, r, h.events, eventID, hostID); !ok {
|
||||
if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleEditor); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -141,7 +143,7 @@ func (h *csvImportHandler) template(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, ok := requireEventOwner(w, r, h.events, eventID, hostID); !ok {
|
||||
if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleEditor); !ok {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||
|
||||
+31
-5
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
type eventHandler struct {
|
||||
repo *storage.EventRepo
|
||||
collabs *storage.CollaboratorRepo
|
||||
enforcer *tierEnforcer
|
||||
}
|
||||
|
||||
@@ -90,6 +91,13 @@ func (h *eventHandler) create(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusCreated, ev)
|
||||
}
|
||||
|
||||
// eventView wraps an Event with the caller's role so the dashboard UI can
|
||||
// branch (e.g. hide the "Delete event" button for editors/viewers).
|
||||
type eventView struct {
|
||||
*domain.Event
|
||||
YourRole domain.Role `json:"your_role"`
|
||||
}
|
||||
|
||||
func (h *eventHandler) get(w http.ResponseWriter, r *http.Request) {
|
||||
hostID, ok := hostFromContext(w, r)
|
||||
if !ok {
|
||||
@@ -99,11 +107,11 @@ func (h *eventHandler) get(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ev, ok := requireEventOwner(w, r, h.repo, id, hostID)
|
||||
ev, role, ok := requireRole(w, r, h.repo, h.collabs, id, hostID, domain.RoleViewer)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, ev)
|
||||
writeJSON(w, http.StatusOK, eventView{Event: ev, YourRole: role})
|
||||
}
|
||||
|
||||
func (h *eventHandler) list(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -116,7 +124,15 @@ func (h *eventHandler) list(w http.ResponseWriter, r *http.Request) {
|
||||
limit := atoiOr(q.Get("limit"), 50)
|
||||
offset := atoiOr(q.Get("offset"), 0)
|
||||
|
||||
events, err := h.repo.List(r.Context(), hostID, limit, offset)
|
||||
// Block C: the dashboard shows every event the user has any role on,
|
||||
// not just events they own. The collaborators repo gives us the id set;
|
||||
// the events repo paginates the merged list.
|
||||
collabIDs, err := h.collabs.ListEventIDsForUser(r.Context(), hostID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to resolve memberships")
|
||||
return
|
||||
}
|
||||
events, err := h.repo.ListForUser(r.Context(), hostID, collabIDs, limit, offset)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list events")
|
||||
return
|
||||
@@ -150,6 +166,11 @@ func (h *eventHandler) update(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// Block C: editor+ can patch event metadata. The plan reserves DELETE
|
||||
// (and the existing host_id row) for owners only.
|
||||
if _, _, ok := requireRole(w, r, h.repo, h.collabs, id, hostID, domain.RoleEditor); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req updateEventRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
@@ -180,7 +201,7 @@ func (h *eventHandler) update(w http.ResponseWriter, r *http.Request) {
|
||||
params.Status = &s
|
||||
}
|
||||
|
||||
ev, err := h.repo.Update(r.Context(), id, hostID, params)
|
||||
ev, err := h.repo.UpdateByID(r.Context(), id, params)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrEventNotFound):
|
||||
@@ -204,7 +225,12 @@ func (h *eventHandler) delete(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.repo.Delete(r.Context(), id, hostID); err != nil {
|
||||
// Block C: only owners can delete an event. Editors get 403; viewers
|
||||
// and non-members get 404.
|
||||
if _, _, ok := requireRole(w, r, h.repo, h.collabs, id, hostID, domain.RoleOwner); !ok {
|
||||
return
|
||||
}
|
||||
if err := h.repo.DeleteByID(r.Context(), id); err != nil {
|
||||
if errors.Is(err, domain.ErrEventNotFound) {
|
||||
writeError(w, http.StatusNotFound, "event not found")
|
||||
return
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
type guestHandler struct {
|
||||
guests *storage.GuestRepo
|
||||
events *storage.EventRepo
|
||||
collabs *storage.CollaboratorRepo
|
||||
enforcer *tierEnforcer
|
||||
}
|
||||
|
||||
@@ -33,7 +34,7 @@ func (h *guestHandler) create(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, ok := requireEventOwner(w, r, h.events, eventID, hostID); !ok {
|
||||
if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleEditor); !ok {
|
||||
return
|
||||
}
|
||||
if !h.enforcer.allowGuestCreate(w, r, hostID, eventID) {
|
||||
@@ -93,7 +94,7 @@ func (h *guestHandler) update(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, ok := requireEventOwner(w, r, h.events, eventID, hostID); !ok {
|
||||
if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleEditor); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -143,7 +144,7 @@ func (h *guestHandler) delete(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, ok := requireEventOwner(w, r, h.events, eventID, hostID); !ok {
|
||||
if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleEditor); !ok {
|
||||
return
|
||||
}
|
||||
if err := h.guests.Delete(r.Context(), eventID, guestID); err != nil {
|
||||
@@ -166,7 +167,9 @@ func (h *guestHandler) list(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, ok := requireEventOwner(w, r, h.events, eventID, hostID); !ok {
|
||||
// Listing guests is viewer+. Editing is gated separately at the
|
||||
// PATCH/POST/DELETE call sites.
|
||||
if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleViewer); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
+38
-4
@@ -36,6 +36,7 @@ type Server struct {
|
||||
billing *billingHandler
|
||||
stripeWH *stripeWebhookHandler
|
||||
privacy *privacyHandler
|
||||
collabs *collaboratorHandler
|
||||
}
|
||||
|
||||
type ServerDeps struct {
|
||||
@@ -84,6 +85,8 @@ func NewServer(deps ServerDeps) (*Server, error) {
|
||||
rsvpRepo := storage.NewRSVPRepo(deps.DB)
|
||||
accessRepo := storage.NewAccessLogRepo(deps.DB)
|
||||
userRepo := storage.NewUserRepo(deps.DB)
|
||||
collabRepo := storage.NewCollaboratorRepo(deps.DB)
|
||||
inviteRepo := storage.NewInviteRepo(deps.DB)
|
||||
verifRepo := storage.NewEmailVerificationRepo(deps.DB)
|
||||
resetRepo := storage.NewPasswordResetRepo(deps.DB)
|
||||
refreshRepo := storage.NewRefreshTokenRepo(deps.DB)
|
||||
@@ -152,8 +155,8 @@ func NewServer(deps ServerDeps) (*Server, error) {
|
||||
hub: hub,
|
||||
authH: authH,
|
||||
me: &meHandler{users: userRepo},
|
||||
events: &eventHandler{repo: eventRepo, enforcer: enforcer},
|
||||
guests: &guestHandler{guests: guestRepo, events: eventRepo, enforcer: enforcer},
|
||||
events: &eventHandler{repo: eventRepo, collabs: collabRepo, enforcer: enforcer},
|
||||
guests: &guestHandler{guests: guestRepo, events: eventRepo, collabs: collabRepo, enforcer: enforcer},
|
||||
tokens: &tokenHandler{
|
||||
logger: deps.Logger,
|
||||
guests: guestRepo,
|
||||
@@ -162,6 +165,7 @@ func NewServer(deps ServerDeps) (*Server, error) {
|
||||
users: userRepo,
|
||||
accessLogs: accessRepo,
|
||||
rsvps: rsvpRepo,
|
||||
collabs: collabRepo,
|
||||
gen: auth.NewGenerator(),
|
||||
ttl: deps.TokenTTL,
|
||||
pub: deps.AccessPublisher,
|
||||
@@ -180,11 +184,12 @@ func NewServer(deps ServerDeps) (*Server, error) {
|
||||
},
|
||||
activity: &activityHandler{
|
||||
events: eventRepo,
|
||||
collabs: collabRepo,
|
||||
rsvps: rsvpRepo,
|
||||
accessLogs: accessRepo,
|
||||
},
|
||||
ws: &wsHandler{logger: deps.Logger, hub: hub, tickets: wsTickets},
|
||||
wsTicket: &wsTicketHandler{tickets: wsTickets, events: eventRepo},
|
||||
wsTicket: &wsTicketHandler{tickets: wsTickets, events: eventRepo, collabs: collabRepo},
|
||||
health: &healthHandler{pool: deps.DB.Pool},
|
||||
signer: signer,
|
||||
limiter: limiter,
|
||||
@@ -198,7 +203,7 @@ func NewServer(deps ServerDeps) (*Server, error) {
|
||||
notifs: deps.NotificationRepo,
|
||||
suppress: deps.SuppressionRepo,
|
||||
},
|
||||
csv: &csvImportHandler{guests: guestRepo, events: eventRepo, enforcer: enforcer},
|
||||
csv: &csvImportHandler{guests: guestRepo, events: eventRepo, collabs: collabRepo, enforcer: enforcer},
|
||||
billing: &billingHandler{
|
||||
logger: deps.Logger,
|
||||
stripe: deps.StripeClient,
|
||||
@@ -211,6 +216,15 @@ func NewServer(deps ServerDeps) (*Server, error) {
|
||||
stripe: deps.StripeClient,
|
||||
subs: subRepo,
|
||||
},
|
||||
collabs: &collaboratorHandler{
|
||||
logger: deps.Logger,
|
||||
events: eventRepo,
|
||||
users: userRepo,
|
||||
collabs: collabRepo,
|
||||
invites: inviteRepo,
|
||||
emails: emails,
|
||||
publicBaseURL: deps.PublicBaseURL,
|
||||
},
|
||||
privacy: &privacyHandler{
|
||||
logger: deps.Logger,
|
||||
users: userRepo,
|
||||
@@ -293,6 +307,26 @@ func (s *Server) Handler() http.Handler {
|
||||
|
||||
mux.Handle("GET /events/{id}/activity", authed(http.HandlerFunc(s.activity.list)))
|
||||
|
||||
// Block C — collaborators (multi-host). All under /events/{id}/collaborators.
|
||||
// requireRole inside each handler enforces the right minimum role.
|
||||
mux.Handle("GET /events/{id}/collaborators",
|
||||
authed(http.HandlerFunc(s.collabs.list)))
|
||||
mux.Handle("POST /events/{id}/collaborators",
|
||||
authed(rl("collab_invite", 50, 24*time.Hour, userIDKey, http.HandlerFunc(s.collabs.invite))))
|
||||
mux.Handle("PATCH /events/{id}/collaborators/{user_id}",
|
||||
authed(http.HandlerFunc(s.collabs.updateRole)))
|
||||
mux.Handle("DELETE /events/{id}/collaborators/{user_id}",
|
||||
authed(http.HandlerFunc(s.collabs.remove)))
|
||||
mux.Handle("DELETE /events/{id}/collaborators/pending",
|
||||
authed(http.HandlerFunc(s.collabs.cancelInvite)))
|
||||
|
||||
// Invite acceptance — preview is unauthed (the invitee may not be
|
||||
// logged in yet); accept requires auth (the caller's account must
|
||||
// exist + match the invited email).
|
||||
mux.HandleFunc("GET /invites/{token}", s.collabs.previewInvite)
|
||||
mux.Handle("POST /invites/{token}/accept",
|
||||
authed(http.HandlerFunc(s.collabs.acceptInvite)))
|
||||
|
||||
mux.Handle("POST /events/{id}/guests/{guest_id}/tokens",
|
||||
authed(rl("tokens_issue", 500, 24*time.Hour, userIDKey, http.HandlerFunc(s.tokens.issue))))
|
||||
mux.Handle("POST /events/{id}/guests/{guest_id}/tokens/rotate",
|
||||
|
||||
@@ -34,6 +34,7 @@ type tokenHandler struct {
|
||||
users *storage.UserRepo
|
||||
accessLogs *storage.AccessLogRepo
|
||||
rsvps *storage.RSVPRepo
|
||||
collabs *storage.CollaboratorRepo
|
||||
gen *auth.Generator
|
||||
ttl time.Duration
|
||||
pub accessPublisher
|
||||
@@ -63,7 +64,7 @@ func (h *tokenHandler) issue(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
event, ok := requireEventOwner(w, r, h.events, eventID, hostID)
|
||||
event, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleEditor)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -214,7 +215,7 @@ func (h *tokenHandler) bulkIssue(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
event, ok := requireEventOwner(w, r, h.events, eventID, hostID)
|
||||
event, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleEditor)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -344,7 +345,7 @@ func (h *tokenHandler) rotate(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
event, ok := requireEventOwner(w, r, h.events, eventID, hostID)
|
||||
event, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleEditor)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -5,12 +5,14 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/alchemistkay/guestguard/internal/domain"
|
||||
"github.com/alchemistkay/guestguard/internal/storage"
|
||||
)
|
||||
|
||||
type wsTicketHandler struct {
|
||||
tickets *wsTicketStore
|
||||
events *storage.EventRepo
|
||||
collabs *storage.CollaboratorRepo
|
||||
}
|
||||
|
||||
type wsTicketResponse struct {
|
||||
@@ -38,7 +40,9 @@ func (h *wsTicketHandler) issue(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, ok := requireEventOwner(w, r, h.events, eventID, hostID); !ok {
|
||||
// Block C: viewers can subscribe to the live monitor too — the WS stream
|
||||
// only carries read-only events (RSVPs, fraud scores, check-ins).
|
||||
if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleViewer); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user