// Package handlers provides HTTP request handlers for NextExpense. // // This file implements event management endpoints including event creation, // reopening, closing, and expense viewing — all scoped under a parent month. package handlers import ( "database/sql" "fmt" "html/template" "log" "net/http" "os" "path/filepath" "strconv" "time" "github.com/cclohmar/NextExpense/internal/database" "github.com/cclohmar/NextExpense/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} } // --------------------------------------------------------------------------- // POST /months/{mid}/events — CreateEvent // --------------------------------------------------------------------------- // CreateEvent handles the creation of a new event under a given month. func (h *EventHandler) CreateEvent(w http.ResponseWriter, r *http.Request) { monthID := chi.URLParam(r, "mid") if monthID == "" { http.Error(w, "Missing month ID", http.StatusBadRequest) return } userID := getUserID(r) if userID == "" { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } // Verify month ownership. month, err := database.GetMonthByID(h.DB, monthID) if err != nil || month == nil || month.UserID != userID { http.Error(w, "Forbidden", http.StatusForbidden) return } name := r.FormValue("name") if name == "" { http.Error(w, "Event name is required", http.StatusBadRequest) return } baseCurrency := r.FormValue("base_currency") if baseCurrency == "" { baseCurrency = "USD" } // Compute exchange rate from user-provided sample. exchangeRate := 1.0 sampleReceipt := r.FormValue("sample_receipt_amount") sampleClaim := r.FormValue("sample_claim_amount") if sampleReceipt != "" && sampleClaim != "" { sampleReceiptVal, err1 := strconv.ParseFloat(sampleReceipt, 64) sampleClaimVal, err2 := strconv.ParseFloat(sampleClaim, 64) if err1 == nil && err2 == nil && sampleReceiptVal > 0 && sampleClaimVal > 0 { exchangeRate = sampleClaimVal / sampleReceiptVal } } id := utils.NewUUID() if err := database.CreateEvent(h.DB, id, userID, monthID, name, baseCurrency, exchangeRate); 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", "/months/"+monthID) w.WriteHeader(http.StatusOK) } // --------------------------------------------------------------------------- // PUT /months/{mid}/events/{eid}/reopen — ReopenEvent // --------------------------------------------------------------------------- // ReopenEvent sets an event's status back to "open". Verifies month and event ownership. func (h *EventHandler) ReopenEvent(w http.ResponseWriter, r *http.Request) { monthID := chi.URLParam(r, "mid") eventID := chi.URLParam(r, "eid") if monthID == "" || eventID == "" { http.Error(w, "Missing ID", http.StatusBadRequest) return } userID := getUserID(r) if userID == "" { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } // Verify month ownership. month, err := database.GetMonthByID(h.DB, monthID) if err != nil || month == nil || month.UserID != userID { http.Error(w, "Forbidden", http.StatusForbidden) return } event, err := database.GetEventByID(h.DB, eventID) if err != nil || event == nil { http.Error(w, "Event not found", http.StatusNotFound) return } if event.MonthID != monthID || event.UserID != userID { http.Error(w, "Forbidden", http.StatusForbidden) return } if err := database.UpdateEventStatus(h.DB, eventID, "open"); err != nil { log.Printf("ERROR [%s] handlers: ReopenEvent: %v", time.Now().Format(time.RFC3339), err) http.Error(w, "Failed to reopen event", http.StatusInternalServerError) return } // Clean up stale download packages. if oldFiles, err := database.DeleteDownloadTokensByEvent(h.DB, eventID); err == nil { for _, fn := range oldFiles { os.Remove(filepath.Join("storage", "postbox", fn)) } } w.Header().Set("HX-Redirect", "/months/"+monthID) w.WriteHeader(http.StatusOK) } // --------------------------------------------------------------------------- // POST /months/{mid}/events/{eid}/close — CloseEvent // --------------------------------------------------------------------------- // CloseEvent sets an event's status to "closed". func (h *EventHandler) CloseEvent(w http.ResponseWriter, r *http.Request) { monthID := chi.URLParam(r, "mid") eventID := chi.URLParam(r, "eid") if monthID == "" || eventID == "" { http.Error(w, "Missing ID", http.StatusBadRequest) return } userID := getUserID(r) if userID == "" { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } // Verify month ownership. month, err := database.GetMonthByID(h.DB, monthID) if err != nil || month == nil || month.UserID != userID { http.Error(w, "Forbidden", http.StatusForbidden) return } event, err := database.GetEventByID(h.DB, eventID) if err != nil || event == nil { http.Error(w, "Event not found", http.StatusNotFound) return } if event.MonthID != monthID || event.UserID != userID { http.Error(w, "Forbidden", http.StatusForbidden) return } if err := database.UpdateEventStatus(h.DB, eventID, "closed"); err != nil { log.Printf("ERROR [%s] handlers: CloseEvent: %v", time.Now().Format(time.RFC3339), err) http.Error(w, "Failed to close event", http.StatusInternalServerError) return } w.Header().Set("HX-Redirect", "/months/"+monthID) w.WriteHeader(http.StatusOK) } // --------------------------------------------------------------------------- // GET /months/{mid}/events/{eid}/expenses — ViewEventExpenses // --------------------------------------------------------------------------- // ViewEventExpenses displays the expense collection view for a specific event. func (h *EventHandler) ViewEventExpenses(w http.ResponseWriter, r *http.Request) { monthID := chi.URLParam(r, "mid") eventID := chi.URLParam(r, "eid") if monthID == "" || eventID == "" { http.Error(w, "Missing ID", http.StatusBadRequest) return } userID := getUserID(r) if userID == "" { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } // Verify month ownership. month, err := database.GetMonthByID(h.DB, monthID) if err != nil || month == nil || month.UserID != userID { http.Error(w, "Forbidden", http.StatusForbidden) return } event, err := database.GetEventByID(h.DB, eventID) if err != nil || event == nil { http.Error(w, "Event not found", http.StatusNotFound) return } if event.MonthID != monthID || event.UserID != userID { http.Error(w, "Forbidden", http.StatusForbidden) return } expenses, err := database.GetExpensesByEvent(h.DB, eventID) if err != nil { log.Printf("ERROR [%s] handlers: ViewEventExpenses: %v", time.Now().Format(time.RFC3339), err) http.Error(w, "Failed to load expenses", http.StatusInternalServerError) return } // Set current_event_id cookie for expense operations. setCurrentEventID(w, eventID) tmpl := getTemplate("event_expenses.html") for i := range expenses { expenses[i].ImagePath = normalizeImagePath(expenses[i].ImagePath) } data := map[string]interface{}{ "Month": month, "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) } } // --------------------------------------------------------------------------- // GET /months/{mid}/events/{eid}/edit — EditEvent // --------------------------------------------------------------------------- // EditEvent returns the event edit form fragment pre-filled with current data. func (h *EventHandler) EditEvent(w http.ResponseWriter, r *http.Request) { monthID := chi.URLParam(r, "mid") eventID := chi.URLParam(r, "eid") event, err := database.GetEventByID(h.DB, eventID) if err != nil || event == nil || event.UserID != getUserID(r) || event.MonthID != monthID { http.Error(w, "Forbidden", http.StatusForbidden) return } rcptCur := event.BaseCurrency if rcptCur == "" { rcptCur = "KES" } sampleClm := event.ExchangeRate * 1000 w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprintf(w, `