Compare commits

179 Commits

Author SHA1 Message Date
d3ce7d5ac3 fix: new linting errors
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m34s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m33s
2025-11-05 07:00:49 +01:00
6e5b4a7b3d chore(deps): update node.js to e5bbac0
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m31s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m37s
2025-11-05 02:28:24 +00:00
cc76a77b31 chore(deps): update node.js to 69698ef
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m34s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m38s
2025-11-04 23:03:15 +00:00
2ac272582f chore(deps): update node.js to 55b6bbe
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m40s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m34s
2025-11-04 14:03:14 +00:00
10240977ca chore(deps): update node.js to 7b3ae90
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m32s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m38s
2025-11-04 12:03:19 +00:00
a7258f6c91 chore(deps): update debian:13.1 docker digest to 01a723b
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m30s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m39s
2025-11-04 11:03:12 +00:00
63701f44f5 chore(deps): update golang:1.25.3 docker digest to 7e3cbcd
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m30s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m36s
2025-11-04 10:03:08 +00:00
ad1811e37c chore(deps): update debian:13.1 docker digest to e623a68
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m31s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m36s
2025-11-04 08:03:05 +00:00
689dba2f1a chore(deps): update debian:13.1 docker digest to 5803574
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m33s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m37s
2025-11-04 02:31:55 +00:00
7b5fb9e35a chore(deps): update node.js to v24.11.0
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m31s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m35s
2025-10-29 04:03:11 +00:00
237d26675d chore(deps): update golang:1.25.3 docker digest to 6bac879
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m28s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m33s
2025-10-26 15:02:41 +00:00
7ec60b0f93 chore(deps): update dependency htmx.org to v2.0.8
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m38s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m43s
2025-10-25 03:03:37 +00:00
fcb76ae7a8 chore(deps): update golang:1.25.3 docker digest to dd08f76
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 5m5s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m24s
2025-10-24 21:03:09 +00:00
37525ac31f chore(deps): update tailwindcss monorepo to v4.1.16
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m29s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m35s
2025-10-23 12:03:41 +00:00
6b11355857 chore(deps): update node.js to 23c24e8
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m30s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m34s
2025-10-22 19:03:11 +00:00
7d8f6fd1e5 chore(deps): update golang:1.25.3 docker digest to 8c945d3
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m30s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m32s
2025-10-22 09:03:16 +00:00
36af297210 chore(deps): update node.js to 91b08ad
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m29s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m33s
2025-10-22 07:03:21 +00:00
b05835dde9 chore(deps): update golang:1.25.3 docker digest to bce1e7e
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m33s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m34s
2025-10-22 03:03:51 +00:00
ff8bd828ec chore(deps): update node.js to v22.21.0
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m32s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m36s
2025-10-21 22:03:12 +00:00
4ec8959db8 chore(deps): update node.js to 915acd9
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m27s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m27s
2025-10-21 13:03:29 +00:00
1d2a6d1c3a chore(deps): update golang:1.25.3 docker digest to 0d8c14c
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 3m57s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m23s
2025-10-21 12:03:39 +00:00
67996068d1 chore(deps): update node.js to 6b66300
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m30s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m35s
2025-10-21 10:03:20 +00:00
3e8723e359 chore(deps): update golang:1.25.3 docker digest to ffa2e57
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m32s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m33s
2025-10-21 09:06:52 +00:00
6c49f0311f chore(deps): update debian:13.1 docker digest to 72547dd
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m31s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m39s
2025-10-21 04:03:20 +00:00
adf68a8e11 chore(deps): update tailwindcss monorepo to v4.1.15
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m28s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m35s
2025-10-20 14:08:02 +00:00
a2a88381cb fix(deps): update module github.com/a-h/templ to v0.3.960
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m34s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m35s
2025-10-15 15:05:42 +00:00
e2ddb0b07b chore(deps): update golang:1.25.3 docker digest to 6ea52a0
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m28s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m35s
2025-10-15 09:04:05 +00:00
1905e5cc03 chore(deps): update golang:1.25.3 docker digest to 2e3aca2
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 5m2s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m24s
2025-10-15 00:04:03 +00:00
a5e334f4ac chore(deps): update golang docker tag to v1.25.3
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m0s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m23s
2025-10-14 00:04:02 +00:00
dc1f9e7a19 chore(deps): update dependency go to v1.25.3
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m35s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m35s
2025-10-13 22:04:09 +00:00
cd248472f4 chore(deps): update node.js to v24.10.0
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m29s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m32s
2025-10-08 23:03:43 +00:00
76311c3603 fix(deps): update module golang.org/x/net to v0.46.0
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m32s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m31s
2025-10-08 22:04:01 +00:00
bba3b32bea fix(deps): update module golang.org/x/crypto to v0.43.0
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m31s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m33s
2025-10-08 19:10:05 +00:00
73cd04015c chore(deps): update golang docker tag to v1.25.2
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 3m33s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m24s
2025-10-07 22:03:43 +00:00
a9c4304ef8 fix(deps): update module golang.org/x/net to v0.45.0
All checks were successful
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m25s
2025-10-07 20:25:52 +00:00
953d53e884 chore(deps): update node.js to v24.9.0
Some checks failed
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Has been cancelled
2025-10-07 20:25:36 +00:00
65b6223256 chore(deps): update node.js to v22.20.0
Some checks failed
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Has been cancelled
2025-10-07 20:22:40 +00:00
65fca6390f chore(deps): update tailwindcss monorepo to v4.1.14
Some checks failed
Build Docker Image / Build-Docker-Image (push) Successful in 1m18s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Has been cancelled
2025-10-07 20:10:23 +00:00
0394a04c3f chore(deps): update dependency go to v1.25.2
All checks were successful
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 3m43s
2025-10-07 20:03:49 +00:00
29dfd4fa75 chore(deps): update golang:1.25.1 docker digest to d709837
Some checks failed
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Has been cancelled
2025-10-07 20:03:34 +00:00
12a0ef8c92 chore(deps): update debian:13.1 docker digest to fd8f5a1
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m28s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 5m2s
2025-10-07 19:30:15 +00:00
65d70fd6df fix: linting errors
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m27s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m38s
2025-10-07 18:09:53 +02:00
278630f2e9 chore(deps): update golang:1.25.1 docker digest to 8305f5f
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m48s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m39s
2025-09-15 08:26:44 +00:00
bca9563525 chore(deps): update node.js to v24.8.0
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m48s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m35s
2025-09-11 10:08:35 +00:00
8e747efe5f chore(deps): update golang:1.25.1 docker digest to bb979b2
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m51s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m43s
2025-09-11 09:16:29 +00:00
37e5348d7e chore(deps): update node.js to afff6d8
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m49s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m36s
2025-09-10 10:12:19 +00:00
8e2b4f17aa chore(deps): update golang:1.25.1 docker digest to 1fd7d46
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m51s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m21s
2025-09-10 00:08:08 +00:00
d9be074ef3 chore(deps): update golang:1.25.1 docker digest to b773c94
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m41s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m29s
2025-09-09 21:08:04 +00:00
037ae74272 fix(deps): update module golang.org/x/net to v0.44.0
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m40s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m7s
2025-09-09 14:06:40 +00:00
fd42aa1160 chore(deps): update node.js to d6ba961
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m25s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 3m50s
2025-09-09 13:05:07 +00:00
b00c93262c chore(deps): update node.js to dc08161
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m11s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 3m58s
2025-09-09 10:08:55 +00:00
68431436fc chore(deps): update golang:1.25.1 docker digest to d6bdb04
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m12s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m6s
2025-09-09 09:05:25 +00:00
e59541a524 chore(deps): update golang:1.25.1 docker digest to 8919d35
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 3m15s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 3m11s
2025-09-09 08:05:11 +00:00
101069b2a6 chore(deps): update debian:13.1 docker digest to 833c135
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 8m20s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 2m39s
2025-09-09 07:26:18 +00:00
3759fd8d71 chore(deps): update golang:1.25.1 docker digest to 0caf875
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 8m16s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 3m44s
2025-09-09 00:06:01 +00:00
aa5636e361 chore(deps): update debian docker tag to v13.1
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m37s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m26s
2025-09-08 22:05:58 +00:00
ddaf7b8368 chore(deps): update dependency htmx.org to v2.0.7
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m30s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m2s
2025-09-08 18:05:33 +00:00
49e9b31a2d fix(deps): update module golang.org/x/crypto to v0.42.0
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m40s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m4s
2025-09-08 16:24:42 +00:00
5944208ca2 chore(deps): update actions/checkout action to v5
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m17s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m20s
2025-09-05 22:08:44 +00:00
95767a8127 chore(deps): update golang:1.25.1 docker digest to a5e935d
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m54s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m35s
2025-09-05 18:06:03 +00:00
e16aec5f98 chore(deps): update tailwindcss monorepo to v4.1.13
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 5m1s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m22s
2025-09-04 18:06:18 +00:00
08dcc486d3 chore(deps): update golang:1.25.1 docker digest to 76a94c4
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m56s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m19s
2025-09-04 00:05:58 +00:00
0e130aeee4 chore(deps): update golang docker tag to v1.25.1
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 8m11s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 3m57s
2025-09-03 19:08:57 +00:00
01101fc2dd chore(deps): update dependency go to v1.25.1
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 7m17s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m31s
2025-09-03 18:06:29 +00:00
caedc4ce90 fix(deps): update opentelemetry-go monorepo
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 5m15s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m48s
2025-08-29 23:06:19 +00:00
8a3615b612 fix(deps): update opentelemetry-go-contrib monorepo
Some checks failed
Build Docker Image / Build-Docker-Image (push) Successful in 5m9s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Failing after 3m31s
2025-08-29 22:07:29 +00:00
efb1475f11 fix(deps): update module github.com/golang-migrate/migrate/v4 to v4.19.0
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m46s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m40s
2025-08-29 19:06:15 +00:00
b163495059 chore(deps): update node.js to 6fe2868
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m37s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m10s
2025-08-29 07:05:36 +00:00
24ede772c9 chore(deps): update node.js to c7fe411
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m33s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m13s
2025-08-29 04:05:31 +00:00
3aca37839c fix(deps): update module github.com/stretchr/testify to v1.11.1
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 3m17s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 3m5s
2025-08-29 01:42:53 +00:00
73449d495e chore(deps): update node.js to v22.19.0
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 11m32s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 3m32s
2025-08-29 01:14:29 +00:00
7652f823c8 chore(deps): update node.js to v24.7.0
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m56s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 7m6s
2025-08-27 22:06:11 +00:00
63c2594cbe chore(deps): update golang:1.25.0 docker digest to 5502b0e
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m41s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 3m57s
2025-08-25 03:05:43 +00:00
52693e2846 fix(deps): update module github.com/stretchr/testify to v1.11.0
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m52s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m11s
2025-08-24 17:08:43 +00:00
d0faee2950 chore(deps): update golang:1.25.0 docker digest to 4859242
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 8m24s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 3m39s
2025-08-22 21:05:43 +00:00
01d459e913 fix: add "?" to validateString
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m52s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m36s
2025-08-20 04:41:29 +02:00
cee533694c chore(deps): update golang:1.25.0 docker digest to 91e2cd4
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 8m40s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 2m40s
2025-08-18 12:05:26 +00:00
f6283c6ab3 fix(deps): update module github.com/a-h/templ to v0.3.943
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m51s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m33s
2025-08-15 16:05:55 +00:00
e48d11b818 chore(deps): update node.js to v24.6.0
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m55s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m38s
2025-08-14 22:06:46 +00:00
b9150334ee chore(deps): update node.js to 3266bc9
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m47s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m41s
2025-08-14 16:06:16 +00:00
d20981beaa fix(deps): update module github.com/mattn/go-sqlite3 to v1.14.32
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 5m0s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m34s
2025-08-14 15:06:41 +00:00
3c95abe59c fix(deps): update module github.com/a-h/templ to v0.3.937
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 5m6s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m30s
2025-08-14 14:10:03 +00:00
fad2bd3928 chore(deps): update tailwindcss monorepo to v4.1.12
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m55s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m34s
2025-08-14 13:06:29 +00:00
c75b99ea9d chore(deps): update node.js to 73297e2
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m46s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m31s
2025-08-14 07:05:57 +00:00
56737a4156 chore(deps): update golang:1.25.0 docker digest to 9e56f0d
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m58s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m46s
2025-08-14 06:09:06 +00:00
ab425d759c chore(deps): update node.js to 58a2604
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m55s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m42s
2025-08-14 01:16:40 +00:00
283679fc4f chore(deps): update golang:1.25.0 docker digest to 10a15b9
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m49s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m38s
2025-08-14 00:06:49 +00:00
e0802cf232 chore(deps): update dependency go to v1.25.0
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 5m19s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m43s
2025-08-13 23:10:18 +00:00
6577dbb297 chore(deps): update golang docker tag to v1.25.0
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m49s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m41s
2025-08-13 22:11:10 +00:00
7e37c24b07 chore(deps): update golang:1.24.6 docker digest to 746a0e9
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 8m30s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 3m41s
2025-08-13 21:06:28 +00:00
3f3edbb8ad chore(deps): update golang:1.24.6 docker digest to 86a999d
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m37s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m22s
2025-08-13 18:06:20 +00:00
16429f1950 chore(deps): update debian docker tag to v13
All checks were successful
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m55s
2025-08-13 15:25:14 +00:00
82a9fd8220 chore(deps): update golang:1.24.6 docker digest to 370491a
Some checks failed
Build Docker Image / Build-Docker-Image (push) Successful in 4m50s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Has been cancelled
2025-08-13 15:08:39 +00:00
192e6b7f50 chore(deps): update node.js to 5cc5271
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m37s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m33s
2025-08-13 02:06:22 +00:00
57377f9c27 chore(deps): update debian:12.11 docker digest to 731dd13
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 5m8s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m58s
2025-08-13 01:16:08 +00:00
66227c5818 chore(deps): update golang:1.24.6 docker digest to 0348485
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 8m32s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 3m44s
2025-08-13 00:08:49 +00:00
6e51e3c8b3 chore(deps): update debian:12.11 docker digest to 5626655
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m57s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m39s
2025-08-12 22:06:39 +00:00
6c916aecb4 fix(deps): update module github.com/mattn/go-sqlite3 to v1.14.31
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 5m0s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m39s
2025-08-11 15:06:14 +00:00
8575fbf56e chore(deps): update actions/checkout digest to 08eba0b
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m46s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m38s
2025-08-11 11:05:53 +00:00
f820fcdfeb fix(deps): update module golang.org/x/net to v0.43.0
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m15s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m8s
2025-08-09 20:35:22 +00:00
f6e58b7afc fix(deps): update module golang.org/x/crypto to v0.41.0
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m44s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m2s
2025-08-09 02:06:26 +00:00
93e669b038 chore(deps): update golang docker tag to v1.24.6
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m28s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 3m55s
2025-08-09 01:10:39 +00:00
d037317aab chore(deps): update dependency go to v1.24.6
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 3m27s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 3m50s
2025-08-08 23:09:13 +00:00
0517e7ec89 feat(transaction): #243 add pagination to transactions
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 2m28s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 2m34s
2025-08-09 00:36:50 +02:00
867c0ca1cd chore(deps): update node.js to 3218f0d
Some checks failed
Build Docker Image / Build-Docker-Image (push) Successful in 4m3s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Failing after 2m50s
2025-08-04 16:07:53 +00:00
ddcbfaa075 chore(deps): update node.js to 0d98a9f
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 3m44s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 3m41s
2025-08-04 13:04:59 +00:00
4583c0a70e chore(deps): update node.js to v22.18.0
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m14s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 3m43s
2025-08-04 10:05:10 +00:00
380854272a chore(deps): update dependency echarts to v6
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 5m9s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m48s
2025-08-01 22:42:20 +02:00
6bc9e0666b feat(layout): #211 optimize the overall layout for mobile
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m10s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m18s
move navigation to aside
proper mobile handling
update logo.svg
remove pirata-one/only use it for the logo
2025-08-01 22:26:17 +02:00
9fa554c60a chore(deps): update node.js to v24.5.0
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m48s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m37s
2025-07-31 22:08:19 +00:00
763c952cbe fix(deps): update module github.com/mattn/go-sqlite3 to v1.14.30
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m44s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m7s
2025-07-30 13:06:28 +00:00
fce669146f fix(deps): update module github.com/a-h/templ to v0.3.924
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m16s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m5s
2025-07-26 16:05:39 +00:00
06219d1fd3 fix(deps): update module github.com/mattn/go-sqlite3 to v1.14.29
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 3m19s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 3m42s
2025-07-25 23:05:50 +00:00
9fac68d7ae chore(deps): update node.js to 37ff334
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 7m58s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 2m47s
2025-07-25 22:07:02 +00:00
8afd48b981 chore(deps): update golang:1.24.5 docker digest to ef5b4be
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 7m35s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 3m47s
2025-07-25 21:05:10 +00:00
25568591fd fix: build
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 5m8s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m39s
2025-07-25 22:47:07 +02:00
e1551c1fa3 chore(deps): update debian:12.11 docker digest to b6507e3
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 5m8s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m49s
2025-07-22 04:06:22 +00:00
e8b3d3e16c fix(deps): update module github.com/a-h/templ to v0.3.920
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 5m5s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m26s
2025-07-20 19:06:40 +00:00
59288d4544 chore(deps): update node.js to 9e6918e
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m59s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m27s
2025-07-17 01:06:01 +00:00
a2d1f22d46 chore(deps): update node.js to 414e20e
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m46s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m40s
2025-07-16 22:09:53 +00:00
0e150b3d7d chore(deps): update node.js to v22.17.1
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 5m24s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m54s
2025-07-16 19:08:44 +00:00
19567313bd chore(deps): update node.js to v24.4.1
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m36s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m14s
2025-07-15 23:08:46 +00:00
42f1cfc07f fix(deps): update module golang.org/x/net to v0.42.0
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 5m3s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m30s
2025-07-10 20:06:21 +00:00
0276bc6a4c fix(deps): update module golang.org/x/crypto to v0.40.0
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 5m10s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m28s
2025-07-10 18:06:59 +00:00
d6c8559d4c chore(deps): update golang:1.24.5 docker digest to 14fd8a5
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 5m2s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 5m2s
2025-07-09 21:05:29 +00:00
38cdd96b6f chore(deps): update golang docker tag to v1.24.5
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 8m3s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m0s
2025-07-09 19:05:32 +00:00
cb49494e60 chore(deps): update node.js to v24.4.0
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m57s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m33s
2025-07-09 13:08:35 +00:00
3ffe7514e2 chore(deps): update dependency go to v1.24.5
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 5m0s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m34s
2025-07-08 17:15:13 +00:00
d13a387303 chore(deps): update node.js to 2fa6c97
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m42s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m32s
2025-07-08 04:05:16 +00:00
a398d275f5 chore(deps): update node.js to 5307f5f
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m42s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m28s
2025-07-08 01:39:32 +00:00
23b97a9cac chore(deps): update node.js to df39165
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m55s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m32s
2025-07-07 22:06:05 +00:00
4b74a9b6d4 chore(deps): update golang:1.24.4 docker digest to 20a022e
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m6s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m6s
2025-07-02 12:04:58 +00:00
c67f232e9b chore(deps): update golang:1.24.4 docker digest to 764d7e0
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 3m47s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m4s
2025-07-02 00:06:32 +00:00
93727ee49a chore(deps): update golang:1.24.4 docker digest to a92f3b1
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m1s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m7s
2025-07-01 21:05:23 +00:00
1a79df9423 chore(deps): update golang:1.24.4 docker digest to 1aa97dd
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 3m50s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 3m48s
2025-07-01 18:05:24 +00:00
582d265fd5 chore(deps): update golang:1.24.4 docker digest to 1bb140b
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 3m55s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 3m56s
2025-07-01 15:05:25 +00:00
f094767582 chore(deps): update golang:1.24.4 docker digest to 270cd53
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 6m39s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 3m18s
2025-07-01 06:54:12 +00:00
440fed9ed1 chore(deps): update debian:12.11 docker digest to d42b86d
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m12s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 3m55s
2025-07-01 04:06:08 +00:00
6e1d24eef7 feat(dashboard): #191 add development of treasurechests
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 12m24s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 2m53s
2025-06-30 00:39:14 +02:00
f37b50515b fix(deps): update opentelemetry-go-contrib monorepo
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 5m58s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 5m25s
2025-06-27 19:06:53 +00:00
b2a512d186 fix(deps): update opentelemetry-go monorepo
All checks were successful
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m58s
2025-06-27 18:28:27 +00:00
4a28fb5ca4 chore(deps): update node.js to v24.3.0
Some checks failed
Build Docker Image / Build-Docker-Image (push) Successful in 5m19s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Has been cancelled
2025-06-27 18:11:32 +00:00
e5f98c1fb0 chore(deps): update node.js to v22.17.0
All checks were successful
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 5m13s
2025-06-27 17:48:00 +00:00
3072df6507 fix(deps): update module github.com/a-h/templ to v0.3.906
Some checks failed
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Has been cancelled
2025-06-27 17:42:29 +00:00
9f35ca7476 chore(deps): update tailwindcss monorepo to v4.1.11
Some checks failed
Build Docker Image / Build-Docker-Image (push) Successful in 5m3s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Has been cancelled
2025-06-27 17:24:55 +00:00
472ab68986 chore(deps): update dependency htmx.org to v2.0.6
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 5m40s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 5m21s
2025-06-27 16:33:51 +00:00
2fd2200ac2 fix: enable dot in stings
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m59s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 3m57s
2025-06-22 19:40:59 +02:00
a58ddb7a1d chore(deps): update dependency htmx.org to v2.0.5
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m34s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m5s
2025-06-20 22:06:46 +00:00
147d57f6e5 feat: #194 enable sqlite wal mode for better performace
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 3m40s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 3m57s
2025-06-20 22:55:59 +02:00
d064626197 feat: #193 disable session handling for static content
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 3m48s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 3m41s
2025-06-20 22:46:13 +02:00
72869e5c68 feat(dashboard): #192 include treemap of treasure chests
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m47s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 3m36s
2025-06-20 21:31:37 +02:00
3120c19669 feat(dashboard): #82 add chart for sum of account and savings
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 5m21s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 5m33s
2025-06-19 18:05:36 +02:00
c9bf320611 chore: include node version manager
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 5m28s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m51s
2025-06-19 12:31:55 +02:00
3b3343bdb5 fix(observabillity): set service.name resource on metrics and traces
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 5m23s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 5m34s
2025-06-17 13:20:13 +02:00
6c92206b3c fix(observabillity): propagate ctx to every log call and add resource to logging
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 5m5s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 5m36s
2025-06-17 12:59:43 +02:00
ff3c7bdf52 fix(observabillity): add service_name tag to logs
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 5m18s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 5m0s
2025-06-16 23:22:31 +02:00
06a8c80f1d feat(dashboard): #162 include total sum of accounts compared to total savings
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 5m15s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 5m7s
2025-06-16 23:00:38 +02:00
596cc602d0 feat(auth): #182 cleanup expired tokens
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 5m19s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 5m7s
2025-06-16 22:42:23 +02:00
3df9fab25b fix(dashboard): #163 month selection on first load
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m54s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m56s
2025-06-16 13:00:35 +02:00
6b8059889d feat(dashboard): #163 first summary
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 5m11s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 5m6s
2025-06-15 22:04:50 +02:00
935019c1c4 chore(deps): update golang:1.24.4 docker digest to 10c1318
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 5m30s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 5m44s
2025-06-12 09:05:30 +00:00
a9d8e10592 chore(deps): update node.js to 71bcbb3
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 5m30s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 5m32s
2025-06-12 07:05:32 +00:00
e8a13dc8e7 chore(deps): update node.js to 68cf33c
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 5m36s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 5m28s
2025-06-12 04:06:04 +00:00
96b4ac414e chore(deps): update node.js to 2040569
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 5m25s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 5m32s
2025-06-12 02:06:05 +00:00
95340547e6 chore(deps): update golang:1.24.4 docker digest to 3178db8
Some checks failed
Build Docker Image / Build-Docker-Image (push) Successful in 5m18s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Failing after 7m28s
2025-06-12 00:06:22 +00:00
58547099bc chore(deps): update tailwindcss monorepo to v4.1.10
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 5m19s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 5m17s
2025-06-11 23:06:53 +00:00
67259d5110 chore(deps): update node.js to f627d0e
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 5m14s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 5m38s
2025-06-11 22:07:17 +00:00
67d10b2b95 chore(deps): update golang:1.24.4 docker digest to 884849e
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 5m20s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 5m32s
2025-06-11 21:05:47 +00:00
910d8848d8 chore(deps): update golang:1.24.4 docker digest to 8806e87
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 5m35s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 5m23s
2025-06-11 18:09:08 +00:00
0fd18fbb4f chore(deps): update tailwindcss monorepo to v4.1.9
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m57s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 5m31s
2025-06-11 16:07:07 +00:00
9843db9402 chore(deps): update golang:1.24.4 docker digest to dc3de88
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 5m8s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m51s
2025-06-11 15:06:04 +00:00
baf44d680b chore(deps): update node.js to 6a2972b
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 6m2s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 5m59s
2025-06-11 04:10:15 +00:00
0a6cc5c771 chore(deps): update golang:1.24.4 docker digest to d1db785
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 8m51s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 5m45s
2025-06-11 03:06:08 +00:00
fa82ce34dc chore(deps): update debian:12.11 docker digest to 0d8498a
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 5m21s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 5m49s
2025-06-11 02:09:19 +00:00
c4719db21f chore(deps): update golang:1.24.4 docker digest to 01f861b
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 5m23s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 5m9s
2025-06-11 00:05:43 +00:00
2e1a0eedd0 fix: sql error
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 4m46s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 4m11s
2025-06-08 18:34:08 +02:00
11f3bcc89f feat(observabillity): #153 instrument sqlx
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 2m29s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 2m49s
2025-06-07 22:04:29 +02:00
c4aca2778f fix(observabillity): update otel-collector endpoint
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 5m49s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 5m33s
2025-06-07 15:57:13 +02:00
63ade5916e fix(observabillity): include otel logs
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 5m33s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 5m4s
2025-06-07 15:32:43 +02:00
75 changed files with 2375 additions and 1274 deletions

View File

@@ -10,6 +10,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out repository code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- run: docker build . -t spend-sparrow-test
- run: docker rmi spend-sparrow-test

View File

@@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out repository code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- run: docker login git.wundenbergs.de -u tim -p ${{ secrets.DOCKER_GITEA_TOKEN }}
- run: docker build . -t git.wundenbergs.de/x/spend-sparrow:latest -t git.wundenbergs.de/x/spend-sparrow:$GITHUB_SHA
- run: docker push git.wundenbergs.de/x/spend-sparrow:latest

3
.gitignore vendored
View File

