package api import ( "encoding/json" "errors" "net/http" "time" "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/shopspring/decimal" "simplefinancedash/backend/internal/auth" "simplefinancedash/backend/internal/db" "simplefinancedash/backend/internal/models" ) type Handlers struct { queries *db.Queries sessionSecret string } func NewHandlers(queries *db.Queries, sessionSecret string) *Handlers { return &Handlers{queries: queries, sessionSecret: sessionSecret} } // Helpers func writeJSON(w http.ResponseWriter, status int, v interface{}) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(v) } func writeError(w http.ResponseWriter, status int, msg string) { writeJSON(w, status, map[string]string{"error": msg}) } func decodeJSON(r *http.Request, v interface{}) error { return json.NewDecoder(r.Body).Decode(v) } func parseUUID(s string) (uuid.UUID, error) { return uuid.Parse(s) } // Auth Handlers func (h *Handlers) Register(w http.ResponseWriter, r *http.Request) { var req struct { Email string `json:"email"` Password string `json:"password"` DisplayName string `json:"display_name"` } if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } if req.Email == "" || req.Password == "" { writeError(w, http.StatusBadRequest, "email and password required") return } hash, err := auth.HashPassword(req.Password) if err != nil { writeError(w, http.StatusInternalServerError, "internal error") return } user, err := h.queries.CreateUser(r.Context(), req.Email, hash, req.DisplayName, "en") if err != nil { writeError(w, http.StatusConflict, "email already registered") return } token, err := auth.GenerateToken(user.ID, h.sessionSecret) if err != nil { writeError(w, http.StatusInternalServerError, "internal error") return } auth.SetSessionCookie(w, token) writeJSON(w, http.StatusCreated, user) } func (h *Handlers) Login(w http.ResponseWriter, r *http.Request) { var req struct { Email string `json:"email"` Password string `json:"password"` } if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } user, err := h.queries.GetUserByEmail(r.Context(), req.Email) if err != nil { writeError(w, http.StatusUnauthorized, "invalid credentials") return } if err := auth.CheckPassword(user.PasswordHash, req.Password); err != nil { writeError(w, http.StatusUnauthorized, "invalid credentials") return } token, err := auth.GenerateToken(user.ID, h.sessionSecret) if err != nil { writeError(w, http.StatusInternalServerError, "internal error") return } auth.SetSessionCookie(w, token) writeJSON(w, http.StatusOK, user) } func (h *Handlers) Logout(w http.ResponseWriter, r *http.Request) { auth.ClearSessionCookie(w) w.WriteHeader(http.StatusNoContent) } func (h *Handlers) Me(w http.ResponseWriter, r *http.Request) { cookie, err := r.Cookie("session") if err != nil { writeError(w, http.StatusUnauthorized, "unauthorized") return } userID, err := auth.ValidateToken(cookie.Value, h.sessionSecret) if err != nil { writeError(w, http.StatusUnauthorized, "unauthorized") return } user, err := h.queries.GetUserByID(r.Context(), userID) if err != nil { writeError(w, http.StatusUnauthorized, "unauthorized") return } writeJSON(w, http.StatusOK, user) } func (h *Handlers) OIDCStart(w http.ResponseWriter, r *http.Request) { // OIDC flow placeholder - would redirect to OIDC provider writeError(w, http.StatusNotImplemented, "OIDC not configured") } func (h *Handlers) OIDCCallback(w http.ResponseWriter, r *http.Request) { // OIDC callback placeholder writeError(w, http.StatusNotImplemented, "OIDC not configured") } // Category Handlers func (h *Handlers) ListCategories(w http.ResponseWriter, r *http.Request) { userID := auth.UserIDFromContext(r.Context()) cats, err := h.queries.ListCategories(r.Context(), userID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list categories") return } if cats == nil { cats = []models.Category{} } writeJSON(w, http.StatusOK, cats) } func (h *Handlers) CreateCategory(w http.ResponseWriter, r *http.Request) { userID := auth.UserIDFromContext(r.Context()) var req struct { Name string `json:"name"` Type models.CategoryType `json:"type"` Icon string `json:"icon"` SortOrder int `json:"sort_order"` } if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } cat, err := h.queries.CreateCategory(r.Context(), userID, req.Name, req.Type, req.Icon, req.SortOrder) if err != nil { writeError(w, http.StatusInternalServerError, "failed to create category") return } writeJSON(w, http.StatusCreated, cat) } func (h *Handlers) UpdateCategory(w http.ResponseWriter, r *http.Request) { userID := auth.UserIDFromContext(r.Context()) id, err := parseUUID(chi.URLParam(r, "id")) if err != nil { writeError(w, http.StatusBadRequest, "invalid id") return } var req struct { Name string `json:"name"` Type models.CategoryType `json:"type"` Icon string `json:"icon"` SortOrder int `json:"sort_order"` } if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } cat, err := h.queries.UpdateCategory(r.Context(), id, userID, req.Name, req.Type, req.Icon, req.SortOrder) if err != nil { writeError(w, http.StatusNotFound, "category not found") return } writeJSON(w, http.StatusOK, cat) } func (h *Handlers) DeleteCategory(w http.ResponseWriter, r *http.Request) { userID := auth.UserIDFromContext(r.Context()) id, err := parseUUID(chi.URLParam(r, "id")) if err != nil { writeError(w, http.StatusBadRequest, "invalid id") return } if err := h.queries.DeleteCategory(r.Context(), id, userID); err != nil { writeError(w, http.StatusInternalServerError, "failed to delete category") return } w.WriteHeader(http.StatusNoContent) } // Budget Handlers func (h *Handlers) ListBudgets(w http.ResponseWriter, r *http.Request) { userID := auth.UserIDFromContext(r.Context()) budgets, err := h.queries.ListBudgets(r.Context(), userID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list budgets") return } if budgets == nil { budgets = []models.Budget{} } writeJSON(w, http.StatusOK, budgets) } func (h *Handlers) CreateBudget(w http.ResponseWriter, r *http.Request) { userID := auth.UserIDFromContext(r.Context()) var req struct { Name string `json:"name"` StartDate string `json:"start_date"` EndDate string `json:"end_date"` Currency string `json:"currency"` CarryoverAmount decimal.Decimal `json:"carryover_amount"` } if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } startDate, err := time.Parse("2006-01-02", req.StartDate) if err != nil { writeError(w, http.StatusBadRequest, "invalid start_date format") return } endDate, err := time.Parse("2006-01-02", req.EndDate) if err != nil { writeError(w, http.StatusBadRequest, "invalid end_date format") return } if req.Currency == "" { req.Currency = "EUR" } budget, err := h.queries.CreateBudget(r.Context(), userID, req.Name, startDate, endDate, req.Currency, req.CarryoverAmount) if err != nil { writeError(w, http.StatusInternalServerError, "failed to create budget") return } writeJSON(w, http.StatusCreated, budget) } func (h *Handlers) GetBudget(w http.ResponseWriter, r *http.Request) { userID := auth.UserIDFromContext(r.Context()) id, err := parseUUID(chi.URLParam(r, "id")) if err != nil { writeError(w, http.StatusBadRequest, "invalid id") return } detail, err := h.queries.GetBudgetWithItems(r.Context(), id, userID) if err != nil { writeError(w, http.StatusNotFound, "budget not found") return } if detail.Items == nil { detail.Items = []models.BudgetItem{} } writeJSON(w, http.StatusOK, detail) } func (h *Handlers) UpdateBudget(w http.ResponseWriter, r *http.Request) { userID := auth.UserIDFromContext(r.Context()) id, err := parseUUID(chi.URLParam(r, "id")) if err != nil { writeError(w, http.StatusBadRequest, "invalid id") return } var req struct { Name string `json:"name"` StartDate string `json:"start_date"` EndDate string `json:"end_date"` Currency string `json:"currency"` CarryoverAmount decimal.Decimal `json:"carryover_amount"` } if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } startDate, _ := time.Parse("2006-01-02", req.StartDate) endDate, _ := time.Parse("2006-01-02", req.EndDate) budget, err := h.queries.UpdateBudget(r.Context(), id, userID, req.Name, startDate, endDate, req.Currency, req.CarryoverAmount) if err != nil { writeError(w, http.StatusNotFound, "budget not found") return } writeJSON(w, http.StatusOK, budget) } func (h *Handlers) DeleteBudget(w http.ResponseWriter, r *http.Request) { userID := auth.UserIDFromContext(r.Context()) id, err := parseUUID(chi.URLParam(r, "id")) if err != nil { writeError(w, http.StatusBadRequest, "invalid id") return } if err := h.queries.DeleteBudget(r.Context(), id, userID); err != nil { writeError(w, http.StatusInternalServerError, "failed to delete budget") return } w.WriteHeader(http.StatusNoContent) } func (h *Handlers) CopyBudgetItems(w http.ResponseWriter, r *http.Request) { userID := auth.UserIDFromContext(r.Context()) id, err := parseUUID(chi.URLParam(r, "id")) if err != nil { writeError(w, http.StatusBadRequest, "invalid id") return } srcID, err := parseUUID(chi.URLParam(r, "srcId")) if err != nil { writeError(w, http.StatusBadRequest, "invalid source id") return } if err := h.queries.CopyBudgetItems(r.Context(), id, srcID, userID); err != nil { writeError(w, http.StatusInternalServerError, "failed to copy items") return } detail, err := h.queries.GetBudgetWithItems(r.Context(), id, userID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to get budget") return } writeJSON(w, http.StatusOK, detail) } // Budget Item Handlers func (h *Handlers) CreateBudgetItem(w http.ResponseWriter, r *http.Request) { budgetID, err := parseUUID(chi.URLParam(r, "id")) if err != nil { writeError(w, http.StatusBadRequest, "invalid budget id") return } var req struct { CategoryID uuid.UUID `json:"category_id"` BudgetedAmount decimal.Decimal `json:"budgeted_amount"` ActualAmount decimal.Decimal `json:"actual_amount"` Notes string `json:"notes"` ItemTier models.ItemTier `json:"item_tier"` } if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } item, err := h.queries.CreateBudgetItem(r.Context(), budgetID, req.CategoryID, req.BudgetedAmount, req.ActualAmount, req.Notes, req.ItemTier) if err != nil { writeError(w, http.StatusInternalServerError, "failed to create budget item") return } writeJSON(w, http.StatusCreated, item) } func (h *Handlers) UpdateBudgetItem(w http.ResponseWriter, r *http.Request) { budgetID, err := parseUUID(chi.URLParam(r, "id")) if err != nil { writeError(w, http.StatusBadRequest, "invalid budget id") return } itemID, err := parseUUID(chi.URLParam(r, "itemId")) if err != nil { writeError(w, http.StatusBadRequest, "invalid item id") return } var req struct { BudgetedAmount decimal.Decimal `json:"budgeted_amount"` ActualAmount decimal.Decimal `json:"actual_amount"` Notes string `json:"notes"` ItemTier models.ItemTier `json:"item_tier"` } if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } item, err := h.queries.UpdateBudgetItem(r.Context(), itemID, budgetID, req.BudgetedAmount, req.ActualAmount, req.Notes, req.ItemTier) if err != nil { writeError(w, http.StatusNotFound, "budget item not found") return } writeJSON(w, http.StatusOK, item) } func (h *Handlers) DeleteBudgetItem(w http.ResponseWriter, r *http.Request) { budgetID, err := parseUUID(chi.URLParam(r, "id")) if err != nil { writeError(w, http.StatusBadRequest, "invalid budget id") return } itemID, err := parseUUID(chi.URLParam(r, "itemId")) if err != nil { writeError(w, http.StatusBadRequest, "invalid item id") return } if err := h.queries.DeleteBudgetItem(r.Context(), itemID, budgetID); err != nil { writeError(w, http.StatusInternalServerError, "failed to delete budget item") return } w.WriteHeader(http.StatusNoContent) } // Template Handlers func (h *Handlers) GetTemplate(w http.ResponseWriter, r *http.Request) { userID := auth.UserIDFromContext(r.Context()) detail, err := h.queries.GetTemplate(r.Context(), userID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to get template") return } writeJSON(w, http.StatusOK, detail) } func (h *Handlers) UpdateTemplateName(w http.ResponseWriter, r *http.Request) { userID := auth.UserIDFromContext(r.Context()) var req struct { Name string `json:"name"` } if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } tmpl, err := h.queries.UpdateTemplateName(r.Context(), userID, req.Name) if err != nil { writeError(w, http.StatusNotFound, "no template found") return } writeJSON(w, http.StatusOK, tmpl) } func (h *Handlers) CreateTemplateItem(w http.ResponseWriter, r *http.Request) { userID := auth.UserIDFromContext(r.Context()) var req struct { CategoryID uuid.UUID `json:"category_id"` ItemTier models.ItemTier `json:"item_tier"` BudgetedAmount *decimal.Decimal `json:"budgeted_amount"` SortOrder int `json:"sort_order"` } if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } if req.ItemTier == models.ItemTierOneOff { writeError(w, http.StatusBadRequest, "one-off items cannot be added to templates") return } if req.ItemTier != models.ItemTierFixed && req.ItemTier != models.ItemTierVariable { writeError(w, http.StatusBadRequest, "item_tier must be 'fixed' or 'variable'") return } if req.ItemTier == models.ItemTierFixed && req.BudgetedAmount == nil { writeError(w, http.StatusBadRequest, "fixed items require budgeted_amount") return } item, err := h.queries.CreateTemplateItem(r.Context(), userID, req.CategoryID, req.ItemTier, req.BudgetedAmount, req.SortOrder) if err != nil { writeError(w, http.StatusInternalServerError, "failed to create template item") return } writeJSON(w, http.StatusCreated, item) } func (h *Handlers) UpdateTemplateItem(w http.ResponseWriter, r *http.Request) { userID := auth.UserIDFromContext(r.Context()) itemID, err := parseUUID(chi.URLParam(r, "itemId")) if err != nil { writeError(w, http.StatusBadRequest, "invalid item id") return } var req struct { ItemTier models.ItemTier `json:"item_tier"` BudgetedAmount *decimal.Decimal `json:"budgeted_amount"` SortOrder int `json:"sort_order"` } if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } if req.ItemTier == models.ItemTierOneOff { writeError(w, http.StatusBadRequest, "one-off items cannot be added to templates") return } if req.ItemTier != models.ItemTierFixed && req.ItemTier != models.ItemTierVariable { writeError(w, http.StatusBadRequest, "item_tier must be 'fixed' or 'variable'") return } if req.ItemTier == models.ItemTierFixed && req.BudgetedAmount == nil { writeError(w, http.StatusBadRequest, "fixed items require budgeted_amount") return } item, err := h.queries.UpdateTemplateItem(r.Context(), userID, itemID, req.ItemTier, req.BudgetedAmount, req.SortOrder) if err != nil { writeError(w, http.StatusNotFound, "template item not found") return } writeJSON(w, http.StatusOK, item) } func (h *Handlers) DeleteTemplateItem(w http.ResponseWriter, r *http.Request) { userID := auth.UserIDFromContext(r.Context()) itemID, err := parseUUID(chi.URLParam(r, "itemId")) if err != nil { writeError(w, http.StatusBadRequest, "invalid item id") return } if err := h.queries.DeleteTemplateItem(r.Context(), userID, itemID); err != nil { writeError(w, http.StatusInternalServerError, "failed to delete template item") return } w.WriteHeader(http.StatusNoContent) } func (h *Handlers) ReorderTemplateItems(w http.ResponseWriter, r *http.Request) { userID := auth.UserIDFromContext(r.Context()) var req struct { Items []struct { ID uuid.UUID `json:"id"` SortOrder int `json:"sort_order"` } `json:"items"` } if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } itemOrders := make([]struct { ID uuid.UUID SortOrder int }, len(req.Items)) for i, item := range req.Items { itemOrders[i].ID = item.ID itemOrders[i].SortOrder = item.SortOrder } if err := h.queries.ReorderTemplateItems(r.Context(), userID, itemOrders); err != nil { writeError(w, http.StatusInternalServerError, "failed to reorder template items") return } w.WriteHeader(http.StatusNoContent) } // Budget Generation Handler func (h *Handlers) GenerateBudget(w http.ResponseWriter, r *http.Request) { userID := auth.UserIDFromContext(r.Context()) var req struct { Month string `json:"month"` Currency string `json:"currency"` } if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } if _, err := time.Parse("2006-01", req.Month); err != nil { writeError(w, http.StatusBadRequest, "invalid month format, expected YYYY-MM") return } if req.Currency == "" { req.Currency = "EUR" } detail, err := h.queries.GenerateBudgetFromTemplate(r.Context(), userID, req.Month, req.Currency) if err != nil { var budgetExistsErr *db.BudgetExistsError if errors.As(err, &budgetExistsErr) { writeJSON(w, http.StatusConflict, map[string]string{ "error": "budget already exists", "budget_id": budgetExistsErr.ExistingBudgetID.String(), }) return } writeError(w, http.StatusInternalServerError, "failed to generate budget") return } writeJSON(w, http.StatusCreated, detail) } // Quick Add Item Handlers func (h *Handlers) ListQuickAddItems(w http.ResponseWriter, r *http.Request) { userID := auth.UserIDFromContext(r.Context()) items, err := h.queries.ListQuickAddItems(r.Context(), userID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list quick add items") return } writeJSON(w, http.StatusOK, items) } func (h *Handlers) CreateQuickAddItem(w http.ResponseWriter, r *http.Request) { userID := auth.UserIDFromContext(r.Context()) var req struct { Name string `json:"name"` Icon string `json:"icon"` } if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } if req.Name == "" { writeError(w, http.StatusBadRequest, "name is required") return } item, err := h.queries.CreateQuickAddItem(r.Context(), userID, req.Name, req.Icon) if err != nil { writeError(w, http.StatusInternalServerError, "failed to create quick add item") return } writeJSON(w, http.StatusCreated, item) } func (h *Handlers) UpdateQuickAddItem(w http.ResponseWriter, r *http.Request) { userID := auth.UserIDFromContext(r.Context()) itemID, err := parseUUID(chi.URLParam(r, "itemId")) if err != nil { writeError(w, http.StatusBadRequest, "invalid item id") return } var req struct { Name string `json:"name"` Icon string `json:"icon"` SortOrder int `json:"sort_order"` } if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } if req.Name == "" { writeError(w, http.StatusBadRequest, "name is required") return } item, err := h.queries.UpdateQuickAddItem(r.Context(), itemID, userID, req.Name, req.Icon, req.SortOrder) if err != nil { writeError(w, http.StatusNotFound, "quick add item not found") return } writeJSON(w, http.StatusOK, item) } func (h *Handlers) DeleteQuickAddItem(w http.ResponseWriter, r *http.Request) { userID := auth.UserIDFromContext(r.Context()) itemID, err := parseUUID(chi.URLParam(r, "itemId")) if err != nil { writeError(w, http.StatusBadRequest, "invalid item id") return } if err := h.queries.DeleteQuickAddItem(r.Context(), itemID, userID); err != nil { writeError(w, http.StatusInternalServerError, "failed to delete quick add item") return } w.WriteHeader(http.StatusNoContent) } // Settings Handlers func (h *Handlers) GetSettings(w http.ResponseWriter, r *http.Request) { userID := auth.UserIDFromContext(r.Context()) user, err := h.queries.GetUserByID(r.Context(), userID) if err != nil { writeError(w, http.StatusNotFound, "user not found") return } writeJSON(w, http.StatusOK, user) } func (h *Handlers) UpdateSettings(w http.ResponseWriter, r *http.Request) { userID := auth.UserIDFromContext(r.Context()) var req struct { DisplayName string `json:"display_name"` PreferredLocale string `json:"preferred_locale"` } if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } user, err := h.queries.UpdateUser(r.Context(), userID, req.DisplayName, req.PreferredLocale) if err != nil { writeError(w, http.StatusInternalServerError, "failed to update settings") return } writeJSON(w, http.StatusOK, user) }