3973e4058d
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>
180 lines
5.3 KiB
Go
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
|
|
}
|