From 9cb71d53cf02873464ad41a3f25da90e808c4992 Mon Sep 17 00:00:00 2001 From: Sebastiaan de Schaetzen Date: Sun, 18 May 2025 08:31:39 +0200 Subject: [PATCH 1/3] Add history entry and fix bug when completing task (#54) The reward wasn't properly being distributed to all users Reviewed-on: https://gitea.seeseepuff.be/seeseemelk/allowance_planner_2000/pulls/54 --- backend/api_test.go | 18 +++++++++++++++++- backend/db.go | 18 +++++++++++++++--- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/backend/api_test.go b/backend/api_test.go index 86abe14..33e7082 100644 --- a/backend/api_test.go +++ b/backend/api_test.go @@ -567,7 +567,8 @@ func TestCompleteTask(t *testing.T) { // Verify the task is marked as completed e.GET("/task/" + strconv.Itoa(taskId)).Expect().Status(404) - // Verify the allowances are updated + + // Verify the allowances are updated for user 1 allowances := e.GET("/user/1/allowance").Expect().Status(200).JSON().Array() allowances.Length().IsEqual(3) allowances.Value(0).Object().Value("id").Number().IsEqual(0) @@ -576,6 +577,21 @@ func TestCompleteTask(t *testing.T) { allowances.Value(1).Object().Value("progress").Number().IsEqual(50) allowances.Value(2).Object().Value("id").Number().IsEqual(2) allowances.Value(2).Object().Value("progress").Number().IsEqual(25) + + // And also for user 2 + allowances = e.GET("/user/2/allowance").Expect().Status(200).JSON().Array() + allowances.Length().IsEqual(1) + allowances.Value(0).Object().Value("id").Number().IsEqual(0) + allowances.Value(0).Object().Value("progress").Number().IsEqual(101) + + for userId := 1; userId <= 2; userId++ { + userIdStr := strconv.Itoa(userId) + // Ensure the history got updated + history := e.GET("/user/" + userIdStr + "/history").Expect().Status(200).JSON().Array() + history.Length().IsEqual(1) + history.Value(0).Object().Value("allowance").Number().IsEqual(101) + history.Value(0).Object().Value("timestamp").String().AsDateTime().InRange(getDelta(time.Now(), 2.0)) + } } func getDelta(base time.Time, delta float64) (time.Time, time.Time) { diff --git a/backend/db.go b/backend/db.go index dbf8436..7f9feff 100644 --- a/backend/db.go +++ b/backend/db.go @@ -341,10 +341,22 @@ func (db *Db) CompleteTask(taskId int) error { return err } + // Add the history entry + err = tx.Query("insert into history (user_id, timestamp, amount) values (?, ?, ?)"). + Bind(userId, time.Now().Unix(), reward). + Exec() + if err != nil { + return err + } + + // Calculate the sums of all weights var sumOfWeights float64 err = tx.Query("select sum(weight) from allowances where user_id = ? and weight > 0").Bind(userId).ScanSingle(&sumOfWeights) sumOfWeights += userWeight + remainingReward := reward + + // Distribute the reward to the allowances for allowanceRow := range tx.Query("select id, weight from allowances where user_id = ? and weight > 0").Bind(userId).Range(&err) { var allowanceId int var allowanceWeight float64 @@ -354,19 +366,19 @@ func (db *Db) CompleteTask(taskId int) error { } // Calculate the amount to add to the allowance - amount := int((allowanceWeight / sumOfWeights) * float64(reward)) + amount := int((allowanceWeight / sumOfWeights) * float64(remainingReward)) sumOfWeights -= allowanceWeight err = tx.Query("update allowances set balance = balance + ? where id = ? and user_id = ?"). Bind(amount, allowanceId, userId).Exec() if err != nil { return err } - reward -= amount + remainingReward -= amount } // Add the remaining reward to the user err = tx.Query("update users set balance = balance + ? where id = ?"). - Bind(reward, userId).Exec() + Bind(remainingReward, userId).Exec() if err != nil { return err } From 79dcfbc02c10f99c21d25eb6293d6fc9da487a84 Mon Sep 17 00:00:00 2001 From: Sebastiaan de Schaetzen Date: Sun, 18 May 2025 09:02:33 +0200 Subject: [PATCH 2/3] Implement completion endpoint for allowance (#55) Closes #19 Reviewed-on: https://gitea.seeseepuff.be/seeseemelk/allowance_planner_2000/pulls/55 --- backend/api_test.go | 76 ++++++++++++++++++++++++++++++++ backend/db.go | 67 +++++++++++++++++++++------- backend/main.go | 48 ++++++++++++++++++++ backend/migrations/1_initial.sql | 2 +- 4 files changed, 176 insertions(+), 17 deletions(-) diff --git a/backend/api_test.go b/backend/api_test.go index 33e7082..a5eaf29 100644 --- a/backend/api_test.go +++ b/backend/api_test.go @@ -594,6 +594,82 @@ func TestCompleteTask(t *testing.T) { } } +func TestCompleteTaskAllowanceWeightsSumTo0(t *testing.T) { + e := startServer(t) + taskId := createTestTaskWithAmount(e, 101) + + e.GET("/tasks").Expect().Status(200).JSON().Array().Length().IsEqual(1) + + // Update rest allowance + e.PUT("/user/1/allowance/0").WithJSON(UpdateAllowanceRequest{ + Weight: 0, + }).Expect().Status(200) + // Create two allowance goals + e.POST("/user/1/allowance").WithJSON(CreateAllowanceRequest{ + Name: "Test Allowance 1", + Target: 1000, + Weight: 0, + }).Expect().Status(201) + + // Complete the task + e.POST("/task/" + strconv.Itoa(taskId) + "/complete").Expect().Status(200) + + // Verify the task is marked as completed + e.GET("/task/" + strconv.Itoa(taskId)).Expect().Status(404) + + // Verify the allowances are updated for user 1 + allowances := e.GET("/user/1/allowance").Expect().Status(200).JSON().Array() + allowances.Length().IsEqual(2) + allowances.Value(0).Object().Value("id").Number().IsEqual(0) + allowances.Value(0).Object().Value("progress").Number().IsEqual(101) + allowances.Value(1).Object().Value("id").Number().IsEqual(1) + allowances.Value(1).Object().Value("progress").Number().IsEqual(0) +} + +func TestCompleteTaskInvalidId(t *testing.T) { + e := startServer(t) + e.POST("/task/999/complete").Expect().Status(404) +} + +func TestCompleteAllowance(t *testing.T) { + e := startServer(t) + createTestTaskWithAmount(e, 100) + + // Create allowance goal + e.POST("/user/1/allowance").WithJSON(CreateAllowanceRequest{ + Name: "Test Allowance 1", + Target: 100, + Weight: 50, + }).Expect().Status(201) + + // Complete the task + e.POST("/task/1/complete").Expect().Status(200) + + // Complete allowance goal + e.POST("/user/1/allowance/1/complete").Expect().Status(200) + + // Verify the allowance no longer exists + e.GET("/user/1/allowance/1").Expect().Status(404) + + // Verify history is updated + history := e.GET("/user/1/history").Expect().Status(200).JSON().Array() + history.Length().IsEqual(2) + history.Value(0).Object().Value("allowance").Number().IsEqual(100) + history.Value(0).Object().Value("timestamp").String().AsDateTime().InRange(getDelta(time.Now(), 2.0)) + history.Value(1).Object().Value("allowance").Number().IsEqual(-100) + history.Value(1).Object().Value("timestamp").String().AsDateTime().InRange(getDelta(time.Now(), 2.0)) +} + +func TestCompleteAllowanceInvalidUserId(t *testing.T) { + e := startServer(t) + e.POST("/user/999/allowance/1/complete").Expect().Status(404) +} + +func TestCompleteAllowanceInvalidAllowanceId(t *testing.T) { + e := startServer(t) + e.POST("/user/1/allowance/999/complete").Expect().Status(404) +} + func getDelta(base time.Time, delta float64) (time.Time, time.Time) { start := base.Add(-time.Duration(delta) * time.Second) end := base.Add(time.Duration(delta) * time.Second) diff --git a/backend/db.go b/backend/db.go index 7f9feff..100040c 100644 --- a/backend/db.go +++ b/backend/db.go @@ -175,6 +175,39 @@ func (db *Db) DeleteAllowance(userId int, allowanceId int) error { return nil } +func (db *Db) CompleteAllowance(userId int, allowanceId int) error { + tx, err := db.db.Begin() + if err != nil { + return err + } + defer tx.MustRollback() + + // Get the cost of the allowance + var cost int + err = tx.Query("select balance from allowances where id = ? and user_id = ?"). + Bind(allowanceId, userId).ScanSingle(&cost) + if err != nil { + return err + } + + // Delete the allowance + err = tx.Query("delete from allowances where id = ? and user_id = ?"). + Bind(allowanceId, userId).Exec() + if err != nil { + return err + } + + // Add a history entry + err = tx.Query("insert into history (user_id, timestamp, amount) values (?, ?, ?)"). + Bind(userId, time.Now().Unix(), -cost). + Exec() + if err != nil { + return err + } + + return tx.Commit() +} + func (db *Db) UpdateUserAllowance(userId int, allowance *UpdateAllowanceRequest) error { tx, err := db.db.Begin() if err != nil { @@ -356,24 +389,26 @@ func (db *Db) CompleteTask(taskId int) error { remainingReward := reward - // Distribute the reward to the allowances - for allowanceRow := range tx.Query("select id, weight from allowances where user_id = ? and weight > 0").Bind(userId).Range(&err) { - var allowanceId int - var allowanceWeight float64 - err = allowanceRow.Scan(&allowanceId, &allowanceWeight) - if err != nil { - return err - } + if sumOfWeights > 0 { + // Distribute the reward to the allowances + for allowanceRow := range tx.Query("select id, weight from allowances where user_id = ? and weight > 0").Bind(userId).Range(&err) { + var allowanceId int + var allowanceWeight float64 + err = allowanceRow.Scan(&allowanceId, &allowanceWeight) + if err != nil { + return err + } - // Calculate the amount to add to the allowance - amount := int((allowanceWeight / sumOfWeights) * float64(remainingReward)) - sumOfWeights -= allowanceWeight - err = tx.Query("update allowances set balance = balance + ? where id = ? and user_id = ?"). - Bind(amount, allowanceId, userId).Exec() - if err != nil { - return err + // Calculate the amount to add to the allowance + amount := int((allowanceWeight / sumOfWeights) * float64(remainingReward)) + sumOfWeights -= allowanceWeight + err = tx.Query("update allowances set balance = balance + ? where id = ? and user_id = ?"). + Bind(amount, allowanceId, userId).Exec() + if err != nil { + return err + } + remainingReward -= amount } - remainingReward -= amount } // Add the remaining reward to the user diff --git a/backend/main.go b/backend/main.go index 8e1f510..512bdf3 100644 --- a/backend/main.go +++ b/backend/main.go @@ -287,6 +287,49 @@ func putUserAllowance(c *gin.Context) { c.IndentedJSON(http.StatusOK, gin.H{"message": "Allowance updated successfully"}) } +func completeAllowance(c *gin.Context) { + userIdStr := c.Param("userId") + allowanceIdStr := c.Param("allowanceId") + + userId, err := strconv.Atoi(userIdStr) + if err != nil { + log.Printf(ErrInvalidUserID+": %v", err) + c.JSON(http.StatusBadRequest, gin.H{"error": ErrInvalidUserID}) + return + } + + allowanceId, err := strconv.Atoi(allowanceIdStr) + if err != nil { + log.Printf("Invalid allowance ID: %v", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid allowance ID"}) + return + } + + exists, err := db.UserExists(userId) + if err != nil { + log.Printf(ErrCheckingUserExist, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": ErrInternalServerError}) + return + } + if !exists { + c.JSON(http.StatusNotFound, gin.H{"error": ErrUserNotFound}) + return + } + + err = db.CompleteAllowance(userId, allowanceId) + if errors.Is(err, mysqlite.ErrNoRows) { + c.JSON(http.StatusNotFound, gin.H{"error": "Allowance not found"}) + return + } + if err != nil { + log.Printf("Error completing allowance: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": ErrInternalServerError}) + return + } + + c.IndentedJSON(http.StatusOK, gin.H{"message": "Allowance completed successfully"}) +} + func createTask(c *gin.Context) { var taskRequest CreateTaskRequest if err := c.ShouldBindJSON(&taskRequest); err != nil { @@ -518,6 +561,7 @@ func start(ctx context.Context, config *ServerConfig) { router.GET("/api/user/:userId/allowance/:allowanceId", getUserAllowanceById) router.DELETE("/api/user/:userId/allowance/:allowanceId", deleteUserAllowance) router.PUT("/api/user/:userId/allowance/:allowanceId", putUserAllowance) + router.POST("/api/user/:userId/allowance/:allowanceId/complete", completeAllowance) router.POST("/api/tasks", createTask) router.GET("/api/tasks", getTasks) router.GET("/api/task/:taskId", getTask) @@ -558,5 +602,9 @@ func main() { Datasource: os.Getenv("DB_PATH"), Addr: ":8080", } + if config.Datasource == "" { + config.Datasource = "allowance_planner.db3" + log.Printf("Warning: No DB_PATH set, using default of %s", config.Datasource) + } start(context.Background(), &config) } diff --git a/backend/migrations/1_initial.sql b/backend/migrations/1_initial.sql index c787888..d400ec8 100644 --- a/backend/migrations/1_initial.sql +++ b/backend/migrations/1_initial.sql @@ -2,7 +2,7 @@ create table users ( id integer primary key, name text not null, - weight real not null default 1.0, + weight real not null default 0.0, balance integer not null default 0 ) strict; From da17f351dec9aa512ec5ca961052b0442a870b24 Mon Sep 17 00:00:00 2001 From: Sebastiaan de Schaetzen Date: Sun, 18 May 2025 09:24:36 +0200 Subject: [PATCH 3/3] Add bulk allowance edit endpoint (#56) Closes #15 Reviewed-on: https://gitea.seeseepuff.be/seeseemelk/allowance_planner_2000/pulls/56 --- backend/api_test.go | 96 +++++++++++++++++++++++++++++++++++++-------- backend/db.go | 25 ++++++++++++ backend/dto.go | 27 +++++++------ backend/main.go | 39 ++++++++++++++++++ 4 files changed, 160 insertions(+), 27 deletions(-) diff --git a/backend/api_test.go b/backend/api_test.go index a5eaf29..f1b6f46 100644 --- a/backend/api_test.go +++ b/backend/api_test.go @@ -361,10 +361,6 @@ func TestGetTaskWhenNoTasks(t *testing.T) { result.Length().IsEqual(0) } -func createTestTask(e *httpexpect.Expect) int { - return createTestTaskWithAmount(e, 100) -} - func createTestTaskWithAmount(e *httpexpect.Expect, amount int) int { requestBody := map[string]interface{}{ "name": "Test Task", @@ -594,6 +590,39 @@ func TestCompleteTask(t *testing.T) { } } +func TestCompleteTaskAllowanceWeightsSumTo0(t *testing.T) { + e := startServer(t) + taskId := createTestTaskWithAmount(e, 101) + + e.GET("/tasks").Expect().Status(200).JSON().Array().Length().IsEqual(1) + + // Update rest allowance + e.PUT("/user/1/allowance/0").WithJSON(UpdateAllowanceRequest{ + Weight: 0, + }).Expect().Status(200) + // Create an allowance goal + createTestAllowance(e, "Test Allowance 1", 1000, 0) + + // Complete the task + e.POST("/task/" + strconv.Itoa(taskId) + "/complete").Expect().Status(200) + + // Verify the task is marked as completed + e.GET("/task/" + strconv.Itoa(taskId)).Expect().Status(404) + + // Verify the allowances are updated for user 1 + allowances := e.GET("/user/1/allowance").Expect().Status(200).JSON().Array() + allowances.Length().IsEqual(2) + allowances.Value(0).Object().Value("id").Number().IsEqual(0) + allowances.Value(0).Object().Value("progress").Number().IsEqual(101) + allowances.Value(1).Object().Value("id").Number().IsEqual(1) + allowances.Value(1).Object().Value("progress").Number().IsEqual(0) +} + +func TestCompleteTaskInvalidId(t *testing.T) { + e := startServer(t) + e.POST("/task/999/complete").Expect().Status(404) +} + func TestCompleteTaskAllowanceWeightsSumTo0(t *testing.T) { e := startServer(t) taskId := createTestTaskWithAmount(e, 101) @@ -626,21 +655,10 @@ func TestCompleteTaskAllowanceWeightsSumTo0(t *testing.T) { allowances.Value(1).Object().Value("progress").Number().IsEqual(0) } -func TestCompleteTaskInvalidId(t *testing.T) { - e := startServer(t) - e.POST("/task/999/complete").Expect().Status(404) -} - func TestCompleteAllowance(t *testing.T) { e := startServer(t) createTestTaskWithAmount(e, 100) - - // Create allowance goal - e.POST("/user/1/allowance").WithJSON(CreateAllowanceRequest{ - Name: "Test Allowance 1", - Target: 100, - Weight: 50, - }).Expect().Status(201) + createTestAllowance(e, "Test Allowance 1", 100, 50) // Complete the task e.POST("/task/1/complete").Expect().Status(200) @@ -670,8 +688,54 @@ func TestCompleteAllowanceInvalidAllowanceId(t *testing.T) { e.POST("/user/1/allowance/999/complete").Expect().Status(404) } +func TestPutBulkAllowance(t *testing.T) { + e := startServer(t) + + createTestAllowance(e, "Test Allowance 1", 1000, 1) + createTestAllowance(e, "Test Allowance 2", 1000, 2) + + // Bulk edit + request := []map[string]interface{}{ + { + "id": 1, + "weight": 5, + }, + { + "id": 0, + "weight": 99, + }, + { + "id": 2, + "weight": 10, + }, + } + e.PUT("/user/1/allowance").WithJSON(request).Expect().Status(200) + + // Verify the allowances are updated + allowances := e.GET("/user/1/allowance").Expect().Status(200).JSON().Array() + allowances.Length().IsEqual(3) + allowances.Value(0).Object().Value("id").Number().IsEqual(0) + allowances.Value(0).Object().Value("weight").Number().IsEqual(99) + allowances.Value(1).Object().Value("id").Number().IsEqual(1) + allowances.Value(1).Object().Value("weight").Number().IsEqual(5) + allowances.Value(2).Object().Value("id").Number().IsEqual(2) + allowances.Value(2).Object().Value("weight").Number().IsEqual(10) +} + func getDelta(base time.Time, delta float64) (time.Time, time.Time) { start := base.Add(-time.Duration(delta) * time.Second) end := base.Add(time.Duration(delta) * time.Second) return start, end } + +func createTestAllowance(e *httpexpect.Expect, name string, target int, weight float64) { + e.POST("/user/1/allowance").WithJSON(CreateAllowanceRequest{ + Name: name, + Target: target, + Weight: weight, + }).Expect().Status(201) +} + +func createTestTask(e *httpexpect.Expect) int { + return createTestTaskWithAmount(e, 100) +} diff --git a/backend/db.go b/backend/db.go index 100040c..fe02ac2 100644 --- a/backend/db.go +++ b/backend/db.go @@ -251,6 +251,31 @@ func (db *Db) UpdateAllowance(userId int, allowanceId int, allowance *UpdateAllo return tx.Commit() } +func (db *Db) BulkUpdateAllowance(userId int, allowances []BulkUpdateAllowanceRequest) error { + tx, err := db.db.Begin() + if err != nil { + return err + } + defer tx.MustRollback() + + for _, allowance := range allowances { + if allowance.ID == 0 { + err = tx.Query("update users set weight=? where id = ?"). + Bind(allowance.Weight, userId). + Exec() + } else { + err = tx.Query("update allowances set weight=? where id = ? and user_id = ?"). + Bind(allowance.Weight, allowance.ID, userId). + Exec() + } + if err != nil { + return err + } + } + + return tx.Commit() +} + func (db *Db) CreateTask(task *CreateTaskRequest) (int, error) { tx, err := db.db.Begin() if err != nil { diff --git a/backend/dto.go b/backend/dto.go index 78f75f6..1cd3b27 100644 --- a/backend/dto.go +++ b/backend/dto.go @@ -31,23 +31,28 @@ type Task struct { } type Allowance struct { - ID int `json:"id"` - Name string `json:"name"` - Target int `json:"target"` - Progress int `json:"progress"` - Weight int `json:"weight"` + ID int `json:"id"` + Name string `json:"name"` + Target int `json:"target"` + Progress int `json:"progress"` + Weight float64 `json:"weight"` } type CreateAllowanceRequest struct { - Name string `json:"name"` - Target int `json:"target"` - Weight int `json:"weight"` + Name string `json:"name"` + Target int `json:"target"` + Weight float64 `json:"weight"` } type UpdateAllowanceRequest struct { - Name string `json:"name"` - Target int `json:"target"` - Weight int `json:"weight"` + Name string `json:"name"` + Target int `json:"target"` + Weight float64 `json:"weight"` +} + +type BulkUpdateAllowanceRequest struct { + ID int `json:"id"` + Weight float64 `json:"weight"` } type CreateGoalResponse struct { diff --git a/backend/main.go b/backend/main.go index 512bdf3..2102399 100644 --- a/backend/main.go +++ b/backend/main.go @@ -189,6 +189,44 @@ func createUserAllowance(c *gin.Context) { c.IndentedJSON(http.StatusCreated, response) } +func bulkPutUserAllowance(c *gin.Context) { + userIdStr := c.Param("userId") + + userId, err := strconv.Atoi(userIdStr) + if err != nil { + log.Printf(ErrInvalidUserID+": %v", err) + c.JSON(http.StatusBadRequest, gin.H{"error": ErrInvalidUserID}) + return + } + + exists, err := db.UserExists(userId) + if err != nil { + log.Printf(ErrCheckingUserExist, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": ErrInternalServerError}) + return + } + if !exists { + c.JSON(http.StatusNotFound, gin.H{"error": ErrUserNotFound}) + return + } + + var allowanceRequest []BulkUpdateAllowanceRequest + if err := c.ShouldBindJSON(&allowanceRequest); err != nil { + log.Printf("Error parsing request body: %v", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + err = db.BulkUpdateAllowance(userId, allowanceRequest) + if err != nil { + log.Printf("Error updating allowance: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": ErrInternalServerError}) + return + } + + c.IndentedJSON(http.StatusOK, gin.H{"message": "Allowance updated successfully"}) +} + func deleteUserAllowance(c *gin.Context) { userIdStr := c.Param("userId") allowanceIdStr := c.Param("allowanceId") @@ -558,6 +596,7 @@ func start(ctx context.Context, config *ServerConfig) { router.GET("/api/user/:userId/history", getHistory) router.GET("/api/user/:userId/allowance", getUserAllowance) router.POST("/api/user/:userId/allowance", createUserAllowance) + router.PUT("/api/user/:userId/allowance", bulkPutUserAllowance) router.GET("/api/user/:userId/allowance/:allowanceId", getUserAllowanceById) router.DELETE("/api/user/:userId/allowance/:allowanceId", deleteUserAllowance) router.PUT("/api/user/:userId/allowance/:allowanceId", putUserAllowance)