NextExpense/internal/handlers/events.go
cclohmar 517e95adc2 chore: rebrand ReceiptNext to NextExpense
- Rename Go module from github.com/cclohmar/ReceiptNext to NextExpense
- Update all import paths across 7 Go source files
- Update templates (titles, headings, branding)
- Update static files (manifest.json, sw.js, CSS)
- Update config (Makefile, install.sh, .env.example)
- Update README with new name and URLs
- Rename service file receiptnext.service -> nextexpense.service
- Update install paths, service names, log paths in install.sh
2026-06-21 18:39:48 +00:00

451 lines
17 KiB
Go

// Package handlers provides HTTP request handlers for NextExpense.
//
// 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"
"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}
}
// ---------------------------------------------------------------------------
// 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
}
// Redirect to onboarding if the user hasn't completed it yet.
user, _ := database.GetUserByID(h.DB, userID)
if user != nil && !user.Onboarded {
w.Header().Set("HX-Redirect", "/onboarding")
w.WriteHeader(http.StatusOK)
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 := getTemplate("dashboard.html")
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
}
baseCurrency := r.FormValue("base_currency")
if baseCurrency == "" {
baseCurrency = "USD"
}
// Compute exchange rate from user-provided sample:
// e.g. receipt=1000 KES, claimed=7.73 USD → rate = 7.73 / 1000 = 0.00773
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
log.Printf("INFO [%s] handlers: CreateEvent: computed rate %.6f from sample %s %s → %.2f %s",
time.Now().Format(time.RFC3339), exchangeRate,
r.FormValue("sample_receipt_currency"), sampleReceipt, sampleClaimVal, baseCurrency)
}
}
id := utils.NewUUID()
if err := database.CreateEvent(h.DB, id, userID, 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", "/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
}
// Clean up any stale download packages — user must regenerate after reopening.
if oldFiles, err := database.DeleteDownloadTokensByEvent(h.DB, eventID); err == nil {
for _, fn := range oldFiles {
os.Remove(filepath.Join("storage", "postbox", fn))
}
}
// Redirect to dashboard so the full page renders with updated status.
w.Header().Set("HX-Redirect", "/dashboard")
w.WriteHeader(http.StatusOK)
}
// ---------------------------------------------------------------------------
// POST /events/{id}/close — CloseEvent
// ---------------------------------------------------------------------------
// CloseEvent sets an event's status to "closed". Only the event owner may
// close it. On success, redirects to the dashboard.
func (h *EventHandler) CloseEvent(w http.ResponseWriter, r *http.Request) {
eventID := chi.URLParam(r, "id")
if eventID == "" {
http.Error(w, "Missing event ID", http.StatusBadRequest)
return
}
userID := getUserID(r)
if userID == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
event, err := database.GetEventByID(h.DB, eventID)
if err != nil || event == nil {
http.Error(w, "Event not found", http.StatusNotFound)
return
}
if 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: UpdateEventStatus(%s): %v",
time.Now().Format(time.RFC3339), eventID, err)
http.Error(w, "Failed to close event", http.StatusInternalServerError)
return
}
w.Header().Set("HX-Redirect", "/dashboard")
w.WriteHeader(http.StatusOK)
}
// ---------------------------------------------------------------------------
// 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 := getTemplate("event_expenses.html")
totalClaim := 0.0
for _, exp := range expenses {
if exp.ConvertedAmount > 0 {
totalClaim += exp.ConvertedAmount
}
}
// Normalize ImagePath for all expenses (old DB entries may have storage/ prefix).
for i := range expenses {
expenses[i].ImagePath = normalizeImagePath(expenses[i].ImagePath)
}
data := map[string]interface{}{
"Event": event,
"Expenses": expenses,
"TotalClaim": totalClaim,
}
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 /events/{id}/edit — EditEvent
// ---------------------------------------------------------------------------
// EditEvent returns the event edit form fragment pre-filled with current data.
func (h *EventHandler) EditEvent(w http.ResponseWriter, r *http.Request) {
eventID := chi.URLParam(r, "id")
event, err := database.GetEventByID(h.DB, eventID)
if err != nil || event == nil || event.UserID != getUserID(r) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
// Build a slug for the sample receipt currency (use base currency as default).
rcptCur := event.BaseCurrency
if rcptCur == "" {
rcptCur = "KES"
}
// Compute a rough reverse sample from the exchange rate:
// rate = claim / receipt → sample_receipt = 1000, sample_claim = 1000 * rate
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="/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 class="form-group">
<label class="form-label">Claim Currency</label>
<input type="text" name="base_currency" value="%s" required maxlength="3" style="text-transform: uppercase;">
</div>
<div style="background: #064e3b; border: 1px solid #065f46; border-radius: 0.5rem; padding: 0.75rem; margin-bottom: 0.5rem;">
<div style="font-size: 0.8rem; font-weight: 600; color: #6ee7b7; margin-bottom: 0.5rem;">Conversion Sample</div>
<p style="font-size: 0.75rem; color: var(--color-text-muted); margin-bottom: 0.5rem;">Update the sample to recalculate the rate.</p>
<div class="form-row">
<div class="form-group">
<label class="form-label">Receipt Amount</label>
<input type="number" name="sample_receipt_amount" value="1000" step="0.01" min="0.01" required>
</div>
<div class="form-group" style="flex: 0 0 110px;">
<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 class="form-group">
<label class="form-label">Converted amount</label>
<input type="number" name="sample_claim_amount" value="%.2f" step="0.01" min="0.01" required>
</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="/events/%s" hx-target="body" hx-push-url="true" id="delete-event-%s" style="display:none"></div>
</div>
</form>
</div>`, event.ID, template.HTMLEscapeString(event.Name), template.HTMLEscapeString(event.BaseCurrency), template.HTMLEscapeString(rcptCur), sampleClm, event.ID, event.ID, event.ID)
}
// ---------------------------------------------------------------------------
// PUT /events/{id} — UpdateEvent
// ---------------------------------------------------------------------------
// UpdateEvent updates the event's base currency and exchange rate.
func (h *EventHandler) UpdateEvent(w http.ResponseWriter, r *http.Request) {
eventID := chi.URLParam(r, "id")
event, err := database.GetEventByID(h.DB, eventID)
if err != nil || event == nil || event.UserID != getUserID(r) {
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
}
// Recalculate all existing expense converted amounts with the new rate.
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)
// Non-fatal — the event was updated, but expenses may have stale conversions.
}
w.Header().Set("HX-Redirect", "/dashboard")
w.WriteHeader(http.StatusOK)
}
// ---------------------------------------------------------------------------
// DELETE /events/{id} — DeleteEvent
// ---------------------------------------------------------------------------
// DeleteEvent removes an event and its expenses after ownership verification.
func (h *EventHandler) DeleteEvent(w http.ResponseWriter, r *http.Request) {
eventID := chi.URLParam(r, "id")
event, err := database.GetEventByID(h.DB, eventID)
if err != nil || event == nil || event.UserID != getUserID(r) {
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", "/dashboard")
w.WriteHeader(http.StatusOK)
}