// 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 ( "archive/zip" "bytes" "database/sql" "encoding/csv" "fmt" "html/template" "log" "net/http" "os" "path/filepath" "strings" "time" "github.com/go-chi/chi/v5" "github.com/jung-kurt/gofpdf" "github.com/cclohmar/ReceiptNext/internal/database" "github.com/cclohmar/ReceiptNext/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)) renderFileError(w, "Missing event ID.") 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) renderFileError(w, "Cannot parse form data.") 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)) renderFileError(w, "Email address is required.") return } if format != "csv" && format != "pdf" { log.Printf("ERROR [%s] handlers: FileEvent: invalid format %q", time.Now().Format(time.RFC3339), format) renderFileError(w, "Format must be 'csv' or 'pdf'.") 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)) renderFileError(w, "Session expired. Please log in again.") 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) renderFileError(w, "Failed to retrieve event. Please try again.") return } if event == nil { log.Printf("ERROR [%s] handlers: FileEvent: event not found: %s", time.Now().Format(time.RFC3339), eventID) renderFileError(w, "Event not found.") 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) renderFileError(w, "You do not have permission to file this event.") 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) renderFileError(w, "Failed to retrieve expenses. Please try again.") return } // 5. Generate the report in the requested format. var reportAttachment *email.Attachment switch format { case "csv": reportAttachment, err = generateCSV(event.Name, expenses) case "pdf": reportAttachment, 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) renderFileError(w, "Failed to generate report. Please try again.") return } // 6. Create a ZIP of all receipt images. zipAttachment, zipErr := createReceiptZip(event.Name, expenses) // 7. Build the list of attachments (report + ZIP if available). attachments := []*email.Attachment{reportAttachment} if zipErr == nil && zipAttachment != nil { attachments = append(attachments, zipAttachment) } else if zipErr != nil { log.Printf("WARN [%s] handlers: FileEvent: receipt zip failed: %v", time.Now().Format(time.RFC3339), zipErr) } // 8. Send the email with all attachments. if h.EmailSender == nil { log.Printf("ERROR [%s] handlers: FileEvent: SMTP not configured, cannot send email", time.Now().Format(time.RFC3339)) renderFileError(w, "SMTP not configured. Please contact the administrator.") return } subject := "Expense report for event " + event.Name body := "Please find attached the expense report and receipt images." if err := h.EmailSender.SendReport(to, subject, body, attachments); err != nil { log.Printf("ERROR [%s] handlers: FileEvent: SendReport(%s): %v", time.Now().Format(time.RFC3339), to, err) renderFileError(w, "Failed to send report: "+err.Error()) 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) renderFileError(w, "Report sent but failed to close the event. Please try again.") 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. // If the expenses use a different currency than the base currency, both // original and converted amounts are included. func generateCSV(eventName string, expenses []database.Expense) (*email.Attachment, error) { var buf bytes.Buffer writer := csv.NewWriter(&buf) // Determine if we need conversion columns. hasConversion := false for _, exp := range expenses { if exp.ConvertedAmount > 0 && exp.BaseCurrency != "" && exp.BaseCurrency != exp.Currency { hasConversion = true break } } // Write header row. var header []string if hasConversion { header = []string{"#", "Date", "Merchant", "Amount", "Currency", "Converted", "Claim Currency", "Category", "Description"} } else { header = []string{"#", "Date", "Merchant", "Amount", "Currency", "Category", "Description"} } if err := writer.Write(header); err != nil { return nil, fmt.Errorf("write CSV header: %w", err) } // Write one data row per expense with item number. totalOrig := 0.0 totalConv := 0.0 for i, exp := range expenses { itemNum := i + 1 var row []string if hasConversion { row = []string{ fmt.Sprintf("%d", itemNum), exp.Date, exp.Merchant, fmt.Sprintf("%.2f", exp.Amount), exp.Currency, fmt.Sprintf("%.2f", exp.ConvertedAmount), exp.BaseCurrency, exp.Category, exp.Description, } } else { row = []string{ fmt.Sprintf("%d", itemNum), exp.Date, exp.Merchant, fmt.Sprintf("%.2f", exp.Amount), exp.Currency, exp.Category, exp.Description, } } if err := writer.Write(row); err != nil { return nil, fmt.Errorf("write CSV row: %w", err) } totalOrig += exp.Amount totalConv += exp.ConvertedAmount } // Write totals row. if hasConversion { writer.Write([]string{"TOTAL", "", "", fmt.Sprintf("%.2f", totalOrig), "", fmt.Sprintf("%.2f", totalConv), "", "", ""}) } else { writer.Write([]string{"TOTAL", "", "", fmt.Sprintf("%.2f", totalOrig), "", "", "", ""}) } writer.Flush() if err := writer.Error(); err != nil { return nil, fmt.Errorf("CSV writer flush: %w", err) } filename := fmt.Sprintf("expense-%s-report.csv", sanitiseFilename(eventName)) return &email.Attachment{ Filename: filename, 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. // If the expenses use a different currency than the base currency, both // original and converted amounts are included. 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) // Determine if we need conversion columns. hasConversion := false for _, exp := range expenses { if exp.ConvertedAmount > 0 && exp.BaseCurrency != "" && exp.BaseCurrency != exp.Currency { hasConversion = true break } } // Table header row with item number. pdf.SetFont("Helvetica", "B", 10) var headers []string var colWidths []float64 if hasConversion { headers = []string{"#", "Date", "Merchant", "Amount", "Curr.", "Converted", "Claim", "Category"} colWidths = []float64{8, 22, 38, 18, 12, 20, 14, 30} } else { headers = []string{"#", "Date", "Merchant", "Amount", "Currency", "Category"} colWidths = []float64{10, 28, 48, 22, 18, 40} } for i, h := range headers { pdf.Cell(colWidths[i], 8, h) } pdf.Ln(8) // Table data rows with item numbers and automatic page breaks. pdf.SetFont("Helvetica", "", 9) marginBottom := 20.0 // mm margin from bottom before page break for i, exp := range expenses { // Check if we need a page break (A4 = 297mm height). if pdf.GetY() > 297-marginBottom { pdf.AddPage() // Re-draw header row on new page. pdf.SetFont("Helvetica", "B", 9) for j, h := range headers { pdf.Cell(colWidths[j], 8, h) } pdf.Ln(8) pdf.SetFont("Helvetica", "", 9) } itemNum := i + 1 if hasConversion { pdf.Cell(colWidths[0], 8, fmt.Sprintf("%d", itemNum)) pdf.Cell(colWidths[1], 8, exp.Date) pdf.Cell(colWidths[2], 8, truncateString(exp.Merchant, 18)) pdf.Cell(colWidths[3], 8, fmt.Sprintf("%.2f", exp.Amount)) pdf.Cell(colWidths[4], 8, exp.Currency) pdf.Cell(colWidths[5], 8, fmt.Sprintf("%.2f", exp.ConvertedAmount)) pdf.Cell(colWidths[6], 8, exp.BaseCurrency) pdf.Cell(colWidths[7], 8, truncateString(exp.Category, 12)) } else { pdf.Cell(colWidths[0], 8, fmt.Sprintf("%d", itemNum)) pdf.Cell(colWidths[1], 8, exp.Date) pdf.Cell(colWidths[2], 8, truncateString(exp.Merchant, 20)) pdf.Cell(colWidths[3], 8, fmt.Sprintf("%.2f", exp.Amount)) pdf.Cell(colWidths[4], 8, exp.Currency) pdf.Cell(colWidths[5], 8, truncateString(exp.Category, 20)) } 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) } filename := fmt.Sprintf("expense-%s-report.pdf", sanitiseFilename(eventName)) return &email.Attachment{ Filename: filename, Content: buf.Bytes(), }, nil } // createReceiptZip creates a ZIP archive containing all receipt images from the // given expenses. Each image is named {event-name}-{index}.{ext} inside the ZIP. // Returns nil if there are no expenses with images, or if all image files are // missing from disk. func createReceiptZip(eventName string, expenses []database.Expense) (*email.Attachment, error) { safeName := sanitiseFilename(eventName) if safeName == "" { safeName = "event" } var buf bytes.Buffer zw := zip.NewWriter(&buf) added := 0 for i, exp := range expenses { if exp.ImagePath == "" { continue } // Normalize path (strip legacy "storage/" prefix if present), // then construct the full path safely within the storage directory. // filepath.Base prevents path traversal by extracting only the filename. normPath := normalizeImagePath(exp.ImagePath) safePath := filepath.Join("storage", filepath.Base(normPath)) // Read the image file from disk. data, err := os.ReadFile(safePath) if err != nil { log.Printf("WARN [%s] handlers: createReceiptZip: reading %q: %v", time.Now().Format(time.RFC3339), safePath, err) continue } // Determine file extension from the image path. ext := filepath.Ext(exp.ImagePath) if ext == "" { ext = ".jpg" } filename := fmt.Sprintf("%s-%d%s", safeName, i+1, ext) f, err := zw.Create(filename) if err != nil { log.Printf("WARN [%s] handlers: createReceiptZip: creating entry %q: %v", time.Now().Format(time.RFC3339), filename, err) continue } if _, err := f.Write(data); err != nil { log.Printf("WARN [%s] handlers: createReceiptZip: writing %q: %v", time.Now().Format(time.RFC3339), filename, err) continue } added++ } if err := zw.Close(); err != nil { return nil, fmt.Errorf("closing zip: %w", err) } if added == 0 { log.Printf("INFO [%s] handlers: createReceiptZip: no receipt images found for event %q", time.Now().Format(time.RFC3339), eventName) return nil, nil } return &email.Attachment{ Filename: fmt.Sprintf("expense-%s-images.zip", safeName), Content: buf.Bytes(), }, nil } // renderFileError writes an HTMX-compatible error fragment targeted at the // #submit-error container on the event expenses page. Using a 200 status // ensures HTMX always swaps the content (HTMX skips 4xx/5xx by default). func renderFileError(w http.ResponseWriter, message string) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("HX-Retarget", "#submit-error") w.WriteHeader(http.StatusOK) fmt.Fprintf(w, `
%s
`, template.HTMLEscapeString(message)) } // truncateString truncates a string to the given maximum length, appending "…" // if the string was shortened. func truncateString(s string, maxLen int) string { if len(s) <= maxLen { return s } return s[:maxLen-1] + "…" } // sanitiseFilename converts a string into a safe filename (alphanumerics, // hyphens, underscores only — no spaces or special characters). func sanitiseFilename(s string) string { var result []rune for _, r := range s { if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' { result = append(result, r) } else if r == ' ' || r == '.' { result = append(result, '-') } } if len(result) == 0 { return "expenses" } return strings.Trim(string(result), "-") }