@@ -25,12 +25,13 @@ go.work.sum
# env file
.env
*.db
data/
secrets/
node_modules/
static/css/tailwind.css
static/js/htmx.min.js
static/js/echarts.min.js
tmp/
mocks/*

View File

@@ -24,6 +24,9 @@ linters:
- cyclop
- contextcheck
- bodyclose # i don't care in the tests, the implementation itself doesn't do http requests
- wsl_v5
- noinlineerr
- unqueryvet
settings:
nestif:
min-complexity: 6

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
24.11.0

View File

@@ -1,4 +1,4 @@
FROM golang:1.24.4@sha256:db5d0afbfb4ab648af2393b92e87eaae9ad5e01132803d80caef91b5752d289c AS builder_go
FROM golang:1.25.3@sha256:7e3cbcd2f6af1bebb937462ec29f77ce28b406081af509afed158fa8721f11af AS builder_go
WORKDIR /spend-sparrow
RUN go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
RUN go install github.com/a-h/templ/cmd/templ@latest
@@ -13,7 +13,7 @@ RUN golangci-lint run ./...
RUN go build -o /spend-sparrow/spend-sparrow .
FROM node:22.16.0@sha256:0b5b940c21ab03353de9042f9166c75bcfc53c4cd0508c7fd88576646adbf875 AS builder_node
FROM node:24.11.0@sha256:e5bbac0e9b8a6e3b96a86a82bbbcf4c533a879694fd613ed616bae5116f6f243 AS builder_node
WORKDIR /spend-sparrow
COPY package.json package-lock.json ./
RUN npm clean-install
@@ -21,7 +21,7 @@ COPY . ./
RUN npm run build
FROM debian:12.11@sha256:bd73076dc2cd9c88f48b5b358328f24f2a4289811bd73787c031e20db9f97123
FROM debian:13.1@sha256:01a723bf5bfb21b9dda0c9a33e0538106e4d02cce8f557e118dd61259553d598
WORKDIR /spend-sparrow
RUN apt-get update && apt-get install -y ca-certificates && echo "" > .env
COPY migration ./migration

View File

@@ -3,6 +3,15 @@
SpendSparrow is a web app to keep track of expenses and income. It is very opinionated by keeping an keen eye on disciplin of it's users. Every Expense needs to be mapped to a Piggy Bank. For emergencies, funds can be moved between Piggy Banks.
## Prerequisites
```bash
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
go install github.com/a-h/templ/cmd/templ@latest
go install github.com/vektra/mockery/v2@latest
```
## Design priciples
The State of the application can always be calculated on the fly. Even though it is not an Event Streaming Application, it is still important to be able to recalculate historic data.
@@ -10,3 +19,4 @@ It may be applicable to do some sort of monthly snapshots to speed up calculatio
This applications uses as little dependencies as feasible, especially on the front end.

View File

@@ -0,0 +1,188 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="210mm"
height="297mm"
viewBox="0 0 210 297"
version="1.1"
id="svg1"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
sodipodi:docname="logo-inkscape.svg"
inkscape:export-filename="static/logo.svg"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#999999"
borderopacity="1"
inkscape:showpageshadow="2"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="2.5914723"
inkscape:cx="385.10927"
inkscape:cy="275.712"
inkscape:window-width="2252"
inkscape:window-height="1450"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="0"
inkscape:current-layer="layer2" />
<defs
id="defs1">
<rect
x="115.37843"
y="80.263254"
width="470.38898"
height="197.18521"
id="rect2" />
<rect
x="175.18448"
y="463.06726"
width="253.29221"
height="303.50433"
id="rect6" />
</defs>
<g
inkscape:label="Faviocon"
inkscape:groupmode="layer"
id="layer1">
<path
d="m 59.240389,97.978247 c 1.775354,-0.394229 4.087813,-2.156354 4.439709,-3.024187 0.206375,-0.508 -0.822855,-1.30175 -1.098021,-1.621896 -0.629709,-0.73025 -0.375709,-1.090083 -0.132292,-1.960562 0.277813,-0.989542 -0.381,-2.082271 -1.314979,-2.510896 -0.933979,-0.428625 -2.050521,-0.293688 -2.989792,0.124354 -0.939271,0.418042 -1.740958,1.090083 -2.526771,1.751542 -0.574145,-0.36248 -1.489604,-1.963209 -2.97127,-0.923396 -1.023938,0.717021 -1.116542,2.278062 -0.98425,3.52425 0.309562,2.876021 1.018645,4.368271 2.354791,4.770437 1.688042,0.508 3.556,0.240771 5.222875,-0.129646"
style="fill:#ffca28;stroke-width:0.264583"
id="path1-3"
inkscape:export-filename="static/favicon.svg"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96" />
<path
d="m 62.124348,89.704727 c -0.224896,3.876145 -4.005792,6.447895 -5.799667,7.580312 l 1.164167,1.000125 c 0,0 0.738187,0.01588 1.748895,-0.306917 1.733021,-0.550333 4.265084,-2.106083 4.439709,-3.024187 0.256646,-1.336146 -1.113896,-1.045104 -1.423459,-2.100792 -0.161395,-0.558271 0.785813,-1.613958 -0.129645,-3.148541 m -6.503459,1.03452 c 0,0 -0.674687,-0.690562 -1.17475,-1.005416 -0.248708,0.468312 -0.425979,0.976312 -0.513291,1.500187 -0.156105,0.92075 0,2.227792 0.36777,3.201459 0.05821,0.150812 0.275167,0.127 0.29898,-0.03175 0.3175,-2.092855 1.021291,-3.66448 1.021291,-3.66448"
style="fill:#e2a610;stroke-width:0.264583"
id="path2-6" />
<path
d="m 50.906014,97.636935 c 0,0 -8.252354,0.891646 -11.975042,8.056565 -3.722687,7.16492 -0.558271,11.50937 2.791354,13.09158 3.349626,1.58221 11.789834,2.14048 17.279938,0.83873 5.490104,-1.30175 6.863292,-4.0005 6.606646,-6.60664 -0.373062,-3.80471 -3.907896,-6.14363 -3.907896,-6.14363 0,0 0.140229,-4.699 -3.505729,-7.749647 -3.235854,-2.709333 -7.289271,-1.486958 -7.289271,-1.486958"
style="fill:#ffca28;stroke-width:0.264583"
id="path3-0" />
<path
d="m 56.120952,95.996518 c 2.233083,0.727604 2.727854,2.746375 2.566458,3.296709 -0.193146,0.645583 -2.667,-1.867959 -6.344708,-1.717146 -1.285875,0.05292 -0.912813,-0.735542 -0.3175,-1.190625 0.785812,-0.600604 2.106083,-1.034521 4.09575,-0.388938"
style="fill:#6d4c41;stroke-width:0.264583"
id="path6-6" />
<path
d="m 56.120952,95.996518 c 2.233083,0.727604 2.727854,2.746375 2.566458,3.296709 -0.193146,0.645583 -2.667,-1.867959 -6.344708,-1.717146 -1.285875,0.05292 -0.912813,-0.735542 -0.3175,-1.190625 0.785812,-0.600604 2.106083,-1.034521 4.09575,-0.388938"
style="fill:#6b4b46;stroke-width:0.264583"
id="path7-2" />
<path
d="m 60.042077,103.11381 c 0.280458,-0.19314 1.222375,0.14023 1.486958,1.98438 0.129646,0.90223 0.169333,1.77535 0.169333,1.77535 0,0 -1.11125,-0.99748 -1.47902,-1.69862 -0.463021,-0.88636 -0.642938,-1.74361 -0.177271,-2.06111"
style="fill:#e2a610;stroke-width:0.264583"
id="path8-6" />
<text
xml:space="preserve"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:19.7556px;font-family:'Pirata One';-inkscape-font-specification:'Pirata One, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;text-align:start;letter-spacing:-0.529167px;writing-mode:lr-tb;direction:ltr;text-anchor:start;fill:#000000;stroke-width:0.264583"
x="82.355011"
y="90.66716"
id="text4-9"
inkscape:label="$"
transform="rotate(20.578693)"><tspan
sodipodi:role="line"
id="tspan4-2"
style="font-size:19.7556px;fill:#4d4d4d;stroke-width:0.264583"
x="82.355011"
y="90.66716">$</tspan></text>
</g>
<g
inkscape:label="Logo"
inkscape:groupmode="layer"
id="g7"
transform="translate(1.4293676,-48.496402)">
<g
id="g8"
inkscape:label="Favicon"
transform="translate(-38.797122,-28.178962)"
inkscape:export-filename="../static/logo.svg"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96">
<path
d="m 98.874384,115.02659 c 1.775356,-0.39423 4.087816,-2.15635 4.439706,-3.02419 0.20638,-0.508 -0.82285,-1.30175 -1.09802,-1.62189 -0.62971,-0.73025 -0.37571,-1.09009 -0.13229,-1.96057 0.27781,-0.98954 -0.381,-2.08227 -1.31498,-2.51089 -0.933978,-0.42863 -2.05052,-0.29369 -2.989791,0.12435 -0.939271,0.41804 -1.740958,1.09009 -2.526771,1.75154 -0.574145,-0.36248 -1.489604,-1.96321 -2.97127,-0.92339 -1.023938,0.71702 -1.116542,2.27806 -0.98425,3.52425 0.309562,2.87602 1.018645,4.36827 2.354791,4.77043 1.688042,0.508 3.556,0.24078 5.222875,-0.12964"
style="fill:#ffca28;stroke-width:0.264583"
id="path1-3-3" />
<path
d="m 101.75834,106.75307 c -0.22489,3.87614 -4.005789,6.44789 -5.799664,7.58031 l 1.164167,1.00013 c 0,0 0.738187,0.0159 1.748895,-0.30692 1.733022,-0.55033 4.265082,-2.10608 4.439712,-3.02419 0.25664,-1.33614 -1.1139,-1.0451 -1.42346,-2.10079 -0.1614,-0.55827 0.78581,-1.61396 -0.12965,-3.14854 m -6.503456,1.03452 c 0,0 -0.674687,-0.69056 -1.17475,-1.00542 -0.248708,0.46832 -0.425979,0.97632 -0.513291,1.50019 -0.156105,0.92075 0,2.22779 0.36777,3.20146 0.05821,0.15081 0.275167,0.127 0.29898,-0.0317 0.3175,-2.09286 1.021291,-3.66448 1.021291,-3.66448"
style="fill:#e2a610;stroke-width:0.264583"
id="path2-6-6" />
<path
d="m 90.540009,114.68528 c 0,0 -8.252354,0.89164 -11.975042,8.05656 -3.722687,7.16492 -0.558271,11.50937 2.791354,13.09158 3.349626,1.58221 11.789834,2.14048 17.279938,0.83873 5.490101,-1.30175 6.863291,-4.0005 6.606641,-6.60664 -0.37306,-3.80471 -3.90789,-6.14363 -3.90789,-6.14363 0,0 0.14023,-4.699 -3.50573,-7.74964 -3.235854,-2.70934 -7.289271,-1.48696 -7.289271,-1.48696"
style="fill:#ffca28;stroke-width:0.264583"
id="path3-0-1" />
<path
d="m 95.754947,113.04486 c 2.233083,0.7276 2.727854,2.74638 2.566458,3.29671 -0.193146,0.64558 -2.667,-1.86796 -6.344708,-1.71715 -1.285875,0.0529 -0.912813,-0.73554 -0.3175,-1.19062 0.785812,-0.60061 2.106083,-1.03452 4.09575,-0.38894"
style="fill:#6d4c41;stroke-width:0.264583"
id="path6-6-2" />
<path
d="m 95.754947,113.04486 c 2.233083,0.7276 2.727854,2.74638 2.566458,3.29671 -0.193146,0.64558 -2.667,-1.86796 -6.344708,-1.71715 -1.285875,0.0529 -0.912813,-0.73554 -0.3175,-1.19062 0.785812,-0.60061 2.106083,-1.03452 4.09575,-0.38894"
style="fill:#6b4b46;stroke-width:0.264583"
id="path7-2-9" />
<path
d="m 99.676072,120.16215 c 0.280458,-0.19314 1.222378,0.14023 1.486958,1.98438 0.12965,0.90223 0.16933,1.77535 0.16933,1.77535 0,0 -1.11125,-0.99748 -1.479017,-1.69862 -0.463021,-0.88636 -0.642938,-1.74361 -0.177271,-2.06111"
style="fill:#e2a610;stroke-width:0.264583"
id="path8-6-3" />
<text
xml:space="preserve"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:19.7556px;font-family:'Pirata One';-inkscape-font-specification:'Pirata One, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;text-align:start;letter-spacing:-0.529167px;writing-mode:lr-tb;direction:ltr;text-anchor:start;fill:#000000;stroke-width:0.264583"
x="125.45235"
y="92.696564"
id="text4-9-1"
inkscape:label="$"
transform="rotate(20.578693)"><tspan
sodipodi:role="line"
id="tspan4-2-9"
style="font-size:19.7556px;fill:#4d4d4d;stroke-width:0.264583"
x="125.45235"
y="92.696564">$</tspan></text>
</g>
<g
inkscape:groupmode="layer"
id="layer2"
inkscape:label="Text"
transform="translate(-1.4293676,48.496402)">
<text
xml:space="preserve"
style="font-size:17.6389px;text-align:start;letter-spacing:-0.529167px;writing-mode:lr-tb;direction:ltr;text-anchor:start;fill:#000000;stroke-width:0.264583"
x="57.635151"
y="55.655094"
id="text1"
inkscape:label="SpendSparrow"><tspan
sodipodi:role="line"
id="tspan1"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:17.6389px;font-family:'Pirata One';-inkscape-font-specification:'Pirata One, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;letter-spacing:-0.529167px;fill:#4d4d4d;stroke:none;stroke-width:0.264583"
x="57.635151"
y="55.655094">pendSparrow</tspan></text>
<text
xml:space="preserve"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:19.7556px;font-family:'Pirata One';-inkscape-font-specification:'Pirata One, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;text-align:start;letter-spacing:-0.529167px;writing-mode:lr-tb;direction:ltr;text-anchor:start;fill:#1a1a1a;stroke-width:0.264583"
x="93.314896"
y="91.227318"
id="text5"><tspan
sodipodi:role="line"
id="tspan5"
style="stroke-width:0.264583"
x="93.314896"
y="91.227318" /></text>
<text
xml:space="preserve"
transform="scale(0.26458333)"
id="text6"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:74.6667px;font-family:'Pirata One';-inkscape-font-specification:'Pirata One, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;text-align:start;letter-spacing:-2px;writing-mode:lr-tb;direction:ltr;white-space:pre;shape-inside:url(#rect6);display:inline;fill:#1a1a1a;stroke:none" />
<text
xml:space="preserve"
transform="scale(0.26458333)"
id="text2"
style="fill:#4d4d4d;text-orientation:auto;-inkscape-font-specification:'Pirata One, Normal';font-family:'Pirata One';font-size:74.66666667px;letter-spacing:-2px;text-align:start;writing-mode:lr-tb;direction:ltr;white-space:pre;shape-inside:url(#rect2)" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 12 KiB

6
dev.sh
View File

@@ -1,3 +1,9 @@
#!/bin/sh
set -e
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
go install github.com/a-h/templ/cmd/templ@latest
go install github.com/vektra/mockery/v2@latest
templ generate --watch --proxy="http://localhost:8080" --cmd="go run ." &
npm run watch

65
go.mod
View File

@@ -1,53 +1,54 @@
module spend-sparrow
go 1.23.0
go 1.24.0
toolchain go1.24.4
toolchain go1.25.3
require (
github.com/a-h/templ v0.3.898
github.com/golang-migrate/migrate/v4 v4.18.3
github.com/a-h/templ v0.3.960
github.com/golang-migrate/migrate/v4 v4.19.0
github.com/google/uuid v1.6.0
github.com/jmoiron/sqlx v1.4.0
github.com/joho/godotenv v1.5.1
github.com/mattn/go-sqlite3 v1.14.28
github.com/stretchr/testify v1.10.0
go.opentelemetry.io/contrib/bridges/otelslog v0.11.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0
go.opentelemetry.io/otel v1.36.0
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.12.2
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.36.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.36.0
go.opentelemetry.io/otel/log v0.12.2
go.opentelemetry.io/otel/sdk v1.36.0
go.opentelemetry.io/otel/sdk/log v0.12.2
go.opentelemetry.io/otel/sdk/metric v1.36.0
go.opentelemetry.io/otel/trace v1.36.0
golang.org/x/crypto v0.39.0
golang.org/x/net v0.41.0
github.com/mattn/go-sqlite3 v1.14.32
github.com/stretchr/testify v1.11.1
github.com/uptrace/opentelemetry-go-extra/otelsql v0.3.2
github.com/uptrace/opentelemetry-go-extra/otelsqlx v0.3.2
go.opentelemetry.io/contrib/bridges/otelslog v0.13.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0
go.opentelemetry.io/otel v1.38.0
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0
go.opentelemetry.io/otel/log v0.14.0
go.opentelemetry.io/otel/sdk v1.38.0
go.opentelemetry.io/otel/sdk/log v0.14.0
go.opentelemetry.io/otel/sdk/metric v1.38.0
go.opentelemetry.io/otel/trace v1.38.0
golang.org/x/crypto v0.43.0
golang.org/x/net v0.46.0
)
require (
github.com/cenkalti/backoff/v5 v5.0.2 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0 // indirect
go.opentelemetry.io/otel/metric v1.36.0 // indirect
go.opentelemetry.io/proto/otlp v1.6.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.26.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 // indirect
google.golang.org/grpc v1.72.1 // indirect
google.golang.org/protobuf v1.36.6 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect
go.opentelemetry.io/otel/metric v1.38.0 // indirect
go.opentelemetry.io/proto/otlp v1.7.1 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/text v0.30.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 // indirect
google.golang.org/grpc v1.75.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

128
go.sum
View File

@@ -1,30 +1,30 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/a-h/templ v0.3.898 h1:g9oxL/dmM6tvwRe2egJS8hBDQTncokbMoOFk1oJMX7s=
github.com/a-h/templ v0.3.898/go.mod h1:oLBbZVQ6//Q6zpvSMPTuBK0F3qOtBdFBcGRspcT+VNQ=
github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8=
github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/a-h/templ v0.3.960 h1:trshEpGa8clF5cdI39iY4ZrZG8Z/QixyzEyUnA7feTM=
github.com/a-h/templ v0.3.960/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/golang-migrate/migrate/v4 v4.18.3 h1:EYGkoOsvgHHfm5U/naS1RP/6PL/Xv3S4B/swMiAmDLs=
github.com/golang-migrate/migrate/v4 v4.18.3/go.mod h1:99BKpIi6ruaaXRM1A77eqZ+FWPQ3cfRa+ZVy5bmWMaY=
github.com/golang-migrate/migrate/v4 v4.19.0 h1:RcjOnCGz3Or6HQYEJ/EEVLfWnmw9KnoigPSjzhCuaSE=
github.com/golang-migrate/migrate/v4 v4.19.0/go.mod h1:9dyEcu+hO+G9hPSw8AIg50yg622pXJsoHItQnDGZkI0=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
@@ -41,68 +41,72 @@ github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/uptrace/opentelemetry-go-extra/otelsql v0.3.2 h1:ZjUj9BLYf9PEqBn8W/OapxhPjVRdC6CsXTdULHsyk5c=
github.com/uptrace/opentelemetry-go-extra/otelsql v0.3.2/go.mod h1:O8bHQfyinKwTXKkiKNGmLQS7vRsqRxIQTFZpYpHK3IQ=
github.com/uptrace/opentelemetry-go-extra/otelsqlx v0.3.2 h1:zA9ZXfdtowo0EKt+t7uqXNlHxPeygrxuFSIroiBVgPU=
github.com/uptrace/opentelemetry-go-extra/otelsqlx v0.3.2/go.mod h1:ySXmuW9JLCm/TjsQksuMY/7MNiWqfHnhH2xeT34uOLU=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/bridges/otelslog v0.11.0 h1:EMIiYTms4Z4m3bBuKp1VmMNRLZcl6j4YbvOPL1IhlWo=
go.opentelemetry.io/contrib/bridges/otelslog v0.11.0/go.mod h1:DIEZmUR7tzuOOVUTDKvkGWtYWSHFV18Qg8+GMb8wPJw=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.12.2 h1:06ZeJRe5BnYXceSM9Vya83XXVaNGe3H1QqsvqRANQq8=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.12.2/go.mod h1:DvPtKE63knkDVP88qpatBj81JxN+w1bqfVbsbCbj1WY=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.36.0 h1:zwdo1gS2eH26Rg+CoqVQpEK1h8gvt5qyU5Kk5Bixvow=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.36.0/go.mod h1:rUKCPscaRWWcqGT6HnEmYrK+YNe5+Sw64xgQTOJ5b30=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0 h1:dNzwXjZKpMpE2JhmO+9HsPl42NIXFIFSUSSs0fiqra0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0/go.mod h1:90PoxvaEB5n6AOdZvi+yWJQoE95U8Dhhw2bSyRqnTD0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.36.0 h1:JgtbA0xkWHnTmYk7YusopJFX6uleBmAuZ8n05NEh8nQ=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.36.0/go.mod h1:179AK5aar5R3eS9FucPy6rggvU0g52cvKId8pv4+v0c=
go.opentelemetry.io/otel/log v0.12.2 h1:yob9JVHn2ZY24byZeaXpTVoPS6l+UrrxmxmPKohXTwc=
go.opentelemetry.io/otel/log v0.12.2/go.mod h1:ShIItIxSYxufUMt+1H5a2wbckGli3/iCfuEbVZi/98E=
go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE=
go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
go.opentelemetry.io/otel/sdk/log v0.12.2 h1:yNoETvTByVKi7wHvYS6HMcZrN5hFLD7I++1xIZ/k6W0=
go.opentelemetry.io/otel/sdk/log v0.12.2/go.mod h1:DcpdmUXHJgSqN/dh+XMWa7Vf89u9ap0/AAk/XGLnEzY=
go.opentelemetry.io/otel/sdk/log/logtest v0.0.0-20250521073539-a85ae98dcedc h1:uqxdywfHqqCl6LmZzI3pUnXT1RGFYyUgxj0AkWPFxi0=
go.opentelemetry.io/otel/sdk/log/logtest v0.0.0-20250521073539-a85ae98dcedc/go.mod h1:TY/N/FT7dmFrP/r5ym3g0yysP1DefqGpAZr4f82P0dE=
go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
go.opentelemetry.io/proto/otlp v1.6.0 h1:jQjP+AQyTf+Fe7OKj/MfkDrmK4MNVtw2NpXsf9fefDI=
go.opentelemetry.io/proto/otlp v1.6.0/go.mod h1:cicgGehlFuNdgZkcALOCh3VE6K/u2tAjzlRhDwmVpZc=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.opentelemetry.io/contrib/bridges/otelslog v0.13.0 h1:bwnLpizECbPr1RrQ27waeY2SPIPeccCx/xLuoYADZ9s=
go.opentelemetry.io/contrib/bridges/otelslog v0.13.0/go.mod h1:3nWlOiiqA9UtUnrcNk82mYasNxD8ehOspL0gOfEo6Y4=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0 h1:OMqPldHt79PqWKOMYIAQs3CxAi7RLgPxwfFSwr4ZxtM=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0/go.mod h1:1biG4qiqTxKiUCtoWDPpL3fB3KxVwCiGw81j3nKMuHE=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0 h1:vl9obrcoWVKp/lwl8tRE33853I8Xru9HFbw/skNeLs8=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0/go.mod h1:GAXRxmLJcVM3u22IjTg74zWBrRCKq8BnOqUVLodpcpw=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 h1:lwI4Dc5leUqENgGuQImwLo4WnuXFPetmPpkLi2IrX54=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0/go.mod h1:Kz/oCE7z5wuyhPxsXDuaPteSWqjSBD5YaSdbxZYGbGk=
go.opentelemetry.io/otel/log v0.14.0 h1:2rzJ+pOAZ8qmZ3DDHg73NEKzSZkhkGIua9gXtxNGgrM=
go.opentelemetry.io/otel/log v0.14.0/go.mod h1:5jRG92fEAgx0SU/vFPxmJvhIuDU9E1SUnEQrMlJpOno=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
go.opentelemetry.io/otel/sdk/log v0.14.0 h1:JU/U3O7N6fsAXj0+CXz21Czg532dW2V4gG1HE/e8Zrg=
go.opentelemetry.io/otel/sdk/log v0.14.0/go.mod h1:imQvII+0ZylXfKU7/wtOND8Hn4OpT3YUoIgqJVksUkM=
go.opentelemetry.io/otel/sdk/log/logtest v0.14.0 h1:Ijbtz+JKXl8T2MngiwqBlPaHqc4YCaP/i13Qrow6gAM=
go.opentelemetry.io/otel/sdk/log/logtest v0.14.0/go.mod h1:dCU8aEL6q+L9cYTqcVOk8rM9Tp8WdnHOPLiBgp0SGOA=
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4=
go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 h1:Kog3KlB4xevJlAcbbbzPfRG0+X9fdoGM+UBRKVz6Wr0=
google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237/go.mod h1:ezi0AVyMKDWy5xAncvjLWH7UcLBB5n7y2fQ8MzjJcto=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 h1:cJfm9zPbe1e873mHJzmQ1nwVEeRDU/T1wXDK2kUSU34=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA=
google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY=
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 h1:eaY8u2EuxbRv7c3NiGK0/NedzVsCcV6hDuU5qPX5EGE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5/go.mod h1:M4/wBTSeyLxupu3W3tJtOgB14jILAS/XWPSSa3TAlJc=
google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4=
google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@@ -3,25 +3,22 @@
@source './static/**/*.js';
@source './template/**/*.templ';
body {
@apply font-garamond text-gray-700;
}
input:focus {
@apply outline-none ring-0;
}
@font-face {
font-family: "Pirata One";
src: url("/static/font/PirataOne-Regular.woff2") format("woff2");
}
@font-face {
font-family: "EB Garamond";
src: url("/static/font/EBGaramond-VariableFont_wght.woff2") format("woff2");
}
@theme {
--font-pirata: "Pirata One", serif;
--font-garamond: "EB Garamond", serif;
body {
font-family: "EB Garamond", serif;
@apply text-gray-700;
}
input:focus {
@apply outline-none ring-0;
}
button {
@apply cursor-pointer;
}
/* Button */
@@ -61,3 +58,5 @@ input:focus {
box-shadow: 0 0 0 2px var(--color-gray-200);
}

View File

@@ -1,6 +1,7 @@
package db
import (
"context"
"database/sql"
"errors"
"log/slog"
@@ -13,23 +14,24 @@ import (
)
type Auth interface {
InsertUser(user *types.User) error
UpdateUser(user *types.User) error
GetUserByEmail(email string) (*types.User, error)
GetUser(userId uuid.UUID) (*types.User, error)
DeleteUser(userId uuid.UUID) error
InsertUser(ctx context.Context, user *types.User) error
UpdateUser(ctx context.Context, user *types.User) error
GetUserByEmail(ctx context.Context, email string) (*types.User, error)
GetUser(ctx context.Context, userId uuid.UUID) (*types.User, error)
DeleteUser(ctx context.Context, userId uuid.UUID) error
InsertToken(token *types.Token) error
GetToken(token string) (*types.Token, error)
GetTokensByUserIdAndType(userId uuid.UUID, tokenType types.TokenType) ([]*types.Token, error)
GetTokensBySessionIdAndType(sessionId string, tokenType types.TokenType) ([]*types.Token, error)
DeleteToken(token string) error
InsertToken(ctx context.Context, token *types.Token) error
GetToken(ctx context.Context, token string) (*types.Token, error)
GetTokensByUserIdAndType(ctx context.Context, userId uuid.UUID, tokenType types.TokenType) ([]*types.Token, error)
GetTokensBySessionIdAndType(ctx context.Context, sessionId string, tokenType types.TokenType) ([]*types.Token, error)
DeleteToken(ctx context.Context, token string) error
InsertSession(session *types.Session) error
GetSession(sessionId string) (*types.Session, error)
GetSessions(userId uuid.UUID) ([]*types.Session, error)
DeleteSession(sessionId string) error
DeleteOldSessions(userId uuid.UUID) error
InsertSession(ctx context.Context, session *types.Session) error
GetSession(ctx context.Context, sessionId string) (*types.Session, error)
GetSessions(ctx context.Context, userId uuid.UUID) ([]*types.Session, error)
DeleteSession(ctx context.Context, sessionId string) error
DeleteOldSessions(ctx context.Context) error
DeleteOldTokens(ctx context.Context) error
}
type AuthSqlite struct {
@@ -40,8 +42,8 @@ func NewAuthSqlite(db *sqlx.DB) *AuthSqlite {
return &AuthSqlite{db: db}
}
func (db AuthSqlite) InsertUser(user *types.User) error {
_, err := db.db.Exec(`
func (db AuthSqlite) InsertUser(ctx context.Context, user *types.User) error {
_, err := db.db.ExecContext(ctx, `
INSERT INTO user (user_id, email, email_verified, email_verified_at, is_admin, password, salt, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
user.Id, user.Email, user.EmailVerified, user.EmailVerifiedAt, user.IsAdmin, user.Password, user.Salt, user.CreateAt)
@@ -51,29 +53,29 @@ func (db AuthSqlite) InsertUser(user *types.User) error {
return ErrAlreadyExists
}
slog.Error("SQL error InsertUser", "err", err)
slog.ErrorContext(ctx, "SQL error InsertUser", "err", err)
return types.ErrInternal
}
return nil
}
func (db AuthSqlite) UpdateUser(user *types.User) error {
_, err := db.db.Exec(`
func (db AuthSqlite) UpdateUser(ctx context.Context, user *types.User) error {
_, err := db.db.ExecContext(ctx, `
UPDATE user
SET email_verified = ?, email_verified_at = ?, password = ?
WHERE user_id = ?`,
user.EmailVerified, user.EmailVerifiedAt, user.Password, user.Id)
if err != nil {
slog.Error("SQL error UpdateUser", "err", err)
slog.ErrorContext(ctx, "SQL error UpdateUser", "err", err)
return types.ErrInternal
}
return nil
}
func (db AuthSqlite) GetUserByEmail(email string) (*types.User, error) {
func (db AuthSqlite) GetUserByEmail(ctx context.Context, email string) (*types.User, error) {
var (
userId uuid.UUID
emailVerified bool
@@ -84,7 +86,7 @@ func (db AuthSqlite) GetUserByEmail(email string) (*types.User, error) {
createdAt time.Time
)
err := db.db.QueryRow(`
err := db.db.QueryRowContext(ctx, `
SELECT user_id, email_verified, email_verified_at, password, salt, created_at
FROM user
WHERE email = ?`, email).Scan(&userId, &emailVerified, &emailVerifiedAt, &password, &salt, &createdAt)
@@ -92,7 +94,7 @@ func (db AuthSqlite) GetUserByEmail(email string) (*types.User, error) {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
} else {
slog.Error("SQL error GetUser", "err", err)
slog.ErrorContext(ctx, "SQL error GetUser", "err", err)
return nil, types.ErrInternal
}
}
@@ -100,7 +102,7 @@ func (db AuthSqlite) GetUserByEmail(email string) (*types.User, error) {
return types.NewUser(userId, email, emailVerified, emailVerifiedAt, isAdmin, password, salt, createdAt), nil
}
func (db AuthSqlite) GetUser(userId uuid.UUID) (*types.User, error) {
func (db AuthSqlite) GetUser(ctx context.Context, userId uuid.UUID) (*types.User, error) {
var (
email string
emailVerified bool
@@ -111,7 +113,7 @@ func (db AuthSqlite) GetUser(userId uuid.UUID) (*types.User, error) {
createdAt time.Time
)
err := db.db.QueryRow(`
err := db.db.QueryRowContext(ctx, `
SELECT email, email_verified, email_verified_at, password, salt, created_at
FROM user
WHERE user_id = ?`, userId).Scan(&email, &emailVerified, &emailVerifiedAt, &password, &salt, &createdAt)
@@ -119,7 +121,7 @@ func (db AuthSqlite) GetUser(userId uuid.UUID) (*types.User, error) {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
} else {
slog.Error("SQL error GetUser", "err", err)
slog.ErrorContext(ctx, "SQL error GetUser", "err", err)
return nil, types.ErrInternal
}
}
@@ -127,78 +129,78 @@ func (db AuthSqlite) GetUser(userId uuid.UUID) (*types.User, error) {
return types.NewUser(userId, email, emailVerified, emailVerifiedAt, isAdmin, password, salt, createdAt), nil
}
func (db AuthSqlite) DeleteUser(userId uuid.UUID) error {
tx, err := db.db.Begin()
func (db AuthSqlite) DeleteUser(ctx context.Context, userId uuid.UUID) error {
tx, err := db.db.BeginTx(ctx, nil)
if err != nil {
slog.Error("Could not start transaction", "err", err)
slog.ErrorContext(ctx, "Could not start transaction", "err", err)
return types.ErrInternal
}
_, err = tx.Exec("DELETE FROM account WHERE user_id = ?", userId)
_, err = tx.ExecContext(ctx, "DELETE FROM account WHERE user_id = ?", userId)
if err != nil {
_ = tx.Rollback()
slog.Error("Could not delete accounts", "err", err)
slog.ErrorContext(ctx, "Could not delete accounts", "err", err)
return types.ErrInternal
}
_, err = tx.Exec("DELETE FROM token WHERE user_id = ?", userId)
_, err = tx.ExecContext(ctx, "DELETE FROM token WHERE user_id = ?", userId)
if err != nil {
_ = tx.Rollback()
slog.Error("Could not delete user tokens", "err", err)
slog.ErrorContext(ctx, "Could not delete user tokens", "err", err)
return types.ErrInternal
}
_, err = tx.Exec("DELETE FROM session WHERE user_id = ?", userId)
_, err = tx.ExecContext(ctx, "DELETE FROM session WHERE user_id = ?", userId)
if err != nil {
_ = tx.Rollback()
slog.Error("Could not delete sessions", "err", err)
slog.ErrorContext(ctx, "Could not delete sessions", "err", err)
return types.ErrInternal
}
_, err = tx.Exec("DELETE FROM user WHERE user_id = ?", userId)
_, err = tx.ExecContext(ctx, "DELETE FROM user WHERE user_id = ?", userId)
if err != nil {
_ = tx.Rollback()
slog.Error("Could not delete user", "err", err)
slog.ErrorContext(ctx, "Could not delete user", "err", err)
return types.ErrInternal
}
_, err = tx.Exec("DELETE FROM treasure_chest WHERE user_id = ?", userId)
_, err = tx.ExecContext(ctx, "DELETE FROM treasure_chest WHERE user_id = ?", userId)
if err != nil {
_ = tx.Rollback()
slog.Error("Could not delete user", "err", err)
slog.ErrorContext(ctx, "Could not delete user", "err", err)
return types.ErrInternal
}
_, err = tx.Exec("DELETE FROM \"transaction\" WHERE user_id = ?", userId)
_, err = tx.ExecContext(ctx, "DELETE FROM \"transaction\" WHERE user_id = ?", userId)
if err != nil {
_ = tx.Rollback()
slog.Error("Could not delete user", "err", err)
slog.ErrorContext(ctx, "Could not delete user", "err", err)
return types.ErrInternal
}
err = tx.Commit()
if err != nil {
slog.Error("Could not commit transaction", "err", err)
slog.ErrorContext(ctx, "Could not commit transaction", "err", err)
return types.ErrInternal
}
return nil
}
func (db AuthSqlite) InsertToken(token *types.Token) error {
_, err := db.db.Exec(`
func (db AuthSqlite) InsertToken(ctx context.Context, token *types.Token) error {
_, err := db.db.ExecContext(ctx, `
INSERT INTO token (user_id, session_id, type, token, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?)`, token.UserId, token.SessionId, token.Type, token.Token, token.CreatedAt, token.ExpiresAt)
if err != nil {
slog.Error("Could not insert token", "err", err)
slog.ErrorContext(ctx, "Could not insert token", "err", err)
return types.ErrInternal
}
return nil
}
func (db AuthSqlite) GetToken(token string) (*types.Token, error) {
func (db AuthSqlite) GetToken(ctx context.Context, token string) (*types.Token, error) {
var (
userId uuid.UUID
sessionId string
@@ -209,67 +211,67 @@ func (db AuthSqlite) GetToken(token string) (*types.Token, error) {
expiresAt time.Time
)
err := db.db.QueryRow(`
err := db.db.QueryRowContext(ctx, `
SELECT user_id, session_id, type, created_at, expires_at
FROM token
WHERE token = ?`, token).Scan(&userId, &sessionId, &tokenType, &createdAtStr, &expiresAtStr)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
slog.Info("Token not found", "token", token)
slog.InfoContext(ctx, "Token not found", "token", token)
return nil, ErrNotFound
} else {
slog.Error("Could not get token", "err", err)
slog.ErrorContext(ctx, "Could not get token", "err", err)
return nil, types.ErrInternal
}
}
createdAt, err = time.Parse(time.RFC3339, createdAtStr)
if err != nil {
slog.Error("Could not parse token.created_at", "err", err)
slog.ErrorContext(ctx, "Could not parse token.created_at", "err", err)
return nil, types.ErrInternal
}
expiresAt, err = time.Parse(time.RFC3339, expiresAtStr)
if err != nil {
slog.Error("Could not parse token.expires_at", "err", err)
slog.ErrorContext(ctx, "Could not parse token.expires_at", "err", err)
return nil, types.ErrInternal
}
return types.NewToken(userId, sessionId, token, tokenType, createdAt, expiresAt), nil
}
func (db AuthSqlite) GetTokensByUserIdAndType(userId uuid.UUID, tokenType types.TokenType) ([]*types.Token, error) {
query, err := db.db.Query(`
func (db AuthSqlite) GetTokensByUserIdAndType(ctx context.Context, userId uuid.UUID, tokenType types.TokenType) ([]*types.Token, error) {
query, err := db.db.QueryContext(ctx, `
SELECT token, created_at, expires_at
FROM token
WHERE user_id = ?
AND type = ?`, userId, tokenType)
if err != nil {
slog.Error("Could not get token", "err", err)
slog.ErrorContext(ctx, "Could not get token", "err", err)
return nil, types.ErrInternal
}
return getTokensFromQuery(query, userId, "", tokenType)
return getTokensFromQuery(ctx, query, userId, "", tokenType)
}
func (db AuthSqlite) GetTokensBySessionIdAndType(sessionId string, tokenType types.TokenType) ([]*types.Token, error) {
query, err := db.db.Query(`
func (db AuthSqlite) GetTokensBySessionIdAndType(ctx context.Context, sessionId string, tokenType types.TokenType) ([]*types.Token, error) {
query, err := db.db.QueryContext(ctx, `
SELECT token, created_at, expires_at
FROM token
WHERE session_id = ?
AND type = ?`, sessionId, tokenType)
if err != nil {
slog.Error("Could not get token", "err", err)
slog.ErrorContext(ctx, "Could not get token", "err", err)
return nil, types.ErrInternal
}
return getTokensFromQuery(query, uuid.Nil, sessionId, tokenType)
return getTokensFromQuery(ctx, query, uuid.Nil, sessionId, tokenType)
}
func getTokensFromQuery(query *sql.Rows, userId uuid.UUID, sessionId string, tokenType types.TokenType) ([]*types.Token, error) {
func getTokensFromQuery(ctx context.Context, query *sql.Rows, userId uuid.UUID, sessionId string, tokenType types.TokenType) ([]*types.Token, error) {
var tokens []*types.Token
hasRows := false
@@ -286,19 +288,19 @@ func getTokensFromQuery(query *sql.Rows, userId uuid.UUID, sessionId string, tok
err := query.Scan(&token, &createdAtStr, &expiresAtStr)
if err != nil {
slog.Error("Could not scan token", "err", err)
slog.ErrorContext(ctx, "Could not scan token", "err", err)
return nil, types.ErrInternal
}
createdAt, err = time.Parse(time.RFC3339, createdAtStr)
if err != nil {
slog.Error("Could not parse token.created_at", "err", err)
slog.ErrorContext(ctx, "Could not parse token.created_at", "err", err)
return nil, types.ErrInternal
}
expiresAt, err = time.Parse(time.RFC3339, expiresAtStr)
if err != nil {
slog.Error("Could not parse token.expires_at", "err", err)
slog.ErrorContext(ctx, "Could not parse token.expires_at", "err", err)
return nil, types.ErrInternal
}
@@ -312,82 +314,92 @@ func getTokensFromQuery(query *sql.Rows, userId uuid.UUID, sessionId string, tok
return tokens, nil
}
func (db AuthSqlite) DeleteToken(token string) error {
_, err := db.db.Exec("DELETE FROM token WHERE token = ?", token)
func (db AuthSqlite) DeleteToken(ctx context.Context, token string) error {
_, err := db.db.ExecContext(ctx, "DELETE FROM token WHERE token = ?", token)
if err != nil {
slog.Error("Could not delete token", "err", err)
slog.ErrorContext(ctx, "Could not delete token", "err", err)
return types.ErrInternal
}
return nil
}
func (db AuthSqlite) InsertSession(session *types.Session) error {
_, err := db.db.Exec(`
func (db AuthSqlite) InsertSession(ctx context.Context, session *types.Session) error {
_, err := db.db.ExecContext(ctx, `
INSERT INTO session (session_id, user_id, created_at, expires_at)
VALUES (?, ?, ?, ?)`, session.Id, session.UserId, session.CreatedAt, session.ExpiresAt)
if err != nil {
slog.Error("Could not insert new session", "err", err)
slog.ErrorContext(ctx, "Could not insert new session", "err", err)
return types.ErrInternal
}
return nil
}
func (db AuthSqlite) GetSession(sessionId string) (*types.Session, error) {
func (db AuthSqlite) GetSession(ctx context.Context, sessionId string) (*types.Session, error) {
var (
userId uuid.UUID
createdAt time.Time
expiresAt time.Time
)
err := db.db.QueryRow(`
err := db.db.QueryRowContext(ctx, `
SELECT user_id, created_at, expires_at
FROM session
WHERE session_id = ?`, sessionId).Scan(&userId, &createdAt, &expiresAt)
if err != nil {
slog.Warn("Session not found", "session-id", sessionId, "err", err)
slog.WarnContext(ctx, "Session not found", "session-id", sessionId, "err", err)
return nil, ErrNotFound
}
return types.NewSession(sessionId, userId, createdAt, expiresAt), nil
}
func (db AuthSqlite) GetSessions(userId uuid.UUID) ([]*types.Session, error) {
func (db AuthSqlite) GetSessions(ctx context.Context, userId uuid.UUID) ([]*types.Session, error) {
var sessions []*types.Session
err := db.db.Select(&sessions, `
err := db.db.SelectContext(ctx, &sessions, `
SELECT *
FROM session
WHERE user_id = ?`, userId)
if err != nil {
slog.Error("Could not get sessions", "err", err)
slog.ErrorContext(ctx, "Could not get sessions", "err", err)
return nil, types.ErrInternal
}
return sessions, nil
}
func (db AuthSqlite) DeleteOldSessions(userId uuid.UUID) error {
_, err := db.db.Exec(`
DELETE FROM session
WHERE expires_at < datetime('now')
AND user_id = ?`, userId)
if err != nil {
slog.Error("Could not delete old sessions", "err", err)
return types.ErrInternal
}
return nil
}
func (db AuthSqlite) DeleteSession(sessionId string) error {
func (db AuthSqlite) DeleteSession(ctx context.Context, sessionId string) error {
if sessionId != "" {
_, err := db.db.Exec("DELETE FROM session WHERE session_id = ?", sessionId)
_, err := db.db.ExecContext(ctx, "DELETE FROM session WHERE session_id = ?", sessionId)
if err != nil {
slog.Error("Could not delete session", "err", err)
slog.ErrorContext(ctx, "Could not delete session", "err", err)
return types.ErrInternal
}
}
return nil
}
func (db AuthSqlite) DeleteOldSessions(ctx context.Context) error {
_, err := db.db.ExecContext(ctx, `
DELETE FROM session
WHERE expires_at < datetime('now')`)
if err != nil {
slog.ErrorContext(ctx, "Could not delete old sessions", "err", err)
return types.ErrInternal
}
return nil
}
func (db AuthSqlite) DeleteOldTokens(ctx context.Context) error {
_, err := db.db.ExecContext(ctx, `
DELETE FROM token
WHERE expires_at < datetime('now')`)
if err != nil {
slog.ErrorContext(ctx, "Could not delete old tokens", "err", err)
return types.ErrInternal
}
return nil
}

View File

@@ -1,6 +1,7 @@
package db
import (
"context"
"database/sql"
"errors"
"log/slog"
@@ -12,24 +13,24 @@ var (
ErrAlreadyExists = errors.New("row already exists")
)
func TransformAndLogDbError(module string, r sql.Result, err error) error {
func TransformAndLogDbError(ctx context.Context, module string, r sql.Result, err error) error {
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
slog.Error("database sql", "module", module, "err", err)
slog.ErrorContext(ctx, "database sql", "module", module, "err", err)
return types.ErrInternal
}
if r != nil {
rows, err := r.RowsAffected()
if err != nil {
slog.Error("database rows affected", "module", module, "err", err)
slog.ErrorContext(ctx, "database rows affected", "module", module, "err", err)
return types.ErrInternal
}
if rows == 0 {
slog.Info("row not found", "module", module)
slog.InfoContext(ctx, "row not found", "module", module)
return ErrNotFound
}
}

View File

@@ -1,6 +1,7 @@
package db
import (
"context"
"errors"
"log/slog"
"spend-sparrow/internal/types"
@@ -20,10 +21,10 @@ func (l migrationLogger) Verbose() bool {
return false
}
func RunMigrations(db *sqlx.DB, pathPrefix string) error {
func RunMigrations(ctx context.Context, db *sqlx.DB, pathPrefix string) error {
driver, err := sqlite3.WithInstance(db.DB, &sqlite3.Config{})
if err != nil {
slog.Error("Could not create Migration instance", "err", err)
slog.ErrorContext(ctx, "Could not create Migration instance", "err", err)
return types.ErrInternal
}
@@ -32,14 +33,14 @@ func RunMigrations(db *sqlx.DB, pathPrefix string) error {
"",
driver)
if err != nil {
slog.Error("Could not create migrations instance", "err", err)
slog.ErrorContext(ctx, "Could not create migrations instance", "err", err)
return types.ErrInternal
}
m.Log = migrationLogger{}
if err = m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
slog.Error("Could not run migrations", "err", err)
slog.ErrorContext(ctx, "Could not run migrations", "err", err)
return types.ErrInternal
}

View File

@@ -39,7 +39,7 @@ func Run(ctx context.Context, database *sqlx.DB, migrationsPrefix string, env fu
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
err = otelShutdown(ctx)
if err != nil {
slog.Error("error shutting down OpenTelemetry SDK", "err", err)
slog.ErrorContext(ctx, "error shutting down OpenTelemetry SDK", "err", err)
}
cancel()
}()
@@ -47,16 +47,16 @@ func Run(ctx context.Context, database *sqlx.DB, migrationsPrefix string, env fu
slog.SetDefault(log.NewLogPropagator())
}
slog.Info("Starting server...")
slog.InfoContext(ctx, "Starting server...")
// init server settings
serverSettings, err := types.NewSettingsFromEnv(env)
serverSettings, err := types.NewSettingsFromEnv(ctx, env)
if err != nil {
return err
}
// init db
err = db.RunMigrations(database, migrationsPrefix)
err = db.RunMigrations(ctx, database, migrationsPrefix)
if err != nil {
return fmt.Errorf("could not run migrations: %w", err)
}
@@ -64,28 +64,29 @@ func Run(ctx context.Context, database *sqlx.DB, migrationsPrefix string, env fu
// init server
httpServer := &http.Server{
Addr: ":" + serverSettings.Port,
Handler: createHandler(database, serverSettings),
Handler: createHandlerWithServices(ctx, database, serverSettings),
ReadHeaderTimeout: 2 * time.Second,
}
go startServer(httpServer)
go startServer(ctx, httpServer)
// graceful shutdown
var wg sync.WaitGroup
wg.Add(1)
go shutdownServer(httpServer, ctx, &wg)
go shutdownServer(ctx, httpServer, &wg)
wg.Wait()
return nil
}
func startServer(s *http.Server) {
slog.Info("Starting server", "addr", s.Addr)
func startServer(ctx context.Context, s *http.Server) {
slog.InfoContext(ctx, "Starting server", "addr", s.Addr)
if err := s.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
slog.Error("error listening and serving", "err", err)
slog.ErrorContext(ctx, "error listening and serving", "err", err)
}
}
func shutdownServer(s *http.Server, ctx context.Context, wg *sync.WaitGroup) {
func shutdownServer(ctx context.Context, s *http.Server, wg *sync.WaitGroup) {
defer wg.Done()
if s == nil {
return
@@ -96,13 +97,13 @@ func shutdownServer(s *http.Server, ctx context.Context, wg *sync.WaitGroup) {
shutdownCtx, cancel := context.WithTimeout(shutdownCtx, 10*time.Second)
defer cancel()
if err := s.Shutdown(shutdownCtx); err != nil {
slog.Error("error shutting down http server", "err", err)
slog.ErrorContext(ctx, "error shutting down http server", "err", err)
} else {
slog.Info("Gracefully stopped http server", "addr", s.Addr)
slog.InfoContext(ctx, "Gracefully stopped http server", "addr", s.Addr)
}
}
func createHandler(d *sqlx.DB, serverSettings *types.Settings) http.Handler {
func createHandlerWithServices(ctx context.Context, d *sqlx.DB, serverSettings *types.Settings) http.Handler {
var router = http.NewServeMux()
authDb := db.NewAuthSqlite(d)
@@ -116,16 +117,21 @@ func createHandler(d *sqlx.DB, serverSettings *types.Settings) http.Handler {
treasureChestService := service.NewTreasureChest(d, randomService, clockService)
transactionService := service.NewTransaction(d, randomService, clockService)
transactionRecurringService := service.NewTransactionRecurring(d, randomService, clockService, transactionService)
dashboardService := service.NewDashboard(d)
render := handler.NewRender()
indexHandler := handler.NewIndex(render)
indexHandler := handler.NewIndex(render, clockService)
dashboardHandler := handler.NewDashboard(render, dashboardService, treasureChestService)
authHandler := handler.NewAuth(authService, render)
accountHandler := handler.NewAccount(accountService, render)
treasureChestHandler := handler.NewTreasureChest(treasureChestService, transactionRecurringService, render)
transactionHandler := handler.NewTransaction(transactionService, accountService, treasureChestService, render)
transactionRecurringHandler := handler.NewTransactionRecurring(transactionRecurringService, render)
go dailyTaskTimer(ctx, transactionRecurringService, authService)
indexHandler.Handle(router)
dashboardHandler.Handle(router)
accountHandler.Handle(router)
treasureChestHandler.Handle(router)
authHandler.Handle(router)
@@ -137,7 +143,6 @@ func createHandler(d *sqlx.DB, serverSettings *types.Settings) http.Handler {
wrapper := middleware.Wrapper(
router,
middleware.GenerateRecurringTransactions(transactionRecurringService),
middleware.SecurityHeaders(serverSettings),
middleware.CacheControl,
middleware.CrossSiteRequestForgery(authService),
@@ -150,3 +155,24 @@ func createHandler(d *sqlx.DB, serverSettings *types.Settings) http.Handler {
return wrapper
}
func dailyTaskTimer(ctx context.Context, transactionRecurring service.TransactionRecurring, auth service.Auth) {
runDailyTasks(ctx, transactionRecurring, auth)
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
runDailyTasks(ctx, transactionRecurring, auth)
}
}
}
func runDailyTasks(ctx context.Context, transactionRecurring service.TransactionRecurring, auth service.Auth) {
slog.InfoContext(ctx, "Running daily tasks")
_ = transactionRecurring.GenerateTransactions(ctx)
_ = auth.CleanupSessionsAndTokens(ctx)
}

View File

@@ -44,7 +44,7 @@ func (h AccountImpl) handleAccountPage() http.HandlerFunc {
return
}
accounts, err := h.s.GetAll(user)
accounts, err := h.s.GetAll(r.Context(), user)
if err != nil {
handleError(w, r, err)
return
@@ -72,7 +72,7 @@ func (h AccountImpl) handleAccountItemComp() http.HandlerFunc {
return
}
account, err := h.s.Get(user, id)
account, err := h.s.Get(r.Context(), user, id)
if err != nil {
handleError(w, r, err)
return
@@ -105,13 +105,13 @@ func (h AccountImpl) handleUpdateAccount() http.HandlerFunc {
id := r.PathValue("id")
name := r.FormValue("name")
if id == "new" {
account, err = h.s.Add(user, name)
account, err = h.s.Add(r.Context(), user, name)
if err != nil {
handleError(w, r, err)
return
}
} else {
account, err = h.s.UpdateName(user, id, name)
account, err = h.s.UpdateName(r.Context(), user, id, name)
if err != nil {
handleError(w, r, err)
return
@@ -135,7 +135,7 @@ func (h AccountImpl) handleDeleteAccount() http.HandlerFunc {
id := r.PathValue("id")
err := h.s.Delete(user, id)
err := h.s.Delete(r.Context(), user, id)
if err != nil {
handleError(w, r, err)
return

View File

@@ -85,7 +85,7 @@ func (handler AuthImpl) handleSignIn() http.HandlerFunc {
email := r.FormValue("email")
password := r.FormValue("password")
session, user, err := handler.service.SignIn(session, email, password)
session, user, err := handler.service.SignIn(r.Context(), session, email, password)
if err != nil {
return nil, err
}
@@ -98,9 +98,9 @@ func (handler AuthImpl) handleSignIn() http.HandlerFunc {
if err != nil {
if errors.Is(err, service.ErrInvalidCredentials) {
utils.TriggerToastWithStatus(w, r, "error", "Invalid email or password", http.StatusUnauthorized)
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Invalid email or password", http.StatusUnauthorized)
} else {
utils.TriggerToastWithStatus(w, r, "error", "An error occurred", http.StatusInternalServerError)
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "An error occurred", http.StatusInternalServerError)
}
return
}
@@ -163,11 +163,11 @@ func (handler AuthImpl) handleVerifyResendComp() http.HandlerFunc {
return
}
go handler.service.SendVerificationMail(user.Id, user.Email)
go handler.service.SendVerificationMail(r.Context(), user.Id, user.Email)
_, err := w.Write([]byte("<p class=\"mt-8\">Verification email sent</p>"))
if err != nil {
slog.Error("Could not write response", "err", err)
slog.ErrorContext(r.Context(), "Could not write response", "err", err)
}
}
}
@@ -178,7 +178,7 @@ func (handler AuthImpl) handleSignUpVerifyResponsePage() http.HandlerFunc {
token := r.URL.Query().Get("token")
err := handler.service.VerifyUserEmail(token)
err := handler.service.VerifyUserEmail(r.Context(), token)
isVerified := err == nil
comp := auth.VerifyResponseComp(isVerified)
@@ -202,33 +202,33 @@ func (handler AuthImpl) handleSignUp() http.HandlerFunc {
var password = r.FormValue("password")
_, err := utils.WaitMinimumTime(securityWaitDuration, func() (any, error) {
slog.Info("signing up", "email", email)
user, err := handler.service.SignUp(email, password)
slog.InfoContext(r.Context(), "signing up", "email", email)
user, err := handler.service.SignUp(r.Context(), email, password)
if err != nil {
return nil, err
}
slog.Info("Sending verification email", "to", user.Email)
go handler.service.SendVerificationMail(user.Id, user.Email)
slog.InfoContext(r.Context(), "Sending verification email", "to", user.Email)
go handler.service.SendVerificationMail(r.Context(), user.Id, user.Email)
return nil, nil
})
if err != nil {
switch {
case errors.Is(err, types.ErrInternal):
utils.TriggerToastWithStatus(w, r, "error", "An error occurred", http.StatusInternalServerError)
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "An error occurred", http.StatusInternalServerError)
return
case errors.Is(err, service.ErrInvalidEmail):
utils.TriggerToastWithStatus(w, r, "error", "The email provided is invalid", http.StatusBadRequest)
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "The email provided is invalid", http.StatusBadRequest)
return
case errors.Is(err, service.ErrInvalidPassword):
utils.TriggerToastWithStatus(w, r, "error", service.ErrInvalidPassword.Error(), http.StatusBadRequest)
utils.TriggerToastWithStatus(r.Context(), w, r, "error", service.ErrInvalidPassword.Error(), http.StatusBadRequest)
return
}
// If err is "service.ErrAccountExists", then just continue
}
utils.TriggerToastWithStatus(w, r, "success", "An activation link has been send to your email", http.StatusOK)
utils.TriggerToastWithStatus(r.Context(), w, r, "success", "An activation link has been send to your email", http.StatusOK)
}
}
@@ -239,7 +239,7 @@ func (handler AuthImpl) handleSignOut() http.HandlerFunc {
session := middleware.GetSession(r)
if session != nil {
err := handler.service.SignOut(session.Id)
err := handler.service.SignOut(r.Context(), session.Id)
if err != nil {
http.Error(w, "An error occurred", http.StatusInternalServerError)
return
@@ -288,12 +288,12 @@ func (handler AuthImpl) handleDeleteAccountComp() http.HandlerFunc {
password := r.FormValue("password")
err := handler.service.DeleteAccount(user, password)
err := handler.service.DeleteAccount(r.Context(), user, password)
if err != nil {
if errors.Is(err, service.ErrInvalidCredentials) {
utils.TriggerToastWithStatus(w, r, "error", "Password not correct", http.StatusBadRequest)
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Password not correct", http.StatusBadRequest)
} else {
utils.TriggerToastWithStatus(w, r, "error", "Internal Server Error", http.StatusInternalServerError)
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Internal Server Error", http.StatusInternalServerError)
}
return
}
@@ -327,20 +327,20 @@ func (handler AuthImpl) handleChangePasswordComp() http.HandlerFunc {
session := middleware.GetSession(r)
user := middleware.GetUser(r)
if session == nil || user == nil {
utils.TriggerToastWithStatus(w, r, "error", "Unathorized", http.StatusUnauthorized)
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Unathorized", http.StatusUnauthorized)
return
}
currPass := r.FormValue("current-password")
newPass := r.FormValue("new-password")
err := handler.service.ChangePassword(user, session.Id, currPass, newPass)
err := handler.service.ChangePassword(r.Context(), user, session.Id, currPass, newPass)
if err != nil {
utils.TriggerToastWithStatus(w, r, "error", err.Error(), http.StatusBadRequest)
utils.TriggerToastWithStatus(r.Context(), w, r, "error", err.Error(), http.StatusBadRequest)
return
}
utils.TriggerToastWithStatus(w, r, "success", "Password changed", http.StatusOK)
utils.TriggerToastWithStatus(r.Context(), w, r, "success", "Password changed", http.StatusOK)
}
}
@@ -365,19 +365,19 @@ func (handler AuthImpl) handleForgotPasswordComp() http.HandlerFunc {
email := r.FormValue("email")
if email == "" {
utils.TriggerToastWithStatus(w, r, "error", "Please enter an email", http.StatusBadRequest)
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Please enter an email", http.StatusBadRequest)
return
}
_, err := utils.WaitMinimumTime(securityWaitDuration, func() (any, error) {
err := handler.service.SendForgotPasswordMail(email)
err := handler.service.SendForgotPasswordMail(r.Context(), email)
return nil, err
})
if err != nil {
utils.TriggerToastWithStatus(w, r, "error", "Internal Server Error", http.StatusInternalServerError)
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Internal Server Error", http.StatusInternalServerError)
} else {
utils.TriggerToastWithStatus(w, r, "info", "If the address exists, an email has been sent.", http.StatusOK)
utils.TriggerToastWithStatus(r.Context(), w, r, "info", "If the address exists, an email has been sent.", http.StatusOK)
}
}
}
@@ -388,19 +388,19 @@ func (handler AuthImpl) handleForgotPasswordResponseComp() http.HandlerFunc {
pageUrl, err := url.Parse(r.Header.Get("Hx-Current-Url"))
if err != nil {
slog.Error("Could not get current URL", "err", err)
utils.TriggerToastWithStatus(w, r, "error", "Internal Server Error", http.StatusInternalServerError)
slog.ErrorContext(r.Context(), "Could not get current URL", "err", err)
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Internal Server Error", http.StatusInternalServerError)
return
}
token := pageUrl.Query().Get("token")
newPass := r.FormValue("new-password")
err = handler.service.ForgotPassword(token, newPass)
err = handler.service.ForgotPassword(r.Context(), token, newPass)
if err != nil {
utils.TriggerToastWithStatus(w, r, "error", err.Error(), http.StatusBadRequest)
utils.TriggerToastWithStatus(r.Context(), w, r, "error", err.Error(), http.StatusBadRequest)
} else {
utils.TriggerToastWithStatus(w, r, "success", "Password changed", http.StatusOK)
utils.TriggerToastWithStatus(r.Context(), w, r, "success", "Password changed", http.StatusOK)
}
}
}

View File

@@ -0,0 +1,254 @@
package handler
import (
"fmt"
"log/slog"
"net/http"
"spend-sparrow/internal/handler/middleware"
"spend-sparrow/internal/service"
"spend-sparrow/internal/template/dashboard"
"spend-sparrow/internal/utils"
"strings"
"time"
"github.com/google/uuid"
)
type Dashboard interface {
Handle(router *http.ServeMux)
}
type DashboardImpl struct {
r *Render
d *service.Dashboard
treasureChest service.TreasureChest
}
func NewDashboard(r *Render, d *service.Dashboard, treasureChest service.TreasureChest) Dashboard {
return DashboardImpl{
r: r,
d: d,
treasureChest: treasureChest,
}
}
func (handler DashboardImpl) Handle(router *http.ServeMux) {
router.Handle("GET /dashboard", handler.handleDashboard())
router.Handle("GET /dashboard/main-chart", handler.handleDashboardMainChart())
router.Handle("GET /dashboard/treasure-chests", handler.handleDashboardTreasureChests())
router.Handle("GET /dashboard/treasure-chest", handler.handleDashboardTreasureChest())
}
func (handler DashboardImpl) handleDashboard() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)
user := middleware.GetUser(r)
if user == nil {
utils.DoRedirect(w, r, "/auth/signin")
return
}
treasureChests, err := handler.treasureChest.GetAll(r.Context(), user)
if err != nil {
handleError(w, r, err)
return
}
comp := dashboard.Dashboard(treasureChests)
handler.r.RenderLayoutWithStatus(r, w, comp, user, http.StatusOK)
}
}
func (handler DashboardImpl) handleDashboardMainChart() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)
user := middleware.GetUser(r)
series, err := handler.d.MainChart(r.Context(), user)
if err != nil {
handleError(w, r, err)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
accountBuilder := strings.Builder{}
savingsBuilder := strings.Builder{}
for _, entry := range series {
accountBuilder.WriteString(fmt.Sprintf(`["%s",%.2f],`, entry.Day.Format(time.RFC3339), float64(entry.Value)/100))
savingsBuilder.WriteString(fmt.Sprintf(`["%s",%.2f],`, entry.Day.Format(time.RFC3339), float64(entry.Savings)/100))
}
account := accountBuilder.String()
savings := savingsBuilder.String()
account = account[:len(account)-1]
savings = savings[:len(savings)-1]
_, err = fmt.Fprintf(w, `
{
"aria": {
"enabled": true
},
"tooltip": {
"trigger": "axis",
"formatter": "<updated by client>"
},
"xAxis": {
"type": "time"
},
"yAxis": {
"axisLabel": {
"formatter": "{value} €"
}
},
"series": [
{
"data": [%s],
"type": "line",
"name": "Account Value"
},
{
"data": [%s],
"type": "line",
"name": "Savings"
}
]
}
`, account, savings)
if err != nil {
slog.InfoContext(r.Context(), "could not write response", "err", err)
}
}
}
func (handler DashboardImpl) handleDashboardTreasureChests() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)
user := middleware.GetUser(r)
treeList, err := handler.d.TreasureChests(r.Context(), user)
if err != nil {
handleError(w, r, err)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
dataBuilder := strings.Builder{}
for _, item := range treeList {
childrenBuilder := strings.Builder{}
for _, child := range item.Children {
if child.Value < 0 {
childrenBuilder.WriteString(fmt.Sprintf(`{"name":"%s\n%.2f €","value":%d},`, child.Name, float64(child.Value)/100, -child.Value))
} else {
childrenBuilder.WriteString(fmt.Sprintf(`{"name":"%s\n%.2f €","value":%d},`, child.Name, float64(child.Value)/100, child.Value))
}
}
children := childrenBuilder.String()
children = children[:len(children)-1]
dataBuilder.WriteString(fmt.Sprintf(`{"name":"%s","children":[%s]},`, item.Name, children))
}
data := dataBuilder.String()
data = data[:len(data)-1]
_, err = fmt.Fprintf(w, `
{
"aria": {
"enabled": true
},
"series": [
{
"data": [%s],
"type": "treemap",
"name": "Savings"
}
]
}
`, data)
if err != nil {
slog.InfoContext(r.Context(), "could not write response", "err", err)
}
}
}
func (handler DashboardImpl) handleDashboardTreasureChest() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r)
user := middleware.GetUser(r)
var treasureChestId *uuid.UUID
treasureChestStr := r.URL.Query().Get("id")
if treasureChestStr != "" {
id, err := uuid.Parse(treasureChestStr)
if err != nil {
handleError(w, r, fmt.Errorf("could not parse treasure chest: %w", service.ErrBadRequest))
return
}
treasureChestId = &id
}
series, err := handler.d.TreasureChest(r.Context(), user, treasureChestId)
if err != nil {
handleError(w, r, err)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
valueBuilder := strings.Builder{}
for _, entry := range series {
valueBuilder.WriteString(fmt.Sprintf(`["%s",%.2f],`, entry.Day.Format(time.RFC3339), float64(entry.Value)/100))
}
value := valueBuilder.String()
if len(value) > 0 {
value = value[:len(value)-1]
}
_, err = fmt.Fprintf(w, `
{
"aria": {
"enabled": true
},
"tooltip": {
"trigger": "axis",
"formatter": "<updated by client>"
},
"xAxis": {
"type": "time"
},
"yAxis": {
"axisLabel": {
"formatter": "{value} €"
}
},
"series": [
{
"data": [%s],
"type": "line",
"name": "Treasure Chest Value"
}
]
}
`, value)
if err != nil {
slog.InfoContext(r.Context(), "could not write response", "err", err)
}
}
}

View File

@@ -15,17 +15,17 @@ import (
func handleError(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, service.ErrUnauthorized):
utils.TriggerToastWithStatus(w, r, "error", "You are not autorized to perform this operation.", http.StatusUnauthorized)
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "You are not autorized to perform this operation.", http.StatusUnauthorized)
return
case errors.Is(err, service.ErrBadRequest):
utils.TriggerToastWithStatus(w, r, "error", extractErrorMessage(err), http.StatusBadRequest)
utils.TriggerToastWithStatus(r.Context(), w, r, "error", extractErrorMessage(err), http.StatusBadRequest)
return
case errors.Is(err, db.ErrNotFound):
utils.TriggerToastWithStatus(w, r, "error", extractErrorMessage(err), http.StatusNotFound)
utils.TriggerToastWithStatus(r.Context(), w, r, "error", extractErrorMessage(err), http.StatusNotFound)
return
}
utils.TriggerToastWithStatus(w, r, "error", "Internal Server Error", http.StatusInternalServerError)
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Internal Server Error", http.StatusInternalServerError)
}
func extractErrorMessage(err error) string {

View File

@@ -3,6 +3,7 @@ package middleware
import (
"context"
"net/http"
"strings"
"spend-sparrow/internal/service"
"spend-sparrow/internal/types"
@@ -16,14 +17,21 @@ var UserKey ContextKey = "user"
func Authenticate(service service.Auth) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if strings.Contains(r.URL.Path, "/static/") {
next.ServeHTTP(w, r.WithContext(ctx))
return
}
sessionId := getSessionID(r)
session, user, _ := service.SignInSession(sessionId)
session, user, _ := service.SignInSession(r.Context(), sessionId)
var err error
// Always sign in anonymous
// This way, we can always generate csrf tokens
if session == nil {
session, err = service.SignInAnonymous()
session, err = service.SignInAnonymous(r.Context())
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
@@ -33,7 +41,6 @@ func Authenticate(service service.Auth) func(http.Handler) http.Handler {
http.SetCookie(w, &cookie)
}
ctx := r.Context()
ctx = context.WithValue(ctx, UserKey, user)
ctx = context.WithValue(ctx, SessionKey, session)

View File

@@ -4,30 +4,27 @@ import (
"log/slog"
"net/http"
"spend-sparrow/internal/service"
"spend-sparrow/internal/types"
"spend-sparrow/internal/utils"
"strings"
)
type csrfResponseWriter struct {
http.ResponseWriter
auth service.Auth
session *types.Session
csrfToken string
}
func newCsrfResponseWriter(w http.ResponseWriter, auth service.Auth, session *types.Session) *csrfResponseWriter {
func newCsrfResponseWriter(w http.ResponseWriter, csrfToken string) *csrfResponseWriter {
return &csrfResponseWriter{
ResponseWriter: w,
auth: auth,
session: session,
csrfToken: csrfToken,
}
}
func (rr *csrfResponseWriter) Write(data []byte) (int, error) {
dataStr := string(data)
csrfToken, err := rr.auth.GetCsrfToken(rr.session)
if err == nil {
dataStr = strings.ReplaceAll(dataStr, "CSRF_TOKEN", csrfToken)
if rr.csrfToken != "" {
dataStr = strings.ReplaceAll(dataStr, "CSRF_TOKEN", rr.csrfToken)
}
return rr.ResponseWriter.Write([]byte(dataStr))
@@ -36,6 +33,13 @@ func (rr *csrfResponseWriter) Write(data []byte) (int, error) {
func CrossSiteRequestForgery(auth service.Auth) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if strings.Contains(r.URL.Path, "/static/") {
next.ServeHTTP(w, r.WithContext(ctx))
return
}
session := GetSession(r)
if r.Method == http.MethodPost ||
@@ -44,10 +48,10 @@ func CrossSiteRequestForgery(auth service.Auth) func(http.Handler) http.Handler
r.Method == http.MethodPatch {
csrfToken := r.Header.Get("Csrf-Token")
if session == nil || csrfToken == "" || !auth.IsCsrfTokenValid(csrfToken, session.Id) {
slog.Info("CSRF-Token not correct", "token", csrfToken)
if session == nil || csrfToken == "" || !auth.IsCsrfTokenValid(ctx, csrfToken, session.Id) {
slog.InfoContext(ctx, "CSRF-Token not correct", "token", csrfToken)
if r.Header.Get("Hx-Request") == "true" {
utils.TriggerToastWithStatus(w, r, "error", "CSRF-Token not correct", http.StatusBadRequest)
utils.TriggerToastWithStatus(ctx, w, r, "error", "CSRF-Token not correct", http.StatusBadRequest)
} else {
http.Error(w, "CSRF-Token not correct", http.StatusBadRequest)
}
@@ -55,7 +59,17 @@ func CrossSiteRequestForgery(auth service.Auth) func(http.Handler) http.Handler
}
}
responseWriter := newCsrfResponseWriter(w, auth, session)
token, err := auth.GetCsrfToken(ctx, session)
if err != nil {
if r.Header.Get("Hx-Request") == "true" {
utils.TriggerToastWithStatus(ctx, w, r, "error", "Could not generate CSRF Token", http.StatusBadRequest)
} else {
http.Error(w, "Could not generate CSRF Token", http.StatusBadRequest)
}
return
}
responseWriter := newCsrfResponseWriter(w, token)
next.ServeHTTP(responseWriter, r)
})
}

View File

@@ -1,22 +0,0 @@
package middleware
import (
"net/http"
"spend-sparrow/internal/service"
)
func GenerateRecurringTransactions(transactionRecurring service.TransactionRecurring) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := GetUser(r)
if user == nil || r.Method != http.MethodGet {
next.ServeHTTP(w, r)
return
}
_ = transactionRecurring.GenerateTransactions(user)
next.ServeHTTP(w, r)
})
}
}

View File

@@ -33,7 +33,7 @@ func Gzip(next http.Handler) http.Handler {
err := gz.Close()
if err != nil && !errors.Is(err, http.ErrBodyNotAllowed) {
slog.Error("Gzip: could not close Writer", "err", err)
slog.ErrorContext(r.Context(), "Gzip: could not close Writer", "err", err)
}
})
}

View File

@@ -8,6 +8,7 @@ import (
type WrappedWriter struct {
http.ResponseWriter
StatusCode int
}
@@ -26,7 +27,7 @@ func Log(next http.Handler) http.Handler {
}
next.ServeHTTP(wrapped, r)
slog.Info("request",
slog.InfoContext(r.Context(), "request",
"remoteAddr", r.RemoteAddr,
"status", wrapped.StatusCode,
"method", r.Method,

View File

@@ -2,7 +2,7 @@ package middleware
import "net/http"
// Chain list of handlers together.
// Wrapper wraps a list of handlers together.
func Wrapper(next http.Handler, handlers ...func(http.Handler) http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
lastHandler := next

View File

@@ -22,7 +22,7 @@ func (render *Render) RenderWithStatus(r *http.Request, w http.ResponseWriter, c
w.WriteHeader(status)
err := comp.Render(r.Context(), w)
if err != nil {
slog.Error("Failed to render layout", "err", err)
slog.ErrorContext(r.Context(), "Failed to render layout", "err", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}

View File

@@ -3,7 +3,9 @@ package handler
import (
"net/http"
"spend-sparrow/internal/handler/middleware"
"spend-sparrow/internal/service"
"spend-sparrow/internal/template"
"spend-sparrow/internal/utils"
"github.com/a-h/templ"
)
@@ -13,12 +15,14 @@ type Index interface {
}
type IndexImpl struct {
render *Render
r *Render
c service.Clock
}
func NewIndex(render *Render) Index {
func NewIndex(r *Render, c service.Clock) Index {
return IndexImpl{
render: render,
r: r,
c: c,
}
}
@@ -33,6 +37,8 @@ func (handler IndexImpl) handleRootAnd404() http.HandlerFunc {
user := middleware.GetUser(r)
htmx := utils.IsHtmx(r)
var comp templ.Component
var status int
@@ -41,14 +47,19 @@ func (handler IndexImpl) handleRootAnd404() http.HandlerFunc {
status = http.StatusNotFound
} else {
if user != nil {
comp = template.Dashboard()
utils.DoRedirect(w, r, "/dashboard")
return
} else {
comp = template.Index()
}
status = http.StatusOK
}
handler.render.RenderLayoutWithStatus(r, w, comp, user, status)
if htmx {
handler.r.RenderWithStatus(r, w, comp, status)
} else {
handler.r.RenderLayoutWithStatus(r, w, comp, user, status)
}
}
}

View File

@@ -14,8 +14,6 @@ import (
"github.com/a-h/templ"
"github.com/google/uuid"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
)
type Transaction interface {
@@ -56,28 +54,26 @@ func (h TransactionImpl) handleTransactionPage() http.HandlerFunc {
return
}
currentSpan := trace.SpanFromContext(r.Context())
currentSpan.SetAttributes(attribute.String("", "test"))
filter := types.TransactionItemsFilter{
AccountId: r.URL.Query().Get("account-id"),
TreasureChestId: r.URL.Query().Get("treasure-chest-id"),
Error: r.URL.Query().Get("error"),
Page: r.URL.Query().Get("page"),
}
transactions, err := h.s.GetAll(user, filter)
transactions, err := h.s.GetAll(r.Context(), user, filter)
if err != nil {
handleError(w, r, err)
return
}
accounts, err := h.account.GetAll(user)
accounts, err := h.account.GetAll(r.Context(), user)
if err != nil {
handleError(w, r, err)
return
}
treasureChests, err := h.treasureChest.GetAll(user)
treasureChests, err := h.treasureChest.GetAll(r.Context(), user)
if err != nil {
handleError(w, r, err)
return
@@ -105,13 +101,13 @@ func (h TransactionImpl) handleTransactionItemComp() http.HandlerFunc {
return
}
accounts, err := h.account.GetAll(user)
accounts, err := h.account.GetAll(r.Context(), user)
if err != nil {
handleError(w, r, err)
return
}
treasureChests, err := h.treasureChest.GetAll(user)
treasureChests, err := h.treasureChest.GetAll(r.Context(), user)
if err != nil {
handleError(w, r, err)
return
@@ -124,7 +120,7 @@ func (h TransactionImpl) handleTransactionItemComp() http.HandlerFunc {
return
}
transaction, err := h.s.Get(user, id)
transaction, err := h.s.Get(r.Context(), user, id)
if err != nil {
handleError(w, r, err)
return
@@ -212,26 +208,26 @@ func (h TransactionImpl) handleUpdateTransaction() http.HandlerFunc {
var transaction *types.Transaction
if idStr == "new" {
transaction, err = h.s.Add(nil, user, input)
transaction, err = h.s.Add(r.Context(), nil, user, input)
if err != nil {
handleError(w, r, err)
return
}
} else {
transaction, err = h.s.Update(user, input)
transaction, err = h.s.Update(r.Context(), user, input)
if err != nil {
handleError(w, r, err)
return
}
}
accounts, err := h.account.GetAll(user)
accounts, err := h.account.GetAll(r.Context(), user)
if err != nil {
handleError(w, r, err)
return
}
treasureChests, err := h.treasureChest.GetAll(user)
treasureChests, err := h.treasureChest.GetAll(r.Context(), user)
if err != nil {
handleError(w, r, err)
return
@@ -253,13 +249,13 @@ func (h TransactionImpl) handleRecalculate() http.HandlerFunc {
return
}
err := h.s.RecalculateBalances(user)
err := h.s.RecalculateBalances(r.Context(), user)
if err != nil {
handleError(w, r, err)
return
}
utils.TriggerToastWithStatus(w, r, "success", "Balances recalculated, please refresh", http.StatusOK)
utils.TriggerToastWithStatus(r.Context(), w, r, "success", "Balances recalculated, please refresh", http.StatusOK)
}
}
@@ -275,7 +271,7 @@ func (h TransactionImpl) handleDeleteTransaction() http.HandlerFunc {
id := r.PathValue("id")
err := h.s.Delete(user, id)
err := h.s.Delete(r.Context(), user, id)
if err != nil {
handleError(w, r, err)
return

View File

@@ -70,13 +70,13 @@ func (h TransactionRecurringImpl) handleUpdateTransactionRecurring() http.Handle
}
if input.Id == "new" {
_, err := h.s.Add(user, input)
_, err := h.s.Add(r.Context(), user, input)
if err != nil {
handleError(w, r, err)
return
}
} else {
_, err := h.s.Update(user, input)
_, err := h.s.Update(r.Context(), user, input)
if err != nil {
handleError(w, r, err)
return
@@ -101,7 +101,7 @@ func (h TransactionRecurringImpl) handleDeleteTransactionRecurring() http.Handle
accountId := r.URL.Query().Get("account-id")
treasureChestId := r.URL.Query().Get("treasure-chest-id")
err := h.s.Delete(user, id)
err := h.s.Delete(r.Context(), user, id)
if err != nil {
handleError(w, r, err)
return
@@ -115,16 +115,16 @@ func (h TransactionRecurringImpl) renderItems(w http.ResponseWriter, r *http.Req
var transactionsRecurring []*types.TransactionRecurring
var err error
if accountId == "" && treasureChestId == "" {
utils.TriggerToastWithStatus(w, r, "error", "Please select an account or treasure chest", http.StatusBadRequest)
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Please select an account or treasure chest", http.StatusBadRequest)
}
if accountId != "" {
transactionsRecurring, err = h.s.GetAllByAccount(user, accountId)
transactionsRecurring, err = h.s.GetAllByAccount(r.Context(), user, accountId)
if err != nil {
handleError(w, r, err)
return
}
} else {
transactionsRecurring, err = h.s.GetAllByTreasureChest(user, treasureChestId)
transactionsRecurring, err = h.s.GetAllByTreasureChest(r.Context(), user, treasureChestId)
if err != nil {
handleError(w, r, err)
return

View File

@@ -48,13 +48,13 @@ func (h TreasureChestImpl) handleTreasureChestPage() http.HandlerFunc {
return
}
treasureChests, err := h.s.GetAll(user)
treasureChests, err := h.s.GetAll(r.Context(), user)
if err != nil {
handleError(w, r, err)
return
}
transactionsRecurring, err := h.transactionRecurring.GetAll(user)
transactionsRecurring, err := h.transactionRecurring.GetAll(r.Context(), user)
if err != nil {
handleError(w, r, err)
return
@@ -77,7 +77,7 @@ func (h TreasureChestImpl) handleTreasureChestItemComp() http.HandlerFunc {
return
}
treasureChests, err := h.s.GetAll(user)
treasureChests, err := h.s.GetAll(r.Context(), user)
if err != nil {
handleError(w, r, err)
return
@@ -90,13 +90,13 @@ func (h TreasureChestImpl) handleTreasureChestItemComp() http.HandlerFunc {
return
}
treasureChest, err := h.s.Get(user, id)
treasureChest, err := h.s.Get(r.Context(), user, id)
if err != nil {
handleError(w, r, err)
return
}
transactionsRecurring, err := h.transactionRecurring.GetAllByTreasureChest(user, treasureChest.Id.String())
transactionsRecurring, err := h.transactionRecurring.GetAllByTreasureChest(r.Context(), user, treasureChest.Id.String())
if err != nil {
handleError(w, r, err)
return
@@ -132,20 +132,20 @@ func (h TreasureChestImpl) handleUpdateTreasureChest() http.HandlerFunc {
parentId := r.FormValue("parent-id")
name := r.FormValue("name")
if id == "new" {
treasureChest, err = h.s.Add(user, parentId, name)
treasureChest, err = h.s.Add(r.Context(), user, parentId, name)
if err != nil {
handleError(w, r, err)
return
}
} else {
treasureChest, err = h.s.Update(user, id, parentId, name)
treasureChest, err = h.s.Update(r.Context(), user, id, parentId, name)
if err != nil {
handleError(w, r, err)
return
}
}
transactionsRecurring, err := h.transactionRecurring.GetAllByTreasureChest(user, treasureChest.Id.String())
transactionsRecurring, err := h.transactionRecurring.GetAllByTreasureChest(r.Context(), user, treasureChest.Id.String())
if err != nil {
handleError(w, r, err)
return
@@ -171,7 +171,7 @@ func (h TreasureChestImpl) handleDeleteTreasureChest() http.HandlerFunc {
id := r.PathValue("id")
err := h.s.Delete(user, id)
err := h.s.Delete(r.Context(), user, id)
if err != nil {
handleError(w, r, err)
return

View File

@@ -9,10 +9,10 @@ import (
)
func NewLogPropagator() *slog.Logger {
console := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{})
otel := otelslog.NewHandler("spend-sparrow")
return slog.New(&logHandler{console, otel})
return slog.New(&logHandler{
console: slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{}),
otel: otelslog.NewHandler("spend-sparrow"),
})
}
type logHandler struct {

View File

@@ -3,6 +3,7 @@ package internal
import (
"context"
"errors"
"log/slog"
"time"
"go.opentelemetry.io/otel"
@@ -13,11 +14,13 @@ import (
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/log"
"go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/resource"
"go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
)
var (
otelEndpoint = "192.168.188.155:4317"
otelEndpoint = "otel-collector:4317"
)
// setupOTelSDK bootstraps the OpenTelemetry pipeline.
@@ -47,8 +50,16 @@ func setupOTelSDK(ctx context.Context) (func(context.Context) error, error) {
prop := newPropagator()
otel.SetTextMapPropagator(prop)
resources, err := resource.New(
ctx,
resource.WithAttributes(semconv.ServiceName("spend-sparrow")),
)
if err != nil {
slog.ErrorContext(ctx, "failed to create resource", "error", err)
}
// Set up trace provider.
tracerProvider, err := newTracerProvider(ctx)
tracerProvider, err := newTracerProvider(ctx, resources)
if err != nil {
handleErr(ctx, err)
return nil, err
@@ -57,7 +68,7 @@ func setupOTelSDK(ctx context.Context) (func(context.Context) error, error) {
otel.SetTracerProvider(tracerProvider)
// Set up meter provider.
meterProvider, err := newMeterProvider(ctx)
meterProvider, err := newMeterProvider(ctx, resources)
if err != nil {
handleErr(ctx, err)
return nil, err
@@ -66,7 +77,7 @@ func setupOTelSDK(ctx context.Context) (func(context.Context) error, error) {
otel.SetMeterProvider(meterProvider)
// Set up logger provider.
loggerProvider, err := newLoggerProvider(ctx)
loggerProvider, err := newLoggerProvider(ctx, resources)
if err != nil {
handleErr(ctx, err)
return nil, err
@@ -84,8 +95,9 @@ func newPropagator() propagation.TextMapPropagator {
)
}
func newTracerProvider(ctx context.Context) (*trace.TracerProvider, error) {
exp, err := otlptracegrpc.New(ctx,
func newTracerProvider(ctx context.Context, resource *resource.Resource) (*trace.TracerProvider, error) {
exp, err := otlptracegrpc.New(
ctx,
otlptracegrpc.WithEndpoint(otelEndpoint),
otlptracegrpc.WithInsecure(),
)
@@ -93,11 +105,15 @@ func newTracerProvider(ctx context.Context) (*trace.TracerProvider, error) {
return nil, err
}
return trace.NewTracerProvider(trace.WithBatcher(exp)), nil
return trace.NewTracerProvider(
trace.WithBatcher(exp),
trace.WithResource(resource),
), nil
}
func newMeterProvider(ctx context.Context) (*metric.MeterProvider, error) {
exp, err := otlpmetricgrpc.New(ctx,
func newMeterProvider(ctx context.Context, resource *resource.Resource) (*metric.MeterProvider, error) {
exp, err := otlpmetricgrpc.New(
ctx,
otlpmetricgrpc.WithInsecure(),
otlpmetricgrpc.WithEndpoint(otelEndpoint))
if err != nil {
@@ -105,12 +121,12 @@ func newMeterProvider(ctx context.Context) (*metric.MeterProvider, error) {
}
return metric.NewMeterProvider(
metric.WithReader(
metric.NewPeriodicReader(
exp, metric.WithInterval(15*time.Second)))), nil
metric.WithReader(metric.NewPeriodicReader(exp, metric.WithInterval(15*time.Second))),
metric.WithResource(resource),
), nil
}
func newLoggerProvider(ctx context.Context) (*log.LoggerProvider, error) {
func newLoggerProvider(ctx context.Context, resource *resource.Resource) (*log.LoggerProvider, error) {
logExporter, err := otlploggrpc.New(
ctx,
otlploggrpc.WithInsecure(),
@@ -121,6 +137,7 @@ func newLoggerProvider(ctx context.Context) (*log.LoggerProvider, error) {
loggerProvider := log.NewLoggerProvider(
log.WithProcessor(log.NewBatchProcessor(logExporter)),
log.WithResource(resource),
)
return loggerProvider, nil
}

View File

@@ -1,6 +1,7 @@
package service
import (
"context"
"errors"
"fmt"
"log/slog"
@@ -12,11 +13,11 @@ import (
)
type Account interface {
Add(user *types.User, name string) (*types.Account, error)
UpdateName(user *types.User, id string, name string) (*types.Account, error)
Get(user *types.User, id string) (*types.Account, error)
GetAll(user *types.User) ([]*types.Account, error)
Delete(user *types.User, id string) error
Add(ctx context.Context, user *types.User, name string) (*types.Account, error)
UpdateName(ctx context.Context, user *types.User, id string, name string) (*types.Account, error)
Get(ctx context.Context, user *types.User, id string) (*types.Account, error)
GetAll(ctx context.Context, user *types.User) ([]*types.Account, error)
Delete(ctx context.Context, user *types.User, id string) error
}
type AccountImpl struct {
@@ -33,12 +34,12 @@ func NewAccount(db *sqlx.DB, random Random, clock Clock) Account {
}
}
func (s AccountImpl) Add(user *types.User, name string) (*types.Account, error) {
func (s AccountImpl) Add(ctx context.Context, user *types.User, name string) (*types.Account, error) {
if user == nil {
return nil, ErrUnauthorized
}
newId, err := s.random.UUID()
newId, err := s.random.UUID(ctx)
if err != nil {
return nil, types.ErrInternal
}
@@ -64,10 +65,10 @@ func (s AccountImpl) Add(user *types.User, name string) (*types.Account, error)
UpdatedBy: nil,
}
r, err := s.db.NamedExec(`
r, err := s.db.NamedExecContext(ctx, `
INSERT INTO account (id, user_id, name, current_balance, oink_balance, created_at, created_by)
VALUES (:id, :user_id, :name, :current_balance, :oink_balance, :created_at, :created_by)`, account)
err = db.TransformAndLogDbError("account Insert", r, err)
err = db.TransformAndLogDbError(ctx, "account Insert", r, err)
if err != nil {
return nil, err
}
@@ -75,7 +76,7 @@ func (s AccountImpl) Add(user *types.User, name string) (*types.Account, error)
return account, nil
}
func (s AccountImpl) UpdateName(user *types.User, id string, name string) (*types.Account, error) {
func (s AccountImpl) UpdateName(ctx context.Context, user *types.User, id string, name string) (*types.Account, error) {
if user == nil {
return nil, ErrUnauthorized
}
@@ -85,12 +86,12 @@ func (s AccountImpl) UpdateName(user *types.User, id string, name string) (*type
}
uuid, err := uuid.Parse(id)
if err != nil {
slog.Error("account update", "err", err)
slog.ErrorContext(ctx, "account update", "err", err)
return nil, fmt.Errorf("could not parse Id: %w", ErrBadRequest)
}
tx, err := s.db.Beginx()
err = db.TransformAndLogDbError("account Update", nil, err)
tx, err := s.db.BeginTxx(ctx, nil)
err = db.TransformAndLogDbError(ctx, "account Update", nil, err)
if err != nil {
return nil, err
}
@@ -99,8 +100,8 @@ func (s AccountImpl) UpdateName(user *types.User, id string, name string) (*type
}()
var account types.Account
err = tx.Get(&account, `SELECT * FROM account WHERE user_id = ? AND id = ?`, user.Id, uuid)
err = db.TransformAndLogDbError("account Update", nil, err)
err = tx.GetContext(ctx, &account, `SELECT * FROM account WHERE user_id = ? AND id = ?`, user.Id, uuid)
err = db.TransformAndLogDbError(ctx, "account Update", nil, err)
if err != nil {
if errors.Is(err, db.ErrNotFound) {
return nil, fmt.Errorf("account %v not found: %w", id, ErrBadRequest)
@@ -113,7 +114,7 @@ func (s AccountImpl) UpdateName(user *types.User, id string, name string) (*type
account.UpdatedAt = &timestamp
account.UpdatedBy = &user.Id
r, err := tx.NamedExec(`
r, err := tx.NamedExecContext(ctx, `
UPDATE account
SET
name = :name,
@@ -121,13 +122,13 @@ func (s AccountImpl) UpdateName(user *types.User, id string, name string) (*type
updated_by = :updated_by
WHERE id = :id
AND user_id = :user_id`, account)
err = db.TransformAndLogDbError("account Update", r, err)
err = db.TransformAndLogDbError(ctx, "account Update", r, err)
if err != nil {
return nil, err
}
err = tx.Commit()
err = db.TransformAndLogDbError("account Update", nil, err)
err = db.TransformAndLogDbError(ctx, "account Update", nil, err)
if err != nil {
return nil, err
}
@@ -135,37 +136,37 @@ func (s AccountImpl) UpdateName(user *types.User, id string, name string) (*type
return &account, nil
}
func (s AccountImpl) Get(user *types.User, id string) (*types.Account, error) {
func (s AccountImpl) Get(ctx context.Context, user *types.User, id string) (*types.Account, error) {
if user == nil {
return nil, ErrUnauthorized
}
uuid, err := uuid.Parse(id)
if err != nil {
slog.Error("account get", "err", err)
slog.ErrorContext(ctx, "account get", "err", err)
return nil, fmt.Errorf("could not parse Id: %w", ErrBadRequest)
}
var account types.Account
err = s.db.Get(&account, `
err = s.db.GetContext(ctx, &account, `
SELECT * FROM account WHERE user_id = ? AND id = ?`, user.Id, uuid)
err = db.TransformAndLogDbError("account Get", nil, err)
err = db.TransformAndLogDbError(ctx, "account Get", nil, err)
if err != nil {
slog.Error("account get", "err", err)
slog.ErrorContext(ctx, "account get", "err", err)
return nil, err
}
return &account, nil
}
func (s AccountImpl) GetAll(user *types.User) ([]*types.Account, error) {
func (s AccountImpl) GetAll(ctx context.Context, user *types.User) ([]*types.Account, error) {
if user == nil {
return nil, ErrUnauthorized
}
accounts := make([]*types.Account, 0)
err := s.db.Select(&accounts, `
err := s.db.SelectContext(ctx, &accounts, `
SELECT * FROM account WHERE user_id = ? ORDER BY name`, user.Id)
err = db.TransformAndLogDbError("account GetAll", nil, err)
err = db.TransformAndLogDbError(ctx, "account GetAll", nil, err)
if err != nil {
return nil, err
}
@@ -173,18 +174,18 @@ func (s AccountImpl) GetAll(user *types.User) ([]*types.Account, error) {
return accounts, nil
}
func (s AccountImpl) Delete(user *types.User, id string) error {
func (s AccountImpl) Delete(ctx context.Context, user *types.User, id string) error {
if user == nil {
return ErrUnauthorized
}
uuid, err := uuid.Parse(id)
if err != nil {
slog.Error("account delete", "err", err)
slog.ErrorContext(ctx, "account delete", "err", err)
return fmt.Errorf("could not parse Id: %w", ErrBadRequest)
}
tx, err := s.db.Beginx()
err = db.TransformAndLogDbError("account Delete", nil, err)
tx, err := s.db.BeginTxx(ctx, nil)
err = db.TransformAndLogDbError(ctx, "account Delete", nil, err)
if err != nil {
return err
}
@@ -193,8 +194,8 @@ func (s AccountImpl) Delete(user *types.User, id string) error {
}()
transactionsCount := 0
err = tx.Get(&transactionsCount, `SELECT COUNT(*) FROM "transaction" WHERE user_id = ? AND account_id = ?`, user.Id, uuid)
err = db.TransformAndLogDbError("account Delete", nil, err)
err = tx.GetContext(ctx, &transactionsCount, `SELECT COUNT(*) FROM "transaction" WHERE user_id = ? AND account_id = ?`, user.Id, uuid)
err = db.TransformAndLogDbError(ctx, "account Delete", nil, err)
if err != nil {
return err
}
@@ -202,14 +203,14 @@ func (s AccountImpl) Delete(user *types.User, id string) error {
return fmt.Errorf("account has transactions, cannot delete: %w", ErrBadRequest)
}
res, err := tx.Exec("DELETE FROM account WHERE id = ? and user_id = ?", uuid, user.Id)
err = db.TransformAndLogDbError("account Delete", res, err)
res, err := tx.ExecContext(ctx, "DELETE FROM account WHERE id = ? and user_id = ?", uuid, user.Id)
err = db.TransformAndLogDbError(ctx, "account Delete", res, err)
if err != nil {
return err
}
err = tx.Commit()
err = db.TransformAndLogDbError("account Delete", nil, err)
err = db.TransformAndLogDbError(ctx, "account Delete", nil, err)
if err != nil {
return err
}

View File

@@ -26,24 +26,26 @@ var (
)
type Auth interface {
SignUp(email string, password string) (*types.User, error)
SendVerificationMail(userId uuid.UUID, email string)
VerifyUserEmail(token string) error
SignUp(ctx context.Context, email string, password string) (*types.User, error)
SendVerificationMail(ctx context.Context, userId uuid.UUID, email string)
VerifyUserEmail(ctx context.Context, token string) error
SignIn(session *types.Session, email string, password string) (*types.Session, *types.User, error)
SignInSession(sessionId string) (*types.Session, *types.User, error)
SignInAnonymous() (*types.Session, error)
SignOut(sessionId string) error
SignIn(ctx context.Context, session *types.Session, email string, password string) (*types.Session, *types.User, error)
SignInSession(ctx context.Context, sessionId string) (*types.Session, *types.User, error)
SignInAnonymous(ctx context.Context) (*types.Session, error)
SignOut(ctx context.Context, sessionId string) error
DeleteAccount(user *types.User, currPass string) error
DeleteAccount(ctx context.Context, user *types.User, currPass string) error
ChangePassword(user *types.User, sessionId string, currPass, newPass string) error
ChangePassword(ctx context.Context, user *types.User, sessionId string, currPass, newPass string) error
SendForgotPasswordMail(email string) error
ForgotPassword(token string, newPass string) error
SendForgotPasswordMail(ctx context.Context, email string) error
ForgotPassword(ctx context.Context, token string, newPass string) error
IsCsrfTokenValid(tokenStr string, sessionId string) bool
GetCsrfToken(session *types.Session) (string, error)
IsCsrfTokenValid(ctx context.Context, tokenStr string, sessionId string) bool
GetCsrfToken(ctx context.Context, session *types.Session) (string, error)
CleanupSessionsAndTokens(ctx context.Context) error
}
type AuthImpl struct {
@@ -64,8 +66,8 @@ func NewAuth(db db.Auth, random Random, clock Clock, mail Mail, serverSettings *
}
}
func (service AuthImpl) SignIn(session *types.Session, email string, password string) (*types.Session, *types.User, error) {
user, err := service.db.GetUserByEmail(email)
func (service AuthImpl) SignIn(ctx context.Context, session *types.Session, email string, password string) (*types.Session, *types.User, error) {
user, err := service.db.GetUserByEmail(ctx, email)
if err != nil {
if errors.Is(err, db.ErrNotFound) {
return nil, nil, ErrInvalidCredentials
@@ -80,30 +82,41 @@ func (service AuthImpl) SignIn(session *types.Session, email string, password st
return nil, nil, ErrInvalidCredentials
}
err = service.cleanUpSessionWithTokens(session)
newSession, err := service.createSession(ctx, user.Id)
if err != nil {
return nil, nil, types.ErrInternal
}
session, err = service.createSession(user.Id)
err = service.db.DeleteSession(ctx, session.Id)
if err != nil {
return nil, nil, types.ErrInternal
}
return session, user, nil
tokens, err := service.db.GetTokensBySessionIdAndType(ctx, session.Id, types.TokenTypeCsrf)
if err != nil {
return nil, nil, types.ErrInternal
}
for _, token := range tokens {
err = service.db.DeleteToken(ctx, token.Token)
if err != nil {
return nil, nil, types.ErrInternal
}
}
func (service AuthImpl) SignInSession(sessionId string) (*types.Session, *types.User, error) {
return newSession, user, nil
}
func (service AuthImpl) SignInSession(ctx context.Context, sessionId string) (*types.Session, *types.User, error) {
if sessionId == "" {
return nil, nil, ErrSessionIdInvalid
}
session, err := service.db.GetSession(sessionId)
session, err := service.db.GetSession(ctx, sessionId)
if err != nil {
return nil, nil, types.ErrInternal
}
if session.ExpiresAt.Before(service.clock.Now()) {
_ = service.db.DeleteSession(sessionId)
_ = service.db.DeleteSession(ctx, sessionId)
return nil, nil, nil
}
@@ -111,7 +124,7 @@ func (service AuthImpl) SignInSession(sessionId string) (*types.Session, *types.
return session, nil, nil
}
user, err := service.db.GetUser(session.UserId)
user, err := service.db.GetUser(ctx, session.UserId)
if err != nil {
return nil, nil, types.ErrInternal
}
@@ -119,18 +132,18 @@ func (service AuthImpl) SignInSession(sessionId string) (*types.Session, *types.
return session, user, nil
}
func (service AuthImpl) SignInAnonymous() (*types.Session, error) {
session, err := service.createSession(uuid.Nil)
func (service AuthImpl) SignInAnonymous(ctx context.Context) (*types.Session, error) {
session, err := service.createSession(ctx, uuid.Nil)
if err != nil {
return nil, types.ErrInternal
}
slog.Info("anonymous session created", "session-id", session.Id)
slog.InfoContext(ctx, "anonymous session created", "session-id", session.Id)
return session, nil
}
func (service AuthImpl) SignUp(email string, password string) (*types.User, error) {
func (service AuthImpl) SignUp(ctx context.Context, email string, password string) (*types.User, error) {
_, err := mail.ParseAddress(email)
if err != nil {
return nil, ErrInvalidEmail
@@ -140,12 +153,12 @@ func (service AuthImpl) SignUp(email string, password string) (*types.User, erro
return nil, ErrInvalidPassword
}
userId, err := service.random.UUID()
userId, err := service.random.UUID(ctx)
if err != nil {
return nil, types.ErrInternal
}
salt, err := service.random.Bytes(16)
salt, err := service.random.Bytes(ctx, 16)
if err != nil {
return nil, types.ErrInternal
}
@@ -154,7 +167,7 @@ func (service AuthImpl) SignUp(email string, password string) (*types.User, erro
user := types.NewUser(userId, email, false, nil, false, hash, salt, service.clock.Now())
err = service.db.InsertUser(user)
err = service.db.InsertUser(ctx, user)
if err != nil {
if errors.Is(err, db.ErrAlreadyExists) {
return nil, ErrAccountExists
@@ -166,8 +179,8 @@ func (service AuthImpl) SignUp(email string, password string) (*types.User, erro
return user, nil
}
func (service AuthImpl) SendVerificationMail(userId uuid.UUID, email string) {
tokens, err := service.db.GetTokensByUserIdAndType(userId, types.TokenTypeEmailVerify)
func (service AuthImpl) SendVerificationMail(ctx context.Context, userId uuid.UUID, email string) {
tokens, err := service.db.GetTokensByUserIdAndType(ctx, userId, types.TokenTypeEmailVerify)
if err != nil && !errors.Is(err, db.ErrNotFound) {
return
}
@@ -179,7 +192,7 @@ func (service AuthImpl) SendVerificationMail(userId uuid.UUID, email string) {
}
if token == nil {
newTokenStr, err := service.random.String(32)
newTokenStr, err := service.random.String(ctx, 32)
if err != nil {
return
}
@@ -192,7 +205,7 @@ func (service AuthImpl) SendVerificationMail(userId uuid.UUID, email string) {
service.clock.Now(),
service.clock.Now().Add(24*time.Hour))
err = service.db.InsertToken(token)
err = service.db.InsertToken(ctx, token)
if err != nil {
return
}
@@ -201,24 +214,24 @@ func (service AuthImpl) SendVerificationMail(userId uuid.UUID, email string) {
var w strings.Builder
err = mailTemplate.Register(service.serverSettings.BaseUrl, token.Token).Render(context.Background(), &w)
if err != nil {
slog.Error("Could not render welcome email", "err", err)
slog.ErrorContext(ctx, "Could not render welcome email", "err", err)
return
}
service.mail.SendMail(email, "Welcome to spend-sparrow", w.String())
service.mail.SendMail(ctx, email, "Welcome to spend-sparrow", w.String())
}
func (service AuthImpl) VerifyUserEmail(tokenStr string) error {
func (service AuthImpl) VerifyUserEmail(ctx context.Context, tokenStr string) error {
if tokenStr == "" {
return types.ErrInternal
}
token, err := service.db.GetToken(tokenStr)
token, err := service.db.GetToken(ctx, tokenStr)
if err != nil {
return types.ErrInternal
}
user, err := service.db.GetUser(token.UserId)
user, err := service.db.GetUser(ctx, token.UserId)
if err != nil {
return types.ErrInternal
}
@@ -236,21 +249,21 @@ func (service AuthImpl) VerifyUserEmail(tokenStr string) error {
user.EmailVerified = true
user.EmailVerifiedAt = &now
err = service.db.UpdateUser(user)
err = service.db.UpdateUser(ctx, user)
if err != nil {
return types.ErrInternal
}
_ = service.db.DeleteToken(token.Token)
_ = service.db.DeleteToken(ctx, token.Token)
return nil
}
func (service AuthImpl) SignOut(sessionId string) error {
return service.db.DeleteSession(sessionId)
func (service AuthImpl) SignOut(ctx context.Context, sessionId string) error {
return service.db.DeleteSession(ctx, sessionId)
}
func (service AuthImpl) DeleteAccount(user *types.User, currPass string) error {
userDb, err := service.db.GetUser(user.Id)
func (service AuthImpl) DeleteAccount(ctx context.Context, user *types.User, currPass string) error {
userDb, err := service.db.GetUser(ctx, user.Id)
if err != nil {
return types.ErrInternal
}
@@ -260,17 +273,17 @@ func (service AuthImpl) DeleteAccount(user *types.User, currPass string) error {
return ErrInvalidCredentials
}
err = service.db.DeleteUser(user.Id)
err = service.db.DeleteUser(ctx, user.Id)
if err != nil {
return err
}
service.mail.SendMail(user.Email, "Account deleted", "Your account has been deleted")
service.mail.SendMail(ctx, user.Email, "Account deleted", "Your account has been deleted")
return nil
}
func (service AuthImpl) ChangePassword(user *types.User, sessionId string, currPass, newPass string) error {
func (service AuthImpl) ChangePassword(ctx context.Context, user *types.User, sessionId string, currPass, newPass string) error {
if !isPasswordValid(newPass) {
return ErrInvalidPassword
}
@@ -288,18 +301,18 @@ func (service AuthImpl) ChangePassword(user *types.User, sessionId string, currP
newHash := GetHashPassword(newPass, user.Salt)
user.Password = newHash
err := service.db.UpdateUser(user)
err := service.db.UpdateUser(ctx, user)
if err != nil {
return err
}
sessions, err := service.db.GetSessions(user.Id)
sessions, err := service.db.GetSessions(ctx, user.Id)
if err != nil {
return types.ErrInternal
}
for _, s := range sessions {
if s.Id != sessionId {
err = service.db.DeleteSession(s.Id)
err = service.db.DeleteSession(ctx, s.Id)
if err != nil {
return types.ErrInternal
}
@@ -309,13 +322,13 @@ func (service AuthImpl) ChangePassword(user *types.User, sessionId string, currP
return nil
}
func (service AuthImpl) SendForgotPasswordMail(email string) error {
tokenStr, err := service.random.String(32)
func (service AuthImpl) SendForgotPasswordMail(ctx context.Context, email string) error {
tokenStr, err := service.random.String(ctx, 32)
if err != nil {
return err
}
user, err := service.db.GetUserByEmail(email)
user, err := service.db.GetUserByEmail(ctx, email)
if err != nil {
if errors.Is(err, db.ErrNotFound) {
return nil
@@ -332,7 +345,7 @@ func (service AuthImpl) SendForgotPasswordMail(email string) error {
service.clock.Now(),
service.clock.Now().Add(15*time.Minute))
err = service.db.InsertToken(token)
err = service.db.InsertToken(ctx, token)
if err != nil {
return types.ErrInternal
}
@@ -340,25 +353,25 @@ func (service AuthImpl) SendForgotPasswordMail(email string) error {
var mail strings.Builder
err = mailTemplate.ResetPassword(service.serverSettings.BaseUrl, token.Token).Render(context.Background(), &mail)
if err != nil {
slog.Error("Could not render reset password email", "err", err)
slog.ErrorContext(ctx, "Could not render reset password email", "err", err)
return types.ErrInternal
}
service.mail.SendMail(email, "Reset Password", mail.String())
service.mail.SendMail(ctx, email, "Reset Password", mail.String())
return nil
}
func (service AuthImpl) ForgotPassword(tokenStr string, newPass string) error {
func (service AuthImpl) ForgotPassword(ctx context.Context, tokenStr string, newPass string) error {
if !isPasswordValid(newPass) {
return ErrInvalidPassword
}
token, err := service.db.GetToken(tokenStr)
token, err := service.db.GetToken(ctx, tokenStr)
if err != nil {
return ErrTokenInvalid
}
err = service.db.DeleteToken(tokenStr)
err = service.db.DeleteToken(ctx, tokenStr)
if err != nil {
return err
}
@@ -368,27 +381,27 @@ func (service AuthImpl) ForgotPassword(tokenStr string, newPass string) error {
return ErrTokenInvalid
}
user, err := service.db.GetUser(token.UserId)
user, err := service.db.GetUser(ctx, token.UserId)
if err != nil {
slog.Error("Could not get user from token", "err", err)
slog.ErrorContext(ctx, "Could not get user from token", "err", err)
return types.ErrInternal
}
passHash := GetHashPassword(newPass, user.Salt)
user.Password = passHash
err = service.db.UpdateUser(user)
err = service.db.UpdateUser(ctx, user)
if err != nil {
return err
}
sessions, err := service.db.GetSessions(user.Id)
sessions, err := service.db.GetSessions(ctx, user.Id)
if err != nil {
return types.ErrInternal
}
for _, session := range sessions {
err = service.db.DeleteSession(session.Id)
err = service.db.DeleteSession(ctx, session.Id)
if err != nil {
return types.ErrInternal
}
@@ -397,8 +410,8 @@ func (service AuthImpl) ForgotPassword(tokenStr string, newPass string) error {
return nil
}
func (service AuthImpl) IsCsrfTokenValid(tokenStr string, sessionId string) bool {
token, err := service.db.GetToken(tokenStr)
func (service AuthImpl) IsCsrfTokenValid(ctx context.Context, tokenStr string, sessionId string) bool {
token, err := service.db.GetToken(ctx, tokenStr)
if err != nil {
return false
}
@@ -412,18 +425,18 @@ func (service AuthImpl) IsCsrfTokenValid(tokenStr string, sessionId string) bool
return true
}
func (service AuthImpl) GetCsrfToken(session *types.Session) (string, error) {
func (service AuthImpl) GetCsrfToken(ctx context.Context, session *types.Session) (string, error) {
if session == nil {
return "", types.ErrInternal
}
tokens, _ := service.db.GetTokensBySessionIdAndType(session.Id, types.TokenTypeCsrf)
tokens, _ := service.db.GetTokensBySessionIdAndType(ctx, session.Id, types.TokenTypeCsrf)
if len(tokens) > 0 {
return tokens[0].Token, nil
}
tokenStr, err := service.random.String(32)
tokenStr, err := service.random.String(ctx, 32)
if err != nil {
return "", types.ErrInternal
}
@@ -435,47 +448,32 @@ func (service AuthImpl) GetCsrfToken(session *types.Session) (string, error) {
types.TokenTypeCsrf,
service.clock.Now(),
service.clock.Now().Add(8*time.Hour))
err = service.db.InsertToken(token)
err = service.db.InsertToken(ctx, token)
if err != nil {
return "", types.ErrInternal
}
slog.Info("CSRF-Token created", "token", tokenStr)
slog.InfoContext(ctx, "CSRF-Token created", "token", tokenStr)
return tokenStr, nil
}
func (service AuthImpl) cleanUpSessionWithTokens(session *types.Session) error {
if session == nil {
return nil
}
err := service.db.DeleteSession(session.Id)
func (service AuthImpl) CleanupSessionsAndTokens(ctx context.Context) error {
err := service.db.DeleteOldSessions(ctx)
if err != nil {
return types.ErrInternal
}
tokens, err := service.db.GetTokensBySessionIdAndType(session.Id, types.TokenTypeCsrf)
err = service.db.DeleteOldTokens(ctx)
if err != nil {
return types.ErrInternal
}
for _, token := range tokens {
err = service.db.DeleteToken(token.Token)
if err != nil {
return types.ErrInternal
}
}
return nil
}
func (service AuthImpl) createSession(userId uuid.UUID) (*types.Session, error) {
sessionId, err := service.random.String(32)
if err != nil {
return nil, types.ErrInternal
}
err = service.db.DeleteOldSessions(userId)
func (service AuthImpl) createSession(ctx context.Context, userId uuid.UUID) (*types.Session, error) {
sessionId, err := service.random.String(ctx, 32)
if err != nil {
return nil, types.ErrInternal
}
@@ -485,7 +483,7 @@ func (service AuthImpl) createSession(userId uuid.UUID) (*types.Session, error)
session := types.NewSession(sessionId, userId, createAt, expiresAt)
err = service.db.InsertSession(session)
err = service.db.InsertSession(ctx, session)
if err != nil {
return nil, types.ErrInternal
}

View File

@@ -13,5 +13,5 @@ func NewClock() Clock {
}
func (c *ClockImpl) Now() time.Time {
return time.Now()
return time.Now().UTC()
}

View File

@@ -0,0 +1,175 @@
package service
import (
"context"
"spend-sparrow/internal/db"
"spend-sparrow/internal/types"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
type Dashboard struct {
db *sqlx.DB
}
func NewDashboard(db *sqlx.DB) *Dashboard {
return &Dashboard{
db: db,
}
}
func (s Dashboard) MainChart(
ctx context.Context,
user *types.User,
) ([]types.DashboardMainChartEntry, error) {
if user == nil {
return nil, ErrUnauthorized
}
transactions := make([]types.Transaction, 0)
err := s.db.SelectContext(ctx, &transactions, `
SELECT *
FROM "transaction"
WHERE user_id = ?
ORDER BY timestamp`, user.Id)
err = db.TransformAndLogDbError(ctx, "dashboard Chart", nil, err)
if err != nil {
return nil, err
}
timeEntries := make([]types.DashboardMainChartEntry, 0)
var lastEntry *types.DashboardMainChartEntry
for _, t := range transactions {
if t.Error != nil {
continue
}
newDay := t.Timestamp.Truncate(24 * time.Hour)
if lastEntry == nil {
lastEntry = &types.DashboardMainChartEntry{
Day: newDay,
Value: 0,
Savings: 0,
}
} else if lastEntry.Day != newDay {
timeEntries = append(timeEntries, *lastEntry)
lastEntry = &types.DashboardMainChartEntry{
Day: newDay,
Value: lastEntry.Value,
Savings: lastEntry.Savings,
}
}
if t.AccountId != nil {
lastEntry.Value += t.Value
}
if t.TreasureChestId != nil {
lastEntry.Savings += t.Value
}
}
if lastEntry != nil {
timeEntries = append(timeEntries, *lastEntry)
}
return timeEntries, nil
}
func (s Dashboard) TreasureChests(
ctx context.Context,
user *types.User,
) ([]*types.DashboardTreasureChest, error) {
if user == nil {
return nil, ErrUnauthorized
}
treasureChests := make([]*types.TreasureChest, 0)
err := s.db.SelectContext(ctx, &treasureChests, `SELECT * FROM treasure_chest WHERE user_id = ?`, user.Id)
err = db.TransformAndLogDbError(ctx, "dashboard TreasureChests", nil, err)
if err != nil {
return nil, err
}
treasureChests = sortTreasureChests(treasureChests)
result := make([]*types.DashboardTreasureChest, 0)
for _, t := range treasureChests {
if t.ParentId == nil {
result = append(result, &types.DashboardTreasureChest{
Name: t.Name,
Value: t.CurrentBalance,
Children: make([]types.DashboardTreasureChest, 0),
})
} else {
result[len(result)-1].Children = append(result[len(result)-1].Children, types.DashboardTreasureChest{
Name: t.Name,
Value: t.CurrentBalance,
Children: make([]types.DashboardTreasureChest, 0),
})
}
}
return result, nil
}
func (s Dashboard) TreasureChest(
ctx context.Context,
user *types.User,
treausureChestId *uuid.UUID,
) ([]types.DashboardMainChartEntry, error) {
if user == nil {
return nil, ErrUnauthorized
}
transactions := make([]types.Transaction, 0)
err := s.db.SelectContext(ctx, &transactions, `
SELECT *
FROM "transaction"
WHERE user_id = ?
AND treasure_chest_id = ?
ORDER BY timestamp`, user.Id, treausureChestId)
err = db.TransformAndLogDbError(ctx, "dashboard Chart", nil, err)
if err != nil {
return nil, err
}
timeEntries := make([]types.DashboardMainChartEntry, 0)
var lastEntry *types.DashboardMainChartEntry
for _, t := range transactions {
if t.Error != nil {
continue
}
newDay := t.Timestamp.Truncate(24 * time.Hour)
if lastEntry == nil {
lastEntry = &types.DashboardMainChartEntry{
Day: newDay,
Value: 0,
}
} else if lastEntry.Day != newDay {
timeEntries = append(timeEntries, *lastEntry)
lastEntry = &types.DashboardMainChartEntry{
Day: newDay,
Value: lastEntry.Value,
}
}
if t.TreasureChestId != nil {
lastEntry.Value += t.Value
}
}
if lastEntry != nil {
timeEntries = append(timeEntries, *lastEntry)
}
return timeEntries, nil
}

View File

@@ -10,7 +10,7 @@ const (
)
var (
safeInputRegex = regexp.MustCompile(`^[a-zA-Z0-9ÄÖÜäöüß,&'" -]+$`)
safeInputRegex = regexp.MustCompile(`^[a-zA-Z0-9ÄÖÜäöüß,&'". \-\?]+$`)
)
func validateString(value string, fieldName string) error {

View File

@@ -1,6 +1,7 @@
package service
import (
"context"
"fmt"
"log/slog"
"net/smtp"
@@ -9,7 +10,7 @@ import (
type Mail interface {
// Sending an email is a fire and forget operation. Thus no error handling
SendMail(to string, subject string, message string)
SendMail(ctx context.Context, to string, subject string, message string)
}
type MailImpl struct {
@@ -20,11 +21,11 @@ func NewMail(server *types.Settings) MailImpl {
return MailImpl{server: server}
}
func (m MailImpl) SendMail(to string, subject string, message string) {
go m.internalSendMail(to, subject, message)
func (m MailImpl) SendMail(ctx context.Context, to string, subject string, message string) {
go m.internalSendMail(ctx, to, subject, message)
}
func (m MailImpl) internalSendMail(to string, subject string, message string) {
func (m MailImpl) internalSendMail(ctx context.Context, to string, subject string, message string) {
if m.server.Smtp == nil {
return
}
@@ -47,9 +48,9 @@ func (m MailImpl) internalSendMail(to string, subject string, message string) {
subject,
message)
slog.Info("sending mail", "to", to)
slog.InfoContext(ctx, "sending mail", "to", to)
err := smtp.SendMail(s.Host+":"+s.Port, auth, s.FromMail, []string{to}, []byte(msg))
if err != nil {
slog.Error("Error sending mail", "err", err)
slog.ErrorContext(ctx, "Error sending mail", "err", err)
}
}

View File

@@ -1,6 +1,7 @@
package service
import (
"context"
"crypto/rand"
"encoding/base64"
"log/slog"
@@ -10,9 +11,9 @@ import (
)
type Random interface {
Bytes(size int) ([]byte, error)
String(size int) (string, error)
UUID() (uuid.UUID, error)
Bytes(ctx context.Context, size int) ([]byte, error)
String(ctx context.Context, size int) (string, error)
UUID(ctx context.Context) (uuid.UUID, error)
}
type RandomImpl struct {
@@ -22,31 +23,31 @@ func NewRandom() *RandomImpl {
return &RandomImpl{}
}
func (r *RandomImpl) Bytes(size int) ([]byte, error) {
func (r *RandomImpl) Bytes(ctx context.Context, tsize int) ([]byte, error) {
b := make([]byte, 32)
_, err := rand.Read(b)
if err != nil {
slog.Error("Error generating random bytes", "err", err)
slog.ErrorContext(ctx, "Error generating random bytes", "err", err)
return []byte{}, types.ErrInternal
}
return b, nil
}
func (r *RandomImpl) String(size int) (string, error) {
bytes, err := r.Bytes(size)
func (r *RandomImpl) String(ctx context.Context, size int) (string, error) {
bytes, err := r.Bytes(ctx, size)
if err != nil {
slog.Error("Error generating random string", "err", err)
slog.ErrorContext(ctx, "Error generating random string", "err", err)
return "", types.ErrInternal
}
return base64.StdEncoding.EncodeToString(bytes), nil
}
func (r *RandomImpl) UUID() (uuid.UUID, error) {
func (r *RandomImpl) UUID(ctx context.Context) (uuid.UUID, error) {
id, err := uuid.NewRandom()
if err != nil {
slog.Error("Error generating random UUID", "err", err)
slog.ErrorContext(ctx, "Error generating random UUID", "err", err)
return uuid.Nil, types.ErrInternal
}

View File

@@ -1,25 +1,29 @@
package service
import (
"context"
"errors"
"fmt"
"log/slog"
"spend-sparrow/internal/db"
"spend-sparrow/internal/types"
"strconv"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
type Transaction interface {
Add(tx *sqlx.Tx, user *types.User, transaction types.Transaction) (*types.Transaction, error)
Update(user *types.User, transaction types.Transaction) (*types.Transaction, error)
Get(user *types.User, id string) (*types.Transaction, error)
GetAll(user *types.User, filter types.TransactionItemsFilter) ([]*types.Transaction, error)
Delete(user *types.User, id string) error
const page_size = 25
RecalculateBalances(user *types.User) error
type Transaction interface {
Add(ctx context.Context, tx *sqlx.Tx, user *types.User, transaction types.Transaction) (*types.Transaction, error)
Update(ctx context.Context, user *types.User, transaction types.Transaction) (*types.Transaction, error)
Get(ctx context.Context, user *types.User, id string) (*types.Transaction, error)
GetAll(ctx context.Context, user *types.User, filter types.TransactionItemsFilter) ([]*types.Transaction, error)
Delete(ctx context.Context, user *types.User, id string) error
RecalculateBalances(ctx context.Context, user *types.User) error
}
type TransactionImpl struct {
@@ -36,7 +40,7 @@ func NewTransaction(db *sqlx.DB, random Random, clock Clock) Transaction {
}
}
func (s TransactionImpl) Add(tx *sqlx.Tx, user *types.User, transactionInput types.Transaction) (*types.Transaction, error) {
func (s TransactionImpl) Add(ctx context.Context, tx *sqlx.Tx, user *types.User, transactionInput types.Transaction) (*types.Transaction, error) {
if user == nil {
return nil, ErrUnauthorized
}
@@ -45,8 +49,8 @@ func (s TransactionImpl) Add(tx *sqlx.Tx, user *types.User, transactionInput typ
ownsTransaction := false
if tx == nil {
ownsTransaction = true
tx, err = s.db.Beginx()
err = db.TransformAndLogDbError("transaction Add", nil, err)
tx, err = s.db.BeginTxx(ctx, nil)
err = db.TransformAndLogDbError(ctx, "transaction Add", nil, err)
if err != nil {
return nil, err
}
@@ -55,38 +59,38 @@ func (s TransactionImpl) Add(tx *sqlx.Tx, user *types.User, transactionInput typ
}()
}
transaction, err := s.validateAndEnrichTransaction(tx, nil, user.Id, transactionInput)
transaction, err := s.validateAndEnrichTransaction(ctx, tx, nil, user.Id, transactionInput)
if err != nil {
return nil, err
}
r, err := tx.NamedExec(`
r, err := tx.NamedExecContext(ctx, `
INSERT INTO "transaction" (id, user_id, account_id, treasure_chest_id, value, timestamp,
party, description, error, created_at, created_by)
VALUES (:id, :user_id, :account_id, :treasure_chest_id, :value, :timestamp,
:party, :description, :error, :created_at, :created_by)`, transaction)
err = db.TransformAndLogDbError("transaction Insert", r, err)
err = db.TransformAndLogDbError(ctx, "transaction Insert", r, err)
if err != nil {
return nil, err
}
if transaction.Error == nil && transaction.AccountId != nil {
r, err = tx.Exec(`
r, err = tx.ExecContext(ctx, `
UPDATE account
SET current_balance = current_balance + ?
WHERE id = ? AND user_id = ?`, transaction.Value, transaction.AccountId, user.Id)
err = db.TransformAndLogDbError("transaction Add", r, err)
err = db.TransformAndLogDbError(ctx, "transaction Add", r, err)
if err != nil {
return nil, err
}
}
if transaction.Error == nil && transaction.TreasureChestId != nil {
r, err = tx.Exec(`
r, err = tx.ExecContext(ctx, `
UPDATE treasure_chest
SET current_balance = current_balance + ?
WHERE id = ? AND user_id = ?`, transaction.Value, transaction.TreasureChestId, user.Id)
err = db.TransformAndLogDbError("transaction Add", r, err)
err = db.TransformAndLogDbError(ctx, "transaction Add", r, err)
if err != nil {
return nil, err
}
@@ -94,7 +98,7 @@ func (s TransactionImpl) Add(tx *sqlx.Tx, user *types.User, transactionInput typ
if ownsTransaction {
err = tx.Commit()
err = db.TransformAndLogDbError("transaction Add", nil, err)
err = db.TransformAndLogDbError(ctx, "transaction Add", nil, err)
if err != nil {
return nil, err
}
@@ -103,13 +107,13 @@ func (s TransactionImpl) Add(tx *sqlx.Tx, user *types.User, transactionInput typ
return transaction, nil
}
func (s TransactionImpl) Update(user *types.User, input types.Transaction) (*types.Transaction, error) {
func (s TransactionImpl) Update(ctx context.Context, user *types.User, input types.Transaction) (*types.Transaction, error) {
if user == nil {
return nil, ErrUnauthorized
}
tx, err := s.db.Beginx()
err = db.TransformAndLogDbError("transaction Update", nil, err)
tx, err := s.db.BeginTxx(ctx, nil)
err = db.TransformAndLogDbError(ctx, "transaction Update", nil, err)
if err != nil {
return nil, err
}
@@ -118,8 +122,8 @@ func (s TransactionImpl) Update(user *types.User, input types.Transaction) (*typ
}()
transaction := &types.Transaction{}
err = tx.Get(transaction, `SELECT * FROM "transaction" WHERE user_id = ? AND id = ?`, user.Id, input.Id)
err = db.TransformAndLogDbError("transaction Update", nil, err)
err = tx.GetContext(ctx, transaction, `SELECT * FROM "transaction" WHERE user_id = ? AND id = ?`, user.Id, input.Id)
err = db.TransformAndLogDbError(ctx, "transaction Update", nil, err)
if err != nil {
if errors.Is(err, db.ErrNotFound) {
return nil, fmt.Errorf("transaction %v not found: %w", input.Id, ErrBadRequest)
@@ -128,53 +132,53 @@ func (s TransactionImpl) Update(user *types.User, input types.Transaction) (*typ
}
if transaction.Error == nil && transaction.AccountId != nil {
r, err := tx.Exec(`
r, err := tx.ExecContext(ctx, `
UPDATE account
SET current_balance = current_balance - ?
WHERE id = ? AND user_id = ?`, transaction.Value, transaction.AccountId, user.Id)
err = db.TransformAndLogDbError("transaction Update", r, err)
err = db.TransformAndLogDbError(ctx, "transaction Update", r, err)
if err != nil {
return nil, err
}
}
if transaction.Error == nil && transaction.TreasureChestId != nil {
r, err := tx.Exec(`
r, err := tx.ExecContext(ctx, `
UPDATE treasure_chest
SET current_balance = current_balance - ?
WHERE id = ? AND user_id = ?`, transaction.Value, transaction.TreasureChestId, user.Id)
err = db.TransformAndLogDbError("transaction Update", r, err)
err = db.TransformAndLogDbError(ctx, "transaction Update", r, err)
if err != nil {
return nil, err
}
}
transaction, err = s.validateAndEnrichTransaction(tx, transaction, user.Id, input)
transaction, err = s.validateAndEnrichTransaction(ctx, tx, transaction, user.Id, input)
if err != nil {
return nil, err
}
if transaction.Error == nil && transaction.AccountId != nil {
r, err := tx.Exec(`
r, err := tx.ExecContext(ctx, `
UPDATE account
SET current_balance = current_balance + ?
WHERE id = ? AND user_id = ?`, transaction.Value, transaction.AccountId, user.Id)
err = db.TransformAndLogDbError("transaction Update", r, err)
err = db.TransformAndLogDbError(ctx, "transaction Update", r, err)
if err != nil {
return nil, err
}
}
if transaction.Error == nil && transaction.TreasureChestId != nil {
r, err := tx.Exec(`
r, err := tx.ExecContext(ctx, `
UPDATE treasure_chest
SET current_balance = current_balance + ?
WHERE id = ? AND user_id = ?`, transaction.Value, transaction.TreasureChestId, user.Id)
err = db.TransformAndLogDbError("transaction Update", r, err)
err = db.TransformAndLogDbError(ctx, "transaction Update", r, err)
if err != nil {
return nil, err
}
}
r, err := tx.NamedExec(`
r, err := tx.NamedExecContext(ctx, `
UPDATE "transaction"
SET
account_id = :account_id,
@@ -188,13 +192,13 @@ func (s TransactionImpl) Update(user *types.User, input types.Transaction) (*typ
updated_by = :updated_by
WHERE id = :id
AND user_id = :user_id`, transaction)
err = db.TransformAndLogDbError("transaction Update", r, err)
err = db.TransformAndLogDbError(ctx, "transaction Update", r, err)
if err != nil {
return nil, err
}
err = tx.Commit()
err = db.TransformAndLogDbError("transaction Update", nil, err)
err = db.TransformAndLogDbError(ctx, "transaction Update", nil, err)
if err != nil {
return nil, err
}
@@ -202,19 +206,19 @@ func (s TransactionImpl) Update(user *types.User, input types.Transaction) (*typ
return transaction, nil
}
func (s TransactionImpl) Get(user *types.User, id string) (*types.Transaction, error) {
func (s TransactionImpl) Get(ctx context.Context, user *types.User, id string) (*types.Transaction, error) {
if user == nil {
return nil, ErrUnauthorized
}
uuid, err := uuid.Parse(id)
if err != nil {
slog.Error("transaction get", "err", err)
slog.ErrorContext(ctx, "transaction get", "err", err)
return nil, fmt.Errorf("could not parse Id: %w", ErrBadRequest)
}
var transaction types.Transaction
err = s.db.Get(&transaction, `SELECT * FROM "transaction" WHERE user_id = ? AND id = ?`, user.Id, uuid)
err = db.TransformAndLogDbError("transaction Get", nil, err)
err = s.db.GetContext(ctx, &transaction, `SELECT * FROM "transaction" WHERE user_id = ? AND id = ?`, user.Id, uuid)
err = db.TransformAndLogDbError(ctx, "transaction Get", nil, err)
if err != nil {
if errors.Is(err, db.ErrNotFound) {
return nil, fmt.Errorf("transaction %v not found: %w", id, ErrBadRequest)
@@ -225,28 +229,47 @@ func (s TransactionImpl) Get(user *types.User, id string) (*types.Transaction, e
return &transaction, nil
}
func (s TransactionImpl) GetAll(user *types.User, filter types.TransactionItemsFilter) ([]*types.Transaction, error) {
func (s TransactionImpl) GetAll(ctx context.Context, user *types.User, filter types.TransactionItemsFilter) ([]*types.Transaction, error) {
if user == nil {
return nil, ErrUnauthorized
}
var (
page int64
offset int64
err error
)
if filter.Page != "" {
page, err = strconv.ParseInt(filter.Page, 10, 64)
if err != nil {
offset = 0
} else {
offset = page - 1
offset *= page_size
}
}
transactions := make([]*types.Transaction, 0)
err := s.db.Select(&transactions, `
err = s.db.SelectContext(ctx, &transactions, `
SELECT *
FROM "transaction"
WHERE user_id = ?
AND (? = '' OR account_id = ?)
AND (? = '' OR treasure_chest_id = ?)
AND (? = ''
OR (? = "true" AND error IS NOT NULL)
OR (? = "false" AND error IS NULL)
AND ($1 = '' OR account_id = $1)
AND ($2 = '' OR treasure_chest_id = $2)
AND ($3 = ''
OR ($3 = "true" AND error IS NOT NULL)
OR ($3 = "false" AND error IS NULL)
)
ORDER BY timestamp DESC, created_at DESC`,
ORDER BY timestamp DESC, created_at DESC
LIMIT $4 OFFSET $5
`,
user.Id,
filter.AccountId, filter.AccountId,
filter.TreasureChestId, filter.TreasureChestId,
filter.Error, filter.Error, filter.Error)
err = db.TransformAndLogDbError("transaction GetAll", nil, err)
filter.AccountId,
filter.TreasureChestId,
filter.Error,
page_size,
offset)
err = db.TransformAndLogDbError(ctx, "transaction GetAll", nil, err)
if err != nil {
return nil, err
}
@@ -254,18 +277,18 @@ func (s TransactionImpl) GetAll(user *types.User, filter types.TransactionItemsF
return transactions, nil
}
func (s TransactionImpl) Delete(user *types.User, id string) error {
func (s TransactionImpl) Delete(ctx context.Context, user *types.User, id string) error {
if user == nil {
return ErrUnauthorized
}
uuid, err := uuid.Parse(id)
if err != nil {
slog.Error("transaction delete", "err", err)
slog.ErrorContext(ctx, "transaction delete", "err", err)
return fmt.Errorf("could not parse Id: %w", ErrBadRequest)
}
tx, err := s.db.Beginx()
err = db.TransformAndLogDbError("transaction Delete", nil, err)
tx, err := s.db.BeginTxx(ctx, nil)
err = db.TransformAndLogDbError(ctx, "transaction Delete", nil, err)
if err != nil {
return nil
}
@@ -274,44 +297,44 @@ func (s TransactionImpl) Delete(user *types.User, id string) error {
}()
var transaction types.Transaction
err = tx.Get(&transaction, `SELECT * FROM "transaction" WHERE user_id = ? AND id = ?`, user.Id, uuid)
err = db.TransformAndLogDbError("transaction Delete", nil, err)
err = tx.GetContext(ctx, &transaction, `SELECT * FROM "transaction" WHERE user_id = ? AND id = ?`, user.Id, uuid)
err = db.TransformAndLogDbError(ctx, "transaction Delete", nil, err)
if err != nil {
return err
}
if transaction.Error == nil && transaction.AccountId != nil {
r, err := tx.Exec(`
r, err := tx.ExecContext(ctx, `
UPDATE account
SET current_balance = current_balance - ?
WHERE id = ?
AND user_id = ?`, transaction.Value, transaction.AccountId, user.Id)
err = db.TransformAndLogDbError("transaction Delete", r, err)
err = db.TransformAndLogDbError(ctx, "transaction Delete", r, err)
if err != nil && !errors.Is(err, db.ErrNotFound) {
return err
}
}
if transaction.Error == nil && transaction.TreasureChestId != nil {
r, err := tx.Exec(`
r, err := tx.ExecContext(ctx, `
UPDATE treasure_chest
SET current_balance = current_balance - ?
WHERE id = ?
AND user_id = ?`, transaction.Value, transaction.TreasureChestId, user.Id)
err = db.TransformAndLogDbError("transaction Delete", r, err)
err = db.TransformAndLogDbError(ctx, "transaction Delete", r, err)
if err != nil && !errors.Is(err, db.ErrNotFound) {
return err
}
}
r, err := tx.Exec("DELETE FROM \"transaction\" WHERE id = ? AND user_id = ?", uuid, user.Id)
err = db.TransformAndLogDbError("transaction Delete", r, err)
r, err := tx.ExecContext(ctx, "DELETE FROM \"transaction\" WHERE id = ? AND user_id = ?", uuid, user.Id)
err = db.TransformAndLogDbError(ctx, "transaction Delete", r, err)
if err != nil {
return err
}
err = tx.Commit()
err = db.TransformAndLogDbError("transaction Delete", nil, err)
err = db.TransformAndLogDbError(ctx, "transaction Delete", nil, err)
if err != nil {
return err
}
@@ -319,13 +342,13 @@ func (s TransactionImpl) Delete(user *types.User, id string) error {
return nil
}
func (s TransactionImpl) RecalculateBalances(user *types.User) error {
func (s TransactionImpl) RecalculateBalances(ctx context.Context, user *types.User) error {
if user == nil {
return ErrUnauthorized
}
tx, err := s.db.Beginx()
err = db.TransformAndLogDbError("transaction RecalculateBalances", nil, err)
tx, err := s.db.BeginTxx(ctx, nil)
err = db.TransformAndLogDbError(ctx, "transaction RecalculateBalances", nil, err)
if err != nil {
return err
}
@@ -333,54 +356,54 @@ func (s TransactionImpl) RecalculateBalances(user *types.User) error {
_ = tx.Rollback()
}()
r, err := tx.Exec(`
r, err := tx.ExecContext(ctx, `
UPDATE account
SET current_balance = 0
WHERE user_id = ?`, user.Id)
err = db.TransformAndLogDbError("transaction RecalculateBalances", r, err)
err = db.TransformAndLogDbError(ctx, "transaction RecalculateBalances", r, err)
if err != nil && !errors.Is(err, db.ErrNotFound) {
return err
}
r, err = tx.Exec(`
r, err = tx.ExecContext(ctx, `
UPDATE treasure_chest
SET current_balance = 0
WHERE user_id = ?`, user.Id)
err = db.TransformAndLogDbError("transaction RecalculateBalances", r, err)
err = db.TransformAndLogDbError(ctx, "transaction RecalculateBalances", r, err)
if err != nil && !errors.Is(err, db.ErrNotFound) {
return err
}
rows, err := tx.Queryx(`
rows, err := tx.QueryxContext(ctx, `
SELECT *
FROM "transaction"
WHERE user_id = ?`, user.Id)
err = db.TransformAndLogDbError("transaction RecalculateBalances", nil, err)
err = db.TransformAndLogDbError(ctx, "transaction RecalculateBalances", nil, err)
if err != nil && !errors.Is(err, db.ErrNotFound) {
return err
}
defer func() {
err := rows.Close()
if err != nil {
slog.Error("transaction RecalculateBalances", "err", err)
slog.ErrorContext(ctx, "transaction RecalculateBalances", "err", err)
}
}()
var transaction types.Transaction
for rows.Next() {
err = rows.StructScan(&transaction)
err = db.TransformAndLogDbError("transaction RecalculateBalances", nil, err)
err = db.TransformAndLogDbError(ctx, "transaction RecalculateBalances", nil, err)
if err != nil {
return err
}
s.updateErrors(&transaction)
r, err = tx.Exec(`
r, err = tx.ExecContext(ctx, `
UPDATE "transaction"
SET error = ?
WHERE user_id = ?
AND id = ?`, transaction.Error, user.Id, transaction.Id)
err = db.TransformAndLogDbError("transaction RecalculateBalances", r, err)
err = db.TransformAndLogDbError(ctx, "transaction RecalculateBalances", r, err)
if err != nil {
return err
}
@@ -390,21 +413,21 @@ func (s TransactionImpl) RecalculateBalances(user *types.User) error {
}
if transaction.AccountId != nil {
r, err = tx.Exec(`
r, err = tx.ExecContext(ctx, `
UPDATE account
SET current_balance = current_balance + ?
WHERE id = ? AND user_id = ?`, transaction.Value, transaction.AccountId, user.Id)
err = db.TransformAndLogDbError("transaction RecalculateBalances", r, err)
err = db.TransformAndLogDbError(ctx, "transaction RecalculateBalances", r, err)
if err != nil {
return err
}
}
if transaction.TreasureChestId != nil {
r, err = tx.Exec(`
r, err = tx.ExecContext(ctx, `
UPDATE treasure_chest
SET current_balance = current_balance + ?
WHERE id = ? AND user_id = ?`, transaction.Value, transaction.TreasureChestId, user.Id)
err = db.TransformAndLogDbError("transaction RecalculateBalances", r, err)
err = db.TransformAndLogDbError(ctx, "transaction RecalculateBalances", r, err)
if err != nil {
return err
}
@@ -412,7 +435,7 @@ func (s TransactionImpl) RecalculateBalances(user *types.User) error {
}
err = tx.Commit()
err = db.TransformAndLogDbError("transaction RecalculateBalances", nil, err)
err = db.TransformAndLogDbError(ctx, "transaction RecalculateBalances", nil, err)
if err != nil {
return err
}
@@ -420,7 +443,7 @@ func (s TransactionImpl) RecalculateBalances(user *types.User) error {
return nil
}
func (s TransactionImpl) validateAndEnrichTransaction(tx *sqlx.Tx, oldTransaction *types.Transaction, userId uuid.UUID, input types.Transaction) (*types.Transaction, error) {
func (s TransactionImpl) validateAndEnrichTransaction(ctx context.Context, tx *sqlx.Tx, oldTransaction *types.Transaction, userId uuid.UUID, input types.Transaction) (*types.Transaction, error) {
var (
id uuid.UUID
createdAt time.Time
@@ -433,7 +456,7 @@ func (s TransactionImpl) validateAndEnrichTransaction(tx *sqlx.Tx, oldTransactio
)
if oldTransaction == nil {
id, err = s.random.UUID()
id, err = s.random.UUID(ctx)
if err != nil {
return nil, types.ErrInternal
}
@@ -449,21 +472,21 @@ func (s TransactionImpl) validateAndEnrichTransaction(tx *sqlx.Tx, oldTransactio
}
if input.AccountId != nil {
err = tx.Get(&rowCount, `SELECT COUNT(*) FROM account WHERE id = ? AND user_id = ?`, input.AccountId, userId)
err = db.TransformAndLogDbError("transaction validate", nil, err)
err = tx.GetContext(ctx, &rowCount, `SELECT COUNT(*) FROM account WHERE id = ? AND user_id = ?`, input.AccountId, userId)
err = db.TransformAndLogDbError(ctx, "transaction validate", nil, err)
if err != nil {
return nil, err
}
if rowCount == 0 {
slog.Error("transaction validate", "err", err)
slog.ErrorContext(ctx, "transaction validate", "err", err)
return nil, fmt.Errorf("account not found: %w", ErrBadRequest)
}
}
if input.TreasureChestId != nil {
var treasureChest types.TreasureChest
err = tx.Get(&treasureChest, `SELECT * FROM treasure_chest WHERE id = ? AND user_id = ?`, input.TreasureChestId, userId)
err = db.TransformAndLogDbError("transaction validate", nil, err)
err = tx.GetContext(ctx, &treasureChest, `SELECT * FROM treasure_chest WHERE id = ? AND user_id = ?`, input.TreasureChestId, userId)
err = db.TransformAndLogDbError(ctx, "transaction validate", nil, err)
if err != nil {
if errors.Is(err, db.ErrNotFound) {
return nil, fmt.Errorf("treasure chest not found: %w", ErrBadRequest)
@@ -511,27 +534,20 @@ func (s TransactionImpl) validateAndEnrichTransaction(tx *sqlx.Tx, oldTransactio
return &transaction, nil
}
func (s TransactionImpl) updateErrors(transaction *types.Transaction) {
func (s TransactionImpl) updateErrors(t *types.Transaction) {
errorStr := ""
switch {
case transaction.Value < 0:
if transaction.TreasureChestId == nil {
errorStr = "no treasure chest specified"
}
case transaction.Value > 0:
if transaction.AccountId == nil && transaction.TreasureChestId == nil {
case (t.AccountId != nil && t.TreasureChestId != nil && t.Value > 0) ||
(t.AccountId == nil && t.TreasureChestId == nil):
errorStr = "either an account or a treasure chest needs to be specified"
} else if transaction.AccountId != nil && transaction.TreasureChestId != nil {
errorStr = "positive amounts can only be applied to either an account or a treasure chest"
}
default:
case t.Value == 0:
errorStr = "\"value\" needs to be specified"
}
if errorStr == "" {
transaction.Error = nil
t.Error = nil
} else {
transaction.Error = &errorStr
t.Error = &errorStr
}
}

View File

@@ -1,6 +1,7 @@
package service
import (
"context"
"errors"
"fmt"
"log/slog"
@@ -15,14 +16,14 @@ import (
)
type TransactionRecurring interface {
Add(user *types.User, transactionRecurring types.TransactionRecurringInput) (*types.TransactionRecurring, error)
Update(user *types.User, transactionRecurring types.TransactionRecurringInput) (*types.TransactionRecurring, error)
GetAll(user *types.User) ([]*types.TransactionRecurring, error)
GetAllByAccount(user *types.User, accountId string) ([]*types.TransactionRecurring, error)
GetAllByTreasureChest(user *types.User, treasureChestId string) ([]*types.TransactionRecurring, error)
Delete(user *types.User, id string) error
Add(ctx context.Context, user *types.User, transactionRecurring types.TransactionRecurringInput) (*types.TransactionRecurring, error)
Update(ctx context.Context, user *types.User, transactionRecurring types.TransactionRecurringInput) (*types.TransactionRecurring, error)
GetAll(ctx context.Context, user *types.User) ([]*types.TransactionRecurring, error)
GetAllByAccount(ctx context.Context, user *types.User, accountId string) ([]*types.TransactionRecurring, error)
GetAllByTreasureChest(ctx context.Context, user *types.User, treasureChestId string) ([]*types.TransactionRecurring, error)
Delete(ctx context.Context, user *types.User, id string) error
GenerateTransactions(user *types.User) error
GenerateTransactions(ctx context.Context) error
}
type TransactionRecurringImpl struct {
@@ -41,7 +42,7 @@ func NewTransactionRecurring(db *sqlx.DB, random Random, clock Clock, transactio
}
}
func (s TransactionRecurringImpl) Add(
func (s TransactionRecurringImpl) Add(ctx context.Context,
user *types.User,
transactionRecurringInput types.TransactionRecurringInput,
) (*types.TransactionRecurring, error) {
@@ -49,8 +50,8 @@ func (s TransactionRecurringImpl) Add(
return nil, ErrUnauthorized
}
tx, err := s.db.Beginx()
err = db.TransformAndLogDbError("transactionRecurring Add", nil, err)
tx, err := s.db.BeginTxx(ctx, nil)
err = db.TransformAndLogDbError(ctx, "transactionRecurring Add", nil, err)
if err != nil {
return nil, err
}
@@ -58,24 +59,24 @@ func (s TransactionRecurringImpl) Add(
_ = tx.Rollback()
}()
transactionRecurring, err := s.validateAndEnrichTransactionRecurring(tx, nil, user.Id, transactionRecurringInput)
transactionRecurring, err := s.validateAndEnrichTransactionRecurring(ctx, tx, nil, user.Id, transactionRecurringInput)
if err != nil {
return nil, err
}
r, err := tx.NamedExec(`
r, err := tx.NamedExecContext(ctx, `
INSERT INTO "transaction_recurring" (id, user_id, interval_months,
next_execution, party, description, account_id, treasure_chest_id, value, created_at, created_by)
VALUES (:id, :user_id, :interval_months,
:next_execution, :party, :description, :account_id, :treasure_chest_id, :value, :created_at, :created_by)`,
transactionRecurring)
err = db.TransformAndLogDbError("transactionRecurring Insert", r, err)
err = db.TransformAndLogDbError(ctx, "transactionRecurring Insert", r, err)
if err != nil {
return nil, err
}
err = tx.Commit()
err = db.TransformAndLogDbError("transactionRecurring Add", nil, err)
err = db.TransformAndLogDbError(ctx, "transactionRecurring Add", nil, err)
if err != nil {
return nil, err
}
@@ -83,7 +84,7 @@ func (s TransactionRecurringImpl) Add(
return transactionRecurring, nil
}
func (s TransactionRecurringImpl) Update(
func (s TransactionRecurringImpl) Update(ctx context.Context,
user *types.User,
input types.TransactionRecurringInput,
) (*types.TransactionRecurring, error) {
@@ -92,12 +93,12 @@ func (s TransactionRecurringImpl) Update(
}
uuid, err := uuid.Parse(input.Id)
if err != nil {
slog.Error("transactionRecurring update", "err", err)
slog.ErrorContext(ctx, "transactionRecurring update", "err", err)
return nil, fmt.Errorf("could not parse Id: %w", ErrBadRequest)
}
tx, err := s.db.Beginx()
err = db.TransformAndLogDbError("transactionRecurring Update", nil, err)
tx, err := s.db.BeginTxx(ctx, nil)
err = db.TransformAndLogDbError(ctx, "transactionRecurring Update", nil, err)
if err != nil {
return nil, err
}
@@ -106,8 +107,8 @@ func (s TransactionRecurringImpl) Update(
}()
transactionRecurring := &types.TransactionRecurring{}
err = tx.Get(transactionRecurring, `SELECT * FROM transaction_recurring WHERE user_id = ? AND id = ?`, user.Id, uuid)
err = db.TransformAndLogDbError("transactionRecurring Update", nil, err)
err = tx.GetContext(ctx, transactionRecurring, `SELECT * FROM transaction_recurring WHERE user_id = ? AND id = ?`, user.Id, uuid)
err = db.TransformAndLogDbError(ctx, "transactionRecurring Update", nil, err)
if err != nil {
if errors.Is(err, db.ErrNotFound) {
return nil, fmt.Errorf("transactionRecurring %v not found: %w", input.Id, ErrBadRequest)
@@ -115,12 +116,12 @@ func (s TransactionRecurringImpl) Update(
return nil, types.ErrInternal
}
transactionRecurring, err = s.validateAndEnrichTransactionRecurring(tx, transactionRecurring, user.Id, input)
transactionRecurring, err = s.validateAndEnrichTransactionRecurring(ctx, tx, transactionRecurring, user.Id, input)
if err != nil {
return nil, err
}
r, err := tx.NamedExec(`
r, err := tx.NamedExecContext(ctx, `
UPDATE transaction_recurring
SET
interval_months = :interval_months,
@@ -134,13 +135,13 @@ func (s TransactionRecurringImpl) Update(
updated_by = :updated_by
WHERE id = :id
AND user_id = :user_id`, transactionRecurring)
err = db.TransformAndLogDbError("transactionRecurring Update", r, err)
err = db.TransformAndLogDbError(ctx, "transactionRecurring Update", r, err)
if err != nil {
return nil, err
}
err = tx.Commit()
err = db.TransformAndLogDbError("transactionRecurring Update", nil, err)
err = db.TransformAndLogDbError(ctx, "transactionRecurring Update", nil, err)
if err != nil {
return nil, err
}
@@ -148,19 +149,19 @@ func (s TransactionRecurringImpl) Update(
return transactionRecurring, nil
}
func (s TransactionRecurringImpl) GetAll(user *types.User) ([]*types.TransactionRecurring, error) {
func (s TransactionRecurringImpl) GetAll(ctx context.Context, user *types.User) ([]*types.TransactionRecurring, error) {
if user == nil {
return nil, ErrUnauthorized
}
transactionRecurrings := make([]*types.TransactionRecurring, 0)
err := s.db.Select(&transactionRecurrings, `
err := s.db.SelectContext(ctx, &transactionRecurrings, `
SELECT *
FROM transaction_recurring
WHERE user_id = ?
ORDER BY created_at DESC`,
user.Id)
err = db.TransformAndLogDbError("transactionRecurring GetAll", nil, err)
err = db.TransformAndLogDbError(ctx, "transactionRecurring GetAll", nil, err)
if err != nil {
return nil, err
}
@@ -168,19 +169,19 @@ func (s TransactionRecurringImpl) GetAll(user *types.User) ([]*types.Transaction
return transactionRecurrings, nil
}
func (s TransactionRecurringImpl) GetAllByAccount(user *types.User, accountId string) ([]*types.TransactionRecurring, error) {
func (s TransactionRecurringImpl) GetAllByAccount(ctx context.Context, user *types.User, accountId string) ([]*types.TransactionRecurring, error) {
if user == nil {
return nil, ErrUnauthorized
}
accountUuid, err := uuid.Parse(accountId)
if err != nil {
slog.Error("transactionRecurring GetAllByAccount", "err", err)
slog.ErrorContext(ctx, "transactionRecurring GetAllByAccount", "err", err)
return nil, fmt.Errorf("could not parse accountId: %w", ErrBadRequest)
}
tx, err := s.db.Beginx()
err = db.TransformAndLogDbError("transactionRecurring GetAllByAccount", nil, err)
tx, err := s.db.BeginTxx(ctx, nil)
err = db.TransformAndLogDbError(ctx, "transactionRecurring GetAllByAccount", nil, err)
if err != nil {
return nil, err
}
@@ -189,8 +190,8 @@ func (s TransactionRecurringImpl) GetAllByAccount(user *types.User, accountId st
}()
var rowCount int
err = tx.Get(&rowCount, `SELECT COUNT(*) FROM account WHERE id = ? AND user_id = ?`, accountUuid, user.Id)
err = db.TransformAndLogDbError("transactionRecurring GetAllByAccount", nil, err)
err = tx.GetContext(ctx, &rowCount, `SELECT COUNT(*) FROM account WHERE id = ? AND user_id = ?`, accountUuid, user.Id)
err = db.TransformAndLogDbError(ctx, "transactionRecurring GetAllByAccount", nil, err)
if err != nil {
if errors.Is(err, db.ErrNotFound) {
return nil, fmt.Errorf("account %v not found: %w", accountId, ErrBadRequest)
@@ -199,20 +200,20 @@ func (s TransactionRecurringImpl) GetAllByAccount(user *types.User, accountId st
}
transactionRecurrings := make([]*types.TransactionRecurring, 0)
err = tx.Select(&transactionRecurrings, `
err = tx.SelectContext(ctx, &transactionRecurrings, `
SELECT *
FROM transaction_recurring
WHERE user_id = ?
AND account_id = ?
ORDER BY created_at DESC`,
user.Id, accountUuid)
err = db.TransformAndLogDbError("transactionRecurring GetAll", nil, err)
err = db.TransformAndLogDbError(ctx, "transactionRecurring GetAll", nil, err)
if err != nil {
return nil, err
}
err = tx.Commit()
err = db.TransformAndLogDbError("transactionRecurring GetAllByAccount", nil, err)
err = db.TransformAndLogDbError(ctx, "transactionRecurring GetAllByAccount", nil, err)
if err != nil {
return nil, err
}
@@ -220,7 +221,7 @@ func (s TransactionRecurringImpl) GetAllByAccount(user *types.User, accountId st
return transactionRecurrings, nil
}
func (s TransactionRecurringImpl) GetAllByTreasureChest(
func (s TransactionRecurringImpl) GetAllByTreasureChest(ctx context.Context,
user *types.User,
treasureChestId string,
) ([]*types.TransactionRecurring, error) {
@@ -230,12 +231,12 @@ func (s TransactionRecurringImpl) GetAllByTreasureChest(
treasureChestUuid, err := uuid.Parse(treasureChestId)
if err != nil {
slog.Error("transactionRecurring GetAllByTreasureChest", "err", err)
slog.ErrorContext(ctx, "transactionRecurring GetAllByTreasureChest", "err", err)
return nil, fmt.Errorf("could not parse treasureChestId: %w", ErrBadRequest)
}
tx, err := s.db.Beginx()
err = db.TransformAndLogDbError("transactionRecurring GetAllByTreasureChest", nil, err)
tx, err := s.db.BeginTxx(ctx, nil)
err = db.TransformAndLogDbError(ctx, "transactionRecurring GetAllByTreasureChest", nil, err)
if err != nil {
return nil, err
}
@@ -244,8 +245,8 @@ func (s TransactionRecurringImpl) GetAllByTreasureChest(
}()
var rowCount int
err = tx.Get(&rowCount, `SELECT COUNT(*) FROM treasure_chest WHERE id = ? AND user_id = ?`, treasureChestId, user.Id)
err = db.TransformAndLogDbError("transactionRecurring GetAllByTreasureChest", nil, err)
err = tx.GetContext(ctx, &rowCount, `SELECT COUNT(*) FROM treasure_chest WHERE id = ? AND user_id = ?`, treasureChestId, user.Id)
err = db.TransformAndLogDbError(ctx, "transactionRecurring GetAllByTreasureChest", nil, err)
if err != nil {
if errors.Is(err, db.ErrNotFound) {
return nil, fmt.Errorf("treasurechest %v not found: %w", treasureChestId, ErrBadRequest)
@@ -254,20 +255,20 @@ func (s TransactionRecurringImpl) GetAllByTreasureChest(
}
transactionRecurrings := make([]*types.TransactionRecurring, 0)
err = tx.Select(&transactionRecurrings, `
err = tx.SelectContext(ctx, &transactionRecurrings, `
SELECT *
FROM transaction_recurring
WHERE user_id = ?
AND treasure_chest_id = ?
ORDER BY created_at DESC`,
user.Id, treasureChestUuid)
err = db.TransformAndLogDbError("transactionRecurring GetAll", nil, err)
err = db.TransformAndLogDbError(ctx, "transactionRecurring GetAll", nil, err)
if err != nil {
return nil, err
}
err = tx.Commit()
err = db.TransformAndLogDbError("transactionRecurring GetAllByTreasureChest", nil, err)
err = db.TransformAndLogDbError(ctx, "transactionRecurring GetAllByTreasureChest", nil, err)
if err != nil {
return nil, err
}
@@ -275,18 +276,18 @@ func (s TransactionRecurringImpl) GetAllByTreasureChest(
return transactionRecurrings, nil
}
func (s TransactionRecurringImpl) Delete(user *types.User, id string) error {
func (s TransactionRecurringImpl) Delete(ctx context.Context, user *types.User, id string) error {
if user == nil {
return ErrUnauthorized
}
uuid, err := uuid.Parse(id)
if err != nil {
slog.Error("transactionRecurring delete", "err", err)
slog.ErrorContext(ctx, "transactionRecurring delete", "err", err)
return fmt.Errorf("could not parse Id: %w", ErrBadRequest)
}
tx, err := s.db.Beginx()
err = db.TransformAndLogDbError("transactionRecurring Delete", nil, err)
tx, err := s.db.BeginTxx(ctx, nil)
err = db.TransformAndLogDbError(ctx, "transactionRecurring Delete", nil, err)
if err != nil {
return nil
}
@@ -295,20 +296,20 @@ func (s TransactionRecurringImpl) Delete(user *types.User, id string) error {
}()
var transactionRecurring types.TransactionRecurring
err = tx.Get(&transactionRecurring, `SELECT * FROM transaction_recurring WHERE user_id = ? AND id = ?`, user.Id, uuid)
err = db.TransformAndLogDbError("transactionRecurring Delete", nil, err)
err = tx.GetContext(ctx, &transactionRecurring, `SELECT * FROM transaction_recurring WHERE user_id = ? AND id = ?`, user.Id, uuid)
err = db.TransformAndLogDbError(ctx, "transactionRecurring Delete", nil, err)
if err != nil {
return err
}
r, err := tx.Exec("DELETE FROM transaction_recurring WHERE id = ? AND user_id = ?", uuid, user.Id)
err = db.TransformAndLogDbError("transactionRecurring Delete", r, err)
r, err := tx.ExecContext(ctx, "DELETE FROM transaction_recurring WHERE id = ? AND user_id = ?", uuid, user.Id)
err = db.TransformAndLogDbError(ctx, "transactionRecurring Delete", r, err)
if err != nil {
return err
}
err = tx.Commit()
err = db.TransformAndLogDbError("transactionRecurring Delete", nil, err)
err = db.TransformAndLogDbError(ctx, "transactionRecurring Delete", nil, err)
if err != nil {
return err
}
@@ -316,14 +317,11 @@ func (s TransactionRecurringImpl) Delete(user *types.User, id string) error {
return nil
}
func (s TransactionRecurringImpl) GenerateTransactions(user *types.User) error {
if user == nil {
return ErrUnauthorized
}
func (s TransactionRecurringImpl) GenerateTransactions(ctx context.Context) error {
now := s.clock.Now()
tx, err := s.db.Beginx()
err = db.TransformAndLogDbError("transactionRecurring GenerateTransactions", nil, err)
tx, err := s.db.BeginTxx(ctx, nil)
err = db.TransformAndLogDbError(ctx, "transactionRecurring GenerateTransactions", nil, err)
if err != nil {
return err
}
@@ -332,15 +330,18 @@ func (s TransactionRecurringImpl) GenerateTransactions(user *types.User) error {
}()
recurringTransactions := make([]*types.TransactionRecurring, 0)
err = tx.Select(&recurringTransactions, `
SELECT * FROM transaction_recurring WHERE user_id = ? AND next_execution <= ?`,
user.Id, now)
err = db.TransformAndLogDbError("transactionRecurring GenerateTransactions", nil, err)
err = tx.SelectContext(ctx, &recurringTransactions, `
SELECT * FROM transaction_recurring WHERE next_execution <= ?`,
now)
err = db.TransformAndLogDbError(ctx, "transactionRecurring GenerateTransactions", nil, err)
if err != nil {
return err
}
for _, transactionRecurring := range recurringTransactions {
user := &types.User{
Id: transactionRecurring.UserId,
}
transaction := types.Transaction{
Timestamp: *transactionRecurring.NextExecution,
Party: transactionRecurring.Party,
@@ -350,22 +351,22 @@ func (s TransactionRecurringImpl) GenerateTransactions(user *types.User) error {
Value: transactionRecurring.Value,
}
_, err = s.transaction.Add(tx, user, transaction)
_, err = s.transaction.Add(ctx, tx, user, transaction)
if err != nil {
return err
}
nextExecution := transactionRecurring.NextExecution.AddDate(0, int(transactionRecurring.IntervalMonths), 0)
r, err := tx.Exec(`UPDATE transaction_recurring SET next_execution = ? WHERE id = ? AND user_id = ?`,
r, err := tx.ExecContext(ctx, `UPDATE transaction_recurring SET next_execution = ? WHERE id = ? AND user_id = ?`,
nextExecution, transactionRecurring.Id, user.Id)
err = db.TransformAndLogDbError("transactionRecurring GenerateTransactions", r, err)
err = db.TransformAndLogDbError(ctx, "transactionRecurring GenerateTransactions", r, err)
if err != nil {
return err
}
}
err = tx.Commit()
err = db.TransformAndLogDbError("transactionRecurring GenerateTransactions", nil, err)
err = db.TransformAndLogDbError(ctx, "transactionRecurring GenerateTransactions", nil, err)
if err != nil {
return err
}
@@ -373,6 +374,7 @@ func (s TransactionRecurringImpl) GenerateTransactions(user *types.User) error {
}
func (s TransactionRecurringImpl) validateAndEnrichTransactionRecurring(
ctx context.Context,
tx *sqlx.Tx,
oldTransactionRecurring *types.TransactionRecurring,
userId uuid.UUID,
@@ -393,7 +395,7 @@ func (s TransactionRecurringImpl) validateAndEnrichTransactionRecurring(
)
if oldTransactionRecurring == nil {
id, err = s.random.UUID()
id, err = s.random.UUID(ctx)
if err != nil {
return nil, types.ErrInternal
}
@@ -413,17 +415,17 @@ func (s TransactionRecurringImpl) validateAndEnrichTransactionRecurring(
if input.AccountId != "" {
temp, err := uuid.Parse(input.AccountId)
if err != nil {
slog.Error("transactionRecurring validate", "err", err)
slog.ErrorContext(ctx, "transactionRecurring validate", "err", err)
return nil, fmt.Errorf("could not parse accountId: %w", ErrBadRequest)
}
accountUuid = &temp
err = tx.Get(&rowCount, `SELECT COUNT(*) FROM account WHERE id = ? AND user_id = ?`, accountUuid, userId)
err = db.TransformAndLogDbError("transactionRecurring validate", nil, err)
err = tx.GetContext(ctx, &rowCount, `SELECT COUNT(*) FROM account WHERE id = ? AND user_id = ?`, accountUuid, userId)
err = db.TransformAndLogDbError(ctx, "transactionRecurring validate", nil, err)
if err != nil {
return nil, err
}
if rowCount == 0 {
slog.Error("transactionRecurring validate", "err", err)
slog.ErrorContext(ctx, "transactionRecurring validate", "err", err)
return nil, fmt.Errorf("account not found: %w", ErrBadRequest)
}
@@ -433,13 +435,13 @@ func (s TransactionRecurringImpl) validateAndEnrichTransactionRecurring(
if input.TreasureChestId != "" {
temp, err := uuid.Parse(input.TreasureChestId)
if err != nil {
slog.Error("transactionRecurring validate", "err", err)
slog.ErrorContext(ctx, "transactionRecurring validate", "err", err)
return nil, fmt.Errorf("could not parse treasureChestId: %w", ErrBadRequest)
}
treasureChestUuid = &temp
var treasureChest types.TreasureChest
err = tx.Get(&treasureChest, `SELECT * FROM treasure_chest WHERE id = ? AND user_id = ?`, treasureChestUuid, userId)
err = db.TransformAndLogDbError("transactionRecurring validate", nil, err)
err = tx.GetContext(ctx, &treasureChest, `SELECT * FROM treasure_chest WHERE id = ? AND user_id = ?`, treasureChestUuid, userId)
err = db.TransformAndLogDbError(ctx, "transactionRecurring validate", nil, err)
if err != nil {
if errors.Is(err, db.ErrNotFound) {
return nil, fmt.Errorf("treasure chest not found: %w", ErrBadRequest)
@@ -453,17 +455,17 @@ func (s TransactionRecurringImpl) validateAndEnrichTransactionRecurring(
}
if !hasAccount && !hasTreasureChest {
slog.Error("transactionRecurring validate", "err", err)
slog.ErrorContext(ctx, "transactionRecurring validate", "err", err)
return nil, fmt.Errorf("either account or treasure chest is required: %w", ErrBadRequest)
}
if hasAccount && hasTreasureChest {
slog.Error("transactionRecurring validate", "err", err)
slog.ErrorContext(ctx, "transactionRecurring validate", "err", err)
return nil, fmt.Errorf("either account or treasure chest is required, not both: %w", ErrBadRequest)
}
valueFloat, err := strconv.ParseFloat(input.Value, 64)
if err != nil {
slog.Error("transactionRecurring validate", "err", err)
slog.ErrorContext(ctx, "transactionRecurring validate", "err", err)
return nil, fmt.Errorf("could not parse value: %w", ErrBadRequest)
}
value := int64(math.Round(valueFloat * DECIMALS_MULTIPLIER))
@@ -482,18 +484,18 @@ func (s TransactionRecurringImpl) validateAndEnrichTransactionRecurring(
}
intervalMonths, err = strconv.ParseInt(input.IntervalMonths, 10, 0)
if err != nil {
slog.Error("transactionRecurring validate", "err", err)
slog.ErrorContext(ctx, "transactionRecurring validate", "err", err)
return nil, fmt.Errorf("could not parse intervalMonths: %w", ErrBadRequest)
}
if intervalMonths < 1 {
slog.Error("transactionRecurring validate", "err", err)
slog.ErrorContext(ctx, "transactionRecurring validate", "err", err)
return nil, fmt.Errorf("intervalMonths needs to be greater than 0: %w", ErrBadRequest)
}
var nextExecution *time.Time = nil
if input.NextExecution != "" {
t, err := time.Parse("2006-01-02", input.NextExecution)
if err != nil {
slog.Error("transaction validate", "err", err)
slog.ErrorContext(ctx, "transaction validate", "err", err)
return nil, fmt.Errorf("could not parse timestamp: %w", ErrBadRequest)
}

View File

@@ -1,6 +1,7 @@
package service
import (
"context"
"errors"
"fmt"
"log/slog"
@@ -13,11 +14,11 @@ import (
)
type TreasureChest interface {
Add(user *types.User, parentId, name string) (*types.TreasureChest, error)
Update(user *types.User, id, parentId, name string) (*types.TreasureChest, error)
Get(user *types.User, id string) (*types.TreasureChest, error)
GetAll(user *types.User) ([]*types.TreasureChest, error)
Delete(user *types.User, id string) error
Add(ctx context.Context, user *types.User, parentId, name string) (*types.TreasureChest, error)
Update(ctx context.Context, user *types.User, id, parentId, name string) (*types.TreasureChest, error)
Get(ctx context.Context, user *types.User, id string) (*types.TreasureChest, error)
GetAll(ctx context.Context, user *types.User) ([]*types.TreasureChest, error)
Delete(ctx context.Context, user *types.User, id string) error
}
type TreasureChestImpl struct {
@@ -34,12 +35,12 @@ func NewTreasureChest(db *sqlx.DB, random Random, clock Clock) TreasureChest {
}
}
func (s TreasureChestImpl) Add(user *types.User, parentId, name string) (*types.TreasureChest, error) {
func (s TreasureChestImpl) Add(ctx context.Context, user *types.User, parentId, name string) (*types.TreasureChest, error) {
if user == nil {
return nil, ErrUnauthorized
}
newId, err := s.random.UUID()
newId, err := s.random.UUID(ctx)
if err != nil {
return nil, types.ErrInternal
}
@@ -51,7 +52,7 @@ func (s TreasureChestImpl) Add(user *types.User, parentId, name string) (*types.
var parentUuid *uuid.UUID
if parentId != "" {
parent, err := s.Get(user, parentId)
parent, err := s.Get(ctx, user, parentId)
if err != nil {
return nil, err
}
@@ -76,10 +77,10 @@ func (s TreasureChestImpl) Add(user *types.User, parentId, name string) (*types.
UpdatedBy: nil,
}
r, err := s.db.NamedExec(`
r, err := s.db.NamedExecContext(ctx, `
INSERT INTO treasure_chest (id, parent_id, user_id, name, current_balance, created_at, created_by)
VALUES (:id, :parent_id, :user_id, :name, :current_balance, :created_at, :created_by)`, treasureChest)
err = db.TransformAndLogDbError("treasureChest Insert", r, err)
err = db.TransformAndLogDbError(ctx, "treasureChest Insert", r, err)
if err != nil {
return nil, err
}
@@ -87,7 +88,7 @@ func (s TreasureChestImpl) Add(user *types.User, parentId, name string) (*types.
return treasureChest, nil
}
func (s TreasureChestImpl) Update(user *types.User, idStr, parentId, name string) (*types.TreasureChest, error) {
func (s TreasureChestImpl) Update(ctx context.Context, user *types.User, idStr, parentId, name string) (*types.TreasureChest, error) {
if user == nil {
return nil, ErrUnauthorized
}
@@ -97,12 +98,12 @@ func (s TreasureChestImpl) Update(user *types.User, idStr, parentId, name string
}
id, err := uuid.Parse(idStr)
if err != nil {
slog.Error("treasureChest update", "err", err)
slog.ErrorContext(ctx, "treasureChest update", "err", err)
return nil, fmt.Errorf("could not parse Id: %w", ErrBadRequest)
}
tx, err := s.db.Beginx()
err = db.TransformAndLogDbError("treasureChest Update", nil, err)
tx, err := s.db.BeginTxx(ctx, nil)
err = db.TransformAndLogDbError(ctx, "treasureChest Update", nil, err)
if err != nil {
return nil, err
}
@@ -111,8 +112,8 @@ func (s TreasureChestImpl) Update(user *types.User, idStr, parentId, name string
}()
treasureChest := &types.TreasureChest{}
err = tx.Get(treasureChest, `SELECT * FROM treasure_chest WHERE user_id = ? AND id = ?`, user.Id, id)
err = db.TransformAndLogDbError("treasureChest Update", nil, err)
err = tx.GetContext(ctx, treasureChest, `SELECT * FROM treasure_chest WHERE user_id = ? AND id = ?`, user.Id, id)
err = db.TransformAndLogDbError(ctx, "treasureChest Update", nil, err)
if err != nil {
if errors.Is(err, db.ErrNotFound) {
return nil, fmt.Errorf("treasureChest %v not found: %w", idStr, err)
@@ -122,13 +123,13 @@ func (s TreasureChestImpl) Update(user *types.User, idStr, parentId, name string
var parentUuid *uuid.UUID
if parentId != "" {
parent, err := s.Get(user, parentId)
parent, err := s.Get(ctx, user, parentId)
if err != nil {
return nil, err
}
var childCount int
err = tx.Get(&childCount, `SELECT COUNT(*) FROM treasure_chest WHERE user_id = ? AND parent_id = ?`, user.Id, id)
err = db.TransformAndLogDbError("treasureChest Update", nil, err)
err = tx.GetContext(ctx, &childCount, `SELECT COUNT(*) FROM treasure_chest WHERE user_id = ? AND parent_id = ?`, user.Id, id)
err = db.TransformAndLogDbError(ctx, "treasureChest Update", nil, err)
if err != nil {
return nil, err
}
@@ -145,7 +146,7 @@ func (s TreasureChestImpl) Update(user *types.User, idStr, parentId, name string
treasureChest.UpdatedAt = &timestamp
treasureChest.UpdatedBy = &user.Id
r, err := tx.NamedExec(`
r, err := tx.NamedExecContext(ctx, `
UPDATE treasure_chest
SET
parent_id = :parent_id,
@@ -155,13 +156,13 @@ func (s TreasureChestImpl) Update(user *types.User, idStr, parentId, name string
updated_by = :updated_by
WHERE id = :id
AND user_id = :user_id`, treasureChest)
err = db.TransformAndLogDbError("treasureChest Update", r, err)
err = db.TransformAndLogDbError(ctx, "treasureChest Update", r, err)
if err != nil {
return nil, err
}
err = tx.Commit()
err = db.TransformAndLogDbError("treasureChest Update", nil, err)
err = db.TransformAndLogDbError(ctx, "treasureChest Update", nil, err)
if err != nil {
return nil, err
}
@@ -169,19 +170,19 @@ func (s TreasureChestImpl) Update(user *types.User, idStr, parentId, name string
return treasureChest, nil
}
func (s TreasureChestImpl) Get(user *types.User, id string) (*types.TreasureChest, error) {
func (s TreasureChestImpl) Get(ctx context.Context, user *types.User, id string) (*types.TreasureChest, error) {
if user == nil {
return nil, ErrUnauthorized
}
uuid, err := uuid.Parse(id)
if err != nil {
slog.Error("treasureChest get", "err", err)
slog.ErrorContext(ctx, "treasureChest get", "err", err)
return nil, fmt.Errorf("could not parse Id: %w", ErrBadRequest)
}
var treasureChest types.TreasureChest
err = s.db.Get(&treasureChest, `SELECT * FROM treasure_chest WHERE user_id = ? AND id = ?`, user.Id, uuid)
err = db.TransformAndLogDbError("treasureChest Get", nil, err)
err = s.db.GetContext(ctx, &treasureChest, `SELECT * FROM treasure_chest WHERE user_id = ? AND id = ?`, user.Id, uuid)
err = db.TransformAndLogDbError(ctx, "treasureChest Get", nil, err)
if err != nil {
if errors.Is(err, db.ErrNotFound) {
return nil, fmt.Errorf("treasureChest %v not found: %w", id, err)
@@ -192,33 +193,33 @@ func (s TreasureChestImpl) Get(user *types.User, id string) (*types.TreasureChes
return &treasureChest, nil
}
func (s TreasureChestImpl) GetAll(user *types.User) ([]*types.TreasureChest, error) {
func (s TreasureChestImpl) GetAll(ctx context.Context, user *types.User) ([]*types.TreasureChest, error) {
if user == nil {
return nil, ErrUnauthorized
}
treasureChests := make([]*types.TreasureChest, 0)
err := s.db.Select(&treasureChests, `SELECT * FROM treasure_chest WHERE user_id = ?`, user.Id)
err = db.TransformAndLogDbError("treasureChest GetAll", nil, err)
err := s.db.SelectContext(ctx, &treasureChests, `SELECT * FROM treasure_chest WHERE user_id = ?`, user.Id)
err = db.TransformAndLogDbError(ctx, "treasureChest GetAll", nil, err)
if err != nil {
return nil, err
}
return sortTree(treasureChests), nil
return sortTreasureChests(treasureChests), nil
}
func (s TreasureChestImpl) Delete(user *types.User, idStr string) error {
func (s TreasureChestImpl) Delete(ctx context.Context, user *types.User, idStr string) error {
if user == nil {
return ErrUnauthorized
}
id, err := uuid.Parse(idStr)
if err != nil {
slog.Error("treasureChest delete", "err", err)
slog.ErrorContext(ctx, "treasureChest delete", "err", err)
return fmt.Errorf("could not parse Id: %w", ErrBadRequest)
}
tx, err := s.db.Beginx()
err = db.TransformAndLogDbError("treasureChest Delete", nil, err)
tx, err := s.db.BeginTxx(ctx, nil)
err = db.TransformAndLogDbError(ctx, "treasureChest Delete", nil, err)
if err != nil {
return nil
}
@@ -227,8 +228,8 @@ func (s TreasureChestImpl) Delete(user *types.User, idStr string) error {
}()
childCount := 0
err = tx.Get(&childCount, `SELECT COUNT(*) FROM treasure_chest WHERE user_id = ? AND parent_id = ?`, user.Id, id)
err = db.TransformAndLogDbError("treasureChest Delete", nil, err)
err = tx.GetContext(ctx, &childCount, `SELECT COUNT(*) FROM treasure_chest WHERE user_id = ? AND parent_id = ?`, user.Id, id)
err = db.TransformAndLogDbError(ctx, "treasureChest Delete", nil, err)
if err != nil {
return err
}
@@ -238,10 +239,10 @@ func (s TreasureChestImpl) Delete(user *types.User, idStr string) error {
}
transactionsCount := 0
err = tx.Get(&transactionsCount,
err = tx.GetContext(ctx, &transactionsCount,
`SELECT COUNT(*) FROM "transaction" WHERE user_id = ? AND treasure_chest_id = ?`,
user.Id, id)
err = db.TransformAndLogDbError("treasureChest Delete", nil, err)
err = db.TransformAndLogDbError(ctx, "treasureChest Delete", nil, err)
if err != nil {
return err
}
@@ -250,10 +251,10 @@ func (s TreasureChestImpl) Delete(user *types.User, idStr string) error {
}
recurringCount := 0
err = tx.Get(&recurringCount, `
err = tx.GetContext(ctx, &recurringCount, `
SELECT COUNT(*) FROM transaction_recurring WHERE user_id = ? AND treasure_chest_id = ?`,
user.Id, id)
err = db.TransformAndLogDbError("treasureChest Delete", nil, err)
err = db.TransformAndLogDbError(ctx, "treasureChest Delete", nil, err)
if err != nil {
return err
}
@@ -261,14 +262,14 @@ func (s TreasureChestImpl) Delete(user *types.User, idStr string) error {
return fmt.Errorf("cannot delete treasure chest with existing recurring transactions: %w", ErrBadRequest)
}
r, err := tx.Exec(`DELETE FROM treasure_chest WHERE id = ? AND user_id = ?`, id, user.Id)
err = db.TransformAndLogDbError("treasureChest Delete", r, err)
r, err := tx.ExecContext(ctx, `DELETE FROM treasure_chest WHERE id = ? AND user_id = ?`, id, user.Id)
err = db.TransformAndLogDbError(ctx, "treasureChest Delete", r, err)
if err != nil {
return err
}
err = tx.Commit()
err = db.TransformAndLogDbError("treasureChest Delete", nil, err)
err = db.TransformAndLogDbError(ctx, "treasureChest Delete", nil, err)
if err != nil {
return err
}
@@ -276,7 +277,7 @@ func (s TreasureChestImpl) Delete(user *types.User, idStr string) error {
return nil
}
func sortTree(nodes []*types.TreasureChest) []*types.TreasureChest {
func sortTreasureChests(nodes []*types.TreasureChest) []*types.TreasureChest {
var (
roots []*types.TreasureChest
)

View File

@@ -1,6 +1,5 @@
package account
import "fmt"
import "spend-sparrow/internal/template/svg"
import "spend-sparrow/internal/types"
@@ -67,7 +66,9 @@ templ EditAccount(account *types.Account) {
hx-swap="outerHTML"
class="button button-neglect px-1 flex items-center gap-2"
>
<span class="h-4 w-4">
@svg.Cancel()
</span>
<span>
Cancel
</span>
@@ -81,9 +82,9 @@ templ AccountItem(account *types.Account) {
<div class="text-xl flex justify-end gap-4">
<p class="mr-auto">{ account.Name }</p>
if account.CurrentBalance < 0 {
<p class="mr-20 text-red-700">{ displayBalance(account.CurrentBalance) }</p>
<p class="mr-20 text-red-700">{ types.FormatEuros(account.CurrentBalance) }</p>
} else {
<p class="mr-20 text-green-700">{ displayBalance(account.CurrentBalance) }</p>
<p class="mr-20 text-green-700">{ types.FormatEuros(account.CurrentBalance) }</p>
}
<a
href={ templ.URL("/transaction?account-id=" + account.Id.String()) }
@@ -121,9 +122,3 @@ templ AccountItem(account *types.Account) {
</div>
</div>
}
func displayBalance(balance int64) string {
euros := float64(balance) / 100
return fmt.Sprintf("%.2f €", euros)
}

View File

@@ -71,10 +71,10 @@ if isSignIn {
Don't have an account?
Sign Up
</a>
<button class="button button-primary font-pirata text-gray-600 text-2xl px-1">Sign In</button>
<button class="button button-primary text-gray-600 text-2xl px-1">Sign In</button>
} else {
<a href="/auth/signin" class="text-gray-500 text-sm px-1 button button-neglect">Already have an account? Sign In</a>
<button class="button button-primary font-pirata text-gray-600 text-2xl px-1">
<button class="button button-primary text-gray-600 text-2xl px-1">
Sign Up
</button>
}

View File

@@ -1,7 +1,7 @@
package auth
templ UserComp(user string) {
<div id="user-info" class="flex gap-5 items-center">
<div id="user-info" class="flex items-center gap-2 text-nowrap">
if user != "" {
<div class="inline-block group relative">
<button class="font-semibold py-2 px-4 inline-flex items-center">
@@ -37,8 +37,8 @@ templ UserComp(user string) {
</div>
</div>
} else {
<a href="/auth/signup" class="font-pirata text-xl button px-1 button-neglect">Sign Up</a>
<a href="/auth/signin" class="font-pirata text-xl button px-1 button-neglect">Sign In</a>
<a href="/auth/signup" class="text-xl button px-1 button-neglect">Sign Up</a>
<a href="/auth/signin" class="text-xl button px-1 button-neglect">Sign In</a>
}
</div>
}

View File

@@ -1,9 +0,0 @@
package template
templ Dashboard() {
<div>
<h1 class="text-8xl">
Dashboard
</h1>
</div>
}

View File

@@ -0,0 +1,32 @@
package dashboard
import "spend-sparrow/internal/types"
templ Dashboard(treasureChests []*types.TreasureChest) {
<div class="mt-10 h-full">
<div id="main-chart" class="h-96 mt-10"></div>
<div id="treasure-chests" class="h-96 mt-10"></div>
<section>
<form class="flex items-center justify-end gap-4 mr-40">
<label for="treasure-chest">Treasure Chest:</label>
<select id="treasure-chest-id" name="treasure-chest-id" class="bg-white input">
<option value="">- Select Treasure Chest -</option>
for _, parent := range treasureChests {
if parent.ParentId == nil {
<optgroup label={ parent.Name }>
for _, child := range treasureChests {
if child.ParentId != nil && *child.ParentId == parent.Id {
<option
value={ child.Id.String() }
>{ child.Name }</option>
}
}
</optgroup>
}
}
</select>
</form>
<div id="treasure-chest" class="h-96 mt-10"></div>
</section>
</div>
}

View File

@@ -0,0 +1,2 @@
package dashboard

View File

@@ -1,11 +1,9 @@
package template
templ Index() {
<!-- <div class="h-full flex flex-col items-center justify-center"> -->
<div class="h-full flex flex-col items-center justify-center">
<h1 class="flex gap-2 w-full justify-center">
<img class="w-24" src="/static/favicon.svg" alt="SpendSparrow logo"/>
<span class="text-8xl tracking-tighter font-bold font-pirata">SpendSparrow</span>
<img width="600" src="/static/logo.svg" alt="SpendSparrow logo"/>
</h1>
<h2 class="text-2xl mt-8 text-gray-800">
Spend your <span class="px-2 text-3xl text-yellow-800">treasure</span> on the important

View File

@@ -1,10 +1,14 @@
package template
import "spend-sparrow/internal/template/svg"
func layoutLinkClass(isActive bool) string {
common := "text-2xl p-2 text-gray-900 decoration-yellow-400 decoration-[0.25rem] hover:bg-gray-200 rounded-lg"
if isActive {
return "text-xl hover:bg-gray-100 p-1 duration-100 rounded-xl transition-colors text-gray-900"
return common + " " + "underline"
}
return "text-xl hover:bg-gray-100 hover:text-gray-900 p-1 duration-200 rounded-xl transition-colors text-gray-400"
return common + " " + "hover:underline"
}
templ Layout(slot templ.Component, user templ.Component, loggedIn bool, path string) {
@@ -26,42 +30,66 @@ templ Layout(slot templ.Component, user templ.Component, loggedIn bool, path str
/>
<script src="/static/js/htmx.min.js"></script>
<script src="/static/js/toast.js"></script>
<script src="/static/js/layout.js"></script>
<script src="/static/js/transaction.js"></script>
<script src="/static/js/time.js"></script>
<script src="/static/js/echarts.min.js"></script>
<script src="/static/js/dashboard.js" defer></script>
</head>
<body class="h-screen flex flex-col" hx-headers='{"Csrf-Token": "CSRF_TOKEN"}'>
// Header
<nav class="flex bg-white items-center gap-2 py-1 px-2 h-12 md:gap-10 md:px-10 md:py-2">
<a href="/" class="flex gap-2 mr-20">
<img class="w-6" src="/static/favicon.svg" alt="SpendSparrow logo"/>
<span class="text-4xl font-bold font-pirata">SpendSparrow</span>
<body hx-headers='{"Csrf-Token": "CSRF_TOKEN"}'>
<div class="flex flex-col min-h-screen">
<header class="sticky top-0 z-50 bg-white flex items-center gap-6 p-4 border-b-1 border-gray-200">
<button id="menuButton" class="w-10 h-10 block xl:hidden">
@svg.Menu()
</button>
<a href="/" class="flex gap-2 -mt-2">
<img width="150" src="/static/logo.svg" alt="SpendSparrow logo"/>
</a>
if loggedIn {
<a class={ layoutLinkClass(path == "/") } href="/">Dashboard</a>
<a class={ layoutLinkClass(path == "/transaction") } href="/transaction">Transaction</a>
<a class={ layoutLinkClass(path == "/treasurechest") } href="/treasurechest">Treasure Chest</a>
<a class={ layoutLinkClass(path == "/account") } href="/account">Account</a>
}
<div class="ml-auto">
@user
</div>
</nav>
<div class="h-12 fixed top-12 mr-4 inset-0 bg-linear-0 from-transparent to-white"></div>
</header>
// Content
<main class="flex-1 overflow-auto">
<div class="flex flex-1">
if loggedIn {
<aside class="shrink-0 h-[calc(100vh-4rem)] xl:block hidden sticky top-18 border-r-1 border-gray-200 overflow-y-auto p-4">
@navigation(path)
</aside>
}
<main class="flex-1 p-6">
if slot != nil {
@slot
}
</main>
// Footer
<!-- </div> -->
</div>
</div>
<dialog id="menu" class="max-h-none w-64 h-screen">
<header class="sticky top-0 z-50 bg-white flex items-center justify-between p-4 border-b-1 border-gray-200">
<a href="/" class="flex gap-2 -mt-2">
<img width="150" src="/static/logo.svg" alt="SpendSparrow logo"/>
</a>
<button id="menuButtonClose" class="h-6 w-6">
@svg.Cancel()
</button>
</header>
@navigation(path)
</dialog>
<div id="toasts" class="fixed bottom-4 right-4 ml-4 max-w-96 flex flex-col gap-2 z-50">
<div
id="toast"
class="transition-all duration-300 opacity-0 px-4 py-2 text-lg hidden text-bold rounded bg-amber-900 text-white"
>
M
</div>
class="transition-all duration-300
opacity-0 px-4 py-2 text-lg hidden text-bold rounded bg-amber-900 text-white"
></div>
</div>
</body>
</html>
}
templ navigation(path string) {
<nav class="w-64 text-nowrap flex gap-2 flex-col text-lg mt-5 px-5 pt-2">
<a class={ layoutLinkClass(path == "/dashboard") } href="/dashboard">Dashboard</a>
<a class={ layoutLinkClass(path == "/transaction") } href="/transaction">Transaction</a>
<a class={ layoutLinkClass(path == "/treasurechest") } href="/treasurechest">Treasure Chest</a>
<a class={ layoutLinkClass(path == "/account") } href="/account">Account</a>
</nav>
}

View File

@@ -31,7 +31,7 @@ templ Save() {
}
templ Cancel() {
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" class="h-4 w-4 text-gray-500">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" class="text-gray-500">
<path fill="currentColor" d="m654 501l346 346l-154 154l-346-346l-346 346L0 847l346-346L0 155L154 1l346 346L846 1l154 154z"></path>
</svg>
}
@@ -47,3 +47,13 @@ templ Info() {
<path fill="currentColor" d="M0 0h48v48H0z" mask="url(#ipSInfo0)"></path>
</svg>
}
templ Menu() {
<svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg" class="text-gray-500">
<g data-name="1" id="_1">
<path d="M441.13,166.52h-372a15,15,0,1,1,0-30h372a15,15,0,0,1,0,30Z"></path>
<path d="M441.13,279.72h-372a15,15,0,1,1,0-30h372a15,15,0,0,1,0,30Z"></path>
<path d="M441.13,392.92h-372a15,15,0,1,1,0-30h372a15,15,0,0,1,0,30Z"></path>
</g>
</svg>
}

View File

@@ -10,6 +10,7 @@ templ Transaction(items templ.Component, filter types.TransactionItemsFilter, ac
<div class="max-w-6xl mt-10 mx-auto">
<div class="flex items-center gap-4">
<form
id="transactionFilterForm"
hx-get="/transaction"
hx-target="#transaction-items"
hx-push-url="true"
@@ -52,6 +53,7 @@ templ Transaction(items templ.Component, filter types.TransactionItemsFilter, ac
selected?={ filter.Error == "false" }
>Has no Errors</option>
</select>
<input id="page" name="page" type="hidden" value={ filter.Page }/>
</form>
<button
hx-get="/transaction/new"
@@ -63,7 +65,25 @@ templ Transaction(items templ.Component, filter types.TransactionItemsFilter, ac
<p>New Transaction</p>
</button>
</div>
<div class="flex justify-end items-center gap-5 mt-5">
<button id="pagePrev1" class="text-2xl p-2 text-yellow-700 font-black hover:bg-gray-200 rounded-lg decoration-yellow-400 decoration-[0.25rem] hover:underline">
&lt;
</button>
<span class="text-gray-400 text-sm">Page: <span class="text-gray-800 text-xl" id="page1">{ getPageNumber(filter.Page) }</span></span>
<button id="pageNext1" class="text-2xl p-2 text-yellow-700 font-black hover:bg-gray-200 rounded-lg decoration-yellow-400 decoration-[0.25rem] hover:underline">
&gt;
</button>
</div>
@items
<div class="flex justify-end items-center gap-5 mt-5">
<button id="pagePrev2" class="text-2xl p-2 text-yellow-700 font-black hover:bg-gray-200 rounded-lg decoration-yellow-400 decoration-[0.25rem] hover:underline">
&lt;
</button>
<span class="text-gray-400 text-sm">Page: <span class="text-gray-800 text-xl" id="page2">{ getPageNumber(filter.Page) }</span></span>
<button id="pageNext2" class="text-2xl p-2 text-yellow-700 font-black hover:bg-gray-200 rounded-lg decoration-yellow-400 decoration-[0.25rem] hover:underline">
&gt;
</button>
</div>
</div>
}
@@ -103,7 +123,7 @@ templ EditTransaction(transaction *types.Transaction, accounts []*types.Account,
if transaction.TreasureChestId != nil {
treasureChestId = transaction.TreasureChestId.String()
}
value = displayBalance(transaction.Value)
value = formatFloat(transaction.Value)
id = transaction.Id.String()
cancelUrl = "/transaction/" + id
@@ -188,7 +208,9 @@ templ EditTransaction(transaction *types.Transaction, accounts []*types.Account,
hx-swap="outerHTML"
class="button button-neglect px-1 flex items-center gap-2"
>
<span class="h-4 w-4">
@svg.Cancel()
</span>
<span>
Cancel
</span>
@@ -250,9 +272,9 @@ templ TransactionItem(transaction *types.Transaction, accounts, treasureChests m
</p>
</div>
if transaction.Value < 0 {
<p class="mr-8 min-w-22 text-right text-red-700">{ displayBalance(transaction.Value)+" €" }</p>
<p class="mr-8 min-w-22 text-right text-red-700">{ types.FormatEuros(transaction.Value) }</p>
} else {
<p class="mr-8 w-22 text-right text-green-700">{ displayBalance(transaction.Value)+" €" }</p>
<p class="mr-8 w-22 text-right text-green-700">{ types.FormatEuros(transaction.Value) }</p>
}
<button
hx-get={ "/transaction/" + transaction.Id.String() + "?edit=true" }
@@ -280,11 +302,16 @@ templ TransactionItem(transaction *types.Transaction, accounts, treasureChests m
</div>
}
func displayBalance(balance int64) string {
func formatFloat(balance int64) string {
euros := float64(balance) / 100
return fmt.Sprintf("%.2f", euros)
}
func calculateReferences() {
func getPageNumber(page string) string {
if page == "" {
return "1"
} else {
return page
}
}

View File

@@ -53,9 +53,9 @@ templ TransactionRecurringItem(transactionRecurring *types.TransactionRecurring,
Every <span class="text-xl">{ transactionRecurring.IntervalMonths }</span> month(s)
</p>
if transactionRecurring.Value < 0 {
<p class="text-right text-red-700">{ displayBalance(transactionRecurring.Value)+" €" }</p>
<p class="text-right text-red-700">{ types.FormatEuros(transactionRecurring.Value) }</p>
} else {
<p class="text-right text-green-700">{ displayBalance(transactionRecurring.Value)+" €" }</p>
<p class="text-right text-green-700">{ types.FormatEuros(transactionRecurring.Value) }</p>
}
<div class="flex gap-2">
<button
@@ -104,7 +104,7 @@ templ EditTransactionRecurring(transactionRecurring *types.TransactionRecurring,
}
party = transactionRecurring.Party
description = transactionRecurring.Description
value = displayBalance(transactionRecurring.Value)
value = formatFloat(transactionRecurring.Value)
id = transactionRecurring.Id.String()
}
@@ -193,7 +193,9 @@ templ EditTransactionRecurring(transactionRecurring *types.TransactionRecurring,
hx-swap="outerHTML"
class="button button-neglect px-1 flex items-center gap-2"
>
<span class="h-4 w-4">
@svg.Cancel()
</span>
<span>
Cancel
</span>
@@ -201,11 +203,8 @@ templ EditTransactionRecurring(transactionRecurring *types.TransactionRecurring,
</div>
}
func displayBalance(balance int64) string {
func formatFloat(balance int64) string {
euros := float64(balance) / 100
return fmt.Sprintf("%.2f", euros)
}
func calculateReferences() {
}

View File

@@ -1,6 +1,5 @@
package treasurechest
import "fmt"
import "spend-sparrow/internal/template/svg"
import "spend-sparrow/internal/types"
import "github.com/google/uuid"
@@ -89,7 +88,9 @@ templ EditTreasureChest(treasureChest *types.TreasureChest, parents []*types.Tre
hx-swap="outerHTML"
class="button button-neglect px-1 flex items-center gap-2"
>
<span class="h-4 w-4">
@svg.Cancel()
</span>
<span>
Cancel
</span>
@@ -130,14 +131,14 @@ templ TreasureChestItem(treasureChest *types.TreasureChest, monthlySums map[uuid
<p class="mr-auto">{ treasureChest.Name }</p>
<p class="mr-20 text-gray-600">
if treasureChest.ParentId != nil {
+ { displayBalance(monthlySums[treasureChest.Id]) } <span class="text-gray-500 text-sm">&nbsp;per month</span>
+ { types.FormatEuros(monthlySums[treasureChest.Id]) } <span class="text-gray-500 text-sm">&nbsp;per month</span>
}
</p>
if treasureChest.ParentId != nil {
if treasureChest.CurrentBalance < 0 {
<p class="mr-20 min-w-20 text-right text-red-700">{ displayBalance(treasureChest.CurrentBalance) }</p>
<p class="mr-20 min-w-20 text-right text-red-700">{ types.FormatEuros(treasureChest.CurrentBalance) }</p>
} else {
<p class="mr-20 min-w-20 text-right text-green-700">{ displayBalance(treasureChest.CurrentBalance) }</p>
<p class="mr-20 min-w-20 text-right text-green-700">{ types.FormatEuros(treasureChest.CurrentBalance) }</p>
}
}
<a
@@ -187,9 +188,3 @@ func filterNoChildNoSelf(nodes []*types.TreasureChest, selfId string) []*types.T
return result
}
func displayBalance(balance int64) string {
euros := float64(balance) / 100
return fmt.Sprintf("%.2f €", euros)
}

View File

@@ -0,0 +1,30 @@
package types
import "time"
type DashboardMonthlySummary struct {
Month time.Time
// Sum of all Transactions with TreasureChests and no Accounts
Savings int64
// Sum of all Transactions with Accounts and no TreasureChests
Income int64
// Sum of all Transactions with Accounts and TreasureChests
Expenses int64
// Income - Expenses
Total int64
SumOfSavings int64
SumOfAccounts int64
}
type DashboardMainChartEntry struct {
Day time.Time
Value int64
Savings int64
}
type DashboardTreasureChest struct {
Name string
Value int64
Children []DashboardTreasureChest
}

36
internal/types/format.go Normal file
View File

@@ -0,0 +1,36 @@
package types
import (
"fmt"
"strings"
)
func FormatEuros(balance int64) string {
prefix := ""
if balance < 0 {
prefix = "- "
balance = -balance
}
n := float64(balance) / 100
s := fmt.Sprintf("%.2f", n) // "1234567.89"
parts := strings.Split(s, ".")
intPart := parts[0]
fracPart := parts[1]
var result strings.Builder
numberOfSeperators := len(intPart) % 3
if numberOfSeperators == 0 {
result.WriteString(intPart)
} else {
for i := range intPart {
if i > 0 && (i-numberOfSeperators)%3 == 0 {
result.WriteString(",")
}
result.WriteByte(intPart[i])
}
}
return prefix + result.String() + "." + fracPart + " €"
}

View File

@@ -1,6 +1,7 @@
package types
import (
"context"
"errors"
"log/slog"
)
@@ -26,13 +27,13 @@ type SmtpSettings struct {
FromName string
}
func NewSettingsFromEnv(env func(string) string) (*Settings, error) {
func NewSettingsFromEnv(ctx context.Context, env func(string) string) (*Settings, error) {
var (
smtp *SmtpSettings
err error
)
if env("SMTP_ENABLED") == "true" {
smtp, err = getSmtpSettings(env)
smtp, err = getSmtpSettings(ctx, env)
if err != nil {
return nil, err
}
@@ -46,26 +47,26 @@ func NewSettingsFromEnv(env func(string) string) (*Settings, error) {
}
if settings.BaseUrl == "" {
slog.Error("BASE_URL must be set")
slog.ErrorContext(ctx, "BASE_URL must be set")
return nil, ErrMissingConfig
}
if settings.Port == "" {
slog.Error("PORT must be set")
slog.ErrorContext(ctx, "PORT must be set")
return nil, ErrMissingConfig
}
if settings.Environment == "" {
slog.Error("ENVIRONMENT must be set")
slog.ErrorContext(ctx, "ENVIRONMENT must be set")
return nil, ErrMissingConfig
}
slog.Info("settings read", "BASE_URL", settings.BaseUrl)
slog.Info("settings read", "ENVIRONMENT", settings.Environment)
slog.Info("settings read", "ENVIRONMENT", settings.Environment)
slog.InfoContext(ctx, "settings read", "BASE_URL", settings.BaseUrl)
slog.InfoContext(ctx, "settings read", "ENVIRONMENT", settings.Environment)
slog.InfoContext(ctx, "settings read", "ENVIRONMENT", settings.Environment)
return settings, nil
}
func getSmtpSettings(env func(string) string) (*SmtpSettings, error) {
func getSmtpSettings(ctx context.Context, env func(string) string) (*SmtpSettings, error) {
smtp := SmtpSettings{
Host: env("SMTP_HOST"),
Port: env("SMTP_PORT"),
@@ -76,27 +77,27 @@ func getSmtpSettings(env func(string) string) (*SmtpSettings, error) {
}
if smtp.Host == "" {
slog.Error("SMTP_HOST must be set")
slog.ErrorContext(ctx, "SMTP_HOST must be set")
return nil, ErrMissingConfig
}
if smtp.Port == "" {
slog.Error("SMTP_PORT must be set")
slog.ErrorContext(ctx, "SMTP_PORT must be set")
return nil, ErrMissingConfig
}
if smtp.User == "" {
slog.Error("SMTP_USER must be set")
slog.ErrorContext(ctx, "SMTP_USER must be set")
return nil, ErrMissingConfig
}
if smtp.Pass == "" {
slog.Error("SMTP_PASS must be set")
slog.ErrorContext(ctx, "SMTP_PASS must be set")
return nil, ErrMissingConfig
}
if smtp.FromMail == "" {
slog.Error("SMTP_FROM_MAIL must be set")
slog.ErrorContext(ctx, "SMTP_FROM_MAIL must be set")
return nil, ErrMissingConfig
}
if smtp.FromName == "" {
slog.Error("SMTP_FROM_NAME must be set")
slog.ErrorContext(ctx, "SMTP_FROM_NAME must be set")
return nil, ErrMissingConfig
}

View File

@@ -6,13 +6,26 @@ import (
"github.com/google/uuid"
)
// At the center of the application is the transaction.
// Transaction is at the center of the application.
//
// Every piece of data should be calculated based on transactions.
// This means potential calculation errors can be fixed later in time.
//
// If it becomes necessary to precalculate snapshots for performance reasons, this can be done in the future.
// But the transaction should always be the source of truth.
//
// There are the following constallations and their explanation:
//
// Account | TreasureChest | Value | Description
// --------|---------------|-------|----------------
// Y | Y | + | Invalid
// Y | Y | - | Expense
// Y | N | + | Deposit
// Y | N | - | Withdrawal (for moving between accounts)
// N | Y | + | Saving
// N | Y | - | Withdrawal (for moving between treasure chests)
// N | N | + | Invalid
// N | N | - | Invalid
type Transaction struct {
Id uuid.UUID `db:"id"`
UserId uuid.UUID `db:"user_id"`
@@ -38,4 +51,5 @@ type TransactionItemsFilter struct {
AccountId string
TreasureChestId string
Error string
Page string
}

View File

@@ -1,6 +1,7 @@
package utils
import (
"context"
"fmt"
"log/slog"
"net/http"
@@ -8,16 +9,16 @@ import (
"time"
)
func TriggerToast(w http.ResponseWriter, r *http.Request, class string, message string) {
func TriggerToast(ctx context.Context, w http.ResponseWriter, r *http.Request, class string, message string) {
if IsHtmx(r) {
w.Header().Set("Hx-Trigger", fmt.Sprintf(`{"toast": "%v|%v"}`, class, strings.ReplaceAll(message, `"`, `\"`)))
} else {
slog.Error("Trying to trigger toast in non-HTMX request")
slog.ErrorContext(ctx, "Trying to trigger toast in non-HTMX request")
}
}
func TriggerToastWithStatus(w http.ResponseWriter, r *http.Request, class string, message string, statusCode int) {
TriggerToast(w, r, class, message)
func TriggerToastWithStatus(ctx context.Context, w http.ResponseWriter, r *http.Request, class string, message string, statusCode int) {
TriggerToast(ctx, w, r, class, message)
w.WriteHeader(statusCode)
}
@@ -29,7 +30,7 @@ func DoRedirect(w http.ResponseWriter, r *http.Request, url string) {
}
}
func WaitMinimumTime[T interface{}](waitTime time.Duration, f func() (T, error)) (T, error) {
func WaitMinimumTime[T any](waitTime time.Duration, f func() (T, error)) (T, error) {
start := time.Now()
result, err := f()
time.Sleep(waitTime - time.Since(start))

17
main.go
View File

@@ -6,31 +6,36 @@ import (
"os"
"spend-sparrow/internal"
"github.com/jmoiron/sqlx"
"github.com/joho/godotenv"
_ "github.com/mattn/go-sqlite3"
"github.com/uptrace/opentelemetry-go-extra/otelsql"
"github.com/uptrace/opentelemetry-go-extra/otelsqlx"
semconv "go.opentelemetry.io/otel/semconv/v1.10.0"
)
func main() {
ctx := context.Background()
err := godotenv.Load()
if err != nil {
slog.Error("Error loading .env file")
slog.ErrorContext(ctx, "Error loading .env file")
return
}
db, err := sqlx.Open("sqlite3", "./data/spend-sparrow.db")
db, err := otelsqlx.Open("sqlite3", "./data/spend-sparrow.db?_journal_mode=WAL",
otelsql.WithAttributes(semconv.DBSystemSqlite))
if err != nil {
slog.Error("Could not open Database data.db", "err", err)
slog.ErrorContext(ctx, "Could not open Database data.db", "err", err)
return
}
defer func() {
if err = db.Close(); err != nil {
slog.Error("Database close failed", "err", err)
slog.ErrorContext(ctx, "Database close failed", "err", err)
}
}()
if err = internal.Run(context.Background(), db, "", os.Getenv); err != nil {
slog.Error("Error running server", "err", err)
slog.ErrorContext(ctx, "Error running server", "err", err)
return
}
}

509
package-lock.json generated
View File

@@ -9,51 +9,32 @@
"version": "1.0.0",
"license": "ISC",
"devDependencies": {
"@tailwindcss/cli": "4.1.8",
"htmx.org": "2.0.4",
"tailwindcss": "4.1.8"
}
},
"node_modules/@ampproject/remapping": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@isaacs/fs-minipass": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
"integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"minipass": "^7.0.4"
},
"engines": {
"node": ">=18.0.0"
"@tailwindcss/cli": "4.1.16",
"echarts": "6.0.0",
"htmx.org": "2.0.8",
"tailwindcss": "4.1.16"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.8",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz",
"integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==",
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/set-array": "^1.2.1",
"@jridgewell/sourcemap-codec": "^1.4.10",
"@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"engines": {
"node": ">=6.0.0"
"node_modules/@jridgewell/remapping": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/resolve-uri": {
@@ -66,27 +47,17 @@
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/set-array": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz",
"integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
"integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==",
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.25",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz",
"integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==",
"version": "0.3.30",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz",
"integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -404,73 +375,68 @@
}
},
"node_modules/@tailwindcss/cli": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.1.8.tgz",
"integrity": "sha512-+6lkjXSr/68zWiabK3mVYVHmOq/SAHjJ13mR8spyB4LgUWZbWzU9kCSErlAUo+gK5aVfgqe8kY6Ltz9+nz5XYA==",
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.1.16.tgz",
"integrity": "sha512-dsnANPrh2ZooHyZ/8uJhc9ecpcYtufToc21NY09NS9vF16rxPCjJ8dP7TUAtPqlUJTHSmRkN2hCdoYQIlgh4fw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@parcel/watcher": "^2.5.1",
"@tailwindcss/node": "4.1.8",
"@tailwindcss/oxide": "4.1.8",
"enhanced-resolve": "^5.18.1",
"@tailwindcss/node": "4.1.16",
"@tailwindcss/oxide": "4.1.16",
"enhanced-resolve": "^5.18.3",
"mri": "^1.2.0",
"picocolors": "^1.1.1",
"tailwindcss": "4.1.8"
"tailwindcss": "4.1.16"
},
"bin": {
"tailwindcss": "dist/index.mjs"
}
},
"node_modules/@tailwindcss/node": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.8.tgz",
"integrity": "sha512-OWwBsbC9BFAJelmnNcrKuf+bka2ZxCE2A4Ft53Tkg4uoiE67r/PMEYwCsourC26E+kmxfwE0hVzMdxqeW+xu7Q==",
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.16.tgz",
"integrity": "sha512-BX5iaSsloNuvKNHRN3k2RcCuTEgASTo77mofW0vmeHkfrDWaoFAFvNHpEgtu0eqyypcyiBkDWzSMxJhp3AUVcw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@ampproject/remapping": "^2.3.0",
"enhanced-resolve": "^5.18.1",
"jiti": "^2.4.2",
"lightningcss": "1.30.1",
"magic-string": "^0.30.17",
"@jridgewell/remapping": "^2.3.4",
"enhanced-resolve": "^5.18.3",
"jiti": "^2.6.1",
"lightningcss": "1.30.2",
"magic-string": "^0.30.19",
"source-map-js": "^1.2.1",
"tailwindcss": "4.1.8"
"tailwindcss": "4.1.16"
}
},
"node_modules/@tailwindcss/oxide": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.8.tgz",
"integrity": "sha512-d7qvv9PsM5N3VNKhwVUhpK6r4h9wtLkJ6lz9ZY9aeZgrUWk1Z8VPyqyDT9MZlem7GTGseRQHkeB1j3tC7W1P+A==",
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.16.tgz",
"integrity": "sha512-2OSv52FRuhdlgyOQqgtQHuCgXnS8nFSYRp2tJ+4WZXKgTxqPy7SMSls8c3mPT5pkZ17SBToGM5LHEJBO7miEdg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.4",
"tar": "^7.4.3"
},
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@tailwindcss/oxide-android-arm64": "4.1.8",
"@tailwindcss/oxide-darwin-arm64": "4.1.8",
"@tailwindcss/oxide-darwin-x64": "4.1.8",
"@tailwindcss/oxide-freebsd-x64": "4.1.8",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.8",
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.8",
"@tailwindcss/oxide-linux-arm64-musl": "4.1.8",
"@tailwindcss/oxide-linux-x64-gnu": "4.1.8",
"@tailwindcss/oxide-linux-x64-musl": "4.1.8",
"@tailwindcss/oxide-wasm32-wasi": "4.1.8",
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.8",
"@tailwindcss/oxide-win32-x64-msvc": "4.1.8"
"@tailwindcss/oxide-android-arm64": "4.1.16",
"@tailwindcss/oxide-darwin-arm64": "4.1.16",
"@tailwindcss/oxide-darwin-x64": "4.1.16",
"@tailwindcss/oxide-freebsd-x64": "4.1.16",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.16",
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.16",
"@tailwindcss/oxide-linux-arm64-musl": "4.1.16",
"@tailwindcss/oxide-linux-x64-gnu": "4.1.16",
"@tailwindcss/oxide-linux-x64-musl": "4.1.16",
"@tailwindcss/oxide-wasm32-wasi": "4.1.16",
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.16",
"@tailwindcss/oxide-win32-x64-msvc": "4.1.16"
}
},
"node_modules/@tailwindcss/oxide-android-arm64": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.8.tgz",
"integrity": "sha512-Fbz7qni62uKYceWYvUjRqhGfZKwhZDQhlrJKGtnZfuNtHFqa8wmr+Wn74CTWERiW2hn3mN5gTpOoxWKk0jRxjg==",
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.16.tgz",
"integrity": "sha512-8+ctzkjHgwDJ5caq9IqRSgsP70xhdhJvm+oueS/yhD5ixLhqTw9fSL1OurzMUhBwE5zK26FXLCz2f/RtkISqHA==",
"cpu": [
"arm64"
],
@@ -485,9 +451,9 @@
}
},
"node_modules/@tailwindcss/oxide-darwin-arm64": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.8.tgz",
"integrity": "sha512-RdRvedGsT0vwVVDztvyXhKpsU2ark/BjgG0huo4+2BluxdXo8NDgzl77qh0T1nUxmM11eXwR8jA39ibvSTbi7A==",
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.16.tgz",
"integrity": "sha512-C3oZy5042v2FOALBZtY0JTDnGNdS6w7DxL/odvSny17ORUnaRKhyTse8xYi3yKGyfnTUOdavRCdmc8QqJYwFKA==",
"cpu": [
"arm64"
],
@@ -502,9 +468,9 @@
}
},
"node_modules/@tailwindcss/oxide-darwin-x64": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.8.tgz",
"integrity": "sha512-t6PgxjEMLp5Ovf7uMb2OFmb3kqzVTPPakWpBIFzppk4JE4ix0yEtbtSjPbU8+PZETpaYMtXvss2Sdkx8Vs4XRw==",
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.16.tgz",
"integrity": "sha512-vjrl/1Ub9+JwU6BP0emgipGjowzYZMjbWCDqwA2Z4vCa+HBSpP4v6U2ddejcHsolsYxwL5r4bPNoamlV0xDdLg==",
"cpu": [
"x64"
],
@@ -519,9 +485,9 @@
}
},
"node_modules/@tailwindcss/oxide-freebsd-x64": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.8.tgz",
"integrity": "sha512-g8C8eGEyhHTqwPStSwZNSrOlyx0bhK/V/+zX0Y+n7DoRUzyS8eMbVshVOLJTDDC+Qn9IJnilYbIKzpB9n4aBsg==",
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.16.tgz",
"integrity": "sha512-TSMpPYpQLm+aR1wW5rKuUuEruc/oOX3C7H0BTnPDn7W/eMw8W+MRMpiypKMkXZfwH8wqPIRKppuZoedTtNj2tg==",
"cpu": [
"x64"
],
@@ -536,9 +502,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.8.tgz",
"integrity": "sha512-Jmzr3FA4S2tHhaC6yCjac3rGf7hG9R6Gf2z9i9JFcuyy0u79HfQsh/thifbYTF2ic82KJovKKkIB6Z9TdNhCXQ==",
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.16.tgz",
"integrity": "sha512-p0GGfRg/w0sdsFKBjMYvvKIiKy/LNWLWgV/plR4lUgrsxFAoQBFrXkZ4C0w8IOXfslB9vHK/JGASWD2IefIpvw==",
"cpu": [
"arm"
],
@@ -553,9 +519,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.8.tgz",
"integrity": "sha512-qq7jXtO1+UEtCmCeBBIRDrPFIVI4ilEQ97qgBGdwXAARrUqSn/L9fUrkb1XP/mvVtoVeR2bt/0L77xx53bPZ/Q==",
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.16.tgz",
"integrity": "sha512-DoixyMmTNO19rwRPdqviTrG1rYzpxgyYJl8RgQvdAQUzxC1ToLRqtNJpU/ATURSKgIg6uerPw2feW0aS8SNr/w==",
"cpu": [
"arm64"
],
@@ -570,9 +536,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.8.tgz",
"integrity": "sha512-O6b8QesPbJCRshsNApsOIpzKt3ztG35gfX9tEf4arD7mwNinsoCKxkj8TgEE0YRjmjtO3r9FlJnT/ENd9EVefQ==",
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.16.tgz",
"integrity": "sha512-H81UXMa9hJhWhaAUca6bU2wm5RRFpuHImrwXBUvPbYb+3jo32I9VIwpOX6hms0fPmA6f2pGVlybO6qU8pF4fzQ==",
"cpu": [
"arm64"
],
@@ -587,9 +553,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.8.tgz",
"integrity": "sha512-32iEXX/pXwikshNOGnERAFwFSfiltmijMIAbUhnNyjFr3tmWmMJWQKU2vNcFX0DACSXJ3ZWcSkzNbaKTdngH6g==",
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.16.tgz",
"integrity": "sha512-ZGHQxDtFC2/ruo7t99Qo2TTIvOERULPl5l0K1g0oK6b5PGqjYMga+FcY1wIUnrUxY56h28FxybtDEla+ICOyew==",
"cpu": [
"x64"
],
@@ -604,9 +570,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.8.tgz",
"integrity": "sha512-s+VSSD+TfZeMEsCaFaHTaY5YNj3Dri8rST09gMvYQKwPphacRG7wbuQ5ZJMIJXN/puxPcg/nU+ucvWguPpvBDg==",
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.16.tgz",
"integrity": "sha512-Oi1tAaa0rcKf1Og9MzKeINZzMLPbhxvm7rno5/zuP1WYmpiG0bEHq4AcRUiG2165/WUzvxkW4XDYCscZWbTLZw==",
"cpu": [
"x64"
],
@@ -621,9 +587,9 @@
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.8.tgz",
"integrity": "sha512-CXBPVFkpDjM67sS1psWohZ6g/2/cd+cq56vPxK4JeawelxwK4YECgl9Y9TjkE2qfF+9/s1tHHJqrC4SS6cVvSg==",
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.16.tgz",
"integrity": "sha512-B01u/b8LteGRwucIBmCQ07FVXLzImWESAIMcUU6nvFt/tYsQ6IHz8DmZ5KtvmwxD+iTYBtM1xwoGXswnlu9v0Q==",
"bundleDependencies": [
"@napi-rs/wasm-runtime",
"@emnapi/core",
@@ -639,30 +605,30 @@
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.4.3",
"@emnapi/runtime": "^1.4.3",
"@emnapi/wasi-threads": "^1.0.2",
"@napi-rs/wasm-runtime": "^0.2.10",
"@tybys/wasm-util": "^0.9.0",
"tslib": "^2.8.0"
"@emnapi/core": "^1.5.0",
"@emnapi/runtime": "^1.5.0",
"@emnapi/wasi-threads": "^1.1.0",
"@napi-rs/wasm-runtime": "^1.0.7",
"@tybys/wasm-util": "^0.10.1",
"tslib": "^2.4.0"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.4.3",
"version": "1.5.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.0.2",
"@emnapi/wasi-threads": "1.1.0",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.4.3",
"version": "1.5.0",
"dev": true,
"inBundle": true,
"license": "MIT",
@@ -672,7 +638,7 @@
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.0.2",
"version": "1.1.0",
"dev": true,
"inBundle": true,
"license": "MIT",
@@ -682,19 +648,19 @@
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.10",
"version": "1.0.7",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.4.3",
"@emnapi/runtime": "^1.4.3",
"@tybys/wasm-util": "^0.9.0"
"@emnapi/core": "^1.5.0",
"@emnapi/runtime": "^1.5.0",
"@tybys/wasm-util": "^0.10.1"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.9.0",
"version": "0.10.1",
"dev": true,
"inBundle": true,
"license": "MIT",
@@ -704,16 +670,16 @@
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.0",
"version": "2.8.1",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.8.tgz",
"integrity": "sha512-7GmYk1n28teDHUjPlIx4Z6Z4hHEgvP5ZW2QS9ygnDAdI/myh3HTHjDqtSqgu1BpRoI4OiLx+fThAyA1JePoENA==",
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.16.tgz",
"integrity": "sha512-zX+Q8sSkGj6HKRTMJXuPvOcP8XfYON24zJBRPlszcH1Np7xuHXhWn8qfFjIujVzvH3BHU+16jBXwgpl20i+v9A==",
"cpu": [
"arm64"
],
@@ -728,9 +694,9 @@
}
},
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.8.tgz",
"integrity": "sha512-fou+U20j+Jl0EHwK92spoWISON2OBnCazIc038Xj2TdweYV33ZRkS9nwqiUi2d/Wba5xg5UoHfvynnb/UB49cQ==",
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.16.tgz",
"integrity": "sha512-m5dDFJUEejbFqP+UXVstd4W/wnxA4F61q8SoL+mqTypId2T2ZpuxosNSgowiCnLp2+Z+rivdU0AqpfgiD7yCBg==",
"cpu": [
"x64"
],
@@ -744,16 +710,6 @@
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide/node_modules/detect-libc": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
"integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
@@ -767,16 +723,6 @@
"node": ">=8"
}
},
"node_modules/chownr": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=18"
}
},
"node_modules/detect-libc": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
@@ -790,10 +736,21 @@
"node": ">=0.10"
}
},
"node_modules/echarts": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz",
"integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "2.3.0",
"zrender": "6.0.0"
}
},
"node_modules/enhanced-resolve": {
"version": "5.18.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz",
"integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==",
"version": "5.18.3",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
"integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -825,9 +782,9 @@
"license": "ISC"
},
"node_modules/htmx.org": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.4.tgz",
"integrity": "sha512-HLxMCdfXDOJirs3vBZl/ZLoY+c7PfM4Ahr2Ad4YXh6d22T5ltbTXFFkpx9Tgb2vvmWFMbIc3LqN2ToNkZJvyYQ==",
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.8.tgz",
"integrity": "sha512-fm297iru0iWsNJlBrjvtN7V9zjaxd+69Oqjh4F/Vq9Wwi2kFisLcrLCiv5oBX0KLfOX/zG8AUo9ROMU5XUB44Q==",
"dev": true,
"license": "0BSD"
},
@@ -865,9 +822,9 @@
}
},
"node_modules/jiti": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz",
"integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==",
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
"dev": true,
"license": "MIT",
"bin": {
@@ -875,9 +832,9 @@
}
},
"node_modules/lightningcss": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz",
"integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==",
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
"integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
@@ -891,22 +848,44 @@
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-darwin-arm64": "1.30.1",
"lightningcss-darwin-x64": "1.30.1",
"lightningcss-freebsd-x64": "1.30.1",
"lightningcss-linux-arm-gnueabihf": "1.30.1",
"lightningcss-linux-arm64-gnu": "1.30.1",
"lightningcss-linux-arm64-musl": "1.30.1",
"lightningcss-linux-x64-gnu": "1.30.1",
"lightningcss-linux-x64-musl": "1.30.1",
"lightningcss-win32-arm64-msvc": "1.30.1",
"lightningcss-win32-x64-msvc": "1.30.1"
"lightningcss-android-arm64": "1.30.2",
"lightningcss-darwin-arm64": "1.30.2",
"lightningcss-darwin-x64": "1.30.2",
"lightningcss-freebsd-x64": "1.30.2",
"lightningcss-linux-arm-gnueabihf": "1.30.2",
"lightningcss-linux-arm64-gnu": "1.30.2",
"lightningcss-linux-arm64-musl": "1.30.2",
"lightningcss-linux-x64-gnu": "1.30.2",
"lightningcss-linux-x64-musl": "1.30.2",
"lightningcss-win32-arm64-msvc": "1.30.2",
"lightningcss-win32-x64-msvc": "1.30.2"
}
},
"node_modules/lightningcss-android-arm64": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz",
"integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz",
"integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==",
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz",
"integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==",
"cpu": [
"arm64"
],
@@ -925,9 +904,9 @@
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz",
"integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==",
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz",
"integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==",
"cpu": [
"x64"
],
@@ -946,9 +925,9 @@
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz",
"integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==",
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz",
"integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==",
"cpu": [
"x64"
],
@@ -967,9 +946,9 @@
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz",
"integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==",
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz",
"integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==",
"cpu": [
"arm"
],
@@ -988,9 +967,9 @@
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz",
"integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==",
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz",
"integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==",
"cpu": [
"arm64"
],
@@ -1009,9 +988,9 @@
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz",
"integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==",
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz",
"integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==",
"cpu": [
"arm64"
],
@@ -1030,9 +1009,9 @@
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz",
"integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==",
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz",
"integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==",
"cpu": [
"x64"
],
@@ -1051,9 +1030,9 @@
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz",
"integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==",
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz",
"integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==",
"cpu": [
"x64"
],
@@ -1072,9 +1051,9 @@
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz",
"integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==",
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz",
"integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==",
"cpu": [
"arm64"
],
@@ -1093,9 +1072,9 @@
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz",
"integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==",
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz",
"integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==",
"cpu": [
"x64"
],
@@ -1114,9 +1093,9 @@
}
},
"node_modules/lightningcss/node_modules/detect-libc": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
"integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==",
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
@@ -1124,13 +1103,13 @@
}
},
"node_modules/magic-string": {
"version": "0.30.17",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
"integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==",
"version": "0.30.19",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz",
"integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0"
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/micromatch": {
@@ -1147,45 +1126,6 @@
"node": ">=8.6"
}
},
"node_modules/minipass": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/minizlib": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz",
"integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==",
"dev": true,
"license": "MIT",
"dependencies": {
"minipass": "^7.1.2"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/mkdirp": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz",
"integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==",
"dev": true,
"license": "MIT",
"bin": {
"mkdirp": "dist/cjs/src/bin.js"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/mri": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
@@ -1234,40 +1174,22 @@
}
},
"node_modules/tailwindcss": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.8.tgz",
"integrity": "sha512-kjeW8gjdxasbmFKpVGrGd5T4i40mV5J2Rasw48QARfYeQ8YS9x02ON9SFWax3Qf616rt4Cp3nVNIj6Hd1mP3og==",
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.16.tgz",
"integrity": "sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA==",
"dev": true,
"license": "MIT"
},
"node_modules/tapable": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz",
"integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==",
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz",
"integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/tar": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz",
"integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==",
"dev": true,
"license": "ISC",
"dependencies": {
"@isaacs/fs-minipass": "^4.0.0",
"chownr": "^3.0.0",
"minipass": "^7.1.2",
"minizlib": "^3.0.1",
"mkdirp": "^3.0.1",
"yallist": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -1281,14 +1203,21 @@
"node": ">=8.0"
}
},
"node_modules/yallist": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
"node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=18"
"license": "0BSD"
},
"node_modules/zrender": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz",
"integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tslib": "2.3.0"
}
}
}

View File

@@ -1,18 +1,19 @@
{
"name": "spend-sparrow",
"version": "1.0.0",
"description": "Your (almost) independent tech stack to host on a VPC.",
"description": "Personal finance tracking done right",
"main": "index.js",
"scripts": {
"build": "cp -f node_modules/htmx.org/dist/htmx.min.js static/js/htmx.min.js && tailwindcss -i input.css -o static/css/tailwind.css --minify",
"watch": "cp -f node_modules/htmx.org/dist/htmx.min.js static/js/htmx.min.js && tailwindcss -i input.css -o static/css/tailwind.css --watch"
"build": "cp -f node_modules/htmx.org/dist/htmx.min.js static/js/htmx.min.js && cp -f node_modules/echarts/dist/echarts.min.js static/js/echarts.min.js && tailwindcss -i input.css -o static/css/tailwind.css --minify",
"watch": "cp -f node_modules/htmx.org/dist/htmx.min.js static/js/htmx.min.js && cp -f node_modules/echarts/dist/echarts.min.js static/js/echarts.min.js && tailwindcss -i input.css -o static/css/tailwind.css --watch"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"htmx.org": "2.0.4",
"tailwindcss": "4.1.8",
"@tailwindcss/cli": "4.1.8"
"@tailwindcss/cli": "4.1.16",
"htmx.org": "2.0.8",
"tailwindcss": "4.1.16",
"echarts": "6.0.0"
}
}

View File

@@ -1 +1,53 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 128 128"><path d="M93.46 39.45c6.71-1.49 15.45-8.15 16.78-11.43.78-1.92-3.11-4.92-4.15-6.13-2.38-2.76-1.42-4.12-.5-7.41 1.05-3.74-1.44-7.87-4.97-9.49s-7.75-1.11-11.3.47-6.58 4.12-9.55 6.62c-2.17-1.37-5.63-7.42-11.23-3.49-3.87 2.71-4.22 8.61-3.72 13.32 1.17 10.87 3.85 16.51 8.9 18.03 6.38 1.92 13.44.91 19.74-.49" style="fill:#ffca28"/><path d="M104.36 8.18c-.85 14.65-15.14 24.37-21.92 28.65l4.4 3.78s2.79.06 6.61-1.16c6.55-2.08 16.12-7.96 16.78-11.43.97-5.05-4.21-3.95-5.38-7.94-.61-2.11 2.97-6.1-.49-11.9M79.78 12.09s-2.55-2.61-4.44-3.8c-.94 1.77-1.61 3.69-1.94 5.67-.59 3.48 0 8.42 1.39 12.1.22.57 1.04.48 1.13-.12 1.2-7.91 3.86-13.85 3.86-13.85" style="fill:#e2a610"/><path d="M61.96 38.16S30.77 41.53 16.7 68.61s-2.11 43.5 10.55 49.48 44.56 8.09 65.31 3.17 25.94-15.12 24.97-24.97c-1.41-14.38-14.77-23.22-14.77-23.22s.53-17.76-13.25-29.29c-12.23-10.24-27.55-5.62-27.55-5.62" style="fill:#ffca28"/><path d="M74.76 83.73c-6.69-8.44-14.59-9.57-17.12-12.6-1.38-1.65-2.19-3.32-1.88-5.39.33-2.2 2.88-3.72 4.86-4.09 2.31-.44 7.82-.21 12.45 4.2 1.1 1.04.7 2.66.67 4.11-.08 3.11 4.37 6.13 7.97 3.53 3.61-2.61.84-8.42-1.49-11.24-1.76-2.13-8.14-6.82-16.07-7.56-2.23-.21-11.2-1.54-16.38 8.31-1.49 2.83-2.04 9.67 5.76 15.45 1.63 1.21 10.09 5.51 12.44 8.3 4.07 4.83 1.28 9.08-1.9 9.64-8.67 1.52-13.58-3.17-14.49-5.74-.65-1.83.03-3.81-.81-5.53-.86-1.77-2.62-2.47-4.48-1.88-6.1 1.94-4.16 8.61-1.46 12.28 2.89 3.93 6.44 6.3 10.43 7.6 14.89 4.85 22.05-2.81 23.3-8.42.92-4.11.82-7.67-1.8-10.97" style="fill:#6b4b46"/><path d="M71.16 48.99c-12.67 27.06-14.85 61.23-14.85 61.23" style="fill:none;stroke:#6b4b46;stroke-width:5;stroke-miterlimit:10"/><path d="M81.67 31.96c8.44 2.75 10.31 10.38 9.7 12.46-.73 2.44-10.08-7.06-23.98-6.49-4.86.2-3.45-2.78-1.2-4.5 2.97-2.27 7.96-3.91 15.48-1.47" style="fill:#6d4c41"/><path d="M81.67 31.96c8.44 2.75 10.31 10.38 9.7 12.46-.73 2.44-10.08-7.06-23.98-6.49-4.86.2-3.45-2.78-1.2-4.5 2.97-2.27 7.96-3.91 15.48-1.47" style="fill:#6b4b46"/><path d="M96.49 58.86c1.06-.73 4.62.53 5.62 7.5.49 3.41.64 6.71.64 6.71s-4.2-3.77-5.59-6.42c-1.75-3.35-2.43-6.59-.67-7.79" style="fill:#e2a610"/></svg>
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="28.384802mm"
height="31.749905mm"
viewBox="0 0 28.384802 31.749905"
version="1.1"
id="svg1"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<g
id="layer1"
transform="translate(-37.253301,-88.598061)">
<path
d="m 59.240389,97.978247 c 1.775354,-0.394229 4.087813,-2.156354 4.439709,-3.024187 0.206375,-0.508 -0.822855,-1.30175 -1.098021,-1.621896 -0.629709,-0.73025 -0.375709,-1.090083 -0.132292,-1.960562 0.277813,-0.989542 -0.381,-2.082271 -1.314979,-2.510896 -0.933979,-0.428625 -2.050521,-0.293688 -2.989792,0.124354 -0.939271,0.418042 -1.740958,1.090083 -2.526771,1.751542 -0.574145,-0.36248 -1.489604,-1.963209 -2.97127,-0.923396 -1.023938,0.717021 -1.116542,2.278062 -0.98425,3.52425 0.309562,2.876021 1.018645,4.368271 2.354791,4.770437 1.688042,0.508 3.556,0.240771 5.222875,-0.129646"
style="fill:#ffca28;stroke-width:0.264583"
id="path1-3" />
<path
d="m 62.124348,89.704727 c -0.224896,3.876145 -4.005792,6.447895 -5.799667,7.580312 l 1.164167,1.000125 c 0,0 0.738187,0.01588 1.748895,-0.306917 1.733021,-0.550333 4.265084,-2.106083 4.439709,-3.024187 0.256646,-1.336146 -1.113896,-1.045104 -1.423459,-2.100792 -0.161395,-0.558271 0.785813,-1.613958 -0.129645,-3.148541 m -6.503459,1.03452 c 0,0 -0.674687,-0.690562 -1.17475,-1.005416 -0.248708,0.468312 -0.425979,0.976312 -0.513291,1.500187 -0.156105,0.92075 0,2.227792 0.36777,3.201459 0.05821,0.150812 0.275167,0.127 0.29898,-0.03175 0.3175,-2.092855 1.021291,-3.66448 1.021291,-3.66448"
style="fill:#e2a610;stroke-width:0.264583"
id="path2-6" />
<path
d="m 50.906014,97.636935 c 0,0 -8.252354,0.891646 -11.975042,8.056565 -3.722687,7.16492 -0.558271,11.50937 2.791354,13.09158 3.349626,1.58221 11.789834,2.14048 17.279938,0.83873 5.490104,-1.30175 6.863292,-4.0005 6.606646,-6.60664 -0.373062,-3.80471 -3.907896,-6.14363 -3.907896,-6.14363 0,0 0.140229,-4.699 -3.505729,-7.749647 -3.235854,-2.709333 -7.289271,-1.486958 -7.289271,-1.486958"
style="fill:#ffca28;stroke-width:0.264583"
id="path3-0" />
<path
d="m 56.120952,95.996518 c 2.233083,0.727604 2.727854,2.746375 2.566458,3.296709 -0.193146,0.645583 -2.667,-1.867959 -6.344708,-1.717146 -1.285875,0.05292 -0.912813,-0.735542 -0.3175,-1.190625 0.785812,-0.600604 2.106083,-1.034521 4.09575,-0.388938"
style="fill:#6d4c41;stroke-width:0.264583"
id="path6-6" />
<path
d="m 56.120952,95.996518 c 2.233083,0.727604 2.727854,2.746375 2.566458,3.296709 -0.193146,0.645583 -2.667,-1.867959 -6.344708,-1.717146 -1.285875,0.05292 -0.912813,-0.735542 -0.3175,-1.190625 0.785812,-0.600604 2.106083,-1.034521 4.09575,-0.388938"
style="fill:#6b4b46;stroke-width:0.264583"
id="path7-2" />
<path
d="m 60.042077,103.11381 c 0.280458,-0.19314 1.222375,0.14023 1.486958,1.98438 0.129646,0.90223 0.169333,1.77535 0.169333,1.77535 0,0 -1.11125,-0.99748 -1.47902,-1.69862 -0.463021,-0.88636 -0.642938,-1.74361 -0.177271,-2.06111"
style="fill:#e2a610;stroke-width:0.264583"
id="path8-6" />
<text
xml:space="preserve"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:19.7556px;font-family:'Pirata One';-inkscape-font-specification:'Pirata One, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;text-align:start;letter-spacing:-0.529167px;writing-mode:lr-tb;direction:ltr;text-anchor:start;fill:#000000;stroke-width:0.264583"
x="82.355011"
y="90.66716"
id="text4-9"
transform="rotate(20.578693)"><tspan
id="tspan4-2"
style="font-size:19.7556px;fill:#4d4d4d;stroke-width:0.264583"
x="82.355011"
y="90.66716">$</tspan></text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

100
static/js/dashboard.js Normal file
View File

@@ -0,0 +1,100 @@
// Initialize the echarts instance based on the prepared dom
async function initMainChart() {
const element = document.getElementById('main-chart')
if (element === null) {
return;
}
var myChart = echarts.init(element);
window.addEventListener('resize', function() {
myChart.resize();
});
try {
const response = await fetch("/dashboard/main-chart");
if (!response.ok) {
throw new Error(`Response status: ${response.status}`);
}
const option = await response.json();
option.tooltip.formatter = function (params) {
return new Date(params[0].data[0]).toLocaleString([], { day: 'numeric', month: 'short', year: 'numeric' }) +
'<br />' +
'Sum of Accounts: <span class="font-bold">' + params[0].data[1] + '</span> € <br />' +
'Sum of Savings: <span class="font-bold">' + params[1].data[1] + '</span> €'
};
myChart.setOption(option);
console.log("initialized main-chart");
} catch (error) {
console.error(error.message);
}
}
async function initTreasureChests() {
const element = document.getElementById('treasure-chests')
if (element === null) {
return;
}
var myChart = echarts.init(element);
window.addEventListener('resize', function() {
myChart.resize();
});
try {
const response = await fetch("/dashboard/treasure-chests");
if (!response.ok) {
throw new Error(`Response status: ${response.status}`);
}
const option = await response.json();
myChart.setOption(option);
console.log("initialized treasure-chests");
} catch (error) {
console.error(error.message);
}
}
async function initTreasureChest() {
const element = document.getElementById('treasure-chest')
if (element === null) {
return;
}
var myChart = echarts.init(element);
window.addEventListener('resize', function() {
myChart.resize();
});
const treasureChestSelect = document.getElementById('treasure-chest-id')
treasureChestSelect.addEventListener("change", async (e) => {
try {
const response = await fetch("/dashboard/treasure-chest?id="+e.target.value);
if (!response.ok) {
throw new Error(`Response status: ${response.status}`);
}
const option = await response.json();
option.tooltip.formatter = function (params) {
return new Date(params[0].data[0]).toLocaleString([], { day: 'numeric', month: 'short', year: 'numeric' }) +
'<br />' +
'Sum of Accounts: <span class="font-bold">' + params[0].data[1] + '</span> €'
};
myChart.setOption(option);
} catch (error) {
console.error(error.message);
}
});
console.log("initialized treasure-chest");
}
initMainChart();
initTreasureChests();
initTreasureChest();

11
static/js/layout.js Normal file
View File

@@ -0,0 +1,11 @@
document.addEventListener("DOMContentLoaded", () => {
menuButton.addEventListener("click", function (e) {
menu.showModal();
});
menuButtonClose.addEventListener("click", function (e) {
menu.close();
});
})

View File

@@ -4,7 +4,6 @@ htmx.on("htmx:afterSwap", () => {
});
document.addEventListener("DOMContentLoaded", () => {
console.log("DOMContentLoaded");
updateTime(document);
})
@@ -17,6 +16,7 @@ function updateTime() {
const newDate = value.includes("UTC") ? new Date(value) : value;
el.valueAsDate = newDate;
}
el.classList.remove("datetime");
})
}

43
static/js/transaction.js Normal file
View File

@@ -0,0 +1,43 @@
document.addEventListener("DOMContentLoaded", () => {
if (!page || !page1 || !pagePrev1 || !pageNext1 || !page2 || !pagePrev2 || !pageNext2 || !transactionFilterForm) {
return;
}
const scrollToTop = function() {
window.scrollTo(0, 0);
};
const incPage = function() {
const currPage = Number(page.value);
var nextPage = currPage
if (currPage > 1) {
nextPage -= 1;
page.value = nextPage;
transactionFilterForm.dispatchEvent(new Event('change'));
}
page1.textContent = nextPage;
page2.textContent = nextPage;
scrollToTop();
};
const decPage = function() {
const currPage = Number(page.value);
var nextPage = currPage + 1;
page.value = nextPage;
transactionFilterForm.dispatchEvent(new Event('change'));
page1.textContent = nextPage;
page2.textContent = nextPage;
scrollToTop();
};
pagePrev1.addEventListener("click", incPage);
pagePrev2.addEventListener("click", incPage);
pageNext1.addEventListener("click", decPage);
pageNext2.addEventListener("click", decPage);
console.log("initialized pagination");
})

71
static/logo.svg Normal file
View File

@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="96.820343mm"
height="31.749899mm"
viewBox="0 0 96.820343 31.749899"
version="1.1"
id="svg1"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<g
id="g7"
transform="translate(-38.090175,-77.467441)">
<g
id="g8"
transform="translate(-38.797122,-28.178962)">
<path
d="m 98.874384,115.02659 c 1.775356,-0.39423 4.087816,-2.15635 4.439706,-3.02419 0.20638,-0.508 -0.82285,-1.30175 -1.09802,-1.62189 -0.62971,-0.73025 -0.37571,-1.09009 -0.13229,-1.96057 0.27781,-0.98954 -0.381,-2.08227 -1.31498,-2.51089 -0.933978,-0.42863 -2.05052,-0.29369 -2.989791,0.12435 -0.939271,0.41804 -1.740958,1.09009 -2.526771,1.75154 -0.574145,-0.36248 -1.489604,-1.96321 -2.97127,-0.92339 -1.023938,0.71702 -1.116542,2.27806 -0.98425,3.52425 0.309562,2.87602 1.018645,4.36827 2.354791,4.77043 1.688042,0.508 3.556,0.24078 5.222875,-0.12964"
style="fill:#ffca28;stroke-width:0.264583"
id="path1-3-3" />
<path
d="m 101.75834,106.75307 c -0.22489,3.87614 -4.005789,6.44789 -5.799664,7.58031 l 1.164167,1.00013 c 0,0 0.738187,0.0159 1.748895,-0.30692 1.733022,-0.55033 4.265082,-2.10608 4.439712,-3.02419 0.25664,-1.33614 -1.1139,-1.0451 -1.42346,-2.10079 -0.1614,-0.55827 0.78581,-1.61396 -0.12965,-3.14854 m -6.503456,1.03452 c 0,0 -0.674687,-0.69056 -1.17475,-1.00542 -0.248708,0.46832 -0.425979,0.97632 -0.513291,1.50019 -0.156105,0.92075 0,2.22779 0.36777,3.20146 0.05821,0.15081 0.275167,0.127 0.29898,-0.0317 0.3175,-2.09286 1.021291,-3.66448 1.021291,-3.66448"
style="fill:#e2a610;stroke-width:0.264583"
id="path2-6-6" />
<path
d="m 90.540009,114.68528 c 0,0 -8.252354,0.89164 -11.975042,8.05656 -3.722687,7.16492 -0.558271,11.50937 2.791354,13.09158 3.349626,1.58221 11.789834,2.14048 17.279938,0.83873 5.490101,-1.30175 6.863291,-4.0005 6.606641,-6.60664 -0.37306,-3.80471 -3.90789,-6.14363 -3.90789,-6.14363 0,0 0.14023,-4.699 -3.50573,-7.74964 -3.235854,-2.70934 -7.289271,-1.48696 -7.289271,-1.48696"
style="fill:#ffca28;stroke-width:0.264583"
id="path3-0-1" />
<path
d="m 95.754947,113.04486 c 2.233083,0.7276 2.727854,2.74638 2.566458,3.29671 -0.193146,0.64558 -2.667,-1.86796 -6.344708,-1.71715 -1.285875,0.0529 -0.912813,-0.73554 -0.3175,-1.19062 0.785812,-0.60061 2.106083,-1.03452 4.09575,-0.38894"
style="fill:#6d4c41;stroke-width:0.264583"
id="path6-6-2" />
<path
d="m 95.754947,113.04486 c 2.233083,0.7276 2.727854,2.74638 2.566458,3.29671 -0.193146,0.64558 -2.667,-1.86796 -6.344708,-1.71715 -1.285875,0.0529 -0.912813,-0.73554 -0.3175,-1.19062 0.785812,-0.60061 2.106083,-1.03452 4.09575,-0.38894"
style="fill:#6b4b46;stroke-width:0.264583"
id="path7-2-9" />
<path
d="m 99.676072,120.16215 c 0.280458,-0.19314 1.222378,0.14023 1.486958,1.98438 0.12965,0.90223 0.16933,1.77535 0.16933,1.77535 0,0 -1.11125,-0.99748 -1.479017,-1.69862 -0.463021,-0.88636 -0.642938,-1.74361 -0.177271,-2.06111"
style="fill:#e2a610;stroke-width:0.264583"
id="path8-6-3" />
<text
xml:space="preserve"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:19.7556px;font-family:'Pirata One';-inkscape-font-specification:'Pirata One, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;text-align:start;letter-spacing:-0.529167px;writing-mode:lr-tb;direction:ltr;text-anchor:start;fill:#000000;stroke-width:0.264583"
x="125.45235"
y="92.696564"
id="text4-9-1"
transform="rotate(20.578693)"><tspan
id="tspan4-2-9"
style="font-size:19.7556px;fill:#4d4d4d;stroke-width:0.264583"
x="125.45235"
y="92.696564">$</tspan></text>
</g>
<g
id="layer2"
transform="translate(-1.4293676,48.496402)">
<text
xml:space="preserve"
style="font-size:17.6389px;text-align:start;letter-spacing:-0.529167px;writing-mode:lr-tb;direction:ltr;text-anchor:start;fill:#000000;stroke-width:0.264583"
x="57.635151"
y="55.655094"
id="text1"><tspan
id="tspan1"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:17.6389px;font-family:'Pirata One';-inkscape-font-specification:'Pirata One, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;letter-spacing:-0.529167px;fill:#4d4d4d;stroke:none;stroke-width:0.264583"
x="57.635151"
y="55.655094">pendSparrow</tspan></text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

@@ -1,6 +1,7 @@
package test_test
import (
"context"
"spend-sparrow/internal/db"
"spend-sparrow/internal/types"
"testing"
@@ -26,7 +27,7 @@ func setupDb(t *testing.T) *sqlx.DB {
}
})
err = db.RunMigrations(d, "../")
err = db.RunMigrations(context.Background(), d, "../")
if err != nil {
t.Fatalf("Error running migrations: %v", err)
}
@@ -47,14 +48,14 @@ func TestUser(t *testing.T) {
createAt := time.Date(2020, 1, 5, 12, 0, 0, 0, time.UTC)
expected := types.NewUser(uuid.New(), "some@email.de", true, &verifiedAt, false, []byte("somePass"), []byte("someSalt"), createAt)
err := underTest.InsertUser(expected)
err := underTest.InsertUser(context.Background(), expected)
require.NoError(t, err)
actual, err := underTest.GetUser(expected.Id)
actual, err := underTest.GetUser(context.Background(), expected.Id)
require.NoError(t, err)
assert.Equal(t, expected, actual)
actual, err = underTest.GetUserByEmail(expected.Email)
actual, err = underTest.GetUserByEmail(context.Background(), expected.Email)
require.NoError(t, err)
assert.Equal(t, expected, actual)
})
@@ -64,7 +65,7 @@ func TestUser(t *testing.T) {
underTest := db.NewAuthSqlite(d)
_, err := underTest.GetUserByEmail("nonExistentEmail")
_, err := underTest.GetUserByEmail(context.Background(), "nonExistentEmail")
assert.Equal(t, db.ErrNotFound, err)
})
t.Run("should return ErrUserExist", func(t *testing.T) {
@@ -77,10 +78,10 @@ func TestUser(t *testing.T) {
createAt := time.Date(2020, 1, 5, 12, 0, 0, 0, time.UTC)
user := types.NewUser(uuid.New(), "some@email.de", true, &verifiedAt, false, []byte("somePass"), []byte("someSalt"), createAt)
err := underTest.InsertUser(user)
err := underTest.InsertUser(context.Background(), user)
require.NoError(t, err)
err = underTest.InsertUser(user)
err = underTest.InsertUser(context.Background(), user)
assert.Equal(t, db.ErrAlreadyExists, err)
})
t.Run("should return ErrInternal on missing NOT NULL fields", func(t *testing.T) {
@@ -92,7 +93,7 @@ func TestUser(t *testing.T) {
createAt := time.Date(2020, 1, 5, 12, 0, 0, 0, time.UTC)
user := types.NewUser(uuid.New(), "some@email.de", false, nil, false, []byte("somePass"), nil, createAt)
err := underTest.InsertUser(user)
err := underTest.InsertUser(context.Background(), user)
assert.Equal(t, types.ErrInternal, err)
})
}
@@ -110,21 +111,21 @@ func TestToken(t *testing.T) {
expiresAt := createAt.Add(24 * time.Hour)
expected := types.NewToken(uuid.New(), "sessionId", "token", types.TokenTypeCsrf, createAt, expiresAt)
err := underTest.InsertToken(expected)
err := underTest.InsertToken(context.Background(), expected)
require.NoError(t, err)
actual, err := underTest.GetToken(expected.Token)
actual, err := underTest.GetToken(context.Background(), expected.Token)
require.NoError(t, err)
assert.Equal(t, expected, actual)
expected.SessionId = ""
actuals, err := underTest.GetTokensByUserIdAndType(expected.UserId, expected.Type)
actuals, err := underTest.GetTokensByUserIdAndType(context.Background(), expected.UserId, expected.Type)
require.NoError(t, err)
assert.Equal(t, []*types.Token{expected}, actuals)
expected.SessionId = "sessionId"
expected.UserId = uuid.Nil
actuals, err = underTest.GetTokensBySessionIdAndType(expected.SessionId, expected.Type)
actuals, err = underTest.GetTokensBySessionIdAndType(context.Background(), expected.SessionId, expected.Type)
require.NoError(t, err)
assert.Equal(t, []*types.Token{expected}, actuals)
})
@@ -140,14 +141,14 @@ func TestToken(t *testing.T) {
expected1 := types.NewToken(userId, "sessionId", "token1", types.TokenTypeCsrf, createAt, expiresAt)
expected2 := types.NewToken(userId, "sessionId", "token2", types.TokenTypeCsrf, createAt, expiresAt)
err := underTest.InsertToken(expected1)
err := underTest.InsertToken(context.Background(), expected1)
require.NoError(t, err)
err = underTest.InsertToken(expected2)
err = underTest.InsertToken(context.Background(), expected2)
require.NoError(t, err)
expected1.UserId = uuid.Nil
expected2.UserId = uuid.Nil
actuals, err := underTest.GetTokensBySessionIdAndType(expected1.SessionId, expected1.Type)
actuals, err := underTest.GetTokensBySessionIdAndType(context.Background(), expected1.SessionId, expected1.Type)
require.NoError(t, err)
assert.Equal(t, []*types.Token{expected1, expected2}, actuals)
@@ -155,7 +156,7 @@ func TestToken(t *testing.T) {
expected2.SessionId = ""
expected1.UserId = userId
expected2.UserId = userId
actuals, err = underTest.GetTokensByUserIdAndType(userId, expected1.Type)
actuals, err = underTest.GetTokensByUserIdAndType(context.Background(), userId, expected1.Type)
require.NoError(t, err)
assert.Equal(t, []*types.Token{expected1, expected2}, actuals)
})
@@ -165,13 +166,13 @@ func TestToken(t *testing.T) {
underTest := db.NewAuthSqlite(d)
_, err := underTest.GetToken("nonExistent")
_, err := underTest.GetToken(context.Background(), "nonExistent")
assert.Equal(t, db.ErrNotFound, err)
_, err = underTest.GetTokensByUserIdAndType(uuid.New(), types.TokenTypeEmailVerify)
_, err = underTest.GetTokensByUserIdAndType(context.Background(), uuid.New(), types.TokenTypeEmailVerify)
assert.Equal(t, db.ErrNotFound, err)
_, err = underTest.GetTokensBySessionIdAndType("sessionId", types.TokenTypeEmailVerify)
_, err = underTest.GetTokensBySessionIdAndType(context.Background(), "sessionId", types.TokenTypeEmailVerify)
assert.Equal(t, db.ErrNotFound, err)
})
t.Run("should return ErrAlreadyExists", func(t *testing.T) {
@@ -184,10 +185,10 @@ func TestToken(t *testing.T) {
createAt := time.Date(2020, 1, 5, 12, 0, 0, 0, time.UTC)
user := types.NewUser(uuid.New(), "some@email.de", true, &verifiedAt, false, []byte("somePass"), []byte("someSalt"), createAt)
err := underTest.InsertUser(user)
err := underTest.InsertUser(context.Background(), user)
require.NoError(t, err)
err = underTest.InsertUser(user)
err = underTest.InsertUser(context.Background(), user)
assert.Equal(t, db.ErrAlreadyExists, err)
})
t.Run("should return ErrInternal on missing NOT NULL fields", func(t *testing.T) {
@@ -199,7 +200,7 @@ func TestToken(t *testing.T) {
createAt := time.Date(2020, 1, 5, 12, 0, 0, 0, time.UTC)
user := types.NewUser(uuid.New(), "some@email.de", false, nil, false, []byte("somePass"), nil, createAt)
err := underTest.InsertUser(user)
err := underTest.InsertUser(context.Background(), user)
assert.Equal(t, types.ErrInternal, err)
})
}

View File

@@ -1,6 +1,7 @@
package test_test
import (
"context"
"spend-sparrow/internal/db"
"spend-sparrow/internal/service"
"spend-sparrow/internal/types"
@@ -36,7 +37,7 @@ func TestSignUp(t *testing.T) {
underTest := service.NewAuth(mockAuthDb, mockRandom, mockClock, mockMail, &settings)
_, err := underTest.SignUp("invalid email address", "SomeStrongPassword123!")
_, err := underTest.SignUp(context.Background(), "invalid email address", "SomeStrongPassword123!")
assert.Equal(t, service.ErrInvalidEmail, err)
})
@@ -58,7 +59,7 @@ func TestSignUp(t *testing.T) {
}
for _, password := range weakPasswords {
_, err := underTest.SignUp("some@valid.email", password)
_, err := underTest.SignUp(context.Background(), "some@valid.email", password)
assert.Equal(t, service.ErrInvalidPassword, err)
}
})
@@ -78,13 +79,15 @@ func TestSignUp(t *testing.T) {
expected := types.NewUser(userId, email, false, nil, false, service.GetHashPassword(password, salt), salt, createTime)
mockRandom.EXPECT().UUID().Return(userId, nil)
mockRandom.EXPECT().Bytes(16).Return(salt, nil)
ctx := context.Background()
mockRandom.EXPECT().UUID(ctx).Return(userId, nil)
mockRandom.EXPECT().Bytes(ctx, 16).Return(salt, nil)
mockClock.EXPECT().Now().Return(createTime)
mockAuthDb.EXPECT().InsertUser(expected).Return(nil)
mockAuthDb.EXPECT().InsertUser(context.Background(), expected).Return(nil)
underTest := service.NewAuth(mockAuthDb, mockRandom, mockClock, mockMail, &settings)
actual, err := underTest.SignUp(email, password)
actual, err := underTest.SignUp(context.Background(), email, password)
require.NoError(t, err)
@@ -105,15 +108,16 @@ func TestSignUp(t *testing.T) {
salt := []byte("salt")
user := types.NewUser(userId, email, false, nil, false, service.GetHashPassword(password, salt), salt, createTime)
mockRandom.EXPECT().UUID().Return(user.Id, nil)
mockRandom.EXPECT().Bytes(16).Return(salt, nil)
ctx := context.Background()
mockRandom.EXPECT().UUID(ctx).Return(user.Id, nil)
mockRandom.EXPECT().Bytes(ctx, 16).Return(salt, nil)
mockClock.EXPECT().Now().Return(createTime)
mockAuthDb.EXPECT().InsertUser(user).Return(db.ErrAlreadyExists)
mockAuthDb.EXPECT().InsertUser(context.Background(), user).Return(db.ErrAlreadyExists)
underTest := service.NewAuth(mockAuthDb, mockRandom, mockClock, mockMail, &settings)
_, err := underTest.SignUp(user.Email, password)
_, err := underTest.SignUp(context.Background(), user.Email, password)
assert.Equal(t, service.ErrAccountExists, err)
})
}
@@ -140,14 +144,14 @@ func TestSendVerificationMail(t *testing.T) {
mockClock := mocks.NewMockClock(t)
mockMail := mocks.NewMockMail(t)
mockAuthDb.EXPECT().GetTokensByUserIdAndType(userId, types.TokenTypeEmailVerify).Return(tokens, nil)
mockMail.EXPECT().SendMail(email, "Welcome to spend-sparrow", mock.MatchedBy(func(message string) bool {
ctx := context.Background()
mockAuthDb.EXPECT().GetTokensByUserIdAndType(context.Background(), userId, types.TokenTypeEmailVerify).Return(tokens, nil)
mockMail.EXPECT().SendMail(ctx, email, "Welcome to spend-sparrow", mock.MatchedBy(func(message string) bool {
return strings.Contains(message, token.Token)
})).Return()
underTest := service.NewAuth(mockAuthDb, mockRandom, mockClock, mockMail, &settings)
underTest.SendVerificationMail(userId, email)
underTest.SendVerificationMail(context.Background(), userId, email)
})
}

View File

@@ -152,7 +152,7 @@ func getTokenAttribute(t *testing.T, data *html.Node) string {
for _, attr := range data.Attr {
if attr.Key == "hx-headers" {
var data map[string]interface{}
var data map[string]any
err := json.Unmarshal([]byte(attr.Val), &data)
require.NoError(t, err)
result, ok := data["Csrf-Token"].(string)
@@ -182,16 +182,16 @@ func createValidUserSession(t *testing.T, db *sqlx.DB, add string) (uuid.UUID, s
csrfToken := "my-verifying-token" + add
email := add + "mail@mail.de"
_, err := db.Exec(`
_, err := db.ExecContext(context.Background(), `
INSERT INTO user (user_id, email, email_verified, is_admin, password, salt, created_at)
VALUES (?, ?, TRUE, FALSE, ?, ?, datetime())`, userId, email, pass, []byte("salt"))
require.NoError(t, err)
_, err = db.Exec(`
_, err = db.ExecContext(context.Background(), `
INSERT INTO session (session_id, user_id, created_at, expires_at)
VALUES (?, ?, datetime(), datetime("now", "+1 day"))`, sessionId, userId)
require.NoError(t, err)
_, err = db.Exec(`
_, err = db.ExecContext(context.Background(), `
INSERT INTO token (token, user_id, session_id, type, created_at, expires_at)
VALUES (?, ?, ?, ?, datetime(), datetime("now", "+1 day"))`, csrfToken, userId, sessionId, types.TokenTypeCsrf)
require.NoError(t, err)

View File

@@ -112,11 +112,11 @@ func TestIntegrationAuth(t *testing.T) {
sessionId := "session-id"
pass := service.GetHashPassword("password", []byte("salt"))
_, err := db.Exec(`
_, err := db.ExecContext(ctx, `
INSERT INTO user (user_id, email, email_verified, is_admin, password, salt, created_at)
VALUES (?, "mail@mail.de", TRUE, FALSE, ?, ?, datetime())`, userId, pass, []byte("salt"))
require.NoError(t, err)
_, err = db.Exec(`
_, err = db.ExecContext(ctx, `
INSERT INTO session (session_id, user_id, created_at, expires_at)
VALUES (?, ?, datetime(), datetime("now", "+1 day"))`, sessionId, userId)
require.NoError(t, err)
@@ -138,7 +138,7 @@ func TestIntegrationAuth(t *testing.T) {
userId := uuid.New()
pass := service.GetHashPassword("password", []byte("salt"))
_, err := db.Exec(`
_, err := db.ExecContext(ctx, `
INSERT INTO user (user_id, email, email_verified, is_admin, password, salt, created_at)
VALUES (?, "mail@mail.de", TRUE, FALSE, ?, ?, datetime())`, userId, pass, []byte("salt"))
require.NoError(t, err)
@@ -165,7 +165,7 @@ func TestIntegrationAuth(t *testing.T) {
userId := uuid.New()
pass := service.GetHashPassword("password", []byte("salt"))
_, err := db.Exec(`
_, err := db.ExecContext(ctx, `
INSERT INTO user (user_id, email, email_verified, is_admin, password, salt, created_at)
VALUES (?, "mail@mail.de", TRUE, FALSE, ?, ?, datetime())`, userId, pass, []byte("salt"))
require.NoError(t, err)
@@ -208,7 +208,7 @@ func TestIntegrationAuth(t *testing.T) {
userId := uuid.New()
pass := service.GetHashPassword("password", []byte("salt"))
_, err := db.Exec(`
_, err := db.ExecContext(ctx, `
INSERT INTO user (user_id, email, email_verified, is_admin, password, salt, created_at)
VALUES (?, "mail@mail.de", TRUE, FALSE, ?, ?, datetime())`, userId, pass, []byte("salt"))
require.NoError(t, err)
@@ -248,7 +248,7 @@ func TestIntegrationAuth(t *testing.T) {
db, basePath, ctx := setupIntegrationTest(t)
pass := service.GetHashPassword("password", []byte("salt"))
_, err := db.Exec(`
_, err := db.ExecContext(ctx, `
INSERT INTO user (user_id, email, email_verified, is_admin, password, salt, created_at)
VALUES (?, "mail@mail.de", FALSE, FALSE, ?, ?, datetime())`, uuid.New(), pass, []byte("salt"))
require.NoError(t, err)
@@ -296,7 +296,7 @@ func TestIntegrationAuth(t *testing.T) {
db, basePath, ctx := setupIntegrationTest(t)
pass := service.GetHashPassword("password", []byte("salt"))
_, err := db.Exec(`
_, err := db.ExecContext(ctx, `
INSERT INTO user (user_id, email, email_verified, is_admin, password, salt, created_at)
VALUES (?, "mail@mail.de", FALSE, FALSE, ?, ?, datetime())`, uuid.New(), pass, []byte("salt"))
require.NoError(t, err)
@@ -415,7 +415,7 @@ func TestIntegrationAuth(t *testing.T) {
db, basePath, ctx := setupIntegrationTest(t)
pass := service.GetHashPassword("password", []byte("salt"))
_, err := db.Exec(`
_, err := db.ExecContext(ctx, `
INSERT INTO user (user_id, email, email_verified, is_admin, password, salt, created_at)
VALUES (?, "mail@mail.de", FALSE, FALSE, ?, ?, datetime())`, uuid.New(), pass, []byte("salt"))
require.NoError(t, err)
@@ -451,10 +451,10 @@ func TestIntegrationAuth(t *testing.T) {
assert.Equal(t, http.StatusOK, resp.StatusCode)
var rows int
err = db.QueryRow("SELECT COUNT(*) FROM session WHERE session_id = ?", anonymousSession.Value).Scan(&rows)
err = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM session WHERE session_id = ?", anonymousSession.Value).Scan(&rows)
require.NoError(t, err)
assert.Equal(t, 0, rows)
err = db.QueryRow("SELECT COUNT(*) FROM token WHERE token = ?", anonymousCsrfToken).Scan(&rows)
err = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM token WHERE token = ?", anonymousCsrfToken).Scan(&rows)
require.NoError(t, err)
assert.Equal(t, 0, rows)
})
@@ -469,11 +469,11 @@ func TestIntegrationAuth(t *testing.T) {
sessionId := "session-id"
pass := service.GetHashPassword("password", []byte("salt"))
_, err := db.Exec(`
_, err := db.ExecContext(ctx, `
INSERT INTO user (user_id, email, email_verified, is_admin, password, salt, created_at)
VALUES (?, "mail@mail.de", TRUE, FALSE, ?, ?, datetime())`, userId, pass, []byte("salt"))
require.NoError(t, err)
_, err = db.Exec(`
_, err = db.ExecContext(ctx, `
INSERT INTO session (session_id, user_id, created_at, expires_at)
VALUES (?, ?, datetime(), datetime("now", "+1 day"))`, sessionId, userId)
require.NoError(t, err)
@@ -548,7 +548,7 @@ func TestIntegrationAuth(t *testing.T) {
db, basePath, ctx := setupIntegrationTest(t)
_, err := db.Exec(`
_, err := db.ExecContext(ctx, `
INSERT INTO user (user_id, email, email_verified, is_admin, password, salt, created_at)
VALUES (?, "mail@mail.de", TRUE, FALSE, ?, ?, datetime())`, uuid.New(), service.GetHashPassword("password", []byte("salt")), []byte("salt"))
require.NoError(t, err)
@@ -627,11 +627,11 @@ func TestIntegrationAuth(t *testing.T) {
assert.Contains(t, resp.Header.Get("Hx-Trigger"), "An activation link has been send to your email")
var rows int
err = db.QueryRow("SELECT COUNT(*) FROM user WHERE email = ? AND email_verified = FALSE", "mail@mail.de").Scan(&rows)
err = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM user WHERE email = ? AND email_verified = FALSE", "mail@mail.de").Scan(&rows)
require.NoError(t, err)
assert.Equal(t, 1, rows)
var token string
err = db.QueryRow("SELECT t.token FROM token t INNER JOIN user u ON u.user_id = t.user_id WHERE u.email = ? AND t.type = ?", "mail@mail.de", types.TokenTypeEmailVerify).Scan(&token)
err = db.QueryRowContext(ctx, "SELECT t.token FROM token t INNER JOIN user u ON u.user_id = t.user_id WHERE u.email = ? AND t.type = ?", "mail@mail.de", types.TokenTypeEmailVerify).Scan(&token)
require.NoError(t, err)
assert.NotEmpty(t, token)
})
@@ -644,7 +644,7 @@ func TestIntegrationAuth(t *testing.T) {
userId := uuid.New()
_, err := db.Exec(`
_, err := db.ExecContext(ctx, `
INSERT INTO user (user_id, email, email_verified, is_admin, password, salt, created_at)
VALUES (?, "mail@mail.de", FALSE, FALSE, ?, ?, datetime())`, userId, []byte("pass"), []byte("salt"))
require.NoError(t, err)
@@ -658,7 +658,7 @@ func TestIntegrationAuth(t *testing.T) {
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
var rows int
err = db.QueryRow("SELECT COUNT(*) FROM user WHERE user_id = ? AND email_verified = FALSE", userId).Scan(&rows)
err = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM user WHERE user_id = ? AND email_verified = FALSE", userId).Scan(&rows)
require.NoError(t, err)
assert.Equal(t, 1, rows)
})
@@ -670,11 +670,11 @@ func TestIntegrationAuth(t *testing.T) {
userId := uuid.New()
token := "my-outdated-verifying-token"
_, err := db.Exec(`
_, err := db.ExecContext(ctx, `
INSERT INTO user (user_id, email, email_verified, is_admin, password, salt, created_at)
VALUES (?, "mail@mail.de", FALSE, FALSE, ?, ?, datetime())`, userId, []byte("pass"), []byte("salt"))
require.NoError(t, err)
_, err = db.Exec(`
_, err = db.ExecContext(ctx, `
INSERT INTO token (token, user_id, type, created_at, expires_at)
VALUES (?, ?, ?, datetime("now", "-16 minute"), datetime("now", "-1 minute"))`, token, userId, types.TokenTypeEmailVerify)
require.NoError(t, err)
@@ -688,7 +688,7 @@ func TestIntegrationAuth(t *testing.T) {
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
var rows int
err = db.QueryRow("SELECT COUNT(*) FROM user WHERE user_id = ? AND email_verified = FALSE", userId).Scan(&rows)
err = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM user WHERE user_id = ? AND email_verified = FALSE", userId).Scan(&rows)
require.NoError(t, err)
assert.Equal(t, 1, rows)
})
@@ -700,11 +700,11 @@ func TestIntegrationAuth(t *testing.T) {
userId := uuid.New()
token := "my-verifying-token"
_, err := db.Exec(`
_, err := db.ExecContext(ctx, `
INSERT INTO user (user_id, email, email_verified, is_admin, password, salt, created_at)
VALUES (?, "mail@mail.de", FALSE, FALSE, ?, ?, datetime())`, userId, []byte("pass"), []byte("salt"))
require.NoError(t, err)
_, err = db.Exec(`
_, err = db.ExecContext(ctx, `
INSERT INTO token (token, user_id, session_id, type, created_at, expires_at)
VALUES (?, ?, "", ?, datetime("now"), datetime("now", "+15 minute"))`, token, userId, types.TokenTypeEmailVerify)
require.NoError(t, err)
@@ -718,7 +718,7 @@ func TestIntegrationAuth(t *testing.T) {
assert.Equal(t, http.StatusOK, resp.StatusCode)
var rows int
err = db.QueryRow("SELECT COUNT(*) FROM user WHERE user_id = ? AND email_verified = TRUE", userId).Scan(&rows)
err = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM user WHERE user_id = ? AND email_verified = TRUE", userId).Scan(&rows)
require.NoError(t, err)
assert.Equal(t, 1, rows)
})
@@ -747,16 +747,16 @@ func TestIntegrationAuth(t *testing.T) {
sessionId := "session-id"
pass := service.GetHashPassword("password", []byte("salt"))
_, err := db.Exec(`
_, err := db.ExecContext(ctx, `
INSERT INTO user (user_id, email, email_verified, is_admin, password, salt, created_at)
VALUES (?, "mail@mail.de", FALSE, FALSE, ?, ?, datetime())`, userId, pass, []byte("salt"))
require.NoError(t, err)
_, err = db.Exec(`
_, err = db.ExecContext(ctx, `
INSERT INTO session (session_id, user_id, created_at, expires_at)
VALUES (?, ?, datetime(), datetime("now", "+1 day"))`, sessionId, userId)
require.NoError(t, err)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, basePath+"/", nil)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, basePath+"/dashboard", nil)
require.NoError(t, err)
req.Header.Set("Cookie", "id="+sessionId)
resp, err := httpClient.Do(req)
@@ -765,7 +765,7 @@ func TestIntegrationAuth(t *testing.T) {
assert.Equal(t, http.StatusOK, resp.StatusCode)
var csrfToken string
err = db.QueryRow("SELECT token FROM token WHERE user_id = ? AND type = ?", userId, types.TokenTypeCsrf).Scan(&csrfToken)
err = db.QueryRowContext(ctx, "SELECT token FROM token WHERE user_id = ? AND type = ?", userId, types.TokenTypeCsrf).Scan(&csrfToken)
require.NoError(t, err)
req, err = http.NewRequestWithContext(ctx, http.MethodPost, basePath+"/api/auth/signout", nil)
@@ -785,7 +785,7 @@ func TestIntegrationAuth(t *testing.T) {
assert.Equal(t, -1, cookie.MaxAge)
var rows int
err = db.QueryRow("SELECT COUNT(*) FROM session WHERE user_id = ?", userId).Scan(&rows)
err = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM session WHERE user_id = ?", userId).Scan(&rows)
require.NoError(t, err)
assert.Equal(t, 0, rows)
})
@@ -825,13 +825,13 @@ func TestIntegrationAuth(t *testing.T) {
userId := uuid.New()
pass := service.GetHashPassword("password", []byte("salt"))
_, err := db.Exec(`
_, err := db.ExecContext(ctx, `
INSERT INTO user (user_id, email, email_verified, is_admin, password, salt, created_at)
VALUES (?, "mail@mail.de", FALSE, FALSE, ?, ?, datetime())`, userId, pass, []byte("salt"))
sessionId := "session-id"
require.NoError(t, err)
_, err = db.Exec(`
_, err = db.ExecContext(ctx, `
INSERT INTO session (session_id, user_id, created_at, expires_at)
VALUES (?, ?, datetime(), datetime("now", "+1 day"))`, sessionId, userId)
require.NoError(t, err)
@@ -871,13 +871,13 @@ func TestIntegrationAuth(t *testing.T) {
userId := uuid.New()
pass := service.GetHashPassword("password", []byte("salt"))
_, err := db.Exec(`
_, err := db.ExecContext(ctx, `
INSERT INTO user (user_id, email, email_verified, is_admin, password, salt, created_at)
VALUES (?, "mail@mail.de", FALSE, FALSE, ?, ?, datetime())`, userId, pass, []byte("salt"))
sessionId := "session-id"
require.NoError(t, err)
_, err = db.Exec(`
_, err = db.ExecContext(ctx, `
INSERT INTO session (session_id, user_id, created_at, expires_at)
VALUES (?, ?, datetime(), datetime("now", "+1 day"))`, sessionId, userId)
require.NoError(t, err)
@@ -964,22 +964,22 @@ func TestIntegrationAuth(t *testing.T) {
assert.Equal(t, http.StatusOK, resp.StatusCode)
var rows int
err = db.QueryRow("SELECT COUNT(*) FROM session WHERE user_id = ?", userId).Scan(&rows)
err = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM session WHERE user_id = ?", userId).Scan(&rows)
require.NoError(t, err)
assert.Equal(t, 0, rows)
err = db.QueryRow("SELECT COUNT(*) FROM token WHERE user_id = ?", userId).Scan(&rows)
err = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM token WHERE user_id = ?", userId).Scan(&rows)
require.NoError(t, err)
assert.Equal(t, 0, rows)
err = db.QueryRow("SELECT COUNT(*) FROM user WHERE user_id = ?", userId).Scan(&rows)
err = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM user WHERE user_id = ?", userId).Scan(&rows)
require.NoError(t, err)
assert.Equal(t, 0, rows)
err = db.QueryRow("SELECT COUNT(*) FROM account WHERE user_id = ?", userId).Scan(&rows)
err = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM account WHERE user_id = ?", userId).Scan(&rows)
require.NoError(t, err)
assert.Equal(t, 0, rows)
err = db.QueryRow("SELECT COUNT(*) FROM treasure_chest WHERE user_id = ?", userId).Scan(&rows)
err = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM treasure_chest WHERE user_id = ?", userId).Scan(&rows)
require.NoError(t, err)
assert.Equal(t, 0, rows)
err = db.QueryRow("SELECT COUNT(*) FROM \"transaction\" WHERE user_id = ?", userId).Scan(&rows)
err = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM \"transaction\" WHERE user_id = ?", userId).Scan(&rows)
require.NoError(t, err)
assert.Equal(t, 0, rows)
})
@@ -1040,13 +1040,13 @@ func TestIntegrationAuth(t *testing.T) {
userId := uuid.New()
pass := service.GetHashPassword("password", []byte("salt"))
_, err := db.Exec(`
_, err := db.ExecContext(ctx, `
INSERT INTO user (user_id, email, email_verified, is_admin, password, salt, created_at)
VALUES (?, "mail@mail.de", FALSE, FALSE, ?, ?, datetime())`, userId, pass, []byte("salt"))
sessionId := "session-id"
require.NoError(t, err)
_, err = db.Exec(`
_, err = db.ExecContext(ctx, `
INSERT INTO session (session_id, user_id, created_at, expires_at)
VALUES (?, ?, datetime(), datetime("now", "+1 day"))`, sessionId, userId)
require.NoError(t, err)
@@ -1069,7 +1069,7 @@ func TestIntegrationAuth(t *testing.T) {
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
var rows int
err = db.QueryRow("SELECT COUNT(*) FROM user WHERE user_id = ? AND password = ?", userId, pass).Scan(&rows)
err = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM user WHERE user_id = ? AND password = ?", userId, pass).Scan(&rows)
require.NoError(t, err)
assert.Equal(t, 1, rows)
})
@@ -1080,13 +1080,13 @@ func TestIntegrationAuth(t *testing.T) {
userId := uuid.New()
pass := service.GetHashPassword("password", []byte("salt"))
_, err := db.Exec(`
_, err := db.ExecContext(ctx, `
INSERT INTO user (user_id, email, email_verified, is_admin, password, salt, created_at)
VALUES (?, "mail@mail.de", FALSE, FALSE, ?, ?, datetime())`, userId, pass, []byte("salt"))
sessionId := "session-id"
require.NoError(t, err)
_, err = db.Exec(`
_, err = db.ExecContext(ctx, `
INSERT INTO session (session_id, user_id, created_at, expires_at)
VALUES (?, ?, datetime(), datetime("now", "+1 day"))`, sessionId, userId)
require.NoError(t, err)
@@ -1119,7 +1119,7 @@ func TestIntegrationAuth(t *testing.T) {
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
var rows int
err = db.QueryRow("SELECT COUNT(*) FROM user WHERE user_id = ? AND password = ?", userId, pass).Scan(&rows)
err = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM user WHERE user_id = ? AND password = ?", userId, pass).Scan(&rows)
require.NoError(t, err)
assert.Equal(t, 1, rows)
})
@@ -1130,13 +1130,13 @@ func TestIntegrationAuth(t *testing.T) {
userId := uuid.New()
pass := service.GetHashPassword("password", []byte("salt"))
_, err := db.Exec(`
_, err := db.ExecContext(ctx, `
INSERT INTO user (user_id, email, email_verified, is_admin, password, salt, created_at)
VALUES (?, "mail@mail.de", FALSE, FALSE, ?, ?, datetime())`, userId, pass, []byte("salt"))
sessionId := "session-id"
require.NoError(t, err)
_, err = db.Exec(`
_, err = db.ExecContext(ctx, `
INSERT INTO session (session_id, user_id, created_at, expires_at)
VALUES (?, ?, datetime(), datetime("now", "+1 day"))`, sessionId, userId)
require.NoError(t, err)
@@ -1169,7 +1169,7 @@ func TestIntegrationAuth(t *testing.T) {
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
var rows int
err = db.QueryRow("SELECT COUNT(*) FROM user WHERE user_id = ? AND password = ?", userId, pass).Scan(&rows)
err = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM user WHERE user_id = ? AND password = ?", userId, pass).Scan(&rows)
require.NoError(t, err)
assert.Equal(t, 1, rows)
})
@@ -1181,21 +1181,21 @@ func TestIntegrationAuth(t *testing.T) {
userIdOther := uuid.New()
pass := service.GetHashPassword("password", []byte("salt"))
_, err := db.Exec(`
_, err := db.ExecContext(ctx, `
INSERT INTO user (user_id, email, email_verified, is_admin, password, salt, created_at)
VALUES (?, "mail@mail.de", FALSE, FALSE, ?, ?, datetime())`, userId, pass, []byte("salt"))
sessionId := "session-id"
require.NoError(t, err)
_, err = db.Exec(`
_, err = db.ExecContext(ctx, `
INSERT INTO session (session_id, user_id, created_at, expires_at)
VALUES (?, ?, datetime(), datetime("now", "+1 day"))`, sessionId, userId)
require.NoError(t, err)
_, err = db.Exec(`
_, err = db.ExecContext(ctx, `
INSERT INTO session (session_id, user_id, created_at, expires_at)
VALUES ("second", ?, datetime(), datetime("now", "+1 day"))`, userId)
require.NoError(t, err)
_, err = db.Exec(`
_, err = db.ExecContext(ctx, `
INSERT INTO session (session_id, user_id, created_at, expires_at)
VALUES ("other", ?, datetime(), datetime("now", "+1 day"))`, userIdOther)
require.NoError(t, err)
@@ -1232,12 +1232,12 @@ func TestIntegrationAuth(t *testing.T) {
pass = service.GetHashPassword("MyNewSecurePassword1!", []byte("salt"))
var rows int
err = db.QueryRow("SELECT COUNT(*) FROM user WHERE user_id = ? AND password = ?", userId, pass).Scan(&rows)
err = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM user WHERE user_id = ? AND password = ?", userId, pass).Scan(&rows)
require.NoError(t, err)
assert.Equal(t, 1, rows)
var sessionIds []string
sessions, err := db.Query(`SELECT session_id FROM session WHERE NOT user_id = ? ORDER BY session_id`, uuid.Nil)
sessions, err := db.QueryContext(ctx, `SELECT session_id FROM session WHERE NOT user_id = ? ORDER BY session_id`, uuid.Nil)
require.NoError(t, err)
for sessions.Next() {
var sessionId string
@@ -1260,13 +1260,13 @@ func TestIntegrationAuth(t *testing.T) {
userId := uuid.New()
pass := service.GetHashPassword("password", []byte("salt"))
_, err := d.Exec(`
_, err := d.ExecContext(ctx, `
INSERT INTO user (user_id, email, email_verified, is_admin, password, salt, created_at)
VALUES (?, "mail@mail.de", FALSE, FALSE, ?, ?, datetime())`, userId, pass, []byte("salt"))
require.NoError(t, err)
sessionId := "session-id"
_, err = d.Exec(`
_, err = d.ExecContext(ctx, `
INSERT INTO session (session_id, user_id, created_at, expires_at)
VALUES ("session-id", ?, datetime(), datetime("now", "+1 day"))`, userId)
require.NoError(t, err)
@@ -1288,7 +1288,7 @@ func TestIntegrationAuth(t *testing.T) {
userId := uuid.New()
pass := service.GetHashPassword("password", []byte("salt"))
_, err := d.Exec(`
_, err := d.ExecContext(ctx, `
INSERT INTO user (user_id, email, email_verified, is_admin, password, salt, created_at)
VALUES (?, "mail@mail.de", FALSE, FALSE, ?, ?, datetime())`, userId, pass, []byte("salt"))
require.NoError(t, err)
@@ -1317,7 +1317,7 @@ func TestIntegrationAuth(t *testing.T) {
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
var rows int
err = d.QueryRow("SELECT COUNT(*) FROM token WHERE user_id = ? AND type = ?", userId, types.TokenTypePasswordReset).Scan(&rows)
err = d.QueryRowContext(ctx, "SELECT COUNT(*) FROM token WHERE user_id = ? AND type = ?", userId, types.TokenTypePasswordReset).Scan(&rows)
require.NoError(t, err)
assert.Equal(t, 0, rows)
})
@@ -1363,7 +1363,7 @@ func TestIntegrationAuth(t *testing.T) {
userId := uuid.New()
pass := service.GetHashPassword("password", []byte("salt"))
_, err := db.Exec(`
_, err := db.ExecContext(ctx, `
INSERT INTO user (user_id, email, email_verified, is_admin, password, salt, created_at)
VALUES (?, "mail@mail.de", TRUE, FALSE, ?, ?, datetime())`, userId, pass, []byte("salt"))
require.NoError(t, err)
@@ -1399,7 +1399,7 @@ func TestIntegrationAuth(t *testing.T) {
assert.Contains(t, resp.Header.Get("Hx-Trigger"), msg)
var rows int
err = db.QueryRow("SELECT COUNT(*) FROM token WHERE user_id = ? AND type = ?", userId, types.TokenTypePasswordReset).Scan(&rows)
err = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM token WHERE user_id = ? AND type = ?", userId, types.TokenTypePasswordReset).Scan(&rows)
require.NoError(t, err)
assert.Equal(t, 1, rows)
})
@@ -1413,7 +1413,7 @@ func TestIntegrationAuth(t *testing.T) {
userId := uuid.New()
pass := service.GetHashPassword("password", []byte("salt"))
_, err := d.Exec(`
_, err := d.ExecContext(ctx, `
INSERT INTO user (user_id, email, email_verified, is_admin, password, salt, created_at)
VALUES (?, "mail@mail.de", FALSE, FALSE, ?, ?, datetime())`, userId, pass, []byte("salt"))
require.NoError(t, err)
@@ -1445,7 +1445,7 @@ func TestIntegrationAuth(t *testing.T) {
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
var rows int
err = d.QueryRow("SELECT COUNT(*) FROM user WHERE user_id = ? AND password = ?", userId, pass).Scan(&rows)
err = d.QueryRowContext(ctx, "SELECT COUNT(*) FROM user WHERE user_id = ? AND password = ?", userId, pass).Scan(&rows)
require.NoError(t, err)
assert.Equal(t, 1, rows)
})
@@ -1456,7 +1456,7 @@ func TestIntegrationAuth(t *testing.T) {
userId := uuid.New()
pass := service.GetHashPassword("password", []byte("salt"))
_, err := d.Exec(`
_, err := d.ExecContext(ctx, `
INSERT INTO user (user_id, email, email_verified, is_admin, password, salt, created_at)
VALUES (?, "mail@mail.de", FALSE, FALSE, ?, ?, datetime())`, userId, pass, []byte("salt"))
require.NoError(t, err)
@@ -1473,7 +1473,7 @@ func TestIntegrationAuth(t *testing.T) {
assert.NotEmpty(t, anonymousCsrfToken)
token := "password-reset-token"
_, err = d.Exec(`
_, err = d.ExecContext(ctx, `
INSERT INTO token (token, user_id, session_id, type, created_at, expires_at)
VALUES (?, ?, ?, ?, datetime("now", "-16 minute"), datetime("now", "-1 minute"))`, token, userId, "", types.TokenTypePasswordReset)
require.NoError(t, err)
@@ -1494,7 +1494,7 @@ func TestIntegrationAuth(t *testing.T) {
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
var rows int
err = d.QueryRow("SELECT COUNT(*) FROM user WHERE user_id = ? AND password = ?", userId, pass).Scan(&rows)
err = d.QueryRowContext(ctx, "SELECT COUNT(*) FROM user WHERE user_id = ? AND password = ?", userId, pass).Scan(&rows)
require.NoError(t, err)
assert.Equal(t, 1, rows)
})
@@ -1505,7 +1505,7 @@ func TestIntegrationAuth(t *testing.T) {
userId := uuid.New()
pass := service.GetHashPassword("password", []byte("salt"))
_, err := d.Exec(`
_, err := d.ExecContext(ctx, `
INSERT INTO user (user_id, email, email_verified, is_admin, password, salt, created_at)
VALUES (?, "mail@mail.de", FALSE, FALSE, ?, ?, datetime())`, userId, pass, []byte("salt"))
require.NoError(t, err)
@@ -1522,7 +1522,7 @@ func TestIntegrationAuth(t *testing.T) {
assert.NotEmpty(t, anonymousCsrfToken)
token := "password-reset-token"
_, err = d.Exec(`
_, err = d.ExecContext(ctx, `
INSERT INTO token (token, user_id, session_id, type, created_at, expires_at)
VALUES (?, ?, ?, ?, datetime("now"), datetime("now", "+15 minute"))`, token, userId, "", types.TokenTypePasswordReset)
require.NoError(t, err)
@@ -1543,7 +1543,7 @@ func TestIntegrationAuth(t *testing.T) {
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
var rows int
err = d.QueryRow("SELECT COUNT(*) FROM user WHERE user_id = ? AND password = ?", userId, pass).Scan(&rows)
err = d.QueryRowContext(ctx, "SELECT COUNT(*) FROM user WHERE user_id = ? AND password = ?", userId, pass).Scan(&rows)
require.NoError(t, err)
assert.Equal(t, 1, rows)
})
@@ -1554,12 +1554,12 @@ func TestIntegrationAuth(t *testing.T) {
userId := uuid.New()
pass := service.GetHashPassword("password", []byte("salt"))
_, err := d.Exec(`
_, err := d.ExecContext(ctx, `
INSERT INTO user (user_id, email, email_verified, is_admin, password, salt, created_at)
VALUES (?, "mail@mail.de", FALSE, FALSE, ?, ?, datetime())`, userId, pass, []byte("salt"))
require.NoError(t, err)
_, err = d.Exec(`
_, err = d.ExecContext(ctx, `
INSERT INTO session (session_id, user_id, created_at, expires_at)
VALUES ("session-id", ?, datetime(), datetime("now", "+1 day"))`, userId)
require.NoError(t, err)
@@ -1590,7 +1590,7 @@ func TestIntegrationAuth(t *testing.T) {
assert.Equal(t, http.StatusOK, resp.StatusCode)
var token string
err = d.QueryRow("SELECT token FROM token WHERE type = ?", types.TokenTypePasswordReset).Scan(&token)
err = d.QueryRowContext(ctx, "SELECT token FROM token WHERE type = ?", types.TokenTypePasswordReset).Scan(&token)
require.NoError(t, err)
formData = url.Values{
@@ -1608,7 +1608,7 @@ func TestIntegrationAuth(t *testing.T) {
_ = resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
sessions, err := d.Query("SELECT session_id FROM session WHERE user_id = ?", userId)
sessions, err := d.QueryContext(ctx, "SELECT session_id FROM session WHERE user_id = ?", userId)
require.NoError(t, err)
assert.False(t, sessions.Next())
})
@@ -1623,11 +1623,11 @@ func TestIntegrationAuth(t *testing.T) {
userId := uuid.New()
sessionId := "session-id"
_, err := d.Exec(`
_, err := d.ExecContext(ctx, `
INSERT INTO user (user_id, email, email_verified, is_admin, password, salt, created_at)
VALUES (?, "mail@mail.de", FALSE, FALSE, ?, ?, datetime())`, userId, []byte("pass"), []byte("salt"))
require.NoError(t, err)
_, err = d.Exec(`
_, err = d.ExecContext(ctx, `
INSERT INTO session (session_id, user_id, created_at, expires_at)
VALUES (?, ?, datetime("now", "-8 hour"), datetime("now", "-1 minute"))`, sessionId, userId)
require.NoError(t, err)
@@ -1643,7 +1643,7 @@ func TestIntegrationAuth(t *testing.T) {
assert.NotEqual(t, sessionId, newSession.Value)
var rows int
err = d.QueryRow("SELECT COUNT(*) FROM session WHERE user_id = ?", userId).Scan(&rows)
err = d.QueryRowContext(ctx, "SELECT COUNT(*) FROM session WHERE user_id = ?", userId).Scan(&rows)
require.NoError(t, err)
assert.Equal(t, 0, rows)
})
@@ -1670,11 +1670,11 @@ func TestIntegrationAuth(t *testing.T) {
userId := uuid.New()
sessionId := "session-id"
_, err := d.Exec(`
_, err := d.ExecContext(ctx, `
INSERT INTO user (user_id, email, email_verified, is_admin, password, salt, created_at)
VALUES (?, "mail@mail.de", FALSE, FALSE, ?, ?, datetime())`, userId, []byte("pass"), []byte("salt"))
require.NoError(t, err)
_, err = d.Exec(`
_, err = d.ExecContext(ctx, `
INSERT INTO session (session_id, user_id, created_at, expires_at)
VALUES (?, ?, datetime("now", "-8 hour"), datetime("now", "-1 minute"))`, sessionId, userId)
require.NoError(t, err)
@@ -1769,7 +1769,7 @@ func TestIntegrationAccount(t *testing.T) {
_ = resp.Body.Close()
var id uuid.UUID
err = db.Get(&id, "SELECT id FROM account")
err = db.GetContext(ctx, &id, "SELECT id FROM account")
require.NoError(t, err)
// Update
@@ -1862,7 +1862,6 @@ func TestIntegrationAccount(t *testing.T) {
">": 400,
"/": 400,
"\\": 400,
"?": 400,
":": 400,
"*": 400,
"|": 400,

View File

@@ -22,7 +22,7 @@ func TestTreasureChestShouldNotDeleteIfTransactionRecurringExists(t *testing.T)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var parentId string
err := db.Get(&parentId, "SELECT id FROM treasure_chest")
err := db.GetContext(ctx, &parentId, "SELECT id FROM treasure_chest")
require.NoError(t, err)
formData = url.Values{
@@ -33,7 +33,7 @@ func TestTreasureChestShouldNotDeleteIfTransactionRecurringExists(t *testing.T)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var childId string
err = db.Get(&childId, "SELECT id FROM treasure_chest WHERE parent_id = ?", parentId)
err = db.GetContext(ctx, &childId, "SELECT id FROM treasure_chest WHERE parent_id = ?", parentId)
require.NoError(t, err)
formData = url.Values{