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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user