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:
+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",
|
||||
|
||||
Reference in New Issue
Block a user