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:
Kwaku Danso
2026-05-17 22:14:50 +01:00
parent 6803d700b4
commit 3973e4058d
28 changed files with 2108 additions and 32 deletions
+38 -4
View File
@@ -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",