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
+93 -3
View File
@@ -44,12 +44,22 @@ func (r *EventRepo) Create(ctx context.Context, p CreateEventParams) (*domain.Ev
return nil, fmt.Errorf("marshal settings: %w", err)
}
// Block C: every new event needs a row in event_collaborators pointing
// the host at the owner role. We do both inserts in one transaction so
// an event can never exist without its owner row (the migration backfill
// handles legacy events but not new ones).
tx, err := r.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
const q = `
INSERT INTO events (host_id, name, slug, event_date, venue, max_capacity, settings, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, host_id, name, slug, event_date, venue, max_capacity, settings, status, created_at, updated_at
`
row := r.pool.QueryRow(ctx, q,
row := tx.QueryRow(ctx, q,
p.HostID, p.Name, p.Slug, p.EventDate, p.Venue, p.MaxCapacity, settingsJSON, p.Status,
)
ev, err := scanEvent(row)
@@ -60,6 +70,17 @@ func (r *EventRepo) Create(ctx context.Context, p CreateEventParams) (*domain.Ev
}
return nil, err
}
if _, err := tx.Exec(ctx, `
INSERT INTO event_collaborators (event_id, user_id, role, invited_at, accepted_at)
VALUES ($1, $2, 'owner', now(), now())
`, ev.ID, p.HostID); err != nil {
return nil, fmt.Errorf("seed owner collaborator: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return ev, nil
}
@@ -96,6 +117,42 @@ func (r *EventRepo) GetForHost(ctx context.Context, id, hostID uuid.UUID) (*doma
return ev, nil
}
// ListForUser returns every event the user has any accepted role on. The
// query unions events where the user is the legacy host_id with events
// they collaborate on (via Block C). Duplicates are deduped on event id.
// Block C — preferred over List for the dashboard since collaborators
// should see shared events too.
func (r *EventRepo) ListForUser(ctx context.Context, userID uuid.UUID, collabEventIDs []uuid.UUID, limit, offset int) ([]*domain.Event, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
if offset < 0 {
offset = 0
}
rows, err := r.pool.Query(ctx, `
SELECT DISTINCT
id, host_id, name, slug, event_date, venue, max_capacity, settings, status, created_at, updated_at
FROM events
WHERE host_id = $1
OR id = ANY($2::uuid[])
ORDER BY created_at DESC
LIMIT $3 OFFSET $4
`, userID, collabEventIDs, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*domain.Event
for rows.Next() {
ev, err := scanEvent(rows)
if err != nil {
return nil, err
}
out = append(out, ev)
}
return out, rows.Err()
}
func (r *EventRepo) List(ctx context.Context, hostID uuid.UUID, limit, offset int) ([]*domain.Event, error) {
if limit <= 0 || limit > 200 {
limit = 50
@@ -150,8 +207,21 @@ type UpdateEventParams struct {
Status *domain.EventStatus
}
// UpdateByID applies the patch without an authz scope. Block C handlers
// run requireRole first, so re-checking host_id here would block legitimate
// editor-collaborators on someone else's event.
func (r *EventRepo) UpdateByID(ctx context.Context, id uuid.UUID, p UpdateEventParams) (*domain.Event, error) {
return r.update(ctx, id, uuid.Nil, p, false)
}
// Update is the legacy host-scoped variant — preserved so a few owner-only
// call sites stay terse. New code should prefer UpdateByID + requireRole.
func (r *EventRepo) Update(ctx context.Context, id, hostID uuid.UUID, p UpdateEventParams) (*domain.Event, error) {
const q = `
return r.update(ctx, id, hostID, p, true)
}
func (r *EventRepo) update(ctx context.Context, id, hostID uuid.UUID, p UpdateEventParams, scopeToHost bool) (*domain.Event, error) {
q := `
UPDATE events SET
name = COALESCE($3, name),
slug = COALESCE($4, slug),
@@ -161,7 +231,14 @@ func (r *EventRepo) Update(ctx context.Context, id, hostID uuid.UUID, p UpdateEv
settings = COALESCE($8, settings),
status = COALESCE($9, status),
updated_at = now()
WHERE id = $1 AND host_id = $2
WHERE id = $1`
if scopeToHost {
q += ` AND host_id = $2`
} else {
// Bind $2 anyway so the parameter count matches the call below.
q += ` AND ($2::uuid IS NULL OR $2::uuid = host_id OR TRUE)`
}
q += `
RETURNING id, host_id, name, slug, event_date, venue, max_capacity, settings, status, created_at, updated_at
`
@@ -202,6 +279,19 @@ func (r *EventRepo) Delete(ctx context.Context, id, hostID uuid.UUID) error {
return nil
}
// DeleteByID is the post-Block-C path: requireRole(Owner) is enforced by
// the handler, so this query doesn't double-check host_id.
func (r *EventRepo) DeleteByID(ctx context.Context, id uuid.UUID) error {
tag, err := r.pool.Exec(ctx, `DELETE FROM events WHERE id = $1`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return domain.ErrEventNotFound
}
return nil
}
type rowScanner interface {
Scan(dest ...any) error
}