diff options
author | 2025-04-24 08:18:27 +0530 | |
---|---|---|
committer | 2025-04-24 08:18:27 +0530 | |
commit | 50d5e6534f5e593297a09323e683c7c8b850117b (patch) | |
tree | 339d6e8b123c5d4caa4129971e2cb1b960b12a89 | |
parent | 76066679b5bdab53419492066c4e80d2ed3be518 (diff) | |
download | finance-50d5e6534f5e593297a09323e683c7c8b850117b.tar.gz finance-50d5e6534f5e593297a09323e683c7c8b850117b.tar.bz2 finance-50d5e6534f5e593297a09323e683c7c8b850117b.zip |
feat: added basic backend features to it
- Set up API framework (Gin Gonic)
- Set up ORM/DB library (GORM)
- Design database schema (Users, Accounts, Transactions, Loans, Goals)
- Set up database connection and migrations
-rw-r--r-- | .gitignore | 6 | ||||
-rw-r--r-- | README.md | 10 | ||||
-rw-r--r-- | backend/cmd/api/main.go | 110 | ||||
-rw-r--r-- | backend/go.mod | 43 | ||||
-rw-r--r-- | backend/go.sum | 109 | ||||
-rw-r--r-- | backend/internal/api/auth/auth.go | 247 | ||||
-rw-r--r-- | backend/internal/api/v1/loans/loans.go | 248 | ||||
-rw-r--r-- | backend/internal/api/v1/users/handler.go | 19 | ||||
-rw-r--r-- | backend/internal/config/config.go | 54 | ||||
-rw-r--r-- | backend/internal/database/database.go | 61 | ||||
-rw-r--r-- | backend/internal/models/models.go | 97 | ||||
-rw-r--r-- | backend/internal/router/router.go | 88 | ||||
-rwxr-xr-x | backend/scripts/setup_db.sh | 38 |
13 files changed, 1124 insertions, 6 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..462a796 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.env +.env.local +.env.development +.env.production +.env.test +.env.test.local
\ No newline at end of file @@ -81,14 +81,14 @@ An application designed to help manage personal finances, including income (like **Phase 1: Backend Core Setup (Go)** * [x] Initialize Go project (`go mod init finance/backend`) -* [ ] Set up API framework (Gin Gonic) -* [ ] Set up ORM/DB library (GORM) -* [ ] Design database schema (Users, Accounts, Transactions, Loans, Goals) -* [ ] Set up database connection and migrations +* [x] Set up API framework (Gin Gonic) +* [x] Set up ORM/DB library (GORM) +* [x] Design database schema (Users, Accounts, Transactions, Loans, Goals) +* [x] Set up database connection and migrations * [ ] Implement User Authentication (Signup, Login, JWT/Session Management) * [ ] Create basic CRUD APIs for Accounts (e.g., Salary Source, Bank Account) * [ ] Create basic CRUD APIs for Transactions (Income, Expense) -* [ ] Create basic CRUD APIs for Loans +* [x] Create basic CRUD APIs for Loans * [ ] Create basic CRUD APIs for Goals * [ ] Set up initial logging and error handling * [ ] Write unit/integration tests for core API endpoints diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go new file mode 100644 index 0000000..de021fa --- /dev/null +++ b/backend/cmd/api/main.go @@ -0,0 +1,110 @@ +package main + +import ( + "log" + "net/http" + + "finance/backend/internal/api/auth" + "finance/backend/internal/api/v1/loans" + "finance/backend/internal/config" + "finance/backend/internal/database" + + "github.com/gin-gonic/gin" +) + +func main() { + // Load Configuration + cfg, err := config.LoadConfig() + if err != nil { + log.Fatalf("Failed to load configuration: %v", err) + } + + // Initialize Database + if err := database.InitDatabase(cfg); err != nil { + log.Fatalf("Failed to initialize database: %v", err) + } + + // Setup Gin Router + r := gin.Default() + + // Public utility endpoints + r.GET("/ping", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "message": "pong", + }) + }) + + // Add database status endpoint + r.GET("/db-status", func(c *gin.Context) { + // Try to get a connection from the pool + sqlDB, err := database.DB.DB() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "status": "error", + "message": "Failed to get database connection", + "error": err.Error(), + }) + return + } + + // Check if database is reachable + err = sqlDB.Ping() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "status": "error", + "message": "Database is not reachable", + "error": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "status": "success", + "message": "Database connection is healthy", + }) + }) + + // API v1 routes + v1 := r.Group("/api/v1") + { + // Auth routes (public) + v1.POST("/auth/signup", auth.Signup(cfg)) + v1.POST("/auth/login", auth.Login(cfg)) + + // Protected routes + protected := v1.Group("") + protected.Use(auth.AuthMiddleware(cfg)) + { + // User routes + protected.GET("/users/me", func(c *gin.Context) { + // Get user from context (set by auth middleware) + user, exists := c.Get("user") + if !exists { + c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) + return + } + + c.JSON(http.StatusOK, gin.H{"user": user}) + }) + + // Loan routes + loanRoutes := protected.Group("/loans") + { + loanRoutes.GET("", loans.GetLoans()) + loanRoutes.GET("/:id", loans.GetLoanByID()) + loanRoutes.POST("", loans.CreateLoan()) + loanRoutes.PUT("/:id", loans.UpdateLoan()) + loanRoutes.DELETE("/:id", loans.DeleteLoan()) + } + } + } + + // Run the server + serverAddr := ":8080" // TODO: Make this configurable via cfg + log.Printf("Starting server on %s", serverAddr) + err = r.Run(serverAddr) + if err != nil { + // Use Fatalf to exit if server fails to start + log.Fatalf("Failed to start server: %v", err) + } +} diff --git a/backend/go.mod b/backend/go.mod index 66c3e08..65e18cf 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,3 +1,44 @@ -module finance/baq +module finance/backend go 1.24.2 + +require ( + github.com/bytedance/sonic v1.11.6 // indirect + github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/gin-gonic/gin v1.10.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.20.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/golang-jwt/jwt/v5 v5.2.2 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/pgx/v5 v5.5.5 // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + golang.org/x/arch v0.8.0 // indirect + golang.org/x/crypto v0.37.0 // indirect + golang.org/x/net v0.25.0 // indirect + golang.org/x/sync v0.13.0 // indirect + golang.org/x/sys v0.32.0 // indirect + golang.org/x/text v0.24.0 // indirect + google.golang.org/protobuf v1.34.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + gorm.io/driver/postgres v1.5.11 // indirect + gorm.io/gorm v1.25.12 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..beb26dd --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,109 @@ +github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= +github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +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-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw= +github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +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.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= +golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +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= +gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314= +gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI= +gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= +gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/backend/internal/api/auth/auth.go b/backend/internal/api/auth/auth.go new file mode 100644 index 0000000..2f8fa6a --- /dev/null +++ b/backend/internal/api/auth/auth.go @@ -0,0 +1,247 @@ +package auth + +import ( + "finance/backend/internal/config" + "finance/backend/internal/database" + "finance/backend/internal/models" + "fmt" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" +) + +// Request and Response Types + +// SignupRequest represents the data needed for registering a new user +type SignupRequest struct { + Name string `json:"name" binding:"required"` + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required,min=8"` +} + +// LoginRequest represents the data needed for user login +type LoginRequest struct { + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required"` +} + +// AuthResponse is returned on successful authentication +type AuthResponse struct { + Token string `json:"token"` + User models.User `json:"user"` +} + +// JWT Token Functions + +// GenerateJWT creates a new JWT token for a given user ID. +func GenerateJWT(userID uint, cfg *config.Config) (string, error) { + // Create the claims + claims := jwt.MapClaims{ + "sub": userID, // Subject (user ID) + "iss": "finance-app", // Issuer + "aud": "finance-app-users", // Audience + "exp": time.Now().Add(time.Hour * 24).Unix(), // Expiration time (e.g., 24 hours) + "iat": time.Now().Unix(), // Issued at + } + + // Create token + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + + // Sign token with secret + signedToken, err := token.SignedString([]byte(cfg.JWTSecret)) + if err != nil { + return "", fmt.Errorf("failed to sign token: %w", err) + } + + return signedToken, nil +} + +// ValidateJWT validates a JWT token and extracts the user ID from it. +// Returns the user ID if the token is valid, or an error if it's not. +func ValidateJWT(tokenString string, cfg *config.Config) (uint, error) { + // Parse the token + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + // Validate the signing method + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + + // Return the secret key + return []byte(cfg.JWTSecret), nil + }) + + if err != nil { + return 0, fmt.Errorf("failed to parse token: %w", err) + } + + // Check if token is valid + if !token.Valid { + return 0, fmt.Errorf("invalid token") + } + + // Extract claims + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return 0, fmt.Errorf("invalid token claims") + } + + // Verify expiration time + if exp, ok := claims["exp"].(float64); ok { + if time.Now().Unix() > int64(exp) { + return 0, fmt.Errorf("token expired") + } + } else { + return 0, fmt.Errorf("invalid expiration claim") + } + + // Extract user ID from subject claim + sub, ok := claims["sub"].(float64) + if !ok { + return 0, fmt.Errorf("invalid subject claim") + } + + // Convert to uint and return + return uint(sub), nil +} + +// Handler Functions + +// Signup handles user registration +func Signup(cfg *config.Config) gin.HandlerFunc { + return func(c *gin.Context) { + var req SignupRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Check if user with this email already exists + var existingUser models.User + result := database.DB.Where("email = ?", req.Email).First(&existingUser) + if result.RowsAffected > 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "User with this email already exists"}) + return + } + + // Create new user + user := models.User{ + Name: req.Name, + Email: req.Email, + } + + // Set password (this will hash it) + if err := user.SetPassword(req.Password); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process password"}) + return + } + + // Save user to database + if err := database.DB.Create(&user).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"}) + return + } + + // Generate JWT token + token, err := GenerateJWT(user.ID, cfg) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate authentication token"}) + return + } + + // Clear password hash before returning user data + user.PasswordHash = "" + + c.JSON(http.StatusCreated, AuthResponse{ + Token: token, + User: user, + }) + } +} + +// Login handles user authentication +func Login(cfg *config.Config) gin.HandlerFunc { + return func(c *gin.Context) { + var req LoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Find user by email + var user models.User + result := database.DB.Where("email = ?", req.Email).First(&user) + if result.Error != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid email or password"}) + return + } + + // Check password + if err := user.CheckPassword(req.Password); err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid email or password"}) + return + } + + // Generate JWT token + token, err := GenerateJWT(user.ID, cfg) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate authentication token"}) + return + } + + // Clear password hash before returning user data + user.PasswordHash = "" + + c.JSON(http.StatusOK, AuthResponse{ + Token: token, + User: user, + }) + } +} + +// AuthMiddleware validates JWT tokens and adds user data to the context +func AuthMiddleware(cfg *config.Config) gin.HandlerFunc { + return func(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header is required"}) + c.Abort() + return + } + + // Check if the Authorization header has the Bearer prefix + parts := strings.Split(authHeader, " ") + if len(parts) != 2 || parts[0] != "Bearer" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header format must be Bearer {token}"}) + c.Abort() + return + } + + // Extract the token + tokenString := parts[1] + + // Validate the token + userID, err := ValidateJWT(tokenString, cfg) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"}) + c.Abort() + return + } + + // Find the user in the database + var user models.User + if err := database.DB.First(&user, userID).Error; err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "User not found"}) + c.Abort() + return + } + + // Set user data in context + c.Set("userID", userID) + c.Set("user", user) + + c.Next() + } +} diff --git a/backend/internal/api/v1/loans/loans.go b/backend/internal/api/v1/loans/loans.go new file mode 100644 index 0000000..06d96b0 --- /dev/null +++ b/backend/internal/api/v1/loans/loans.go @@ -0,0 +1,248 @@ +package loans + +import ( + "net/http" + "strconv" + "time" + + "finance/backend/internal/database" + "finance/backend/internal/models" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// GetLoans returns all loans for the authenticated user +func GetLoans() gin.HandlerFunc { + return func(c *gin.Context) { + // Get user from context (set by auth middleware) + user, exists := c.Get("user") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + userObj := user.(*models.User) + var loans []models.Loan + + // Fetch all loans for the user + if err := database.DB.Where("user_id = ?", userObj.ID).Find(&loans).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch loans"}) + return + } + + c.JSON(http.StatusOK, gin.H{"loans": loans}) + } +} + +// GetLoanByID returns a specific loan by ID +func GetLoanByID() gin.HandlerFunc { + return func(c *gin.Context) { + // Get user from context (set by auth middleware) + user, exists := c.Get("user") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + userObj := user.(*models.User) + + // Get loan ID from URL parameter + loanID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid loan ID format"}) + return + } + + var loan models.Loan + + // Fetch the loan and ensure it belongs to the authenticated user + if err := database.DB.Where("id = ? AND user_id = ?", loanID, userObj.ID).First(&loan).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "Loan not found"}) + } else { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch loan"}) + } + return + } + + c.JSON(http.StatusOK, gin.H{"loan": loan}) + } +} + +// CreateLoan creates a new loan +func CreateLoan() gin.HandlerFunc { + return func(c *gin.Context) { + // Get user from context (set by auth middleware) + user, exists := c.Get("user") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + userObj := user.(*models.User) + + // Define a struct to bind the request JSON + var input struct { + Name string `json:"name" binding:"required"` + OriginalAmount int64 `json:"originalAmount" binding:"required"` + CurrentBalance int64 `json:"currentBalance" binding:"required"` + InterestRate float64 `json:"interestRate"` + StartDate string `json:"startDate" binding:"required"` + EndDate string `json:"endDate" binding:"required"` + AccountID *uint `json:"accountId"` + } + + // Bind JSON to struct + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Parse dates + startDate, err := time.Parse("2006-01-02", input.StartDate) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid start date format"}) + return + } + + endDate, err := time.Parse("2006-01-02", input.EndDate) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid end date format"}) + return + } + + // Create loan object + loan := models.Loan{ + UserID: userObj.ID, + Name: input.Name, + OriginalAmount: input.OriginalAmount, + CurrentBalance: input.CurrentBalance, + InterestRate: input.InterestRate, + StartDate: startDate, + EndDate: endDate, + AccountID: input.AccountID, + } + + // Save to database + if err := database.DB.Create(&loan).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create loan"}) + return + } + + c.JSON(http.StatusCreated, gin.H{"loan": loan}) + } +} + +// UpdateLoan updates an existing loan +func UpdateLoan() gin.HandlerFunc { + return func(c *gin.Context) { + // Get user from context (set by auth middleware) + user, exists := c.Get("user") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + userObj := user.(*models.User) + + // Get loan ID from URL parameter + loanID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid loan ID format"}) + return + } + + // Check if the loan exists and belongs to the user + var loan models.Loan + if err := database.DB.Where("id = ? AND user_id = ?", loanID, userObj.ID).First(&loan).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "Loan not found"}) + } else { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch loan"}) + } + return + } + + // Define a struct to bind the request JSON + var input struct { + Name string `json:"name"` + CurrentBalance int64 `json:"currentBalance"` + InterestRate float64 `json:"interestRate"` + EndDate string `json:"endDate"` + AccountID *uint `json:"accountId"` + } + + // Bind JSON to struct + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Update fields if provided + if input.Name != "" { + loan.Name = input.Name + } + if input.CurrentBalance != 0 { + loan.CurrentBalance = input.CurrentBalance + } + if input.InterestRate != 0 { + loan.InterestRate = input.InterestRate + } + if input.EndDate != "" { + endDate, err := time.Parse("2006-01-02", input.EndDate) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid end date format"}) + return + } + loan.EndDate = endDate + } + if input.AccountID != nil { + loan.AccountID = input.AccountID + } + + // Save updates to database + if err := database.DB.Save(&loan).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update loan"}) + return + } + + c.JSON(http.StatusOK, gin.H{"loan": loan}) + } +} + +// DeleteLoan deletes a loan +func DeleteLoan() gin.HandlerFunc { + return func(c *gin.Context) { + // Get user from context (set by auth middleware) + user, exists := c.Get("user") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + userObj := user.(*models.User) + + // Get loan ID from URL parameter + loanID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid loan ID format"}) + return + } + + // Check if the loan exists and belongs to the user + var loan models.Loan + if err := database.DB.Where("id = ? AND user_id = ?", loanID, userObj.ID).First(&loan).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "Loan not found"}) + } else { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch loan"}) + } + return + } + + // Delete the loan + if err := database.DB.Delete(&loan).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete loan"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Loan deleted successfully"}) + } +} diff --git a/backend/internal/api/v1/users/handler.go b/backend/internal/api/v1/users/handler.go new file mode 100644 index 0000000..9e53147 --- /dev/null +++ b/backend/internal/api/v1/users/handler.go @@ -0,0 +1,19 @@ +package users + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// GetCurrentUser returns the authenticated user's information +func GetCurrentUser(c *gin.Context) { + // Get user from context (set by auth middleware) + user, exists := c.Get("user") + if !exists { + c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) + return + } + + c.JSON(http.StatusOK, gin.H{"user": user}) +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 0000000..f61f423 --- /dev/null +++ b/backend/internal/config/config.go @@ -0,0 +1,54 @@ +package config + +import ( + "log" + "os" + + "github.com/joho/godotenv" +) + +// Config holds the application configuration +type Config struct { + DatabaseDSN string + JWTSecret string // Secret key for signing JWTs + // Add other config fields here later (e.g., server port) +} + +// LoadConfig loads configuration from environment variables or a .env file +func LoadConfig() (*Config, error) { + // Attempt to load .env file (useful for development) + // In production, rely on environment variables set directly + err := godotenv.Load() // Load .env file from current directory or parent dirs + if err != nil { + log.Println("No .env file found, relying on environment variables") + } + + dsn := os.Getenv("DATABASE_DSN") + if dsn == "" { + // Set a default for local development if not provided + log.Println("DATABASE_DSN not set, using default local PostgreSQL DSN") + log.Println("IMPORTANT: Ensure PostgreSQL is running with the correct credentials.") + log.Println("Try creating a database 'finance' and setting up a user with the correct permissions.") + log.Println("Example commands:") + log.Println(" createdb finance") + log.Println(" createuser -P -s -e user_name") + + // Use more common/generic default credentials + dsn = "host=localhost user=postgres password=postgres dbname=finance port=5432 sslmode=disable TimeZone=UTC" + // Consider making the default conditional or removing it for stricter environments + } + + jwtSecret := os.Getenv("JWT_SECRET") + if jwtSecret == "" { + log.Println("WARNING: JWT_SECRET environment variable not set. Using an insecure default.") + // !!! IMPORTANT: Use a strong, randomly generated secret in production !!! + // For development only: + jwtSecret = "insecure-default-dev-secret-change-me" + // return nil, errors.New("JWT_SECRET environment variable is required") + } + + return &Config{ + DatabaseDSN: dsn, + JWTSecret: jwtSecret, + }, nil +} diff --git a/backend/internal/database/database.go b/backend/internal/database/database.go new file mode 100644 index 0000000..15228e2 --- /dev/null +++ b/backend/internal/database/database.go @@ -0,0 +1,61 @@ +package database + +import ( + "log" + "time" + + "finance/backend/internal/config" + "finance/backend/internal/models" + + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +var DB *gorm.DB + +// InitDatabase initializes the database connection and runs migrations. +func InitDatabase(cfg *config.Config) error { + var err error + log.Println("Connecting to database...") + + // Configure GORM logger + // You might want to adjust the log level based on environment (e.g., Silent in production) + newLogger := logger.New( + log.New(log.Writer(), "\r\n", log.LstdFlags), // io writer + logger.Config{ + SlowThreshold: 200 * time.Millisecond, // Explicitly set slow query threshold + LogLevel: logger.Info, // Log all SQL + IgnoreRecordNotFoundError: true, + Colorful: false, // Disable color in logs, or true if terminal supports + }, + ) + + DB, err = gorm.Open(postgres.Open(cfg.DatabaseDSN), &gorm.Config{ + Logger: newLogger, + }) + + if err != nil { + log.Printf("Failed to connect to database: %v\n", err) + return err + } + + log.Println("Database connection established.") + + // Run Migrations + log.Println("Running database migrations...") + err = DB.AutoMigrate( + &models.User{}, + &models.Account{}, + &models.Transaction{}, + &models.Loan{}, + &models.Goal{}, + ) + if err != nil { + log.Printf("Failed to run migrations: %v\n", err) + return err + } + + log.Println("Database migrations completed.") + return nil +} diff --git a/backend/internal/models/models.go b/backend/internal/models/models.go new file mode 100644 index 0000000..c984012 --- /dev/null +++ b/backend/internal/models/models.go @@ -0,0 +1,97 @@ +package models + +import ( + "time" + + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +// User represents a user in the system +// We'll store a hashed password, not the plaintext one. +type User struct { + gorm.Model // Includes ID, CreatedAt, UpdatedAt, DeletedAt + Name string `gorm:"not null"` + Email string `gorm:"uniqueIndex;not null"` // Unique email + PasswordHash string `gorm:"not null"` // Store hashed password +} + +// SetPassword hashes the given password and sets it on the user model. +func (u *User) SetPassword(password string) error { + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return err + } + u.PasswordHash = string(hashedPassword) + return nil +} + +// CheckPassword compares a given password with the user's hashed password. +// Returns nil if the password is correct, otherwise returns an error. +func (u *User) CheckPassword(password string) error { + return bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) +} + +// Account represents a financial account (e.g., bank account, credit card, cash) +// It belongs to a User. +type Account struct { + gorm.Model + UserID uint `gorm:"not null;index"` // Foreign key to User + User User // Belongs To relationship + Name string `gorm:"not null"` + Type string `gorm:"not null;index"` // e.g., "Bank", "Credit Card", "Cash", "Loan", "Income Source" + // Balance is stored in the smallest currency unit (e.g., cents) + // to avoid floating point issues. + Balance int64 `gorm:"not null;default:0"` +} + +// Transaction represents an income or expense event. +// It belongs to a User and is usually associated with an Account. +type Transaction struct { + gorm.Model + UserID uint `gorm:"not null;index"` // Foreign key to User + User User // Belongs To relationship + AccountID *uint `gorm:"index"` // Foreign key to Account (nullable, e.g., for cash transactions not tied to a bank) + Account *Account // Belongs To relationship (pointer for nullable FK) + Description string `gorm:"not null"` + // Amount stored in the smallest currency unit (e.g., cents) + Amount int64 `gorm:"not null"` + Type string `gorm:"not null;index"` // "Income" or "Expense" + Date time.Time `gorm:"not null;index"` // Date of the transaction + Category string `gorm:"index"` // e.g., "Salary", "Groceries", "Utilities" +} + +// Loan represents a loan taken by the user. +// It belongs to a User and may be linked to an Account representing the loan source/payments. +type Loan struct { + gorm.Model + UserID uint `gorm:"not null;index"` // Foreign key to User + User User // Belongs To relationship + AccountID *uint `gorm:"index"` // Optional FK to the related Account (e.g., where payments are made from/to) + Account *Account // Belongs To relationship + Name string `gorm:"not null"` // e.g., "Car Loan", "Student Loan" + OriginalAmount int64 `gorm:"not null"` // In smallest currency unit + CurrentBalance int64 `gorm:"not null"` // In smallest currency unit + InterestRate float64 // Annual interest rate (e.g., 0.05 for 5%) + StartDate time.Time + EndDate time.Time // Expected payoff date + // Add fields for payment frequency, next due date etc. if needed later +} + +// Goal represents a financial goal the user is working towards. +// It belongs to a User. +type Goal struct { + gorm.Model + UserID uint `gorm:"not null;index"` // Foreign key to User + User User // Belongs To relationship + Name string `gorm:"not null"` // e.g., "Save for Down Payment", "Pay Off Credit Card" + TargetAmount int64 `gorm:"not null"` // In smallest currency unit + CurrentAmount int64 `gorm:"not null;default:0"` // In smallest currency unit + TargetDate time.Time // Optional target date for the goal + Status string `gorm:"not null;default:'Active';index"` // e.g., "Active", "Achieved", "Cancelled" + // Link to specific accounts or loans if needed (e.g., goal to pay off a specific loan) + // RelatedAccountID *uint + // RelatedLoanID *uint +} + +// Add other models below (Goal) diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go new file mode 100644 index 0000000..b82e1af --- /dev/null +++ b/backend/internal/router/router.go @@ -0,0 +1,88 @@ +package router + +import ( + "finance/backend/internal/api/auth" + "finance/backend/internal/api/v1/users" + "finance/backend/internal/config" + "finance/backend/internal/database" + "net/http" + + "github.com/gin-gonic/gin" +) + +// SetupRouter configures the API routes +func SetupRouter(cfg *config.Config) *gin.Engine { + r := gin.Default() + + // Enable CORS + r.Use(func(c *gin.Context) { + c.Writer.Header().Set("Access-Control-Allow-Origin", "*") + c.Writer.Header().Set("Access-Control-Allow-Credentials", "true") + c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With") + c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE") + + if c.Request.Method == "OPTIONS" { + c.AbortWithStatus(204) + return + } + + c.Next() + }) + + // Public utility endpoints + r.GET("/ping", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "message": "pong", + }) + }) + + // Add database status endpoint + r.GET("/db-status", func(c *gin.Context) { + // Try to get a connection from the pool + sqlDB, err := database.DB.DB() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "status": "error", + "message": "Failed to get database connection", + "error": err.Error(), + }) + return + } + + // Check if database is reachable + err = sqlDB.Ping() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "status": "error", + "message": "Database is not reachable", + "error": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "status": "success", + "message": "Database connection is healthy", + }) + }) + + // API v1 routes + v1 := r.Group("/api/v1") + { + // Auth routes (public) + v1.POST("/auth/signup", auth.Signup(cfg)) + v1.POST("/auth/login", auth.Login(cfg)) + + // Protected routes + protected := v1.Group("") + protected.Use(auth.AuthMiddleware(cfg)) + { + // User routes + protected.GET("/users/me", users.GetCurrentUser) + + // Add other protected routes here + } + } + + return r +} diff --git a/backend/scripts/setup_db.sh b/backend/scripts/setup_db.sh new file mode 100755 index 0000000..7388015 --- /dev/null +++ b/backend/scripts/setup_db.sh @@ -0,0 +1,38 @@ +#!/bin/bash + +# Script to set up the PostgreSQL database for the finance application + +# Prompt for PostgreSQL user to use +read -p "Enter PostgreSQL admin username [postgres]: " PG_USER +PG_USER=${PG_USER:-postgres} + +# Prompt for PostgreSQL password +read -s -p "Enter PostgreSQL password for $PG_USER: " PG_PASSWORD +echo + +# Check if database exists +echo "Checking if database exists..." +if PGPASSWORD="$PG_PASSWORD" psql -U "$PG_USER" -h localhost -lqt | cut -d \| -f 1 | grep -qw finance; then + echo "Database 'finance' already exists" +else + echo "Creating database 'finance'..." + PGPASSWORD="$PG_PASSWORD" createdb -U "$PG_USER" -h localhost finance + if [ $? -ne 0 ]; then + echo "Failed to create database. Please check your credentials." + exit 1 + fi + echo "Database 'finance' created successfully" +fi + +# Create .env file with database connection details +echo "Creating .env file with database connection info..." +cat > .env <<EOF +# Database Connection +DATABASE_DSN=host=localhost user=$PG_USER password=$PG_PASSWORD dbname=finance port=5432 sslmode=disable TimeZone=UTC + +# JWT Secret (for user authentication) +JWT_SECRET=$(openssl rand -hex 32) +EOF + +echo ".env file created with your database connection details." +echo "You can now run the application with 'go run cmd/api/main.go'"
\ No newline at end of file |