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
This commit is contained in:
@@ -2,6 +2,7 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -450,6 +451,191 @@ func (h *Handlers) DeleteBudgetItem(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusNoContent)
|
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
|
// Settings Handlers
|
||||||
|
|
||||||
func (h *Handlers) GetSettings(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) GetSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
Reference in New Issue
Block a user