From ceca2fc71f25d67244d18f38bfa3636e42b5a354 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 12 Mar 2026 12:10:21 +0100 Subject: [PATCH] feat(05-02): add template handlers and budget generation endpoint - Add GetTemplate, UpdateTemplateName, CreateTemplateItem, UpdateTemplateItem, DeleteTemplateItem, ReorderTemplateItems handlers - Add GenerateBudget handler with 409 BudgetExistsError response including budget_id - Handler-level validation: one_off items rejected for template routes, fixed items require budgeted_amount - Month format validated via time.Parse before calling query layer --- backend/internal/api/handlers.go | 186 +++++++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) diff --git a/backend/internal/api/handlers.go b/backend/internal/api/handlers.go index 6eeba21..d716906 100644 --- a/backend/internal/api/handlers.go +++ b/backend/internal/api/handlers.go @@ -2,6 +2,7 @@ package api import ( "encoding/json" + "errors" "net/http" "time" @@ -450,6 +451,191 @@ func (h *Handlers) DeleteBudgetItem(w http.ResponseWriter, r *http.Request) { 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) +} + // Settings Handlers func (h *Handlers) GetSettings(w http.ResponseWriter, r *http.Request) {