From 06c8ebcbcc76491bffa63f89cb3238f0a3d4ce73 Mon Sep 17 00:00:00 2001 From: Sebastiaan de Schaetzen Date: Fri, 30 May 2025 20:22:33 +0200 Subject: [PATCH] Add support for schedules (#137) Reviewed-on: https://gitea.seeseepuff.be/seeseemelk/allowance_planner_2000/pulls/137 --- backend/allowance_planner.db3.backup.3 | Bin 20480 -> 0 bytes backend/api_test.go | 53 ++++++++++++- backend/db.go | 104 ++++++++++++++++++++++--- backend/dto.go | 4 +- backend/go.mod | 11 +-- backend/go.sum | 12 +++ backend/main.go | 15 ++++ backend/migrations/5_add_schedules.sql | 3 + backend/web.go | 12 ++- backend/web.gohtml | 3 + common/api.yaml | 5 +- 11 files changed, 199 insertions(+), 23 deletions(-) delete mode 100644 backend/allowance_planner.db3.backup.3 create mode 100644 backend/migrations/5_add_schedules.sql diff --git a/backend/allowance_planner.db3.backup.3 b/backend/allowance_planner.db3.backup.3 deleted file mode 100644 index 380cafd58d06ed547fc988be1d29f3fda77d7c53..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 20480 zcmeI(O=}ZD7zgl~$%Z6r(v=1YC6rDO#6T&99y|%g67f(E+TLZ7Op+!0Qf6m~Ii`Z& z!VlpG@gsN?1igs4>+Ci!u5DUx+VUTg+1+P$H#5I^_OQ$5_~ntSCGUsI7n&cCyM!_F zgmXfOWxjdyU6f=!i7g5>E4jtSnx#ed-Zd+g55y|(5a-#(`|?huzGh#9ApijgKmY;| zfB*y_0D*riFrDNIwMK)!eXK>tlfv`Du?V^{TCG`!uiCAXHa}@SJ!dCF{KM|er+ zF0}HV?2FjbeE~ER-VMDlR*R#X)2dad)oS!CpN?INXcVPYJ~QGp zbj@HBh;<`UaxByeDLWz&MeZPw%VxW2^5I)LBipxrMw-(HtWc}h>Dm2stV1`_p_lX zxi~JnGOkLN%QAPB-Fx%=zWzF}n>^CW?dpp2oiOJkaXy&}1p*L&00bZa0SG_<0uX=z z1Rwx`|6gE(<;fOh&*OgIlQx}g(mZKUb}VHir7yjaiJ4J2TgCZ4bXGCj%=t{5AErWq z00bZa0SG_<0uX=z1Rwwb2teTG3Y5)r0ctTeJlNc(vpeRgK*^eKB=`T+`AW= 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 { tx, err := db.db.Begin() if err != nil { @@ -453,7 +530,10 @@ func (db *Db) CompleteTask(taskId int) error { } // Remove the task - err = tx.Query("delete from tasks where id = ?").Bind(taskId).Exec() + err = tx.Query("update tasks set completed=? where id = ?").Bind(time.Now().Unix(), taskId).Exec() + if err != nil { + return err + } return tx.Commit() } diff --git a/backend/dto.go b/backend/dto.go index d51831c..6b89d3c 100644 --- a/backend/dto.go +++ b/backend/dto.go @@ -29,7 +29,8 @@ type Task struct { ID int `json:"id"` Name string `json:"name"` Reward float64 `json:"reward"` - Assigned *int `json:"assigned"` // Pointer to allow null + Assigned *int `json:"assigned"` + Schedule *string `json:"schedule"` } type Allowance struct { @@ -68,6 +69,7 @@ type CreateTaskRequest struct { Name string `json:"name" binding:"required"` Reward float64 `json:"reward"` Assigned *int `json:"assigned"` + Schedule *string `json:"schedule"` } type CreateTaskResponse struct { diff --git a/backend/go.mod b/backend/go.mod index 8ded2c3..4e1850c 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -6,11 +6,12 @@ require ( 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 + github.com/gin-gonic/gin v1.10.1 ) require ( 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/andybalholm/brotli v1.1.1 // indirect github.com/bytedance/sonic v1.13.2 // indirect @@ -49,7 +50,7 @@ require ( github.com/sergi/go-diff v1.3.1 // indirect github.com/stretchr/testify v1.10.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect - github.com/ugorji/go/codec v1.2.12 // indirect + github.com/ugorji/go/codec v1.2.14 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.62.0 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect @@ -68,10 +69,10 @@ 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.7 // indirect + modernc.org/libc v1.65.8 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect - modernc.org/sqlite v1.37.0 // indirect + modernc.org/sqlite v1.37.1 // indirect moul.io/http2curl/v2 v2.3.0 // indirect - zombiezen.com/go/sqlite v1.4.0 // indirect + zombiezen.com/go/sqlite v1.4.2 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 0a43417..411208b 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -2,6 +2,8 @@ gitea.seeseepuff.be/seeseemelk/mysqlite v0.14.0 h1:aRItVfUj48fBmuec7rm/jY9KCfvHW 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/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/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= @@ -34,6 +36,8 @@ 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-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.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/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -127,6 +131,8 @@ 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/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.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/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasthttp v1.62.0 h1:8dKRBX/y2rCzyc6903Zu1+3qN0H/d2MsxPPmVNamiH0= @@ -216,6 +222,8 @@ 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.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00= 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/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= @@ -226,6 +234,8 @@ modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= 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.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/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= @@ -235,3 +245,5 @@ moul.io/http2curl/v2 v2.3.0/go.mod h1:RW4hyBjTWSYDOxapodpNEtX0g5Eb16sxklBqmd2RHc 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/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= diff --git a/backend/main.go b/backend/main.go index c1481a2..514972c 100644 --- a/backend/main.go +++ b/backend/main.go @@ -4,7 +4,9 @@ import ( "context" "embed" "errors" + "fmt" "gitea.seeseepuff.be/seeseemelk/mysqlite" + "github.com/adhocore/gronx" "log" "net" "net/http" @@ -436,6 +438,14 @@ func createTask(c *gin.Context) { 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 taskRequest.Assigned != nil { exists, err := db.UserExists(*taskRequest.Assigned) @@ -513,6 +523,11 @@ func putTask(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"error": "Task not found"}) 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) if err != nil { diff --git a/backend/migrations/5_add_schedules.sql b/backend/migrations/5_add_schedules.sql new file mode 100644 index 0000000..16c2807 --- /dev/null +++ b/backend/migrations/5_add_schedules.sql @@ -0,0 +1,3 @@ +alter table tasks add column schedule text; +alter table tasks add column completed date; +alter table tasks add column next_run date; diff --git a/backend/web.go b/backend/web.go index d8188e1..02e178b 100644 --- a/backend/web.go +++ b/backend/web.go @@ -71,10 +71,18 @@ func renderCreateTask(c *gin.Context) { return } - _, err = db.CreateTask(&CreateTaskRequest{ + request := &CreateTaskRequest{ Name: name, Reward: reward, - }) + } + + schedule := c.PostForm("schedule") + if schedule != "" { + request.Schedule = &schedule + } + + _, err = db.CreateTask(request) + if err != nil { renderError(c, http.StatusInternalServerError, err) return diff --git a/backend/web.gohtml b/backend/web.gohtml index cd23150..5d838c0 100644 --- a/backend/web.gohtml +++ b/backend/web.gohtml @@ -81,6 +81,7 @@ Name Assigned Reward + Schedule Actions @@ -96,6 +97,7 @@ {{end}} {{.Reward}} + {{.Schedule}} Mark as completed @@ -105,6 +107,7 @@ + diff --git a/common/api.yaml b/common/api.yaml index e2fddd9..20dedbe 100644 --- a/common/api.yaml +++ b/common/api.yaml @@ -422,7 +422,10 @@ components: description: The task name reward: type: integer - description: The task reward, in cents + description: The task reward + schedule: + type: string + description: The schedule of the task, in cron format assigned: type: integer description: The user ID of the user assigned to the task