Compare commits

..

No commits in common. "ba8a7e8690f43e8fa18efd779d06e3ce2782c768" and "5a20e76df2b7a23c617ae2e8d0d10f7a8b21b419" have entirely different histories.

11 changed files with 28 additions and 430 deletions

Binary file not shown.

View File

@ -2,11 +2,10 @@ package main
import ( import (
"fmt" "fmt"
"github.com/gavv/httpexpect/v2"
"strconv" "strconv"
"testing" "testing"
"time" "time"
"github.com/gavv/httpexpect/v2"
) )
const ( const (
@ -16,9 +15,8 @@ const (
func startServer(t *testing.T) *httpexpect.Expect { func startServer(t *testing.T) *httpexpect.Expect {
config := ServerConfig{ config := ServerConfig{
Datasource: ":memory:", Datasource: ":memory:",
//Datasource: "test.db", Addr: ":0",
Addr: ":0", Started: make(chan bool),
Started: make(chan bool),
} }
go start(t.Context(), &config) go start(t.Context(), &config)
<-config.Started <-config.Started
@ -286,54 +284,6 @@ func TestCreateTask(t *testing.T) {
responseWithUser.Value("id").Number().IsEqual(2) responseWithUser.Value("id").Number().IsEqual(2)
} }
func TestCreateScheduleTask(t *testing.T) {
e := startServer(t)
// Create a new task without assigned user
requestBody := map[string]interface{}{
"name": "Test Task",
"reward": 100,
"schedule": "0 */5 * * * *",
}
response := e.POST("/tasks").
WithJSON(requestBody).
Expect().
Status(201). // Expect Created status
JSON().Object()
requestBody["schedule"] = "every 5 seconds"
e.POST("/tasks").WithJSON(requestBody).Expect().Status(400)
// Verify the response has an ID
response.ContainsKey("id")
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("schedule").IsEqual("0 */5 * * * *")
result.Value("reward").IsEqual(100)
result.Value("assigned").IsNull()
// Complete the task
e.POST("/task/1/complete").Expect().Status(200)
// Set expires date to 1 second in the past
db.db.Query("update tasks set next_run = ? where id = 1").Bind(time.Now().Add(10 * -time.Minute).Unix()).MustExec()
// Verify a new task is created
newTask := e.GET("/task/2").Expect().Status(200).JSON().Object()
newTask.Value("id").IsEqual(2)
newTask.Value("name").IsEqual("Test Task")
newTask.Value("schedule").IsEqual("0 */5 * * * *")
newTask.Value("reward").IsEqual(100)
newTask.Value("assigned").IsNull()
}
func TestDeleteTask(t *testing.T) { func TestDeleteTask(t *testing.T) {
e := startServer(t) e := startServer(t)
@ -964,88 +914,3 @@ func createTestAllowance(e *httpexpect.Expect, name string, target float64, weig
func createTestTask(e *httpexpect.Expect) int { func createTestTask(e *httpexpect.Expect) int {
return createTestTaskWithAmount(e, 100) return createTestTaskWithAmount(e, 100)
} }
// Transfer tests
func TestTransferSuccessful(t *testing.T) {
e := startServer(t)
// Create two allowances for user 1
createTestAllowance(e, "From Allowance", 100, 1)
createTestAllowance(e, "To Allowance", 100, 1)
// Add 30 to allowance 1
req := map[string]interface{}{"amount": 30, "description": "funds"}
e.POST("/user/1/allowance/1/add").WithJSON(req).Expect().Status(200)
// Transfer 10 from 1 to 2
transfer := map[string]interface{}{"from": 1, "to": 2, "amount": 10}
e.POST("/transfer").WithJSON(transfer).Expect().Status(200).JSON().Object().Value("message").IsEqual("Transfer successful")
// Verify balances
allowances := e.GET("/user/1/allowance").Expect().Status(200).JSON().Array()
allowances.Value(1).Object().Value("progress").Number().InDelta(20.0, 0.01)
allowances.Value(2).Object().Value("progress").Number().InDelta(10.0, 0.01)
}
func TestTransferCapsAtTarget(t *testing.T) {
e := startServer(t)
// Create two allowances
createTestAllowance(e, "From Allowance", 100, 1)
createTestAllowance(e, "To Allowance", 5, 1)
// Add 10 to allowance 1
req := map[string]interface{}{"amount": 10, "description": "funds"}
e.POST("/user/1/allowance/1/add").WithJSON(req).Expect().Status(200)
// Transfer 10 from 1 to 2, but to only needs 5
transfer := map[string]interface{}{"from": 1, "to": 2, "amount": 10}
e.POST("/transfer").WithJSON(transfer).Expect().Status(200)
// Verify capped transfer
allowances := e.GET("/user/1/allowance").Expect().Status(200).JSON().Array()
allowances.Value(1).Object().Value("progress").Number().InDelta(5.0, 0.01) // from had 10, transferred 5 -> left 5
allowances.Value(2).Object().Value("progress").Number().InDelta(5.0, 0.01) // to reached target
}
func TestTransferDifferentUsersFails(t *testing.T) {
e := startServer(t)
// Create allowance for user 1 and user 2
createTestAllowance(e, "User1 Allowance", 100, 1)
// create for user 2
e.POST("/user/2/allowance").WithJSON(CreateAllowanceRequest{Name: "User2 Allowance", Target: 100, Weight: 1}).Expect().Status(201)
// Add to user1 allowance
req := map[string]interface{}{"amount": 10, "description": "funds"}
e.POST("/user/1/allowance/1/add").WithJSON(req).Expect().Status(200)
// Attempt transfer between different users
transfer := map[string]interface{}{"from": 1, "to": 1 /* wrong id to simulate different user's id? */}
// To ensure different user, fetch the allowance id for user2 (it's 1 for user2 in its own context but global id will be 2)
// Create above for user2 produced global id 2, so use that
transfer = map[string]interface{}{"from": 1, "to": 2, "amount": 5}
e.POST("/transfer").WithJSON(transfer).Expect().Status(400)
}
func TestTransferInsufficientFunds(t *testing.T) {
e := startServer(t)
// Create two allowances
createTestAllowance(e, "From Allowance", 100, 1)
createTestAllowance(e, "To Allowance", 100, 1)
// Ensure from has 0 balance
transfer := map[string]interface{}{"from": 1, "to": 2, "amount": 10}
resp := e.POST("/transfer").WithJSON(transfer).Expect().Status(400).JSON().Object()
// Error text should mention insufficient funds
resp.Value("error").String().Contains("insufficient")
}
func TestTransferNotFound(t *testing.T) {
e := startServer(t)
// No allowances exist yet (only user rows). Attempt transfer with non-existent IDs
transfer := map[string]interface{}{"from": 999, "to": 1000, "amount": 1}
e.POST("/transfer").WithJSON(transfer).Expect().Status(404)
}

View File

@ -7,8 +7,6 @@ import (
"math" "math"
"time" "time"
"github.com/adhocore/gronx"
"gitea.seeseepuff.be/seeseemelk/mysqlite" "gitea.seeseepuff.be/seeseemelk/mysqlite"
) )
@ -315,20 +313,10 @@ func (db *Db) CreateTask(task *CreateTaskRequest) (int, error) {
} }
defer tx.MustRollback() defer tx.MustRollback()
var nextRun *int64
if task.Schedule != nil {
nextRunTime, err := gronx.NextTick(*task.Schedule, false)
if err != nil {
return 0, fmt.Errorf("failed to calculate next run: %w", err)
}
nextRunTimeAsInt := nextRunTime.Unix()
nextRun = &nextRunTimeAsInt
}
// Insert the new task // Insert the new task
reward := int(math.Round(task.Reward * 100.0)) reward := int(math.Round(task.Reward * 100.0))
err = tx.Query("insert into tasks (name, reward, assigned, schedule, next_run) values (?, ?, ?, ?, ?)"). err = tx.Query("insert into tasks (name, reward, assigned) values (?, ?, ?)").
Bind(task.Name, reward, task.Assigned, task.Schedule, nextRun). Bind(task.Name, reward, task.Assigned).
Exec() Exec()
if err != nil { if err != nil {
@ -352,17 +340,13 @@ func (db *Db) CreateTask(task *CreateTaskRequest) (int, error) {
} }
func (db *Db) GetTasks() ([]Task, error) { func (db *Db) GetTasks() ([]Task, error) {
err := db.UpdateScheduledTasks()
if err != nil {
return nil, fmt.Errorf("failed to update scheduled tasks: %w", err)
}
tasks := make([]Task, 0) tasks := make([]Task, 0)
var err error
for row := range db.db.Query("select id, name, reward, assigned, schedule from tasks where completed is null").Range(&err) { for row := range db.db.Query("select id, name, reward, assigned from tasks").Range(&err) {
task := Task{} task := Task{}
var reward int64 var reward int64
err = row.Scan(&task.ID, &task.Name, &reward, &task.Assigned, &task.Schedule) err = row.Scan(&task.ID, &task.Name, &reward, &task.Assigned)
task.Reward = float64(reward) / 100.0 task.Reward = float64(reward) / 100.0
if err != nil { if err != nil {
return nil, err return nil, err
@ -378,78 +362,16 @@ func (db *Db) GetTasks() ([]Task, error) {
func (db *Db) GetTask(id int) (Task, error) { func (db *Db) GetTask(id int) (Task, error) {
task := Task{} task := Task{}
err := db.UpdateScheduledTasks()
if err != nil {
return Task{}, fmt.Errorf("failed to update scheduled tasks: %w", err)
}
var reward int64 var reward int64
err = db.db.Query("select id, name, reward, assigned, schedule from tasks where id = ? and completed is null"). err := db.db.Query("select id, name, reward, assigned from tasks where id = ?").
Bind(id).ScanSingle(&task.ID, &task.Name, &reward, &task.Assigned, &task.Schedule) Bind(id).ScanSingle(&task.ID, &task.Name, &reward, &task.Assigned)
if err != nil {
return task, err
}
task.Reward = float64(reward) / 100.0 task.Reward = float64(reward) / 100.0
if err != nil {
return Task{}, err
}
return task, nil return task, nil
} }
func (db *Db) UpdateScheduledTasks() error {
type ScheduledTask struct {
ID int
Schedule string
Expires int64
}
tasks := make([]ScheduledTask, 0)
var err error
for row := range db.db.Query("select id, schedule, next_run from tasks where schedule is not null").Range(&err) {
task := ScheduledTask{}
err := row.Scan(&task.ID, &task.Schedule, &task.Expires)
if err != nil {
return err
}
if time.Now().Unix() >= task.Expires {
tasks = append(tasks, task)
}
}
if err != nil {
return fmt.Errorf("failed to fetch scheduled tasks: %w", err)
}
tx, err := db.db.Begin()
if err != nil {
return err
}
defer tx.MustRollback()
for _, task := range tasks {
nextRun, err := gronx.NextTickAfter(task.Schedule, time.Now(), false)
if err != nil {
return fmt.Errorf("failed to calculate next run for task %d: %w", task.ID, err)
}
err = tx.Query("insert into tasks (name, reward, assigned, schedule, next_run) select name, reward, assigned, schedule, ? from tasks where id = ?").
Bind(nextRun.Unix(), task.ID).
Exec()
if err != nil {
return err
}
err = tx.Query("update tasks set schedule = null where id = ?").Bind(task.ID).Exec()
if err != nil {
return err
}
tx.Query("select last_insert_rowid()").MustScanSingle(&task.ID)
log.Printf("Task %d scheduled for %s", task.ID, nextRun)
}
if err != nil {
return err
}
return tx.Commit()
}
func (db *Db) DeleteTask(id int) error { func (db *Db) DeleteTask(id int) error {
tx, err := db.db.Begin() tx, err := db.db.Begin()
if err != nil { if err != nil {
@ -531,10 +453,7 @@ func (db *Db) CompleteTask(taskId int) error {
} }
// Remove the task // Remove the task
err = tx.Query("update tasks set completed=? where id = ?").Bind(time.Now().Unix(), taskId).Exec() err = tx.Query("delete from tasks where id = ?").Bind(taskId).Exec()
if err != nil {
return err
}
return tx.Commit() return tx.Commit()
} }
@ -712,74 +631,3 @@ func (db *Db) AddAllowanceAmount(userId int, allowanceId int, request AddAllowan
return tx.Commit() return tx.Commit()
} }
// TransferAllowance transfers amount from one allowance goal to another.
// Both allowance ids must exist and belong to the same user. The transfer
// will not move more than the 'to' goal still needs (target - balance).
func (db *Db) TransferAllowance(fromId int, toId int, amount float64) error {
if fromId == toId {
return nil
}
amountCents := int(math.Round(amount * 100.0))
if amountCents <= 0 {
return fmt.Errorf("amount must be positive")
}
tx, err := db.db.Begin()
if err != nil {
return err
}
defer tx.MustRollback()
// Fetch from allowance (user_id, balance)
var fromUserId int
var fromBalance int
err = tx.Query("select user_id, balance from allowances where id = ?").Bind(fromId).ScanSingle(&fromUserId, &fromBalance)
if err != nil {
return err
}
// Fetch to allowance (user_id, target, balance)
var toUserId int
var toTarget int
var toBalance int
err = tx.Query("select user_id, target, balance from allowances where id = ?").Bind(toId).ScanSingle(&toUserId, &toTarget, &toBalance)
if err != nil {
return err
}
// Ensure same owner
if fromUserId != toUserId {
return fmt.Errorf("allowances do not belong to the same user")
}
// Calculate how much the 'to' goal still needs
remainingTo := toTarget - toBalance
if remainingTo <= 0 {
// Nothing to transfer
return fmt.Errorf("target already reached")
}
// Limit transfer to what 'to' still needs
transfer := amountCents
if transfer > remainingTo {
transfer = remainingTo
}
// Ensure 'from' has enough balance
if fromBalance < transfer {
return fmt.Errorf("insufficient funds in source allowance")
}
// Perform updates
err = tx.Query("update allowances set balance = balance - ? where id = ? and user_id = ?").Bind(transfer, fromId, fromUserId).Exec()
if err != nil {
return err
}
err = tx.Query("update allowances set balance = balance + ? where id = ? and user_id = ?").Bind(transfer, toId, toUserId).Exec()
if err != nil {
return err
}
return tx.Commit()
}

View File

@ -29,8 +29,7 @@ type Task struct {
ID int `json:"id"` ID int `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Reward float64 `json:"reward"` Reward float64 `json:"reward"`
Assigned *int `json:"assigned"` Assigned *int `json:"assigned"` // Pointer to allow null
Schedule *string `json:"schedule"`
} }
type Allowance struct { type Allowance struct {
@ -69,7 +68,6 @@ type CreateTaskRequest struct {
Name string `json:"name" binding:"required"` Name string `json:"name" binding:"required"`
Reward float64 `json:"reward"` Reward float64 `json:"reward"`
Assigned *int `json:"assigned"` Assigned *int `json:"assigned"`
Schedule *string `json:"schedule"`
} }
type CreateTaskResponse struct { type CreateTaskResponse struct {
@ -80,10 +78,3 @@ type AddAllowanceAmountRequest struct {
Amount float64 `json:"amount"` Amount float64 `json:"amount"`
Description string `json:"description"` Description string `json:"description"`
} }
// TransferRequest represents a request to transfer amount between two goals.
type TransferRequest struct {
From int `json:"from"`
To int `json:"to"`
Amount float64 `json:"amount"`
}

View File

@ -6,12 +6,11 @@ require (
gitea.seeseepuff.be/seeseemelk/mysqlite v0.14.0 gitea.seeseepuff.be/seeseemelk/mysqlite v0.14.0
github.com/gavv/httpexpect/v2 v2.17.0 github.com/gavv/httpexpect/v2 v2.17.0
github.com/gin-contrib/cors v1.7.5 github.com/gin-contrib/cors v1.7.5
github.com/gin-gonic/gin v1.10.1 github.com/gin-gonic/gin v1.10.0
) )
require ( require (
github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 // indirect github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 // indirect
github.com/adhocore/gronx v1.19.6 // indirect
github.com/ajg/form v1.5.1 // indirect github.com/ajg/form v1.5.1 // indirect
github.com/andybalholm/brotli v1.1.1 // indirect github.com/andybalholm/brotli v1.1.1 // indirect
github.com/bytedance/sonic v1.13.2 // indirect github.com/bytedance/sonic v1.13.2 // indirect
@ -50,7 +49,7 @@ require (
github.com/sergi/go-diff v1.3.1 // indirect github.com/sergi/go-diff v1.3.1 // indirect
github.com/stretchr/testify v1.10.0 // indirect github.com/stretchr/testify v1.10.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.14 // indirect github.com/ugorji/go/codec v1.2.12 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.62.0 // indirect github.com/valyala/fasthttp v1.62.0 // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
@ -69,10 +68,10 @@ require (
gopkg.in/fsnotify.v1 v1.4.7 // indirect gopkg.in/fsnotify.v1 v1.4.7 // indirect
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.65.8 // indirect modernc.org/libc v1.65.7 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.37.1 // indirect modernc.org/sqlite v1.37.0 // indirect
moul.io/http2curl/v2 v2.3.0 // indirect moul.io/http2curl/v2 v2.3.0 // indirect
zombiezen.com/go/sqlite v1.4.2 // indirect zombiezen.com/go/sqlite v1.4.0 // indirect
) )

View File

@ -2,8 +2,6 @@ gitea.seeseepuff.be/seeseemelk/mysqlite v0.14.0 h1:aRItVfUj48fBmuec7rm/jY9KCfvHW
gitea.seeseepuff.be/seeseemelk/mysqlite v0.14.0/go.mod h1:cgswydOxJjMlNwfcBIXnKjr47LwXnMT9BInkiHb0tXE= 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 h1:ZBbLwSJqkHBuFDA6DUhhse0IGJ7T5bemHyNILUjvOq4=
github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2/go.mod h1:VSw57q4QFiWDbRnjdX8Cb3Ow0SFncRw+bA/ofY6Q83w= github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2/go.mod h1:VSw57q4QFiWDbRnjdX8Cb3Ow0SFncRw+bA/ofY6Q83w=
github.com/adhocore/gronx v1.19.6 h1:5KNVcoR9ACgL9HhEqCm5QXsab/gI4QDIybTAWcXDKDc=
github.com/adhocore/gronx v1.19.6/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg=
github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU=
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
@ -36,8 +34,6 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= 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/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@ -131,8 +127,6 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= 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 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/ugorji/go/codec v1.2.14 h1:yOQvXCBc3Ij46LRkRoh4Yd5qK6LVOgi0bYOXfb7ifjw=
github.com/ugorji/go/codec v1.2.14/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.62.0 h1:8dKRBX/y2rCzyc6903Zu1+3qN0H/d2MsxPPmVNamiH0= github.com/valyala/fasthttp v1.62.0 h1:8dKRBX/y2rCzyc6903Zu1+3qN0H/d2MsxPPmVNamiH0=
@ -222,8 +216,6 @@ modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00= modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00=
modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU= modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU=
modernc.org/libc v1.65.8 h1:7PXRJai0TXZ8uNA3srsmYzmTyrLoHImV5QxHeni108Q=
modernc.org/libc v1.65.8/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@ -234,8 +226,6 @@ modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI= modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM= modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs=
modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
@ -245,5 +235,3 @@ moul.io/http2curl/v2 v2.3.0/go.mod h1:RW4hyBjTWSYDOxapodpNEtX0g5Eb16sxklBqmd2RHc
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
zombiezen.com/go/sqlite v1.4.0 h1:N1s3RIljwtp4541Y8rM880qgGIgq3fTD2yks1xftnKU= zombiezen.com/go/sqlite v1.4.0 h1:N1s3RIljwtp4541Y8rM880qgGIgq3fTD2yks1xftnKU=
zombiezen.com/go/sqlite v1.4.0/go.mod h1:0w9F1DN9IZj9AcLS9YDKMboubCACkwYCGkzoy3eG5ik= zombiezen.com/go/sqlite v1.4.0/go.mod h1:0w9F1DN9IZj9AcLS9YDKMboubCACkwYCGkzoy3eG5ik=
zombiezen.com/go/sqlite v1.4.2 h1:KZXLrBuJ7tKNEm+VJcApLMeQbhmAUOKA5VWS93DfFRo=
zombiezen.com/go/sqlite v1.4.2/go.mod h1:5Kd4taTAD4MkBzT25mQ9uaAlLjyR0rFhsR6iINO70jc=

View File

@ -4,16 +4,13 @@ import (
"context" "context"
"embed" "embed"
"errors" "errors"
"fmt" "gitea.seeseepuff.be/seeseemelk/mysqlite"
"log" "log"
"net" "net"
"net/http" "net/http"
"os" "os"
"strconv" "strconv"
"gitea.seeseepuff.be/seeseemelk/mysqlite"
"github.com/adhocore/gronx"
"github.com/gin-contrib/cors" "github.com/gin-contrib/cors"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@ -439,14 +436,6 @@ func createTask(c *gin.Context) {
return return
} }
if taskRequest.Schedule != nil {
valid := gronx.IsValid(*taskRequest.Schedule)
if !valid {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid cron schedule: %s", *taskRequest.Schedule)})
return
}
}
// If assigned is not nil, check if user exists // If assigned is not nil, check if user exists
if taskRequest.Assigned != nil { if taskRequest.Assigned != nil {
exists, err := db.UserExists(*taskRequest.Assigned) exists, err := db.UserExists(*taskRequest.Assigned)
@ -524,11 +513,6 @@ func putTask(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "Task not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Task not found"})
return return
} }
if err != nil {
log.Printf("Error getting task: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": ErrInternalServerError})
return
}
err = db.UpdateTask(taskId, &taskRequest) err = db.UpdateTask(taskId, &taskRequest)
if err != nil { if err != nil {
@ -654,68 +638,10 @@ func getHistory(c *gin.Context) {
c.IndentedJSON(http.StatusOK, history) c.IndentedJSON(http.StatusOK, history)
} }
// TransferRequest handler: accepts JSON with from, to, amount and transfers between allowances /*
func transferHandler(c *gin.Context) { Initialises the database, and then starts the server.
var req TransferRequest If the context gets cancelled, the server is shutdown and the database is closed.
if err := c.ShouldBindJSON(&req); err != nil { */
log.Printf("Error parsing transfer request: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
if req.From == 0 || req.To == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid allowance id"})
return
}
if req.Amount <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Amount must be positive"})
return
}
// Verify both allowances exist and fetch owners
var fromUserId int
err := db.db.Query("select user_id from allowances where id = ?").Bind(req.From).ScanSingle(&fromUserId)
if err != nil {
if errors.Is(err, mysqlite.ErrNoRows) {
c.JSON(http.StatusNotFound, gin.H{"error": "Source allowance not found"})
return
}
log.Printf("Error checking source allowance: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": ErrInternalServerError})
return
}
var toUserId int
err = db.db.Query("select user_id from allowances where id = ?").Bind(req.To).ScanSingle(&toUserId)
if err != nil {
if errors.Is(err, mysqlite.ErrNoRows) {
c.JSON(http.StatusNotFound, gin.H{"error": "Destination allowance not found"})
return
}
log.Printf("Error checking destination allowance: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": ErrInternalServerError})
return
}
if fromUserId != toUserId {
c.JSON(http.StatusBadRequest, gin.H{"error": "Allowances do not belong to the same user"})
return
}
// Perform transfer
err = db.TransferAllowance(req.From, req.To, req.Amount)
if err != nil {
// Map common errors to 400
if errors.Is(err, mysqlite.ErrNoRows) {
c.JSON(http.StatusNotFound, gin.H{"error": "Allowance not found"})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Transfer successful"})
}
func start(ctx context.Context, config *ServerConfig) { func start(ctx context.Context, config *ServerConfig) {
db = NewDb(config.Datasource) db = NewDb(config.Datasource)
defer db.db.MustClose() defer db.db.MustClose()
@ -747,8 +673,6 @@ func start(ctx context.Context, config *ServerConfig) {
router.PUT("/api/task/:taskId", putTask) router.PUT("/api/task/:taskId", putTask)
router.DELETE("/api/task/:taskId", deleteTask) router.DELETE("/api/task/:taskId", deleteTask)
router.POST("/api/task/:taskId/complete", completeTask) router.POST("/api/task/:taskId/complete", completeTask)
// transfer endpoint
router.POST("/api/transfer", transferHandler)
srv := &http.Server{ srv := &http.Server{
Addr: config.Addr, Addr: config.Addr,

View File

@ -1,3 +0,0 @@
alter table tasks add column schedule text;
alter table tasks add column completed date;
alter table tasks add column next_run date;

View File

@ -71,18 +71,10 @@ func renderCreateTask(c *gin.Context) {
return return
} }
request := &CreateTaskRequest{ _, err = db.CreateTask(&CreateTaskRequest{
Name: name, Name: name,
Reward: reward, Reward: reward,
} })
schedule := c.PostForm("schedule")
if schedule != "" {
request.Schedule = &schedule
}
_, err = db.CreateTask(request)
if err != nil { if err != nil {
renderError(c, http.StatusInternalServerError, err) renderError(c, http.StatusInternalServerError, err)
return return

View File

@ -81,7 +81,6 @@
<th>Name</th> <th>Name</th>
<th>Assigned</th> <th>Assigned</th>
<th>Reward</th> <th>Reward</th>
<th>Schedule</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
@ -97,7 +96,6 @@
{{end}} {{end}}
</td> </td>
<td>{{.Reward}}</td> <td>{{.Reward}}</td>
<td>{{.Schedule}}</td>
<td> <td>
<a href="/completeTask?task={{.ID}}">Mark as completed</a> <a href="/completeTask?task={{.ID}}">Mark as completed</a>
</td> </td>
@ -107,7 +105,6 @@
<td><label><input type="text" name="name" placeholder="Name"></label></td> <td><label><input type="text" name="name" placeholder="Name"></label></td>
<td></td> <td></td>
<td><label><input type="number" name="reward" placeholder="Reward"></label></td> <td><label><input type="number" name="reward" placeholder="Reward"></label></td>
<td><label><input type="text" name="schedule" placeholder="Schedule"></label></td>
<td><input type="submit" value="Create"></td> <td><input type="submit" value="Create"></td>
</tr> </tr>
</tbody> </tbody>

View File

@ -422,10 +422,7 @@ components:
description: The task name description: The task name
reward: reward:
type: integer type: integer
description: The task reward description: The task reward, in cents
schedule:
type: string
description: The schedule of the task, in cron format
assigned: assigned:
type: integer type: integer
description: The user ID of the user assigned to the task description: The user ID of the user assigned to the task