- Passwordless email OTP authentication - Event-based expense tracking with HTMX UI - AI receipt extraction via DeepSeek Vision API - CSV/PDF report generation with email filing - PWA with service worker and manifest - Mobile-first responsive design - SQLite database with auto-migration
240 lines
8.4 KiB
Go
240 lines
8.4 KiB
Go
// Package handlers provides HTTP request handlers for ExpenseFlow.
|
|
//
|
|
// This file implements event management endpoints including dashboard
|
|
// listing, event creation, reopening, and expense viewing.
|
|
package handlers
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"html/template"
|
|
"log"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/expenseflow/internal/database"
|
|
"github.com/expenseflow/internal/utils"
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// EventHandler
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// EventHandler groups HTTP handlers related to event management.
|
|
// It depends on a shared *sql.DB handle for database operations.
|
|
type EventHandler struct {
|
|
DB *sql.DB
|
|
}
|
|
|
|
// NewEventHandler creates a new EventHandler with the given database handle.
|
|
func NewEventHandler(db *sql.DB) *EventHandler {
|
|
return &EventHandler{DB: db}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GET /dashboard — Dashboard
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Dashboard renders the main dashboard page showing all events belonging
|
|
// to the authenticated user, along with the new event creation form.
|
|
func (h *EventHandler) Dashboard(w http.ResponseWriter, r *http.Request) {
|
|
userID := getUserID(r)
|
|
if userID == "" {
|
|
log.Printf("ERROR [%s] handlers: Dashboard: missing user ID", time.Now().Format(time.RFC3339))
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
events, err := database.GetEventsByUser(h.DB, userID)
|
|
if err != nil {
|
|
log.Printf("ERROR [%s] handlers: Dashboard: GetEventsByUser: %v",
|
|
time.Now().Format(time.RFC3339), err)
|
|
http.Error(w, "Failed to load events", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
tmpl, err := template.ParseFiles("templates/dashboard.html")
|
|
if err != nil {
|
|
log.Printf("ERROR [%s] handlers: Dashboard: parse template: %v",
|
|
time.Now().Format(time.RFC3339), err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
data := map[string]interface{}{
|
|
"Events": events,
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := tmpl.Execute(w, data); err != nil {
|
|
log.Printf("ERROR [%s] handlers: Dashboard: execute template: %v",
|
|
time.Now().Format(time.RFC3339), err)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// POST /events — CreateEvent
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// CreateEvent handles the creation of a new event for the authenticated user.
|
|
// It reads the event name from the form, generates a UUID, persists the
|
|
// event, and redirects to the dashboard via HX-Redirect.
|
|
func (h *EventHandler) CreateEvent(w http.ResponseWriter, r *http.Request) {
|
|
userID := getUserID(r)
|
|
if userID == "" {
|
|
log.Printf("ERROR [%s] handlers: CreateEvent: missing user ID", time.Now().Format(time.RFC3339))
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
name := r.FormValue("name")
|
|
if name == "" {
|
|
log.Printf("ERROR [%s] handlers: CreateEvent: missing event name", time.Now().Format(time.RFC3339))
|
|
http.Error(w, "Event name is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
id := utils.New()
|
|
if err := database.CreateEvent(h.DB, id, userID, name); err != nil {
|
|
log.Printf("ERROR [%s] handlers: CreateEvent: %v",
|
|
time.Now().Format(time.RFC3339), err)
|
|
http.Error(w, "Failed to create event", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("HX-Redirect", "/dashboard")
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// PUT /events/{id}/reopen — ReopenEvent
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ReopenEvent sets an event's status back to "open" and returns an HTMX
|
|
// fragment replacing the event's status badge with a green "open" badge.
|
|
// It verifies that the requesting user owns the event.
|
|
func (h *EventHandler) ReopenEvent(w http.ResponseWriter, r *http.Request) {
|
|
eventID := chi.URLParam(r, "id")
|
|
if eventID == "" {
|
|
log.Printf("ERROR [%s] handlers: ReopenEvent: missing event ID",
|
|
time.Now().Format(time.RFC3339))
|
|
http.Error(w, "Missing event ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
userID := getUserID(r)
|
|
if userID == "" {
|
|
log.Printf("ERROR [%s] handlers: ReopenEvent: missing user ID",
|
|
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: ReopenEvent: 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: ReopenEvent: event %s not found",
|
|
time.Now().Format(time.RFC3339), eventID)
|
|
http.Error(w, "Event not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if event.UserID != userID {
|
|
log.Printf("ERROR [%s] handlers: ReopenEvent: ownership mismatch for event %s",
|
|
time.Now().Format(time.RFC3339), eventID)
|
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
if err := database.UpdateEventStatus(h.DB, eventID, "open"); err != nil {
|
|
log.Printf("ERROR [%s] handlers: ReopenEvent: UpdateEventStatus(%s): %v",
|
|
time.Now().Format(time.RFC3339), eventID, err)
|
|
http.Error(w, "Failed to reopen event", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Return HTMX fragment: green "open" badge targeting #status-badge-{id}.
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
fmt.Fprintf(w, `<span id="status-badge-%s" class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">open</span>`, eventID)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GET /events/{id}/expenses — ViewEventExpenses
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ViewEventExpenses displays the expense collection view for a specific event.
|
|
// It verifies event ownership, sets the current_event_id cookie, and renders
|
|
// the event_expenses.html template with the event and its expense list.
|
|
func (h *EventHandler) ViewEventExpenses(w http.ResponseWriter, r *http.Request) {
|
|
eventID := chi.URLParam(r, "id")
|
|
if eventID == "" {
|
|
log.Printf("ERROR [%s] handlers: ViewEventExpenses: missing event ID",
|
|
time.Now().Format(time.RFC3339))
|
|
http.Error(w, "Missing event ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
userID := getUserID(r)
|
|
if userID == "" {
|
|
log.Printf("ERROR [%s] handlers: ViewEventExpenses: missing user ID",
|
|
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: ViewEventExpenses: 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: ViewEventExpenses: event %s not found",
|
|
time.Now().Format(time.RFC3339), eventID)
|
|
http.Error(w, "Event not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if event.UserID != userID {
|
|
log.Printf("ERROR [%s] handlers: ViewEventExpenses: ownership mismatch for event %s",
|
|
time.Now().Format(time.RFC3339), eventID)
|
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
expenses, err := database.GetExpensesByEvent(h.DB, eventID)
|
|
if err != nil {
|
|
log.Printf("ERROR [%s] handlers: ViewEventExpenses: GetExpensesByEvent(%s): %v",
|
|
time.Now().Format(time.RFC3339), eventID, err)
|
|
http.Error(w, "Failed to load expenses", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Set the current_event_id cookie so subsequent expense operations
|
|
// (SaveExpense, UploadReceipt) know which event to associate with.
|
|
setCurrentEventID(w, eventID)
|
|
|
|
tmpl, err := template.ParseFiles("templates/event_expenses.html")
|
|
if err != nil {
|
|
log.Printf("ERROR [%s] handlers: ViewEventExpenses: parse template: %v",
|
|
time.Now().Format(time.RFC3339), err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
data := map[string]interface{}{
|
|
"Event": event,
|
|
"Expenses": expenses,
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := tmpl.Execute(w, data); err != nil {
|
|
log.Printf("ERROR [%s] handlers: ViewEventExpenses: execute template: %v",
|
|
time.Now().Format(time.RFC3339), err)
|
|
}
|
|
}
|