- Add BudgetExistsError struct and ErrBudgetExists sentinel - Update GetBudgetWithItems, CopyBudgetItems to include item_tier - Update CreateBudgetItem/UpdateBudgetItem signatures to accept itemTier (default one_off) - Add GetTemplate, UpdateTemplateName, CreateTemplateItem, UpdateTemplateItem, DeleteTemplateItem - Add ReorderTemplateItems with transaction - Add GenerateBudgetFromTemplate with duplicate-month detection and locale-aware naming - Update handlers to pass ItemTier from request body (Rule 3 fix - blocking compile)
483 lines
14 KiB
Go
483 lines
14 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"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)
|
|
}
|
|
|
|
// 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)
|
|
}
|