// 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, `open`, 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) } }