From b5aae3be3d25d0eda5106b51482630e0fa9619da Mon Sep 17 00:00:00 2001 From: Sebastiaan de Schaetzen Date: Sun, 18 May 2025 08:00:29 +0200 Subject: [PATCH] 48/add-complete (#53) Closes #48 Reviewed-on: https://gitea.seeseepuff.be/seeseemelk/allowance_planner_2000/pulls/53 --- README.md | 7 ++ backend/api_test.go | 80 +++++++++++++++++++---- backend/db.go | 106 +++++++++++++++++++++++++++++-- backend/go.mod | 6 +- backend/go.sum | 8 +++ backend/main.go | 35 +++++++++- backend/migrations/1_initial.sql | 6 +- 7 files changed, 224 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index e955e6c..4638ab0 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,9 @@ # Allowance Planner 2000 An improved Allowance Planner app. + +## Running backend +In order to run the backend, go to the `backend directory and run: + +```bash +$ go run . +``` diff --git a/backend/api_test.go b/backend/api_test.go index 68f4708..86abe14 100644 --- a/backend/api_test.go +++ b/backend/api_test.go @@ -52,7 +52,9 @@ func TestGetUserBadId(t *testing.T) { func TestGetUserAllowanceWhenNoAllowancePresent(t *testing.T) { e := startServer(t) result := e.GET("/user/1/allowance").Expect().Status(200).JSON().Array() - result.Length().IsEqual(0) + result.Length().IsEqual(1) + item := result.Value(0).Object() + item.Value("id").IsEqual(0) } func TestGetUserAllowance(t *testing.T) { @@ -68,8 +70,8 @@ func TestGetUserAllowance(t *testing.T) { // Validate allowance result := e.GET("/user/1/allowance").Expect().Status(200).JSON().Array() - result.Length().IsEqual(1) - item := result.Value(0).Object() + result.Length().IsEqual(2) + item := result.Value(1).Object() item.Value("id").IsEqual(1) item.Value("name").IsEqual(TestAllowanceName) item.Value("target").IsEqual(5000) @@ -114,9 +116,9 @@ func TestCreateUserAllowance(t *testing.T) { Status(200). JSON().Array() - allowances.Length().IsEqual(1) + allowances.Length().IsEqual(2) - allowance := allowances.Value(0).Object() + allowance := allowances.Value(1).Object() allowance.Value("id").IsEqual(allowanceId) allowance.Value("name").IsEqual(TestAllowanceName) allowance.Value("target").IsEqual(5000) @@ -208,7 +210,12 @@ func TestDeleteUserAllowance(t *testing.T) { Expect(). Status(200). JSON().Array() - allowances.Length().IsEqual(0) + allowances.Length().IsEqual(1) +} + +func TestDeleteUserRestAllowance(t *testing.T) { + e := startServer(t) + e.DELETE("/user/1/allowance/0").Expect().Status(400) } func TestDeleteUserAllowanceNotFound(t *testing.T) { @@ -248,7 +255,16 @@ func TestCreateTask(t *testing.T) { // Verify the response has an ID response.ContainsKey("id") - taskId := response.Value("id").Number().Raw() + response.Value("id").Number().IsEqual(1) + + e.GET("/tasks").Expect().Status(200).JSON().Array().Length().IsEqual(1) + + // Get task + result := e.GET("/task/1").Expect().Status(200).JSON().Object() + result.Value("id").IsEqual(1) + result.Value("name").IsEqual("Test Task") + result.Value("reward").IsEqual(100) + result.Value("assigned").IsNull() // Create a new task with assigned user assignedUserId := 1 @@ -265,7 +281,7 @@ func TestCreateTask(t *testing.T) { JSON().Object() responseWithUser.ContainsKey("id") - responseWithUser.Value("id").Number().NotEqual(taskId) // Ensure different ID + responseWithUser.Value("id").Number().IsEqual(2) } func TestDeleteTask(t *testing.T) { @@ -345,12 +361,16 @@ func TestGetTaskWhenNoTasks(t *testing.T) { result.Length().IsEqual(0) } -func createTestTask(e *httpexpect.Expect) { +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", - "reward": 100, + "reward": amount, } - e.POST("/tasks").WithJSON(requestBody).Expect().Status(201) + return int(e.POST("/tasks").WithJSON(requestBody).Expect().Status(201).JSON().Object().Value("id").Number().Raw()) } func TestGetTasksWhenTasks(t *testing.T) { @@ -520,6 +540,44 @@ func TestPutAllowanceById(t *testing.T) { result.Value("weight").IsEqual(15) } +func TestCompleteTask(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: 25, + }).Expect().Status(200) + // Create two allowance goals + e.POST("/user/1/allowance").WithJSON(CreateAllowanceRequest{ + Name: "Test Allowance 1", + Target: 1000, + Weight: 50, + }).Expect().Status(201) + e.POST("/user/1/allowance").WithJSON(CreateAllowanceRequest{ + Name: "Test Allowance 1", + Target: 1000, + Weight: 25, + }).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 + 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("progress").Number().IsEqual(26) + allowances.Value(1).Object().Value("id").Number().IsEqual(1) + 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) +} + 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 e171cdf..dbf8436 100644 --- a/backend/db.go +++ b/backend/db.go @@ -71,7 +71,14 @@ func (db *Db) GetUserAllowances(userId int) ([]Allowance, error) { allowances := make([]Allowance, 0) var err error - for row := range db.db.Query("select id, name, target, progress, weight from allowances where user_id = ?"). + totalAllowance := Allowance{} + err = db.db.Query("select balance, weight from users where id = ?").Bind(userId).ScanSingle(&totalAllowance.Progress, &totalAllowance.Weight) + if err != nil { + return nil, err + } + allowances = append(allowances, totalAllowance) + + for row := range db.db.Query("select id, name, target, balance, weight from allowances where user_id = ?"). Bind(userId).Range(&err) { allowance := Allowance{} err = row.Scan(&allowance.ID, &allowance.Name, &allowance.Target, &allowance.Progress, &allowance.Weight) @@ -88,11 +95,19 @@ func (db *Db) GetUserAllowances(userId int) ([]Allowance, error) { func (db *Db) GetUserAllowanceById(userId int, allowanceId int) (*Allowance, error) { allowance := &Allowance{} - err := db.db.Query("select id, name, target, progress, weight from allowances where user_id = ? and id = ?"). - Bind(userId, allowanceId). - ScanSingle(&allowance.ID, &allowance.Name, &allowance.Target, &allowance.Progress, &allowance.Weight) - if err != nil { - return nil, err + if allowanceId == 0 { + err := db.db.Query("select balance, weight from users where id = ?"). + Bind(userId).ScanSingle(&allowance.Progress, &allowance.Weight) + if err != nil { + return nil, err + } + } else { + err := db.db.Query("select id, name, target, balance, weight from allowances where user_id = ? and id = ?"). + Bind(userId, allowanceId). + ScanSingle(&allowance.ID, &allowance.Name, &allowance.Target, &allowance.Progress, &allowance.Weight) + if err != nil { + return nil, err + } } return allowance, nil } @@ -114,7 +129,7 @@ func (db *Db) CreateAllowance(userId int, allowance *CreateAllowanceRequest) (in defer tx.MustRollback() // Insert the new allowance - err = tx.Query("insert into allowances (user_id, name, target, progress, weight) values (?, ?, ?, 0, ?)"). + err = tx.Query("insert into allowances (user_id, name, target, weight) values (?, ?, ?, ?)"). Bind(userId, allowance.Name, allowance.Target, allowance.Weight). Exec() @@ -160,6 +175,22 @@ func (db *Db) DeleteAllowance(userId int, allowanceId int) error { return nil } +func (db *Db) UpdateUserAllowance(userId int, allowance *UpdateAllowanceRequest) error { + tx, err := db.db.Begin() + if err != nil { + return err + } + defer tx.MustRollback() + + err = tx.Query("update users set weight=? where id = ?"). + Bind(allowance.Weight, userId). + Exec() + if err != nil { + return err + } + return tx.Commit() +} + func (db *Db) UpdateAllowance(userId int, allowanceId int, allowance *UpdateAllowanceRequest) error { // Check if the allowance exists for the user count := 0 @@ -289,6 +320,67 @@ func (db *Db) UpdateTask(id int, task *CreateTaskRequest) error { return tx.Commit() } +func (db *Db) CompleteTask(taskId int) error { + tx, err := db.db.Begin() + if err != nil { + return err + } + defer tx.MustRollback() + + var reward int + err = tx.Query("select reward from tasks where id = ?").Bind(taskId).ScanSingle(&reward) + if err != nil { + return err + } + + for userRow := range tx.Query("select id, weight from users").Range(&err) { + var userId int + var userWeight float64 + err = userRow.Scan(&userId, &userWeight) + if err != nil { + return err + } + + var sumOfWeights float64 + err = tx.Query("select sum(weight) from allowances where user_id = ? and weight > 0").Bind(userId).ScanSingle(&sumOfWeights) + sumOfWeights += userWeight + + 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(reward)) + 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 + } + + // Add the remaining reward to the user + err = tx.Query("update users set balance = balance + ? where id = ?"). + Bind(reward, userId).Exec() + if err != nil { + return err + } + } + if err != nil { + return err + } + + // Remove the task + err = tx.Query("delete from tasks where id = ?").Bind(taskId).Exec() + + return tx.Commit() +} + func (db *Db) AddHistory(userId int, allowance *PostHistory) error { tx, err := db.db.Begin() if err != nil { diff --git a/backend/go.mod b/backend/go.mod index f231ba2..8ded2c3 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -3,7 +3,7 @@ module allowance_planner go 1.24.2 require ( - gitea.seeseepuff.be/seeseemelk/mysqlite v0.12.0 + gitea.seeseepuff.be/seeseemelk/mysqlite v0.14.0 github.com/gavv/httpexpect/v2 v2.17.0 github.com/gin-contrib/cors v1.7.5 github.com/gin-gonic/gin v1.10.0 @@ -68,9 +68,9 @@ require ( gopkg.in/fsnotify.v1 v1.4.7 // indirect gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - modernc.org/libc v1.65.6 // indirect + modernc.org/libc v1.65.7 // indirect modernc.org/mathutil v1.7.1 // indirect - modernc.org/memory v1.10.0 // indirect + modernc.org/memory v1.11.0 // indirect modernc.org/sqlite v1.37.0 // indirect moul.io/http2curl/v2 v2.3.0 // indirect zombiezen.com/go/sqlite v1.4.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index d07840c..964d5a6 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,5 +1,9 @@ gitea.seeseepuff.be/seeseemelk/mysqlite v0.12.0 h1:kl0VFgvm52UKxJhZpf1hvucxZdOoXY50g/VmzsWH+/8= gitea.seeseepuff.be/seeseemelk/mysqlite v0.12.0/go.mod h1:cgswydOxJjMlNwfcBIXnKjr47LwXnMT9BInkiHb0tXE= +gitea.seeseepuff.be/seeseemelk/mysqlite v0.13.0 h1:nqSXu5i5fHB1rrx/kfi8Phn/J6eFa2yh02FiGc9U1yg= +gitea.seeseepuff.be/seeseemelk/mysqlite v0.13.0/go.mod h1:cgswydOxJjMlNwfcBIXnKjr47LwXnMT9BInkiHb0tXE= +gitea.seeseepuff.be/seeseemelk/mysqlite v0.14.0 h1:aRItVfUj48fBmuec7rm/jY9KCfvHW2VzJfItVk4t8sw= +gitea.seeseepuff.be/seeseemelk/mysqlite v0.14.0/go.mod h1:cgswydOxJjMlNwfcBIXnKjr47LwXnMT9BInkiHb0tXE= github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 h1:ZBbLwSJqkHBuFDA6DUhhse0IGJ7T5bemHyNILUjvOq4= github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2/go.mod h1:VSw57q4QFiWDbRnjdX8Cb3Ow0SFncRw+bA/ofY6Q83w= github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= @@ -216,10 +220,14 @@ modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= modernc.org/libc v1.65.6 h1:OhJUhmuJ6MVZdqL5qmnd0/my46DKGFhSX4WOR7ijfyE= modernc.org/libc v1.65.6/go.mod h1:MOiGAM9lrMBT9L8xT1nO41qYl5eg9gCp9/kWhz5L7WA= +modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00= +modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4= modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= diff --git a/backend/main.go b/backend/main.go index 16b20a3..8e1f510 100644 --- a/backend/main.go +++ b/backend/main.go @@ -207,6 +207,11 @@ func deleteUserAllowance(c *gin.Context) { return } + if allowanceId == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "Allowance id zero cannot be deleted"}) + return + } + exists, err := db.UserExists(userId) if err != nil { log.Printf(ErrCheckingUserExist, err) @@ -268,7 +273,11 @@ func putUserAllowance(c *gin.Context) { return } - err = db.UpdateAllowance(userId, allowanceId, &allowanceRequest) + if allowanceId == 0 { + err = db.UpdateUserAllowance(userId, &allowanceRequest) + } else { + err = db.UpdateAllowance(userId, allowanceId, &allowanceRequest) + } if err != nil { log.Printf("Error updating allowance: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": ErrInternalServerError}) @@ -410,6 +419,29 @@ func deleteTask(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "Task deleted successfully"}) } +func completeTask(c *gin.Context) { + taskIdStr := c.Param("taskId") + taskId, err := strconv.Atoi(taskIdStr) + if err != nil { + log.Printf("Invalid task ID: %v", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid task ID"}) + return + } + + err = db.CompleteTask(taskId) + if errors.Is(err, mysqlite.ErrNoRows) { + c.JSON(http.StatusNotFound, gin.H{"error": "Task not found"}) + return + } + if err != nil { + log.Printf("Error completing task: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": ErrInternalServerError}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Task completed successfully"}) +} + func postHistory(c *gin.Context) { userIdStr := c.Param("userId") userId, err := strconv.Atoi(userIdStr) @@ -491,6 +523,7 @@ func start(ctx context.Context, config *ServerConfig) { router.GET("/api/task/:taskId", getTask) router.PUT("/api/task/:taskId", putTask) router.DELETE("/api/task/:taskId", deleteTask) + router.POST("/api/task/:taskId/complete", completeTask) srv := &http.Server{ Addr: config.Addr, diff --git a/backend/migrations/1_initial.sql b/backend/migrations/1_initial.sql index cb7a4ce..c787888 100644 --- a/backend/migrations/1_initial.sql +++ b/backend/migrations/1_initial.sql @@ -1,7 +1,9 @@ create table users ( id integer primary key, - name text not null + name text not null, + weight real not null default 1.0, + balance integer not null default 0 ) strict; create table history @@ -18,7 +20,7 @@ create table allowances user_id integer not null, name text not null, target integer not null, - progress integer not null, + balance integer not null default 0, weight real not null );