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
+12
View File
@@ -90,6 +90,18 @@ func (s *SESEmailSender) SendPasswordReset(ctx context.Context, to, name, link s
})
}
// SendCollaboratorInvite renders the team-invite template and posts it to SES.
func (s *SESEmailSender) SendCollaboratorInvite(ctx context.Context, to, inviterName, eventName, role, link string) error {
return s.sendTemplated(ctx, to,
inviterName+" invited you to "+eventName,
TmplCollaboratorInvite, map[string]any{
"InviterName": inviterName,
"EventName": eventName,
"Role": role,
"Link": link,
})
}
// SendGuest is used by the notifier worker for invitation / confirmation /
// reminder emails — anything addressed at a guest.
func (s *SESEmailSender) SendGuest(ctx context.Context, to, subject string, name TemplateName, data map[string]any) (providerMessageID string, err error) {
+7
View File
@@ -33,6 +33,7 @@ type EmailSenderConfig struct {
type CombinedEmailSender interface {
SendVerification(ctx context.Context, to, name, link string) error
SendPasswordReset(ctx context.Context, to, name, link string) error
SendCollaboratorInvite(ctx context.Context, to, inviterName, eventName, role, link string) error
SendGuest(ctx context.Context, to, subject string, name TemplateName, data map[string]any) (string, error)
}
@@ -81,6 +82,12 @@ func (l *logCombinedSender) SendPasswordReset(_ context.Context, to, name, link
return nil
}
func (l *logCombinedSender) SendCollaboratorInvite(_ context.Context, to, inviterName, eventName, role, link string) error {
l.logger.Info("auth email (stub): collaborator invite",
"to", to, "inviter", inviterName, "event", eventName, "role", role, "link", link)
return nil
}
func (l *logCombinedSender) SendGuest(_ context.Context, to, subject string, name TemplateName, data map[string]any) (string, error) {
if data == nil {
data = map[string]any{}
+12
View File
@@ -71,6 +71,18 @@ func (s *ResendEmailSender) SendPasswordReset(ctx context.Context, to, name, lin
return err
}
func (s *ResendEmailSender) SendCollaboratorInvite(ctx context.Context, to, inviterName, eventName, role, link string) error {
_, err := s.sendTemplated(ctx, to,
inviterName+" invited you to "+eventName,
TmplCollaboratorInvite, map[string]any{
"InviterName": inviterName,
"EventName": eventName,
"Role": role,
"Link": link,
})
return err
}
// --- GuestEmailDispatcher ---
func (s *ResendEmailSender) SendGuest(ctx context.Context, to, subject string, name TemplateName, data map[string]any) (string, error) {
+11
View File
@@ -72,6 +72,17 @@ func (s *SMTPEmailSender) SendPasswordReset(ctx context.Context, to, name, link
TmplPasswordReset, map[string]any{"Name": name, "Link": link, "ExpiryHumane": "1 hour"})
}
func (s *SMTPEmailSender) SendCollaboratorInvite(ctx context.Context, to, inviterName, eventName, role, link string) error {
return s.sendTemplated(ctx, to,
inviterName+" invited you to "+eventName,
TmplCollaboratorInvite, map[string]any{
"InviterName": inviterName,
"EventName": eventName,
"Role": role,
"Link": link,
})
}
// --- GuestEmailDispatcher ---
func (s *SMTPEmailSender) SendGuest(ctx context.Context, to, subject string, name TemplateName, data map[string]any) (string, error) {
+6 -5
View File
@@ -19,11 +19,12 @@ var templatesFS embed.FS
type TemplateName string
const (
TmplVerification TemplateName = "verification"
TmplPasswordReset TemplateName = "reset"
TmplInvitation TemplateName = "invitation"
TmplConfirmation TemplateName = "confirmation"
TmplReminder TemplateName = "reminder"
TmplVerification TemplateName = "verification"
TmplPasswordReset TemplateName = "reset"
TmplInvitation TemplateName = "invitation"
TmplConfirmation TemplateName = "confirmation"
TmplReminder TemplateName = "reminder"
TmplCollaboratorInvite TemplateName = "collaborator_invite"
)
// Templates renders branded transactional emails for both HTML and
@@ -0,0 +1,16 @@
{{define "body"}}
<p style="font-size:13px;letter-spacing:0.2em;text-transform:uppercase;color:#16a34a;margin:0 0 16px;">✦ Team invitation</p>
<h1 style="font-size:24px;margin:0 0 4px;color:#0a0a0a;">{{.EventName}}</h1>
<p style="margin:16px 0 20px;">
{{.InviterName}} invited you to collaborate on this event as
<strong>{{.Role}}</strong>.
</p>
<p style="margin:0 0 24px;text-align:center;">
<a href="{{.Link}}" style="background:#22c55e;color:#0a0a0a;padding:12px 22px;border-radius:8px;font-weight:600;text-decoration:none;display:inline-block;">Accept invitation</a>
</p>
<p style="margin:0 0 8px;color:#64748b;font-size:13px;">
This invitation expires in 7 days. If you don't have a GuestGuard account
yet, you'll be able to create one before accepting.
</p>
<p style="margin:8px 0 0;word-break:break-all;font-size:12px;color:#0f172a;">{{.Link}}</p>
{{end}}
@@ -0,0 +1,7 @@
{{.InviterName}} invited you to collaborate on "{{.EventName}}" as {{.Role}}.
Accept the invitation:
{{.Link}}
This link expires in 7 days. If you don't have a GuestGuard account yet,
you'll be able to create one before accepting.