- 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)
397 lines
14 KiB
Go
397 lines
14 KiB
Go
// 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, `<div class="card" style="padding: 1rem;">
|
|
<h3 style="margin-bottom: 1rem;">Edit Event</h3>
|
|
<form hx-put="/months/%s/events/%s" hx-target="body" hx-push-url="true">
|
|
<div class="form-group">
|
|
<label class="form-label">Event</label>
|
|
<input type="text" name="name" value="%s" placeholder="Event name">
|
|
</div>
|
|
<div style="background: #064e3b; border: 1px solid #065f46; border-radius: 0.5rem; padding: 1rem; margin-bottom: 0.5rem;">
|
|
<div style="font-size: 0.8rem; font-weight: 600; color: #6ee7b7; margin-bottom: 0.75rem;">Conversion Rate</div>
|
|
<div class="form-row" style="gap: 1rem;">
|
|
<div style="flex: 1;">
|
|
<label class="form-label">Claim Amount</label>
|
|
<input type="number" name="sample_claim_amount" value="%.2f" step="0.01" min="0.01" required>
|
|
</div>
|
|
<div style="flex: 0 0 80px;">
|
|
<label class="form-label">Currency</label>
|
|
<input type="text" name="base_currency" value="%s" required maxlength="3" style="text-transform: uppercase;">
|
|
</div>
|
|
</div>
|
|
<div class="form-row" style="gap: 1rem; margin-top: 0.5rem;">
|
|
<div style="flex: 1;">
|
|
<label class="form-label">Local Amount</label>
|
|
<input type="number" name="sample_receipt_amount" value="1000" step="0.01" min="0.01" required>
|
|
</div>
|
|
<div style="flex: 0 0 80px;">
|
|
<label class="form-label">Currency</label>
|
|
<input type="text" name="sample_receipt_currency" value="%s" required maxlength="3" style="text-transform: uppercase;">
|
|
</div>
|
|
</div>
|
|
<div style="font-size: 0.7rem; color: var(--color-text-muted); margin-top: 0.5rem;">
|
|
Rate = Claim / Local
|
|
</div>
|
|
</div>
|
|
<button type="submit" class="btn btn-primary btn-block">Save Changes</button>
|
|
<div style="display:flex; gap:0.5rem; margin-top:0.5rem;">
|
|
<button type="button" class="btn btn-secondary" style="flex:1; text-align:center;"
|
|
onclick="document.getElementById('create-event-form').innerHTML='';document.getElementById('create-event-form').classList.add('hidden')">Cancel</button>
|
|
<button type="button" class="btn btn-secondary" style="flex:1; text-align:center; color:#fca5a5; border-color:#7f1d1d;"
|
|
onclick="if(confirm('Delete this event and all its receipts?')){htmx.trigger('#delete-event-%s','click')}">Delete</button>
|
|
<div hx-delete="/months/%s/events/%s" hx-target="body" hx-push-url="true" id="delete-event-%s" style="display:none"></div>
|
|
</div>
|
|
</form>
|
|
</div>`, monthID, event.ID, template.HTMLEscapeString(event.Name), sampleClm, template.HTMLEscapeString(event.BaseCurrency), template.HTMLEscapeString(rcptCur), event.ID, monthID, event.ID, event.ID)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// PUT /months/{mid}/events/{eid} — UpdateEvent
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// UpdateEvent updates the event's base currency and exchange rate.
|
|
func (h *EventHandler) UpdateEvent(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
|
|
}
|
|
|
|
baseCurrency := r.FormValue("base_currency")
|
|
if baseCurrency == "" {
|
|
baseCurrency = "USD"
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
if err := database.UpdateEvent(h.DB, eventID, baseCurrency, exchangeRate); err != nil {
|
|
log.Printf("ERROR [%s] handlers: UpdateEvent: %v", time.Now().Format(time.RFC3339), err)
|
|
http.Error(w, "Failed to update event", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if err := database.RecalculateExpenses(h.DB, eventID, baseCurrency, exchangeRate); err != nil {
|
|
log.Printf("ERROR [%s] handlers: UpdateEvent: recalc expenses: %v", time.Now().Format(time.RFC3339), err)
|
|
}
|
|
|
|
w.Header().Set("HX-Redirect", "/months/"+monthID)
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// DELETE /months/{mid}/events/{eid} — DeleteEvent
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// DeleteEvent removes an event and its expenses after ownership verification.
|
|
func (h *EventHandler) DeleteEvent(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
|
|
}
|
|
|
|
if err := database.DeleteEvent(h.DB, eventID); err != nil {
|
|
log.Printf("ERROR [%s] handlers: DeleteEvent(%s): %v", time.Now().Format(time.RFC3339), eventID, err)
|
|
http.Error(w, "Failed to delete event", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("HX-Redirect", "/months/"+monthID)
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|