Files
guestguard/internal/notification/factory.go
T
Kwaku Danso 3973e4058d 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>
2026-05-17 22:14:50 +01:00

105 lines
3.6 KiB
Go

package notification
import (
"context"
"log/slog"
)
// EmailBackend names the chosen email delivery channel for telemetry +
// startup logging. Mostly a debugging aid — code paths don't branch on
// this value.
type EmailBackend string
const (
BackendResend EmailBackend = "resend"
BackendSMTP EmailBackend = "smtp"
BackendSES EmailBackend = "ses"
BackendLog EmailBackend = "log"
)
// EmailSenderConfig collects every email-related env var so the picker
// has a single, ordered place to decide which backend wins. Priority is
// Resend > SMTP > SES > Log — the first one with non-empty creds is used.
type EmailSenderConfig struct {
Resend ResendConfig
SMTP SMTPConfig
SES SESConfig
}
// CombinedEmailSender satisfies both the auth.EmailSender interface (for
// verification + reset emails) and GuestEmailDispatcher (for invitation,
// confirmation, reminder). One concrete value handles both audiences so
// callers don't end up with two configurations.
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)
}
// PickEmailSender returns the configured email sender + which backend was
// chosen. Falls back to a logger stub if nothing is configured, so the
// service stays bootable in stripped-down dev environments.
func PickEmailSender(ctx context.Context, cfg EmailSenderConfig, tpls *Templates, logger *slog.Logger) (CombinedEmailSender, EmailBackend, error) {
switch {
case cfg.Resend.APIKey != "":
s, err := NewResendEmailSender(cfg.Resend, tpls)
if err != nil {
return nil, "", err
}
return s, BackendResend, nil
case cfg.SMTP.Host != "":
s, err := NewSMTPEmailSender(cfg.SMTP, tpls)
if err != nil {
return nil, "", err
}
return s, BackendSMTP, nil
case cfg.SES.FromEmail != "":
s, err := NewSESEmailSender(ctx, cfg.SES, tpls)
if err != nil {
return nil, "", err
}
return s, BackendSES, nil
}
return &logCombinedSender{logger: logger, tpls: tpls}, BackendLog, nil
}
// logCombinedSender is the dev fallback. Verification + reset emails come
// through as structured log lines (preserving the Block A behaviour);
// guest emails get rendered + dumped so engineers can eyeball the output.
type logCombinedSender struct {
logger *slog.Logger
tpls *Templates
}
func (l *logCombinedSender) SendVerification(_ context.Context, to, name, link string) error {
l.logger.Info("auth email (stub): verification", "to", to, "name", name, "link", link)
return nil
}
func (l *logCombinedSender) SendPasswordReset(_ context.Context, to, name, link string) error {
l.logger.Info("auth email (stub): password reset", "to", to, "name", name, "link", 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{}
}
data["Subject"] = subject
_, text, err := l.tpls.Render(name, data)
if err != nil {
return "", err
}
l.logger.Info("guest email (stub)",
"to", to, "subject", subject, "template", string(name), "text_body", text,
)
return "log:" + string(name), nil
}