Files
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

180 lines
5.3 KiB
Go

package api
import (
"errors"
"io"
"net/http"
"github.com/alchemistkay/guestguard/internal/csvimport"
"github.com/alchemistkay/guestguard/internal/domain"
"github.com/alchemistkay/guestguard/internal/storage"
)
const (
// 1 MB cap on uploads. With ~200 bytes per row that's ~5,000 guests —
// matches the row cap in csvimport.DefaultMaxRows.
csvMaxBytes = 1 << 20
)
type csvImportHandler struct {
guests *storage.GuestRepo
events *storage.EventRepo
collabs *storage.CollaboratorRepo
enforcer *tierEnforcer
}
type importResponse struct {
Added int `json:"added"`
Skipped int `json:"skipped"`
SkippedEmails []string `json:"skipped_emails,omitempty"`
Errors []csvimport.RowError `json:"errors,omitempty"`
TotalCount int `json:"total_count"`
}
type previewResponse struct {
Rows []csvimport.Row `json:"rows"`
Errors []csvimport.RowError `json:"errors,omitempty"`
TotalCount int `json:"total_count"`
}
// POST /events/{id}/guests/import/preview — parse + validate but don't write.
// Used by the frontend to show a "is this what you meant?" table before commit.
func (h *csvImportHandler) preview(w http.ResponseWriter, r *http.Request) {
hostID, ok := hostFromContext(w, r)
if !ok {
return
}
eventID, ok := parseIDParam(w, r, "id")
if !ok {
return
}
if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleEditor); !ok {
return
}
body, ok := readCSVUpload(w, r)
if !ok {
return
}
defer body.Close()
res, err := csvimport.Parse(body, csvimport.Options{})
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, previewResponse{
Rows: res.Rows,
Errors: res.Errors,
TotalCount: res.TotalCount,
})
}
// POST /events/{id}/guests/import — parse, validate, and commit valid rows
// in a single transaction. Rows with row-level errors are reported back
// but don't prevent the rest from importing.
func (h *csvImportHandler) commit(w http.ResponseWriter, r *http.Request) {
hostID, ok := hostFromContext(w, r)
if !ok {
return
}
eventID, ok := parseIDParam(w, r, "id")
if !ok {
return
}
if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleEditor); !ok {
return
}
body, ok := readCSVUpload(w, r)
if !ok {
return
}
defer body.Close()
parsed, err := csvimport.Parse(body, csvimport.Options{})
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
// Plan enforcement: prevent an import that, even if perfectly
// dedup-free, would exceed the per-event guest cap. We check upfront
// (current count + parsed-row count) and reject with 402 if it'd
// overflow. False positives on dedup-heavy CSVs are acceptable —
// host can dedupe and re-upload.
if !h.enforcer.allowGuestImport(w, r, hostID, eventID, len(parsed.Rows)) {
return
}
rows := make([]storage.BulkImportRow, 0, len(parsed.Rows))
for _, r := range parsed.Rows {
rows = append(rows, storage.BulkImportRow{
Name: r.Name,
Email: r.Email,
Phone: r.Phone,
PlusOnes: r.PlusOnes,
})
}
res, err := h.guests.BulkImportGuests(r.Context(), eventID, rows)
if err != nil {
writeError(w, http.StatusInternalServerError, "import failed")
return
}
writeJSON(w, http.StatusOK, importResponse{
Added: res.Added,
Skipped: res.Skipped,
SkippedEmails: res.SkippedEmails,
Errors: parsed.Errors,
TotalCount: parsed.TotalCount,
})
}
// GET /events/{id}/guests/import/template — download a sample CSV. Auth is
// applied at the route level; ownership is verified so an attacker can't
// probe the existence of an event by hitting this endpoint.
func (h *csvImportHandler) template(w http.ResponseWriter, r *http.Request) {
hostID, ok := hostFromContext(w, r)
if !ok {
return
}
eventID, ok := parseIDParam(w, r, "id")
if !ok {
return
}
if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleEditor); !ok {
return
}
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="guestguard-import-template.csv"`)
_, _ = w.Write([]byte(csvimport.TemplateCSV))
}
// readCSVUpload returns the multipart file body (capped at csvMaxBytes) or
// writes an error and returns (nil, false). Accepted shapes:
//
// - multipart/form-data with a "file" field (the drag-drop UI uses this)
// - any other Content-Type — the raw body is treated as the CSV (curl-friendly)
func readCSVUpload(w http.ResponseWriter, r *http.Request) (io.ReadCloser, bool) {
r.Body = http.MaxBytesReader(w, r.Body, csvMaxBytes)
if err := r.ParseMultipartForm(csvMaxBytes); err == nil && r.MultipartForm != nil {
files := r.MultipartForm.File["file"]
if len(files) == 0 {
writeError(w, http.StatusBadRequest, `form field "file" is required`)
return nil, false
}
f, err := files[0].Open()
if err != nil {
writeError(w, http.StatusBadRequest, "cannot read uploaded file")
return nil, false
}
return f, true
} else if err != nil && !errors.Is(err, http.ErrNotMultipart) {
writeError(w, http.StatusBadRequest, "invalid multipart body")
return nil, false
}
// Fall through: raw body as CSV.
return r.Body, true
}