diff --git a/internal/database/db.go b/internal/database/db.go index ea93f67..00fbd1f 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -60,6 +60,16 @@ type Expense struct { CreatedAt string } +// DownloadToken represents a download token for a generated report package. +type DownloadToken struct { + Token string + EventID string + Filename string + CreatedAt string + ExpiresAt string + Accessed bool +} + // --------------------------------------------------------------------------- // Initialization // --------------------------------------------------------------------------- @@ -127,6 +137,14 @@ func createTables(db *sql.DB) error { created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY(event_id) REFERENCES events(id) ON DELETE CASCADE )`, + `CREATE TABLE IF NOT EXISTS download_tokens ( + token TEXT PRIMARY KEY, + event_id TEXT NOT NULL, + filename TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + expires_at DATETIME NOT NULL, + accessed INTEGER NOT NULL DEFAULT 0 + )`, } for _, stmt := range statements { @@ -421,3 +439,97 @@ func DeleteExpense(db *sql.DB, id string) error { } return err } + +// --------------------------------------------------------------------------- +// Download token queries +// --------------------------------------------------------------------------- + +// CreateDownloadToken inserts a new download token row. +func CreateDownloadToken(db *sql.DB, token, eventID, filename, expiresAt string) error { + _, err := db.Exec( + "INSERT INTO download_tokens (token, event_id, filename, expires_at) VALUES (?, ?, ?, ?)", + token, eventID, filename, expiresAt, + ) + if err != nil { + log.Printf("ERROR [%s] database: CreateDownloadToken(%s): %v", + time.Now().Format(time.RFC3339), token, err) + } + return err +} + +// GetDownloadTokenByToken retrieves a download token record by its token string. +func GetDownloadTokenByToken(db *sql.DB, token string) (*DownloadToken, error) { + dt := &DownloadToken{} + err := db.QueryRow( + "SELECT token, event_id, filename, created_at, expires_at, accessed FROM download_tokens WHERE token = ?", + token, + ).Scan(&dt.Token, &dt.EventID, &dt.Filename, &dt.CreatedAt, &dt.ExpiresAt, &dt.Accessed) + if err != nil { + return nil, err + } + return dt, nil +} + +// MarkDownloadTokenAccessed sets the accessed flag for a token. +func MarkDownloadTokenAccessed(db *sql.DB, token string) error { + _, err := db.Exec("UPDATE download_tokens SET accessed = 1 WHERE token = ?", token) + if err != nil { + log.Printf("ERROR [%s] database: MarkDownloadTokenAccessed(%s): %v", + time.Now().Format(time.RFC3339), token, err) + } + return err +} + +// DeleteExpiredDownloadTokens removes tokens past their expiry and their files. +// Returns the filenames of deleted tokens so the caller can clean up disk files. +func DeleteExpiredDownloadTokens(db *sql.DB) ([]string, error) { + rows, err := db.Query("SELECT filename FROM download_tokens WHERE expires_at < datetime('now')") + if err != nil { + return nil, err + } + defer rows.Close() + + var filenames []string + for rows.Next() { + var fn string + if err := rows.Scan(&fn); err != nil { + continue + } + filenames = append(filenames, fn) + } + + if len(filenames) > 0 { + if _, err := db.Exec("DELETE FROM download_tokens WHERE expires_at < datetime('now')"); err != nil { + return filenames, err + } + } + + return filenames, nil +} + +// DeleteDownloadTokensByEvent removes all download tokens for a given event. +// Returns the filenames so the caller can clean up disk files. +func DeleteDownloadTokensByEvent(db *sql.DB, eventID string) ([]string, error) { + rows, err := db.Query("SELECT filename FROM download_tokens WHERE event_id = ?", eventID) + if err != nil { + return nil, err + } + defer rows.Close() + + var filenames []string + for rows.Next() { + var fn string + if err := rows.Scan(&fn); err != nil { + continue + } + filenames = append(filenames, fn) + } + + if len(filenames) > 0 { + if _, err := db.Exec("DELETE FROM download_tokens WHERE event_id = ?", eventID); err != nil { + return filenames, err + } + } + + return filenames, nil +} diff --git a/internal/handlers/file.go b/internal/handlers/file.go index 86abfa7..df4cfc9 100644 --- a/internal/handlers/file.go +++ b/internal/handlers/file.go @@ -7,8 +7,10 @@ package handlers import ( "archive/zip" "bytes" + "crypto/rand" "database/sql" "encoding/csv" + "encoding/hex" "fmt" "html/template" "log" @@ -184,6 +186,311 @@ func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) { 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) { + eventID := chi.URLParam(r, "id") + if eventID == "" { + log.Printf("ERROR [%s] handlers: GenerateReport: missing event ID", + time.Now().Format(time.RFC3339)) + renderFileError(w, "Missing event 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 + } + + format := strings.ToLower(strings.TrimSpace(r.FormValue("format"))) + if format != "csv" && format != "pdf" { + format = "pdf" + } + + userID := getUserID(r) + if userID == "" { + renderFileError(w, "Session expired. Please log in again.") + return + } + + event, err := database.GetEventByID(h.DB, eventID) + if err != nil || event == nil { + renderFileError(w, "Event not found.") + return + } + if 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 + } + + // Generate the report. + var reportAtt *email.Attachment + if format == "csv" { + reportAtt, err = generateCSV(event.Name, expenses) + } else { + reportAtt, err = generatePDF(event.Name, expenses) + } + if err != nil { + log.Printf("ERROR [%s] handlers: GenerateReport: generate %s: %v", + time.Now().Format(time.RFC3339), format, err) + renderFileError(w, "Failed to generate report.") + return + } + + // Create receipt images ZIP. + var zipAtt *email.Attachment + zipAtt, _ = createReceiptZip(event.Name, expenses) + + // Package everything into a single download ZIP. + var pkgBuf bytes.Buffer + pkg := zip.NewWriter(&pkgBuf) + + // Add report file. + addToZip(pkg, reportAtt.Filename, reportAtt.Content) + // Add images ZIP if present. + if zipAtt != nil { + addToZip(pkg, zipAtt.Filename, zipAtt.Content) + } + + if err := pkg.Close(); err != nil { + renderFileError(w, "Failed to create package.") + return + } + + // Save to postbox directory — first clean up any previous packages for this event. + os.MkdirAll("storage/postbox", 0755) + + // Delete old download tokens and their files for this event. + if oldFiles, err := database.DeleteDownloadTokensByEvent(h.DB, eventID); err == nil { + for _, fn := range oldFiles { + oldPath := filepath.Join("storage", "postbox", fn) + os.Remove(oldPath) + } + } + + tokenBytes := make([]byte, 32) + if _, err := rand.Read(tokenBytes); err != nil { + renderFileError(w, "Failed to generate download token.") + return + } + token := hex.EncodeToString(tokenBytes) + pkgFilename := token + ".zip" + 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. + ext := format + reportName := fmt.Sprintf("%s-report.%s", sanitiseFilename(event.Name), ext) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprintf(w, `
%s & %d receipt images packaged.
+Or send a download link via email (tiny email, no attachment limits):
+ +Analyzing receipt...
- + {{if and (eq .Event.Status "open") .Expenses}}