Init
This commit is contained in:
101
backend/cmd/server/main.go
Normal file
101
backend/cmd/server/main.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"simplefinancedash/backend/internal/api"
|
||||
"simplefinancedash/backend/internal/db"
|
||||
)
|
||||
|
||||
// These directories are populated at build time:
|
||||
// - frontend_dist/ is copied from frontend/dist by the Dockerfile
|
||||
// - migrations/ is symlinked or copied from backend/migrations/
|
||||
|
||||
//go:embed frontend_dist
|
||||
var frontendFiles embed.FS
|
||||
|
||||
//go:embed migrations
|
||||
var migrationsFiles embed.FS
|
||||
|
||||
func main() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
databaseURL := getEnv("DATABASE_URL", "postgres://simplefin:simplefin@localhost:5432/simplefindb?sslmode=disable")
|
||||
sessionSecret := getEnv("SESSION_SECRET", "change-me-in-production")
|
||||
port := getEnv("PORT", "8080")
|
||||
|
||||
// Connect to database
|
||||
pool, err := db.Connect(ctx, databaseURL)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect to database: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
// Run migrations
|
||||
migrationsFS, err := fs.Sub(migrationsFiles, "migrations")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to setup migrations filesystem: %v", err)
|
||||
}
|
||||
if err := db.RunMigrations(ctx, pool, migrationsFS); err != nil {
|
||||
log.Fatalf("Failed to run migrations: %v", err)
|
||||
}
|
||||
log.Println("Migrations completed")
|
||||
|
||||
// Setup frontend filesystem
|
||||
frontendFS, err := fs.Sub(frontendFiles, "frontend_dist")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to setup frontend filesystem: %v", err)
|
||||
}
|
||||
|
||||
// Create router
|
||||
queries := db.NewQueries(pool)
|
||||
router := api.NewRouter(queries, sessionSecret, frontendFS)
|
||||
|
||||
// Start server
|
||||
server := &http.Server{
|
||||
Addr: ":" + port,
|
||||
Handler: router,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 15 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
log.Printf("Server starting on :%s", port)
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("Server failed: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Graceful shutdown
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
log.Println("Shutting down server...")
|
||||
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer shutdownCancel()
|
||||
|
||||
if err := server.Shutdown(shutdownCtx); err != nil {
|
||||
log.Fatalf("Server forced to shutdown: %v", err)
|
||||
}
|
||||
|
||||
fmt.Println("Server stopped")
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
21
backend/go.mod
Normal file
21
backend/go.mod
Normal file
@@ -0,0 +1,21 @@
|
||||
module simplefinancedash/backend
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/go-chi/cors v1.2.2
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.8.0
|
||||
github.com/shopspring/decimal v1.4.0
|
||||
golang.org/x/crypto v0.48.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
)
|
||||
38
backend/go.sum
Normal file
38
backend/go.sum
Normal file
@@ -0,0 +1,38 @@
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
||||
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
480
backend/internal/api/handlers.go
Normal file
480
backend/internal/api/handlers.go
Normal file
@@ -0,0 +1,480 @@
|
||||
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"`
|
||||
}
|
||||
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)
|
||||
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"`
|
||||
}
|
||||
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)
|
||||
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)
|
||||
}
|
||||
83
backend/internal/api/router.go
Normal file
83
backend/internal/api/router.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
"simplefinancedash/backend/internal/auth"
|
||||
"simplefinancedash/backend/internal/db"
|
||||
)
|
||||
|
||||
func NewRouter(queries *db.Queries, sessionSecret string, frontendFS fs.FS) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(middleware.Compress(5))
|
||||
r.Use(cors.Handler(cors.Options{
|
||||
AllowedOrigins: []string{"http://localhost:5173", "http://localhost:8080"},
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Content-Type"},
|
||||
AllowCredentials: true,
|
||||
}))
|
||||
|
||||
h := NewHandlers(queries, sessionSecret)
|
||||
|
||||
// Auth routes (no auth required)
|
||||
r.Route("/api/auth", func(r chi.Router) {
|
||||
r.Post("/register", h.Register)
|
||||
r.Post("/login", h.Login)
|
||||
r.Post("/logout", h.Logout)
|
||||
r.Get("/me", h.Me)
|
||||
r.Get("/oidc", h.OIDCStart)
|
||||
r.Get("/oidc/callback", h.OIDCCallback)
|
||||
})
|
||||
|
||||
// Protected routes
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(auth.Middleware(sessionSecret))
|
||||
|
||||
r.Route("/api/categories", func(r chi.Router) {
|
||||
r.Get("/", h.ListCategories)
|
||||
r.Post("/", h.CreateCategory)
|
||||
r.Put("/{id}", h.UpdateCategory)
|
||||
r.Delete("/{id}", h.DeleteCategory)
|
||||
})
|
||||
|
||||
r.Route("/api/budgets", func(r chi.Router) {
|
||||
r.Get("/", h.ListBudgets)
|
||||
r.Post("/", h.CreateBudget)
|
||||
r.Get("/{id}", h.GetBudget)
|
||||
r.Put("/{id}", h.UpdateBudget)
|
||||
r.Delete("/{id}", h.DeleteBudget)
|
||||
r.Post("/{id}/copy-from/{srcId}", h.CopyBudgetItems)
|
||||
|
||||
r.Post("/{id}/items", h.CreateBudgetItem)
|
||||
r.Put("/{id}/items/{itemId}", h.UpdateBudgetItem)
|
||||
r.Delete("/{id}/items/{itemId}", h.DeleteBudgetItem)
|
||||
})
|
||||
|
||||
r.Get("/api/settings", h.GetSettings)
|
||||
r.Put("/api/settings", h.UpdateSettings)
|
||||
})
|
||||
|
||||
// Serve SPA for all non-API routes
|
||||
spaHandler := http.FileServer(http.FS(frontendFS))
|
||||
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Try to serve the file directly first
|
||||
f, err := frontendFS.Open(r.URL.Path[1:]) // strip leading /
|
||||
if err == nil {
|
||||
f.Close()
|
||||
spaHandler.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
// Fall back to index.html for SPA routing
|
||||
r.URL.Path = "/"
|
||||
spaHandler.ServeHTTP(w, r)
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
109
backend/internal/auth/auth.go
Normal file
109
backend/internal/auth/auth.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const userIDKey contextKey = "userID"
|
||||
|
||||
func HashPassword(password string) (string, error) {
|
||||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("hashing password: %w", err)
|
||||
}
|
||||
return string(bytes), nil
|
||||
}
|
||||
|
||||
func CheckPassword(hash, password string) error {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||
}
|
||||
|
||||
func GenerateToken(userID uuid.UUID, secret string) (string, error) {
|
||||
claims := jwt.MapClaims{
|
||||
"sub": userID.String(),
|
||||
"exp": time.Now().Add(24 * 7 * time.Hour).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(secret))
|
||||
}
|
||||
|
||||
func ValidateToken(tokenString, secret string) (uuid.UUID, error) {
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return []byte(secret), nil
|
||||
})
|
||||
if err != nil {
|
||||
return uuid.Nil, fmt.Errorf("parsing token: %w", err)
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok || !token.Valid {
|
||||
return uuid.Nil, fmt.Errorf("invalid token")
|
||||
}
|
||||
|
||||
sub, ok := claims["sub"].(string)
|
||||
if !ok {
|
||||
return uuid.Nil, fmt.Errorf("invalid subject claim")
|
||||
}
|
||||
|
||||
return uuid.Parse(sub)
|
||||
}
|
||||
|
||||
func Middleware(secret string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie("session")
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := ValidateToken(cookie.Value, secret)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), userIDKey, userID)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func UserIDFromContext(ctx context.Context) uuid.UUID {
|
||||
id, _ := ctx.Value(userIDKey).(uuid.UUID)
|
||||
return id
|
||||
}
|
||||
|
||||
func SetSessionCookie(w http.ResponseWriter, token string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "session",
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: 7 * 24 * 60 * 60,
|
||||
})
|
||||
}
|
||||
|
||||
func ClearSessionCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "session",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
MaxAge: -1,
|
||||
})
|
||||
}
|
||||
82
backend/internal/db/db.go
Normal file
82
backend/internal/db/db.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func Connect(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) {
|
||||
pool, err := pgxpool.New(ctx, databaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connecting to database: %w", err)
|
||||
}
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("pinging database: %w", err)
|
||||
}
|
||||
return pool, nil
|
||||
}
|
||||
|
||||
func RunMigrations(ctx context.Context, pool *pgxpool.Pool, migrationsFS fs.FS) error {
|
||||
entries, err := fs.ReadDir(migrationsFS, ".")
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading migrations directory: %w", err)
|
||||
}
|
||||
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
return entries[i].Name() < entries[j].Name()
|
||||
})
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") {
|
||||
continue
|
||||
}
|
||||
|
||||
var version int
|
||||
fmt.Sscanf(entry.Name(), "%d", &version)
|
||||
|
||||
var exists bool
|
||||
err := pool.QueryRow(ctx,
|
||||
"SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version = $1)", version,
|
||||
).Scan(&exists)
|
||||
if err != nil {
|
||||
exists = false
|
||||
}
|
||||
if exists {
|
||||
continue
|
||||
}
|
||||
|
||||
content, err := fs.ReadFile(migrationsFS, entry.Name())
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading migration %s: %w", entry.Name(), err)
|
||||
}
|
||||
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("beginning transaction for %s: %w", entry.Name(), err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, string(content)); err != nil {
|
||||
tx.Rollback(ctx)
|
||||
return fmt.Errorf("executing migration %s: %w", entry.Name(), err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx,
|
||||
"INSERT INTO schema_migrations (version) VALUES ($1)", version,
|
||||
); err != nil {
|
||||
tx.Rollback(ctx)
|
||||
return fmt.Errorf("recording migration %s: %w", entry.Name(), err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("committing migration %s: %w", entry.Name(), err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
357
backend/internal/db/queries.go
Normal file
357
backend/internal/db/queries.go
Normal file
@@ -0,0 +1,357 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/shopspring/decimal"
|
||||
"simplefinancedash/backend/internal/models"
|
||||
)
|
||||
|
||||
type Queries struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewQueries(pool *pgxpool.Pool) *Queries {
|
||||
return &Queries{pool: pool}
|
||||
}
|
||||
|
||||
// Users
|
||||
|
||||
func (q *Queries) CreateUser(ctx context.Context, email, passwordHash, displayName, locale string) (*models.User, error) {
|
||||
u := &models.User{}
|
||||
err := q.pool.QueryRow(ctx,
|
||||
`INSERT INTO users (email, password_hash, display_name, preferred_locale)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, email, password_hash, oidc_subject, display_name, preferred_locale, created_at, updated_at`,
|
||||
email, passwordHash, displayName, locale,
|
||||
).Scan(&u.ID, &u.Email, &u.PasswordHash, &u.OIDCSubject, &u.DisplayName, &u.PreferredLocale, &u.CreatedAt, &u.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating user: %w", err)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (q *Queries) GetUserByEmail(ctx context.Context, email string) (*models.User, error) {
|
||||
u := &models.User{}
|
||||
err := q.pool.QueryRow(ctx,
|
||||
`SELECT id, email, password_hash, oidc_subject, display_name, preferred_locale, created_at, updated_at
|
||||
FROM users WHERE email = $1`, email,
|
||||
).Scan(&u.ID, &u.Email, &u.PasswordHash, &u.OIDCSubject, &u.DisplayName, &u.PreferredLocale, &u.CreatedAt, &u.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting user by email: %w", err)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (q *Queries) GetUserByID(ctx context.Context, id uuid.UUID) (*models.User, error) {
|
||||
u := &models.User{}
|
||||
err := q.pool.QueryRow(ctx,
|
||||
`SELECT id, email, password_hash, oidc_subject, display_name, preferred_locale, created_at, updated_at
|
||||
FROM users WHERE id = $1`, id,
|
||||
).Scan(&u.ID, &u.Email, &u.PasswordHash, &u.OIDCSubject, &u.DisplayName, &u.PreferredLocale, &u.CreatedAt, &u.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting user by id: %w", err)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (q *Queries) GetUserByOIDCSubject(ctx context.Context, subject string) (*models.User, error) {
|
||||
u := &models.User{}
|
||||
err := q.pool.QueryRow(ctx,
|
||||
`SELECT id, email, password_hash, oidc_subject, display_name, preferred_locale, created_at, updated_at
|
||||
FROM users WHERE oidc_subject = $1`, subject,
|
||||
).Scan(&u.ID, &u.Email, &u.PasswordHash, &u.OIDCSubject, &u.DisplayName, &u.PreferredLocale, &u.CreatedAt, &u.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting user by oidc subject: %w", err)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateUser(ctx context.Context, id uuid.UUID, displayName, locale string) (*models.User, error) {
|
||||
u := &models.User{}
|
||||
err := q.pool.QueryRow(ctx,
|
||||
`UPDATE users SET display_name = $2, preferred_locale = $3, updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, email, password_hash, oidc_subject, display_name, preferred_locale, created_at, updated_at`,
|
||||
id, displayName, locale,
|
||||
).Scan(&u.ID, &u.Email, &u.PasswordHash, &u.OIDCSubject, &u.DisplayName, &u.PreferredLocale, &u.CreatedAt, &u.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("updating user: %w", err)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (q *Queries) UpsertOIDCUser(ctx context.Context, email, subject, displayName string) (*models.User, error) {
|
||||
u := &models.User{}
|
||||
err := q.pool.QueryRow(ctx,
|
||||
`INSERT INTO users (email, oidc_subject, display_name)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (oidc_subject) DO UPDATE SET email = $1, display_name = $3, updated_at = now()
|
||||
RETURNING id, email, password_hash, oidc_subject, display_name, preferred_locale, created_at, updated_at`,
|
||||
email, subject, displayName,
|
||||
).Scan(&u.ID, &u.Email, &u.PasswordHash, &u.OIDCSubject, &u.DisplayName, &u.PreferredLocale, &u.CreatedAt, &u.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("upserting oidc user: %w", err)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// Categories
|
||||
|
||||
func (q *Queries) ListCategories(ctx context.Context, userID uuid.UUID) ([]models.Category, error) {
|
||||
rows, err := q.pool.Query(ctx,
|
||||
`SELECT id, user_id, name, type, icon, sort_order, created_at, updated_at
|
||||
FROM categories WHERE user_id = $1 ORDER BY type, sort_order, name`, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing categories: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var cats []models.Category
|
||||
for rows.Next() {
|
||||
var c models.Category
|
||||
if err := rows.Scan(&c.ID, &c.UserID, &c.Name, &c.Type, &c.Icon, &c.SortOrder, &c.CreatedAt, &c.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scanning category: %w", err)
|
||||
}
|
||||
cats = append(cats, c)
|
||||
}
|
||||
return cats, nil
|
||||
}
|
||||
|
||||
func (q *Queries) CreateCategory(ctx context.Context, userID uuid.UUID, name string, catType models.CategoryType, icon string, sortOrder int) (*models.Category, error) {
|
||||
c := &models.Category{}
|
||||
err := q.pool.QueryRow(ctx,
|
||||
`INSERT INTO categories (user_id, name, type, icon, sort_order)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, user_id, name, type, icon, sort_order, created_at, updated_at`,
|
||||
userID, name, catType, icon, sortOrder,
|
||||
).Scan(&c.ID, &c.UserID, &c.Name, &c.Type, &c.Icon, &c.SortOrder, &c.CreatedAt, &c.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating category: %w", err)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateCategory(ctx context.Context, id, userID uuid.UUID, name string, catType models.CategoryType, icon string, sortOrder int) (*models.Category, error) {
|
||||
c := &models.Category{}
|
||||
err := q.pool.QueryRow(ctx,
|
||||
`UPDATE categories SET name = $3, type = $4, icon = $5, sort_order = $6, updated_at = now()
|
||||
WHERE id = $1 AND user_id = $2
|
||||
RETURNING id, user_id, name, type, icon, sort_order, created_at, updated_at`,
|
||||
id, userID, name, catType, icon, sortOrder,
|
||||
).Scan(&c.ID, &c.UserID, &c.Name, &c.Type, &c.Icon, &c.SortOrder, &c.CreatedAt, &c.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("updating category: %w", err)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (q *Queries) DeleteCategory(ctx context.Context, id, userID uuid.UUID) error {
|
||||
_, err := q.pool.Exec(ctx,
|
||||
`DELETE FROM categories WHERE id = $1 AND user_id = $2`, id, userID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// Budgets
|
||||
|
||||
func (q *Queries) ListBudgets(ctx context.Context, userID uuid.UUID) ([]models.Budget, error) {
|
||||
rows, err := q.pool.Query(ctx,
|
||||
`SELECT id, user_id, name, start_date, end_date, currency, carryover_amount, created_at, updated_at
|
||||
FROM budgets WHERE user_id = $1 ORDER BY start_date DESC`, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing budgets: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var budgets []models.Budget
|
||||
for rows.Next() {
|
||||
var b models.Budget
|
||||
if err := rows.Scan(&b.ID, &b.UserID, &b.Name, &b.StartDate, &b.EndDate, &b.Currency, &b.CarryoverAmount, &b.CreatedAt, &b.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scanning budget: %w", err)
|
||||
}
|
||||
budgets = append(budgets, b)
|
||||
}
|
||||
return budgets, nil
|
||||
}
|
||||
|
||||
func (q *Queries) CreateBudget(ctx context.Context, userID uuid.UUID, name string, startDate, endDate time.Time, currency string, carryover decimal.Decimal) (*models.Budget, error) {
|
||||
b := &models.Budget{}
|
||||
err := q.pool.QueryRow(ctx,
|
||||
`INSERT INTO budgets (user_id, name, start_date, end_date, currency, carryover_amount)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, user_id, name, start_date, end_date, currency, carryover_amount, created_at, updated_at`,
|
||||
userID, name, startDate, endDate, currency, carryover,
|
||||
).Scan(&b.ID, &b.UserID, &b.Name, &b.StartDate, &b.EndDate, &b.Currency, &b.CarryoverAmount, &b.CreatedAt, &b.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating budget: %w", err)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func (q *Queries) GetBudget(ctx context.Context, id, userID uuid.UUID) (*models.Budget, error) {
|
||||
b := &models.Budget{}
|
||||
err := q.pool.QueryRow(ctx,
|
||||
`SELECT id, user_id, name, start_date, end_date, currency, carryover_amount, created_at, updated_at
|
||||
FROM budgets WHERE id = $1 AND user_id = $2`, id, userID,
|
||||
).Scan(&b.ID, &b.UserID, &b.Name, &b.StartDate, &b.EndDate, &b.Currency, &b.CarryoverAmount, &b.CreatedAt, &b.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting budget: %w", err)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateBudget(ctx context.Context, id, userID uuid.UUID, name string, startDate, endDate time.Time, currency string, carryover decimal.Decimal) (*models.Budget, error) {
|
||||
b := &models.Budget{}
|
||||
err := q.pool.QueryRow(ctx,
|
||||
`UPDATE budgets SET name = $3, start_date = $4, end_date = $5, currency = $6, carryover_amount = $7, updated_at = now()
|
||||
WHERE id = $1 AND user_id = $2
|
||||
RETURNING id, user_id, name, start_date, end_date, currency, carryover_amount, created_at, updated_at`,
|
||||
id, userID, name, startDate, endDate, currency, carryover,
|
||||
).Scan(&b.ID, &b.UserID, &b.Name, &b.StartDate, &b.EndDate, &b.Currency, &b.CarryoverAmount, &b.CreatedAt, &b.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("updating budget: %w", err)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func (q *Queries) DeleteBudget(ctx context.Context, id, userID uuid.UUID) error {
|
||||
_, err := q.pool.Exec(ctx,
|
||||
`DELETE FROM budgets WHERE id = $1 AND user_id = $2`, id, userID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (q *Queries) GetBudgetWithItems(ctx context.Context, id, userID uuid.UUID) (*models.BudgetDetail, error) {
|
||||
budget, err := q.GetBudget(ctx, id, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err := q.pool.Query(ctx,
|
||||
`SELECT bi.id, bi.budget_id, bi.category_id, c.name, c.type,
|
||||
bi.budgeted_amount, bi.actual_amount, bi.notes, bi.created_at, bi.updated_at
|
||||
FROM budget_items bi
|
||||
JOIN categories c ON c.id = bi.category_id
|
||||
WHERE bi.budget_id = $1
|
||||
ORDER BY c.type, c.sort_order, c.name`, id,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing budget items: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var items []models.BudgetItem
|
||||
for rows.Next() {
|
||||
var i models.BudgetItem
|
||||
if err := rows.Scan(&i.ID, &i.BudgetID, &i.CategoryID, &i.CategoryName, &i.CategoryType,
|
||||
&i.BudgetedAmount, &i.ActualAmount, &i.Notes, &i.CreatedAt, &i.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scanning budget item: %w", err)
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
|
||||
totals := computeTotals(budget.CarryoverAmount, items)
|
||||
|
||||
return &models.BudgetDetail{
|
||||
Budget: *budget,
|
||||
Items: items,
|
||||
Totals: totals,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func computeTotals(carryover decimal.Decimal, items []models.BudgetItem) models.BudgetTotals {
|
||||
var t models.BudgetTotals
|
||||
for _, item := range items {
|
||||
switch item.CategoryType {
|
||||
case models.CategoryIncome:
|
||||
t.IncomeBudget = t.IncomeBudget.Add(item.BudgetedAmount)
|
||||
t.IncomeActual = t.IncomeActual.Add(item.ActualAmount)
|
||||
case models.CategoryBill:
|
||||
t.BillsBudget = t.BillsBudget.Add(item.BudgetedAmount)
|
||||
t.BillsActual = t.BillsActual.Add(item.ActualAmount)
|
||||
case models.CategoryVariableExpense:
|
||||
t.ExpensesBudget = t.ExpensesBudget.Add(item.BudgetedAmount)
|
||||
t.ExpensesActual = t.ExpensesActual.Add(item.ActualAmount)
|
||||
case models.CategoryDebt:
|
||||
t.DebtsBudget = t.DebtsBudget.Add(item.BudgetedAmount)
|
||||
t.DebtsActual = t.DebtsActual.Add(item.ActualAmount)
|
||||
case models.CategorySaving:
|
||||
t.SavingsBudget = t.SavingsBudget.Add(item.BudgetedAmount)
|
||||
t.SavingsActual = t.SavingsActual.Add(item.ActualAmount)
|
||||
case models.CategoryInvestment:
|
||||
t.InvestmentsBudget = t.InvestmentsBudget.Add(item.BudgetedAmount)
|
||||
t.InvestmentsActual = t.InvestmentsActual.Add(item.ActualAmount)
|
||||
}
|
||||
}
|
||||
|
||||
t.Available = carryover.Add(t.IncomeActual).
|
||||
Sub(t.BillsActual).
|
||||
Sub(t.ExpensesActual).
|
||||
Sub(t.DebtsActual).
|
||||
Sub(t.SavingsActual).
|
||||
Sub(t.InvestmentsActual)
|
||||
|
||||
return t
|
||||
}
|
||||
|
||||
func (q *Queries) CopyBudgetItems(ctx context.Context, targetBudgetID, sourceBudgetID, userID uuid.UUID) error {
|
||||
// Verify both budgets belong to user
|
||||
if _, err := q.GetBudget(ctx, targetBudgetID, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := q.GetBudget(ctx, sourceBudgetID, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err := q.pool.Exec(ctx,
|
||||
`INSERT INTO budget_items (budget_id, category_id, budgeted_amount, actual_amount, notes)
|
||||
SELECT $1, category_id, budgeted_amount, 0, ''
|
||||
FROM budget_items WHERE budget_id = $2`,
|
||||
targetBudgetID, sourceBudgetID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// Budget Items
|
||||
|
||||
func (q *Queries) CreateBudgetItem(ctx context.Context, budgetID, categoryID uuid.UUID, budgeted, actual decimal.Decimal, notes string) (*models.BudgetItem, error) {
|
||||
i := &models.BudgetItem{}
|
||||
err := q.pool.QueryRow(ctx,
|
||||
`INSERT INTO budget_items (budget_id, category_id, budgeted_amount, actual_amount, notes)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, budget_id, category_id, budgeted_amount, actual_amount, notes, created_at, updated_at`,
|
||||
budgetID, categoryID, budgeted, actual, notes,
|
||||
).Scan(&i.ID, &i.BudgetID, &i.CategoryID, &i.BudgetedAmount, &i.ActualAmount, &i.Notes, &i.CreatedAt, &i.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating budget item: %w", err)
|
||||
}
|
||||
return i, nil
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateBudgetItem(ctx context.Context, id, budgetID uuid.UUID, budgeted, actual decimal.Decimal, notes string) (*models.BudgetItem, error) {
|
||||
i := &models.BudgetItem{}
|
||||
err := q.pool.QueryRow(ctx,
|
||||
`UPDATE budget_items SET budgeted_amount = $3, actual_amount = $4, notes = $5, updated_at = now()
|
||||
WHERE id = $1 AND budget_id = $2
|
||||
RETURNING id, budget_id, category_id, budgeted_amount, actual_amount, notes, created_at, updated_at`,
|
||||
id, budgetID, budgeted, actual, notes,
|
||||
).Scan(&i.ID, &i.BudgetID, &i.CategoryID, &i.BudgetedAmount, &i.ActualAmount, &i.Notes, &i.CreatedAt, &i.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("updating budget item: %w", err)
|
||||
}
|
||||
return i, nil
|
||||
}
|
||||
|
||||
func (q *Queries) DeleteBudgetItem(ctx context.Context, id, budgetID uuid.UUID) error {
|
||||
_, err := q.pool.Exec(ctx,
|
||||
`DELETE FROM budget_items WHERE id = $1 AND budget_id = $2`, id, budgetID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
88
backend/internal/models/models.go
Normal file
88
backend/internal/models/models.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
type CategoryType string
|
||||
|
||||
const (
|
||||
CategoryBill CategoryType = "bill"
|
||||
CategoryVariableExpense CategoryType = "variable_expense"
|
||||
CategoryDebt CategoryType = "debt"
|
||||
CategorySaving CategoryType = "saving"
|
||||
CategoryInvestment CategoryType = "investment"
|
||||
CategoryIncome CategoryType = "income"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Email string `json:"email"`
|
||||
PasswordHash string `json:"-"`
|
||||
OIDCSubject *string `json:"oidc_subject,omitempty"`
|
||||
DisplayName string `json:"display_name"`
|
||||
PreferredLocale string `json:"preferred_locale"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type Category struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
Type CategoryType `json:"type"`
|
||||
Icon string `json:"icon"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type Budget struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
StartDate time.Time `json:"start_date"`
|
||||
EndDate time.Time `json:"end_date"`
|
||||
Currency string `json:"currency"`
|
||||
CarryoverAmount decimal.Decimal `json:"carryover_amount"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type BudgetItem struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
BudgetID uuid.UUID `json:"budget_id"`
|
||||
CategoryID uuid.UUID `json:"category_id"`
|
||||
CategoryName string `json:"category_name,omitempty"`
|
||||
CategoryType CategoryType `json:"category_type,omitempty"`
|
||||
BudgetedAmount decimal.Decimal `json:"budgeted_amount"`
|
||||
ActualAmount decimal.Decimal `json:"actual_amount"`
|
||||
Notes string `json:"notes"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type BudgetTotals struct {
|
||||
IncomeBudget decimal.Decimal `json:"income_budget"`
|
||||
IncomeActual decimal.Decimal `json:"income_actual"`
|
||||
BillsBudget decimal.Decimal `json:"bills_budget"`
|
||||
BillsActual decimal.Decimal `json:"bills_actual"`
|
||||
ExpensesBudget decimal.Decimal `json:"expenses_budget"`
|
||||
ExpensesActual decimal.Decimal `json:"expenses_actual"`
|
||||
DebtsBudget decimal.Decimal `json:"debts_budget"`
|
||||
DebtsActual decimal.Decimal `json:"debts_actual"`
|
||||
SavingsBudget decimal.Decimal `json:"savings_budget"`
|
||||
SavingsActual decimal.Decimal `json:"savings_actual"`
|
||||
InvestmentsBudget decimal.Decimal `json:"investments_budget"`
|
||||
InvestmentsActual decimal.Decimal `json:"investments_actual"`
|
||||
Available decimal.Decimal `json:"available"`
|
||||
}
|
||||
|
||||
type BudgetDetail struct {
|
||||
Budget
|
||||
Items []BudgetItem `json:"items"`
|
||||
Totals BudgetTotals `json:"totals"`
|
||||
}
|
||||
70
backend/migrations/001_initial.sql
Normal file
70
backend/migrations/001_initial.sql
Normal file
@@ -0,0 +1,70 @@
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
CREATE TYPE category_type AS ENUM (
|
||||
'bill',
|
||||
'variable_expense',
|
||||
'debt',
|
||||
'saving',
|
||||
'investment',
|
||||
'income'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL DEFAULT '',
|
||||
oidc_subject TEXT,
|
||||
display_name TEXT NOT NULL DEFAULT '',
|
||||
preferred_locale TEXT NOT NULL DEFAULT 'en',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_users_email ON users (email);
|
||||
CREATE UNIQUE INDEX idx_users_oidc_subject ON users (oidc_subject) WHERE oidc_subject IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
type category_type NOT NULL,
|
||||
icon TEXT NOT NULL DEFAULT '',
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_categories_user_id ON categories (user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS budgets (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE NOT NULL,
|
||||
currency TEXT NOT NULL DEFAULT 'EUR',
|
||||
carryover_amount NUMERIC(12, 2) NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_budgets_user_id ON budgets (user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS budget_items (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
budget_id UUID NOT NULL REFERENCES budgets(id) ON DELETE CASCADE,
|
||||
category_id UUID NOT NULL REFERENCES categories(id) ON DELETE RESTRICT,
|
||||
budgeted_amount NUMERIC(12, 2) NOT NULL DEFAULT 0,
|
||||
actual_amount NUMERIC(12, 2) NOT NULL DEFAULT 0,
|
||||
notes TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_budget_items_budget_id ON budget_items (budget_id);
|
||||
CREATE INDEX idx_budget_items_category_id ON budget_items (category_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INT PRIMARY KEY,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
Reference in New Issue
Block a user