// Package handlers implements HTTP request handlers for ExpenseFlow. // // This file implements the event filing workflow — generating CSV or PDF // expense reports and emailing them as attachments to a specified recipient. package handlers import ( "bytes" "database/sql" "encoding/csv" "fmt" "log" "net/http" "time" "github.com/go-chi/chi/v5" "github.com/jung-kurt/gofpdf" "github.com/expenseflow/internal/database" "github.com/expenseflow/internal/email" ) // --------------------------------------------------------------------------- // FileHandler // --------------------------------------------------------------------------- // FileHandler handles the event filing workflow: generating expense reports // in CSV or PDF format and emailing them to a specified address. // It depends on a shared *sql.DB handle for database access and an // *email.Sender for delivering the report as an email attachment. type FileHandler struct { DB *sql.DB EmailSender *email.Sender } // --------------------------------------------------------------------------- // POST /events/{id}/file — FileEvent // --------------------------------------------------------------------------- // FileEvent generates an expense report (CSV or PDF) for a given event and // emails it as an attachment to the specified recipient. On success the // event status is updated to "closed" and the client is redirected to the // dashboard via the HX-Redirect header. // // Flow: // 1. Extract event ID from the URL via chi.URLParam // 2. Parse the form for target email and report format // 3. Verify the authenticated user owns this event // 4. Fetch all expenses for the event from the database // 5. Generate the report in the requested format (CSV or PDF) // 6. Send the report as an email attachment // 7. Update the event status to "closed" // 8. Return an HX-Redirect header pointing to /dashboard func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) { // 1. Get event ID from the URL path parameter. eventID := chi.URLParam(r, "id") if eventID == "" { log.Printf("ERROR [%s] handlers: FileEvent: missing event ID in URL", time.Now().Format(time.RFC3339)) http.Error(w, "Missing event ID", http.StatusBadRequest) return } // 2. Parse form fields. if err := r.ParseForm(); err != nil { log.Printf("ERROR [%s] handlers: FileEvent: parse form: %v", time.Now().Format(time.RFC3339), err) http.Error(w, "Cannot parse form data", http.StatusBadRequest) return } to := r.FormValue("email") format := r.FormValue("format") if to == "" { log.Printf("ERROR [%s] handlers: FileEvent: missing email field", time.Now().Format(time.RFC3339)) http.Error(w, "Email address is required", http.StatusBadRequest) return } if format != "csv" && format != "pdf" { log.Printf("ERROR [%s] handlers: FileEvent: invalid format %q", time.Now().Format(time.RFC3339), format) http.Error(w, "Format must be 'csv' or 'pdf'", http.StatusBadRequest) return } // 3. Verify the authenticated user owns this event. userID := getUserID(r) if userID == "" { log.Printf("ERROR [%s] handlers: FileEvent: unauthenticated request", time.Now().Format(time.RFC3339)) http.Error(w, "Unauthorized", http.StatusUnauthorized) return } event, err := database.GetEventByID(h.DB, eventID) if err != nil { log.Printf("ERROR [%s] handlers: FileEvent: GetEventByID(%s): %v", time.Now().Format(time.RFC3339), eventID, err) http.Error(w, "Failed to retrieve event", http.StatusInternalServerError) return } if event == nil { log.Printf("ERROR [%s] handlers: FileEvent: event not found: %s", time.Now().Format(time.RFC3339), eventID) http.Error(w, "Event not found", http.StatusNotFound) return } if event.UserID != userID { log.Printf("ERROR [%s] handlers: FileEvent: user %s does not own event %s", time.Now().Format(time.RFC3339), userID, eventID) http.Error(w, "Forbidden", http.StatusForbidden) return } // 4. Fetch all expenses for the event. expenses, err := database.GetExpensesByEvent(h.DB, eventID) if err != nil { log.Printf("ERROR [%s] handlers: FileEvent: GetExpensesByEvent(%s): %v", time.Now().Format(time.RFC3339), eventID, err) http.Error(w, "Failed to retrieve expenses", http.StatusInternalServerError) return } // 5. Generate the report in the requested format. var attachment *email.Attachment switch format { case "csv": attachment, err = generateCSV(event.Name, expenses) case "pdf": attachment, err = generatePDF(event.Name, expenses) } if err != nil { log.Printf("ERROR [%s] handlers: FileEvent: generate %s report: %v", time.Now().Format(time.RFC3339), format, err) http.Error(w, "Failed to generate report", http.StatusInternalServerError) return } // 6. Send the report as an email attachment. subject := "Expense report for event " + event.Name body := "Please find attached the expense report." if err := h.EmailSender.SendReport(to, subject, body, attachment); err != nil { log.Printf("ERROR [%s] handlers: FileEvent: SendReport(%s): %v", time.Now().Format(time.RFC3339), to, err) http.Error(w, "Failed to send report email", http.StatusInternalServerError) return } // 7. Update the event status to "closed". if err := database.UpdateEventStatus(h.DB, eventID, "closed"); err != nil { log.Printf("ERROR [%s] handlers: FileEvent: UpdateEventStatus(%s): %v", time.Now().Format(time.RFC3339), eventID, err) http.Error(w, "Failed to close event", http.StatusInternalServerError) return } // 8. Redirect to the dashboard via HTMX. w.Header().Set("HX-Redirect", "/dashboard") w.WriteHeader(http.StatusOK) } // --------------------------------------------------------------------------- // Report generation helpers // --------------------------------------------------------------------------- // generateCSV creates a CSV attachment from the provided expenses. // The CSV includes a header row and one data row per expense. func generateCSV(eventName string, expenses []database.Expense) (*email.Attachment, error) { var buf bytes.Buffer writer := csv.NewWriter(&buf) // Write header row. if err := writer.Write([]string{"Date", "Merchant", "Amount", "Currency", "Category", "Description"}); err != nil { return nil, fmt.Errorf("write CSV header: %w", err) } // Write one data row per expense. for _, exp := range expenses { if err := writer.Write([]string{ exp.Date, exp.Merchant, fmt.Sprintf("%.2f", exp.Amount), exp.Currency, exp.Category, exp.Description, }); err != nil { return nil, fmt.Errorf("write CSV row: %w", err) } } writer.Flush() if err := writer.Error(); err != nil { return nil, fmt.Errorf("CSV writer flush: %w", err) } return &email.Attachment{ Filename: "report.csv", Content: buf.Bytes(), }, nil } // generatePDF creates a PDF attachment from the provided expenses using gofpdf. // The PDF contains a title row, a header row, and one data row per expense. func generatePDF(eventName string, expenses []database.Expense) (*email.Attachment, error) { pdf := gofpdf.New("P", "mm", "A4", "") pdf.AddPage() // Title: "Expense Report: " pdf.SetFont("Helvetica", "B", 16) pdf.Cell(0, 10, "Expense Report: "+eventName) pdf.Ln(15) // Table header row. pdf.SetFont("Helvetica", "B", 10) headers := []string{"Date", "Merchant", "Amount", "Currency", "Category"} for _, h := range headers { pdf.Cell(35, 8, h) } pdf.Ln(8) // Table data rows. pdf.SetFont("Helvetica", "", 10) for _, exp := range expenses { pdf.Cell(35, 8, exp.Date) pdf.Cell(35, 8, exp.Merchant) pdf.Cell(20, 8, fmt.Sprintf("%.2f", exp.Amount)) pdf.Cell(20, 8, exp.Currency) pdf.Cell(35, 8, exp.Category) pdf.Ln(8) } // Write the PDF document to a memory buffer. var buf bytes.Buffer if err := pdf.Output(&buf); err != nil { return nil, fmt.Errorf("PDF output: %w", err) } return &email.Attachment{ Filename: "report.pdf", Content: buf.Bytes(), }, nil }