- Restructure hierarchy: Month → Event → Expense (new months table, FK) - Add MonthHandler with CRUD, monthly reports, dropdown create form - Events now scoped under months with extended ownership chain - AI extraction: 16 specific expense categories (Airfare, Meals, etc.) - UI: category dropdown, button-consistent cards, centered mobile shell on desktop - Dashboard/month views show claim totals per card - Description field now mandatory, forms simplified - Months sorted by name chronologically (latest first)
889 lines
30 KiB
Go
889 lines
30 KiB
Go
// Package handlers implements HTTP request handlers for NextExpense.
|
|
//
|
|
// 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"
|
|
"crypto/rand"
|
|
"database/sql"
|
|
"encoding/csv"
|
|
"encoding/hex"
|
|
"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/NextExpense/internal/database"
|
|
"github.com/cclohmar/NextExpense/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
|
|
// month view via the HX-Redirect header.
|
|
func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) {
|
|
// 1. Get month and event IDs from the URL path parameters.
|
|
monthID := chi.URLParam(r, "mid")
|
|
eventID := chi.URLParam(r, "eid")
|
|
if monthID == "" || eventID == "" {
|
|
log.Printf("ERROR [%s] handlers: FileEvent: missing ID in URL",
|
|
time.Now().Format(time.RFC3339))
|
|
renderFileError(w, "Missing 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")
|
|
|
|
if to == "" {
|
|
log.Printf("ERROR [%s] handlers: FileEvent: missing email field",
|
|
time.Now().Format(time.RFC3339))
|
|
renderFileError(w, "Email address is required.")
|
|
return
|
|
}
|
|
|
|
// 3. Verify the authenticated user owns this event (via month).
|
|
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
|
|
}
|
|
|
|
if !verifyMonthOwnership(h.DB, monthID, userID) {
|
|
renderFileError(w, "You do not have permission to file this event.")
|
|
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.MonthID != monthID || 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
|
|
}
|
|
|
|
// Fetch user info for report personalisation.
|
|
reportUser, _ := database.GetUserByID(h.DB, userID)
|
|
userName := ""
|
|
userDept := ""
|
|
if reportUser != nil {
|
|
userName = reportUser.Name
|
|
userDept = reportUser.Department
|
|
}
|
|
|
|
// 5. Generate both CSV and PDF reports.
|
|
csvAttachment, err := generateCSV(event.Name, expenses, userName, userDept)
|
|
if err != nil {
|
|
log.Printf("ERROR [%s] handlers: FileEvent: generate CSV: %v",
|
|
time.Now().Format(time.RFC3339), err)
|
|
renderFileError(w, "Failed to generate CSV report. Please try again.")
|
|
return
|
|
}
|
|
pdfAttachment, err := generatePDF(event.Name, expenses, userName, userDept)
|
|
if err != nil {
|
|
log.Printf("ERROR [%s] handlers: FileEvent: generate PDF: %v",
|
|
time.Now().Format(time.RFC3339), err)
|
|
renderFileError(w, "Failed to generate PDF 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 (CSV + PDF + ZIP if available).
|
|
attachments := []*email.Attachment{csvAttachment, pdfAttachment}
|
|
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 month view via HTMX.
|
|
w.Header().Set("HX-Redirect", "/months/"+monthID)
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// POST /events/{id}/generate — GenerateReport
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// GenerateReport creates a report package (CSV/PDF + receipt images ZIP),
|
|
// stores it on disk with a crypto-random download token, and returns an
|
|
// HTMX fragment with download and email-link options. The event is NOT
|
|
// closed — the user can add more receipts and regenerate.
|
|
func (h *FileHandler) GenerateReport(w http.ResponseWriter, r *http.Request) {
|
|
monthID := chi.URLParam(r, "mid")
|
|
eventID := chi.URLParam(r, "eid")
|
|
if monthID == "" || eventID == "" {
|
|
log.Printf("ERROR [%s] handlers: GenerateReport: missing ID",
|
|
time.Now().Format(time.RFC3339))
|
|
renderFileError(w, "Missing ID.")
|
|
return
|
|
}
|
|
|
|
if err := r.ParseForm(); err != nil {
|
|
log.Printf("ERROR [%s] handlers: GenerateReport: parse form: %v",
|
|
time.Now().Format(time.RFC3339), err)
|
|
renderFileError(w, "Cannot parse form data.")
|
|
return
|
|
}
|
|
|
|
userID := getUserID(r)
|
|
if userID == "" {
|
|
renderFileError(w, "Session expired. Please log in again.")
|
|
return
|
|
}
|
|
|
|
if !verifyMonthOwnership(h.DB, monthID, userID) {
|
|
renderFileError(w, "You do not have permission to access this event.")
|
|
return
|
|
}
|
|
|
|
event, err := database.GetEventByID(h.DB, eventID)
|
|
if err != nil || event == nil {
|
|
renderFileError(w, "Event not found.")
|
|
return
|
|
}
|
|
if event.MonthID != monthID || event.UserID != userID {
|
|
renderFileError(w, "You do not have permission to access this event.")
|
|
return
|
|
}
|
|
|
|
expenses, err := database.GetExpensesByEvent(h.DB, eventID)
|
|
if err != nil {
|
|
renderFileError(w, "Failed to retrieve expenses.")
|
|
return
|
|
}
|
|
if len(expenses) == 0 {
|
|
renderFileError(w, "No expenses to include in the report.")
|
|
return
|
|
}
|
|
|
|
// Fetch user info for report personalisation.
|
|
repUser, _ := database.GetUserByID(h.DB, userID)
|
|
uName := ""
|
|
uDept := ""
|
|
if repUser != nil {
|
|
uName = repUser.Name
|
|
uDept = repUser.Department
|
|
}
|
|
|
|
// Generate both CSV and PDF reports.
|
|
csvAtt, err := generateCSV(event.Name, expenses, uName, uDept)
|
|
if err != nil {
|
|
log.Printf("ERROR [%s] handlers: GenerateReport: generate CSV: %v",
|
|
time.Now().Format(time.RFC3339), err)
|
|
renderFileError(w, "Failed to generate CSV report.")
|
|
return
|
|
}
|
|
pdfAtt, err := generatePDF(event.Name, expenses, uName, uDept)
|
|
if err != nil {
|
|
log.Printf("ERROR [%s] handlers: GenerateReport: generate PDF: %v",
|
|
time.Now().Format(time.RFC3339), err)
|
|
renderFileError(w, "Failed to generate PDF report.")
|
|
return
|
|
}
|
|
|
|
// Package everything into a single flat ZIP (report + receipt images).
|
|
var pkgBuf bytes.Buffer
|
|
pkg := zip.NewWriter(&pkgBuf)
|
|
|
|
// Add both report files.
|
|
addToZip(pkg, csvAtt.Filename, csvAtt.Content)
|
|
addToZip(pkg, pdfAtt.Filename, pdfAtt.Content)
|
|
|
|
// Add receipt images directly (not nested).
|
|
for i, exp := range expenses {
|
|
if exp.ImagePath == "" {
|
|
continue
|
|
}
|
|
normPath := normalizeImagePath(exp.ImagePath)
|
|
safePath := filepath.Join("storage", filepath.Base(normPath))
|
|
data, err := os.ReadFile(safePath)
|
|
if err != nil {
|
|
log.Printf("WARN [%s] handlers: GenerateReport: reading %q: %v",
|
|
time.Now().Format(time.RFC3339), safePath, err)
|
|
continue
|
|
}
|
|
ext := filepath.Ext(exp.ImagePath)
|
|
if ext == "" {
|
|
ext = ".jpg"
|
|
}
|
|
imgName := fmt.Sprintf("receipt-%d%s", i+1, ext)
|
|
addToZip(pkg, imgName, data)
|
|
}
|
|
|
|
if err := pkg.Close(); err != nil {
|
|
renderFileError(w, "Failed to create package.")
|
|
return
|
|
}
|
|
|
|
// Save to postbox directory.
|
|
os.MkdirAll("storage/postbox", 0755)
|
|
|
|
tokenBytes := make([]byte, 32)
|
|
if _, err := rand.Read(tokenBytes); err != nil {
|
|
renderFileError(w, "Failed to generate download token.")
|
|
return
|
|
}
|
|
token := hex.EncodeToString(tokenBytes)
|
|
|
|
// Use event name as the download filename (GUID only in storage path).
|
|
safeEvent := sanitiseFilename(event.Name)
|
|
if safeEvent == "" {
|
|
safeEvent = "report"
|
|
}
|
|
dlName := safeEvent + ".zip"
|
|
pkgFilename := token + ".zip" // storage filename is always the GUID
|
|
pkgPath := filepath.Join("storage", "postbox", pkgFilename)
|
|
|
|
if err := os.WriteFile(pkgPath, pkgBuf.Bytes(), 0644); err != nil {
|
|
log.Printf("ERROR [%s] handlers: GenerateReport: write %s: %v",
|
|
time.Now().Format(time.RFC3339), pkgPath, err)
|
|
renderFileError(w, "Failed to save report package.")
|
|
return
|
|
}
|
|
|
|
// Store token in DB (24h expiry).
|
|
expiresAt := time.Now().Add(24 * time.Hour).Format(time.RFC3339)
|
|
if err := database.CreateDownloadToken(h.DB, token, eventID, pkgFilename, expiresAt); err != nil {
|
|
os.Remove(pkgPath)
|
|
renderFileError(w, "Failed to store download token.")
|
|
return
|
|
}
|
|
|
|
log.Printf("INFO [%s] handlers: GenerateReport: package %s created for event %s",
|
|
time.Now().Format(time.RFC3339), pkgFilename, eventID)
|
|
|
|
// Render the download/send fragment.
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
fmt.Fprintf(w, `<div id="report-package" style="background: #064e3b; border: 1px solid #065f46; border-radius: 0.5rem; padding: 1rem; margin-top: 1rem;">
|
|
<div style="font-weight: 600; color: #6ee7b7; margin-bottom: 0.5rem;">Report Ready</div>
|
|
<p style="font-size: 0.8rem; color: var(--color-text-muted); margin-bottom: 0.75rem;">CSV + PDF report & %d receipt images packaged.</p>
|
|
<div style="display: flex; gap: 0.5rem; margin-bottom: 0.75rem;">
|
|
<a href="/dl/%s/%s" class="btn btn-primary" style="flex:1; text-align:center; text-decoration:none; font-size:0.85rem;" download>⬇ Download Now</a>
|
|
</div>
|
|
<div style="border-top: 1px solid #065f46; padding-top: 0.75rem;">
|
|
<p style="font-size: 0.75rem; color: var(--color-text-muted); margin-bottom: 0.5rem;">Or send a download link via email (tiny email, no attachment limits):</p>
|
|
<form hx-post="/months/%s/events/%s/send-link" hx-target="#send-link-result" hx-indicator="#send-link-spinner" style="display: flex; gap: 0.5rem;">
|
|
<input type="hidden" name="token" value="%s">
|
|
<input type="email" name="email" placeholder="finance@company.com" required style="flex:1; padding:0.5rem; border:1px solid #475569; border-radius:0.375rem; background:#1e293b; color:#f8fafc; font-size:0.85rem;">
|
|
<button type="submit" class="btn btn-secondary" style="font-size:0.85rem; white-space:nowrap;">Send Link</button>
|
|
</form>
|
|
<div id="send-link-spinner" class="htmx-indicator" style="text-align:center; padding:0.5rem;"><div class="spinner"></div></div>
|
|
<div id="send-link-result"></div>
|
|
</div>
|
|
</div>`,
|
|
len(expenses),
|
|
template.HTMLEscapeString(token), template.HTMLEscapeString(dlName),
|
|
template.HTMLEscapeString(monthID), template.HTMLEscapeString(eventID), template.HTMLEscapeString(token))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// POST /months/{mid}/events/{eid}/send-link — SendDownloadLink
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// SendDownloadLink emails a download link for a previously generated report
|
|
// package to the specified recipient.
|
|
func (h *FileHandler) SendDownloadLink(w http.ResponseWriter, r *http.Request) {
|
|
monthID := chi.URLParam(r, "mid")
|
|
|
|
if err := r.ParseForm(); err != nil {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
fmt.Fprintf(w, `<div style="color:#fca5a5; font-size:0.8rem;">Failed to parse form.</div>`)
|
|
return
|
|
}
|
|
|
|
token := strings.TrimSpace(r.FormValue("token"))
|
|
to := strings.TrimSpace(r.FormValue("email"))
|
|
if token == "" || to == "" {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
fmt.Fprintf(w, `<div style="color:#fca5a5; font-size:0.8rem;">Token and email are required.</div>`)
|
|
return
|
|
}
|
|
|
|
// Verify token exists and belongs to user's event.
|
|
dt, err := database.GetDownloadTokenByToken(h.DB, token)
|
|
if err != nil || dt == nil {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
fmt.Fprintf(w, `<div style="color:#fca5a5; font-size:0.8rem;">Invalid or expired download token.</div>`)
|
|
return
|
|
}
|
|
|
|
event, err := database.GetEventByID(h.DB, dt.EventID)
|
|
if err != nil || event == nil || event.UserID != getUserID(r) || event.MonthID != monthID {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
fmt.Fprintf(w, `<div style="color:#fca5a5; font-size:0.8rem;">Permission denied.</div>`)
|
|
return
|
|
}
|
|
|
|
if h.EmailSender == nil {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
fmt.Fprintf(w, `<div style="color:#fca5a5; font-size:0.8rem;">SMTP not configured.</div>`)
|
|
return
|
|
}
|
|
|
|
// Build the download URL using the request's Host header (most reliable),
|
|
// falling back to BASE_URL env var.
|
|
scheme := "https"
|
|
host := r.Host
|
|
baseURL := os.Getenv("BASE_URL")
|
|
if host == "" && baseURL != "" {
|
|
// Parse scheme and host from BASE_URL as fallback.
|
|
if strings.HasPrefix(baseURL, "https://") {
|
|
host = strings.TrimPrefix(baseURL, "https://")
|
|
} else if strings.HasPrefix(baseURL, "http://") {
|
|
scheme = "http"
|
|
host = strings.TrimPrefix(baseURL, "http://")
|
|
}
|
|
}
|
|
if host == "" {
|
|
host = "localhost:8080"
|
|
}
|
|
safeName := sanitiseFilename(event.Name)
|
|
if safeName == "" {
|
|
safeName = "report"
|
|
}
|
|
link := fmt.Sprintf("%s://%s/dl/%s/%s.zip", scheme, host, token, safeName)
|
|
|
|
subject := "Expense report: " + event.Name
|
|
body := fmt.Sprintf("Expense report for %s is ready.\n\nDownload: %s\n\nThis link expires in 24 hours.", event.Name, link)
|
|
|
|
if err := h.EmailSender.SendReport(to, subject, body, nil); err != nil {
|
|
log.Printf("ERROR [%s] handlers: SendDownloadLink: %v",
|
|
time.Now().Format(time.RFC3339), err)
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
fmt.Fprintf(w, `<div style="color:#fca5a5; font-size:0.8rem;">Failed to send: %s</div>`,
|
|
template.HTMLEscapeString(err.Error()))
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
fmt.Fprintf(w, `<div style="color:#6ee7b7; font-size:0.8rem; margin-top:0.5rem;">Download link sent to %s.</div>`,
|
|
template.HTMLEscapeString(to))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GET /dl/{token} — ServeDownload
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ServeDownload streams a previously generated report package to the client.
|
|
// Access is controlled via the crypto-random token in the URL — no login
|
|
// required. The token is valid for 24 hours from creation.
|
|
// The optional filename suffix in the URL (e.g. /dl/{token}/Lagos-report.zip)
|
|
// is used for the Content-Disposition header but does not affect access control.
|
|
func (h *FileHandler) ServeDownload(w http.ResponseWriter, r *http.Request) {
|
|
token := chi.URLParam(r, "token")
|
|
if token == "" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
dt, err := database.GetDownloadTokenByToken(h.DB, token)
|
|
if err != nil || dt == nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
// Check expiry.
|
|
expiresAt, err := time.Parse(time.RFC3339, dt.ExpiresAt)
|
|
if err != nil || time.Now().After(expiresAt) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
pkgPath := filepath.Join("storage", "postbox", dt.Filename)
|
|
if _, err := os.Stat(pkgPath); os.IsNotExist(err) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
// Mark as accessed.
|
|
database.MarkDownloadTokenAccessed(h.DB, token)
|
|
|
|
// Build a friendly download filename from the URL suffix, falling back to the token.
|
|
dlName := dt.Filename
|
|
if name := chi.URLParam(r, "name"); name != "" {
|
|
dlName = filepath.Base(name) // prevent path traversal in the suffix
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/zip")
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, dlName))
|
|
http.ServeFile(w, r, pkgPath)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// StartDownloadCleanup
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// StartDownloadCleanup runs a background goroutine that periodically deletes
|
|
// expired download tokens and their associated files from disk.
|
|
func (h *FileHandler) StartDownloadCleanup() {
|
|
go func() {
|
|
for {
|
|
time.Sleep(1 * time.Hour)
|
|
filenames, err := database.DeleteExpiredDownloadTokens(h.DB)
|
|
if err != nil {
|
|
log.Printf("ERROR [%s] handlers: download cleanup: %v",
|
|
time.Now().Format(time.RFC3339), err)
|
|
continue
|
|
}
|
|
for _, fn := range filenames {
|
|
path := filepath.Join("storage", "postbox", fn)
|
|
if err := os.Remove(path); err != nil {
|
|
log.Printf("WARN [%s] handlers: download cleanup: remove %s: %v",
|
|
time.Now().Format(time.RFC3339), path, err)
|
|
}
|
|
}
|
|
if len(filenames) > 0 {
|
|
log.Printf("INFO [%s] handlers: download cleanup: removed %d expired packages",
|
|
time.Now().Format(time.RFC3339), len(filenames))
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Internal helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// addToZip adds a file to a zip.Writer. Errors are logged but not returned
|
|
// since a missing image in the ZIP is non-fatal — the report is the priority.
|
|
func addToZip(zw *zip.Writer, name string, data []byte) {
|
|
f, err := zw.Create(name)
|
|
if err != nil {
|
|
log.Printf("WARN [%s] handlers: addToZip: create %q: %v",
|
|
time.Now().Format(time.RFC3339), name, err)
|
|
return
|
|
}
|
|
if _, err := f.Write(data); err != nil {
|
|
log.Printf("WARN [%s] handlers: addToZip: write %q: %v",
|
|
time.Now().Format(time.RFC3339), name, err)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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, userName, userDept string) (*email.Attachment, error) {
|
|
var buf bytes.Buffer
|
|
writer := csv.NewWriter(&buf)
|
|
|
|
// Write user metadata row (prepared by).
|
|
if userName != "" {
|
|
metaLine := fmt.Sprintf("Prepared by: %s", userName)
|
|
if userDept != "" && userDept != "-" {
|
|
metaLine += fmt.Sprintf(" | Department: %s", userDept)
|
|
}
|
|
writer.Write([]string{metaLine})
|
|
writer.Write([]string{""}) // blank separator
|
|
}
|
|
|
|
// 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 {
|
|
baseCur := ""
|
|
for _, exp := range expenses {
|
|
if exp.BaseCurrency != "" {
|
|
baseCur = exp.BaseCurrency
|
|
break
|
|
}
|
|
}
|
|
writer.Write([]string{"TOTAL", "", "", fmt.Sprintf("%.2f", totalOrig), "", fmt.Sprintf("%.2f", totalConv), baseCur, "", ""})
|
|
} 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, userName, userDept string) (*email.Attachment, error) {
|
|
pdf := gofpdf.New("P", "mm", "A4", "")
|
|
pdf.AddPage()
|
|
|
|
// Title: "Expense Report: <event name>"
|
|
pdf.SetFont("Helvetica", "B", 16)
|
|
pdf.Cell(0, 10, "Expense Report: "+eventName)
|
|
pdf.Ln(8)
|
|
|
|
// User info block.
|
|
if userName != "" {
|
|
pdf.SetFont("Helvetica", "", 9)
|
|
infoLine := fmt.Sprintf("Prepared by: %s", userName)
|
|
if userDept != "" && userDept != "-" {
|
|
infoLine += fmt.Sprintf(" | Department: %s", userDept)
|
|
}
|
|
pdf.Cell(0, 6, infoLine)
|
|
pdf.Ln(10)
|
|
}
|
|
|
|
// 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
|
|
var totalOrig, totalConv float64
|
|
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))
|
|
totalOrig += exp.Amount
|
|
totalConv += exp.ConvertedAmount
|
|
} 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))
|
|
totalOrig += exp.Amount
|
|
}
|
|
pdf.Ln(8)
|
|
}
|
|
|
|
// Totals row with a separator line.
|
|
pdf.SetDrawColor(71, 85, 105) // #475569 border colour
|
|
pdf.Line(10, pdf.GetY()+1, 200, pdf.GetY()+1)
|
|
pdf.Ln(3)
|
|
pdf.SetFont("Helvetica", "B", 9)
|
|
if hasConversion {
|
|
// Determine the base/claim currency from the first expense that has one.
|
|
baseCur := ""
|
|
for _, exp := range expenses {
|
|
if exp.BaseCurrency != "" {
|
|
baseCur = exp.BaseCurrency
|
|
break
|
|
}
|
|
}
|
|
pdf.Cell(colWidths[0], 8, "")
|
|
pdf.Cell(colWidths[1], 8, "")
|
|
pdf.Cell(colWidths[2], 8, "TOTAL")
|
|
pdf.Cell(colWidths[3], 8, fmt.Sprintf("%.2f", totalOrig))
|
|
pdf.Cell(colWidths[4], 8, "")
|
|
pdf.Cell(colWidths[5], 8, fmt.Sprintf("%.2f", totalConv))
|
|
pdf.Cell(colWidths[6], 8, baseCur)
|
|
pdf.Cell(colWidths[7], 8, "")
|
|
} else {
|
|
pdf.Cell(colWidths[0], 8, "")
|
|
pdf.Cell(colWidths[1], 8, "")
|
|
pdf.Cell(colWidths[2], 8, "TOTAL")
|
|
pdf.Cell(colWidths[3], 8, fmt.Sprintf("%.2f", totalOrig))
|
|
pdf.Cell(colWidths[4], 8, "")
|
|
pdf.Cell(colWidths[5], 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, `<div id="submit-error" style="background: #450a0a; border: 1px solid #7f1d1d; color: #fca5a5; padding: 0.75rem; border-radius: 0.5rem; margin-bottom: 1rem;">%s</div>`,
|
|
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), "-")
|
|
}
|