package api import ( "errors" "io" "net/http" "github.com/alchemistkay/guestguard/internal/csvimport" "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 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 := requireEventOwner(w, r, h.events, eventID, hostID); !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 := requireEventOwner(w, r, h.events, eventID, hostID); !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 := requireEventOwner(w, r, h.events, eventID, hostID); !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 }