184 Commits

Author SHA1 Message Date
7a8167cb3c fix(deps): update module golang.org/x/crypto to v0.48.0
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m12s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m14s
2026-02-13 23:18:57 +00:00
08b0a7b6fa chore(deps): update dependency go to v1.26.0
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m12s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m17s
2026-02-13 22:56:13 +00:00
41d3d04fd7 chore(deps): update golang docker tag to v1.26.0
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m12s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m17s
2026-02-13 22:22:55 +00:00
9d094244ad fix(deps): update module github.com/mattn/go-sqlite3 to v1.14.34
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m13s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m39s
2026-02-13 21:10:14 +00:00
ae00022f49 fix: renovate
All checks were successful
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m25s
2026-02-13 21:46:33 +01:00
0775f81142 chore(deps): update golang:1.25.6 docker digest to 06d1251
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m9s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m13s
2026-02-13 10:12:46 +00:00
8468fd4293 chore(deps): update debian:13.3 docker digest to 2c91e48
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m9s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m29s
2026-02-13 09:13:20 +00:00
5afaf3ecdb chore(deps): update actions/checkout digest to de0fac2
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m4s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m26s
2026-02-13 08:20:21 +00:00
a5d21bfc66 chore(deps): update node.js to v24.13.1
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 2m36s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m24s
2026-02-13 08:00:18 +00:00
d7dcfa7088 fix: deps
All checks were successful
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m17s
2026-01-17 18:44:46 +00:00
2423ed6314 fix(deps): update module golang.org/x/net to v0.49.0 2026-01-17 18:44:46 +00:00
77c901fb78 chore(deps): update node.js to v24.13.0
All checks were successful
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m19s
2026-01-17 18:41:08 +00:00
7de9aa6452 chore(deps): update golang docker tag to v1.25.6
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 3m16s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m10s
2026-01-15 22:03:25 +00:00
0bc8812a92 chore(deps): update dependency go to v1.25.6
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m23s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m18s
2026-01-15 19:03:28 +00:00
e82295a4c6 chore(deps): update golang:1.25.5 docker digest to 8bbd140
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m13s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m16s
2026-01-14 01:03:07 +00:00
d7e6993049 chore(deps): update golang:1.25.5 docker digest to 581c059
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m13s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m14s
2026-01-13 19:06:13 +00:00
3cfc007f36 chore(deps): update node.js to v24.13.0
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m13s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m17s
2026-01-13 15:04:39 +00:00
7c7566497b chore(deps): update node.js to 929c026
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m11s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m19s
2026-01-13 14:03:40 +00:00
b0303c224d chore(deps): update debian:13.3 docker digest to 5cf544f
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m14s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m15s
2026-01-13 11:06:49 +00:00
94de96847c chore(deps): update node.js to 50113f9
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m13s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m16s
2026-01-13 08:04:06 +00:00
c1ee572856 chore(deps): update golang:1.25.5 docker digest to 0f406d3
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 2m57s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m18s
2026-01-13 07:03:23 +00:00
8ee63c6b90 chore(deps): update debian docker tag to v13.3
Some checks failed
Build Docker Image / Build-Docker-Image (push) Successful in 1m18s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Failing after 11m46s
2026-01-13 02:04:19 +00:00
0d56d86a41 fix: linting
All checks were successful
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m29s
2026-01-08 19:33:42 +01:00
a570c44d75 feat(tag): draft for inline editing
Some checks failed
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Failing after 1m15s
2026-01-08 18:54:18 +01:00
5af5ab2a0c feat(tag): make private things private 2026-01-07 09:17:15 +01:00
f1e0c1c1c2 fix: linting
All checks were successful
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m25s
2026-01-07 08:54:33 +01:00
b13712b0df feat(tag): add tag editing
Some checks failed
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Failing after 1m13s
2026-01-06 20:40:35 +01:00
70d6110bc4 feat(budget): rename description to name
All checks were successful
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m25s
2026-01-06 19:41:26 +01:00
faf28b559a chore: refine future domain model
Some checks failed
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Failing after 12m0s
2026-01-06 19:15:32 +01:00
39f196341f feat: add architecture diagarm
All checks were successful
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m20s
2026-01-04 20:34:04 +01:00
5efba04f1b fix(budget): empty string check
All checks were successful
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m20s
2026-01-04 19:55:32 +01:00
238ec6d55d fix(deps): update
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m19s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m16s
2026-01-04 15:04:21 +01:00
76fdafc709 fix(deps): update module github.com/mattn/go-sqlite3 to v1.14.33
Some checks failed
Build Docker Image / Build-Docker-Image (push) Has been cancelled
2026-01-04 14:04:14 +00:00
7b95216987 feat(budget): fix design and editing
All checks were successful
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m19s
2026-01-04 14:28:05 +01:00
209af10fd4 fix: linting
All checks were successful
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m14s
2026-01-03 20:56:38 +01:00
029c01cd32 feat(budget): further improvements
Some checks failed
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Failing after 1m11s
2026-01-03 20:53:56 +01:00
cee01c9a29 feat(budget): further implementation with modal for editing
Some checks failed
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Failing after 1m9s
2026-01-03 16:34:04 +01:00
43e4334201 chore(deps): update golang:1.25.5 docker digest to 6cc2338
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m11s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m15s
2026-01-02 22:03:47 +00:00
2bbfe7b175 feat(otel): remove custom service name in favor of env variable
All checks were successful
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m16s
2026-01-02 19:12:21 +01:00
b5ab697cca feat(otel): don't log requests if otel enabled
All checks were successful
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m20s
2026-01-02 18:25:34 +01:00
ada411e1eb feat(otel): remove fixed conf in favor of env variables
All checks were successful
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m21s
2026-01-02 17:58:34 +01:00
818dab401e feat(budget): first draft
All checks were successful
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m17s
2026-01-02 17:52:33 +01:00
1be6d9cb11 fix: linting
All checks were successful
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m19s
2026-01-01 20:00:19 +01:00
2b320986fd feat: add budgets
Some checks failed
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Failing after 1m11s
2026-01-01 19:57:47 +01:00
d7dbca8242 feat: dont use templ proxy due to regression
All checks were successful
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m28s
2026-01-01 19:37:33 +01:00
fbb6758e57 feat: update templ
All checks were successful
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m24s
2026-01-01 16:23:49 +01:00
2ac14c84cc fix: browser console error
All checks were successful
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 2m2s
2026-01-01 16:06:25 +01:00
1be46780bb feat: extract into remaining packages
All checks were successful
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m19s
There has been a cyclic dependency.
transaction
	-> treasure_chest
	-> transaction_recurring
	-> transaction

This has been temporarily solved by moving the GenerateTransactions
function into the transaction package. In the future, this function has
to be rewritten to use a proper Service insteas of direct DB access or
replaced with a different system entirely.
2025-12-31 22:26:59 +01:00
6de8d8fb10 chore(deps): update node.js to b52a8d1
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m11s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m18s
2025-12-30 14:04:32 +00:00
423629c7ee chore(deps): update golang:1.25.5 docker digest to 31c1e53
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m11s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m17s
2025-12-30 13:06:49 +00:00
09fed02474 chore(deps): update golang:1.25.5 docker digest to b6ba523
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m10s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m15s
2025-12-30 10:03:53 +00:00
fe5bf72a03 chore(deps): update node.js to e7a6c52
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m8s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m14s
2025-12-30 08:04:27 +00:00
20ff57a24d chore(deps): update golang:1.25.5 docker digest to 6396b3d
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 2m50s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m10s
2025-12-30 07:03:57 +00:00
b2fb257a57 chore(deps): update node.js to 33587cf
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m11s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m15s
2025-12-30 06:04:19 +00:00
923726f6fa chore(deps): update debian:13.2 docker digest to c71b05e
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m13s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m18s
2025-12-30 05:04:40 +00:00
7c78091027 chore(deps): update golang:1.25.5 docker digest to 97be073
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m10s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m16s
2025-12-30 04:04:23 +00:00
11914db84f chore(deps): update debian:13.2 docker digest to ea3a08b
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m11s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m17s
2025-12-30 03:08:49 +00:00
05e63faf50 feat: move transaction_recurring to seperate module
All checks were successful
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m16s
2025-12-27 10:38:28 +01:00
28113d27d0 feat: move treasure_chest to seperate module
All checks were successful
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m18s
2025-12-27 10:18:20 +01:00
0325fe101c feat: move some service declarations to core
All checks were successful
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m18s
2025-12-27 06:58:04 +01:00
ea2663a53d fix: rename
All checks were successful
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m16s
2025-12-26 07:14:21 +01:00
2b23700c84 fix: rename
Some checks failed
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Has been cancelled
2025-12-26 07:13:27 +01:00
c927d917ec feat: extract dashboard
All checks were successful
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m19s
2025-12-26 07:11:39 +01:00
5e563f2c59 feat: move remaining db package to core
Some checks failed
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Failing after 57s
2025-12-26 06:34:36 +01:00
75433834ed feat: extract authentication to domain package
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m7s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m10s
2025-12-25 07:39:48 +01:00
f9a5a9e5f9 feat: extract account to domain package
All checks were successful
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m17s
2025-12-24 07:45:44 +01:00
1e61b765ae fix: resolve hints
All checks were successful
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 3m35s
2025-12-23 20:17:47 +01:00
677c6b795e chore(deps): update golang:1.25.5 docker digest to 36b4f45
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m13s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m14s
2025-12-15 10:06:47 +00:00
3b0ba91b73 chore(deps): update tailwindcss monorepo to v4.1.18
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Build Docker Image / Build-Docker-Image (push) Successful in 1m12s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m16s
2025-12-14 17:04:15 +00:00
74b63bc494 chore(deps): update node.js to v24.12.0
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m13s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m21s
2025-12-11 23:04:19 +00:00
480f311856 chore(deps): update node.js to v24.12.0
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m11s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m15s
2025-12-10 17:03:18 +00:00
1a0524a24b chore(deps): update golang:1.25.5 docker digest to a22b2e6
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m12s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m18s
2025-12-09 22:03:27 +00:00
2e641a1db5 chore(deps): update node.js to 9a2ed90
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m11s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m18s
2025-12-09 11:07:18 +00:00
d952956a8d chore(deps): update golang:1.25.5 docker digest to 0ece421
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m12s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m17s
2025-12-09 10:03:47 +00:00
b28b41aff4 chore(deps): update node.js to 822e393
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m12s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m15s
2025-12-09 08:04:14 +00:00
18e651babf chore(deps): update node.js to ef9b3fb
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m10s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m19s
2025-12-09 06:03:56 +00:00
5e992873cc chore(deps): update debian:13.2 docker digest to 0d01188
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m6s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m16s
2025-12-09 05:04:48 +00:00
26b75d3db9 chore(deps): update golang:1.25.5 docker digest to 68ee6df
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 2m45s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m9s
2025-12-09 04:06:32 +00:00
772e3e5c2e chore(deps): update debian:13.2 docker digest to f54909a
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m12s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m25s
2025-12-09 02:11:27 +00:00
fcba476a88 fix(deps): update module golang.org/x/net to v0.48.0
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m17s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m15s
2025-12-08 22:06:49 +00:00
d1bdf38227 fix(deps): update opentelemetry-go-contrib monorepo
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m15s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m14s
2025-12-08 21:03:53 +00:00
dea1b9027b fix(deps): update module golang.org/x/crypto to v0.46.0
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m16s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m13s
2025-12-08 20:13:25 +00:00
ee9ef98fa3 fix(deps): update opentelemetry-go monorepo
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m50s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m14s
2025-12-08 18:03:46 +00:00
9a48f23a2c fix(deps): update opentelemetry-go monorepo
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 2m45s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m9s
2025-12-08 17:03:42 +00:00
99d52aa505 chore(deps): update golang docker tag to v1.25.5
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 2m47s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m7s
2025-12-02 19:03:45 +00:00
8833147278 chore(deps): update dependency go to v1.25.5
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m16s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m17s
2025-12-02 17:13:28 +00:00
69727339aa chore(deps): update actions/checkout digest to 8e8c483
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m21s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m24s
2025-12-02 17:09:18 +00:00
e97b7c3069 fix(deps): update module github.com/golang-migrate/migrate/v4 to v4.19.1
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 1m13s
2025-11-29 22:03:46 +00:00
492baab18b Merge branch 'prod' into renovate/actions-checkout-6.x
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m52s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m48s
2025-11-23 20:14:09 +00:00
263ea213cd chore(deps): update golang:1.25.4 docker digest to 6981837
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-11-23 01:03:54 +00:00
6881a64691 chore(deps): update actions/checkout action to v6
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m26s
2025-11-20 17:49:21 +00:00
5efd5f9bbc fix(deps): update module golang.org/x/crypto to v0.45.0
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m39s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m35s
2025-11-19 21:03:39 +00:00
396c97516d chore(deps): update node.js to aa648b3
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m46s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m30s
2025-11-18 17:06:35 +00:00
88caf44fc5 chore(deps): update node.js to d5a23e0
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 1m32s
2025-11-18 15:05:03 +00:00
c8dce6f33a chore(deps): update golang:1.25.4 docker digest to f60eaa8
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 1m29s
2025-11-18 13:06:57 +00:00
12affdff43 chore(deps): update node.js to c5453ea
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 1m42s
2025-11-18 11:11:41 +00:00
9f3fcc0171 chore(deps): update golang:1.25.4 docker digest to 2948461
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 1m33s
2025-11-18 10:12:40 +00:00
f5dd96cf9f chore(deps): update golang:1.25.4 docker digest to 3976069
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 1m31s
2025-11-18 07:03:51 +00:00
d3900957c9 chore(deps): update debian docker tag to v13.2
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-11-18 05:04:12 +00:00
f90c5f83e1 chore(deps): update actions/checkout digest to 93cb6ef
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m24s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m31s
2025-11-17 18:12:21 +00:00
a5246e523c chore(deps): update node.js to v24.11.1
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 1m32s
2025-11-13 05:02:44 +00:00
11a620b73a chore(deps): update node.js to v24.11.1
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m19s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m29s
2025-11-11 23:03:17 +00:00
5db923d438 chore(deps): update golang:1.25.4 docker digest to e68f6a0
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 1m22s
2025-11-11 22:04:13 +00:00
e1c4eeb51d fix(deps): update module golang.org/x/net to v0.47.0
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 1m32s
2025-11-11 20:03:38 +00:00
cf728abe11 fix(deps): update module golang.org/x/crypto to v0.44.0
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m36s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m33s
2025-11-11 19:06:35 +00:00
84f72a1e25 chore(deps): update golang:1.25.4 docker digest to 6ca9eb0
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 1m21s
2025-11-08 10:03:26 +00:00
3d2dbaebc7 chore(deps): update tailwindcss monorepo to v4.1.17
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 1m33s
2025-11-06 16:06:38 +00:00
0b9d1d31e4 chore(deps): update golang docker tag to v1.25.4
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 3m25s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m19s
2025-11-05 22:06:01 +00:00
d3daa4e5ba chore(deps): update dependency go to v1.25.4
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 1m31s
2025-11-05 20:06:26 +00:00
2839a2c4c3 chore(deps): update golang:1.25.3 docker digest to 6d4e5e7
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 1m25s
Build and Push Docker Image / Build-And-Push-Docker-Image (push) Successful in 1m32s
2025-11-05 06:05:58 +00:00
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
101 changed files with 2974 additions and 1809 deletions

View File

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

View File

@@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out repository code - name: Check out repository code
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- run: docker login git.wundenbergs.de -u tim -p ${{ secrets.DOCKER_GITEA_TOKEN }} - 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 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 - run: docker push git.wundenbergs.de/x/spend-sparrow:latest

2
.gitignore vendored
View File

@@ -36,3 +36,5 @@ tmp/
mocks/* mocks/*
!mocks/default.go !mocks/default.go
arch.png

View File

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

View File

@@ -3,11 +3,11 @@ dir: mocks/
outpkg: mocks outpkg: mocks
issue-845-fix: True issue-845-fix: True
packages: packages:
spend-sparrow/internal/service: spend-sparrow/internal/core:
interfaces: interfaces:
Random: Random:
Clock: Clock:
Mail: Mail:
spend-sparrow/internal/db: spend-sparrow/internal/authentication:
interfaces: interfaces:
Auth: Db:

2
.nvmrc
View File

@@ -1 +1 @@
24.6.0 24.13.1

View File

@@ -1,4 +1,4 @@
FROM golang:1.25.0@sha256:4859242e2c392ddc9d3225fd41181c00a443d9cc005b8e5131ce164106fbc676 AS builder_go FROM golang:1.26.0@sha256:c83e68f3ebb6943a2904fa66348867d108119890a2c6a2e6f07b38d0eb6c25c5 AS builder_go
WORKDIR /spend-sparrow WORKDIR /spend-sparrow
RUN go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest RUN go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
RUN go install github.com/a-h/templ/cmd/templ@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 . RUN go build -o /spend-sparrow/spend-sparrow .
FROM node:22.18.0@sha256:3266bc9e8bee1acc8a77386eefaf574987d2729b8c5ec35b0dbd6ddbc40b0ce2 AS builder_node FROM node:24.13.1@sha256:00e9195ebd49985a6da8921f419978d85dfe354589755192dc090425ce4da2f7 AS builder_node
WORKDIR /spend-sparrow WORKDIR /spend-sparrow
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm clean-install RUN npm clean-install
@@ -21,7 +21,7 @@ COPY . ./
RUN npm run build RUN npm run build
FROM debian:13.0@sha256:6d87375016340817ac2391e670971725a9981cfc24e221c47734681ed0f6c0f5 FROM debian:13.3@sha256:2c91e484d93f0830a7e05a2b9d92a7b102be7cab562198b984a84fdbc7806d91
WORKDIR /spend-sparrow WORKDIR /spend-sparrow
RUN apt-get update && apt-get install -y ca-certificates && echo "" > .env RUN apt-get update && apt-get install -y ca-certificates && echo "" > .env
COPY migration ./migration COPY migration ./migration

32
arch.gv Normal file
View File

@@ -0,0 +1,32 @@
digraph {
Tag
Transaction
Account
BankConnection
Analytics
// Buckets
Budget
RecurringCost
SavingGoal
// Analytics -> {
// Budget
// RecurringCost
// SavingGoal
// Tag
// Transaction
// Account
// } [label="uses"]
BankConnection -> Transaction [label="imports into"]
BankConnection -> Account [label="references"]
Transaction -> Account [label="references"]
Transaction -> Tag [label="references"]
Budget -> Tag [label="references"]
RecurringCost -> Tag [label="references"]
SavingGoal -> Tag [label="references"]
}

12
dev.sh
View File

@@ -1,12 +1,14 @@
#!/bin/sh #!/bin/bash
set -e set -e
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest 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/a-h/templ/cmd/templ@latest
go install github.com/vektra/mockery/v2@latest go install github.com/vektra/mockery/v2@latest
templ generate --watch --proxy="http://localhost:8080" --cmd="go run ." & templ generate --watch --cmd="go run ." &
# proxy currently not working with gzip?
# templ generate --watch --proxy="http://localhost:8080" --cmd="go run ." &
xdg-open http://localhost:8080
npm run watch npm run watch
read -n1 -s read -n1 -s -r
kill $(jobs -p) kill "$(jobs -p)"

70
go.mod
View File

@@ -1,55 +1,53 @@
module spend-sparrow module spend-sparrow
go 1.23.0 go 1.24.0
toolchain go1.25.0 toolchain go1.26.0
require ( require (
github.com/a-h/templ v0.3.943 github.com/a-h/templ v0.3.977
github.com/golang-migrate/migrate/v4 v4.18.3 github.com/golang-migrate/migrate/v4 v4.19.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/jmoiron/sqlx v1.4.0 github.com/jmoiron/sqlx v1.4.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/mattn/go-sqlite3 v1.14.32 github.com/mattn/go-sqlite3 v1.14.34
github.com/stretchr/testify v1.10.0 github.com/stretchr/testify v1.11.1
github.com/uptrace/opentelemetry-go-extra/otelsql v0.3.2 github.com/uptrace/opentelemetry-go-extra/otelsql v0.3.2
github.com/uptrace/opentelemetry-go-extra/otelsqlx v0.3.2 github.com/uptrace/opentelemetry-go-extra/otelsqlx v0.3.2
go.opentelemetry.io/contrib/bridges/otelslog v0.12.0 go.opentelemetry.io/contrib/bridges/otelslog v0.14.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0
go.opentelemetry.io/otel v1.37.0 go.opentelemetry.io/otel v1.39.0
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.13.0 go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.15.0
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.37.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.39.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0
go.opentelemetry.io/otel/log v0.13.0 go.opentelemetry.io/otel/log v0.15.0
go.opentelemetry.io/otel/sdk v1.37.0 go.opentelemetry.io/otel/sdk v1.39.0
go.opentelemetry.io/otel/sdk/log v0.13.0 go.opentelemetry.io/otel/sdk/log v0.15.0
go.opentelemetry.io/otel/sdk/metric v1.37.0 go.opentelemetry.io/otel/sdk/metric v1.39.0
go.opentelemetry.io/otel/trace v1.37.0 go.opentelemetry.io/otel/trace v1.39.0
golang.org/x/crypto v0.41.0 golang.org/x/crypto v0.48.0
golang.org/x/net v0.43.0 golang.org/x/net v0.49.0
) )
require ( 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/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // 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 github.com/stretchr/objx v0.5.2 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 // indirect
go.opentelemetry.io/otel/metric v1.37.0 // indirect go.opentelemetry.io/otel/metric v1.39.0 // indirect
go.opentelemetry.io/proto/otlp v1.7.0 // indirect go.opentelemetry.io/proto/otlp v1.9.0 // indirect
go.uber.org/atomic v1.11.0 // indirect golang.org/x/sys v0.41.0 // indirect
golang.org/x/sys v0.35.0 // indirect golang.org/x/text v0.34.0 // indirect
golang.org/x/text v0.28.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect google.golang.org/grpc v1.77.0 // indirect
google.golang.org/grpc v1.73.0 // indirect google.golang.org/protobuf v1.36.10 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

143
go.sum
View File

@@ -1,11 +1,13 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/a-h/templ v0.3.943 h1:o+mT/4yqhZ33F3ootBiHwaY4HM5EVaOJfIshvd5UNTY= github.com/a-h/templ v0.3.977 h1:kiKAPXTZE2Iaf8JbtM21r54A8bCNsncrfnokZZSrSDg=
github.com/a-h/templ v0.3.943/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo= github.com/a-h/templ v0.3.977/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= 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/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 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.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -15,21 +17,16 @@ 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-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 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= 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.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
github.com/golang-migrate/migrate/v4 v4.18.3/go.mod h1:99BKpIi6ruaaXRM1A77eqZ+FWPQ3cfRa+ZVy5bmWMaY= github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4=
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=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
@@ -41,72 +38,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 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 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.22/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.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.34/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.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= 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/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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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 h1:ZjUj9BLYf9PEqBn8W/OapxhPjVRdC6CsXTdULHsyk5c=
github.com/uptrace/opentelemetry-go-extra/otelsql v0.3.2/go.mod h1:O8bHQfyinKwTXKkiKNGmLQS7vRsqRxIQTFZpYpHK3IQ= 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 h1:zA9ZXfdtowo0EKt+t7uqXNlHxPeygrxuFSIroiBVgPU=
github.com/uptrace/opentelemetry-go-extra/otelsqlx v0.3.2/go.mod h1:ySXmuW9JLCm/TjsQksuMY/7MNiWqfHnhH2xeT34uOLU= 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.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/bridges/otelslog v0.12.0 h1:lFM7SZo8Ce01RzRfnUFQZEYeWRf/MtOA3A5MobOqk2g= go.opentelemetry.io/contrib/bridges/otelslog v0.14.0 h1:eypSOd+0txRKCXPNyqLPsbSfA0jULgJcGmSAdFAnrCM=
go.opentelemetry.io/contrib/bridges/otelslog v0.12.0/go.mod h1:Dw05mhFtrKAYu72Tkb3YBYeQpRUJ4quDgo2DQw3No5A= go.opentelemetry.io/contrib/bridges/otelslog v0.14.0/go.mod h1:CRGvIBL/aAxpQU34ZxyQVFlovVcp67s4cAmQu8Jh9mc=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.13.0 h1:z6lNIajgEBVtQZHjfw2hAccPEBDs+nx58VemmXWa2ec= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.15.0 h1:W+m0g+/6v3pa5PgVf2xoFMi5YtNR06WtS7ve5pcvLtM=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.13.0/go.mod h1:+kyc3bRx/Qkq05P6OCu3mTEIOxYRYzoIg+JsUp5X+PM= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.15.0/go.mod h1:JM31r0GGZ/GU94mX8hN4D8v6e40aFlUECSQ48HaLgHM=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.37.0 h1:zG8GlgXCJQd5BU98C0hZnBbElszTmUgCNCfYneaDL0A= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.39.0 h1:cEf8jF6WbuGQWUVcqgyWtTR0kOOAWY1DYZ+UhvdmQPw=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.37.0/go.mod h1:hOfBCz8kv/wuq73Mx2H2QnWokh/kHZxkh6SNF2bdKtw= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.39.0/go.mod h1:k1lzV5n5U3HkGvTCJHraTAGJ7MqsgL1wrGwTj1Isfiw=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0 h1:EtFWSnwW9hGObjkIdmlnWSydO+Qs8OwzfzXLUPg4xOc= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 h1:in9O8ESIOlwJAEGTkkf34DesGRAc/Pn8qJ7k3r/42LM=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0/go.mod h1:QjUEoiGCPkvFZ/MjK6ZZfNOS6mfVEVKYE99dFhuN2LI= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0/go.mod h1:Rp0EXBm5tfnv0WL+ARyO/PHBEaEAT8UUHQ6AGJcSq6c=
go.opentelemetry.io/otel/log v0.13.0 h1:yoxRoIZcohB6Xf0lNv9QIyCzQvrtGZklVbdCoyb7dls= go.opentelemetry.io/otel/log v0.15.0 h1:0VqVnc3MgyYd7QqNVIldC3dsLFKgazR6P3P3+ypkyDY=
go.opentelemetry.io/otel/log v0.13.0/go.mod h1:INKfG4k1O9CL25BaM1qLe0zIedOpvlS5Z7XgSbmN83E= go.opentelemetry.io/otel/log v0.15.0/go.mod h1:9c/G1zbyZfgu1HmQD7Qj84QMmwTp2QCQsZH1aeoWDE4=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/log v0.13.0 h1:I3CGUszjM926OphK8ZdzF+kLqFvfRY/IIoFq/TjwfaQ= go.opentelemetry.io/otel/sdk/log v0.15.0 h1:WgMEHOUt5gjJE93yqfqJOkRflApNif84kxoHWS9VVHE=
go.opentelemetry.io/otel/sdk/log v0.13.0/go.mod h1:lOrQyCCXmpZdN7NchXb6DOZZa1N5G1R2tm5GMMTpDBw= go.opentelemetry.io/otel/sdk/log v0.15.0/go.mod h1:qDC/FlKQCXfH5hokGsNg9aUBGMJQsrUyeOiW5u+dKBQ=
go.opentelemetry.io/otel/sdk/log/logtest v0.13.0 h1:9yio6AFZ3QD9j9oqshV1Ibm9gPLlHNxurno5BreMtIA= go.opentelemetry.io/otel/sdk/log/logtest v0.14.0 h1:Ijbtz+JKXl8T2MngiwqBlPaHqc4YCaP/i13Qrow6gAM=
go.opentelemetry.io/otel/sdk/log/logtest v0.13.0/go.mod h1:QOGiAJHl+fob8Nu85ifXfuQYmJTFAvcrxL6w5/tu168= go.opentelemetry.io/otel/sdk/log/logtest v0.14.0/go.mod h1:dCU8aEL6q+L9cYTqcVOk8rM9Tp8WdnHOPLiBgp0SGOA=
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.opentelemetry.io/proto/otlp v1.7.0 h1:jX1VolD6nHuFzOYso2E73H85i92Mv8JQYk0K9vz09os= go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo= go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
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.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE= google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@@ -1,105 +1,96 @@
package handler package account
import ( import (
"net/http"
"spend-sparrow/internal/handler/middleware"
"spend-sparrow/internal/service"
t "spend-sparrow/internal/template/account"
"spend-sparrow/internal/types"
"spend-sparrow/internal/utils"
"github.com/a-h/templ" "github.com/a-h/templ"
"net/http"
"spend-sparrow/internal/core"
) )
type Account interface { type Handler struct {
Handle(router *http.ServeMux) s Service
r *core.Render
} }
type AccountImpl struct { func NewHandler(s Service, r *core.Render) Handler {
s service.Account return Handler{
r *Render
}
func NewAccount(s service.Account, r *Render) Account {
return AccountImpl{
s: s, s: s,
r: r, r: r,
} }
} }
func (h AccountImpl) Handle(r *http.ServeMux) { func (h Handler) Handle(r *http.ServeMux) {
r.Handle("GET /account", h.handleAccountPage()) r.Handle("GET /account", h.handleAccountPage())
r.Handle("GET /account/{id}", h.handleAccountItemComp()) r.Handle("GET /account/{id}", h.handleAccountItemComp())
r.Handle("POST /account/{id}", h.handleUpdateAccount()) r.Handle("POST /account/{id}", h.handleUpdateAccount())
r.Handle("DELETE /account/{id}", h.handleDeleteAccount()) r.Handle("DELETE /account/{id}", h.handleDeleteAccount())
} }
func (h AccountImpl) handleAccountPage() http.HandlerFunc { func (h Handler) handleAccountPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r) core.UpdateSpan(r)
user := middleware.GetUser(r) user := core.GetUser(r)
if user == nil { if user == nil {
utils.DoRedirect(w, r, "/auth/signin") core.DoRedirect(w, r, "/auth/signin")
return return
} }
accounts, err := h.s.GetAll(r.Context(), user) accounts, err := h.s.GetAll(r.Context(), user)
if err != nil { if err != nil {
handleError(w, r, err) core.HandleError(w, r, err)
return return
} }
comp := t.Account(accounts) comp := template(accounts)
h.r.RenderLayout(r, w, comp, user) h.r.RenderLayout(r, w, comp, user)
} }
} }
func (h AccountImpl) handleAccountItemComp() http.HandlerFunc { func (h Handler) handleAccountItemComp() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r) core.UpdateSpan(r)
user := middleware.GetUser(r) user := core.GetUser(r)
if user == nil { if user == nil {
utils.DoRedirect(w, r, "/auth/signin") core.DoRedirect(w, r, "/auth/signin")
return return
} }
id := r.PathValue("id") id := r.PathValue("id")
if id == "new" { if id == "new" {
comp := t.EditAccount(nil) comp := editAccount(nil)
h.r.Render(r, w, comp) h.r.Render(r, w, comp)
return return
} }
account, err := h.s.Get(r.Context(), user, id) account, err := h.s.Get(r.Context(), user, id)
if err != nil { if err != nil {
handleError(w, r, err) core.HandleError(w, r, err)
return return
} }
var comp templ.Component var comp templ.Component
if r.URL.Query().Get("edit") == "true" { if r.URL.Query().Get("edit") == "true" {
comp = t.EditAccount(account) comp = editAccount(account)
} else { } else {
comp = t.AccountItem(account) comp = accountItem(account)
} }
h.r.Render(r, w, comp) h.r.Render(r, w, comp)
} }
} }
func (h AccountImpl) handleUpdateAccount() http.HandlerFunc { func (h Handler) handleUpdateAccount() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r) core.UpdateSpan(r)
user := middleware.GetUser(r) user := core.GetUser(r)
if user == nil { if user == nil {
utils.DoRedirect(w, r, "/auth/signin") core.DoRedirect(w, r, "/auth/signin")
return return
} }
var ( var (
account *types.Account account *Account
err error err error
) )
id := r.PathValue("id") id := r.PathValue("id")
@@ -107,29 +98,29 @@ func (h AccountImpl) handleUpdateAccount() http.HandlerFunc {
if id == "new" { if id == "new" {
account, err = h.s.Add(r.Context(), user, name) account, err = h.s.Add(r.Context(), user, name)
if err != nil { if err != nil {
handleError(w, r, err) core.HandleError(w, r, err)
return return
} }
} else { } else {
account, err = h.s.UpdateName(r.Context(), user, id, name) account, err = h.s.UpdateName(r.Context(), user, id, name)
if err != nil { if err != nil {
handleError(w, r, err) core.HandleError(w, r, err)
return return
} }
} }
comp := t.AccountItem(account) comp := accountItem(account)
h.r.Render(r, w, comp) h.r.Render(r, w, comp)
} }
} }
func (h AccountImpl) handleDeleteAccount() http.HandlerFunc { func (h Handler) handleDeleteAccount() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r) core.UpdateSpan(r)
user := middleware.GetUser(r) user := core.GetUser(r)
if user == nil { if user == nil {
utils.DoRedirect(w, r, "/auth/signin") core.DoRedirect(w, r, "/auth/signin")
return return
} }
@@ -137,7 +128,7 @@ func (h AccountImpl) handleDeleteAccount() http.HandlerFunc {
err := h.s.Delete(r.Context(), user, id) err := h.s.Delete(r.Context(), user, id)
if err != nil { if err != nil {
handleError(w, r, err) core.HandleError(w, r, err)
return return
} }
} }

View File

@@ -1,55 +1,55 @@
package service package account
import ( import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"spend-sparrow/internal/db" "spend-sparrow/internal/auth_types"
"spend-sparrow/internal/types" "spend-sparrow/internal/core"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
) )
type Account interface { type Service interface {
Add(ctx context.Context, user *types.User, name string) (*types.Account, error) Add(ctx context.Context, user *auth_types.User, name string) (*Account, error)
UpdateName(ctx context.Context, user *types.User, id string, name string) (*types.Account, error) UpdateName(ctx context.Context, user *auth_types.User, id string, name string) (*Account, error)
Get(ctx context.Context, user *types.User, id string) (*types.Account, error) Get(ctx context.Context, user *auth_types.User, id string) (*Account, error)
GetAll(ctx context.Context, user *types.User) ([]*types.Account, error) GetAll(ctx context.Context, user *auth_types.User) ([]*Account, error)
Delete(ctx context.Context, user *types.User, id string) error Delete(ctx context.Context, user *auth_types.User, id string) error
} }
type AccountImpl struct { type ServiceImpl struct {
db *sqlx.DB db *sqlx.DB
clock Clock clock core.Clock
random Random random core.Random
} }
func NewAccount(db *sqlx.DB, random Random, clock Clock) Account { func NewServiceImpl(db *sqlx.DB, random core.Random, clock core.Clock) Service {
return AccountImpl{ return ServiceImpl{
db: db, db: db,
clock: clock, clock: clock,
random: random, random: random,
} }
} }
func (s AccountImpl) Add(ctx context.Context, user *types.User, name string) (*types.Account, error) { func (s ServiceImpl) Add(ctx context.Context, user *auth_types.User, name string) (*Account, error) {
if user == nil { if user == nil {
return nil, ErrUnauthorized return nil, core.ErrUnauthorized
} }
newId, err := s.random.UUID(ctx) newId, err := s.random.UUID(ctx)
if err != nil { if err != nil {
return nil, types.ErrInternal return nil, core.ErrInternal
} }
err = validateString(name, "name") err = core.ValidateString(name, "name")
if err != nil { if err != nil {
return nil, err return nil, err
} }
account := &types.Account{ account := &Account{
Id: newId, Id: newId,
UserId: user.Id, UserId: user.Id,
@@ -68,7 +68,7 @@ func (s AccountImpl) Add(ctx context.Context, user *types.User, name string) (*t
r, err := s.db.NamedExecContext(ctx, ` r, err := s.db.NamedExecContext(ctx, `
INSERT INTO account (id, user_id, name, current_balance, oink_balance, created_at, created_by) 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) VALUES (:id, :user_id, :name, :current_balance, :oink_balance, :created_at, :created_by)`, account)
err = db.TransformAndLogDbError(ctx, "account Insert", r, err) err = core.TransformAndLogDbError(ctx, "account Insert", r, err)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -76,22 +76,22 @@ func (s AccountImpl) Add(ctx context.Context, user *types.User, name string) (*t
return account, nil return account, nil
} }
func (s AccountImpl) UpdateName(ctx context.Context, user *types.User, id string, name string) (*types.Account, error) { func (s ServiceImpl) UpdateName(ctx context.Context, user *auth_types.User, id string, name string) (*Account, error) {
if user == nil { if user == nil {
return nil, ErrUnauthorized return nil, core.ErrUnauthorized
} }
err := validateString(name, "name") err := core.ValidateString(name, "name")
if err != nil { if err != nil {
return nil, err return nil, err
} }
uuid, err := uuid.Parse(id) uuid, err := uuid.Parse(id)
if err != nil { if err != nil {
slog.ErrorContext(ctx, "account update", "err", err) slog.ErrorContext(ctx, "account update", "err", err)
return nil, fmt.Errorf("could not parse Id: %w", ErrBadRequest) return nil, fmt.Errorf("could not parse Id: %w", core.ErrBadRequest)
} }
tx, err := s.db.BeginTxx(ctx, nil) tx, err := s.db.BeginTxx(ctx, nil)
err = db.TransformAndLogDbError(ctx, "account Update", nil, err) err = core.TransformAndLogDbError(ctx, "account Update", nil, err)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -99,14 +99,14 @@ func (s AccountImpl) UpdateName(ctx context.Context, user *types.User, id string
_ = tx.Rollback() _ = tx.Rollback()
}() }()
var account types.Account var account Account
err = tx.GetContext(ctx, &account, `SELECT * FROM account WHERE user_id = ? AND id = ?`, user.Id, uuid) err = tx.GetContext(ctx, &account, `SELECT * FROM account WHERE user_id = ? AND id = ?`, user.Id, uuid)
err = db.TransformAndLogDbError(ctx, "account Update", nil, err) err = core.TransformAndLogDbError(ctx, "account Update", nil, err)
if err != nil { if err != nil {
if errors.Is(err, db.ErrNotFound) { if errors.Is(err, core.ErrNotFound) {
return nil, fmt.Errorf("account %v not found: %w", id, ErrBadRequest) return nil, fmt.Errorf("account %v not found: %w", id, core.ErrBadRequest)
} }
return nil, types.ErrInternal return nil, core.ErrInternal
} }
timestamp := s.clock.Now() timestamp := s.clock.Now()
@@ -122,13 +122,13 @@ func (s AccountImpl) UpdateName(ctx context.Context, user *types.User, id string
updated_by = :updated_by updated_by = :updated_by
WHERE id = :id WHERE id = :id
AND user_id = :user_id`, account) AND user_id = :user_id`, account)
err = db.TransformAndLogDbError(ctx, "account Update", r, err) err = core.TransformAndLogDbError(ctx, "account Update", r, err)
if err != nil { if err != nil {
return nil, err return nil, err
} }
err = tx.Commit() err = tx.Commit()
err = db.TransformAndLogDbError(ctx, "account Update", nil, err) err = core.TransformAndLogDbError(ctx, "account Update", nil, err)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -136,20 +136,20 @@ func (s AccountImpl) UpdateName(ctx context.Context, user *types.User, id string
return &account, nil return &account, nil
} }
func (s AccountImpl) Get(ctx context.Context, user *types.User, id string) (*types.Account, error) { func (s ServiceImpl) Get(ctx context.Context, user *auth_types.User, id string) (*Account, error) {
if user == nil { if user == nil {
return nil, ErrUnauthorized return nil, core.ErrUnauthorized
} }
uuid, err := uuid.Parse(id) uuid, err := uuid.Parse(id)
if err != nil { if err != nil {
slog.ErrorContext(ctx, "account get", "err", err) slog.ErrorContext(ctx, "account get", "err", err)
return nil, fmt.Errorf("could not parse Id: %w", ErrBadRequest) return nil, fmt.Errorf("could not parse Id: %w", core.ErrBadRequest)
} }
var account types.Account var account Account
err = s.db.GetContext(ctx, &account, ` err = s.db.GetContext(ctx, &account, `
SELECT * FROM account WHERE user_id = ? AND id = ?`, user.Id, uuid) SELECT * FROM account WHERE user_id = ? AND id = ?`, user.Id, uuid)
err = db.TransformAndLogDbError(ctx, "account Get", nil, err) err = core.TransformAndLogDbError(ctx, "account Get", nil, err)
if err != nil { if err != nil {
slog.ErrorContext(ctx, "account get", "err", err) slog.ErrorContext(ctx, "account get", "err", err)
return nil, err return nil, err
@@ -158,15 +158,15 @@ func (s AccountImpl) Get(ctx context.Context, user *types.User, id string) (*typ
return &account, nil return &account, nil
} }
func (s AccountImpl) GetAll(ctx context.Context, user *types.User) ([]*types.Account, error) { func (s ServiceImpl) GetAll(ctx context.Context, user *auth_types.User) ([]*Account, error) {
if user == nil { if user == nil {
return nil, ErrUnauthorized return nil, core.ErrUnauthorized
} }
accounts := make([]*types.Account, 0) accounts := make([]*Account, 0)
err := s.db.SelectContext(ctx, &accounts, ` err := s.db.SelectContext(ctx, &accounts, `
SELECT * FROM account WHERE user_id = ? ORDER BY name`, user.Id) SELECT * FROM account WHERE user_id = ? ORDER BY name`, user.Id)
err = db.TransformAndLogDbError(ctx, "account GetAll", nil, err) err = core.TransformAndLogDbError(ctx, "account GetAll", nil, err)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -174,18 +174,18 @@ func (s AccountImpl) GetAll(ctx context.Context, user *types.User) ([]*types.Acc
return accounts, nil return accounts, nil
} }
func (s AccountImpl) Delete(ctx context.Context, user *types.User, id string) error { func (s ServiceImpl) Delete(ctx context.Context, user *auth_types.User, id string) error {
if user == nil { if user == nil {
return ErrUnauthorized return core.ErrUnauthorized
} }
uuid, err := uuid.Parse(id) uuid, err := uuid.Parse(id)
if err != nil { if err != nil {
slog.ErrorContext(ctx, "account delete", "err", err) slog.ErrorContext(ctx, "account delete", "err", err)
return fmt.Errorf("could not parse Id: %w", ErrBadRequest) return fmt.Errorf("could not parse Id: %w", core.ErrBadRequest)
} }
tx, err := s.db.BeginTxx(ctx, nil) tx, err := s.db.BeginTxx(ctx, nil)
err = db.TransformAndLogDbError(ctx, "account Delete", nil, err) err = core.TransformAndLogDbError(ctx, "account Delete", nil, err)
if err != nil { if err != nil {
return err return err
} }
@@ -195,22 +195,22 @@ func (s AccountImpl) Delete(ctx context.Context, user *types.User, id string) er
transactionsCount := 0 transactionsCount := 0
err = tx.GetContext(ctx, &transactionsCount, `SELECT COUNT(*) FROM "transaction" WHERE user_id = ? AND account_id = ?`, user.Id, uuid) 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) err = core.TransformAndLogDbError(ctx, "account Delete", nil, err)
if err != nil { if err != nil {
return err return err
} }
if transactionsCount > 0 { if transactionsCount > 0 {
return fmt.Errorf("account has transactions, cannot delete: %w", ErrBadRequest) return fmt.Errorf("account has transactions, cannot delete: %w", core.ErrBadRequest)
} }
res, err := tx.ExecContext(ctx, "DELETE FROM account WHERE id = ? and user_id = ?", uuid, user.Id) res, err := tx.ExecContext(ctx, "DELETE FROM account WHERE id = ? and user_id = ?", uuid, user.Id)
err = db.TransformAndLogDbError(ctx, "account Delete", res, err) err = core.TransformAndLogDbError(ctx, "account Delete", res, err)
if err != nil { if err != nil {
return err return err
} }
err = tx.Commit() err = tx.Commit()
err = db.TransformAndLogDbError(ctx, "account Delete", nil, err) err = core.TransformAndLogDbError(ctx, "account Delete", nil, err)
if err != nil { if err != nil {
return err return err
} }

View File

@@ -1,9 +1,11 @@
package account package account
import "spend-sparrow/internal/template/svg" import (
import "spend-sparrow/internal/types" "spend-sparrow/internal/core"
"spend-sparrow/internal/template/svg"
)
templ Account(accounts []*types.Account) { templ template(accounts []*Account) {
<div class="max-w-6xl mt-10 mx-auto"> <div class="max-w-6xl mt-10 mx-auto">
<button <button
hx-get="/account/new" hx-get="/account/new"
@@ -11,33 +13,35 @@ templ Account(accounts []*types.Account) {
hx-swap="afterbegin" hx-swap="afterbegin"
class="ml-auto button button-primary px-2 flex-1 flex items-center gap-2 justify-center" class="ml-auto button button-primary px-2 flex-1 flex items-center gap-2 justify-center"
> >
@svg.Plus() <div class="w-3">
@svg.Plus()
</div>
<p>New Account</p> <p>New Account</p>
</button> </button>
<div id="account-items" class="my-6 flex flex-col items-center"> <div id="account-items" class="my-6 flex flex-col items-center">
for _, account := range accounts { for _, account := range accounts {
@AccountItem(account) @accountItem(account)
} }
</div> </div>
</div> </div>
} }
templ EditAccount(account *types.Account) { templ editAccount(account *Account) {
{{ {{
var ( var (
name string name string
id string id string
cancelUrl string cancelUrl string
) )
if account == nil { if account == nil {
name = "" name = ""
id = "new" id = "new"
cancelUrl = "/empty" cancelUrl = "/empty"
} else { } else {
name = account.Name name = account.Name
id = account.Id.String() id = account.Id.String()
cancelUrl = "/account/" + id cancelUrl = "/account/" + id
} }
}} }}
<div id="account" class="border-1 border-gray-300 w-full my-4 p-4 bg-gray-50 rounded-lg"> <div id="account" class="border-1 border-gray-300 w-full my-4 p-4 bg-gray-50 rounded-lg">
<form <form
@@ -77,14 +81,14 @@ templ EditAccount(account *types.Account) {
</div> </div>
} }
templ AccountItem(account *types.Account) { templ accountItem(account *Account) {
<div id="account" class="border-1 border-gray-300 w-full my-4 p-4 bg-gray-50 rounded-lg"> <div id="account" class="border-1 border-gray-300 w-full my-4 p-4 bg-gray-50 rounded-lg">
<div class="text-xl flex justify-end gap-4"> <div class="text-xl flex justify-end gap-4">
<p class="mr-auto">{ account.Name }</p> <p class="mr-auto">{ account.Name }</p>
if account.CurrentBalance < 0 { if account.CurrentBalance < 0 {
<p class="mr-20 text-red-700">{ types.FormatEuros(account.CurrentBalance) }</p> <p class="mr-20 text-red-700">{ core.FormatEuros(account.CurrentBalance) }</p>
} else { } else {
<p class="mr-20 text-green-700">{ types.FormatEuros(account.CurrentBalance) }</p> <p class="mr-20 text-green-700">{ core.FormatEuros(account.CurrentBalance) }</p>
} }
<a <a
href={ templ.URL("/transaction?account-id=" + account.Id.String()) } href={ templ.URL("/transaction?account-id=" + account.Id.String()) }

View File

@@ -1,9 +1,8 @@
package types package account
import ( import (
"time"
"github.com/google/uuid" "github.com/google/uuid"
"time"
) )
// The Account holds money. // The Account holds money.

View File

@@ -1,4 +1,4 @@
package types package auth_types
import ( import (
"time" "time"

View File

@@ -1,11 +1,12 @@
package db package authentication
import ( import (
"context" "context"
"database/sql" "database/sql"
"errors" "errors"
"log/slog" "log/slog"
"spend-sparrow/internal/types" "spend-sparrow/internal/auth_types"
"spend-sparrow/internal/core"
"strings" "strings"
"time" "time"
@@ -13,36 +14,36 @@ import (
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
) )
type Auth interface { type Db interface {
InsertUser(ctx context.Context, user *types.User) error InsertUser(ctx context.Context, user *auth_types.User) error
UpdateUser(ctx context.Context, user *types.User) error UpdateUser(ctx context.Context, user *auth_types.User) error
GetUserByEmail(ctx context.Context, email string) (*types.User, error) GetUserByEmail(ctx context.Context, email string) (*auth_types.User, error)
GetUser(ctx context.Context, userId uuid.UUID) (*types.User, error) GetUser(ctx context.Context, userId uuid.UUID) (*auth_types.User, error)
DeleteUser(ctx context.Context, userId uuid.UUID) error DeleteUser(ctx context.Context, userId uuid.UUID) error
InsertToken(ctx context.Context, token *types.Token) error InsertToken(ctx context.Context, token *auth_types.Token) error
GetToken(ctx context.Context, token string) (*types.Token, error) GetToken(ctx context.Context, token string) (*auth_types.Token, error)
GetTokensByUserIdAndType(ctx context.Context, userId uuid.UUID, tokenType types.TokenType) ([]*types.Token, error) GetTokensByUserIdAndType(ctx context.Context, userId uuid.UUID, tokenType auth_types.TokenType) ([]*auth_types.Token, error)
GetTokensBySessionIdAndType(ctx context.Context, sessionId string, tokenType types.TokenType) ([]*types.Token, error) GetTokensBySessionIdAndType(ctx context.Context, sessionId string, tokenType auth_types.TokenType) ([]*auth_types.Token, error)
DeleteToken(ctx context.Context, token string) error DeleteToken(ctx context.Context, token string) error
InsertSession(ctx context.Context, session *types.Session) error InsertSession(ctx context.Context, session *auth_types.Session) error
GetSession(ctx context.Context, sessionId string) (*types.Session, error) GetSession(ctx context.Context, sessionId string) (*auth_types.Session, error)
GetSessions(ctx context.Context, userId uuid.UUID) ([]*types.Session, error) GetSessions(ctx context.Context, userId uuid.UUID) ([]*auth_types.Session, error)
DeleteSession(ctx context.Context, sessionId string) error DeleteSession(ctx context.Context, sessionId string) error
DeleteOldSessions(ctx context.Context) error DeleteOldSessions(ctx context.Context) error
DeleteOldTokens(ctx context.Context) error DeleteOldTokens(ctx context.Context) error
} }
type AuthSqlite struct { type DbSqlite struct {
db *sqlx.DB db *sqlx.DB
} }
func NewAuthSqlite(db *sqlx.DB) *AuthSqlite { func NewDbSqlite(db *sqlx.DB) *DbSqlite {
return &AuthSqlite{db: db} return &DbSqlite{db: db}
} }
func (db AuthSqlite) InsertUser(ctx context.Context, user *types.User) error { func (db DbSqlite) InsertUser(ctx context.Context, user *auth_types.User) error {
_, err := db.db.ExecContext(ctx, ` _, err := db.db.ExecContext(ctx, `
INSERT INTO user (user_id, email, email_verified, email_verified_at, is_admin, password, salt, created_at) INSERT INTO user (user_id, email, email_verified, email_verified_at, is_admin, password, salt, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
@@ -50,17 +51,17 @@ func (db AuthSqlite) InsertUser(ctx context.Context, user *types.User) error {
if err != nil { if err != nil {
if strings.Contains(err.Error(), "email") { if strings.Contains(err.Error(), "email") {
return ErrAlreadyExists return core.ErrAlreadyExists
} }
slog.ErrorContext(ctx, "SQL error InsertUser", "err", err) slog.ErrorContext(ctx, "SQL error InsertUser", "err", err)
return types.ErrInternal return core.ErrInternal
} }
return nil return nil
} }
func (db AuthSqlite) UpdateUser(ctx context.Context, user *types.User) error { func (db DbSqlite) UpdateUser(ctx context.Context, user *auth_types.User) error {
_, err := db.db.ExecContext(ctx, ` _, err := db.db.ExecContext(ctx, `
UPDATE user UPDATE user
SET email_verified = ?, email_verified_at = ?, password = ? SET email_verified = ?, email_verified_at = ?, password = ?
@@ -69,13 +70,13 @@ func (db AuthSqlite) UpdateUser(ctx context.Context, user *types.User) error {
if err != nil { if err != nil {
slog.ErrorContext(ctx, "SQL error UpdateUser", "err", err) slog.ErrorContext(ctx, "SQL error UpdateUser", "err", err)
return types.ErrInternal return core.ErrInternal
} }
return nil return nil
} }
func (db AuthSqlite) GetUserByEmail(ctx context.Context, email string) (*types.User, error) { func (db DbSqlite) GetUserByEmail(ctx context.Context, email string) (*auth_types.User, error) {
var ( var (
userId uuid.UUID userId uuid.UUID
emailVerified bool emailVerified bool
@@ -92,17 +93,17 @@ func (db AuthSqlite) GetUserByEmail(ctx context.Context, email string) (*types.U
WHERE email = ?`, email).Scan(&userId, &emailVerified, &emailVerifiedAt, &password, &salt, &createdAt) WHERE email = ?`, email).Scan(&userId, &emailVerified, &emailVerifiedAt, &password, &salt, &createdAt)
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound return nil, core.ErrNotFound
} else { } else {
slog.ErrorContext(ctx, "SQL error GetUser", "err", err) slog.ErrorContext(ctx, "SQL error GetUser", "err", err)
return nil, types.ErrInternal return nil, core.ErrInternal
} }
} }
return types.NewUser(userId, email, emailVerified, emailVerifiedAt, isAdmin, password, salt, createdAt), nil return auth_types.NewUser(userId, email, emailVerified, emailVerifiedAt, isAdmin, password, salt, createdAt), nil
} }
func (db AuthSqlite) GetUser(ctx context.Context, userId uuid.UUID) (*types.User, error) { func (db DbSqlite) GetUser(ctx context.Context, userId uuid.UUID) (*auth_types.User, error) {
var ( var (
email string email string
emailVerified bool emailVerified bool
@@ -119,92 +120,92 @@ func (db AuthSqlite) GetUser(ctx context.Context, userId uuid.UUID) (*types.User
WHERE user_id = ?`, userId).Scan(&email, &emailVerified, &emailVerifiedAt, &password, &salt, &createdAt) WHERE user_id = ?`, userId).Scan(&email, &emailVerified, &emailVerifiedAt, &password, &salt, &createdAt)
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound return nil, core.ErrNotFound
} else { } else {
slog.ErrorContext(ctx, "SQL error GetUser", "err", err) slog.ErrorContext(ctx, "SQL error GetUser", "err", err)
return nil, types.ErrInternal return nil, core.ErrInternal
} }
} }
return types.NewUser(userId, email, emailVerified, emailVerifiedAt, isAdmin, password, salt, createdAt), nil return auth_types.NewUser(userId, email, emailVerified, emailVerifiedAt, isAdmin, password, salt, createdAt), nil
} }
func (db AuthSqlite) DeleteUser(ctx context.Context, userId uuid.UUID) error { func (db DbSqlite) DeleteUser(ctx context.Context, userId uuid.UUID) error {
tx, err := db.db.BeginTx(ctx, nil) tx, err := db.db.BeginTx(ctx, nil)
if err != nil { if err != nil {
slog.ErrorContext(ctx, "Could not start transaction", "err", err) slog.ErrorContext(ctx, "Could not start transaction", "err", err)
return types.ErrInternal return core.ErrInternal
} }
_, err = tx.ExecContext(ctx, "DELETE FROM account WHERE user_id = ?", userId) _, err = tx.ExecContext(ctx, "DELETE FROM account WHERE user_id = ?", userId)
if err != nil { if err != nil {
_ = tx.Rollback() _ = tx.Rollback()
slog.ErrorContext(ctx, "Could not delete accounts", "err", err) slog.ErrorContext(ctx, "Could not delete accounts", "err", err)
return types.ErrInternal return core.ErrInternal
} }
_, err = tx.ExecContext(ctx, "DELETE FROM token WHERE user_id = ?", userId) _, err = tx.ExecContext(ctx, "DELETE FROM token WHERE user_id = ?", userId)
if err != nil { if err != nil {
_ = tx.Rollback() _ = tx.Rollback()
slog.ErrorContext(ctx, "Could not delete user tokens", "err", err) slog.ErrorContext(ctx, "Could not delete user tokens", "err", err)
return types.ErrInternal return core.ErrInternal
} }
_, err = tx.ExecContext(ctx, "DELETE FROM session WHERE user_id = ?", userId) _, err = tx.ExecContext(ctx, "DELETE FROM session WHERE user_id = ?", userId)
if err != nil { if err != nil {
_ = tx.Rollback() _ = tx.Rollback()
slog.ErrorContext(ctx, "Could not delete sessions", "err", err) slog.ErrorContext(ctx, "Could not delete sessions", "err", err)
return types.ErrInternal return core.ErrInternal
} }
_, err = tx.ExecContext(ctx, "DELETE FROM user WHERE user_id = ?", userId) _, err = tx.ExecContext(ctx, "DELETE FROM user WHERE user_id = ?", userId)
if err != nil { if err != nil {
_ = tx.Rollback() _ = tx.Rollback()
slog.ErrorContext(ctx, "Could not delete user", "err", err) slog.ErrorContext(ctx, "Could not delete user", "err", err)
return types.ErrInternal return core.ErrInternal
} }
_, err = tx.ExecContext(ctx, "DELETE FROM treasure_chest WHERE user_id = ?", userId) _, err = tx.ExecContext(ctx, "DELETE FROM treasure_chest WHERE user_id = ?", userId)
if err != nil { if err != nil {
_ = tx.Rollback() _ = tx.Rollback()
slog.ErrorContext(ctx, "Could not delete user", "err", err) slog.ErrorContext(ctx, "Could not delete user", "err", err)
return types.ErrInternal return core.ErrInternal
} }
_, err = tx.ExecContext(ctx, "DELETE FROM \"transaction\" WHERE user_id = ?", userId) _, err = tx.ExecContext(ctx, "DELETE FROM \"transaction\" WHERE user_id = ?", userId)
if err != nil { if err != nil {
_ = tx.Rollback() _ = tx.Rollback()
slog.ErrorContext(ctx, "Could not delete user", "err", err) slog.ErrorContext(ctx, "Could not delete user", "err", err)
return types.ErrInternal return core.ErrInternal
} }
err = tx.Commit() err = tx.Commit()
if err != nil { if err != nil {
slog.ErrorContext(ctx, "Could not commit transaction", "err", err) slog.ErrorContext(ctx, "Could not commit transaction", "err", err)
return types.ErrInternal return core.ErrInternal
} }
return nil return nil
} }
func (db AuthSqlite) InsertToken(ctx context.Context, token *types.Token) error { func (db DbSqlite) InsertToken(ctx context.Context, token *auth_types.Token) error {
_, err := db.db.ExecContext(ctx, ` _, err := db.db.ExecContext(ctx, `
INSERT INTO token (user_id, session_id, type, token, created_at, expires_at) 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) VALUES (?, ?, ?, ?, ?, ?)`, token.UserId, token.SessionId, token.Type, token.Token, token.CreatedAt, token.ExpiresAt)
if err != nil { if err != nil {
slog.ErrorContext(ctx, "Could not insert token", "err", err) slog.ErrorContext(ctx, "Could not insert token", "err", err)
return types.ErrInternal return core.ErrInternal
} }
return nil return nil
} }
func (db AuthSqlite) GetToken(ctx context.Context, token string) (*types.Token, error) { func (db DbSqlite) GetToken(ctx context.Context, token string) (*auth_types.Token, error) {
var ( var (
userId uuid.UUID userId uuid.UUID
sessionId string sessionId string
tokenType types.TokenType tokenType auth_types.TokenType
createdAtStr string createdAtStr string
expiresAtStr string expiresAtStr string
createdAt time.Time createdAt time.Time
@@ -219,29 +220,29 @@ func (db AuthSqlite) GetToken(ctx context.Context, token string) (*types.Token,
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
slog.InfoContext(ctx, "Token not found", "token", token) slog.InfoContext(ctx, "Token not found", "token", token)
return nil, ErrNotFound return nil, core.ErrNotFound
} else { } else {
slog.ErrorContext(ctx, "Could not get token", "err", err) slog.ErrorContext(ctx, "Could not get token", "err", err)
return nil, types.ErrInternal return nil, core.ErrInternal
} }
} }
createdAt, err = time.Parse(time.RFC3339, createdAtStr) createdAt, err = time.Parse(time.RFC3339, createdAtStr)
if err != nil { if err != nil {
slog.ErrorContext(ctx, "Could not parse token.created_at", "err", err) slog.ErrorContext(ctx, "Could not parse token.created_at", "err", err)
return nil, types.ErrInternal return nil, core.ErrInternal
} }
expiresAt, err = time.Parse(time.RFC3339, expiresAtStr) expiresAt, err = time.Parse(time.RFC3339, expiresAtStr)
if err != nil { if err != nil {
slog.ErrorContext(ctx, "Could not parse token.expires_at", "err", err) slog.ErrorContext(ctx, "Could not parse token.expires_at", "err", err)
return nil, types.ErrInternal return nil, core.ErrInternal
} }
return types.NewToken(userId, sessionId, token, tokenType, createdAt, expiresAt), nil return auth_types.NewToken(userId, sessionId, token, tokenType, createdAt, expiresAt), nil
} }
func (db AuthSqlite) GetTokensByUserIdAndType(ctx context.Context, userId uuid.UUID, tokenType types.TokenType) ([]*types.Token, error) { func (db DbSqlite) GetTokensByUserIdAndType(ctx context.Context, userId uuid.UUID, tokenType auth_types.TokenType) ([]*auth_types.Token, error) {
query, err := db.db.QueryContext(ctx, ` query, err := db.db.QueryContext(ctx, `
SELECT token, created_at, expires_at SELECT token, created_at, expires_at
FROM token FROM token
@@ -250,13 +251,13 @@ func (db AuthSqlite) GetTokensByUserIdAndType(ctx context.Context, userId uuid.U
if err != nil { if err != nil {
slog.ErrorContext(ctx, "Could not get token", "err", err) slog.ErrorContext(ctx, "Could not get token", "err", err)
return nil, types.ErrInternal return nil, core.ErrInternal
} }
return getTokensFromQuery(ctx, query, userId, "", tokenType) return getTokensFromQuery(ctx, query, userId, "", tokenType)
} }
func (db AuthSqlite) GetTokensBySessionIdAndType(ctx context.Context, sessionId string, tokenType types.TokenType) ([]*types.Token, error) { func (db DbSqlite) GetTokensBySessionIdAndType(ctx context.Context, sessionId string, tokenType auth_types.TokenType) ([]*auth_types.Token, error) {
query, err := db.db.QueryContext(ctx, ` query, err := db.db.QueryContext(ctx, `
SELECT token, created_at, expires_at SELECT token, created_at, expires_at
FROM token FROM token
@@ -265,14 +266,14 @@ func (db AuthSqlite) GetTokensBySessionIdAndType(ctx context.Context, sessionId
if err != nil { if err != nil {
slog.ErrorContext(ctx, "Could not get token", "err", err) slog.ErrorContext(ctx, "Could not get token", "err", err)
return nil, types.ErrInternal return nil, core.ErrInternal
} }
return getTokensFromQuery(ctx, query, uuid.Nil, sessionId, tokenType) return getTokensFromQuery(ctx, query, uuid.Nil, sessionId, tokenType)
} }
func getTokensFromQuery(ctx context.Context, 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 auth_types.TokenType) ([]*auth_types.Token, error) {
var tokens []*types.Token var tokens []*auth_types.Token
hasRows := false hasRows := false
for query.Next() { for query.Next() {
@@ -289,54 +290,54 @@ func getTokensFromQuery(ctx context.Context, query *sql.Rows, userId uuid.UUID,
err := query.Scan(&token, &createdAtStr, &expiresAtStr) err := query.Scan(&token, &createdAtStr, &expiresAtStr)
if err != nil { if err != nil {
slog.ErrorContext(ctx, "Could not scan token", "err", err) slog.ErrorContext(ctx, "Could not scan token", "err", err)
return nil, types.ErrInternal return nil, core.ErrInternal
} }
createdAt, err = time.Parse(time.RFC3339, createdAtStr) createdAt, err = time.Parse(time.RFC3339, createdAtStr)
if err != nil { if err != nil {
slog.ErrorContext(ctx, "Could not parse token.created_at", "err", err) slog.ErrorContext(ctx, "Could not parse token.created_at", "err", err)
return nil, types.ErrInternal return nil, core.ErrInternal
} }
expiresAt, err = time.Parse(time.RFC3339, expiresAtStr) expiresAt, err = time.Parse(time.RFC3339, expiresAtStr)
if err != nil { if err != nil {
slog.ErrorContext(ctx, "Could not parse token.expires_at", "err", err) slog.ErrorContext(ctx, "Could not parse token.expires_at", "err", err)
return nil, types.ErrInternal return nil, core.ErrInternal
} }
tokens = append(tokens, types.NewToken(userId, sessionId, token, tokenType, createdAt, expiresAt)) tokens = append(tokens, auth_types.NewToken(userId, sessionId, token, tokenType, createdAt, expiresAt))
} }
if !hasRows { if !hasRows {
return nil, ErrNotFound return nil, core.ErrNotFound
} }
return tokens, nil return tokens, nil
} }
func (db AuthSqlite) DeleteToken(ctx context.Context, token string) error { func (db DbSqlite) DeleteToken(ctx context.Context, token string) error {
_, err := db.db.ExecContext(ctx, "DELETE FROM token WHERE token = ?", token) _, err := db.db.ExecContext(ctx, "DELETE FROM token WHERE token = ?", token)
if err != nil { if err != nil {
slog.ErrorContext(ctx, "Could not delete token", "err", err) slog.ErrorContext(ctx, "Could not delete token", "err", err)
return types.ErrInternal return core.ErrInternal
} }
return nil return nil
} }
func (db AuthSqlite) InsertSession(ctx context.Context, session *types.Session) error { func (db DbSqlite) InsertSession(ctx context.Context, session *auth_types.Session) error {
_, err := db.db.ExecContext(ctx, ` _, err := db.db.ExecContext(ctx, `
INSERT INTO session (session_id, user_id, created_at, expires_at) INSERT INTO session (session_id, user_id, created_at, expires_at)
VALUES (?, ?, ?, ?)`, session.Id, session.UserId, session.CreatedAt, session.ExpiresAt) VALUES (?, ?, ?, ?)`, session.Id, session.UserId, session.CreatedAt, session.ExpiresAt)
if err != nil { if err != nil {
slog.ErrorContext(ctx, "Could not insert new session", "err", err) slog.ErrorContext(ctx, "Could not insert new session", "err", err)
return types.ErrInternal return core.ErrInternal
} }
return nil return nil
} }
func (db AuthSqlite) GetSession(ctx context.Context, sessionId string) (*types.Session, error) { func (db DbSqlite) GetSession(ctx context.Context, sessionId string) (*auth_types.Session, error) {
var ( var (
userId uuid.UUID userId uuid.UUID
createdAt time.Time createdAt time.Time
@@ -350,56 +351,56 @@ func (db AuthSqlite) GetSession(ctx context.Context, sessionId string) (*types.S
if err != nil { if err != nil {
slog.WarnContext(ctx, "Session not found", "session-id", sessionId, "err", err) slog.WarnContext(ctx, "Session not found", "session-id", sessionId, "err", err)
return nil, ErrNotFound return nil, core.ErrNotFound
} }
return types.NewSession(sessionId, userId, createdAt, expiresAt), nil return auth_types.NewSession(sessionId, userId, createdAt, expiresAt), nil
} }
func (db AuthSqlite) GetSessions(ctx context.Context, userId uuid.UUID) ([]*types.Session, error) { func (db DbSqlite) GetSessions(ctx context.Context, userId uuid.UUID) ([]*auth_types.Session, error) {
var sessions []*types.Session var sessions []*auth_types.Session
err := db.db.SelectContext(ctx, &sessions, ` err := db.db.SelectContext(ctx, &sessions, `
SELECT * SELECT *
FROM session FROM session
WHERE user_id = ?`, userId) WHERE user_id = ?`, userId)
if err != nil { if err != nil {
slog.ErrorContext(ctx, "Could not get sessions", "err", err) slog.ErrorContext(ctx, "Could not get sessions", "err", err)
return nil, types.ErrInternal return nil, core.ErrInternal
} }
return sessions, nil return sessions, nil
} }
func (db AuthSqlite) DeleteSession(ctx context.Context, sessionId string) error { func (db DbSqlite) DeleteSession(ctx context.Context, sessionId string) error {
if sessionId != "" { if sessionId != "" {
_, err := db.db.ExecContext(ctx, "DELETE FROM session WHERE session_id = ?", sessionId) _, err := db.db.ExecContext(ctx, "DELETE FROM session WHERE session_id = ?", sessionId)
if err != nil { if err != nil {
slog.ErrorContext(ctx, "Could not delete session", "err", err) slog.ErrorContext(ctx, "Could not delete session", "err", err)
return types.ErrInternal return core.ErrInternal
} }
} }
return nil return nil
} }
func (db AuthSqlite) DeleteOldSessions(ctx context.Context) error { func (db DbSqlite) DeleteOldSessions(ctx context.Context) error {
_, err := db.db.ExecContext(ctx, ` _, err := db.db.ExecContext(ctx, `
DELETE FROM session DELETE FROM session
WHERE expires_at < datetime('now')`) WHERE expires_at < datetime('now')`)
if err != nil { if err != nil {
slog.ErrorContext(ctx, "Could not delete old sessions", "err", err) slog.ErrorContext(ctx, "Could not delete old sessions", "err", err)
return types.ErrInternal return core.ErrInternal
} }
return nil return nil
} }
func (db AuthSqlite) DeleteOldTokens(ctx context.Context) error { func (db DbSqlite) DeleteOldTokens(ctx context.Context) error {
_, err := db.db.ExecContext(ctx, ` _, err := db.db.ExecContext(ctx, `
DELETE FROM token DELETE FROM token
WHERE expires_at < datetime('now')`) WHERE expires_at < datetime('now')`)
if err != nil { if err != nil {
slog.ErrorContext(ctx, "Could not delete old tokens", "err", err) slog.ErrorContext(ctx, "Could not delete old tokens", "err", err)
return types.ErrInternal return core.ErrInternal
} }
return nil return nil
} }

View File

@@ -1,35 +1,33 @@
package handler package authentication
import ( import (
"errors" "errors"
"log/slog" "log/slog"
"net/http" "net/http"
"net/url" "net/url"
"spend-sparrow/internal/handler/middleware" "spend-sparrow/internal/auth_types"
"spend-sparrow/internal/service" "spend-sparrow/internal/authentication/template"
"spend-sparrow/internal/template/auth" "spend-sparrow/internal/core"
"spend-sparrow/internal/types"
"spend-sparrow/internal/utils"
"time" "time"
) )
type Auth interface { type Handler interface {
Handle(router *http.ServeMux) Handle(router *http.ServeMux)
} }
type AuthImpl struct { type HandlerImpl struct {
service service.Auth service Service
render *Render render *core.Render
} }
func NewAuth(service service.Auth, render *Render) Auth { func NewHandler(service Service, render *core.Render) Handler {
return AuthImpl{ return HandlerImpl{
service: service, service: service,
render: render, render: render,
} }
} }
func (handler AuthImpl) Handle(router *http.ServeMux) { func (handler HandlerImpl) Handle(router *http.ServeMux) {
router.Handle("GET /auth/signin", handler.handleSignInPage()) router.Handle("GET /auth/signin", handler.handleSignInPage())
router.Handle("POST /api/auth/signin", handler.handleSignIn()) router.Handle("POST /api/auth/signin", handler.handleSignIn())
@@ -56,32 +54,32 @@ var (
securityWaitDuration = 250 * time.Millisecond securityWaitDuration = 250 * time.Millisecond
) )
func (handler AuthImpl) handleSignInPage() http.HandlerFunc { func (handler HandlerImpl) handleSignInPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r) core.UpdateSpan(r)
user := middleware.GetUser(r) user := core.GetUser(r)
if user != nil { if user != nil {
if !user.EmailVerified { if !user.EmailVerified {
utils.DoRedirect(w, r, "/auth/verify") core.DoRedirect(w, r, "/auth/verify")
} else { } else {
utils.DoRedirect(w, r, "/") core.DoRedirect(w, r, "/")
} }
return return
} }
comp := auth.SignInOrUpComp(true) comp := template.SignInOrUpComp(true)
handler.render.RenderLayout(r, w, comp, nil) handler.render.RenderLayout(r, w, comp, nil)
} }
} }
func (handler AuthImpl) handleSignIn() http.HandlerFunc { func (handler HandlerImpl) handleSignIn() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r) core.UpdateSpan(r)
user, err := utils.WaitMinimumTime(securityWaitDuration, func() (*types.User, error) { user, err := core.WaitMinimumTime(securityWaitDuration, func() (*auth_types.User, error) {
session := middleware.GetSession(r) session := core.GetSession(r)
email := r.FormValue("email") email := r.FormValue("email")
password := r.FormValue("password") password := r.FormValue("password")
@@ -90,76 +88,76 @@ func (handler AuthImpl) handleSignIn() http.HandlerFunc {
return nil, err return nil, err
} }
cookie := middleware.CreateSessionCookie(session.Id) cookie := core.CreateSessionCookie(session.Id)
http.SetCookie(w, &cookie) http.SetCookie(w, &cookie)
return user, nil return user, nil
}) })
if err != nil { if err != nil {
if errors.Is(err, service.ErrInvalidCredentials) { if errors.Is(err, ErrInvalidCredentials) {
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Invalid email or password", http.StatusUnauthorized) core.TriggerToastWithStatus(r.Context(), w, r, "error", "Invalid email or password", http.StatusUnauthorized)
} else { } else {
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "An error occurred", http.StatusInternalServerError) core.TriggerToastWithStatus(r.Context(), w, r, "error", "An error occurred", http.StatusInternalServerError)
} }
return return
} }
if user.EmailVerified { if user.EmailVerified {
utils.DoRedirect(w, r, "/") core.DoRedirect(w, r, "/")
} else { } else {
utils.DoRedirect(w, r, "/auth/verify") core.DoRedirect(w, r, "/auth/verify")
} }
} }
} }
func (handler AuthImpl) handleSignUpPage() http.HandlerFunc { func (handler HandlerImpl) handleSignUpPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r) core.UpdateSpan(r)
user := middleware.GetUser(r) user := core.GetUser(r)
if user != nil { if user != nil {
if !user.EmailVerified { if !user.EmailVerified {
utils.DoRedirect(w, r, "/auth/verify") core.DoRedirect(w, r, "/auth/verify")
} else { } else {
utils.DoRedirect(w, r, "/") core.DoRedirect(w, r, "/")
} }
return return
} }
signUpComp := auth.SignInOrUpComp(false) signUpComp := template.SignInOrUpComp(false)
handler.render.RenderLayout(r, w, signUpComp, nil) handler.render.RenderLayout(r, w, signUpComp, nil)
} }
} }
func (handler AuthImpl) handleSignUpVerifyPage() http.HandlerFunc { func (handler HandlerImpl) handleSignUpVerifyPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r) core.UpdateSpan(r)
user := middleware.GetUser(r) user := core.GetUser(r)
if user == nil { if user == nil {
utils.DoRedirect(w, r, "/auth/signin") core.DoRedirect(w, r, "/auth/signin")
return return
} }
if user.EmailVerified { if user.EmailVerified {
utils.DoRedirect(w, r, "/") core.DoRedirect(w, r, "/")
return return
} }
signIn := auth.VerifyComp() signIn := template.VerifyComp()
handler.render.RenderLayout(r, w, signIn, user) handler.render.RenderLayout(r, w, signIn, user)
} }
} }
func (handler AuthImpl) handleVerifyResendComp() http.HandlerFunc { func (handler HandlerImpl) handleVerifyResendComp() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r) core.UpdateSpan(r)
user := middleware.GetUser(r) user := core.GetUser(r)
if user == nil { if user == nil {
utils.DoRedirect(w, r, "/auth/signin") core.DoRedirect(w, r, "/auth/signin")
return return
} }
@@ -172,16 +170,16 @@ func (handler AuthImpl) handleVerifyResendComp() http.HandlerFunc {
} }
} }
func (handler AuthImpl) handleSignUpVerifyResponsePage() http.HandlerFunc { func (handler HandlerImpl) handleSignUpVerifyResponsePage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r) core.UpdateSpan(r)
token := r.URL.Query().Get("token") token := r.URL.Query().Get("token")
err := handler.service.VerifyUserEmail(r.Context(), token) err := handler.service.VerifyUserEmail(r.Context(), token)
isVerified := err == nil isVerified := err == nil
comp := auth.VerifyResponseComp(isVerified) comp := template.VerifyResponseComp(isVerified)
var status int var status int
if isVerified { if isVerified {
@@ -194,14 +192,14 @@ func (handler AuthImpl) handleSignUpVerifyResponsePage() http.HandlerFunc {
} }
} }
func (handler AuthImpl) handleSignUp() http.HandlerFunc { func (handler HandlerImpl) handleSignUp() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r) core.UpdateSpan(r)
var email = r.FormValue("email") var email = r.FormValue("email")
var password = r.FormValue("password") var password = r.FormValue("password")
_, err := utils.WaitMinimumTime(securityWaitDuration, func() (any, error) { _, err := core.WaitMinimumTime(securityWaitDuration, func() (any, error) {
slog.InfoContext(r.Context(), "signing up", "email", email) slog.InfoContext(r.Context(), "signing up", "email", email)
user, err := handler.service.SignUp(r.Context(), email, password) user, err := handler.service.SignUp(r.Context(), email, password)
if err != nil { if err != nil {
@@ -215,28 +213,28 @@ func (handler AuthImpl) handleSignUp() http.HandlerFunc {
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, types.ErrInternal): case errors.Is(err, core.ErrInternal):
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "An error occurred", http.StatusInternalServerError) core.TriggerToastWithStatus(r.Context(), w, r, "error", "An error occurred", http.StatusInternalServerError)
return return
case errors.Is(err, service.ErrInvalidEmail): case errors.Is(err, ErrInvalidEmail):
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "The email provided is invalid", http.StatusBadRequest) core.TriggerToastWithStatus(r.Context(), w, r, "error", "The email provided is invalid", http.StatusBadRequest)
return return
case errors.Is(err, service.ErrInvalidPassword): case errors.Is(err, ErrInvalidPassword):
utils.TriggerToastWithStatus(r.Context(), w, r, "error", service.ErrInvalidPassword.Error(), http.StatusBadRequest) core.TriggerToastWithStatus(r.Context(), w, r, "error", ErrInvalidPassword.Error(), http.StatusBadRequest)
return return
} }
// If err is "service.ErrAccountExists", then just continue // If err is "service.ErrAccountExists", then just continue
} }
utils.TriggerToastWithStatus(r.Context(), w, r, "success", "An activation link has been send to your email", http.StatusOK) core.TriggerToastWithStatus(r.Context(), w, r, "success", "An activation link has been send to your email", http.StatusOK)
} }
} }
func (handler AuthImpl) handleSignOut() http.HandlerFunc { func (handler HandlerImpl) handleSignOut() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r) core.UpdateSpan(r)
session := middleware.GetSession(r) session := core.GetSession(r)
if session != nil { if session != nil {
err := handler.service.SignOut(r.Context(), session.Id) err := handler.service.SignOut(r.Context(), session.Id)
@@ -257,32 +255,32 @@ func (handler AuthImpl) handleSignOut() http.HandlerFunc {
} }
http.SetCookie(w, &c) http.SetCookie(w, &c)
utils.DoRedirect(w, r, "/") core.DoRedirect(w, r, "/")
} }
} }
func (handler AuthImpl) handleDeleteAccountPage() http.HandlerFunc { func (handler HandlerImpl) handleDeleteAccountPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r) core.UpdateSpan(r)
user := middleware.GetUser(r) user := core.GetUser(r)
if user == nil { if user == nil {
utils.DoRedirect(w, r, "/auth/signin") core.DoRedirect(w, r, "/auth/signin")
return return
} }
comp := auth.DeleteAccountComp() comp := template.DeleteAccountComp()
handler.render.RenderLayout(r, w, comp, user) handler.render.RenderLayout(r, w, comp, user)
} }
} }
func (handler AuthImpl) handleDeleteAccountComp() http.HandlerFunc { func (handler HandlerImpl) handleDeleteAccountComp() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r) core.UpdateSpan(r)
user := middleware.GetUser(r) user := core.GetUser(r)
if user == nil { if user == nil {
utils.DoRedirect(w, r, "/auth/signin") core.DoRedirect(w, r, "/auth/signin")
return return
} }
@@ -290,44 +288,44 @@ func (handler AuthImpl) handleDeleteAccountComp() http.HandlerFunc {
err := handler.service.DeleteAccount(r.Context(), user, password) err := handler.service.DeleteAccount(r.Context(), user, password)
if err != nil { if err != nil {
if errors.Is(err, service.ErrInvalidCredentials) { if errors.Is(err, ErrInvalidCredentials) {
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Password not correct", http.StatusBadRequest) core.TriggerToastWithStatus(r.Context(), w, r, "error", "Password not correct", http.StatusBadRequest)
} else { } else {
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Internal Server Error", http.StatusInternalServerError) core.TriggerToastWithStatus(r.Context(), w, r, "error", "Internal Server Error", http.StatusInternalServerError)
} }
return return
} }
utils.DoRedirect(w, r, "/") core.DoRedirect(w, r, "/")
} }
} }
func (handler AuthImpl) handleChangePasswordPage() http.HandlerFunc { func (handler HandlerImpl) handleChangePasswordPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r) core.UpdateSpan(r)
isPasswordReset := r.URL.Query().Has("token") isPasswordReset := r.URL.Query().Has("token")
user := middleware.GetUser(r) user := core.GetUser(r)
if user == nil && !isPasswordReset { if user == nil && !isPasswordReset {
utils.DoRedirect(w, r, "/auth/signin") core.DoRedirect(w, r, "/auth/signin")
return return
} }
comp := auth.ChangePasswordComp(isPasswordReset) comp := template.ChangePasswordComp(isPasswordReset)
handler.render.RenderLayout(r, w, comp, user) handler.render.RenderLayout(r, w, comp, user)
} }
} }
func (handler AuthImpl) handleChangePasswordComp() http.HandlerFunc { func (handler HandlerImpl) handleChangePasswordComp() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r) core.UpdateSpan(r)
session := middleware.GetSession(r) session := core.GetSession(r)
user := middleware.GetUser(r) user := core.GetUser(r)
if session == nil || user == nil { if session == nil || user == nil {
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Unathorized", http.StatusUnauthorized) core.TriggerToastWithStatus(r.Context(), w, r, "error", "Unathorized", http.StatusUnauthorized)
return return
} }
@@ -336,60 +334,60 @@ func (handler AuthImpl) handleChangePasswordComp() http.HandlerFunc {
err := handler.service.ChangePassword(r.Context(), user, session.Id, currPass, newPass) err := handler.service.ChangePassword(r.Context(), user, session.Id, currPass, newPass)
if err != nil { if err != nil {
utils.TriggerToastWithStatus(r.Context(), w, r, "error", err.Error(), http.StatusBadRequest) core.TriggerToastWithStatus(r.Context(), w, r, "error", err.Error(), http.StatusBadRequest)
return return
} }
utils.TriggerToastWithStatus(r.Context(), w, r, "success", "Password changed", http.StatusOK) core.TriggerToastWithStatus(r.Context(), w, r, "success", "Password changed", http.StatusOK)
} }
} }
func (handler AuthImpl) handleForgotPasswordPage() http.HandlerFunc { func (handler HandlerImpl) handleForgotPasswordPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r) core.UpdateSpan(r)
user := middleware.GetUser(r) user := core.GetUser(r)
if user != nil { if user != nil {
utils.DoRedirect(w, r, "/") core.DoRedirect(w, r, "/")
return return
} }
comp := auth.ResetPasswordComp() comp := template.ResetPasswordComp()
handler.render.RenderLayout(r, w, comp, user) handler.render.RenderLayout(r, w, comp, user)
} }
} }
func (handler AuthImpl) handleForgotPasswordComp() http.HandlerFunc { func (handler HandlerImpl) handleForgotPasswordComp() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r) core.UpdateSpan(r)
email := r.FormValue("email") email := r.FormValue("email")
if email == "" { if email == "" {
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Please enter an email", http.StatusBadRequest) core.TriggerToastWithStatus(r.Context(), w, r, "error", "Please enter an email", http.StatusBadRequest)
return return
} }
_, err := utils.WaitMinimumTime(securityWaitDuration, func() (any, error) { _, err := core.WaitMinimumTime(securityWaitDuration, func() (any, error) {
err := handler.service.SendForgotPasswordMail(r.Context(), email) err := handler.service.SendForgotPasswordMail(r.Context(), email)
return nil, err return nil, err
}) })
if err != nil { if err != nil {
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Internal Server Error", http.StatusInternalServerError) core.TriggerToastWithStatus(r.Context(), w, r, "error", "Internal Server Error", http.StatusInternalServerError)
} else { } else {
utils.TriggerToastWithStatus(r.Context(), w, r, "info", "If the address exists, an email has been sent.", http.StatusOK) core.TriggerToastWithStatus(r.Context(), w, r, "info", "If the address exists, an email has been sent.", http.StatusOK)
} }
} }
} }
func (handler AuthImpl) handleForgotPasswordResponseComp() http.HandlerFunc { func (handler HandlerImpl) handleForgotPasswordResponseComp() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r) core.UpdateSpan(r)
pageUrl, err := url.Parse(r.Header.Get("Hx-Current-Url")) pageUrl, err := url.Parse(r.Header.Get("Hx-Current-Url"))
if err != nil { if err != nil {
slog.ErrorContext(r.Context(), "Could not get current URL", "err", err) slog.ErrorContext(r.Context(), "Could not get current URL", "err", err)
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Internal Server Error", http.StatusInternalServerError) core.TriggerToastWithStatus(r.Context(), w, r, "error", "Internal Server Error", http.StatusInternalServerError)
return return
} }
@@ -398,9 +396,9 @@ func (handler AuthImpl) handleForgotPasswordResponseComp() http.HandlerFunc {
err = handler.service.ForgotPassword(r.Context(), token, newPass) err = handler.service.ForgotPassword(r.Context(), token, newPass)
if err != nil { if err != nil {
utils.TriggerToastWithStatus(r.Context(), w, r, "error", err.Error(), http.StatusBadRequest) core.TriggerToastWithStatus(r.Context(), w, r, "error", err.Error(), http.StatusBadRequest)
} else { } else {
utils.TriggerToastWithStatus(r.Context(), w, r, "success", "Password changed", http.StatusOK) core.TriggerToastWithStatus(r.Context(), w, r, "success", "Password changed", http.StatusOK)
} }
} }
} }

View File

@@ -1,4 +1,4 @@
package service package authentication
import ( import (
"context" "context"
@@ -6,9 +6,9 @@ import (
"errors" "errors"
"log/slog" "log/slog"
"net/mail" "net/mail"
"spend-sparrow/internal/db" "spend-sparrow/internal/auth_types"
"spend-sparrow/internal/core"
mailTemplate "spend-sparrow/internal/template/mail" mailTemplate "spend-sparrow/internal/template/mail"
"spend-sparrow/internal/types"
"strings" "strings"
"time" "time"
@@ -25,39 +25,39 @@ var (
ErrTokenInvalid = errors.New("token is invalid") ErrTokenInvalid = errors.New("token is invalid")
) )
type Auth interface { type Service interface {
SignUp(ctx context.Context, email string, password string) (*types.User, error) SignUp(ctx context.Context, email string, password string) (*auth_types.User, error)
SendVerificationMail(ctx context.Context, userId uuid.UUID, email string) SendVerificationMail(ctx context.Context, userId uuid.UUID, email string)
VerifyUserEmail(ctx context.Context, token string) error VerifyUserEmail(ctx context.Context, token string) error
SignIn(ctx context.Context, session *types.Session, email string, password string) (*types.Session, *types.User, error) SignIn(ctx context.Context, session *auth_types.Session, email string, password string) (*auth_types.Session, *auth_types.User, error)
SignInSession(ctx context.Context, sessionId string) (*types.Session, *types.User, error) SignInSession(ctx context.Context, sessionId string) (*auth_types.Session, *auth_types.User, error)
SignInAnonymous(ctx context.Context) (*types.Session, error) SignInAnonymous(ctx context.Context) (*auth_types.Session, error)
SignOut(ctx context.Context, sessionId string) error SignOut(ctx context.Context, sessionId string) error
DeleteAccount(ctx context.Context, user *types.User, currPass string) error DeleteAccount(ctx context.Context, user *auth_types.User, currPass string) error
ChangePassword(ctx context.Context, user *types.User, sessionId string, currPass, newPass string) error ChangePassword(ctx context.Context, user *auth_types.User, sessionId string, currPass, newPass string) error
SendForgotPasswordMail(ctx context.Context, email string) error SendForgotPasswordMail(ctx context.Context, email string) error
ForgotPassword(ctx context.Context, token string, newPass string) error ForgotPassword(ctx context.Context, token string, newPass string) error
IsCsrfTokenValid(ctx context.Context, tokenStr string, sessionId string) bool IsCsrfTokenValid(ctx context.Context, tokenStr string, sessionId string) bool
GetCsrfToken(ctx context.Context, session *types.Session) (string, error) GetCsrfToken(ctx context.Context, session *auth_types.Session) (string, error)
CleanupSessionsAndTokens(ctx context.Context) error CleanupSessionsAndTokens(ctx context.Context) error
} }
type AuthImpl struct { type ServiceImpl struct {
db db.Auth db Db
random Random random core.Random
clock Clock clock core.Clock
mail Mail mail core.Mail
serverSettings *types.Settings serverSettings *core.Settings
} }
func NewAuth(db db.Auth, random Random, clock Clock, mail Mail, serverSettings *types.Settings) *AuthImpl { func NewService(db Db, random core.Random, clock core.Clock, mail core.Mail, serverSettings *core.Settings) *ServiceImpl {
return &AuthImpl{ return &ServiceImpl{
db: db, db: db,
random: random, random: random,
clock: clock, clock: clock,
@@ -66,13 +66,13 @@ func NewAuth(db db.Auth, random Random, clock Clock, mail Mail, serverSettings *
} }
} }
func (service AuthImpl) SignIn(ctx context.Context, session *types.Session, email string, password string) (*types.Session, *types.User, error) { func (service ServiceImpl) SignIn(ctx context.Context, session *auth_types.Session, email string, password string) (*auth_types.Session, *auth_types.User, error) {
user, err := service.db.GetUserByEmail(ctx, email) user, err := service.db.GetUserByEmail(ctx, email)
if err != nil { if err != nil {
if errors.Is(err, db.ErrNotFound) { if errors.Is(err, core.ErrNotFound) {
return nil, nil, ErrInvalidCredentials return nil, nil, ErrInvalidCredentials
} else { } else {
return nil, nil, types.ErrInternal return nil, nil, core.ErrInternal
} }
} }
@@ -84,36 +84,36 @@ func (service AuthImpl) SignIn(ctx context.Context, session *types.Session, emai
newSession, err := service.createSession(ctx, user.Id) newSession, err := service.createSession(ctx, user.Id)
if err != nil { if err != nil {
return nil, nil, types.ErrInternal return nil, nil, core.ErrInternal
} }
err = service.db.DeleteSession(ctx, session.Id) err = service.db.DeleteSession(ctx, session.Id)
if err != nil { if err != nil {
return nil, nil, types.ErrInternal return nil, nil, core.ErrInternal
} }
tokens, err := service.db.GetTokensBySessionIdAndType(ctx, session.Id, types.TokenTypeCsrf) tokens, err := service.db.GetTokensBySessionIdAndType(ctx, session.Id, auth_types.TokenTypeCsrf)
if err != nil { if err != nil {
return nil, nil, types.ErrInternal return nil, nil, core.ErrInternal
} }
for _, token := range tokens { for _, token := range tokens {
err = service.db.DeleteToken(ctx, token.Token) err = service.db.DeleteToken(ctx, token.Token)
if err != nil { if err != nil {
return nil, nil, types.ErrInternal return nil, nil, core.ErrInternal
} }
} }
return newSession, user, nil return newSession, user, nil
} }
func (service AuthImpl) SignInSession(ctx context.Context, sessionId string) (*types.Session, *types.User, error) { func (service ServiceImpl) SignInSession(ctx context.Context, sessionId string) (*auth_types.Session, *auth_types.User, error) {
if sessionId == "" { if sessionId == "" {
return nil, nil, ErrSessionIdInvalid return nil, nil, ErrSessionIdInvalid
} }
session, err := service.db.GetSession(ctx, sessionId) session, err := service.db.GetSession(ctx, sessionId)
if err != nil { if err != nil {
return nil, nil, types.ErrInternal return nil, nil, core.ErrInternal
} }
if session.ExpiresAt.Before(service.clock.Now()) { if session.ExpiresAt.Before(service.clock.Now()) {
_ = service.db.DeleteSession(ctx, sessionId) _ = service.db.DeleteSession(ctx, sessionId)
@@ -126,16 +126,16 @@ func (service AuthImpl) SignInSession(ctx context.Context, sessionId string) (*t
user, err := service.db.GetUser(ctx, session.UserId) user, err := service.db.GetUser(ctx, session.UserId)
if err != nil { if err != nil {
return nil, nil, types.ErrInternal return nil, nil, core.ErrInternal
} }
return session, user, nil return session, user, nil
} }
func (service AuthImpl) SignInAnonymous(ctx context.Context) (*types.Session, error) { func (service ServiceImpl) SignInAnonymous(ctx context.Context) (*auth_types.Session, error) {
session, err := service.createSession(ctx, uuid.Nil) session, err := service.createSession(ctx, uuid.Nil)
if err != nil { if err != nil {
return nil, types.ErrInternal return nil, core.ErrInternal
} }
slog.InfoContext(ctx, "anonymous session created", "session-id", session.Id) slog.InfoContext(ctx, "anonymous session created", "session-id", session.Id)
@@ -143,7 +143,7 @@ func (service AuthImpl) SignInAnonymous(ctx context.Context) (*types.Session, er
return session, nil return session, nil
} }
func (service AuthImpl) SignUp(ctx context.Context, email string, password string) (*types.User, error) { func (service ServiceImpl) SignUp(ctx context.Context, email string, password string) (*auth_types.User, error) {
_, err := mail.ParseAddress(email) _, err := mail.ParseAddress(email)
if err != nil { if err != nil {
return nil, ErrInvalidEmail return nil, ErrInvalidEmail
@@ -155,37 +155,37 @@ func (service AuthImpl) SignUp(ctx context.Context, email string, password strin
userId, err := service.random.UUID(ctx) userId, err := service.random.UUID(ctx)
if err != nil { if err != nil {
return nil, types.ErrInternal return nil, core.ErrInternal
} }
salt, err := service.random.Bytes(ctx, 16) salt, err := service.random.Bytes(ctx, 16)
if err != nil { if err != nil {
return nil, types.ErrInternal return nil, core.ErrInternal
} }
hash := GetHashPassword(password, salt) hash := GetHashPassword(password, salt)
user := types.NewUser(userId, email, false, nil, false, hash, salt, service.clock.Now()) user := auth_types.NewUser(userId, email, false, nil, false, hash, salt, service.clock.Now())
err = service.db.InsertUser(ctx, user) err = service.db.InsertUser(ctx, user)
if err != nil { if err != nil {
if errors.Is(err, db.ErrAlreadyExists) { if errors.Is(err, core.ErrAlreadyExists) {
return nil, ErrAccountExists return nil, ErrAccountExists
} else { } else {
return nil, types.ErrInternal return nil, core.ErrInternal
} }
} }
return user, nil return user, nil
} }
func (service AuthImpl) SendVerificationMail(ctx context.Context, userId uuid.UUID, email string) { func (service ServiceImpl) SendVerificationMail(ctx context.Context, userId uuid.UUID, email string) {
tokens, err := service.db.GetTokensByUserIdAndType(ctx, userId, types.TokenTypeEmailVerify) tokens, err := service.db.GetTokensByUserIdAndType(ctx, userId, auth_types.TokenTypeEmailVerify)
if err != nil && !errors.Is(err, db.ErrNotFound) { if err != nil && !errors.Is(err, core.ErrNotFound) {
return return
} }
var token *types.Token var token *auth_types.Token
if len(tokens) > 0 { if len(tokens) > 0 {
token = tokens[0] token = tokens[0]
@@ -197,11 +197,11 @@ func (service AuthImpl) SendVerificationMail(ctx context.Context, userId uuid.UU
return return
} }
token = types.NewToken( token = auth_types.NewToken(
userId, userId,
"", "",
newTokenStr, newTokenStr,
types.TokenTypeEmailVerify, auth_types.TokenTypeEmailVerify,
service.clock.Now(), service.clock.Now(),
service.clock.Now().Add(24*time.Hour)) service.clock.Now().Add(24*time.Hour))
@@ -221,29 +221,29 @@ func (service AuthImpl) SendVerificationMail(ctx context.Context, userId uuid.UU
service.mail.SendMail(ctx, email, "Welcome to spend-sparrow", w.String()) service.mail.SendMail(ctx, email, "Welcome to spend-sparrow", w.String())
} }
func (service AuthImpl) VerifyUserEmail(ctx context.Context, tokenStr string) error { func (service ServiceImpl) VerifyUserEmail(ctx context.Context, tokenStr string) error {
if tokenStr == "" { if tokenStr == "" {
return types.ErrInternal return core.ErrInternal
} }
token, err := service.db.GetToken(ctx, tokenStr) token, err := service.db.GetToken(ctx, tokenStr)
if err != nil { if err != nil {
return types.ErrInternal return core.ErrInternal
} }
user, err := service.db.GetUser(ctx, token.UserId) user, err := service.db.GetUser(ctx, token.UserId)
if err != nil { if err != nil {
return types.ErrInternal return core.ErrInternal
} }
if token.Type != types.TokenTypeEmailVerify { if token.Type != auth_types.TokenTypeEmailVerify {
return types.ErrInternal return core.ErrInternal
} }
now := service.clock.Now() now := service.clock.Now()
if token.ExpiresAt.Before(now) { if token.ExpiresAt.Before(now) {
return types.ErrInternal return core.ErrInternal
} }
user.EmailVerified = true user.EmailVerified = true
@@ -251,21 +251,21 @@ func (service AuthImpl) VerifyUserEmail(ctx context.Context, tokenStr string) er
err = service.db.UpdateUser(ctx, user) err = service.db.UpdateUser(ctx, user)
if err != nil { if err != nil {
return types.ErrInternal return core.ErrInternal
} }
_ = service.db.DeleteToken(ctx, token.Token) _ = service.db.DeleteToken(ctx, token.Token)
return nil return nil
} }
func (service AuthImpl) SignOut(ctx context.Context, sessionId string) error { func (service ServiceImpl) SignOut(ctx context.Context, sessionId string) error {
return service.db.DeleteSession(ctx, sessionId) return service.db.DeleteSession(ctx, sessionId)
} }
func (service AuthImpl) DeleteAccount(ctx context.Context, user *types.User, currPass string) error { func (service ServiceImpl) DeleteAccount(ctx context.Context, user *auth_types.User, currPass string) error {
userDb, err := service.db.GetUser(ctx, user.Id) userDb, err := service.db.GetUser(ctx, user.Id)
if err != nil { if err != nil {
return types.ErrInternal return core.ErrInternal
} }
currHash := GetHashPassword(currPass, userDb.Salt) currHash := GetHashPassword(currPass, userDb.Salt)
@@ -283,7 +283,7 @@ func (service AuthImpl) DeleteAccount(ctx context.Context, user *types.User, cur
return nil return nil
} }
func (service AuthImpl) ChangePassword(ctx context.Context, user *types.User, sessionId string, currPass, newPass string) error { func (service ServiceImpl) ChangePassword(ctx context.Context, user *auth_types.User, sessionId string, currPass, newPass string) error {
if !isPasswordValid(newPass) { if !isPasswordValid(newPass) {
return ErrInvalidPassword return ErrInvalidPassword
} }
@@ -308,13 +308,13 @@ func (service AuthImpl) ChangePassword(ctx context.Context, user *types.User, se
sessions, err := service.db.GetSessions(ctx, user.Id) sessions, err := service.db.GetSessions(ctx, user.Id)
if err != nil { if err != nil {
return types.ErrInternal return core.ErrInternal
} }
for _, s := range sessions { for _, s := range sessions {
if s.Id != sessionId { if s.Id != sessionId {
err = service.db.DeleteSession(ctx, s.Id) err = service.db.DeleteSession(ctx, s.Id)
if err != nil { if err != nil {
return types.ErrInternal return core.ErrInternal
} }
} }
} }
@@ -322,7 +322,7 @@ func (service AuthImpl) ChangePassword(ctx context.Context, user *types.User, se
return nil return nil
} }
func (service AuthImpl) SendForgotPasswordMail(ctx context.Context, email string) error { func (service ServiceImpl) SendForgotPasswordMail(ctx context.Context, email string) error {
tokenStr, err := service.random.String(ctx, 32) tokenStr, err := service.random.String(ctx, 32)
if err != nil { if err != nil {
return err return err
@@ -330,38 +330,38 @@ func (service AuthImpl) SendForgotPasswordMail(ctx context.Context, email string
user, err := service.db.GetUserByEmail(ctx, email) user, err := service.db.GetUserByEmail(ctx, email)
if err != nil { if err != nil {
if errors.Is(err, db.ErrNotFound) { if errors.Is(err, core.ErrNotFound) {
return nil return nil
} else { } else {
return types.ErrInternal return core.ErrInternal
} }
} }
token := types.NewToken( token := auth_types.NewToken(
user.Id, user.Id,
"", "",
tokenStr, tokenStr,
types.TokenTypePasswordReset, auth_types.TokenTypePasswordReset,
service.clock.Now(), service.clock.Now(),
service.clock.Now().Add(15*time.Minute)) service.clock.Now().Add(15*time.Minute))
err = service.db.InsertToken(ctx, token) err = service.db.InsertToken(ctx, token)
if err != nil { if err != nil {
return types.ErrInternal return core.ErrInternal
} }
var mail strings.Builder var mail strings.Builder
err = mailTemplate.ResetPassword(service.serverSettings.BaseUrl, token.Token).Render(context.Background(), &mail) err = mailTemplate.ResetPassword(service.serverSettings.BaseUrl, token.Token).Render(context.Background(), &mail)
if err != nil { if err != nil {
slog.ErrorContext(ctx, "Could not render reset password email", "err", err) slog.ErrorContext(ctx, "Could not render reset password email", "err", err)
return types.ErrInternal return core.ErrInternal
} }
service.mail.SendMail(ctx, email, "Reset Password", mail.String()) service.mail.SendMail(ctx, email, "Reset Password", mail.String())
return nil return nil
} }
func (service AuthImpl) ForgotPassword(ctx context.Context, tokenStr string, newPass string) error { func (service ServiceImpl) ForgotPassword(ctx context.Context, tokenStr string, newPass string) error {
if !isPasswordValid(newPass) { if !isPasswordValid(newPass) {
return ErrInvalidPassword return ErrInvalidPassword
} }
@@ -376,7 +376,7 @@ func (service AuthImpl) ForgotPassword(ctx context.Context, tokenStr string, new
return err return err
} }
if token.Type != types.TokenTypePasswordReset || if token.Type != auth_types.TokenTypePasswordReset ||
token.ExpiresAt.Before(service.clock.Now()) { token.ExpiresAt.Before(service.clock.Now()) {
return ErrTokenInvalid return ErrTokenInvalid
} }
@@ -384,7 +384,7 @@ func (service AuthImpl) ForgotPassword(ctx context.Context, tokenStr string, new
user, err := service.db.GetUser(ctx, token.UserId) user, err := service.db.GetUser(ctx, token.UserId)
if err != nil { if err != nil {
slog.ErrorContext(ctx, "Could not get user from token", "err", err) slog.ErrorContext(ctx, "Could not get user from token", "err", err)
return types.ErrInternal return core.ErrInternal
} }
passHash := GetHashPassword(newPass, user.Salt) passHash := GetHashPassword(newPass, user.Salt)
@@ -397,26 +397,26 @@ func (service AuthImpl) ForgotPassword(ctx context.Context, tokenStr string, new
sessions, err := service.db.GetSessions(ctx, user.Id) sessions, err := service.db.GetSessions(ctx, user.Id)
if err != nil { if err != nil {
return types.ErrInternal return core.ErrInternal
} }
for _, session := range sessions { for _, session := range sessions {
err = service.db.DeleteSession(ctx, session.Id) err = service.db.DeleteSession(ctx, session.Id)
if err != nil { if err != nil {
return types.ErrInternal return core.ErrInternal
} }
} }
return nil return nil
} }
func (service AuthImpl) IsCsrfTokenValid(ctx context.Context, tokenStr string, sessionId string) bool { func (service ServiceImpl) IsCsrfTokenValid(ctx context.Context, tokenStr string, sessionId string) bool {
token, err := service.db.GetToken(ctx, tokenStr) token, err := service.db.GetToken(ctx, tokenStr)
if err != nil { if err != nil {
return false return false
} }
if token.Type != types.TokenTypeCsrf || if token.Type != auth_types.TokenTypeCsrf ||
token.SessionId != sessionId || token.SessionId != sessionId ||
token.ExpiresAt.Before(service.clock.Now()) { token.ExpiresAt.Before(service.clock.Now()) {
return false return false
@@ -425,12 +425,12 @@ func (service AuthImpl) IsCsrfTokenValid(ctx context.Context, tokenStr string, s
return true return true
} }
func (service AuthImpl) GetCsrfToken(ctx context.Context, session *types.Session) (string, error) { func (service ServiceImpl) GetCsrfToken(ctx context.Context, session *auth_types.Session) (string, error) {
if session == nil { if session == nil {
return "", types.ErrInternal return "", core.ErrInternal
} }
tokens, _ := service.db.GetTokensBySessionIdAndType(ctx, session.Id, types.TokenTypeCsrf) tokens, _ := service.db.GetTokensBySessionIdAndType(ctx, session.Id, auth_types.TokenTypeCsrf)
if len(tokens) > 0 { if len(tokens) > 0 {
return tokens[0].Token, nil return tokens[0].Token, nil
@@ -438,19 +438,19 @@ func (service AuthImpl) GetCsrfToken(ctx context.Context, session *types.Session
tokenStr, err := service.random.String(ctx, 32) tokenStr, err := service.random.String(ctx, 32)
if err != nil { if err != nil {
return "", types.ErrInternal return "", core.ErrInternal
} }
token := types.NewToken( token := auth_types.NewToken(
session.UserId, session.UserId,
session.Id, session.Id,
tokenStr, tokenStr,
types.TokenTypeCsrf, auth_types.TokenTypeCsrf,
service.clock.Now(), service.clock.Now(),
service.clock.Now().Add(8*time.Hour)) service.clock.Now().Add(8*time.Hour))
err = service.db.InsertToken(ctx, token) err = service.db.InsertToken(ctx, token)
if err != nil { if err != nil {
return "", types.ErrInternal return "", core.ErrInternal
} }
slog.InfoContext(ctx, "CSRF-Token created", "token", tokenStr) slog.InfoContext(ctx, "CSRF-Token created", "token", tokenStr)
@@ -458,34 +458,34 @@ func (service AuthImpl) GetCsrfToken(ctx context.Context, session *types.Session
return tokenStr, nil return tokenStr, nil
} }
func (service AuthImpl) CleanupSessionsAndTokens(ctx context.Context) error { func (service ServiceImpl) CleanupSessionsAndTokens(ctx context.Context) error {
err := service.db.DeleteOldSessions(ctx) err := service.db.DeleteOldSessions(ctx)
if err != nil { if err != nil {
return types.ErrInternal return core.ErrInternal
} }
err = service.db.DeleteOldTokens(ctx) err = service.db.DeleteOldTokens(ctx)
if err != nil { if err != nil {
return types.ErrInternal return core.ErrInternal
} }
return nil return nil
} }
func (service AuthImpl) createSession(ctx context.Context, userId uuid.UUID) (*types.Session, error) { func (service ServiceImpl) createSession(ctx context.Context, userId uuid.UUID) (*auth_types.Session, error) {
sessionId, err := service.random.String(ctx, 32) sessionId, err := service.random.String(ctx, 32)
if err != nil { if err != nil {
return nil, types.ErrInternal return nil, core.ErrInternal
} }
createAt := service.clock.Now() createAt := service.clock.Now()
expiresAt := createAt.Add(24 * time.Hour) expiresAt := createAt.Add(24 * time.Hour)
session := types.NewSession(sessionId, userId, createAt, expiresAt) session := auth_types.NewSession(sessionId, userId, createAt, expiresAt)
err = service.db.InsertSession(ctx, session) err = service.db.InsertSession(ctx, session)
if err != nil { if err != nil {
return nil, types.ErrInternal return nil, core.ErrInternal
} }
return session, nil return session, nil

View File

@@ -1,4 +1,4 @@
package auth package template
templ ChangePasswordComp(isPasswordReset bool) { templ ChangePasswordComp(isPasswordReset bool) {
<form <form

View File

@@ -0,0 +1 @@
package template

View File

@@ -1,4 +1,4 @@
package auth package template
templ DeleteAccountComp() { templ DeleteAccountComp() {
<form <form

View File

@@ -1,4 +1,4 @@
package auth package template
templ ResetPasswordComp() { templ ResetPasswordComp() {
<form <form

View File

@@ -1,13 +1,13 @@
package auth package template
templ SignInOrUpComp(isSignIn bool) { templ SignInOrUpComp(isSignIn bool) {
{{ {{
var postUrl string var postUrl string
if isSignIn { if isSignIn {
postUrl = "/api/auth/signin" postUrl = "/api/auth/signin"
} else { } else {
postUrl = "/api/auth/signup" postUrl = "/api/auth/signup"
} }
}} }}
<form <form
class="max-w-xl px-2 mx-auto flex flex-col gap-4 h-full justify-center" class="max-w-xl px-2 mx-auto flex flex-col gap-4 h-full justify-center"

View File

@@ -1,4 +1,4 @@
package auth package template
templ VerifyComp() { templ VerifyComp() {
<main class="h-full"> <main class="h-full">

View File

@@ -1,4 +1,4 @@
package auth package template
templ VerifyResponseComp(isVerified bool) { templ VerifyResponseComp(isVerified bool) {
<main> <main>

97
internal/budget/db.go Normal file
View File

@@ -0,0 +1,97 @@
package budget
import (
"context"
"log/slog"
"spend-sparrow/internal/core"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
type Db interface {
Insert(ctx context.Context, budget Budget) (*Budget, error)
Update(ctx context.Context, budget Budget) (*Budget, error)
Delete(ctx context.Context, userId uuid.UUID, budgetId uuid.UUID) error
Get(ctx context.Context, userId uuid.UUID, budgetId uuid.UUID) (*Budget, error)
GetAll(ctx context.Context, userId uuid.UUID) ([]Budget, error)
}
type DbSqlite struct {
db *sqlx.DB
}
func NewDbSqlite(db *sqlx.DB) *DbSqlite {
return &DbSqlite{db: db}
}
func (db DbSqlite) Insert(ctx context.Context, budget Budget) (*Budget, error) {
r, err := db.db.ExecContext(ctx, `
INSERT INTO budget (id, user_id, name, value, created_at, created_by, updated_at, updated_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
budget.Id, budget.UserId, budget.Name, budget.Value, budget.CreatedAt, budget.CreatedBy, budget.UpdatedAt, budget.UpdatedBy,
)
err = core.TransformAndLogDbError(ctx, "budget", r, err)
if err != nil {
return nil, core.ErrInternal
}
return db.Get(ctx, budget.UserId, budget.Id)
}
func (db DbSqlite) Update(ctx context.Context, budget Budget) (*Budget, error) {
_, err := db.db.ExecContext(ctx, `
UPDATE budget
SET name = ?,
value = ?,
updated_at = ?,
updated_by = ?
WHERE user_id = ?
AND id = ?`,
budget.Name, budget.Value, budget.UpdatedAt, budget.UpdatedBy, budget.UserId, budget.Id)
if err != nil {
slog.ErrorContext(ctx, "SQL error UpdateUser", "err", err)
return nil, core.ErrInternal
}
return db.Get(ctx, budget.UserId, budget.Id)
}
func (db DbSqlite) Delete(ctx context.Context, userId uuid.UUID, budgetId uuid.UUID) error {
r, err := db.db.ExecContext(
ctx,
"DELETE FROM budget WHERE user_id = ? AND id = ?",
userId,
budgetId)
err = core.TransformAndLogDbError(ctx, "budget", r, err)
if err != nil {
return err
}
return nil
}
func (db DbSqlite) Get(ctx context.Context, userId uuid.UUID, budgetId uuid.UUID) (*Budget, error) {
var budget Budget
err := db.db.Get(&budget, "SELECT * FROM budget WHERE id = ? AND user_id = ?", budgetId, userId)
if err != nil {
slog.ErrorContext(ctx, "Could not get budget", "err", err)
return nil, core.ErrInternal
}
return &budget, nil
}
func (db DbSqlite) GetAll(ctx context.Context, userId uuid.UUID) ([]Budget, error) {
var budgets []Budget
err := db.db.Select(&budgets, "SELECT * FROM budget WHERE user_id = ?", userId)
if err != nil {
slog.ErrorContext(ctx, "Could not GetAll budget", "err", err)
return nil, core.ErrInternal
}
return budgets, nil
}

184
internal/budget/handler.go Normal file
View File

@@ -0,0 +1,184 @@
package budget
import (
"fmt"
"math"
"net/http"
"spend-sparrow/internal/core"
"strconv"
"github.com/google/uuid"
)
const (
DECIMALS_MULTIPLIER = 100
)
type Handler interface {
Handle(router *http.ServeMux)
}
type HandlerImpl struct {
s Service
r *core.Render
}
func NewHandler(s Service, r *core.Render) Handler {
return HandlerImpl{
s: s,
r: r,
}
}
func (h HandlerImpl) Handle(r *http.ServeMux) {
r.Handle("GET /budget", h.handlePage())
r.Handle("GET /budget/new", h.handleNew())
r.Handle("GET /budget/{id}", h.handleEdit())
r.Handle("POST /budget/{id}", h.handlePost())
r.Handle("DELETE /budget/{id}", h.handleDelete())
}
func (h HandlerImpl) handlePage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
core.UpdateSpan(r)
user := core.GetUser(r)
if user == nil {
core.DoRedirect(w, r, "/auth/signin")
return
}
budgets, err := h.s.GetAll(r.Context(), user)
if err != nil {
h.r.RenderLayout(r, w, core.ErrorComp(err), user)
return
}
comp := page(budgets)
h.r.RenderLayout(r, w, comp, user)
}
}
func (h HandlerImpl) handleNew() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
core.UpdateSpan(r)
user := core.GetUser(r)
if user == nil {
core.DoRedirect(w, r, "/auth/signin")
return
}
comp := editNew()
h.r.RenderLayout(r, w, comp, user)
}
}
func (h HandlerImpl) handleEdit() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
core.UpdateSpan(r)
user := core.GetUser(r)
if user == nil {
core.DoRedirect(w, r, "/auth/signin")
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
core.HandleError(w, r, fmt.Errorf("could not parse Id: %w", core.ErrBadRequest))
return
}
budget, err := h.s.Get(r.Context(), user, id)
if err != nil {
core.HandleError(w, r, fmt.Errorf("could not parse Id: %w", core.ErrBadRequest))
return
}
comp := edit(*budget)
h.r.RenderLayout(r, w, comp, user)
}
}
func (h HandlerImpl) handlePost() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
core.UpdateSpan(r)
user := core.GetUser(r)
if user == nil {
core.DoRedirect(w, r, "/auth/signin")
return
}
var (
id uuid.UUID
err error
)
idStr := r.PathValue("id")
if idStr != "new" {
id, err = uuid.Parse(idStr)
if err != nil {
core.HandleError(w, r, fmt.Errorf("could not parse Id: %w", core.ErrBadRequest))
return
}
}
valueF, err := strconv.ParseFloat(r.FormValue("value"), 64)
if err != nil {
core.HandleError(w, r, fmt.Errorf("could not parse value: %w", core.ErrBadRequest))
return
}
value := int64(math.Round(valueF * DECIMALS_MULTIPLIER))
input := Budget{
Id: id,
Name: r.FormValue("name"),
Value: value,
}
if idStr == "new" {
_, err = h.s.Add(r.Context(), user, input)
if err != nil {
core.HandleError(w, r, err)
return
}
} else {
_, err = h.s.Update(r.Context(), user, input)
if err != nil {
core.HandleError(w, r, err)
return
}
}
core.DoRedirect(w, r, "/budget")
}
}
func (h HandlerImpl) handleDelete() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
core.UpdateSpan(r)
user := core.GetUser(r)
if user == nil {
core.DoRedirect(w, r, "/auth/signin")
return
}
idStr := r.PathValue("id")
id, err := uuid.Parse(idStr)
if err != nil {
core.HandleError(w, r, fmt.Errorf("could not parse Id: %w", core.ErrBadRequest))
return
}
err = h.s.Delete(r.Context(), user, id)
if err != nil {
core.HandleError(w, r, err)
return
}
core.DoRedirect(w, r, "/budget")
}
}

119
internal/budget/service.go Normal file
View File

@@ -0,0 +1,119 @@
package budget
import (
"context"
"spend-sparrow/internal/auth_types"
"spend-sparrow/internal/core"
"github.com/google/uuid"
)
type Service interface {
Add(ctx context.Context, user *auth_types.User, budget Budget) (*Budget, error)
Update(ctx context.Context, user *auth_types.User, budget Budget) (*Budget, error)
Delete(ctx context.Context, user *auth_types.User, budgetId uuid.UUID) error
Get(ctx context.Context, user *auth_types.User, budgetId uuid.UUID) (*Budget, error)
GetAll(ctx context.Context, user *auth_types.User) ([]Budget, error)
}
type ServiceImpl struct {
db Db
clock core.Clock
random core.Random
}
func NewService(db Db, random core.Random, clock core.Clock) Service {
return ServiceImpl{
db: db,
clock: clock,
random: random,
}
}
func (s ServiceImpl) Add(ctx context.Context, user *auth_types.User, budget Budget) (*Budget, error) {
if user == nil {
return nil, core.ErrUnauthorized
}
isValid := s.isBudgetValid(budget)
if !isValid {
return nil, core.ErrBadRequest
}
newId, err := s.random.UUID(ctx)
if err != nil {
return nil, core.ErrInternal
}
budget.Id = newId
budget.UserId = user.Id
budget.CreatedBy = user.Id
budget.CreatedAt = s.clock.Now()
return s.db.Insert(ctx, budget)
}
func (s ServiceImpl) Update(ctx context.Context, user *auth_types.User, input Budget) (*Budget, error) {
if user == nil {
return nil, core.ErrUnauthorized
}
budget, err := s.Get(ctx, user, input.Id)
if err != nil {
return nil, err
}
budget.Name = input.Name
budget.Value = input.Value
if user.Id != budget.UserId {
return nil, core.ErrBadRequest
}
isValid := s.isBudgetValid(*budget)
if !isValid {
return nil, core.ErrBadRequest
}
budget.UpdatedBy = &user.Id
now := s.clock.Now()
budget.UpdatedAt = &now
return s.db.Update(ctx, *budget)
}
func (s ServiceImpl) Delete(ctx context.Context, user *auth_types.User, budgetId uuid.UUID) error {
if user == nil {
return core.ErrUnauthorized
}
return s.db.Delete(ctx, user.Id, budgetId)
}
func (s ServiceImpl) Get(ctx context.Context, user *auth_types.User, budgetId uuid.UUID) (*Budget, error) {
if user == nil {
return nil, core.ErrUnauthorized
}
return s.db.Get(ctx, user.Id, budgetId)
}
func (s ServiceImpl) GetAll(ctx context.Context, user *auth_types.User) ([]Budget, error) {
if user == nil {
return nil, core.ErrUnauthorized
}
return s.db.GetAll(ctx, user.Id)
}
func (s ServiceImpl) isBudgetValid(budget Budget) bool {
err := core.ValidateString(budget.Name, "name")
if err != nil {
return false
}
if budget.Value < 0 {
return false
}
return true
}

View File

@@ -0,0 +1,138 @@
package budget
import (
"spend-sparrow/internal/core"
"spend-sparrow/internal/tag"
"spend-sparrow/internal/template/svg"
)
templ page(budgets []Budget) {
@core.Breadcrumb([]string{"Home", "Budget"}, []string{"/", "/budget"})
<div class="flex flex-wrap gap-20 text-xl mt-10 justify-center">
@newItem()
for _,budget:=range(budgets ) {
@item(budget)
}
</div>
}
templ editNew() {
<div class="flex flex-col h-full">
@core.Breadcrumb([]string{"Home", "Budget", "New"}, []string{"/", "/budget", "/budget/new"})
<div class="flex justify-center items-center flex-1">
<form
hx-post={ "/budget/new" }
class="grid grid-cols-4 items-center gap-4 max-w-2xl"
>
<label for="timestamp" class="text-sm text-gray-500">Name</label>
<input
autofocus
name="name"
type="text"
class="bg-white input datetime col-span-3"
/>
<label for="value" class="text-sm text-gray-500">Value</label>
<input
name="value"
type="number"
class="bg-white input col-span-3"
/>
<div class="flex gap-6 justify-end col-span-4">
<a href="/budget" class="col-start-3 p-2 px-4 decoration-yellow-400 decoration-[0.25rem] hover:bg-gray-200 rounded-lg hover:underline flex items-center gap-2 justify-center">
<span class="h-4 w-4">
@svg.Cancel()
</span>
<span>
Cancel
</span>
</a>
<button type="submit" class="col-start-4 p-2 px-4 decoration-yellow-400 decoration-[0.25rem] hover:bg-gray-200 rounded-lg hover:underline flex items-center gap-2 justify-center">
@svg.Save()
<span>
Save
</span>
</button>
</div>
</form>
</div>
</div>
}
templ edit(budget Budget) {
<div class="flex flex-col h-full">
@core.Breadcrumb([]string{"Home", "Budget", budget.Name}, []string{"/", "/budget", "/budget/" + budget.Id.String()})
<div class="flex justify-center items-center flex-1">
<form
hx-post={ "/budget/" + budget.Id.String() }
class="grid grid-cols-4 items-center gap-4 max-w-2xl"
>
<label for="timestamp" class="text-sm text-gray-500">Name</label>
<input
autofocus
name="name"
type="text"
value={ budget.Name }
class="bg-white input datetime col-span-3"
/>
<label for="value" class="text-sm text-gray-500">Value</label>
<input
name="value"
type="number"
value={ budget.Value / 100 }
class="bg-white input col-span-3"
/>
<label for="tag" class="text-sm text-gray-500">Tags</label>
@tag.InlineEditInput("col-span-3")
<div class="flex flex-row-reverse gap-6 justify-end col-span-4">
<button type="submit" class="col-start-4 p-2 px-4 decoration-yellow-400 decoration-[0.25rem] hover:bg-gray-200 rounded-lg hover:underline flex items-center gap-2 justify-center">
@svg.Save()
<span>
Save
</span>
</button>
<a href="/budget" class="col-start-3 p-2 px-4 decoration-yellow-400 decoration-[0.25rem] hover:bg-gray-200 rounded-lg hover:underline flex items-center gap-2 justify-center">
<span class="h-4 w-4">
@svg.Cancel()
</span>
<span>
Cancel
</span>
</a>
<button
hx-delete={ "/budget/" + budget.Id.String() }
hx-confirm={ "Do you really want to delete '" + budget.Name + "'" }
class="col-start-4 p-2 px-4 decoration-yellow-400 decoration-[0.25rem] hover:bg-red-50 rounded-lg hover:underline flex items-center gap-2 justify-center"
>
@svg.Delete()
<span>
Delete
</span>
</button>
</div>
</form>
</div>
</div>
}
templ newItem() {
<a
href="/budget/new"
class="p-5 w-64 h-64 flex gap-10 active:bg-gray-200 flex-col justify-center items-center hover:bg-gray-100 transition-all cursor-pointer rounded-lg bg-gray-50 border-1 border-gray-300"
>
New Budget
<div class="w-10">
@svg.Plus()
</div>
</a>
}
templ item(budget Budget) {
<a href={ "/budget/" + budget.Id.String() } class="p-5 w-64 h-64 flex gap-10 active:bg-gray-200 flex-col justify-center items-center hover:bg-gray-100 transition-all cursor-pointer rounded-lg bg-gray-50 border-1 border-gray-300">
<span>
{ budget.Name }
</span>
<span>
{ core.FormatEuros(budget.Value) }
</span>
</a>
}

20
internal/budget/types.go Normal file
View File

@@ -0,0 +1,20 @@
package budget
import (
"time"
"github.com/google/uuid"
)
type Budget struct {
Id uuid.UUID `db:"id"`
UserId uuid.UUID `db:"user_id"`
Name string `db:"name"`
Value int64 `db:"value"`
CreatedAt time.Time `db:"created_at"`
CreatedBy uuid.UUID `db:"created_by"`
UpdatedAt *time.Time `db:"updated_at"`
UpdatedBy *uuid.UUID `db:"updated_by"`
}

51
internal/core/auth.go Normal file
View File

@@ -0,0 +1,51 @@
package core
import (
"net/http"
"spend-sparrow/internal/auth_types"
)
type ContextKey string
var SessionKey ContextKey = "session"
var UserKey ContextKey = "user"
func GetUser(r *http.Request) *auth_types.User {
obj := r.Context().Value(UserKey)
if obj == nil {
return nil
}
user, ok := obj.(*auth_types.User)
if !ok {
return nil
}
return user
}
func GetSession(r *http.Request) *auth_types.Session {
obj := r.Context().Value(SessionKey)
if obj == nil {
return nil
}
session, ok := obj.(*auth_types.Session)
if !ok {
return nil
}
return session
}
func CreateSessionCookie(sessionId string) http.Cookie {
return http.Cookie{
Name: "id",
Value: sessionId,
MaxAge: 60 * 60 * 8, // 8 hours
Secure: true,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Path: "/",
}
}

View File

@@ -0,0 +1,13 @@
package core
templ Breadcrumb(elements []string, links []string) {
<div class="flex gap-5 mb-10 text-lg items-center">
for i, element := range(elements) {
if (i>0) {
<span class="text-gray-500">&gt;</span><a class="p-2 decoration-yellow-400 decoration-[0.25rem] hover:bg-gray-200 rounded-lg hover:underline" href={ links[i] }>{ element }</a>
} else {
<a class="p-2 decoration-yellow-400 decoration-[0.25rem] hover:bg-gray-200 rounded-lg hover:underline" href={ links[i] }>{ element }</a>
}
}
</div>
}

View File

@@ -1,4 +1,4 @@
package service package core
import "time" import "time"

70
internal/core/error.go Normal file
View File

@@ -0,0 +1,70 @@
package core
import (
"context"
"database/sql"
"errors"
"log/slog"
"net/http"
"strings"
)
var (
ErrNotFound = errors.New("the value does not exist")
ErrAlreadyExists = errors.New("row already exists")
ErrInternal = errors.New("internal server error")
ErrUnauthorized = errors.New("you are not authorized to perform this action")
ErrBadRequest = errors.New("bad request")
)
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.ErrorContext(ctx, "database sql", "module", module, "err", err)
return ErrInternal
}
if r != nil {
rows, err := r.RowsAffected()
if err != nil {
slog.ErrorContext(ctx, "database rows affected", "module", module, "err", err)
return ErrInternal
}
if rows == 0 {
slog.InfoContext(ctx, "row not found", "module", module)
return ErrNotFound
}
}
return nil
}
func HandleError(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, ErrUnauthorized):
TriggerToastWithStatus(r.Context(), w, r, "error", "You are not autorized to perform this operation.", http.StatusUnauthorized)
return
case errors.Is(err, ErrBadRequest):
TriggerToastWithStatus(r.Context(), w, r, "error", extractErrorMessage(err), http.StatusBadRequest)
return
case errors.Is(err, ErrNotFound):
TriggerToastWithStatus(r.Context(), w, r, "error", extractErrorMessage(err), http.StatusNotFound)
return
}
TriggerToastWithStatus(r.Context(), w, r, "error", "Internal Server Error", http.StatusInternalServerError)
}
func extractErrorMessage(err error) string {
errMsg := err.Error()
if errMsg == "" {
return ""
}
return strings.SplitN(errMsg, ":", 2)[0]
}

View File

@@ -1,4 +1,4 @@
package types package core
import ( import (
"fmt" "fmt"

View File

@@ -1,4 +1,4 @@
package utils package core
import ( import (
"context" "context"

View File

@@ -1,6 +1,9 @@
package template package core
import "spend-sparrow/internal/template/svg" import (
"spend-sparrow/internal/template/svg"
"strings"
)
func layoutLinkClass(isActive bool) string { 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" common := "text-2xl p-2 text-gray-900 decoration-yellow-400 decoration-[0.25rem] hover:bg-gray-200 rounded-lg"
@@ -87,9 +90,17 @@ templ Layout(slot templ.Component, user templ.Component, loggedIn bool, path str
templ navigation(path string) { templ navigation(path string) {
<nav class="w-64 text-nowrap flex gap-2 flex-col text-lg mt-5 px-5 pt-2"> <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(strings.HasPrefix(path, "/dashboard")) } href="/dashboard">Dashboard</a>
<a class={ layoutLinkClass(path == "/transaction") } href="/transaction">Transaction</a> <a class={ layoutLinkClass(strings.HasPrefix(path, "/transaction")) } href="/transaction">Transaction</a>
<a class={ layoutLinkClass(path == "/treasurechest") } href="/treasurechest">Treasure Chest</a> <a class={ layoutLinkClass(strings.HasPrefix(path, "/treasurechest")) } href="/treasurechest">Treasure Chest</a>
<a class={ layoutLinkClass(path == "/account") } href="/account">Account</a> <a class={ layoutLinkClass(strings.HasPrefix(path, "/account")) } href="/account">Account</a>
<a class={ layoutLinkClass(strings.HasPrefix(path, "/budget")) } href="/budget">Budget</a>
<a class={ layoutLinkClass(strings.HasPrefix(path, "/tag")) } href="/tag">Tag</a>
</nav> </nav>
} }
templ ErrorComp(err error) {
<div>
The following error occured: { err.Error() }
</div>
}

View File

@@ -1,4 +1,4 @@
package log package core
import ( import (
"context" "context"

View File

@@ -1,11 +1,10 @@
package service package core
import ( import (
"context" "context"
"fmt" "fmt"
"log/slog" "log/slog"
"net/smtp" "net/smtp"
"spend-sparrow/internal/types"
) )
type Mail interface { type Mail interface {
@@ -14,10 +13,10 @@ type Mail interface {
} }
type MailImpl struct { type MailImpl struct {
server *types.Settings server *Settings
} }
func NewMail(server *types.Settings) MailImpl { func NewMail(server *Settings) MailImpl {
return MailImpl{server: server} return MailImpl{server: server}
} }

View File

@@ -1,10 +1,9 @@
package db package core
import ( import (
"context" "context"
"errors" "errors"
"log/slog" "log/slog"
"spend-sparrow/internal/types"
"github.com/golang-migrate/migrate/v4" "github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/sqlite3" "github.com/golang-migrate/migrate/v4/database/sqlite3"
@@ -25,7 +24,7 @@ func RunMigrations(ctx context.Context, db *sqlx.DB, pathPrefix string) error {
driver, err := sqlite3.WithInstance(db.DB, &sqlite3.Config{}) driver, err := sqlite3.WithInstance(db.DB, &sqlite3.Config{})
if err != nil { if err != nil {
slog.ErrorContext(ctx, "Could not create Migration instance", "err", err) slog.ErrorContext(ctx, "Could not create Migration instance", "err", err)
return types.ErrInternal return ErrInternal
} }
m, err := migrate.NewWithDatabaseInstance( m, err := migrate.NewWithDatabaseInstance(
@@ -34,14 +33,14 @@ func RunMigrations(ctx context.Context, db *sqlx.DB, pathPrefix string) error {
driver) driver)
if err != nil { if err != nil {
slog.ErrorContext(ctx, "Could not create migrations instance", "err", err) slog.ErrorContext(ctx, "Could not create migrations instance", "err", err)
return types.ErrInternal return ErrInternal
} }
m.Log = migrationLogger{} m.Log = migrationLogger{}
if err = m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) { if err = m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
slog.ErrorContext(ctx, "Could not run migrations", "err", err) slog.ErrorContext(ctx, "Could not run migrations", "err", err)
return types.ErrInternal return ErrInternal
} }
return nil return nil

View File

@@ -0,0 +1,16 @@
package core
import (
"net/http"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
)
func UpdateSpan(r *http.Request) {
currentSpan := trace.SpanFromContext(r.Context())
if currentSpan != nil {
currentSpan.SetAttributes(attribute.String("http.pattern", r.Pattern))
currentSpan.SetAttributes(attribute.String("http.pattern.id", r.PathValue("id")))
}
}

View File

@@ -1,11 +1,10 @@
package service package core
import ( import (
"context" "context"
"crypto/rand" "crypto/rand"
"encoding/base64" "encoding/base64"
"log/slog" "log/slog"
"spend-sparrow/internal/types"
"github.com/google/uuid" "github.com/google/uuid"
) )
@@ -28,7 +27,7 @@ func (r *RandomImpl) Bytes(ctx context.Context, tsize int) ([]byte, error) {
_, err := rand.Read(b) _, err := rand.Read(b)
if err != nil { if err != nil {
slog.ErrorContext(ctx, "Error generating random bytes", "err", err) slog.ErrorContext(ctx, "Error generating random bytes", "err", err)
return []byte{}, types.ErrInternal return []byte{}, ErrInternal
} }
return b, nil return b, nil
@@ -38,7 +37,7 @@ func (r *RandomImpl) String(ctx context.Context, size int) (string, error) {
bytes, err := r.Bytes(ctx, size) bytes, err := r.Bytes(ctx, size)
if err != nil { if err != nil {
slog.ErrorContext(ctx, "Error generating random string", "err", err) slog.ErrorContext(ctx, "Error generating random string", "err", err)
return "", types.ErrInternal return "", ErrInternal
} }
return base64.StdEncoding.EncodeToString(bytes), nil return base64.StdEncoding.EncodeToString(bytes), nil
@@ -48,7 +47,7 @@ func (r *RandomImpl) UUID(ctx context.Context) (uuid.UUID, error) {
id, err := uuid.NewRandom() id, err := uuid.NewRandom()
if err != nil { if err != nil {
slog.ErrorContext(ctx, "Error generating random UUID", "err", err) slog.ErrorContext(ctx, "Error generating random UUID", "err", err)
return uuid.Nil, types.ErrInternal return uuid.Nil, ErrInternal
} }
return id, nil return id, nil

View File

@@ -1,11 +1,9 @@
package handler package core
import ( import (
"log/slog" "log/slog"
"net/http" "net/http"
"spend-sparrow/internal/template" "spend-sparrow/internal/auth_types"
"spend-sparrow/internal/template/auth"
"spend-sparrow/internal/types"
"github.com/a-h/templ" "github.com/a-h/templ"
) )
@@ -31,21 +29,21 @@ func (render *Render) Render(r *http.Request, w http.ResponseWriter, comp templ.
render.RenderWithStatus(r, w, comp, http.StatusOK) render.RenderWithStatus(r, w, comp, http.StatusOK)
} }
func (render *Render) RenderLayout(r *http.Request, w http.ResponseWriter, slot templ.Component, user *types.User) { func (render *Render) RenderLayout(r *http.Request, w http.ResponseWriter, slot templ.Component, user *auth_types.User) {
render.RenderLayoutWithStatus(r, w, slot, user, http.StatusOK) render.RenderLayoutWithStatus(r, w, slot, user, http.StatusOK)
} }
func (render *Render) RenderLayoutWithStatus(r *http.Request, w http.ResponseWriter, slot templ.Component, user *types.User, status int) { func (render *Render) RenderLayoutWithStatus(r *http.Request, w http.ResponseWriter, slot templ.Component, user *auth_types.User, status int) {
userComp := render.getUserComp(user) userComp := render.getUserComp(user)
layout := template.Layout(slot, userComp, user != nil, r.URL.Path) layout := Layout(slot, userComp, user != nil, r.URL.Path)
render.RenderWithStatus(r, w, layout, status) render.RenderWithStatus(r, w, layout, status)
} }
func (render *Render) getUserComp(user *types.User) templ.Component { func (render *Render) getUserComp(user *auth_types.User) templ.Component {
if user != nil { if user != nil {
return auth.UserComp(user.Email) return UserComp(user.Email)
} else { } else {
return auth.UserComp("") return UserComp("")
} }
} }

View File

@@ -1,4 +1,4 @@
package types package core
import ( import (
"context" "context"

View File

@@ -1,4 +1,4 @@
package auth package core
templ UserComp(user string) { templ UserComp(user string) {
<div id="user-info" class="flex items-center gap-2 text-nowrap"> <div id="user-info" class="flex items-center gap-2 text-nowrap">

View File

@@ -1,19 +1,15 @@
package service package core
import ( import (
"fmt" "fmt"
"regexp" "regexp"
) )
const (
DECIMALS_MULTIPLIER = 100
)
var ( var (
safeInputRegex = regexp.MustCompile(`^[a-zA-Z0-9ÄÖÜäöüß,&'". \-\?]+$`) safeInputRegex = regexp.MustCompile(`^[a-zA-Z0-9ÄÖÜäöüß,&'". \-\?\(\)]+$`)
) )
func validateString(value string, fieldName string) error { func ValidateString(value string, fieldName string) error {
switch { switch {
case value == "": case value == "":
return fmt.Errorf("field \"%s\" needs to be set: %w", fieldName, ErrBadRequest) return fmt.Errorf("field \"%s\" needs to be set: %w", fieldName, ErrBadRequest)

View File

@@ -1,87 +1,89 @@
package handler package dashboard
import ( import (
"fmt" "fmt"
"log/slog" "log/slog"
"net/http" "net/http"
"spend-sparrow/internal/handler/middleware" "spend-sparrow/internal/core"
"spend-sparrow/internal/service" "spend-sparrow/internal/treasure_chest"
"spend-sparrow/internal/template/dashboard" "strings"
"spend-sparrow/internal/utils"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
) )
type Dashboard interface { type Handler interface {
Handle(router *http.ServeMux) Handle(router *http.ServeMux)
} }
type DashboardImpl struct { type HandlerImpl struct {
r *Render r *core.Render
d *service.Dashboard s *Service
treasureChest service.TreasureChest treasureChest treasure_chest.Service
} }
func NewDashboard(r *Render, d *service.Dashboard, treasureChest service.TreasureChest) Dashboard { func NewHandler(r *core.Render, s *Service, treasureChest treasure_chest.Service) Handler {
return DashboardImpl{ return HandlerImpl{
r: r, r: r,
d: d, s: s,
treasureChest: treasureChest, treasureChest: treasureChest,
} }
} }
func (handler DashboardImpl) Handle(router *http.ServeMux) { func (handler HandlerImpl) Handle(router *http.ServeMux) {
router.Handle("GET /dashboard", handler.handleDashboard()) router.Handle("GET /dashboard", handler.handleDashboard())
router.Handle("GET /dashboard/main-chart", handler.handleDashboardMainChart()) router.Handle("GET /dashboard/main-chart", handler.handleMainChart())
router.Handle("GET /dashboard/treasure-chests", handler.handleDashboardTreasureChests()) router.Handle("GET /dashboard/treasure-chests", handler.handleTreasureChests())
router.Handle("GET /dashboard/treasure-chest", handler.handleDashboardTreasureChest()) router.Handle("GET /dashboard/treasure-chest", handler.handleTreasureChest())
} }
func (handler DashboardImpl) handleDashboard() http.HandlerFunc { func (handler HandlerImpl) handleDashboard() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r) core.UpdateSpan(r)
user := middleware.GetUser(r) user := core.GetUser(r)
if user == nil { if user == nil {
utils.DoRedirect(w, r, "/auth/signin") core.DoRedirect(w, r, "/auth/signin")
return return
} }
treasureChests, err := handler.treasureChest.GetAll(r.Context(), user) treasureChests, err := handler.treasureChest.GetAll(r.Context(), user)
if err != nil { if err != nil {
handleError(w, r, err) core.HandleError(w, r, err)
return return
} }
comp := dashboard.Dashboard(treasureChests) comp := DashboardComp(treasureChests)
handler.r.RenderLayoutWithStatus(r, w, comp, user, http.StatusOK) handler.r.RenderLayoutWithStatus(r, w, comp, user, http.StatusOK)
} }
} }
func (handler DashboardImpl) handleDashboardMainChart() http.HandlerFunc { func (handler HandlerImpl) handleMainChart() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r) core.UpdateSpan(r)
user := middleware.GetUser(r) user := core.GetUser(r)
series, err := handler.d.MainChart(r.Context(), user) series, err := handler.s.MainChart(r.Context(), user)
if err != nil { if err != nil {
handleError(w, r, err) core.HandleError(w, r, err)
return return
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
account := "" accountBuilder := strings.Builder{}
savings := "" savingsBuilder := strings.Builder{}
for _, entry := range series { for _, entry := range series {
account += fmt.Sprintf(`["%s",%.2f],`, entry.Day.Format(time.RFC3339), float64(entry.Value)/100) fmt.Fprintf(&accountBuilder, `["%s",%.2f],`, entry.Day.Format(time.RFC3339), float64(entry.Value)/100)
savings += fmt.Sprintf(`["%s",%.2f],`, entry.Day.Format(time.RFC3339), float64(entry.Savings)/100) fmt.Fprintf(&savingsBuilder, `["%s",%.2f],`, entry.Day.Format(time.RFC3339), float64(entry.Savings)/100)
} }
account := accountBuilder.String()
savings := savingsBuilder.String()
account = account[:len(account)-1] account = account[:len(account)-1]
savings = savings[:len(savings)-1] savings = savings[:len(savings)-1]
@@ -122,38 +124,39 @@ func (handler DashboardImpl) handleDashboardMainChart() http.HandlerFunc {
} }
} }
func (handler DashboardImpl) handleDashboardTreasureChests() http.HandlerFunc { func (handler HandlerImpl) handleTreasureChests() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r) core.UpdateSpan(r)
user := middleware.GetUser(r) user := core.GetUser(r)
treeList, err := handler.d.TreasureChests(r.Context(), user) treeList, err := handler.s.TreasureChests(r.Context(), user)
if err != nil { if err != nil {
handleError(w, r, err) core.HandleError(w, r, err)
return return
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
data := "" dataBuilder := strings.Builder{}
for _, item := range treeList { for _, item := range treeList {
children := "" childrenBuilder := strings.Builder{}
for _, child := range item.Children { for _, child := range item.Children {
if child.Value < 0 { if child.Value < 0 {
children += fmt.Sprintf(`{"name":"%s\n%.2f €","value":%d},`, child.Name, float64(child.Value)/100, -child.Value) fmt.Fprintf(&childrenBuilder, `{"name":"%s\n%.2f €","value":%d},`, child.Name, float64(child.Value)/100, -child.Value)
} else { } else {
children += fmt.Sprintf(`{"name":"%s\n%.2f €","value":%d},`, child.Name, float64(child.Value)/100, child.Value) fmt.Fprintf(&childrenBuilder, `{"name":"%s\n%.2f €","value":%d},`, child.Name, float64(child.Value)/100, child.Value)
} }
} }
children := childrenBuilder.String()
children = children[:len(children)-1] children = children[:len(children)-1]
data += fmt.Sprintf(`{"name":"%s","children":[%s]},`, item.Name, children) fmt.Fprintf(&dataBuilder, `{"name":"%s","children":[%s]},`, item.Name, children)
} }
data := dataBuilder.String()
data = data[:len(data)-1] data = data[:len(data)-1]
_, err = fmt.Fprintf(w, ` _, err = fmt.Fprintf(w, `
@@ -176,11 +179,11 @@ func (handler DashboardImpl) handleDashboardTreasureChests() http.HandlerFunc {
} }
} }
func (handler DashboardImpl) handleDashboardTreasureChest() http.HandlerFunc { func (handler HandlerImpl) handleTreasureChest() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r) core.UpdateSpan(r)
user := middleware.GetUser(r) user := core.GetUser(r)
var treasureChestId *uuid.UUID var treasureChestId *uuid.UUID
@@ -188,28 +191,30 @@ func (handler DashboardImpl) handleDashboardTreasureChest() http.HandlerFunc {
if treasureChestStr != "" { if treasureChestStr != "" {
id, err := uuid.Parse(treasureChestStr) id, err := uuid.Parse(treasureChestStr)
if err != nil { if err != nil {
handleError(w, r, fmt.Errorf("could not parse treasure chest: %w", service.ErrBadRequest)) core.HandleError(w, r, fmt.Errorf("could not parse treasure chest: %w", core.ErrBadRequest))
return return
} }
treasureChestId = &id treasureChestId = &id
} }
series, err := handler.d.TreasureChest(r.Context(), user, treasureChestId) series, err := handler.s.TreasureChest(r.Context(), user, treasureChestId)
if err != nil { if err != nil {
handleError(w, r, err) core.HandleError(w, r, err)
return return
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
value := "" valueBuilder := strings.Builder{}
for _, entry := range series { for _, entry := range series {
value += fmt.Sprintf(`["%s",%.2f],`, entry.Day.Format(time.RFC3339), float64(entry.Value)/100) fmt.Fprintf(&valueBuilder, `["%s",%.2f],`, entry.Day.Format(time.RFC3339), float64(entry.Value)/100)
} }
value := valueBuilder.String()
if len(value) > 0 { if len(value) > 0 {
value = value[:len(value)-1] value = value[:len(value)-1]
} }

View File

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

View File

@@ -1,8 +1,8 @@
package dashboard package dashboard
import "spend-sparrow/internal/types" import "spend-sparrow/internal/treasure_chest_types"
templ Dashboard(treasureChests []*types.TreasureChest) { templ DashboardComp(treasureChests []*treasure_chest_types.TreasureChest) {
<div class="mt-10 h-full"> <div class="mt-10 h-full">
<div id="main-chart" class="h-96 mt-10"></div> <div id="main-chart" class="h-96 mt-10"></div>
<div id="treasure-chests" class="h-96 mt-10"></div> <div id="treasure-chests" class="h-96 mt-10"></div>

View File

@@ -1,4 +1,4 @@
package types package dashboard
import "time" import "time"

View File

@@ -1,39 +0,0 @@
package db
import (
"context"
"database/sql"
"errors"
"log/slog"
"spend-sparrow/internal/types"
)
var (
ErrNotFound = errors.New("the value does not exist")
ErrAlreadyExists = errors.New("row already exists")
)
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.ErrorContext(ctx, "database sql", "module", module, "err", err)
return types.ErrInternal
}
if r != nil {
rows, err := r.RowsAffected()
if err != nil {
slog.ErrorContext(ctx, "database rows affected", "module", module, "err", err)
return types.ErrInternal
}
if rows == 0 {
slog.InfoContext(ctx, "row not found", "module", module)
return ErrNotFound
}
}
return nil
}

View File

@@ -1,19 +1,23 @@
package internal package internal
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"spend-sparrow/internal/db"
"spend-sparrow/internal/handler"
"spend-sparrow/internal/handler/middleware"
"spend-sparrow/internal/log"
"spend-sparrow/internal/service"
"spend-sparrow/internal/types"
"context"
"net/http" "net/http"
"os/signal" "os/signal"
"spend-sparrow/internal/account"
"spend-sparrow/internal/authentication"
"spend-sparrow/internal/budget"
"spend-sparrow/internal/core"
"spend-sparrow/internal/dashboard"
"spend-sparrow/internal/handler"
"spend-sparrow/internal/handler/middleware"
"spend-sparrow/internal/tag"
"spend-sparrow/internal/transaction"
"spend-sparrow/internal/transaction_recurring"
"spend-sparrow/internal/treasure_chest"
"sync" "sync"
"syscall" "syscall"
"time" "time"
@@ -27,8 +31,8 @@ func Run(ctx context.Context, database *sqlx.DB, migrationsPrefix string, env fu
ctx, cancel := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM) ctx, cancel := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
defer cancel() defer cancel()
otelEnabled := types.IsOtelEnabled(env) isOtelEnabled := core.IsOtelEnabled(env)
if otelEnabled { if isOtelEnabled {
// use context.Background(), otherwise the shutdown can't be called, as the context is already cancelled // use context.Background(), otherwise the shutdown can't be called, as the context is already cancelled
otelShutdown, err := setupOTelSDK(context.Background()) otelShutdown, err := setupOTelSDK(context.Background())
if err != nil { if err != nil {
@@ -44,19 +48,19 @@ func Run(ctx context.Context, database *sqlx.DB, migrationsPrefix string, env fu
cancel() cancel()
}() }()
slog.SetDefault(log.NewLogPropagator()) slog.SetDefault(core.NewLogPropagator())
} }
slog.InfoContext(ctx, "Starting server...") slog.InfoContext(ctx, "Starting server...")
// init server settings // init server settings
serverSettings, err := types.NewSettingsFromEnv(ctx, env) serverSettings, err := core.NewSettingsFromEnv(ctx, env)
if err != nil { if err != nil {
return err return err
} }
// init db // init db
err = db.RunMigrations(ctx, database, migrationsPrefix) err = core.RunMigrations(ctx, database, migrationsPrefix)
if err != nil { if err != nil {
return fmt.Errorf("could not run migrations: %w", err) return fmt.Errorf("could not run migrations: %w", err)
} }
@@ -64,7 +68,7 @@ func Run(ctx context.Context, database *sqlx.DB, migrationsPrefix string, env fu
// init server // init server
httpServer := &http.Server{ httpServer := &http.Server{
Addr: ":" + serverSettings.Port, Addr: ":" + serverSettings.Port,
Handler: createHandlerWithServices(ctx, database, serverSettings), Handler: createHandlerWithServices(ctx, database, serverSettings, isOtelEnabled),
ReadHeaderTimeout: 2 * time.Second, ReadHeaderTimeout: 2 * time.Second,
} }
go startServer(ctx, httpServer) go startServer(ctx, httpServer)
@@ -103,32 +107,38 @@ func shutdownServer(ctx context.Context, s *http.Server, wg *sync.WaitGroup) {
} }
} }
func createHandlerWithServices(ctx context.Context, d *sqlx.DB, serverSettings *types.Settings) http.Handler { func createHandlerWithServices(ctx context.Context, d *sqlx.DB, serverSettings *core.Settings, isOtelEnabled bool) http.Handler {
var router = http.NewServeMux() var router = http.NewServeMux()
authDb := db.NewAuthSqlite(d) authDb := authentication.NewDbSqlite(d)
budgetDb := budget.NewDbSqlite(d)
tagDb := tag.NewDbSqlite(d)
randomService := service.NewRandom() randomService := core.NewRandom()
clockService := service.NewClock() clockService := core.NewClock()
mailService := service.NewMail(serverSettings) mailService := core.NewMail(serverSettings)
authService := service.NewAuth(authDb, randomService, clockService, mailService, serverSettings) authService := authentication.NewService(authDb, randomService, clockService, mailService, serverSettings)
accountService := service.NewAccount(d, randomService, clockService) accountService := account.NewServiceImpl(d, randomService, clockService)
treasureChestService := service.NewTreasureChest(d, randomService, clockService) treasureChestService := treasure_chest.NewService(d, randomService, clockService)
transactionService := service.NewTransaction(d, randomService, clockService) transactionService := transaction.NewService(d, randomService, clockService)
transactionRecurringService := service.NewTransactionRecurring(d, randomService, clockService, transactionService) transactionRecurringService := transaction_recurring.NewService(d, randomService, clockService)
dashboardService := service.NewDashboard(d) dashboardService := dashboard.NewService(d)
budgetService := budget.NewService(budgetDb, randomService, clockService)
tagService := tag.NewService(tagDb, randomService, clockService)
render := handler.NewRender() render := core.NewRender()
indexHandler := handler.NewIndex(render, clockService) indexHandler := handler.NewIndex(render, clockService)
dashboardHandler := handler.NewDashboard(render, dashboardService, treasureChestService) dashboardHandler := dashboard.NewHandler(render, dashboardService, treasureChestService)
authHandler := handler.NewAuth(authService, render) authHandler := authentication.NewHandler(authService, render)
accountHandler := handler.NewAccount(accountService, render) accountHandler := account.NewHandler(accountService, render)
treasureChestHandler := handler.NewTreasureChest(treasureChestService, transactionRecurringService, render) treasureChestHandler := treasure_chest.NewHandler(treasureChestService, transactionRecurringService, render)
transactionHandler := handler.NewTransaction(transactionService, accountService, treasureChestService, render) transactionHandler := transaction.NewHandler(transactionService, accountService, treasureChestService, render)
transactionRecurringHandler := handler.NewTransactionRecurring(transactionRecurringService, render) transactionRecurringHandler := transaction_recurring.NewHandler(transactionRecurringService, render)
budgetHandler := budget.NewHandler(budgetService, render)
tagHandler := tag.NewHandler(tagService, render)
go dailyTaskTimer(ctx, transactionRecurringService, authService) go dailyTaskTimer(ctx, transactionService, authService)
indexHandler.Handle(router) indexHandler.Handle(router)
dashboardHandler.Handle(router) dashboardHandler.Handle(router)
@@ -137,6 +147,8 @@ func createHandlerWithServices(ctx context.Context, d *sqlx.DB, serverSettings *
authHandler.Handle(router) authHandler.Handle(router)
transactionHandler.Handle(router) transactionHandler.Handle(router)
transactionRecurringHandler.Handle(router) transactionRecurringHandler.Handle(router)
budgetHandler.Handle(router)
tagHandler.Handle(router)
// Serve static files (CSS, JS and images) // Serve static files (CSS, JS and images)
router.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static/")))) router.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static/"))))
@@ -148,7 +160,7 @@ func createHandlerWithServices(ctx context.Context, d *sqlx.DB, serverSettings *
middleware.CrossSiteRequestForgery(authService), middleware.CrossSiteRequestForgery(authService),
middleware.Authenticate(authService), middleware.Authenticate(authService),
middleware.Gzip, middleware.Gzip,
middleware.Log, middleware.Log(isOtelEnabled),
) )
wrapper = otelhttp.NewHandler(wrapper, "http.request") wrapper = otelhttp.NewHandler(wrapper, "http.request")
@@ -156,8 +168,8 @@ func createHandlerWithServices(ctx context.Context, d *sqlx.DB, serverSettings *
return wrapper return wrapper
} }
func dailyTaskTimer(ctx context.Context, transactionRecurring service.TransactionRecurring, auth service.Auth) { func dailyTaskTimer(ctx context.Context, transaction transaction.Service, auth authentication.Service) {
runDailyTasks(ctx, transactionRecurring, auth) runDailyTasks(ctx, transaction, auth)
ticker := time.NewTicker(24 * time.Hour) ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop() defer ticker.Stop()
@@ -166,13 +178,13 @@ func dailyTaskTimer(ctx context.Context, transactionRecurring service.Transactio
case <-ctx.Done(): case <-ctx.Done():
return return
case <-ticker.C: case <-ticker.C:
runDailyTasks(ctx, transactionRecurring, auth) runDailyTasks(ctx, transaction, auth)
} }
} }
} }
func runDailyTasks(ctx context.Context, transactionRecurring service.TransactionRecurring, auth service.Auth) { func runDailyTasks(ctx context.Context, transaction transaction.Service, auth authentication.Service) {
slog.InfoContext(ctx, "Running daily tasks") slog.InfoContext(ctx, "Running daily tasks")
_ = transactionRecurring.GenerateTransactions(ctx) _ = transaction.GenerateRecurringTransactions(ctx)
_ = auth.CleanupSessionsAndTokens(ctx) _ = auth.CleanupSessionsAndTokens(ctx)
} }

View File

@@ -1,46 +0,0 @@
package handler
import (
"errors"
"net/http"
"spend-sparrow/internal/db"
"spend-sparrow/internal/service"
"spend-sparrow/internal/utils"
"strings"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
)
func handleError(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, service.ErrUnauthorized):
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(r.Context(), w, r, "error", extractErrorMessage(err), http.StatusBadRequest)
return
case errors.Is(err, db.ErrNotFound):
utils.TriggerToastWithStatus(r.Context(), w, r, "error", extractErrorMessage(err), http.StatusNotFound)
return
}
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Internal Server Error", http.StatusInternalServerError)
}
func extractErrorMessage(err error) string {
errMsg := err.Error()
if errMsg == "" {
return ""
}
return strings.SplitN(errMsg, ":", 2)[0]
}
func updateSpan(r *http.Request) {
currentSpan := trace.SpanFromContext(r.Context())
if currentSpan != nil {
currentSpan.SetAttributes(attribute.String("http.pattern", r.Pattern))
currentSpan.SetAttributes(attribute.String("http.pattern.id", r.PathValue("id")))
}
}

View File

@@ -3,18 +3,12 @@ package middleware
import ( import (
"context" "context"
"net/http" "net/http"
"spend-sparrow/internal/authentication"
"spend-sparrow/internal/core"
"strings" "strings"
"spend-sparrow/internal/service"
"spend-sparrow/internal/types"
) )
type ContextKey string func Authenticate(service authentication.Service) func(http.Handler) http.Handler {
var SessionKey ContextKey = "session"
var UserKey ContextKey = "user"
func Authenticate(service service.Auth) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
@@ -37,46 +31,18 @@ func Authenticate(service service.Auth) func(http.Handler) http.Handler {
return return
} }
cookie := CreateSessionCookie(session.Id) cookie := core.CreateSessionCookie(session.Id)
http.SetCookie(w, &cookie) http.SetCookie(w, &cookie)
} }
ctx = context.WithValue(ctx, UserKey, user) ctx = context.WithValue(ctx, core.UserKey, user)
ctx = context.WithValue(ctx, SessionKey, session) ctx = context.WithValue(ctx, core.SessionKey, session)
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))
}) })
} }
} }
func GetUser(r *http.Request) *types.User {
obj := r.Context().Value(UserKey)
if obj == nil {
return nil
}
user, ok := obj.(*types.User)
if !ok {
return nil
}
return user
}
func GetSession(r *http.Request) *types.Session {
obj := r.Context().Value(SessionKey)
if obj == nil {
return nil
}
session, ok := obj.(*types.Session)
if !ok {
return nil
}
return session
}
func getSessionID(r *http.Request) string { func getSessionID(r *http.Request) string {
cookie, err := r.Cookie("id") cookie, err := r.Cookie("id")
if err != nil { if err != nil {

View File

@@ -3,8 +3,8 @@ package middleware
import ( import (
"log/slog" "log/slog"
"net/http" "net/http"
"spend-sparrow/internal/service" "spend-sparrow/internal/authentication"
"spend-sparrow/internal/utils" "spend-sparrow/internal/core"
"strings" "strings"
) )
@@ -30,7 +30,7 @@ func (rr *csrfResponseWriter) Write(data []byte) (int, error) {
return rr.ResponseWriter.Write([]byte(dataStr)) return rr.ResponseWriter.Write([]byte(dataStr))
} }
func CrossSiteRequestForgery(auth service.Auth) func(http.Handler) http.Handler { func CrossSiteRequestForgery(auth authentication.Service) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
@@ -40,7 +40,7 @@ func CrossSiteRequestForgery(auth service.Auth) func(http.Handler) http.Handler
return return
} }
session := GetSession(r) session := core.GetSession(r)
if r.Method == http.MethodPost || if r.Method == http.MethodPost ||
r.Method == http.MethodPut || r.Method == http.MethodPut ||
@@ -51,7 +51,7 @@ func CrossSiteRequestForgery(auth service.Auth) func(http.Handler) http.Handler
if session == nil || csrfToken == "" || !auth.IsCsrfTokenValid(ctx, csrfToken, session.Id) { if session == nil || csrfToken == "" || !auth.IsCsrfTokenValid(ctx, csrfToken, session.Id) {
slog.InfoContext(ctx, "CSRF-Token not correct", "token", csrfToken) slog.InfoContext(ctx, "CSRF-Token not correct", "token", csrfToken)
if r.Header.Get("Hx-Request") == "true" { if r.Header.Get("Hx-Request") == "true" {
utils.TriggerToastWithStatus(ctx, w, r, "error", "CSRF-Token not correct", http.StatusBadRequest) core.TriggerToastWithStatus(ctx, w, r, "error", "CSRF-Token not correct", http.StatusBadRequest)
} else { } else {
http.Error(w, "CSRF-Token not correct", http.StatusBadRequest) http.Error(w, "CSRF-Token not correct", http.StatusBadRequest)
} }
@@ -62,7 +62,7 @@ func CrossSiteRequestForgery(auth service.Auth) func(http.Handler) http.Handler
token, err := auth.GetCsrfToken(ctx, session) token, err := auth.GetCsrfToken(ctx, session)
if err != nil { if err != nil {
if r.Header.Get("Hx-Request") == "true" { if r.Header.Get("Hx-Request") == "true" {
utils.TriggerToastWithStatus(ctx, w, r, "error", "Could not generate CSRF Token", http.StatusBadRequest) core.TriggerToastWithStatus(ctx, w, r, "error", "Could not generate CSRF Token", http.StatusBadRequest)
} else { } else {
http.Error(w, "Could not generate CSRF Token", http.StatusBadRequest) http.Error(w, "Could not generate CSRF Token", http.StatusBadRequest)
} }

View File

@@ -1,15 +1 @@
package middleware package middleware
import "net/http"
func CreateSessionCookie(sessionId string) http.Cookie {
return http.Cookie{
Name: "id",
Value: sessionId,
MaxAge: 60 * 60 * 8, // 8 hours
Secure: true,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Path: "/",
}
}

View File

@@ -17,21 +17,28 @@ func (w *WrappedWriter) WriteHeader(code int) {
w.ResponseWriter.WriteHeader(code) w.ResponseWriter.WriteHeader(code)
} }
func Log(next http.Handler) http.Handler { func Log(enabled bool) func(http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return func(next http.Handler) http.Handler {
start := time.Now() return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !enabled {
next.ServeHTTP(w, r)
return
}
wrapped := &WrappedWriter{ start := time.Now()
ResponseWriter: w,
StatusCode: http.StatusOK,
}
next.ServeHTTP(wrapped, r)
slog.InfoContext(r.Context(), "request", wrapped := &WrappedWriter{
"remoteAddr", r.RemoteAddr, ResponseWriter: w,
"status", wrapped.StatusCode, StatusCode: http.StatusOK,
"method", r.Method, }
"path", r.URL.Path, next.ServeHTTP(wrapped, r)
"duration", time.Since(start).String())
}) slog.InfoContext(r.Context(), "request",
"remoteAddr", r.RemoteAddr,
"status", wrapped.StatusCode,
"method", r.Method,
"path", r.URL.Path,
"duration", time.Since(start).String())
})
}
} }

View File

@@ -2,10 +2,10 @@ package middleware
import ( import (
"net/http" "net/http"
"spend-sparrow/internal/types" "spend-sparrow/internal/core"
) )
func SecurityHeaders(serverSettings *types.Settings) func(http.Handler) http.Handler { func SecurityHeaders(serverSettings *core.Settings) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Content-Type-Options", "nosniff")

View File

@@ -2,7 +2,7 @@ package middleware
import "net/http" 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 { func Wrapper(next http.Handler, handlers ...func(http.Handler) http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
lastHandler := next lastHandler := next

View File

@@ -2,10 +2,8 @@ package handler
import ( import (
"net/http" "net/http"
"spend-sparrow/internal/handler/middleware" "spend-sparrow/internal/core"
"spend-sparrow/internal/service"
"spend-sparrow/internal/template" "spend-sparrow/internal/template"
"spend-sparrow/internal/utils"
"github.com/a-h/templ" "github.com/a-h/templ"
) )
@@ -15,11 +13,11 @@ type Index interface {
} }
type IndexImpl struct { type IndexImpl struct {
r *Render r *core.Render
c service.Clock c core.Clock
} }
func NewIndex(r *Render, c service.Clock) Index { func NewIndex(r *core.Render, c core.Clock) Index {
return IndexImpl{ return IndexImpl{
r: r, r: r,
c: c, c: c,
@@ -33,11 +31,11 @@ func (handler IndexImpl) Handle(router *http.ServeMux) {
func (handler IndexImpl) handleRootAnd404() http.HandlerFunc { func (handler IndexImpl) handleRootAnd404() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r) core.UpdateSpan(r)
user := middleware.GetUser(r) user := core.GetUser(r)
htmx := utils.IsHtmx(r) htmx := core.IsHtmx(r)
var comp templ.Component var comp templ.Component
@@ -47,7 +45,7 @@ func (handler IndexImpl) handleRootAnd404() http.HandlerFunc {
status = http.StatusNotFound status = http.StatusNotFound
} else { } else {
if user != nil { if user != nil {
utils.DoRedirect(w, r, "/dashboard") core.DoRedirect(w, r, "/dashboard")
return return
} else { } else {
comp = template.Index() comp = template.Index()
@@ -65,7 +63,7 @@ func (handler IndexImpl) handleRootAnd404() http.HandlerFunc {
func (handler IndexImpl) handleEmpty() http.HandlerFunc { func (handler IndexImpl) handleEmpty() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r) core.UpdateSpan(r)
// Return nothing // Return nothing
} }

View File

@@ -16,11 +16,6 @@ import (
"go.opentelemetry.io/otel/sdk/metric" "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/resource" "go.opentelemetry.io/otel/sdk/resource"
"go.opentelemetry.io/otel/sdk/trace" "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
)
var (
otelEndpoint = "otel-collector:4317"
) )
// setupOTelSDK bootstraps the OpenTelemetry pipeline. // setupOTelSDK bootstraps the OpenTelemetry pipeline.
@@ -50,10 +45,7 @@ func setupOTelSDK(ctx context.Context) (func(context.Context) error, error) {
prop := newPropagator() prop := newPropagator()
otel.SetTextMapPropagator(prop) otel.SetTextMapPropagator(prop)
resources, err := resource.New( resources, err := resource.New(ctx)
ctx,
resource.WithAttributes(semconv.ServiceName("spend-sparrow")),
)
if err != nil { if err != nil {
slog.ErrorContext(ctx, "failed to create resource", "error", err) slog.ErrorContext(ctx, "failed to create resource", "error", err)
} }
@@ -96,11 +88,7 @@ func newPropagator() propagation.TextMapPropagator {
} }
func newTracerProvider(ctx context.Context, resource *resource.Resource) (*trace.TracerProvider, error) { func newTracerProvider(ctx context.Context, resource *resource.Resource) (*trace.TracerProvider, error) {
exp, err := otlptracegrpc.New( exp, err := otlptracegrpc.New(ctx)
ctx,
otlptracegrpc.WithEndpoint(otelEndpoint),
otlptracegrpc.WithInsecure(),
)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -112,10 +100,7 @@ func newTracerProvider(ctx context.Context, resource *resource.Resource) (*trace
} }
func newMeterProvider(ctx context.Context, resource *resource.Resource) (*metric.MeterProvider, error) { func newMeterProvider(ctx context.Context, resource *resource.Resource) (*metric.MeterProvider, error) {
exp, err := otlpmetricgrpc.New( exp, err := otlpmetricgrpc.New(ctx)
ctx,
otlpmetricgrpc.WithInsecure(),
otlpmetricgrpc.WithEndpoint(otelEndpoint))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -127,10 +112,7 @@ func newMeterProvider(ctx context.Context, resource *resource.Resource) (*metric
} }
func newLoggerProvider(ctx context.Context, resource *resource.Resource) (*log.LoggerProvider, error) { func newLoggerProvider(ctx context.Context, resource *resource.Resource) (*log.LoggerProvider, error) {
logExporter, err := otlploggrpc.New( logExporter, err := otlploggrpc.New(ctx)
ctx,
otlploggrpc.WithInsecure(),
otlploggrpc.WithEndpoint(otelEndpoint))
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -1,8 +0,0 @@
package service
import "errors"
var (
ErrBadRequest = errors.New("bad request")
ErrUnauthorized = errors.New("unauthorized")
)

111
internal/tag/db.go Normal file
View File

@@ -0,0 +1,111 @@
package tag
import (
"context"
"log/slog"
"spend-sparrow/internal/core"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
type Db interface {
insert(ctx context.Context, tag Tag) (*Tag, error)
update(ctx context.Context, tag Tag) (*Tag, error)
delete(ctx context.Context, userId uuid.UUID, id uuid.UUID) error
get(ctx context.Context, userId uuid.UUID, id uuid.UUID) (*Tag, error)
getAll(ctx context.Context, userId uuid.UUID) ([]Tag, error)
find(ctx context.Context, userId uuid.UUID, search string) ([]Tag, error)
}
type DbSqlite struct {
db *sqlx.DB
}
func NewDbSqlite(db *sqlx.DB) *DbSqlite {
return &DbSqlite{db: db}
}
func (db DbSqlite) insert(ctx context.Context, tag Tag) (*Tag, error) {
r, err := db.db.ExecContext(ctx, `
INSERT INTO tag (id, user_id, name, created_at, created_by, updated_at, updated_by)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
tag.Id, tag.UserId, tag.Name, tag.CreatedAt, tag.CreatedBy, tag.UpdatedAt, tag.UpdatedBy,
)
err = core.TransformAndLogDbError(ctx, "tag", r, err)
if err != nil {
return nil, core.ErrInternal
}
return db.get(ctx, tag.UserId, tag.Id)
}
func (db DbSqlite) update(ctx context.Context, tag Tag) (*Tag, error) {
_, err := db.db.ExecContext(ctx, `
UPDATE tag
SET name = ?,
updated_at = ?,
updated_by = ?
WHERE user_id = ?
AND id = ?`,
tag.Name, tag.UpdatedAt, tag.UpdatedBy, tag.UserId, tag.Id)
if err != nil {
slog.ErrorContext(ctx, "SQL error UpdateUser", "err", err)
return nil, core.ErrInternal
}
return db.get(ctx, tag.UserId, tag.Id)
}
func (db DbSqlite) delete(ctx context.Context, userId uuid.UUID, id uuid.UUID) error {
r, err := db.db.ExecContext(
ctx,
"DELETE FROM tag WHERE user_id = ? AND id = ?",
userId,
id)
err = core.TransformAndLogDbError(ctx, "tag", r, err)
if err != nil {
return err
}
return nil
}
func (db DbSqlite) get(ctx context.Context, userId uuid.UUID, id uuid.UUID) (*Tag, error) {
var tag Tag
err := db.db.GetContext(ctx, &tag, "SELECT * FROM tag WHERE id = ? AND user_id = ?", id, userId)
if err != nil {
slog.ErrorContext(ctx, "Could not get tag", "err", err)
return nil, core.ErrInternal
}
return &tag, nil
}
func (db DbSqlite) getAll(ctx context.Context, userId uuid.UUID) ([]Tag, error) {
var tags []Tag
err := db.db.SelectContext(ctx, &tags, "SELECT * FROM tag WHERE user_id = ?", userId)
if err != nil {
slog.ErrorContext(ctx, "Could not GetAll tag", "err", err)
return nil, core.ErrInternal
}
return tags, nil
}
func (db DbSqlite) find(ctx context.Context, userId uuid.UUID, search string) ([]Tag, error) {
var tags []Tag
err := db.db.SelectContext(ctx, &tags, "SELECT * FROM tag WHERE user_id = ? AND name LIKE ?", userId, "%"+search+"%")
slog.InfoContext(ctx, "find", "len", len(tags), "search", search)
if err != nil {
slog.ErrorContext(ctx, "Could not find tag", "err", err)
return nil, core.ErrInternal
}
return tags, nil
}

200
internal/tag/handler.go Normal file
View File

@@ -0,0 +1,200 @@
package tag
import (
"fmt"
"net/http"
"spend-sparrow/internal/core"
"github.com/google/uuid"
)
const (
DECIMALS_MULTIPLIER = 100
)
type Handler interface {
Handle(router *http.ServeMux)
}
type HandlerImpl struct {
s Service
r *core.Render
}
func NewHandler(s Service, r *core.Render) Handler {
return HandlerImpl{
s: s,
r: r,
}
}
func (h HandlerImpl) Handle(r *http.ServeMux) {
r.Handle("GET /tag", h.handlePage())
r.Handle("POST /tag/search", h.handleInlineEditSearch())
r.Handle("GET /tag/new", h.handleNew())
r.Handle("GET /tag/{id}", h.handleEdit())
r.Handle("POST /tag/{id}", h.handlePost())
r.Handle("DELETE /tag/{id}", h.handleDelete())
}
func (h HandlerImpl) handlePage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
core.UpdateSpan(r)
user := core.GetUser(r)
if user == nil {
core.DoRedirect(w, r, "/auth/signin")
return
}
tags, err := h.s.getAll(r.Context(), user)
if err != nil {
h.r.RenderLayout(r, w, core.ErrorComp(err), user)
return
}
comp := page(tags)
h.r.RenderLayout(r, w, comp, user)
}
}
func (h HandlerImpl) handleInlineEditSearch() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
core.UpdateSpan(r)
user := core.GetUser(r)
if user == nil {
core.DoRedirect(w, r, "/auth/signin")
return
}
search := r.FormValue("search")
tags, err := h.s.find(r.Context(), user, search)
if err != nil {
core.HandleError(w, r, fmt.Errorf("could not find tags: %w", core.ErrInternal))
}
comp := inlineEditSearch(tags)
h.r.Render(r, w, comp)
}
}
func (h HandlerImpl) handleNew() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
core.UpdateSpan(r)
user := core.GetUser(r)
if user == nil {
core.DoRedirect(w, r, "/auth/signin")
return
}
comp := editNew()
h.r.RenderLayout(r, w, comp, user)
}
}
func (h HandlerImpl) handleEdit() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
core.UpdateSpan(r)
user := core.GetUser(r)
if user == nil {
core.DoRedirect(w, r, "/auth/signin")
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
core.HandleError(w, r, fmt.Errorf("could not parse Id: %w", core.ErrBadRequest))
return
}
tag, err := h.s.get(r.Context(), user, id)
if err != nil {
core.HandleError(w, r, fmt.Errorf("could not parse Id: %w", core.ErrBadRequest))
return
}
comp := edit(*tag)
h.r.RenderLayout(r, w, comp, user)
}
}
func (h HandlerImpl) handlePost() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
core.UpdateSpan(r)
user := core.GetUser(r)
if user == nil {
core.DoRedirect(w, r, "/auth/signin")
return
}
var (
id uuid.UUID
err error
)
idStr := r.PathValue("id")
if idStr != "new" {
id, err = uuid.Parse(idStr)
if err != nil {
core.HandleError(w, r, fmt.Errorf("could not parse Id: %w", core.ErrBadRequest))
return
}
}
if err != nil {
core.HandleError(w, r, fmt.Errorf("could not parse value: %w", core.ErrBadRequest))
return
}
input := Tag{
Id: id,
Name: r.FormValue("name"),
}
if idStr == "new" {
_, err = h.s.add(r.Context(), user, input)
if err != nil {
core.HandleError(w, r, err)
return
}
} else {
_, err = h.s.update(r.Context(), user, input)
if err != nil {
core.HandleError(w, r, err)
return
}
}
core.DoRedirect(w, r, "/tag")
}
}
func (h HandlerImpl) handleDelete() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
core.UpdateSpan(r)
user := core.GetUser(r)
if user == nil {
core.DoRedirect(w, r, "/auth/signin")
return
}
idStr := r.PathValue("id")
id, err := uuid.Parse(idStr)
if err != nil {
core.HandleError(w, r, fmt.Errorf("could not parse Id: %w", core.ErrBadRequest))
return
}
err = h.s.delete(r.Context(), user, id)
if err != nil {
core.HandleError(w, r, err)
return
}
core.DoRedirect(w, r, "/tag")
}
}

119
internal/tag/service.go Normal file
View File

@@ -0,0 +1,119 @@
package tag
import (
"context"
"spend-sparrow/internal/auth_types"
"spend-sparrow/internal/core"
"github.com/google/uuid"
)
type Service interface {
add(ctx context.Context, user *auth_types.User, tag Tag) (*Tag, error)
update(ctx context.Context, user *auth_types.User, tag Tag) (*Tag, error)
delete(ctx context.Context, user *auth_types.User, tagId uuid.UUID) error
get(ctx context.Context, user *auth_types.User, tagId uuid.UUID) (*Tag, error)
getAll(ctx context.Context, user *auth_types.User) ([]Tag, error)
find(ctx context.Context, user *auth_types.User, search string) ([]Tag, error)
}
type ServiceImpl struct {
db Db
clock core.Clock
random core.Random
}
func NewService(db Db, random core.Random, clock core.Clock) Service {
return ServiceImpl{
db: db,
clock: clock,
random: random,
}
}
func (s ServiceImpl) add(ctx context.Context, user *auth_types.User, tag Tag) (*Tag, error) {
if user == nil {
return nil, core.ErrUnauthorized
}
isValid := s.isTagValid(tag)
if !isValid {
return nil, core.ErrBadRequest
}
newId, err := s.random.UUID(ctx)
if err != nil {
return nil, core.ErrInternal
}
tag.Id = newId
tag.UserId = user.Id
tag.CreatedBy = user.Id
tag.CreatedAt = s.clock.Now()
return s.db.insert(ctx, tag)
}
func (s ServiceImpl) update(ctx context.Context, user *auth_types.User, input Tag) (*Tag, error) {
if user == nil {
return nil, core.ErrUnauthorized
}
tag, err := s.get(ctx, user, input.Id)
if err != nil {
return nil, err
}
tag.Name = input.Name
if user.Id != tag.UserId {
return nil, core.ErrBadRequest
}
isValid := s.isTagValid(*tag)
if !isValid {
return nil, core.ErrBadRequest
}
tag.UpdatedBy = &user.Id
now := s.clock.Now()
tag.UpdatedAt = &now
return s.db.update(ctx, *tag)
}
func (s ServiceImpl) delete(ctx context.Context, user *auth_types.User, tagId uuid.UUID) error {
if user == nil {
return core.ErrUnauthorized
}
return s.db.delete(ctx, user.Id, tagId)
}
func (s ServiceImpl) get(ctx context.Context, user *auth_types.User, tagId uuid.UUID) (*Tag, error) {
if user == nil {
return nil, core.ErrUnauthorized
}
return s.db.get(ctx, user.Id, tagId)
}
func (s ServiceImpl) getAll(ctx context.Context, user *auth_types.User) ([]Tag, error) {
if user == nil {
return nil, core.ErrUnauthorized
}
return s.db.getAll(ctx, user.Id)
}
func (s ServiceImpl) find(ctx context.Context, user *auth_types.User, search string) ([]Tag, error) {
if user == nil {
return nil, core.ErrUnauthorized
}
return s.db.find(ctx, user.Id, search)
}
func (s ServiceImpl) isTagValid(tag Tag) bool {
err := core.ValidateString(tag.Name, "name")
return err == nil
}

159
internal/tag/template.templ Normal file
View File

@@ -0,0 +1,159 @@
package tag
import (
"spend-sparrow/internal/core"
"spend-sparrow/internal/template/svg"
)
templ InlineEditInput(classes string) {
<div class={ "flex flex-wrap gap-2 input max-w-full " + classes }>
<span class="flex items-center gap-1 p-1 bg-green-100 rounded-sm">
Lebensmittel
<button class="hover:bg-red-900 rounded p-1 w-5 transition-all">
@svg.Cancel()
</button>
</span>
<span class="flex items-center gap-1 p-1 bg-yellow-100 rounded-sm">
Sparen
<button class="hover:bg-red-900 rounded p-1 w-5 transition-all">
@svg.Cancel()
</button>
</span>
<span class="flex items-center gap-1 p-1 bg-red-100 rounded-sm">
Tanken
<button class="hover:bg-red-900 rounded p-1 w-5 transition-all">
@svg.Cancel()
</button>
</span>
<div>
<input
class="inline"
name="search"
placeholder="Begin Typing To Search ..."
hx-post="/tag/search"
hx-trigger="input changed delay:250ms, keyup[key=='Enter'], load"
hx-target="#tag-search-results"
/>
<div id="tag-search-results" class="absolute bg-white border-gray-200 border-1 rounded-lg p-4"></div>
</div>
</div>
}
templ inlineEditSearch(tags []Tag) {
for _,tag:=range(tags) {
<p x-data={ tag.Id.String() }>{ tag.Name }</p>
}
}
templ page(tags []Tag) {
@core.Breadcrumb([]string{"Home", "Tag"}, []string{"/", "/tag"})
<div class="flex flex-wrap gap-20 text-xl mt-10 justify-center">
@newItem()
for _,tag:=range(tags ) {
@item(tag)
}
</div>
}
templ editNew() {
<div class="flex flex-col h-full">
@core.Breadcrumb([]string{"Home", "Tag", "New"}, []string{"/", "/tag", "/tag/new"})
<div class="flex justify-center items-center flex-1">
<form
hx-post={ "/tag/new" }
class="grid grid-cols-4 items-center gap-4 max-w-2xl"
>
<label for="timestamp" class="text-sm text-gray-500">Name</label>
<input
autofocus
name="name"
type="text"
class="bg-white input datetime col-span-3"
/>
<div class="flex gap-6 justify-end col-span-4">
<a href="/tag" class="col-start-3 p-2 px-4 decoration-yellow-400 decoration-[0.25rem] hover:bg-gray-200 rounded-lg hover:underline flex items-center gap-2 justify-center">
<span class="h-4 w-4">
@svg.Cancel()
</span>
<span>
Cancel
</span>
</a>
<button type="submit" class="col-start-4 p-2 px-4 decoration-yellow-400 decoration-[0.25rem] hover:bg-gray-200 rounded-lg hover:underline flex items-center gap-2 justify-center">
@svg.Save()
<span>
Save
</span>
</button>
</div>
</form>
</div>
</div>
}
templ edit(tag Tag) {
<div class="flex flex-col h-full">
@core.Breadcrumb([]string{"Home", "Tag", tag.Name}, []string{"/", "/tag", "/tag/" + tag.Id.String()})
<div class="flex justify-center items-center flex-1">
<form
hx-post={ "/tag/" + tag.Id.String() }
class="grid grid-cols-4 items-center gap-4 max-w-2xl"
>
<label for="timestamp" class="text-sm text-gray-500">Name</label>
<input
autofocus
name="name"
type="text"
value={ tag.Name }
class="bg-white input datetime col-span-3"
/>
<div class="flex flex-row-reverse gap-6 justify-end col-span-4">
<button type="submit" class="col-start-4 p-2 px-4 decoration-yellow-400 decoration-[0.25rem] hover:bg-gray-200 rounded-lg hover:underline flex items-center gap-2 justify-center">
@svg.Save()
<span>
Save
</span>
</button>
<a href="/tag" class="col-start-3 p-2 px-4 decoration-yellow-400 decoration-[0.25rem] hover:bg-gray-200 rounded-lg hover:underline flex items-center gap-2 justify-center">
<span class="h-4 w-4">
@svg.Cancel()
</span>
<span>
Cancel
</span>
</a>
<button
hx-delete={ "/tag/" + tag.Id.String() }
hx-confirm={ "Do you really want to delete '" + tag.Name + "'" }
class="col-start-4 p-2 px-4 decoration-yellow-400 decoration-[0.25rem] hover:bg-red-50 rounded-lg hover:underline flex items-center gap-2 justify-center"
>
@svg.Delete()
<span>
Delete
</span>
</button>
</div>
</form>
</div>
</div>
}
templ newItem() {
<a
href="/tag/new"
class="p-5 w-64 h-64 flex gap-10 active:bg-gray-200 flex-col justify-center items-center hover:bg-gray-100 transition-all cursor-pointer rounded-lg bg-gray-50 border-1 border-gray-300"
>
New Tag
<div class="w-10">
@svg.Plus()
</div>
</a>
}
templ item(tag Tag) {
<a href={ "/tag/" + tag.Id.String() } class="p-5 w-64 h-64 flex gap-10 active:bg-gray-200 flex-col justify-center items-center hover:bg-gray-100 transition-all cursor-pointer rounded-lg bg-gray-50 border-1 border-gray-300">
<span>
{ tag.Name }
</span>
</a>
}

19
internal/tag/types.go Normal file
View File

@@ -0,0 +1,19 @@
package tag
import (
"time"
"github.com/google/uuid"
)
type Tag struct {
Id uuid.UUID `db:"id"`
UserId uuid.UUID `db:"user_id"`
Name string `db:"name"`
CreatedAt time.Time `db:"created_at"`
CreatedBy uuid.UUID `db:"created_by"`
UpdatedAt *time.Time `db:"updated_at"`
UpdatedBy *uuid.UUID `db:"updated_by"`
}

View File

@@ -1 +0,0 @@
package account

View File

@@ -1 +0,0 @@
package auth

View File

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

View File

@@ -19,7 +19,7 @@ templ Eye() {
} }
templ Plus() { templ Plus() {
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 304 384" class="h-4 w-4 text-gray-500"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 304 384" class="text-gray-500">
<path fill="currentColor" d="M299 213H171v128h-43V213H0v-42h128V43h43v128h128v42z"></path> <path fill="currentColor" d="M299 213H171v128h-43V213H0v-42h128V43h43v128h128v42z"></path>
</svg> </svg>
} }

View File

@@ -1 +0,0 @@
package transaction

View File

@@ -1 +0,0 @@
package transaction_recurring

View File

@@ -1 +0,0 @@
package treasurechest

View File

@@ -1,14 +1,13 @@
package handler package transaction
import ( import (
"fmt" "fmt"
"math" "math"
"net/http" "net/http"
"spend-sparrow/internal/handler/middleware" "spend-sparrow/internal/account"
"spend-sparrow/internal/service" "spend-sparrow/internal/core"
t "spend-sparrow/internal/template/transaction" "spend-sparrow/internal/treasure_chest"
"spend-sparrow/internal/types" "spend-sparrow/internal/treasure_chest_types"
"spend-sparrow/internal/utils"
"strconv" "strconv"
"time" "time"
@@ -16,19 +15,23 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
) )
type Transaction interface { const (
DECIMALS_MULTIPLIER = 100
)
type Handler interface {
Handle(router *http.ServeMux) Handle(router *http.ServeMux)
} }
type TransactionImpl struct { type HandlerImpl struct {
s service.Transaction s Service
account service.Account account account.Service
treasureChest service.TreasureChest treasureChest treasure_chest.Service
r *Render r *core.Render
} }
func NewTransaction(s service.Transaction, account service.Account, treasureChest service.TreasureChest, r *Render) Transaction { func NewHandler(s Service, account account.Service, treasureChest treasure_chest.Service, r *core.Render) Handler {
return TransactionImpl{ return HandlerImpl{
s: s, s: s,
account: account, account: account,
treasureChest: treasureChest, treasureChest: treasureChest,
@@ -36,7 +39,7 @@ func NewTransaction(s service.Transaction, account service.Account, treasureChes
} }
} }
func (h TransactionImpl) Handle(r *http.ServeMux) { func (h HandlerImpl) Handle(r *http.ServeMux) {
r.Handle("GET /transaction", h.handleTransactionPage()) r.Handle("GET /transaction", h.handleTransactionPage())
r.Handle("GET /transaction/{id}", h.handleTransactionItemComp()) r.Handle("GET /transaction/{id}", h.handleTransactionItemComp())
r.Handle("POST /transaction/{id}", h.handleUpdateTransaction()) r.Handle("POST /transaction/{id}", h.handleUpdateTransaction())
@@ -44,17 +47,17 @@ func (h TransactionImpl) Handle(r *http.ServeMux) {
r.Handle("DELETE /transaction/{id}", h.handleDeleteTransaction()) r.Handle("DELETE /transaction/{id}", h.handleDeleteTransaction())
} }
func (h TransactionImpl) handleTransactionPage() http.HandlerFunc { func (h HandlerImpl) handleTransactionPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r) core.UpdateSpan(r)
user := middleware.GetUser(r) user := core.GetUser(r)
if user == nil { if user == nil {
utils.DoRedirect(w, r, "/auth/signin") core.DoRedirect(w, r, "/auth/signin")
return return
} }
filter := types.TransactionItemsFilter{ filter := TransactionItemsFilter{
AccountId: r.URL.Query().Get("account-id"), AccountId: r.URL.Query().Get("account-id"),
TreasureChestId: r.URL.Query().Get("treasure-chest-id"), TreasureChestId: r.URL.Query().Get("treasure-chest-id"),
Error: r.URL.Query().Get("error"), Error: r.URL.Query().Get("error"),
@@ -63,87 +66,87 @@ func (h TransactionImpl) handleTransactionPage() http.HandlerFunc {
transactions, err := h.s.GetAll(r.Context(), user, filter) transactions, err := h.s.GetAll(r.Context(), user, filter)
if err != nil { if err != nil {
handleError(w, r, err) core.HandleError(w, r, err)
return return
} }
accounts, err := h.account.GetAll(r.Context(), user) accounts, err := h.account.GetAll(r.Context(), user)
if err != nil { if err != nil {
handleError(w, r, err) core.HandleError(w, r, err)
return return
} }
treasureChests, err := h.treasureChest.GetAll(r.Context(), user) treasureChests, err := h.treasureChest.GetAll(r.Context(), user)
if err != nil { if err != nil {
handleError(w, r, err) core.HandleError(w, r, err)
return return
} }
accountMap, treasureChestMap := h.getTransactionData(accounts, treasureChests) accountMap, treasureChestMap := h.getTransactionData(accounts, treasureChests)
items := t.TransactionItems(transactions, accountMap, treasureChestMap) items := TransactionItems(transactions, accountMap, treasureChestMap)
if utils.IsHtmx(r) { if core.IsHtmx(r) {
h.r.Render(r, w, items) h.r.Render(r, w, items)
} else { } else {
comp := t.Transaction(items, filter, accounts, treasureChests) comp := TransactionComp(items, filter, accounts, treasureChests)
h.r.RenderLayout(r, w, comp, user) h.r.RenderLayout(r, w, comp, user)
} }
} }
} }
func (h TransactionImpl) handleTransactionItemComp() http.HandlerFunc { func (h HandlerImpl) handleTransactionItemComp() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r) core.UpdateSpan(r)
user := middleware.GetUser(r) user := core.GetUser(r)
if user == nil { if user == nil {
utils.DoRedirect(w, r, "/auth/signin") core.DoRedirect(w, r, "/auth/signin")
return return
} }
accounts, err := h.account.GetAll(r.Context(), user) accounts, err := h.account.GetAll(r.Context(), user)
if err != nil { if err != nil {
handleError(w, r, err) core.HandleError(w, r, err)
return return
} }
treasureChests, err := h.treasureChest.GetAll(r.Context(), user) treasureChests, err := h.treasureChest.GetAll(r.Context(), user)
if err != nil { if err != nil {
handleError(w, r, err) core.HandleError(w, r, err)
return return
} }
id := r.PathValue("id") id := r.PathValue("id")
if id == "new" { if id == "new" {
comp := t.EditTransaction(nil, accounts, treasureChests) comp := EditTransaction(nil, accounts, treasureChests)
h.r.Render(r, w, comp) h.r.Render(r, w, comp)
return return
} }
transaction, err := h.s.Get(r.Context(), user, id) transaction, err := h.s.Get(r.Context(), user, id)
if err != nil { if err != nil {
handleError(w, r, err) core.HandleError(w, r, err)
return return
} }
var comp templ.Component var comp templ.Component
if r.URL.Query().Get("edit") == "true" { if r.URL.Query().Get("edit") == "true" {
comp = t.EditTransaction(transaction, accounts, treasureChests) comp = EditTransaction(transaction, accounts, treasureChests)
} else { } else {
accountMap, treasureChestMap := h.getTransactionData(accounts, treasureChests) accountMap, treasureChestMap := h.getTransactionData(accounts, treasureChests)
comp = t.TransactionItem(transaction, accountMap, treasureChestMap) comp = TransactionItem(transaction, accountMap, treasureChestMap)
} }
h.r.Render(r, w, comp) h.r.Render(r, w, comp)
} }
} }
func (h TransactionImpl) handleUpdateTransaction() http.HandlerFunc { func (h HandlerImpl) handleUpdateTransaction() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r) core.UpdateSpan(r)
user := middleware.GetUser(r) user := core.GetUser(r)
if user == nil { if user == nil {
utils.DoRedirect(w, r, "/auth/signin") core.DoRedirect(w, r, "/auth/signin")
return return
} }
@@ -156,7 +159,7 @@ func (h TransactionImpl) handleUpdateTransaction() http.HandlerFunc {
if idStr != "new" { if idStr != "new" {
id, err = uuid.Parse(idStr) id, err = uuid.Parse(idStr)
if err != nil { if err != nil {
handleError(w, r, fmt.Errorf("could not parse Id: %w", service.ErrBadRequest)) core.HandleError(w, r, fmt.Errorf("could not parse Id: %w", core.ErrBadRequest))
return return
} }
} }
@@ -166,7 +169,7 @@ func (h TransactionImpl) handleUpdateTransaction() http.HandlerFunc {
if accountIdStr != "" { if accountIdStr != "" {
i, err := uuid.Parse(accountIdStr) i, err := uuid.Parse(accountIdStr)
if err != nil { if err != nil {
handleError(w, r, fmt.Errorf("could not parse account id: %w", service.ErrBadRequest)) core.HandleError(w, r, fmt.Errorf("could not parse account id: %w", core.ErrBadRequest))
return return
} }
accountId = &i accountId = &i
@@ -177,7 +180,7 @@ func (h TransactionImpl) handleUpdateTransaction() http.HandlerFunc {
if treasureChestIdStr != "" { if treasureChestIdStr != "" {
i, err := uuid.Parse(treasureChestIdStr) i, err := uuid.Parse(treasureChestIdStr)
if err != nil { if err != nil {
handleError(w, r, fmt.Errorf("could not parse treasure chest id: %w", service.ErrBadRequest)) core.HandleError(w, r, fmt.Errorf("could not parse treasure chest id: %w", core.ErrBadRequest))
return return
} }
treasureChestId = &i treasureChestId = &i
@@ -185,18 +188,18 @@ func (h TransactionImpl) handleUpdateTransaction() http.HandlerFunc {
valueF, err := strconv.ParseFloat(r.FormValue("value"), 64) valueF, err := strconv.ParseFloat(r.FormValue("value"), 64)
if err != nil { if err != nil {
handleError(w, r, fmt.Errorf("could not parse value: %w", service.ErrBadRequest)) core.HandleError(w, r, fmt.Errorf("could not parse value: %w", core.ErrBadRequest))
return return
} }
value := int64(math.Round(valueF * service.DECIMALS_MULTIPLIER)) value := int64(math.Round(valueF * DECIMALS_MULTIPLIER))
timestamp, err := time.Parse("2006-01-02", r.FormValue("timestamp")) timestamp, err := time.Parse("2006-01-02", r.FormValue("timestamp"))
if err != nil { if err != nil {
handleError(w, r, fmt.Errorf("could not parse timestamp: %w", service.ErrBadRequest)) core.HandleError(w, r, fmt.Errorf("could not parse timestamp: %w", core.ErrBadRequest))
return return
} }
input := types.Transaction{ input := Transaction{
Id: id, Id: id,
AccountId: accountId, AccountId: accountId,
TreasureChestId: treasureChestId, TreasureChestId: treasureChestId,
@@ -206,66 +209,66 @@ func (h TransactionImpl) handleUpdateTransaction() http.HandlerFunc {
Description: r.FormValue("description"), Description: r.FormValue("description"),
} }
var transaction *types.Transaction var transaction *Transaction
if idStr == "new" { if idStr == "new" {
transaction, err = h.s.Add(r.Context(), nil, user, input) transaction, err = h.s.Add(r.Context(), nil, user, input)
if err != nil { if err != nil {
handleError(w, r, err) core.HandleError(w, r, err)
return return
} }
} else { } else {
transaction, err = h.s.Update(r.Context(), user, input) transaction, err = h.s.Update(r.Context(), user, input)
if err != nil { if err != nil {
handleError(w, r, err) core.HandleError(w, r, err)
return return
} }
} }
accounts, err := h.account.GetAll(r.Context(), user) accounts, err := h.account.GetAll(r.Context(), user)
if err != nil { if err != nil {
handleError(w, r, err) core.HandleError(w, r, err)
return return
} }
treasureChests, err := h.treasureChest.GetAll(r.Context(), user) treasureChests, err := h.treasureChest.GetAll(r.Context(), user)
if err != nil { if err != nil {
handleError(w, r, err) core.HandleError(w, r, err)
return return
} }
accountMap, treasureChestMap := h.getTransactionData(accounts, treasureChests) accountMap, treasureChestMap := h.getTransactionData(accounts, treasureChests)
comp := t.TransactionItem(transaction, accountMap, treasureChestMap) comp := TransactionItem(transaction, accountMap, treasureChestMap)
h.r.Render(r, w, comp) h.r.Render(r, w, comp)
} }
} }
func (h TransactionImpl) handleRecalculate() http.HandlerFunc { func (h HandlerImpl) handleRecalculate() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r) core.UpdateSpan(r)
user := middleware.GetUser(r) user := core.GetUser(r)
if user == nil { if user == nil {
utils.DoRedirect(w, r, "/auth/signin") core.DoRedirect(w, r, "/auth/signin")
return return
} }
err := h.s.RecalculateBalances(r.Context(), user) err := h.s.RecalculateBalances(r.Context(), user)
if err != nil { if err != nil {
handleError(w, r, err) core.HandleError(w, r, err)
return return
} }
utils.TriggerToastWithStatus(r.Context(), w, r, "success", "Balances recalculated, please refresh", http.StatusOK) core.TriggerToastWithStatus(r.Context(), w, r, "success", "Balances recalculated, please refresh", http.StatusOK)
} }
} }
func (h TransactionImpl) handleDeleteTransaction() http.HandlerFunc { func (h HandlerImpl) handleDeleteTransaction() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r) core.UpdateSpan(r)
user := middleware.GetUser(r) user := core.GetUser(r)
if user == nil { if user == nil {
utils.DoRedirect(w, r, "/auth/signin") core.DoRedirect(w, r, "/auth/signin")
return return
} }
@@ -273,13 +276,13 @@ func (h TransactionImpl) handleDeleteTransaction() http.HandlerFunc {
err := h.s.Delete(r.Context(), user, id) err := h.s.Delete(r.Context(), user, id)
if err != nil { if err != nil {
handleError(w, r, err) core.HandleError(w, r, err)
return return
} }
} }
} }
func (h TransactionImpl) getTransactionData(accounts []*types.Account, treasureChests []*types.TreasureChest) (map[uuid.UUID]string, map[uuid.UUID]string) { func (h HandlerImpl) getTransactionData(accounts []*account.Account, treasureChests []*treasure_chest_types.TreasureChest) (map[uuid.UUID]string, map[uuid.UUID]string) {
accountMap := make(map[uuid.UUID]string, 0) accountMap := make(map[uuid.UUID]string, 0)
for _, account := range accounts { for _, account := range accounts {
accountMap[account.Id] = account.Name accountMap[account.Id] = account.Name

View File

@@ -1,12 +1,14 @@
package service package transaction
import ( import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"spend-sparrow/internal/db" "spend-sparrow/internal/auth_types"
"spend-sparrow/internal/types" "spend-sparrow/internal/core"
"spend-sparrow/internal/transaction_recurring"
"spend-sparrow/internal/treasure_chest_types"
"strconv" "strconv"
"time" "time"
@@ -16,33 +18,34 @@ import (
const page_size = 25 const page_size = 25
type Transaction interface { type Service interface {
Add(ctx context.Context, tx *sqlx.Tx, user *types.User, transaction types.Transaction) (*types.Transaction, error) Add(ctx context.Context, tx *sqlx.Tx, user *auth_types.User, transaction Transaction) (*Transaction, error)
Update(ctx context.Context, user *types.User, transaction types.Transaction) (*types.Transaction, error) Update(ctx context.Context, user *auth_types.User, transaction Transaction) (*Transaction, error)
Get(ctx context.Context, user *types.User, id string) (*types.Transaction, error) Get(ctx context.Context, user *auth_types.User, id string) (*Transaction, error)
GetAll(ctx context.Context, user *types.User, filter types.TransactionItemsFilter) ([]*types.Transaction, error) GetAll(ctx context.Context, user *auth_types.User, filter TransactionItemsFilter) ([]*Transaction, error)
Delete(ctx context.Context, user *types.User, id string) error Delete(ctx context.Context, user *auth_types.User, id string) error
RecalculateBalances(ctx context.Context, user *types.User) error RecalculateBalances(ctx context.Context, user *auth_types.User) error
GenerateRecurringTransactions(ctx context.Context) error
} }
type TransactionImpl struct { type ServiceImpl struct {
db *sqlx.DB db *sqlx.DB
clock Clock clock core.Clock
random Random random core.Random
} }
func NewTransaction(db *sqlx.DB, random Random, clock Clock) Transaction { func NewService(db *sqlx.DB, random core.Random, clock core.Clock) Service {
return TransactionImpl{ return ServiceImpl{
db: db, db: db,
clock: clock, clock: clock,
random: random, random: random,
} }
} }
func (s TransactionImpl) Add(ctx context.Context, tx *sqlx.Tx, user *types.User, transactionInput types.Transaction) (*types.Transaction, error) { func (s ServiceImpl) Add(ctx context.Context, tx *sqlx.Tx, user *auth_types.User, transactionInput Transaction) (*Transaction, error) {
if user == nil { if user == nil {
return nil, ErrUnauthorized return nil, core.ErrUnauthorized
} }
var err error var err error
@@ -50,7 +53,7 @@ func (s TransactionImpl) Add(ctx context.Context, tx *sqlx.Tx, user *types.User,
if tx == nil { if tx == nil {
ownsTransaction = true ownsTransaction = true
tx, err = s.db.BeginTxx(ctx, nil) tx, err = s.db.BeginTxx(ctx, nil)
err = db.TransformAndLogDbError(ctx, "transaction Add", nil, err) err = core.TransformAndLogDbError(ctx, "transaction Add", nil, err)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -69,7 +72,7 @@ func (s TransactionImpl) Add(ctx context.Context, tx *sqlx.Tx, user *types.User,
party, description, error, created_at, created_by) party, description, error, created_at, created_by)
VALUES (:id, :user_id, :account_id, :treasure_chest_id, :value, :timestamp, VALUES (:id, :user_id, :account_id, :treasure_chest_id, :value, :timestamp,
:party, :description, :error, :created_at, :created_by)`, transaction) :party, :description, :error, :created_at, :created_by)`, transaction)
err = db.TransformAndLogDbError(ctx, "transaction Insert", r, err) err = core.TransformAndLogDbError(ctx, "transaction Insert", r, err)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -79,7 +82,7 @@ func (s TransactionImpl) Add(ctx context.Context, tx *sqlx.Tx, user *types.User,
UPDATE account UPDATE account
SET current_balance = current_balance + ? SET current_balance = current_balance + ?
WHERE id = ? AND user_id = ?`, transaction.Value, transaction.AccountId, user.Id) WHERE id = ? AND user_id = ?`, transaction.Value, transaction.AccountId, user.Id)
err = db.TransformAndLogDbError(ctx, "transaction Add", r, err) err = core.TransformAndLogDbError(ctx, "transaction Add", r, err)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -90,7 +93,7 @@ func (s TransactionImpl) Add(ctx context.Context, tx *sqlx.Tx, user *types.User,
UPDATE treasure_chest UPDATE treasure_chest
SET current_balance = current_balance + ? SET current_balance = current_balance + ?
WHERE id = ? AND user_id = ?`, transaction.Value, transaction.TreasureChestId, user.Id) WHERE id = ? AND user_id = ?`, transaction.Value, transaction.TreasureChestId, user.Id)
err = db.TransformAndLogDbError(ctx, "transaction Add", r, err) err = core.TransformAndLogDbError(ctx, "transaction Add", r, err)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -98,7 +101,7 @@ func (s TransactionImpl) Add(ctx context.Context, tx *sqlx.Tx, user *types.User,
if ownsTransaction { if ownsTransaction {
err = tx.Commit() err = tx.Commit()
err = db.TransformAndLogDbError(ctx, "transaction Add", nil, err) err = core.TransformAndLogDbError(ctx, "transaction Add", nil, err)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -107,13 +110,13 @@ func (s TransactionImpl) Add(ctx context.Context, tx *sqlx.Tx, user *types.User,
return transaction, nil return transaction, nil
} }
func (s TransactionImpl) Update(ctx context.Context, user *types.User, input types.Transaction) (*types.Transaction, error) { func (s ServiceImpl) Update(ctx context.Context, user *auth_types.User, input Transaction) (*Transaction, error) {
if user == nil { if user == nil {
return nil, ErrUnauthorized return nil, core.ErrUnauthorized
} }
tx, err := s.db.BeginTxx(ctx, nil) tx, err := s.db.BeginTxx(ctx, nil)
err = db.TransformAndLogDbError(ctx, "transaction Update", nil, err) err = core.TransformAndLogDbError(ctx, "transaction Update", nil, err)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -121,14 +124,14 @@ func (s TransactionImpl) Update(ctx context.Context, user *types.User, input typ
_ = tx.Rollback() _ = tx.Rollback()
}() }()
transaction := &types.Transaction{} transaction := &Transaction{}
err = tx.GetContext(ctx, transaction, `SELECT * FROM "transaction" WHERE user_id = ? AND id = ?`, user.Id, input.Id) 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) err = core.TransformAndLogDbError(ctx, "transaction Update", nil, err)
if err != nil { if err != nil {
if errors.Is(err, db.ErrNotFound) { if errors.Is(err, core.ErrNotFound) {
return nil, fmt.Errorf("transaction %v not found: %w", input.Id, ErrBadRequest) return nil, fmt.Errorf("transaction %v not found: %w", input.Id, core.ErrBadRequest)
} }
return nil, types.ErrInternal return nil, core.ErrInternal
} }
if transaction.Error == nil && transaction.AccountId != nil { if transaction.Error == nil && transaction.AccountId != nil {
@@ -136,7 +139,7 @@ func (s TransactionImpl) Update(ctx context.Context, user *types.User, input typ
UPDATE account UPDATE account
SET current_balance = current_balance - ? SET current_balance = current_balance - ?
WHERE id = ? AND user_id = ?`, transaction.Value, transaction.AccountId, user.Id) WHERE id = ? AND user_id = ?`, transaction.Value, transaction.AccountId, user.Id)
err = db.TransformAndLogDbError(ctx, "transaction Update", r, err) err = core.TransformAndLogDbError(ctx, "transaction Update", r, err)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -146,7 +149,7 @@ func (s TransactionImpl) Update(ctx context.Context, user *types.User, input typ
UPDATE treasure_chest UPDATE treasure_chest
SET current_balance = current_balance - ? SET current_balance = current_balance - ?
WHERE id = ? AND user_id = ?`, transaction.Value, transaction.TreasureChestId, user.Id) WHERE id = ? AND user_id = ?`, transaction.Value, transaction.TreasureChestId, user.Id)
err = db.TransformAndLogDbError(ctx, "transaction Update", r, err) err = core.TransformAndLogDbError(ctx, "transaction Update", r, err)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -162,7 +165,7 @@ func (s TransactionImpl) Update(ctx context.Context, user *types.User, input typ
UPDATE account UPDATE account
SET current_balance = current_balance + ? SET current_balance = current_balance + ?
WHERE id = ? AND user_id = ?`, transaction.Value, transaction.AccountId, user.Id) WHERE id = ? AND user_id = ?`, transaction.Value, transaction.AccountId, user.Id)
err = db.TransformAndLogDbError(ctx, "transaction Update", r, err) err = core.TransformAndLogDbError(ctx, "transaction Update", r, err)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -172,7 +175,7 @@ func (s TransactionImpl) Update(ctx context.Context, user *types.User, input typ
UPDATE treasure_chest UPDATE treasure_chest
SET current_balance = current_balance + ? SET current_balance = current_balance + ?
WHERE id = ? AND user_id = ?`, transaction.Value, transaction.TreasureChestId, user.Id) WHERE id = ? AND user_id = ?`, transaction.Value, transaction.TreasureChestId, user.Id)
err = db.TransformAndLogDbError(ctx, "transaction Update", r, err) err = core.TransformAndLogDbError(ctx, "transaction Update", r, err)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -192,13 +195,13 @@ func (s TransactionImpl) Update(ctx context.Context, user *types.User, input typ
updated_by = :updated_by updated_by = :updated_by
WHERE id = :id WHERE id = :id
AND user_id = :user_id`, transaction) AND user_id = :user_id`, transaction)
err = db.TransformAndLogDbError(ctx, "transaction Update", r, err) err = core.TransformAndLogDbError(ctx, "transaction Update", r, err)
if err != nil { if err != nil {
return nil, err return nil, err
} }
err = tx.Commit() err = tx.Commit()
err = db.TransformAndLogDbError(ctx, "transaction Update", nil, err) err = core.TransformAndLogDbError(ctx, "transaction Update", nil, err)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -206,32 +209,32 @@ func (s TransactionImpl) Update(ctx context.Context, user *types.User, input typ
return transaction, nil return transaction, nil
} }
func (s TransactionImpl) Get(ctx context.Context, user *types.User, id string) (*types.Transaction, error) { func (s ServiceImpl) Get(ctx context.Context, user *auth_types.User, id string) (*Transaction, error) {
if user == nil { if user == nil {
return nil, ErrUnauthorized return nil, core.ErrUnauthorized
} }
uuid, err := uuid.Parse(id) uuid, err := uuid.Parse(id)
if err != nil { if err != nil {
slog.ErrorContext(ctx, "transaction get", "err", err) slog.ErrorContext(ctx, "transaction get", "err", err)
return nil, fmt.Errorf("could not parse Id: %w", ErrBadRequest) return nil, fmt.Errorf("could not parse Id: %w", core.ErrBadRequest)
} }
var transaction types.Transaction var transaction Transaction
err = s.db.GetContext(ctx, &transaction, `SELECT * FROM "transaction" WHERE user_id = ? AND id = ?`, user.Id, uuid) 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) err = core.TransformAndLogDbError(ctx, "transaction Get", nil, err)
if err != nil { if err != nil {
if errors.Is(err, db.ErrNotFound) { if errors.Is(err, core.ErrNotFound) {
return nil, fmt.Errorf("transaction %v not found: %w", id, ErrBadRequest) return nil, fmt.Errorf("transaction %v not found: %w", id, core.ErrBadRequest)
} }
return nil, types.ErrInternal return nil, core.ErrInternal
} }
return &transaction, nil return &transaction, nil
} }
func (s TransactionImpl) GetAll(ctx context.Context, user *types.User, filter types.TransactionItemsFilter) ([]*types.Transaction, error) { func (s ServiceImpl) GetAll(ctx context.Context, user *auth_types.User, filter TransactionItemsFilter) ([]*Transaction, error) {
if user == nil { if user == nil {
return nil, ErrUnauthorized return nil, core.ErrUnauthorized
} }
var ( var (
@@ -249,7 +252,7 @@ func (s TransactionImpl) GetAll(ctx context.Context, user *types.User, filter ty
} }
} }
transactions := make([]*types.Transaction, 0) transactions := make([]*Transaction, 0)
err = s.db.SelectContext(ctx, &transactions, ` err = s.db.SelectContext(ctx, &transactions, `
SELECT * SELECT *
FROM "transaction" FROM "transaction"
@@ -269,7 +272,7 @@ func (s TransactionImpl) GetAll(ctx context.Context, user *types.User, filter ty
filter.Error, filter.Error,
page_size, page_size,
offset) offset)
err = db.TransformAndLogDbError(ctx, "transaction GetAll", nil, err) err = core.TransformAndLogDbError(ctx, "transaction GetAll", nil, err)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -277,18 +280,18 @@ func (s TransactionImpl) GetAll(ctx context.Context, user *types.User, filter ty
return transactions, nil return transactions, nil
} }
func (s TransactionImpl) Delete(ctx context.Context, user *types.User, id string) error { func (s ServiceImpl) Delete(ctx context.Context, user *auth_types.User, id string) error {
if user == nil { if user == nil {
return ErrUnauthorized return core.ErrUnauthorized
} }
uuid, err := uuid.Parse(id) uuid, err := uuid.Parse(id)
if err != nil { if err != nil {
slog.ErrorContext(ctx, "transaction delete", "err", err) slog.ErrorContext(ctx, "transaction delete", "err", err)
return fmt.Errorf("could not parse Id: %w", ErrBadRequest) return fmt.Errorf("could not parse Id: %w", core.ErrBadRequest)
} }
tx, err := s.db.BeginTxx(ctx, nil) tx, err := s.db.BeginTxx(ctx, nil)
err = db.TransformAndLogDbError(ctx, "transaction Delete", nil, err) err = core.TransformAndLogDbError(ctx, "transaction Delete", nil, err)
if err != nil { if err != nil {
return nil return nil
} }
@@ -296,9 +299,9 @@ func (s TransactionImpl) Delete(ctx context.Context, user *types.User, id string
_ = tx.Rollback() _ = tx.Rollback()
}() }()
var transaction types.Transaction var transaction Transaction
err = tx.GetContext(ctx, &transaction, `SELECT * FROM "transaction" WHERE user_id = ? AND id = ?`, user.Id, uuid) err = tx.GetContext(ctx, &transaction, `SELECT * FROM "transaction" WHERE user_id = ? AND id = ?`, user.Id, uuid)
err = db.TransformAndLogDbError(ctx, "transaction Delete", nil, err) err = core.TransformAndLogDbError(ctx, "transaction Delete", nil, err)
if err != nil { if err != nil {
return err return err
} }
@@ -309,8 +312,8 @@ func (s TransactionImpl) Delete(ctx context.Context, user *types.User, id string
SET current_balance = current_balance - ? SET current_balance = current_balance - ?
WHERE id = ? WHERE id = ?
AND user_id = ?`, transaction.Value, transaction.AccountId, user.Id) AND user_id = ?`, transaction.Value, transaction.AccountId, user.Id)
err = db.TransformAndLogDbError(ctx, "transaction Delete", r, err) err = core.TransformAndLogDbError(ctx, "transaction Delete", r, err)
if err != nil && !errors.Is(err, db.ErrNotFound) { if err != nil && !errors.Is(err, core.ErrNotFound) {
return err return err
} }
} }
@@ -321,20 +324,20 @@ func (s TransactionImpl) Delete(ctx context.Context, user *types.User, id string
SET current_balance = current_balance - ? SET current_balance = current_balance - ?
WHERE id = ? WHERE id = ?
AND user_id = ?`, transaction.Value, transaction.TreasureChestId, user.Id) AND user_id = ?`, transaction.Value, transaction.TreasureChestId, user.Id)
err = db.TransformAndLogDbError(ctx, "transaction Delete", r, err) err = core.TransformAndLogDbError(ctx, "transaction Delete", r, err)
if err != nil && !errors.Is(err, db.ErrNotFound) { if err != nil && !errors.Is(err, core.ErrNotFound) {
return err return err
} }
} }
r, err := tx.ExecContext(ctx, "DELETE FROM \"transaction\" WHERE id = ? AND user_id = ?", uuid, user.Id) r, err := tx.ExecContext(ctx, "DELETE FROM \"transaction\" WHERE id = ? AND user_id = ?", uuid, user.Id)
err = db.TransformAndLogDbError(ctx, "transaction Delete", r, err) err = core.TransformAndLogDbError(ctx, "transaction Delete", r, err)
if err != nil { if err != nil {
return err return err
} }
err = tx.Commit() err = tx.Commit()
err = db.TransformAndLogDbError(ctx, "transaction Delete", nil, err) err = core.TransformAndLogDbError(ctx, "transaction Delete", nil, err)
if err != nil { if err != nil {
return err return err
} }
@@ -342,13 +345,13 @@ func (s TransactionImpl) Delete(ctx context.Context, user *types.User, id string
return nil return nil
} }
func (s TransactionImpl) RecalculateBalances(ctx context.Context, user *types.User) error { func (s ServiceImpl) RecalculateBalances(ctx context.Context, user *auth_types.User) error {
if user == nil { if user == nil {
return ErrUnauthorized return core.ErrUnauthorized
} }
tx, err := s.db.BeginTxx(ctx, nil) tx, err := s.db.BeginTxx(ctx, nil)
err = db.TransformAndLogDbError(ctx, "transaction RecalculateBalances", nil, err) err = core.TransformAndLogDbError(ctx, "transaction RecalculateBalances", nil, err)
if err != nil { if err != nil {
return err return err
} }
@@ -360,8 +363,8 @@ func (s TransactionImpl) RecalculateBalances(ctx context.Context, user *types.Us
UPDATE account UPDATE account
SET current_balance = 0 SET current_balance = 0
WHERE user_id = ?`, user.Id) WHERE user_id = ?`, user.Id)
err = db.TransformAndLogDbError(ctx, "transaction RecalculateBalances", r, err) err = core.TransformAndLogDbError(ctx, "transaction RecalculateBalances", r, err)
if err != nil && !errors.Is(err, db.ErrNotFound) { if err != nil && !errors.Is(err, core.ErrNotFound) {
return err return err
} }
@@ -369,8 +372,8 @@ func (s TransactionImpl) RecalculateBalances(ctx context.Context, user *types.Us
UPDATE treasure_chest UPDATE treasure_chest
SET current_balance = 0 SET current_balance = 0
WHERE user_id = ?`, user.Id) WHERE user_id = ?`, user.Id)
err = db.TransformAndLogDbError(ctx, "transaction RecalculateBalances", r, err) err = core.TransformAndLogDbError(ctx, "transaction RecalculateBalances", r, err)
if err != nil && !errors.Is(err, db.ErrNotFound) { if err != nil && !errors.Is(err, core.ErrNotFound) {
return err return err
} }
@@ -378,8 +381,8 @@ func (s TransactionImpl) RecalculateBalances(ctx context.Context, user *types.Us
SELECT * SELECT *
FROM "transaction" FROM "transaction"
WHERE user_id = ?`, user.Id) WHERE user_id = ?`, user.Id)
err = db.TransformAndLogDbError(ctx, "transaction RecalculateBalances", nil, err) err = core.TransformAndLogDbError(ctx, "transaction RecalculateBalances", nil, err)
if err != nil && !errors.Is(err, db.ErrNotFound) { if err != nil && !errors.Is(err, core.ErrNotFound) {
return err return err
} }
defer func() { defer func() {
@@ -389,10 +392,10 @@ func (s TransactionImpl) RecalculateBalances(ctx context.Context, user *types.Us
} }
}() }()
var transaction types.Transaction var transaction Transaction
for rows.Next() { for rows.Next() {
err = rows.StructScan(&transaction) err = rows.StructScan(&transaction)
err = db.TransformAndLogDbError(ctx, "transaction RecalculateBalances", nil, err) err = core.TransformAndLogDbError(ctx, "transaction RecalculateBalances", nil, err)
if err != nil { if err != nil {
return err return err
} }
@@ -403,7 +406,7 @@ func (s TransactionImpl) RecalculateBalances(ctx context.Context, user *types.Us
SET error = ? SET error = ?
WHERE user_id = ? WHERE user_id = ?
AND id = ?`, transaction.Error, user.Id, transaction.Id) AND id = ?`, transaction.Error, user.Id, transaction.Id)
err = db.TransformAndLogDbError(ctx, "transaction RecalculateBalances", r, err) err = core.TransformAndLogDbError(ctx, "transaction RecalculateBalances", r, err)
if err != nil { if err != nil {
return err return err
} }
@@ -417,7 +420,7 @@ func (s TransactionImpl) RecalculateBalances(ctx context.Context, user *types.Us
UPDATE account UPDATE account
SET current_balance = current_balance + ? SET current_balance = current_balance + ?
WHERE id = ? AND user_id = ?`, transaction.Value, transaction.AccountId, user.Id) WHERE id = ? AND user_id = ?`, transaction.Value, transaction.AccountId, user.Id)
err = db.TransformAndLogDbError(ctx, "transaction RecalculateBalances", r, err) err = core.TransformAndLogDbError(ctx, "transaction RecalculateBalances", r, err)
if err != nil { if err != nil {
return err return err
} }
@@ -427,7 +430,7 @@ func (s TransactionImpl) RecalculateBalances(ctx context.Context, user *types.Us
UPDATE treasure_chest UPDATE treasure_chest
SET current_balance = current_balance + ? SET current_balance = current_balance + ?
WHERE id = ? AND user_id = ?`, transaction.Value, transaction.TreasureChestId, user.Id) WHERE id = ? AND user_id = ?`, transaction.Value, transaction.TreasureChestId, user.Id)
err = db.TransformAndLogDbError(ctx, "transaction RecalculateBalances", r, err) err = core.TransformAndLogDbError(ctx, "transaction RecalculateBalances", r, err)
if err != nil { if err != nil {
return err return err
} }
@@ -435,7 +438,7 @@ func (s TransactionImpl) RecalculateBalances(ctx context.Context, user *types.Us
} }
err = tx.Commit() err = tx.Commit()
err = db.TransformAndLogDbError(ctx, "transaction RecalculateBalances", nil, err) err = core.TransformAndLogDbError(ctx, "transaction RecalculateBalances", nil, err)
if err != nil { if err != nil {
return err return err
} }
@@ -443,7 +446,63 @@ func (s TransactionImpl) RecalculateBalances(ctx context.Context, user *types.Us
return nil return nil
} }
func (s TransactionImpl) validateAndEnrichTransaction(ctx context.Context, tx *sqlx.Tx, oldTransaction *types.Transaction, userId uuid.UUID, input types.Transaction) (*types.Transaction, error) { func (s ServiceImpl) GenerateRecurringTransactions(ctx context.Context) error {
now := s.clock.Now()
tx, err := s.db.BeginTxx(ctx, nil)
err = core.TransformAndLogDbError(ctx, "transaction GenerateRecurringTransactions", nil, err)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
recurringTransactions := make([]*transaction_recurring.TransactionRecurring, 0)
err = tx.SelectContext(ctx, &recurringTransactions, `
SELECT * FROM transaction_recurring WHERE next_execution <= ?`,
now)
err = core.TransformAndLogDbError(ctx, "transaction GenerateRecurringTransactions", nil, err)
if err != nil {
return err
}
for _, transactionRecurring := range recurringTransactions {
user := &auth_types.User{
Id: transactionRecurring.UserId,
}
transaction := Transaction{
Timestamp: *transactionRecurring.NextExecution,
Party: transactionRecurring.Party,
Description: transactionRecurring.Description,
TreasureChestId: transactionRecurring.TreasureChestId,
Value: transactionRecurring.Value,
}
_, err = s.Add(ctx, tx, user, transaction)
if err != nil {
return err
}
nextExecution := transactionRecurring.NextExecution.AddDate(0, int(transactionRecurring.IntervalMonths), 0)
r, err := tx.ExecContext(ctx, `UPDATE transaction_recurring SET next_execution = ? WHERE id = ? AND user_id = ?`,
nextExecution, transactionRecurring.Id, user.Id)
err = core.TransformAndLogDbError(ctx, "transaction GenerateRecurringTransactions", r, err)
if err != nil {
return err
}
}
err = tx.Commit()
err = core.TransformAndLogDbError(ctx, "transaction GenerateRecurringTransactions", nil, err)
if err != nil {
return err
}
return nil
}
func (s ServiceImpl) validateAndEnrichTransaction(ctx context.Context, tx *sqlx.Tx, oldTransaction *Transaction, userId uuid.UUID, input Transaction) (*Transaction, error) {
var ( var (
id uuid.UUID id uuid.UUID
createdAt time.Time createdAt time.Time
@@ -458,7 +517,7 @@ func (s TransactionImpl) validateAndEnrichTransaction(ctx context.Context, tx *s
if oldTransaction == nil { if oldTransaction == nil {
id, err = s.random.UUID(ctx) id, err = s.random.UUID(ctx)
if err != nil { if err != nil {
return nil, types.ErrInternal return nil, core.ErrInternal
} }
createdAt = s.clock.Now() createdAt = s.clock.Now()
createdBy = userId createdBy = userId
@@ -473,45 +532,45 @@ func (s TransactionImpl) validateAndEnrichTransaction(ctx context.Context, tx *s
if input.AccountId != nil { if input.AccountId != nil {
err = tx.GetContext(ctx, &rowCount, `SELECT COUNT(*) FROM account WHERE id = ? AND user_id = ?`, input.AccountId, userId) 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) err = core.TransformAndLogDbError(ctx, "transaction validate", nil, err)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if rowCount == 0 { if rowCount == 0 {
slog.ErrorContext(ctx, "transaction validate", "err", err) slog.ErrorContext(ctx, "transaction validate", "err", err)
return nil, fmt.Errorf("account not found: %w", ErrBadRequest) return nil, fmt.Errorf("account not found: %w", core.ErrBadRequest)
} }
} }
if input.TreasureChestId != nil { if input.TreasureChestId != nil {
var treasureChest types.TreasureChest var treasureChest treasure_chest_types.TreasureChest
err = tx.GetContext(ctx, &treasureChest, `SELECT * FROM treasure_chest WHERE id = ? AND user_id = ?`, input.TreasureChestId, userId) 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) err = core.TransformAndLogDbError(ctx, "transaction validate", nil, err)
if err != nil { if err != nil {
if errors.Is(err, db.ErrNotFound) { if errors.Is(err, core.ErrNotFound) {
return nil, fmt.Errorf("treasure chest not found: %w", ErrBadRequest) return nil, fmt.Errorf("treasure chest not found: %w", core.ErrBadRequest)
} }
return nil, err return nil, err
} }
if treasureChest.ParentId == nil { if treasureChest.ParentId == nil {
return nil, fmt.Errorf("treasure chest is a group: %w", ErrBadRequest) return nil, fmt.Errorf("treasure chest is a group: %w", core.ErrBadRequest)
} }
} }
if input.Party != "" { if input.Party != "" {
err = validateString(input.Party, "party") err = core.ValidateString(input.Party, "party")
if err != nil { if err != nil {
return nil, err return nil, err
} }
} }
if input.Description != "" { if input.Description != "" {
err = validateString(input.Description, "description") err = core.ValidateString(input.Description, "description")
if err != nil { if err != nil {
return nil, err return nil, err
} }
} }
transaction := types.Transaction{ transaction := Transaction{
Id: id, Id: id,
UserId: userId, UserId: userId,
@@ -534,7 +593,7 @@ func (s TransactionImpl) validateAndEnrichTransaction(ctx context.Context, tx *s
return &transaction, nil return &transaction, nil
} }
func (s TransactionImpl) updateErrors(t *types.Transaction) { func (s ServiceImpl) updateErrors(t *Transaction) {
errorStr := "" errorStr := ""
switch { switch {

View File

@@ -1,12 +1,16 @@
package transaction package transaction
import "fmt" import (
import "time" "fmt"
import "spend-sparrow/internal/template/svg" "github.com/google/uuid"
import "spend-sparrow/internal/types" "spend-sparrow/internal/account"
import "github.com/google/uuid" "spend-sparrow/internal/core"
"spend-sparrow/internal/template/svg"
"spend-sparrow/internal/treasure_chest_types"
"time"
)
templ Transaction(items templ.Component, filter types.TransactionItemsFilter, accounts []*types.Account, treasureChests []*types.TreasureChest) { templ TransactionComp(items templ.Component, filter TransactionItemsFilter, accounts []*account.Account, treasureChests []*treasure_chest_types.TreasureChest) {
<div class="max-w-6xl mt-10 mx-auto"> <div class="max-w-6xl mt-10 mx-auto">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<form <form
@@ -61,7 +65,9 @@ templ Transaction(items templ.Component, filter types.TransactionItemsFilter, ac
hx-swap="afterbegin" hx-swap="afterbegin"
class="button button-primary ml-auto px-2 flex items-center gap-2 justify-center" class="button button-primary ml-auto px-2 flex items-center gap-2 justify-center"
> >
@svg.Plus() <div class="w-3">
@svg.Plus()
</div>
<p>New Transaction</p> <p>New Transaction</p>
</button> </button>
</div> </div>
@@ -87,7 +93,7 @@ templ Transaction(items templ.Component, filter types.TransactionItemsFilter, ac
</div> </div>
} }
templ TransactionItems(transactions []*types.Transaction, accounts, treasureChests map[uuid.UUID]string) { templ TransactionItems(transactions []*Transaction, accounts, treasureChests map[uuid.UUID]string) {
<div id="transaction-items" class="my-6"> <div id="transaction-items" class="my-6">
for _, transaction := range transactions { for _, transaction := range transactions {
@TransactionItem(transaction, accounts, treasureChests) @TransactionItem(transaction, accounts, treasureChests)
@@ -95,7 +101,7 @@ templ TransactionItems(transactions []*types.Transaction, accounts, treasureChes
</div> </div>
} }
templ EditTransaction(transaction *types.Transaction, accounts []*types.Account, treasureChests []*types.TreasureChest) { templ EditTransaction(transaction *Transaction, accounts []*account.Account, treasureChests []*treasure_chest_types.TreasureChest) {
{{ {{
var ( var (
timestamp time.Time timestamp time.Time
@@ -219,7 +225,7 @@ templ EditTransaction(transaction *types.Transaction, accounts []*types.Account,
</div> </div>
} }
templ TransactionItem(transaction *types.Transaction, accounts, treasureChests map[uuid.UUID]string) { templ TransactionItem(transaction *Transaction, accounts, treasureChests map[uuid.UUID]string) {
{{ {{
background := "bg-gray-50" background := "bg-gray-50"
if transaction.Error != nil { if transaction.Error != nil {
@@ -272,9 +278,9 @@ templ TransactionItem(transaction *types.Transaction, accounts, treasureChests m
</p> </p>
</div> </div>
if transaction.Value < 0 { if transaction.Value < 0 {
<p class="mr-8 min-w-22 text-right text-red-700">{ types.FormatEuros(transaction.Value) }</p> <p class="mr-8 min-w-22 text-right text-red-700">{ core.FormatEuros(transaction.Value) }</p>
} else { } else {
<p class="mr-8 w-22 text-right text-green-700">{ types.FormatEuros(transaction.Value) }</p> <p class="mr-8 w-22 text-right text-green-700">{ core.FormatEuros(transaction.Value) }</p>
} }
<button <button
hx-get={ "/transaction/" + transaction.Id.String() + "?edit=true" } hx-get={ "/transaction/" + transaction.Id.String() + "?edit=true" }

View File

@@ -1,4 +1,4 @@
package types package transaction
import ( import (
"time" "time"
@@ -6,7 +6,7 @@ import (
"github.com/google/uuid" "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. // Every piece of data should be calculated based on transactions.
// This means potential calculation errors can be fixed later in time. // This means potential calculation errors can be fixed later in time.

View File

@@ -1,43 +1,40 @@
package handler package transaction_recurring
import ( import (
"net/http" "net/http"
"spend-sparrow/internal/handler/middleware" "spend-sparrow/internal/auth_types"
"spend-sparrow/internal/service" "spend-sparrow/internal/core"
t "spend-sparrow/internal/template/transaction_recurring"
"spend-sparrow/internal/types"
"spend-sparrow/internal/utils"
) )
type TransactionRecurring interface { type Handler interface {
Handle(router *http.ServeMux) Handle(router *http.ServeMux)
} }
type TransactionRecurringImpl struct { type HandlerImpl struct {
s service.TransactionRecurring s Service
r *Render r *core.Render
} }
func NewTransactionRecurring(s service.TransactionRecurring, r *Render) TransactionRecurring { func NewHandler(s Service, r *core.Render) Handler {
return TransactionRecurringImpl{ return HandlerImpl{
s: s, s: s,
r: r, r: r,
} }
} }
func (h TransactionRecurringImpl) Handle(r *http.ServeMux) { func (h HandlerImpl) Handle(r *http.ServeMux) {
r.Handle("GET /transaction-recurring", h.handleTransactionRecurringItemComp()) r.Handle("GET /transaction-recurring", h.handleTransactionRecurringItemComp())
r.Handle("POST /transaction-recurring/{id}", h.handleUpdateTransactionRecurring()) r.Handle("POST /transaction-recurring/{id}", h.handleUpdateTransactionRecurring())
r.Handle("DELETE /transaction-recurring/{id}", h.handleDeleteTransactionRecurring()) r.Handle("DELETE /transaction-recurring/{id}", h.handleDeleteTransactionRecurring())
} }
func (h TransactionRecurringImpl) handleTransactionRecurringItemComp() http.HandlerFunc { func (h HandlerImpl) handleTransactionRecurringItemComp() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r) core.UpdateSpan(r)
user := middleware.GetUser(r) user := core.GetUser(r)
if user == nil { if user == nil {
utils.DoRedirect(w, r, "/auth/signin") core.DoRedirect(w, r, "/auth/signin")
return return
} }
@@ -48,17 +45,17 @@ func (h TransactionRecurringImpl) handleTransactionRecurringItemComp() http.Hand
} }
} }
func (h TransactionRecurringImpl) handleUpdateTransactionRecurring() http.HandlerFunc { func (h HandlerImpl) handleUpdateTransactionRecurring() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r) core.UpdateSpan(r)
user := middleware.GetUser(r) user := core.GetUser(r)
if user == nil { if user == nil {
utils.DoRedirect(w, r, "/auth/signin") core.DoRedirect(w, r, "/auth/signin")
return return
} }
input := types.TransactionRecurringInput{ input := TransactionRecurringInput{
Id: r.PathValue("id"), Id: r.PathValue("id"),
IntervalMonths: r.FormValue("interval-months"), IntervalMonths: r.FormValue("interval-months"),
NextExecution: r.FormValue("next-execution"), NextExecution: r.FormValue("next-execution"),
@@ -72,13 +69,13 @@ func (h TransactionRecurringImpl) handleUpdateTransactionRecurring() http.Handle
if input.Id == "new" { if input.Id == "new" {
_, err := h.s.Add(r.Context(), user, input) _, err := h.s.Add(r.Context(), user, input)
if err != nil { if err != nil {
handleError(w, r, err) core.HandleError(w, r, err)
return return
} }
} else { } else {
_, err := h.s.Update(r.Context(), user, input) _, err := h.s.Update(r.Context(), user, input)
if err != nil { if err != nil {
handleError(w, r, err) core.HandleError(w, r, err)
return return
} }
} }
@@ -87,13 +84,13 @@ func (h TransactionRecurringImpl) handleUpdateTransactionRecurring() http.Handle
} }
} }
func (h TransactionRecurringImpl) handleDeleteTransactionRecurring() http.HandlerFunc { func (h HandlerImpl) handleDeleteTransactionRecurring() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r) core.UpdateSpan(r)
user := middleware.GetUser(r) user := core.GetUser(r)
if user == nil { if user == nil {
utils.DoRedirect(w, r, "/auth/signin") core.DoRedirect(w, r, "/auth/signin")
return return
} }
@@ -103,7 +100,7 @@ func (h TransactionRecurringImpl) handleDeleteTransactionRecurring() http.Handle
err := h.s.Delete(r.Context(), user, id) err := h.s.Delete(r.Context(), user, id)
if err != nil { if err != nil {
handleError(w, r, err) core.HandleError(w, r, err)
return return
} }
@@ -111,26 +108,26 @@ func (h TransactionRecurringImpl) handleDeleteTransactionRecurring() http.Handle
} }
} }
func (h TransactionRecurringImpl) renderItems(w http.ResponseWriter, r *http.Request, user *types.User, id, accountId, treasureChestId string) { func (h HandlerImpl) renderItems(w http.ResponseWriter, r *http.Request, user *auth_types.User, id, accountId, treasureChestId string) {
var transactionsRecurring []*types.TransactionRecurring var transactionsRecurring []*TransactionRecurring
var err error var err error
if accountId == "" && treasureChestId == "" { if accountId == "" && treasureChestId == "" {
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Please select an account or treasure chest", http.StatusBadRequest) core.TriggerToastWithStatus(r.Context(), w, r, "error", "Please select an account or treasure chest", http.StatusBadRequest)
} }
if accountId != "" { if accountId != "" {
transactionsRecurring, err = h.s.GetAllByAccount(r.Context(), user, accountId) transactionsRecurring, err = h.s.GetAllByAccount(r.Context(), user, accountId)
if err != nil { if err != nil {
handleError(w, r, err) core.HandleError(w, r, err)
return return
} }
} else { } else {
transactionsRecurring, err = h.s.GetAllByTreasureChest(r.Context(), user, treasureChestId) transactionsRecurring, err = h.s.GetAllByTreasureChest(r.Context(), user, treasureChestId)
if err != nil { if err != nil {
handleError(w, r, err) core.HandleError(w, r, err)
return return
} }
} }
comp := t.TransactionRecurringItems(transactionsRecurring, id, accountId, treasureChestId) comp := TransactionRecurringItems(transactionsRecurring, id, accountId, treasureChestId)
h.r.Render(r, w, comp) h.r.Render(r, w, comp)
} }

View File

@@ -1,4 +1,4 @@
package service package transaction_recurring
import ( import (
"context" "context"
@@ -6,8 +6,9 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"math" "math"
"spend-sparrow/internal/db" "spend-sparrow/internal/auth_types"
"spend-sparrow/internal/types" "spend-sparrow/internal/core"
"spend-sparrow/internal/treasure_chest_types"
"strconv" "strconv"
"time" "time"
@@ -15,43 +16,43 @@ import (
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
) )
type TransactionRecurring interface { const (
Add(ctx context.Context, user *types.User, transactionRecurring types.TransactionRecurringInput) (*types.TransactionRecurring, error) DECIMALS_MULTIPLIER = 100
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(ctx context.Context) error type Service interface {
Add(ctx context.Context, user *auth_types.User, transactionRecurring TransactionRecurringInput) (*TransactionRecurring, error)
Update(ctx context.Context, user *auth_types.User, transactionRecurring TransactionRecurringInput) (*TransactionRecurring, error)
GetAll(ctx context.Context, user *auth_types.User) ([]*TransactionRecurring, error)
GetAllByAccount(ctx context.Context, user *auth_types.User, accountId string) ([]*TransactionRecurring, error)
GetAllByTreasureChest(ctx context.Context, user *auth_types.User, treasureChestId string) ([]*TransactionRecurring, error)
Delete(ctx context.Context, user *auth_types.User, id string) error
} }
type TransactionRecurringImpl struct { type ServiceImpl struct {
db *sqlx.DB db *sqlx.DB
clock Clock clock core.Clock
random Random random core.Random
transaction Transaction
} }
func NewTransactionRecurring(db *sqlx.DB, random Random, clock Clock, transaction Transaction) TransactionRecurring { func NewService(db *sqlx.DB, random core.Random, clock core.Clock) Service {
return TransactionRecurringImpl{ return ServiceImpl{
db: db, db: db,
clock: clock, clock: clock,
random: random, random: random,
transaction: transaction,
} }
} }
func (s TransactionRecurringImpl) Add(ctx context.Context, func (s ServiceImpl) Add(ctx context.Context,
user *types.User, user *auth_types.User,
transactionRecurringInput types.TransactionRecurringInput, transactionRecurringInput TransactionRecurringInput,
) (*types.TransactionRecurring, error) { ) (*TransactionRecurring, error) {
if user == nil { if user == nil {
return nil, ErrUnauthorized return nil, core.ErrUnauthorized
} }
tx, err := s.db.BeginTxx(ctx, nil) tx, err := s.db.BeginTxx(ctx, nil)
err = db.TransformAndLogDbError(ctx, "transactionRecurring Add", nil, err) err = core.TransformAndLogDbError(ctx, "transactionRecurring Add", nil, err)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -70,13 +71,13 @@ func (s TransactionRecurringImpl) Add(ctx context.Context,
VALUES (:id, :user_id, :interval_months, VALUES (:id, :user_id, :interval_months,
:next_execution, :party, :description, :account_id, :treasure_chest_id, :value, :created_at, :created_by)`, :next_execution, :party, :description, :account_id, :treasure_chest_id, :value, :created_at, :created_by)`,
transactionRecurring) transactionRecurring)
err = db.TransformAndLogDbError(ctx, "transactionRecurring Insert", r, err) err = core.TransformAndLogDbError(ctx, "transactionRecurring Insert", r, err)
if err != nil { if err != nil {
return nil, err return nil, err
} }
err = tx.Commit() err = tx.Commit()
err = db.TransformAndLogDbError(ctx, "transactionRecurring Add", nil, err) err = core.TransformAndLogDbError(ctx, "transactionRecurring Add", nil, err)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -84,21 +85,21 @@ func (s TransactionRecurringImpl) Add(ctx context.Context,
return transactionRecurring, nil return transactionRecurring, nil
} }
func (s TransactionRecurringImpl) Update(ctx context.Context, func (s ServiceImpl) Update(ctx context.Context,
user *types.User, user *auth_types.User,
input types.TransactionRecurringInput, input TransactionRecurringInput,
) (*types.TransactionRecurring, error) { ) (*TransactionRecurring, error) {
if user == nil { if user == nil {
return nil, ErrUnauthorized return nil, core.ErrUnauthorized
} }
uuid, err := uuid.Parse(input.Id) uuid, err := uuid.Parse(input.Id)
if err != nil { if err != nil {
slog.ErrorContext(ctx, "transactionRecurring update", "err", err) slog.ErrorContext(ctx, "transactionRecurring update", "err", err)
return nil, fmt.Errorf("could not parse Id: %w", ErrBadRequest) return nil, fmt.Errorf("could not parse Id: %w", core.ErrBadRequest)
} }
tx, err := s.db.BeginTxx(ctx, nil) tx, err := s.db.BeginTxx(ctx, nil)
err = db.TransformAndLogDbError(ctx, "transactionRecurring Update", nil, err) err = core.TransformAndLogDbError(ctx, "transactionRecurring Update", nil, err)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -106,14 +107,14 @@ func (s TransactionRecurringImpl) Update(ctx context.Context,
_ = tx.Rollback() _ = tx.Rollback()
}() }()
transactionRecurring := &types.TransactionRecurring{} transactionRecurring := &TransactionRecurring{}
err = tx.GetContext(ctx, transactionRecurring, `SELECT * FROM transaction_recurring WHERE user_id = ? AND id = ?`, user.Id, uuid) 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) err = core.TransformAndLogDbError(ctx, "transactionRecurring Update", nil, err)
if err != nil { if err != nil {
if errors.Is(err, db.ErrNotFound) { if errors.Is(err, core.ErrNotFound) {
return nil, fmt.Errorf("transactionRecurring %v not found: %w", input.Id, ErrBadRequest) return nil, fmt.Errorf("transactionRecurring %v not found: %w", input.Id, core.ErrBadRequest)
} }
return nil, types.ErrInternal return nil, core.ErrInternal
} }
transactionRecurring, err = s.validateAndEnrichTransactionRecurring(ctx, tx, transactionRecurring, user.Id, input) transactionRecurring, err = s.validateAndEnrichTransactionRecurring(ctx, tx, transactionRecurring, user.Id, input)
@@ -135,13 +136,13 @@ func (s TransactionRecurringImpl) Update(ctx context.Context,
updated_by = :updated_by updated_by = :updated_by
WHERE id = :id WHERE id = :id
AND user_id = :user_id`, transactionRecurring) AND user_id = :user_id`, transactionRecurring)
err = db.TransformAndLogDbError(ctx, "transactionRecurring Update", r, err) err = core.TransformAndLogDbError(ctx, "transactionRecurring Update", r, err)
if err != nil { if err != nil {
return nil, err return nil, err
} }
err = tx.Commit() err = tx.Commit()
err = db.TransformAndLogDbError(ctx, "transactionRecurring Update", nil, err) err = core.TransformAndLogDbError(ctx, "transactionRecurring Update", nil, err)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -149,19 +150,19 @@ func (s TransactionRecurringImpl) Update(ctx context.Context,
return transactionRecurring, nil return transactionRecurring, nil
} }
func (s TransactionRecurringImpl) GetAll(ctx context.Context, user *types.User) ([]*types.TransactionRecurring, error) { func (s ServiceImpl) GetAll(ctx context.Context, user *auth_types.User) ([]*TransactionRecurring, error) {
if user == nil { if user == nil {
return nil, ErrUnauthorized return nil, core.ErrUnauthorized
} }
transactionRecurrings := make([]*types.TransactionRecurring, 0) transactionRecurrings := make([]*TransactionRecurring, 0)
err := s.db.SelectContext(ctx, &transactionRecurrings, ` err := s.db.SelectContext(ctx, &transactionRecurrings, `
SELECT * SELECT *
FROM transaction_recurring FROM transaction_recurring
WHERE user_id = ? WHERE user_id = ?
ORDER BY created_at DESC`, ORDER BY created_at DESC`,
user.Id) user.Id)
err = db.TransformAndLogDbError(ctx, "transactionRecurring GetAll", nil, err) err = core.TransformAndLogDbError(ctx, "transactionRecurring GetAll", nil, err)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -169,19 +170,19 @@ func (s TransactionRecurringImpl) GetAll(ctx context.Context, user *types.User)
return transactionRecurrings, nil return transactionRecurrings, nil
} }
func (s TransactionRecurringImpl) GetAllByAccount(ctx context.Context, user *types.User, accountId string) ([]*types.TransactionRecurring, error) { func (s ServiceImpl) GetAllByAccount(ctx context.Context, user *auth_types.User, accountId string) ([]*TransactionRecurring, error) {
if user == nil { if user == nil {
return nil, ErrUnauthorized return nil, core.ErrUnauthorized
} }
accountUuid, err := uuid.Parse(accountId) accountUuid, err := uuid.Parse(accountId)
if err != nil { if err != nil {
slog.ErrorContext(ctx, "transactionRecurring GetAllByAccount", "err", err) slog.ErrorContext(ctx, "transactionRecurring GetAllByAccount", "err", err)
return nil, fmt.Errorf("could not parse accountId: %w", ErrBadRequest) return nil, fmt.Errorf("could not parse accountId: %w", core.ErrBadRequest)
} }
tx, err := s.db.BeginTxx(ctx, nil) tx, err := s.db.BeginTxx(ctx, nil)
err = db.TransformAndLogDbError(ctx, "transactionRecurring GetAllByAccount", nil, err) err = core.TransformAndLogDbError(ctx, "transactionRecurring GetAllByAccount", nil, err)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -191,15 +192,15 @@ func (s TransactionRecurringImpl) GetAllByAccount(ctx context.Context, user *typ
var rowCount int var rowCount int
err = tx.GetContext(ctx, &rowCount, `SELECT COUNT(*) FROM account WHERE id = ? AND user_id = ?`, accountUuid, user.Id) 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) err = core.TransformAndLogDbError(ctx, "transactionRecurring GetAllByAccount", nil, err)
if err != nil { if err != nil {
if errors.Is(err, db.ErrNotFound) { if errors.Is(err, core.ErrNotFound) {
return nil, fmt.Errorf("account %v not found: %w", accountId, ErrBadRequest) return nil, fmt.Errorf("account %v not found: %w", accountId, core.ErrBadRequest)
} }
return nil, types.ErrInternal return nil, core.ErrInternal
} }
transactionRecurrings := make([]*types.TransactionRecurring, 0) transactionRecurrings := make([]*TransactionRecurring, 0)
err = tx.SelectContext(ctx, &transactionRecurrings, ` err = tx.SelectContext(ctx, &transactionRecurrings, `
SELECT * SELECT *
FROM transaction_recurring FROM transaction_recurring
@@ -207,13 +208,13 @@ func (s TransactionRecurringImpl) GetAllByAccount(ctx context.Context, user *typ
AND account_id = ? AND account_id = ?
ORDER BY created_at DESC`, ORDER BY created_at DESC`,
user.Id, accountUuid) user.Id, accountUuid)
err = db.TransformAndLogDbError(ctx, "transactionRecurring GetAll", nil, err) err = core.TransformAndLogDbError(ctx, "transactionRecurring GetAll", nil, err)
if err != nil { if err != nil {
return nil, err return nil, err
} }
err = tx.Commit() err = tx.Commit()
err = db.TransformAndLogDbError(ctx, "transactionRecurring GetAllByAccount", nil, err) err = core.TransformAndLogDbError(ctx, "transactionRecurring GetAllByAccount", nil, err)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -221,22 +222,22 @@ func (s TransactionRecurringImpl) GetAllByAccount(ctx context.Context, user *typ
return transactionRecurrings, nil return transactionRecurrings, nil
} }
func (s TransactionRecurringImpl) GetAllByTreasureChest(ctx context.Context, func (s ServiceImpl) GetAllByTreasureChest(ctx context.Context,
user *types.User, user *auth_types.User,
treasureChestId string, treasureChestId string,
) ([]*types.TransactionRecurring, error) { ) ([]*TransactionRecurring, error) {
if user == nil { if user == nil {
return nil, ErrUnauthorized return nil, core.ErrUnauthorized
} }
treasureChestUuid, err := uuid.Parse(treasureChestId) treasureChestUuid, err := uuid.Parse(treasureChestId)
if err != nil { if err != nil {
slog.ErrorContext(ctx, "transactionRecurring GetAllByTreasureChest", "err", err) slog.ErrorContext(ctx, "transactionRecurring GetAllByTreasureChest", "err", err)
return nil, fmt.Errorf("could not parse treasureChestId: %w", ErrBadRequest) return nil, fmt.Errorf("could not parse treasureChestId: %w", core.ErrBadRequest)
} }
tx, err := s.db.BeginTxx(ctx, nil) tx, err := s.db.BeginTxx(ctx, nil)
err = db.TransformAndLogDbError(ctx, "transactionRecurring GetAllByTreasureChest", nil, err) err = core.TransformAndLogDbError(ctx, "transactionRecurring GetAllByTreasureChest", nil, err)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -246,15 +247,15 @@ func (s TransactionRecurringImpl) GetAllByTreasureChest(ctx context.Context,
var rowCount int var rowCount int
err = tx.GetContext(ctx, &rowCount, `SELECT COUNT(*) FROM treasure_chest WHERE id = ? AND user_id = ?`, treasureChestId, user.Id) 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) err = core.TransformAndLogDbError(ctx, "transactionRecurring GetAllByTreasureChest", nil, err)
if err != nil { if err != nil {
if errors.Is(err, db.ErrNotFound) { if errors.Is(err, core.ErrNotFound) {
return nil, fmt.Errorf("treasurechest %v not found: %w", treasureChestId, ErrBadRequest) return nil, fmt.Errorf("treasurechest %v not found: %w", treasureChestId, core.ErrBadRequest)
} }
return nil, types.ErrInternal return nil, core.ErrInternal
} }
transactionRecurrings := make([]*types.TransactionRecurring, 0) transactionRecurrings := make([]*TransactionRecurring, 0)
err = tx.SelectContext(ctx, &transactionRecurrings, ` err = tx.SelectContext(ctx, &transactionRecurrings, `
SELECT * SELECT *
FROM transaction_recurring FROM transaction_recurring
@@ -262,13 +263,13 @@ func (s TransactionRecurringImpl) GetAllByTreasureChest(ctx context.Context,
AND treasure_chest_id = ? AND treasure_chest_id = ?
ORDER BY created_at DESC`, ORDER BY created_at DESC`,
user.Id, treasureChestUuid) user.Id, treasureChestUuid)
err = db.TransformAndLogDbError(ctx, "transactionRecurring GetAll", nil, err) err = core.TransformAndLogDbError(ctx, "transactionRecurring GetAll", nil, err)
if err != nil { if err != nil {
return nil, err return nil, err
} }
err = tx.Commit() err = tx.Commit()
err = db.TransformAndLogDbError(ctx, "transactionRecurring GetAllByTreasureChest", nil, err) err = core.TransformAndLogDbError(ctx, "transactionRecurring GetAllByTreasureChest", nil, err)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -276,18 +277,18 @@ func (s TransactionRecurringImpl) GetAllByTreasureChest(ctx context.Context,
return transactionRecurrings, nil return transactionRecurrings, nil
} }
func (s TransactionRecurringImpl) Delete(ctx context.Context, user *types.User, id string) error { func (s ServiceImpl) Delete(ctx context.Context, user *auth_types.User, id string) error {
if user == nil { if user == nil {
return ErrUnauthorized return core.ErrUnauthorized
} }
uuid, err := uuid.Parse(id) uuid, err := uuid.Parse(id)
if err != nil { if err != nil {
slog.ErrorContext(ctx, "transactionRecurring delete", "err", err) slog.ErrorContext(ctx, "transactionRecurring delete", "err", err)
return fmt.Errorf("could not parse Id: %w", ErrBadRequest) return fmt.Errorf("could not parse Id: %w", core.ErrBadRequest)
} }
tx, err := s.db.BeginTxx(ctx, nil) tx, err := s.db.BeginTxx(ctx, nil)
err = db.TransformAndLogDbError(ctx, "transactionRecurring Delete", nil, err) err = core.TransformAndLogDbError(ctx, "transactionRecurring Delete", nil, err)
if err != nil { if err != nil {
return nil return nil
} }
@@ -295,21 +296,21 @@ func (s TransactionRecurringImpl) Delete(ctx context.Context, user *types.User,
_ = tx.Rollback() _ = tx.Rollback()
}() }()
var transactionRecurring types.TransactionRecurring var transactionRecurring TransactionRecurring
err = tx.GetContext(ctx, &transactionRecurring, `SELECT * FROM transaction_recurring WHERE user_id = ? AND id = ?`, user.Id, uuid) 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) err = core.TransformAndLogDbError(ctx, "transactionRecurring Delete", nil, err)
if err != nil { if err != nil {
return err return err
} }
r, err := tx.ExecContext(ctx, "DELETE FROM transaction_recurring WHERE id = ? AND user_id = ?", uuid, user.Id) 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) err = core.TransformAndLogDbError(ctx, "transactionRecurring Delete", r, err)
if err != nil { if err != nil {
return err return err
} }
err = tx.Commit() err = tx.Commit()
err = db.TransformAndLogDbError(ctx, "transactionRecurring Delete", nil, err) err = core.TransformAndLogDbError(ctx, "transactionRecurring Delete", nil, err)
if err != nil { if err != nil {
return err return err
} }
@@ -317,69 +318,13 @@ func (s TransactionRecurringImpl) Delete(ctx context.Context, user *types.User,
return nil return nil
} }
func (s TransactionRecurringImpl) GenerateTransactions(ctx context.Context) error { func (s ServiceImpl) validateAndEnrichTransactionRecurring(
now := s.clock.Now()
tx, err := s.db.BeginTxx(ctx, nil)
err = db.TransformAndLogDbError(ctx, "transactionRecurring GenerateTransactions", nil, err)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
recurringTransactions := make([]*types.TransactionRecurring, 0)
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,
Description: transactionRecurring.Description,
TreasureChestId: transactionRecurring.TreasureChestId,
Value: transactionRecurring.Value,
}
_, 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.ExecContext(ctx, `UPDATE transaction_recurring SET next_execution = ? WHERE id = ? AND user_id = ?`,
nextExecution, transactionRecurring.Id, user.Id)
err = db.TransformAndLogDbError(ctx, "transactionRecurring GenerateTransactions", r, err)
if err != nil {
return err
}
}
err = tx.Commit()
err = db.TransformAndLogDbError(ctx, "transactionRecurring GenerateTransactions", nil, err)
if err != nil {
return err
}
return nil
}
func (s TransactionRecurringImpl) validateAndEnrichTransactionRecurring(
ctx context.Context, ctx context.Context,
tx *sqlx.Tx, tx *sqlx.Tx,
oldTransactionRecurring *types.TransactionRecurring, oldTransactionRecurring *TransactionRecurring,
userId uuid.UUID, userId uuid.UUID,
input types.TransactionRecurringInput, input TransactionRecurringInput,
) (*types.TransactionRecurring, error) { ) (*TransactionRecurring, error) {
var ( var (
id uuid.UUID id uuid.UUID
accountUuid *uuid.UUID accountUuid *uuid.UUID
@@ -397,7 +342,7 @@ func (s TransactionRecurringImpl) validateAndEnrichTransactionRecurring(
if oldTransactionRecurring == nil { if oldTransactionRecurring == nil {
id, err = s.random.UUID(ctx) id, err = s.random.UUID(ctx)
if err != nil { if err != nil {
return nil, types.ErrInternal return nil, core.ErrInternal
} }
createdAt = s.clock.Now() createdAt = s.clock.Now()
createdBy = userId createdBy = userId
@@ -416,17 +361,17 @@ func (s TransactionRecurringImpl) validateAndEnrichTransactionRecurring(
temp, err := uuid.Parse(input.AccountId) temp, err := uuid.Parse(input.AccountId)
if err != nil { if err != nil {
slog.ErrorContext(ctx, "transactionRecurring validate", "err", err) slog.ErrorContext(ctx, "transactionRecurring validate", "err", err)
return nil, fmt.Errorf("could not parse accountId: %w", ErrBadRequest) return nil, fmt.Errorf("could not parse accountId: %w", core.ErrBadRequest)
} }
accountUuid = &temp accountUuid = &temp
err = tx.GetContext(ctx, &rowCount, `SELECT COUNT(*) FROM account WHERE id = ? AND user_id = ?`, accountUuid, userId) err = tx.GetContext(ctx, &rowCount, `SELECT COUNT(*) FROM account WHERE id = ? AND user_id = ?`, accountUuid, userId)
err = db.TransformAndLogDbError(ctx, "transactionRecurring validate", nil, err) err = core.TransformAndLogDbError(ctx, "transactionRecurring validate", nil, err)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if rowCount == 0 { if rowCount == 0 {
slog.ErrorContext(ctx, "transactionRecurring validate", "err", err) slog.ErrorContext(ctx, "transactionRecurring validate", "err", err)
return nil, fmt.Errorf("account not found: %w", ErrBadRequest) return nil, fmt.Errorf("account not found: %w", core.ErrBadRequest)
} }
hasAccount = true hasAccount = true
@@ -436,48 +381,48 @@ func (s TransactionRecurringImpl) validateAndEnrichTransactionRecurring(
temp, err := uuid.Parse(input.TreasureChestId) temp, err := uuid.Parse(input.TreasureChestId)
if err != nil { if err != nil {
slog.ErrorContext(ctx, "transactionRecurring validate", "err", err) slog.ErrorContext(ctx, "transactionRecurring validate", "err", err)
return nil, fmt.Errorf("could not parse treasureChestId: %w", ErrBadRequest) return nil, fmt.Errorf("could not parse treasureChestId: %w", core.ErrBadRequest)
} }
treasureChestUuid = &temp treasureChestUuid = &temp
var treasureChest types.TreasureChest var treasureChest treasure_chest_types.TreasureChest
err = tx.GetContext(ctx, &treasureChest, `SELECT * FROM treasure_chest WHERE id = ? AND user_id = ?`, treasureChestUuid, userId) err = tx.GetContext(ctx, &treasureChest, `SELECT * FROM treasure_chest WHERE id = ? AND user_id = ?`, treasureChestUuid, userId)
err = db.TransformAndLogDbError(ctx, "transactionRecurring validate", nil, err) err = core.TransformAndLogDbError(ctx, "transactionRecurring validate", nil, err)
if err != nil { if err != nil {
if errors.Is(err, db.ErrNotFound) { if errors.Is(err, core.ErrNotFound) {
return nil, fmt.Errorf("treasure chest not found: %w", ErrBadRequest) return nil, fmt.Errorf("treasure chest not found: %w", core.ErrBadRequest)
} }
return nil, err return nil, err
} }
if treasureChest.ParentId == nil { if treasureChest.ParentId == nil {
return nil, fmt.Errorf("treasure chest is a group: %w", ErrBadRequest) return nil, fmt.Errorf("treasure chest is a group: %w", core.ErrBadRequest)
} }
hasTreasureChest = true hasTreasureChest = true
} }
if !hasAccount && !hasTreasureChest { if !hasAccount && !hasTreasureChest {
slog.ErrorContext(ctx, "transactionRecurring validate", "err", err) slog.ErrorContext(ctx, "transactionRecurring validate", "err", err)
return nil, fmt.Errorf("either account or treasure chest is required: %w", ErrBadRequest) return nil, fmt.Errorf("either account or treasure chest is required: %w", core.ErrBadRequest)
} }
if hasAccount && hasTreasureChest { if hasAccount && hasTreasureChest {
slog.ErrorContext(ctx, "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) return nil, fmt.Errorf("either account or treasure chest is required, not both: %w", core.ErrBadRequest)
} }
valueFloat, err := strconv.ParseFloat(input.Value, 64) valueFloat, err := strconv.ParseFloat(input.Value, 64)
if err != nil { if err != nil {
slog.ErrorContext(ctx, "transactionRecurring validate", "err", err) slog.ErrorContext(ctx, "transactionRecurring validate", "err", err)
return nil, fmt.Errorf("could not parse value: %w", ErrBadRequest) return nil, fmt.Errorf("could not parse value: %w", core.ErrBadRequest)
} }
value := int64(math.Round(valueFloat * DECIMALS_MULTIPLIER)) value := int64(math.Round(valueFloat * DECIMALS_MULTIPLIER))
if input.Party != "" { if input.Party != "" {
err = validateString(input.Party, "party") err = core.ValidateString(input.Party, "party")
if err != nil { if err != nil {
return nil, err return nil, err
} }
} }
if input.Description != "" { if input.Description != "" {
err = validateString(input.Description, "description") err = core.ValidateString(input.Description, "description")
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -485,25 +430,25 @@ func (s TransactionRecurringImpl) validateAndEnrichTransactionRecurring(
intervalMonths, err = strconv.ParseInt(input.IntervalMonths, 10, 0) intervalMonths, err = strconv.ParseInt(input.IntervalMonths, 10, 0)
if err != nil { if err != nil {
slog.ErrorContext(ctx, "transactionRecurring validate", "err", err) slog.ErrorContext(ctx, "transactionRecurring validate", "err", err)
return nil, fmt.Errorf("could not parse intervalMonths: %w", ErrBadRequest) return nil, fmt.Errorf("could not parse intervalMonths: %w", core.ErrBadRequest)
} }
if intervalMonths < 1 { if intervalMonths < 1 {
slog.ErrorContext(ctx, "transactionRecurring validate", "err", err) slog.ErrorContext(ctx, "transactionRecurring validate", "err", err)
return nil, fmt.Errorf("intervalMonths needs to be greater than 0: %w", ErrBadRequest) return nil, fmt.Errorf("intervalMonths needs to be greater than 0: %w", core.ErrBadRequest)
} }
var nextExecution *time.Time = nil var nextExecution *time.Time = nil
if input.NextExecution != "" { if input.NextExecution != "" {
t, err := time.Parse("2006-01-02", input.NextExecution) t, err := time.Parse("2006-01-02", input.NextExecution)
if err != nil { if err != nil {
slog.ErrorContext(ctx, "transaction validate", "err", err) slog.ErrorContext(ctx, "transaction validate", "err", err)
return nil, fmt.Errorf("could not parse timestamp: %w", ErrBadRequest) return nil, fmt.Errorf("could not parse timestamp: %w", core.ErrBadRequest)
} }
t = time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, t.Location()) t = time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, t.Location())
nextExecution = &t nextExecution = &t
} }
transactionRecurring := types.TransactionRecurring{ transactionRecurring := TransactionRecurring{
Id: id, Id: id,
UserId: userId, UserId: userId,

View File

@@ -1,11 +1,13 @@
package transaction_recurring package transaction_recurring
import "fmt" import (
import "time" "fmt"
import "spend-sparrow/internal/template/svg" "spend-sparrow/internal/core"
import "spend-sparrow/internal/types" "spend-sparrow/internal/template/svg"
"time"
)
templ TransactionRecurringItems(transactionsRecurring []*types.TransactionRecurring, editId, accountId, treasureChestId string) { templ TransactionRecurringItems(transactionsRecurring []*TransactionRecurring, editId, accountId, treasureChestId string) {
<!-- Don't use table, because embedded forms are only valid for cells --> <!-- Don't use table, because embedded forms are only valid for cells -->
<div id="transaction-recurring" class="max-w-full grid gap-4 mt-10 grid-cols-[max-content_auto_auto_auto_auto_max-content] items-center text-xl"> <div id="transaction-recurring" class="max-w-full grid gap-4 mt-10 grid-cols-[max-content_auto_auto_auto_auto_max-content] items-center text-xl">
<span class="text-sm text-gray-500">Next Execution</span> <span class="text-sm text-gray-500">Next Execution</span>
@@ -27,7 +29,7 @@ templ TransactionRecurringItems(transactionsRecurring []*types.TransactionRecurr
</div> </div>
} }
templ TransactionRecurringItem(transactionRecurring *types.TransactionRecurring, accountId, treasureChestId string) { templ TransactionRecurringItem(transactionRecurring *TransactionRecurring, accountId, treasureChestId string) {
<p class="text-gray-600"> <p class="text-gray-600">
if transactionRecurring.NextExecution != nil { if transactionRecurring.NextExecution != nil {
{ transactionRecurring.NextExecution.Format("2006/01") } { transactionRecurring.NextExecution.Format("2006/01") }
@@ -53,9 +55,9 @@ templ TransactionRecurringItem(transactionRecurring *types.TransactionRecurring,
Every <span class="text-xl">{ transactionRecurring.IntervalMonths }</span> month(s) Every <span class="text-xl">{ transactionRecurring.IntervalMonths }</span> month(s)
</p> </p>
if transactionRecurring.Value < 0 { if transactionRecurring.Value < 0 {
<p class="text-right text-red-700">{ types.FormatEuros(transactionRecurring.Value) }</p> <p class="text-right text-red-700">{ core.FormatEuros(transactionRecurring.Value) }</p>
} else { } else {
<p class="text-right text-green-700">{ types.FormatEuros(transactionRecurring.Value) }</p> <p class="text-right text-green-700">{ core.FormatEuros(transactionRecurring.Value) }</p>
} }
<div class="flex gap-2"> <div class="flex gap-2">
<button <button
@@ -84,7 +86,7 @@ templ TransactionRecurringItem(transactionRecurring *types.TransactionRecurring,
</div> </div>
} }
templ EditTransactionRecurring(transactionRecurring *types.TransactionRecurring, accountId, treasureChestId string) { templ EditTransactionRecurring(transactionRecurring *TransactionRecurring, accountId, treasureChestId string) {
{{ {{
var ( var (
id string id string

View File

@@ -1,4 +1,4 @@
package types package transaction_recurring
import ( import (
"time" "time"

View File

@@ -1,131 +1,128 @@
package handler package treasure_chest
import ( import (
"net/http" "net/http"
"spend-sparrow/internal/handler/middleware" "spend-sparrow/internal/core"
"spend-sparrow/internal/service" "spend-sparrow/internal/transaction_recurring"
tr "spend-sparrow/internal/template/transaction_recurring" "spend-sparrow/internal/treasure_chest_types"
t "spend-sparrow/internal/template/treasurechest"
"spend-sparrow/internal/types"
"spend-sparrow/internal/utils"
"github.com/a-h/templ" "github.com/a-h/templ"
"github.com/google/uuid" "github.com/google/uuid"
) )
type TreasureChest interface { type Handler interface {
Handle(router *http.ServeMux) Handle(router *http.ServeMux)
} }
type TreasureChestImpl struct { type HandlerImpl struct {
s service.TreasureChest s Service
transactionRecurring service.TransactionRecurring transactionRecurring transaction_recurring.Service
r *Render r *core.Render
} }
func NewTreasureChest(s service.TreasureChest, transactionRecurring service.TransactionRecurring, r *Render) TreasureChest { func NewHandler(s Service, transactionRecurring transaction_recurring.Service, r *core.Render) Handler {
return TreasureChestImpl{ return HandlerImpl{
s: s, s: s,
transactionRecurring: transactionRecurring, transactionRecurring: transactionRecurring,
r: r, r: r,
} }
} }
func (h TreasureChestImpl) Handle(r *http.ServeMux) { func (h HandlerImpl) Handle(r *http.ServeMux) {
r.Handle("GET /treasurechest", h.handleTreasureChestPage()) r.Handle("GET /treasurechest", h.handleHandlerPage())
r.Handle("GET /treasurechest/{id}", h.handleTreasureChestItemComp()) r.Handle("GET /treasurechest/{id}", h.handleHandlerItemComp())
r.Handle("POST /treasurechest/{id}", h.handleUpdateTreasureChest()) r.Handle("POST /treasurechest/{id}", h.handleUpdateHandler())
r.Handle("DELETE /treasurechest/{id}", h.handleDeleteTreasureChest()) r.Handle("DELETE /treasurechest/{id}", h.handleDeleteHandler())
} }
func (h TreasureChestImpl) handleTreasureChestPage() http.HandlerFunc { func (h HandlerImpl) handleHandlerPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r) core.UpdateSpan(r)
user := middleware.GetUser(r) user := core.GetUser(r)
if user == nil { if user == nil {
utils.DoRedirect(w, r, "/auth/signin") core.DoRedirect(w, r, "/auth/signin")
return return
} }
treasureChests, err := h.s.GetAll(r.Context(), user) treasureChests, err := h.s.GetAll(r.Context(), user)
if err != nil { if err != nil {
handleError(w, r, err) core.HandleError(w, r, err)
return return
} }
transactionsRecurring, err := h.transactionRecurring.GetAll(r.Context(), user) transactionsRecurring, err := h.transactionRecurring.GetAll(r.Context(), user)
if err != nil { if err != nil {
handleError(w, r, err) core.HandleError(w, r, err)
return return
} }
monthlySums := h.calculateMonthlySums(treasureChests, transactionsRecurring) monthlySums := h.calculateMonthlySums(treasureChests, transactionsRecurring)
comp := t.TreasureChest(treasureChests, monthlySums) comp := TreasureChestComp(treasureChests, monthlySums)
h.r.RenderLayout(r, w, comp, user) h.r.RenderLayout(r, w, comp, user)
} }
} }
func (h TreasureChestImpl) handleTreasureChestItemComp() http.HandlerFunc { func (h HandlerImpl) handleHandlerItemComp() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r) core.UpdateSpan(r)
user := middleware.GetUser(r) user := core.GetUser(r)
if user == nil { if user == nil {
utils.DoRedirect(w, r, "/auth/signin") core.DoRedirect(w, r, "/auth/signin")
return return
} }
treasureChests, err := h.s.GetAll(r.Context(), user) treasureChests, err := h.s.GetAll(r.Context(), user)
if err != nil { if err != nil {
handleError(w, r, err) core.HandleError(w, r, err)
return return
} }
id := r.PathValue("id") id := r.PathValue("id")
if id == "new" { if id == "new" {
comp := t.EditTreasureChest(nil, treasureChests, nil) comp := EditTreasureChest(nil, treasureChests, nil)
h.r.Render(r, w, comp) h.r.Render(r, w, comp)
return return
} }
treasureChest, err := h.s.Get(r.Context(), user, id) treasureChest, err := h.s.Get(r.Context(), user, id)
if err != nil { if err != nil {
handleError(w, r, err) core.HandleError(w, r, err)
return return
} }
transactionsRecurring, err := h.transactionRecurring.GetAllByTreasureChest(r.Context(), user, treasureChest.Id.String()) transactionsRecurring, err := h.transactionRecurring.GetAllByTreasureChest(r.Context(), user, treasureChest.Id.String())
if err != nil { if err != nil {
handleError(w, r, err) core.HandleError(w, r, err)
return return
} }
transactionsRec := tr.TransactionRecurringItems(transactionsRecurring, "", "", treasureChest.Id.String()) transactionsRec := transaction_recurring.TransactionRecurringItems(transactionsRecurring, "", "", treasureChest.Id.String())
var comp templ.Component var comp templ.Component
if r.URL.Query().Get("edit") == "true" { if r.URL.Query().Get("edit") == "true" {
comp = t.EditTreasureChest(treasureChest, treasureChests, transactionsRec) comp = EditTreasureChest(treasureChest, treasureChests, transactionsRec)
} else { } else {
monthlySums := h.calculateMonthlySums(treasureChests, transactionsRecurring) monthlySums := h.calculateMonthlySums(treasureChests, transactionsRecurring)
comp = t.TreasureChestItem(treasureChest, monthlySums) comp = TreasureChestItem(treasureChest, monthlySums)
} }
h.r.Render(r, w, comp) h.r.Render(r, w, comp)
} }
} }
func (h TreasureChestImpl) handleUpdateTreasureChest() http.HandlerFunc { func (h HandlerImpl) handleUpdateHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r) core.UpdateSpan(r)
user := middleware.GetUser(r) user := core.GetUser(r)
if user == nil { if user == nil {
utils.DoRedirect(w, r, "/auth/signin") core.DoRedirect(w, r, "/auth/signin")
return return
} }
var ( var (
treasureChest *types.TreasureChest treasureChest *treasure_chest_types.TreasureChest
err error err error
) )
id := r.PathValue("id") id := r.PathValue("id")
@@ -134,38 +131,38 @@ func (h TreasureChestImpl) handleUpdateTreasureChest() http.HandlerFunc {
if id == "new" { if id == "new" {
treasureChest, err = h.s.Add(r.Context(), user, parentId, name) treasureChest, err = h.s.Add(r.Context(), user, parentId, name)
if err != nil { if err != nil {
handleError(w, r, err) core.HandleError(w, r, err)
return return
} }
} else { } else {
treasureChest, err = h.s.Update(r.Context(), user, id, parentId, name) treasureChest, err = h.s.Update(r.Context(), user, id, parentId, name)
if err != nil { if err != nil {
handleError(w, r, err) core.HandleError(w, r, err)
return return
} }
} }
transactionsRecurring, err := h.transactionRecurring.GetAllByTreasureChest(r.Context(), user, treasureChest.Id.String()) transactionsRecurring, err := h.transactionRecurring.GetAllByTreasureChest(r.Context(), user, treasureChest.Id.String())
if err != nil { if err != nil {
handleError(w, r, err) core.HandleError(w, r, err)
return return
} }
treasureChests := make([]*types.TreasureChest, 1) treasureChests := make([]*treasure_chest_types.TreasureChest, 1)
treasureChests[0] = treasureChest treasureChests[0] = treasureChest
monthlySums := h.calculateMonthlySums(treasureChests, transactionsRecurring) monthlySums := h.calculateMonthlySums(treasureChests, transactionsRecurring)
comp := t.TreasureChestItem(treasureChest, monthlySums) comp := TreasureChestItem(treasureChest, monthlySums)
h.r.Render(r, w, comp) h.r.Render(r, w, comp)
} }
} }
func (h TreasureChestImpl) handleDeleteTreasureChest() http.HandlerFunc { func (h HandlerImpl) handleDeleteHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
updateSpan(r) core.UpdateSpan(r)
user := middleware.GetUser(r) user := core.GetUser(r)
if user == nil { if user == nil {
utils.DoRedirect(w, r, "/auth/signin") core.DoRedirect(w, r, "/auth/signin")
return return
} }
@@ -173,15 +170,15 @@ func (h TreasureChestImpl) handleDeleteTreasureChest() http.HandlerFunc {
err := h.s.Delete(r.Context(), user, id) err := h.s.Delete(r.Context(), user, id)
if err != nil { if err != nil {
handleError(w, r, err) core.HandleError(w, r, err)
return return
} }
} }
} }
func (h TreasureChestImpl) calculateMonthlySums( func (h HandlerImpl) calculateMonthlySums(
treasureChests []*types.TreasureChest, treasureChests []*treasure_chest_types.TreasureChest,
transactionsRecurring []*types.TransactionRecurring, transactionsRecurring []*transaction_recurring.TransactionRecurring,
) map[uuid.UUID]int64 { ) map[uuid.UUID]int64 {
monthlySums := make(map[uuid.UUID]int64) monthlySums := make(map[uuid.UUID]int64)
for _, tc := range treasureChests { for _, tc := range treasureChests {

View File

@@ -1,4 +1,4 @@
package service package treasure_chest
import ( import (
"context" "context"
@@ -6,46 +6,47 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"slices" "slices"
"spend-sparrow/internal/db" "spend-sparrow/internal/auth_types"
"spend-sparrow/internal/types" "spend-sparrow/internal/core"
"spend-sparrow/internal/treasure_chest_types"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
) )
type TreasureChest interface { type Service interface {
Add(ctx context.Context, user *types.User, parentId, name string) (*types.TreasureChest, error) Add(ctx context.Context, user *auth_types.User, parentId, name string) (*treasure_chest_types.TreasureChest, error)
Update(ctx context.Context, user *types.User, id, parentId, name string) (*types.TreasureChest, error) Update(ctx context.Context, user *auth_types.User, id, parentId, name string) (*treasure_chest_types.TreasureChest, error)
Get(ctx context.Context, user *types.User, id string) (*types.TreasureChest, error) Get(ctx context.Context, user *auth_types.User, id string) (*treasure_chest_types.TreasureChest, error)
GetAll(ctx context.Context, user *types.User) ([]*types.TreasureChest, error) GetAll(ctx context.Context, user *auth_types.User) ([]*treasure_chest_types.TreasureChest, error)
Delete(ctx context.Context, user *types.User, id string) error Delete(ctx context.Context, user *auth_types.User, id string) error
} }
type TreasureChestImpl struct { type ServiceImpl struct {
db *sqlx.DB db *sqlx.DB
clock Clock clock core.Clock
random Random random core.Random
} }
func NewTreasureChest(db *sqlx.DB, random Random, clock Clock) TreasureChest { func NewService(db *sqlx.DB, random core.Random, clock core.Clock) Service {
return TreasureChestImpl{ return ServiceImpl{
db: db, db: db,
clock: clock, clock: clock,
random: random, random: random,
} }
} }
func (s TreasureChestImpl) Add(ctx context.Context, user *types.User, parentId, name string) (*types.TreasureChest, error) { func (s ServiceImpl) Add(ctx context.Context, user *auth_types.User, parentId, name string) (*treasure_chest_types.TreasureChest, error) {
if user == nil { if user == nil {
return nil, ErrUnauthorized return nil, core.ErrUnauthorized
} }
newId, err := s.random.UUID(ctx) newId, err := s.random.UUID(ctx)
if err != nil { if err != nil {
return nil, types.ErrInternal return nil, core.ErrInternal
} }
err = validateString(name, "name") err = core.ValidateString(name, "name")
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -57,12 +58,12 @@ func (s TreasureChestImpl) Add(ctx context.Context, user *types.User, parentId,
return nil, err return nil, err
} }
if parent.ParentId != nil { if parent.ParentId != nil {
return nil, fmt.Errorf("only a depth of 1 allowed: %w", ErrBadRequest) return nil, fmt.Errorf("only a depth of 1 allowed: %w", core.ErrBadRequest)
} }
parentUuid = &parent.Id parentUuid = &parent.Id
} }
treasureChest := &types.TreasureChest{ treasureChest := &treasure_chest_types.TreasureChest{
Id: newId, Id: newId,
ParentId: parentUuid, ParentId: parentUuid,
UserId: user.Id, UserId: user.Id,
@@ -80,7 +81,7 @@ func (s TreasureChestImpl) Add(ctx context.Context, user *types.User, parentId,
r, err := s.db.NamedExecContext(ctx, ` r, err := s.db.NamedExecContext(ctx, `
INSERT INTO treasure_chest (id, parent_id, user_id, name, current_balance, created_at, created_by) 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) VALUES (:id, :parent_id, :user_id, :name, :current_balance, :created_at, :created_by)`, treasureChest)
err = db.TransformAndLogDbError(ctx, "treasureChest Insert", r, err) err = core.TransformAndLogDbError(ctx, "treasureChest Insert", r, err)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -88,22 +89,22 @@ func (s TreasureChestImpl) Add(ctx context.Context, user *types.User, parentId,
return treasureChest, nil return treasureChest, nil
} }
func (s TreasureChestImpl) Update(ctx context.Context, user *types.User, idStr, parentId, name string) (*types.TreasureChest, error) { func (s ServiceImpl) Update(ctx context.Context, user *auth_types.User, idStr, parentId, name string) (*treasure_chest_types.TreasureChest, error) {
if user == nil { if user == nil {
return nil, ErrUnauthorized return nil, core.ErrUnauthorized
} }
err := validateString(name, "name") err := core.ValidateString(name, "name")
if err != nil { if err != nil {
return nil, err return nil, err
} }
id, err := uuid.Parse(idStr) id, err := uuid.Parse(idStr)
if err != nil { if err != nil {
slog.ErrorContext(ctx, "treasureChest update", "err", err) slog.ErrorContext(ctx, "treasureChest update", "err", err)
return nil, fmt.Errorf("could not parse Id: %w", ErrBadRequest) return nil, fmt.Errorf("could not parse Id: %w", core.ErrBadRequest)
} }
tx, err := s.db.BeginTxx(ctx, nil) tx, err := s.db.BeginTxx(ctx, nil)
err = db.TransformAndLogDbError(ctx, "treasureChest Update", nil, err) err = core.TransformAndLogDbError(ctx, "treasureChest Update", nil, err)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -111,14 +112,14 @@ func (s TreasureChestImpl) Update(ctx context.Context, user *types.User, idStr,
_ = tx.Rollback() _ = tx.Rollback()
}() }()
treasureChest := &types.TreasureChest{} treasureChest := &treasure_chest_types.TreasureChest{}
err = tx.GetContext(ctx, treasureChest, `SELECT * FROM treasure_chest WHERE user_id = ? AND id = ?`, user.Id, id) 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) err = core.TransformAndLogDbError(ctx, "treasureChest Update", nil, err)
if err != nil { if err != nil {
if errors.Is(err, db.ErrNotFound) { if errors.Is(err, core.ErrNotFound) {
return nil, fmt.Errorf("treasureChest %v not found: %w", idStr, err) return nil, fmt.Errorf("treasureChest %v not found: %w", idStr, err)
} }
return nil, types.ErrInternal return nil, core.ErrInternal
} }
var parentUuid *uuid.UUID var parentUuid *uuid.UUID
@@ -129,12 +130,12 @@ func (s TreasureChestImpl) Update(ctx context.Context, user *types.User, idStr,
} }
var childCount int var childCount int
err = tx.GetContext(ctx, &childCount, `SELECT COUNT(*) FROM treasure_chest WHERE user_id = ? AND parent_id = ?`, user.Id, id) 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) err = core.TransformAndLogDbError(ctx, "treasureChest Update", nil, err)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if parent.ParentId != nil || childCount > 0 { if parent.ParentId != nil || childCount > 0 {
return nil, fmt.Errorf("only one level allowed: %w", ErrBadRequest) return nil, fmt.Errorf("only one level allowed: %w", core.ErrBadRequest)
} }
parentUuid = &parent.Id parentUuid = &parent.Id
@@ -156,13 +157,13 @@ func (s TreasureChestImpl) Update(ctx context.Context, user *types.User, idStr,
updated_by = :updated_by updated_by = :updated_by
WHERE id = :id WHERE id = :id
AND user_id = :user_id`, treasureChest) AND user_id = :user_id`, treasureChest)
err = db.TransformAndLogDbError(ctx, "treasureChest Update", r, err) err = core.TransformAndLogDbError(ctx, "treasureChest Update", r, err)
if err != nil { if err != nil {
return nil, err return nil, err
} }
err = tx.Commit() err = tx.Commit()
err = db.TransformAndLogDbError(ctx, "treasureChest Update", nil, err) err = core.TransformAndLogDbError(ctx, "treasureChest Update", nil, err)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -170,56 +171,56 @@ func (s TreasureChestImpl) Update(ctx context.Context, user *types.User, idStr,
return treasureChest, nil return treasureChest, nil
} }
func (s TreasureChestImpl) Get(ctx context.Context, user *types.User, id string) (*types.TreasureChest, error) { func (s ServiceImpl) Get(ctx context.Context, user *auth_types.User, id string) (*treasure_chest_types.TreasureChest, error) {
if user == nil { if user == nil {
return nil, ErrUnauthorized return nil, core.ErrUnauthorized
} }
uuid, err := uuid.Parse(id) uuid, err := uuid.Parse(id)
if err != nil { if err != nil {
slog.ErrorContext(ctx, "treasureChest get", "err", err) slog.ErrorContext(ctx, "treasureChest get", "err", err)
return nil, fmt.Errorf("could not parse Id: %w", ErrBadRequest) return nil, fmt.Errorf("could not parse Id: %w", core.ErrBadRequest)
} }
var treasureChest types.TreasureChest var treasureChest treasure_chest_types.TreasureChest
err = s.db.GetContext(ctx, &treasureChest, `SELECT * FROM treasure_chest WHERE user_id = ? AND id = ?`, user.Id, uuid) 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) err = core.TransformAndLogDbError(ctx, "treasureChest Get", nil, err)
if err != nil { if err != nil {
if errors.Is(err, db.ErrNotFound) { if errors.Is(err, core.ErrNotFound) {
return nil, fmt.Errorf("treasureChest %v not found: %w", id, err) return nil, fmt.Errorf("treasureChest %v not found: %w", id, err)
} }
return nil, types.ErrInternal return nil, core.ErrInternal
} }
return &treasureChest, nil return &treasureChest, nil
} }
func (s TreasureChestImpl) GetAll(ctx context.Context, user *types.User) ([]*types.TreasureChest, error) { func (s ServiceImpl) GetAll(ctx context.Context, user *auth_types.User) ([]*treasure_chest_types.TreasureChest, error) {
if user == nil { if user == nil {
return nil, ErrUnauthorized return nil, core.ErrUnauthorized
} }
treasureChests := make([]*types.TreasureChest, 0) treasureChests := make([]*treasure_chest_types.TreasureChest, 0)
err := s.db.SelectContext(ctx, &treasureChests, `SELECT * FROM treasure_chest WHERE user_id = ?`, user.Id) err := s.db.SelectContext(ctx, &treasureChests, `SELECT * FROM treasure_chest WHERE user_id = ?`, user.Id)
err = db.TransformAndLogDbError(ctx, "treasureChest GetAll", nil, err) err = core.TransformAndLogDbError(ctx, "treasureChest GetAll", nil, err)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return sortTreasureChests(treasureChests), nil return SortTreasureChests(treasureChests), nil
} }
func (s TreasureChestImpl) Delete(ctx context.Context, user *types.User, idStr string) error { func (s ServiceImpl) Delete(ctx context.Context, user *auth_types.User, idStr string) error {
if user == nil { if user == nil {
return ErrUnauthorized return core.ErrUnauthorized
} }
id, err := uuid.Parse(idStr) id, err := uuid.Parse(idStr)
if err != nil { if err != nil {
slog.ErrorContext(ctx, "treasureChest delete", "err", err) slog.ErrorContext(ctx, "treasureChest delete", "err", err)
return fmt.Errorf("could not parse Id: %w", ErrBadRequest) return fmt.Errorf("could not parse Id: %w", core.ErrBadRequest)
} }
tx, err := s.db.BeginTxx(ctx, nil) tx, err := s.db.BeginTxx(ctx, nil)
err = db.TransformAndLogDbError(ctx, "treasureChest Delete", nil, err) err = core.TransformAndLogDbError(ctx, "treasureChest Delete", nil, err)
if err != nil { if err != nil {
return nil return nil
} }
@@ -229,47 +230,47 @@ func (s TreasureChestImpl) Delete(ctx context.Context, user *types.User, idStr s
childCount := 0 childCount := 0
err = tx.GetContext(ctx, &childCount, `SELECT COUNT(*) FROM treasure_chest WHERE user_id = ? AND parent_id = ?`, user.Id, id) 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) err = core.TransformAndLogDbError(ctx, "treasureChest Delete", nil, err)
if err != nil { if err != nil {
return err return err
} }
if childCount > 0 { if childCount > 0 {
return fmt.Errorf("treasure chest has children: %w", ErrBadRequest) return fmt.Errorf("treasure chest has children: %w", core.ErrBadRequest)
} }
transactionsCount := 0 transactionsCount := 0
err = tx.GetContext(ctx, &transactionsCount, err = tx.GetContext(ctx, &transactionsCount,
`SELECT COUNT(*) FROM "transaction" WHERE user_id = ? AND treasure_chest_id = ?`, `SELECT COUNT(*) FROM "transaction" WHERE user_id = ? AND treasure_chest_id = ?`,
user.Id, id) user.Id, id)
err = db.TransformAndLogDbError(ctx, "treasureChest Delete", nil, err) err = core.TransformAndLogDbError(ctx, "treasureChest Delete", nil, err)
if err != nil { if err != nil {
return err return err
} }
if transactionsCount > 0 { if transactionsCount > 0 {
return fmt.Errorf("treasure chest has transactions: %w", ErrBadRequest) return fmt.Errorf("treasure chest has transactions: %w", core.ErrBadRequest)
} }
recurringCount := 0 recurringCount := 0
err = tx.GetContext(ctx, &recurringCount, ` err = tx.GetContext(ctx, &recurringCount, `
SELECT COUNT(*) FROM transaction_recurring WHERE user_id = ? AND treasure_chest_id = ?`, SELECT COUNT(*) FROM transaction_recurring WHERE user_id = ? AND treasure_chest_id = ?`,
user.Id, id) user.Id, id)
err = db.TransformAndLogDbError(ctx, "treasureChest Delete", nil, err) err = core.TransformAndLogDbError(ctx, "treasureChest Delete", nil, err)
if err != nil { if err != nil {
return err return err
} }
if recurringCount > 0 { if recurringCount > 0 {
return fmt.Errorf("cannot delete treasure chest with existing recurring transactions: %w", ErrBadRequest) return fmt.Errorf("cannot delete treasure chest with existing recurring transactions: %w", core.ErrBadRequest)
} }
r, err := tx.ExecContext(ctx, `DELETE FROM treasure_chest WHERE id = ? AND user_id = ?`, id, user.Id) 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) err = core.TransformAndLogDbError(ctx, "treasureChest Delete", r, err)
if err != nil { if err != nil {
return err return err
} }
err = tx.Commit() err = tx.Commit()
err = db.TransformAndLogDbError(ctx, "treasureChest Delete", nil, err) err = core.TransformAndLogDbError(ctx, "treasureChest Delete", nil, err)
if err != nil { if err != nil {
return err return err
} }
@@ -277,12 +278,12 @@ func (s TreasureChestImpl) Delete(ctx context.Context, user *types.User, idStr s
return nil return nil
} }
func sortTreasureChests(nodes []*types.TreasureChest) []*types.TreasureChest { func SortTreasureChests(nodes []*treasure_chest_types.TreasureChest) []*treasure_chest_types.TreasureChest {
var ( var (
roots []*types.TreasureChest roots []*treasure_chest_types.TreasureChest
) )
children := make(map[uuid.UUID][]*types.TreasureChest) children := make(map[uuid.UUID][]*treasure_chest_types.TreasureChest)
result := make([]*types.TreasureChest, 0) result := make([]*treasure_chest_types.TreasureChest, 0)
for _, node := range nodes { for _, node := range nodes {
if node.ParentId == nil { if node.ParentId == nil {
@@ -292,7 +293,7 @@ func sortTreasureChests(nodes []*types.TreasureChest) []*types.TreasureChest {
} }
} }
slices.SortFunc(roots, func(a, b *types.TreasureChest) int { slices.SortFunc(roots, func(a, b *treasure_chest_types.TreasureChest) int {
return compareStrings(a.Name, b.Name) return compareStrings(a.Name, b.Name)
}) })
@@ -301,7 +302,7 @@ func sortTreasureChests(nodes []*types.TreasureChest) []*types.TreasureChest {
childList := children[root.Id] childList := children[root.Id]
slices.SortFunc(childList, func(a, b *types.TreasureChest) int { slices.SortFunc(childList, func(a, b *treasure_chest_types.TreasureChest) int {
return compareStrings(a.Name, b.Name) return compareStrings(a.Name, b.Name)
}) })
result = append(result, childList...) result = append(result, childList...)

View File

@@ -1,10 +1,13 @@
package treasurechest package treasure_chest
import "spend-sparrow/internal/template/svg" import (
import "spend-sparrow/internal/types" "github.com/google/uuid"
import "github.com/google/uuid" "spend-sparrow/internal/core"
"spend-sparrow/internal/template/svg"
"spend-sparrow/internal/treasure_chest_types"
)
templ TreasureChest(treasureChests []*types.TreasureChest, monthlySums map[uuid.UUID]int64) { templ TreasureChestComp(treasureChests []*treasure_chest_types.TreasureChest, monthlySums map[uuid.UUID]int64) {
<div class="max-w-6xl mt-10 mx-auto"> <div class="max-w-6xl mt-10 mx-auto">
<button <button
hx-get="/treasurechest/new" hx-get="/treasurechest/new"
@@ -12,7 +15,9 @@ templ TreasureChest(treasureChests []*types.TreasureChest, monthlySums map[uuid.
hx-swap="afterbegin" hx-swap="afterbegin"
class="ml-auto text-center button button-primary px-2 flex items-center gap-2" class="ml-auto text-center button button-primary px-2 flex items-center gap-2"
> >
@svg.Plus() <div class="w-3">
@svg.Plus()
</div>
New Treasure Chest New Treasure Chest
</button> </button>
<div id="treasurechest-items" class="my-6 flex flex-col"> <div id="treasurechest-items" class="my-6 flex flex-col">
@@ -23,30 +28,30 @@ templ TreasureChest(treasureChests []*types.TreasureChest, monthlySums map[uuid.
</div> </div>
} }
templ EditTreasureChest(treasureChest *types.TreasureChest, parents []*types.TreasureChest, transactionsRecurring templ.Component) { templ EditTreasureChest(treasureChest *treasure_chest_types.TreasureChest, parents []*treasure_chest_types.TreasureChest, transactionsRecurring templ.Component) {
{{ {{
var ( var (
id string id string
name string name string
parentId uuid.UUID parentId uuid.UUID
cancelUrl string cancelUrl string
) )
indentation := " mt-10" indentation := " mt-10"
if treasureChest == nil { if treasureChest == nil {
id = "new" id = "new"
name = "" name = ""
parentId = uuid.Nil parentId = uuid.Nil
cancelUrl = "/empty" cancelUrl = "/empty"
} else { } else {
id = treasureChest.Id.String() id = treasureChest.Id.String()
name = treasureChest.Name name = treasureChest.Name
if treasureChest.ParentId != nil { if treasureChest.ParentId != nil {
parentId = *treasureChest.ParentId parentId = *treasureChest.ParentId
indentation = " mt-2 ml-14" indentation = " mt-2 ml-14"
}
cancelUrl = "/treasurechest/" + id
} }
cancelUrl = "/treasurechest/" + id
}
}} }}
<div id={ "treasurechest-" + id } class={ "border-1 border-gray-300 p-4 bg-gray-50 rounded-lg" + indentation }> <div id={ "treasurechest-" + id } class={ "border-1 border-gray-300 p-4 bg-gray-50 rounded-lg" + indentation }>
<form <form
@@ -106,7 +111,9 @@ templ EditTreasureChest(treasureChest *types.TreasureChest, parents []*types.Tre
hx-swap="outerHTML" hx-swap="outerHTML"
class="button button-primary ml-auto px-2 flex items-center gap-2" class="button button-primary ml-auto px-2 flex items-center gap-2"
> >
@svg.Plus() <div class="w-3">
@svg.Plus()
</div>
<p>New Monthly Transaction</p> <p>New Monthly Transaction</p>
</button> </button>
</div> </div>
@@ -115,30 +122,30 @@ templ EditTreasureChest(treasureChest *types.TreasureChest, parents []*types.Tre
</div> </div>
} }
templ TreasureChestItem(treasureChest *types.TreasureChest, monthlySums map[uuid.UUID]int64) { templ TreasureChestItem(treasureChest *treasure_chest_types.TreasureChest, monthlySums map[uuid.UUID]int64) {
{{ {{
var indentation string var indentation string
viewTransactions := "" viewTransactions := ""
if treasureChest.ParentId != nil { if treasureChest.ParentId != nil {
indentation = " mt-2 ml-14" indentation = " mt-2 ml-14"
} else { } else {
indentation = " mt-10" indentation = " mt-10"
viewTransactions = "hidden" viewTransactions = "hidden"
} }
}} }}
<div id={ "treasurechest-" + treasureChest.Id.String() } class={ "border-1 border-gray-300 p-4 bg-gray-50 rounded-lg" + indentation }> <div id={ "treasurechest-" + treasureChest.Id.String() } class={ "border-1 border-gray-300 p-4 bg-gray-50 rounded-lg" + indentation }>
<div class="text-xl flex justify-end items-center gap-4"> <div class="text-xl flex justify-end items-center gap-4">
<p class="mr-auto">{ treasureChest.Name }</p> <p class="mr-auto">{ treasureChest.Name }</p>
<p class="mr-20 text-gray-600"> <p class="mr-20 text-gray-600">
if treasureChest.ParentId != nil { if treasureChest.ParentId != nil {
+ { types.FormatEuros(monthlySums[treasureChest.Id]) } <span class="text-gray-500 text-sm">&nbsp;per month</span> + { core.FormatEuros(monthlySums[treasureChest.Id]) } <span class="text-gray-500 text-sm">&nbsp;per month</span>
} }
</p> </p>
if treasureChest.ParentId != nil { if treasureChest.ParentId != nil {
if treasureChest.CurrentBalance < 0 { if treasureChest.CurrentBalance < 0 {
<p class="mr-20 min-w-20 text-right text-red-700">{ types.FormatEuros(treasureChest.CurrentBalance) }</p> <p class="mr-20 min-w-20 text-right text-red-700">{ core.FormatEuros(treasureChest.CurrentBalance) }</p>
} else { } else {
<p class="mr-20 min-w-20 text-right text-green-700">{ types.FormatEuros(treasureChest.CurrentBalance) }</p> <p class="mr-20 min-w-20 text-right text-green-700">{ core.FormatEuros(treasureChest.CurrentBalance) }</p>
} }
} }
<a <a
@@ -177,8 +184,8 @@ templ TreasureChestItem(treasureChest *types.TreasureChest, monthlySums map[uuid
</div> </div>
} }
func filterNoChildNoSelf(nodes []*types.TreasureChest, selfId string) []*types.TreasureChest { func filterNoChildNoSelf(nodes []*treasure_chest_types.TreasureChest, selfId string) []*treasure_chest_types.TreasureChest {
var result []*types.TreasureChest var result []*treasure_chest_types.TreasureChest
for _, node := range nodes { for _, node := range nodes {
if node.ParentId == nil && node.Id.String() != selfId { if node.ParentId == nil && node.Id.String() != selfId {

View File

@@ -1,4 +1,4 @@
package types package treasure_chest_types
import ( import (
"time" "time"

View File

@@ -1,10 +0,0 @@
package types
import (
"errors"
)
var (
ErrInternal = errors.New("internal server error")
ErrUnauthorized = errors.New("you are not authorized to perform this action")
)

View File

@@ -22,8 +22,11 @@ func main() {
return return
} }
db, err := otelsqlx.Open("sqlite3", "./data/spend-sparrow.db?_journal_mode=WAL", db, err := otelsqlx.Open(
otelsql.WithAttributes(semconv.DBSystemSqlite)) "sqlite3",
"./data/spend-sparrow.db?_journal_mode=WAL",
otelsql.WithAttributes(semconv.DBSystemSqlite),
)
if err != nil { if err != nil {
slog.ErrorContext(ctx, "Could not open Database data.db", "err", err) slog.ErrorContext(ctx, "Could not open Database data.db", "err", err)
return return

View File

@@ -0,0 +1,14 @@
CREATE TABLE "budget" (
id TEXT NOT NULL UNIQUE PRIMARY KEY,
user_id TEXT NOT NULL,
description TEXT NOT NULL,
value INTEGER NOT NULL,
created_at DATETIME NOT NULL,
created_by TEXT NOT NULL,
updated_at DATETIME,
updated_by TEXT
) WITHOUT ROWID;

View File

@@ -0,0 +1 @@
ALTER TABLE "budget" RENAME COLUMN description TO name;

13
migration/012_tag.up.sql Normal file
View File

@@ -0,0 +1,13 @@
CREATE TABLE "tag" (
id TEXT NOT NULL UNIQUE PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
created_at DATETIME NOT NULL,
created_by TEXT NOT NULL,
updated_at DATETIME,
updated_by TEXT
) WITHOUT ROWID;

417
package-lock.json generated
View File

@@ -9,23 +9,10 @@
"version": "1.0.0", "version": "1.0.0",
"license": "ISC", "license": "ISC",
"devDependencies": { "devDependencies": {
"@tailwindcss/cli": "4.1.12", "@tailwindcss/cli": "4.1.18",
"echarts": "6.0.0", "echarts": "6.0.0",
"htmx.org": "2.0.6", "htmx.org": "2.0.8",
"tailwindcss": "4.1.12" "tailwindcss": "4.1.18"
}
},
"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"
} }
}, },
"node_modules/@jridgewell/gen-mapping": { "node_modules/@jridgewell/gen-mapping": {
@@ -61,9 +48,9 @@
} }
}, },
"node_modules/@jridgewell/sourcemap-codec": { "node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.0", "version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@@ -388,73 +375,68 @@
} }
}, },
"node_modules/@tailwindcss/cli": { "node_modules/@tailwindcss/cli": {
"version": "4.1.12", "version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.1.12.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.1.18.tgz",
"integrity": "sha512-2PyJ5MGh/6JPS+cEaAq6MGDx3UemkX/mJt+/phm7/VOpycpecwNnHuFZbbgx6TNK/aIjvFOhhTVlappM7tmqvQ==", "integrity": "sha512-sMZ+lZbDyxwjD2E0L7oRUjJ01Ffjtme5OtjvvnC+cV4CEDcbqzbp25TCpxHj6kWLU9+DlqJOiNgSOgctC2aZmg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@parcel/watcher": "^2.5.1", "@parcel/watcher": "^2.5.1",
"@tailwindcss/node": "4.1.12", "@tailwindcss/node": "4.1.18",
"@tailwindcss/oxide": "4.1.12", "@tailwindcss/oxide": "4.1.18",
"enhanced-resolve": "^5.18.3", "enhanced-resolve": "^5.18.3",
"mri": "^1.2.0", "mri": "^1.2.0",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
"tailwindcss": "4.1.12" "tailwindcss": "4.1.18"
}, },
"bin": { "bin": {
"tailwindcss": "dist/index.mjs" "tailwindcss": "dist/index.mjs"
} }
}, },
"node_modules/@tailwindcss/node": { "node_modules/@tailwindcss/node": {
"version": "4.1.12", "version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.12.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz",
"integrity": "sha512-3hm9brwvQkZFe++SBt+oLjo4OLDtkvlE8q2WalaD/7QWaeM7KEJbAiY/LJZUaCs7Xa8aUu4xy3uoyX4q54UVdQ==", "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/remapping": "^2.3.4", "@jridgewell/remapping": "^2.3.4",
"enhanced-resolve": "^5.18.3", "enhanced-resolve": "^5.18.3",
"jiti": "^2.5.1", "jiti": "^2.6.1",
"lightningcss": "1.30.1", "lightningcss": "1.30.2",
"magic-string": "^0.30.17", "magic-string": "^0.30.21",
"source-map-js": "^1.2.1", "source-map-js": "^1.2.1",
"tailwindcss": "4.1.12" "tailwindcss": "4.1.18"
} }
}, },
"node_modules/@tailwindcss/oxide": { "node_modules/@tailwindcss/oxide": {
"version": "4.1.12", "version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.12.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz",
"integrity": "sha512-gM5EoKHW/ukmlEtphNwaGx45fGoEmP10v51t9unv55voWh6WrOL19hfuIdo2FjxIaZzw776/BUQg7Pck++cIVw==", "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==",
"dev": true, "dev": true,
"hasInstallScript": true,
"license": "MIT", "license": "MIT",
"dependencies": {
"detect-libc": "^2.0.4",
"tar": "^7.4.3"
},
"engines": { "engines": {
"node": ">= 10" "node": ">= 10"
}, },
"optionalDependencies": { "optionalDependencies": {
"@tailwindcss/oxide-android-arm64": "4.1.12", "@tailwindcss/oxide-android-arm64": "4.1.18",
"@tailwindcss/oxide-darwin-arm64": "4.1.12", "@tailwindcss/oxide-darwin-arm64": "4.1.18",
"@tailwindcss/oxide-darwin-x64": "4.1.12", "@tailwindcss/oxide-darwin-x64": "4.1.18",
"@tailwindcss/oxide-freebsd-x64": "4.1.12", "@tailwindcss/oxide-freebsd-x64": "4.1.18",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.12", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18",
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.12", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18",
"@tailwindcss/oxide-linux-arm64-musl": "4.1.12", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18",
"@tailwindcss/oxide-linux-x64-gnu": "4.1.12", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18",
"@tailwindcss/oxide-linux-x64-musl": "4.1.12", "@tailwindcss/oxide-linux-x64-musl": "4.1.18",
"@tailwindcss/oxide-wasm32-wasi": "4.1.12", "@tailwindcss/oxide-wasm32-wasi": "4.1.18",
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.12", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18",
"@tailwindcss/oxide-win32-x64-msvc": "4.1.12" "@tailwindcss/oxide-win32-x64-msvc": "4.1.18"
} }
}, },
"node_modules/@tailwindcss/oxide-android-arm64": { "node_modules/@tailwindcss/oxide-android-arm64": {
"version": "4.1.12", "version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.12.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz",
"integrity": "sha512-oNY5pq+1gc4T6QVTsZKwZaGpBb2N1H1fsc1GD4o7yinFySqIuRZ2E4NvGasWc6PhYJwGK2+5YT1f9Tp80zUQZQ==", "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -469,9 +451,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-darwin-arm64": { "node_modules/@tailwindcss/oxide-darwin-arm64": {
"version": "4.1.12", "version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.12.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz",
"integrity": "sha512-cq1qmq2HEtDV9HvZlTtrj671mCdGB93bVY6J29mwCyaMYCP/JaUBXxrQQQm7Qn33AXXASPUb2HFZlWiiHWFytw==", "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -486,9 +468,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-darwin-x64": { "node_modules/@tailwindcss/oxide-darwin-x64": {
"version": "4.1.12", "version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.12.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz",
"integrity": "sha512-6UCsIeFUcBfpangqlXay9Ffty9XhFH1QuUFn0WV83W8lGdX8cD5/+2ONLluALJD5+yJ7k8mVtwy3zMZmzEfbLg==", "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -503,9 +485,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-freebsd-x64": { "node_modules/@tailwindcss/oxide-freebsd-x64": {
"version": "4.1.12", "version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.12.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz",
"integrity": "sha512-JOH/f7j6+nYXIrHobRYCtoArJdMJh5zy5lr0FV0Qu47MID/vqJAY3r/OElPzx1C/wdT1uS7cPq+xdYYelny1ww==", "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -520,9 +502,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
"version": "4.1.12", "version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.12.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz",
"integrity": "sha512-v4Ghvi9AU1SYgGr3/j38PD8PEe6bRfTnNSUE3YCMIRrrNigCFtHZ2TCm8142X8fcSqHBZBceDx+JlFJEfNg5zQ==", "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -537,9 +519,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": { "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
"version": "4.1.12", "version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.12.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz",
"integrity": "sha512-YP5s1LmetL9UsvVAKusHSyPlzSRqYyRB0f+Kl/xcYQSPLEw/BvGfxzbH+ihUciePDjiXwHh+p+qbSP3SlJw+6g==", "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -554,9 +536,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-linux-arm64-musl": { "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
"version": "4.1.12", "version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.12.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz",
"integrity": "sha512-V8pAM3s8gsrXcCv6kCHSuwyb/gPsd863iT+v1PGXC4fSL/OJqsKhfK//v8P+w9ThKIoqNbEnsZqNy+WDnwQqCA==", "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -571,9 +553,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-linux-x64-gnu": { "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.1.12", "version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.12.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz",
"integrity": "sha512-xYfqYLjvm2UQ3TZggTGrwxjYaLB62b1Wiysw/YE3Yqbh86sOMoTn0feF98PonP7LtjsWOWcXEbGqDL7zv0uW8Q==", "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -588,9 +570,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-linux-x64-musl": { "node_modules/@tailwindcss/oxide-linux-x64-musl": {
"version": "4.1.12", "version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.12.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz",
"integrity": "sha512-ha0pHPamN+fWZY7GCzz5rKunlv9L5R8kdh+YNvP5awe3LtuXb5nRi/H27GeL2U+TdhDOptU7T6Is7mdwh5Ar3A==", "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -605,9 +587,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-wasm32-wasi": { "node_modules/@tailwindcss/oxide-wasm32-wasi": {
"version": "4.1.12", "version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.12.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz",
"integrity": "sha512-4tSyu3dW+ktzdEpuk6g49KdEangu3eCYoqPhWNsZgUhyegEda3M9rG0/j1GV/JjVVsj+lG7jWAyrTlLzd/WEBg==", "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==",
"bundleDependencies": [ "bundleDependencies": [
"@napi-rs/wasm-runtime", "@napi-rs/wasm-runtime",
"@emnapi/core", "@emnapi/core",
@@ -623,30 +605,30 @@
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"@emnapi/core": "^1.4.5", "@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.4.5", "@emnapi/runtime": "^1.7.1",
"@emnapi/wasi-threads": "^1.0.4", "@emnapi/wasi-threads": "^1.1.0",
"@napi-rs/wasm-runtime": "^0.2.12", "@napi-rs/wasm-runtime": "^1.1.0",
"@tybys/wasm-util": "^0.10.0", "@tybys/wasm-util": "^0.10.1",
"tslib": "^2.8.0" "tslib": "^2.4.0"
}, },
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"
} }
}, },
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.4.5", "version": "1.7.1",
"dev": true, "dev": true,
"inBundle": true, "inBundle": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"@emnapi/wasi-threads": "1.0.4", "@emnapi/wasi-threads": "1.1.0",
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.4.5", "version": "1.7.1",
"dev": true, "dev": true,
"inBundle": true, "inBundle": true,
"license": "MIT", "license": "MIT",
@@ -656,7 +638,7 @@
} }
}, },
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.0.4", "version": "1.1.0",
"dev": true, "dev": true,
"inBundle": true, "inBundle": true,
"license": "MIT", "license": "MIT",
@@ -666,19 +648,19 @@
} }
}, },
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12", "version": "1.1.0",
"dev": true, "dev": true,
"inBundle": true, "inBundle": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"@emnapi/core": "^1.4.3", "@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.4.3", "@emnapi/runtime": "^1.7.1",
"@tybys/wasm-util": "^0.10.0" "@tybys/wasm-util": "^0.10.1"
} }
}, },
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.0", "version": "0.10.1",
"dev": true, "dev": true,
"inBundle": true, "inBundle": true,
"license": "MIT", "license": "MIT",
@@ -688,16 +670,16 @@
} }
}, },
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.0", "version": "2.8.1",
"dev": true, "dev": true,
"inBundle": true, "inBundle": true,
"license": "0BSD", "license": "0BSD",
"optional": true "optional": true
}, },
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.1.12", "version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.12.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",
"integrity": "sha512-iGLyD/cVP724+FGtMWslhcFyg4xyYyM+5F4hGvKA7eifPkXHRAUDFaimu53fpNg9X8dfP75pXx/zFt/jlNF+lg==", "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -712,9 +694,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-win32-x64-msvc": { "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
"version": "4.1.12", "version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.12.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz",
"integrity": "sha512-NKIh5rzw6CpEodv/++r0hGLlfgT/gFN+5WNdZtvh6wpU2BpGNgdjvj6H2oFc8nCM839QM1YOhjpgbAONUb4IxA==", "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -728,16 +710,6 @@
"node": ">= 10" "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": { "node_modules/braces": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
@@ -751,16 +723,6 @@
"node": ">=8" "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": { "node_modules/detect-libc": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
@@ -820,9 +782,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/htmx.org": { "node_modules/htmx.org": {
"version": "2.0.6", "version": "2.0.8",
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.6.tgz", "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.8.tgz",
"integrity": "sha512-7ythjYneGSk3yCHgtCnQeaoF+D+o7U2LF37WU3O0JYv3gTZSicdEFiI/Ai/NJyC5ZpYJWMpUb11OC5Lr6AfAqA==", "integrity": "sha512-fm297iru0iWsNJlBrjvtN7V9zjaxd+69Oqjh4F/Vq9Wwi2kFisLcrLCiv5oBX0KLfOX/zG8AUo9ROMU5XUB44Q==",
"dev": true, "dev": true,
"license": "0BSD" "license": "0BSD"
}, },
@@ -860,9 +822,9 @@
} }
}, },
"node_modules/jiti": { "node_modules/jiti": {
"version": "2.5.1", "version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
"integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==", "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
@@ -870,9 +832,9 @@
} }
}, },
"node_modules/lightningcss": { "node_modules/lightningcss": {
"version": "1.30.1", "version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
"integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==", "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
"dev": true, "dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"dependencies": { "dependencies": {
@@ -886,22 +848,44 @@
"url": "https://opencollective.com/parcel" "url": "https://opencollective.com/parcel"
}, },
"optionalDependencies": { "optionalDependencies": {
"lightningcss-darwin-arm64": "1.30.1", "lightningcss-android-arm64": "1.30.2",
"lightningcss-darwin-x64": "1.30.1", "lightningcss-darwin-arm64": "1.30.2",
"lightningcss-freebsd-x64": "1.30.1", "lightningcss-darwin-x64": "1.30.2",
"lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-freebsd-x64": "1.30.2",
"lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.2",
"lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.2",
"lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.2",
"lightningcss-linux-x64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.2",
"lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-linux-x64-musl": "1.30.2",
"lightningcss-win32-x64-msvc": "1.30.1" "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": { "node_modules/lightningcss-darwin-arm64": {
"version": "1.30.1", "version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz", "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz",
"integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==", "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -920,9 +904,9 @@
} }
}, },
"node_modules/lightningcss-darwin-x64": { "node_modules/lightningcss-darwin-x64": {
"version": "1.30.1", "version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz", "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz",
"integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==", "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -941,9 +925,9 @@
} }
}, },
"node_modules/lightningcss-freebsd-x64": { "node_modules/lightningcss-freebsd-x64": {
"version": "1.30.1", "version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz", "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz",
"integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==", "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -962,9 +946,9 @@
} }
}, },
"node_modules/lightningcss-linux-arm-gnueabihf": { "node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.30.1", "version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz", "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz",
"integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==", "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -983,9 +967,9 @@
} }
}, },
"node_modules/lightningcss-linux-arm64-gnu": { "node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.30.1", "version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz", "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz",
"integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==", "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1004,9 +988,9 @@
} }
}, },
"node_modules/lightningcss-linux-arm64-musl": { "node_modules/lightningcss-linux-arm64-musl": {
"version": "1.30.1", "version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz", "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz",
"integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==", "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1025,9 +1009,9 @@
} }
}, },
"node_modules/lightningcss-linux-x64-gnu": { "node_modules/lightningcss-linux-x64-gnu": {
"version": "1.30.1", "version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz", "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz",
"integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==", "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1046,9 +1030,9 @@
} }
}, },
"node_modules/lightningcss-linux-x64-musl": { "node_modules/lightningcss-linux-x64-musl": {
"version": "1.30.1", "version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz", "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz",
"integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==", "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1067,9 +1051,9 @@
} }
}, },
"node_modules/lightningcss-win32-arm64-msvc": { "node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.30.1", "version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz", "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz",
"integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==", "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1088,9 +1072,9 @@
} }
}, },
"node_modules/lightningcss-win32-x64-msvc": { "node_modules/lightningcss-win32-x64-msvc": {
"version": "1.30.1", "version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz", "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz",
"integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==", "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1109,9 +1093,9 @@
} }
}, },
"node_modules/lightningcss/node_modules/detect-libc": { "node_modules/lightningcss/node_modules/detect-libc": {
"version": "2.0.4", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
@@ -1119,13 +1103,13 @@
} }
}, },
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.17", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
}, },
"node_modules/micromatch": { "node_modules/micromatch": {
@@ -1142,45 +1126,6 @@
"node": ">=8.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": { "node_modules/mri": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
@@ -1229,9 +1174,9 @@
} }
}, },
"node_modules/tailwindcss": { "node_modules/tailwindcss": {
"version": "4.1.12", "version": "4.1.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.12.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
"integrity": "sha512-DzFtxOi+7NsFf7DBtI3BJsynR+0Yp6etH+nRPTbpWnS2pZBaSksv/JGctNwSWzbFjp0vxSqknaUylseZqMDGrA==", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@@ -1245,24 +1190,6 @@
"node": ">=6" "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": { "node_modules/to-regex-range": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -1283,16 +1210,6 @@
"dev": true, "dev": true,
"license": "0BSD" "license": "0BSD"
}, },
"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==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=18"
}
},
"node_modules/zrender": { "node_modules/zrender": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz", "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz",

View File

@@ -11,9 +11,9 @@
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"devDependencies": { "devDependencies": {
"@tailwindcss/cli": "4.1.12", "@tailwindcss/cli": "4.1.18",
"htmx.org": "2.0.6", "htmx.org": "2.0.8",
"tailwindcss": "4.1.12", "tailwindcss": "4.1.18",
"echarts": "6.0.0" "echarts": "6.0.0"
} }
} }

View File

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

View File

@@ -11,7 +11,7 @@ function updateTime() {
document.querySelectorAll(".datetime").forEach((el) => { document.querySelectorAll(".datetime").forEach((el) => {
if (el.textContent !== "") { if (el.textContent !== "") {
el.textContent = el.textContent.includes("UTC") ? new Date(el.textContent).toLocaleString([], { day: 'numeric', month: 'short', year: 'numeric' }) : el.textContent; el.textContent = el.textContent.includes("UTC") ? new Date(el.textContent).toLocaleString([], { day: 'numeric', month: 'short', year: 'numeric' }) : el.textContent;
} else if (el.attributes['value'] !== "") { } else if (el.attributes['value'] && el.attributes['value'] !== "") {
const value = el.attributes['value'].value; const value = el.attributes['value'].value;
const newDate = value.includes("UTC") ? new Date(value) : value; const newDate = value.includes("UTC") ? new Date(value) : value;
el.valueAsDate = newDate; el.valueAsDate = newDate;

View File

@@ -1,10 +1,16 @@
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
if (!page || !page1 || !pagePrev1 || !pageNext1 || !page2 || !pagePrev2 || !pageNext2 || !transactionFilterForm) { if (typeof page === "undefined" ||
typeof page1 === "undefined" ||
typeof pagePrev1 === "undefined" ||
typeof pageNext1 === "undefined" ||
typeof page2 === "undefined" ||
typeof pagePrev2 === "undefined" ||
typeof pageNext2 === "undefined" ||
typeof transactionFilterForm === "undefined") {
return; return;
} }
const scrollToTop = function() { const scrollToTop = function() {
window.scrollTo(0, 0); window.scrollTo(0, 0);
}; };

View File

@@ -2,8 +2,9 @@ package test_test
import ( import (
"context" "context"
"spend-sparrow/internal/db" "spend-sparrow/internal/auth_types"
"spend-sparrow/internal/types" "spend-sparrow/internal/authentication"
"spend-sparrow/internal/core"
"testing" "testing"
"time" "time"
@@ -27,7 +28,7 @@ func setupDb(t *testing.T) *sqlx.DB {
} }
}) })
err = db.RunMigrations(context.Background(), d, "../") err = core.RunMigrations(context.Background(), d, "../")
if err != nil { if err != nil {
t.Fatalf("Error running migrations: %v", err) t.Fatalf("Error running migrations: %v", err)
} }
@@ -42,11 +43,11 @@ func TestUser(t *testing.T) {
t.Parallel() t.Parallel()
d := setupDb(t) d := setupDb(t)
underTest := db.NewAuthSqlite(d) underTest := authentication.NewDbSqlite(d)
verifiedAt := time.Date(2020, 1, 5, 13, 0, 0, 0, time.UTC) verifiedAt := time.Date(2020, 1, 5, 13, 0, 0, 0, time.UTC)
createAt := time.Date(2020, 1, 5, 12, 0, 0, 0, time.UTC) 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) expected := auth_types.NewUser(uuid.New(), "some@email.de", true, &verifiedAt, false, []byte("somePass"), []byte("someSalt"), createAt)
err := underTest.InsertUser(context.Background(), expected) err := underTest.InsertUser(context.Background(), expected)
require.NoError(t, err) require.NoError(t, err)
@@ -63,38 +64,38 @@ func TestUser(t *testing.T) {
t.Parallel() t.Parallel()
d := setupDb(t) d := setupDb(t)
underTest := db.NewAuthSqlite(d) underTest := authentication.NewDbSqlite(d)
_, err := underTest.GetUserByEmail(context.Background(), "nonExistentEmail") _, err := underTest.GetUserByEmail(context.Background(), "nonExistentEmail")
assert.Equal(t, db.ErrNotFound, err) assert.Equal(t, core.ErrNotFound, err)
}) })
t.Run("should return ErrUserExist", func(t *testing.T) { t.Run("should return ErrUserExist", func(t *testing.T) {
t.Parallel() t.Parallel()
d := setupDb(t) d := setupDb(t)
underTest := db.NewAuthSqlite(d) underTest := authentication.NewDbSqlite(d)
verifiedAt := time.Date(2020, 1, 5, 13, 0, 0, 0, time.UTC) verifiedAt := time.Date(2020, 1, 5, 13, 0, 0, 0, time.UTC)
createAt := time.Date(2020, 1, 5, 12, 0, 0, 0, time.UTC) 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) user := auth_types.NewUser(uuid.New(), "some@email.de", true, &verifiedAt, false, []byte("somePass"), []byte("someSalt"), createAt)
err := underTest.InsertUser(context.Background(), user) err := underTest.InsertUser(context.Background(), user)
require.NoError(t, err) require.NoError(t, err)
err = underTest.InsertUser(context.Background(), user) err = underTest.InsertUser(context.Background(), user)
assert.Equal(t, db.ErrAlreadyExists, err) assert.Equal(t, core.ErrAlreadyExists, err)
}) })
t.Run("should return ErrInternal on missing NOT NULL fields", func(t *testing.T) { t.Run("should return ErrInternal on missing NOT NULL fields", func(t *testing.T) {
t.Parallel() t.Parallel()
d := setupDb(t) d := setupDb(t)
underTest := db.NewAuthSqlite(d) underTest := authentication.NewDbSqlite(d)
createAt := time.Date(2020, 1, 5, 12, 0, 0, 0, time.UTC) 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) user := auth_types.NewUser(uuid.New(), "some@email.de", false, nil, false, []byte("somePass"), nil, createAt)
err := underTest.InsertUser(context.Background(), user) err := underTest.InsertUser(context.Background(), user)
assert.Equal(t, types.ErrInternal, err) assert.Equal(t, core.ErrInternal, err)
}) })
} }
@@ -105,11 +106,11 @@ func TestToken(t *testing.T) {
t.Parallel() t.Parallel()
d := setupDb(t) d := setupDb(t)
underTest := db.NewAuthSqlite(d) underTest := authentication.NewDbSqlite(d)
createAt := time.Date(2020, 1, 5, 12, 0, 0, 0, time.UTC) createAt := time.Date(2020, 1, 5, 12, 0, 0, 0, time.UTC)
expiresAt := createAt.Add(24 * time.Hour) expiresAt := createAt.Add(24 * time.Hour)
expected := types.NewToken(uuid.New(), "sessionId", "token", types.TokenTypeCsrf, createAt, expiresAt) expected := auth_types.NewToken(uuid.New(), "sessionId", "token", auth_types.TokenTypeCsrf, createAt, expiresAt)
err := underTest.InsertToken(context.Background(), expected) err := underTest.InsertToken(context.Background(), expected)
require.NoError(t, err) require.NoError(t, err)
@@ -121,25 +122,25 @@ func TestToken(t *testing.T) {
expected.SessionId = "" expected.SessionId = ""
actuals, err := underTest.GetTokensByUserIdAndType(context.Background(), expected.UserId, expected.Type) actuals, err := underTest.GetTokensByUserIdAndType(context.Background(), expected.UserId, expected.Type)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, []*types.Token{expected}, actuals) assert.Equal(t, []*auth_types.Token{expected}, actuals)
expected.SessionId = "sessionId" expected.SessionId = "sessionId"
expected.UserId = uuid.Nil expected.UserId = uuid.Nil
actuals, err = underTest.GetTokensBySessionIdAndType(context.Background(), expected.SessionId, expected.Type) actuals, err = underTest.GetTokensBySessionIdAndType(context.Background(), expected.SessionId, expected.Type)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, []*types.Token{expected}, actuals) assert.Equal(t, []*auth_types.Token{expected}, actuals)
}) })
t.Run("should insert and return multiple tokens", func(t *testing.T) { t.Run("should insert and return multiple tokens", func(t *testing.T) {
t.Parallel() t.Parallel()
d := setupDb(t) d := setupDb(t)
underTest := db.NewAuthSqlite(d) underTest := authentication.NewDbSqlite(d)
createAt := time.Date(2020, 1, 5, 12, 0, 0, 0, time.UTC) createAt := time.Date(2020, 1, 5, 12, 0, 0, 0, time.UTC)
expiresAt := createAt.Add(24 * time.Hour) expiresAt := createAt.Add(24 * time.Hour)
userId := uuid.New() userId := uuid.New()
expected1 := types.NewToken(userId, "sessionId", "token1", types.TokenTypeCsrf, createAt, expiresAt) expected1 := auth_types.NewToken(userId, "sessionId", "token1", auth_types.TokenTypeCsrf, createAt, expiresAt)
expected2 := types.NewToken(userId, "sessionId", "token2", types.TokenTypeCsrf, createAt, expiresAt) expected2 := auth_types.NewToken(userId, "sessionId", "token2", auth_types.TokenTypeCsrf, createAt, expiresAt)
err := underTest.InsertToken(context.Background(), expected1) err := underTest.InsertToken(context.Background(), expected1)
require.NoError(t, err) require.NoError(t, err)
@@ -150,7 +151,7 @@ func TestToken(t *testing.T) {
expected2.UserId = uuid.Nil expected2.UserId = uuid.Nil
actuals, err := underTest.GetTokensBySessionIdAndType(context.Background(), expected1.SessionId, expected1.Type) actuals, err := underTest.GetTokensBySessionIdAndType(context.Background(), expected1.SessionId, expected1.Type)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, []*types.Token{expected1, expected2}, actuals) assert.Equal(t, []*auth_types.Token{expected1, expected2}, actuals)
expected1.SessionId = "" expected1.SessionId = ""
expected2.SessionId = "" expected2.SessionId = ""
@@ -158,49 +159,49 @@ func TestToken(t *testing.T) {
expected2.UserId = userId expected2.UserId = userId
actuals, err = underTest.GetTokensByUserIdAndType(context.Background(), userId, expected1.Type) actuals, err = underTest.GetTokensByUserIdAndType(context.Background(), userId, expected1.Type)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, []*types.Token{expected1, expected2}, actuals) assert.Equal(t, []*auth_types.Token{expected1, expected2}, actuals)
}) })
t.Run("should return ErrNotFound", func(t *testing.T) { t.Run("should return ErrNotFound", func(t *testing.T) {
t.Parallel() t.Parallel()
d := setupDb(t) d := setupDb(t)
underTest := db.NewAuthSqlite(d) underTest := authentication.NewDbSqlite(d)
_, err := underTest.GetToken(context.Background(), "nonExistent") _, err := underTest.GetToken(context.Background(), "nonExistent")
assert.Equal(t, db.ErrNotFound, err) assert.Equal(t, core.ErrNotFound, err)
_, err = underTest.GetTokensByUserIdAndType(context.Background(), uuid.New(), types.TokenTypeEmailVerify) _, err = underTest.GetTokensByUserIdAndType(context.Background(), uuid.New(), auth_types.TokenTypeEmailVerify)
assert.Equal(t, db.ErrNotFound, err) assert.Equal(t, core.ErrNotFound, err)
_, err = underTest.GetTokensBySessionIdAndType(context.Background(), "sessionId", types.TokenTypeEmailVerify) _, err = underTest.GetTokensBySessionIdAndType(context.Background(), "sessionId", auth_types.TokenTypeEmailVerify)
assert.Equal(t, db.ErrNotFound, err) assert.Equal(t, core.ErrNotFound, err)
}) })
t.Run("should return ErrAlreadyExists", func(t *testing.T) { t.Run("should return ErrAlreadyExists", func(t *testing.T) {
t.Parallel() t.Parallel()
d := setupDb(t) d := setupDb(t)
underTest := db.NewAuthSqlite(d) underTest := authentication.NewDbSqlite(d)
verifiedAt := time.Date(2020, 1, 5, 13, 0, 0, 0, time.UTC) verifiedAt := time.Date(2020, 1, 5, 13, 0, 0, 0, time.UTC)
createAt := time.Date(2020, 1, 5, 12, 0, 0, 0, time.UTC) 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) user := auth_types.NewUser(uuid.New(), "some@email.de", true, &verifiedAt, false, []byte("somePass"), []byte("someSalt"), createAt)
err := underTest.InsertUser(context.Background(), user) err := underTest.InsertUser(context.Background(), user)
require.NoError(t, err) require.NoError(t, err)
err = underTest.InsertUser(context.Background(), user) err = underTest.InsertUser(context.Background(), user)
assert.Equal(t, db.ErrAlreadyExists, err) assert.Equal(t, core.ErrAlreadyExists, err)
}) })
t.Run("should return ErrInternal on missing NOT NULL fields", func(t *testing.T) { t.Run("should return ErrInternal on missing NOT NULL fields", func(t *testing.T) {
t.Parallel() t.Parallel()
d := setupDb(t) d := setupDb(t)
underTest := db.NewAuthSqlite(d) underTest := authentication.NewDbSqlite(d)
createAt := time.Date(2020, 1, 5, 12, 0, 0, 0, time.UTC) 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) user := auth_types.NewUser(uuid.New(), "some@email.de", false, nil, false, []byte("somePass"), nil, createAt)
err := underTest.InsertUser(context.Background(), user) err := underTest.InsertUser(context.Background(), user)
assert.Equal(t, types.ErrInternal, err) assert.Equal(t, core.ErrInternal, err)
}) })
} }

View File

@@ -2,9 +2,9 @@ package test_test
import ( import (
"context" "context"
"spend-sparrow/internal/db" "spend-sparrow/internal/auth_types"
"spend-sparrow/internal/service" "spend-sparrow/internal/authentication"
"spend-sparrow/internal/types" "spend-sparrow/internal/core"
"spend-sparrow/mocks" "spend-sparrow/mocks"
"strings" "strings"
"testing" "testing"
@@ -17,7 +17,7 @@ import (
) )
var ( var (
settings = types.Settings{ settings = core.Settings{
Port: "", Port: "",
BaseUrl: "", BaseUrl: "",
Environment: "test", Environment: "test",
@@ -30,26 +30,26 @@ func TestSignUp(t *testing.T) {
t.Run("should check for correct email address", func(t *testing.T) { t.Run("should check for correct email address", func(t *testing.T) {
t.Parallel() t.Parallel()
mockAuthDb := mocks.NewMockAuth(t) mockAuthDb := mocks.NewMockDb(t)
mockRandom := mocks.NewMockRandom(t) mockRandom := mocks.NewMockRandom(t)
mockClock := mocks.NewMockClock(t) mockClock := mocks.NewMockClock(t)
mockMail := mocks.NewMockMail(t) mockMail := mocks.NewMockMail(t)
underTest := service.NewAuth(mockAuthDb, mockRandom, mockClock, mockMail, &settings) underTest := authentication.NewService(mockAuthDb, mockRandom, mockClock, mockMail, &settings)
_, err := underTest.SignUp(context.Background(), "invalid email address", "SomeStrongPassword123!") _, err := underTest.SignUp(context.Background(), "invalid email address", "SomeStrongPassword123!")
assert.Equal(t, service.ErrInvalidEmail, err) assert.Equal(t, authentication.ErrInvalidEmail, err)
}) })
t.Run("should check for password complexity", func(t *testing.T) { t.Run("should check for password complexity", func(t *testing.T) {
t.Parallel() t.Parallel()
mockAuthDb := mocks.NewMockAuth(t) mockAuthDb := mocks.NewMockDb(t)
mockRandom := mocks.NewMockRandom(t) mockRandom := mocks.NewMockRandom(t)
mockClock := mocks.NewMockClock(t) mockClock := mocks.NewMockClock(t)
mockMail := mocks.NewMockMail(t) mockMail := mocks.NewMockMail(t)
underTest := service.NewAuth(mockAuthDb, mockRandom, mockClock, mockMail, &settings) underTest := authentication.NewService(mockAuthDb, mockRandom, mockClock, mockMail, &settings)
weakPasswords := []string{ weakPasswords := []string{
"123!ab", // too short "123!ab", // too short
@@ -60,13 +60,13 @@ func TestSignUp(t *testing.T) {
for _, password := range weakPasswords { for _, password := range weakPasswords {
_, err := underTest.SignUp(context.Background(), "some@valid.email", password) _, err := underTest.SignUp(context.Background(), "some@valid.email", password)
assert.Equal(t, service.ErrInvalidPassword, err) assert.Equal(t, authentication.ErrInvalidPassword, err)
} }
}) })
t.Run("should signup correctly", func(t *testing.T) { t.Run("should signup correctly", func(t *testing.T) {
t.Parallel() t.Parallel()
mockAuthDb := mocks.NewMockAuth(t) mockAuthDb := mocks.NewMockDb(t)
mockRandom := mocks.NewMockRandom(t) mockRandom := mocks.NewMockRandom(t)
mockClock := mocks.NewMockClock(t) mockClock := mocks.NewMockClock(t)
mockMail := mocks.NewMockMail(t) mockMail := mocks.NewMockMail(t)
@@ -77,7 +77,7 @@ func TestSignUp(t *testing.T) {
salt := []byte("salt") salt := []byte("salt")
createTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) createTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
expected := types.NewUser(userId, email, false, nil, false, service.GetHashPassword(password, salt), salt, createTime) expected := auth_types.NewUser(userId, email, false, nil, false, authentication.GetHashPassword(password, salt), salt, createTime)
ctx := context.Background() ctx := context.Background()
@@ -86,7 +86,7 @@ func TestSignUp(t *testing.T) {
mockClock.EXPECT().Now().Return(createTime) mockClock.EXPECT().Now().Return(createTime)
mockAuthDb.EXPECT().InsertUser(context.Background(), expected).Return(nil) mockAuthDb.EXPECT().InsertUser(context.Background(), expected).Return(nil)
underTest := service.NewAuth(mockAuthDb, mockRandom, mockClock, mockMail, &settings) underTest := authentication.NewService(mockAuthDb, mockRandom, mockClock, mockMail, &settings)
actual, err := underTest.SignUp(context.Background(), email, password) actual, err := underTest.SignUp(context.Background(), email, password)
require.NoError(t, err) require.NoError(t, err)
@@ -96,7 +96,7 @@ func TestSignUp(t *testing.T) {
t.Run("should return ErrAccountExists", func(t *testing.T) { t.Run("should return ErrAccountExists", func(t *testing.T) {
t.Parallel() t.Parallel()
mockAuthDb := mocks.NewMockAuth(t) mockAuthDb := mocks.NewMockDb(t)
mockRandom := mocks.NewMockRandom(t) mockRandom := mocks.NewMockRandom(t)
mockClock := mocks.NewMockClock(t) mockClock := mocks.NewMockClock(t)
mockMail := mocks.NewMockMail(t) mockMail := mocks.NewMockMail(t)
@@ -106,19 +106,19 @@ func TestSignUp(t *testing.T) {
createTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) createTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
password := "SomeStrongPassword123!" password := "SomeStrongPassword123!"
salt := []byte("salt") salt := []byte("salt")
user := types.NewUser(userId, email, false, nil, false, service.GetHashPassword(password, salt), salt, createTime) user := auth_types.NewUser(userId, email, false, nil, false, authentication.GetHashPassword(password, salt), salt, createTime)
ctx := context.Background() ctx := context.Background()
mockRandom.EXPECT().UUID(ctx).Return(user.Id, nil) mockRandom.EXPECT().UUID(ctx).Return(user.Id, nil)
mockRandom.EXPECT().Bytes(ctx, 16).Return(salt, nil) mockRandom.EXPECT().Bytes(ctx, 16).Return(salt, nil)
mockClock.EXPECT().Now().Return(createTime) mockClock.EXPECT().Now().Return(createTime)
mockAuthDb.EXPECT().InsertUser(context.Background(), user).Return(db.ErrAlreadyExists) mockAuthDb.EXPECT().InsertUser(context.Background(), user).Return(core.ErrAlreadyExists)
underTest := service.NewAuth(mockAuthDb, mockRandom, mockClock, mockMail, &settings) underTest := authentication.NewService(mockAuthDb, mockRandom, mockClock, mockMail, &settings)
_, err := underTest.SignUp(context.Background(), user.Email, password) _, err := underTest.SignUp(context.Background(), user.Email, password)
assert.Equal(t, service.ErrAccountExists, err) assert.Equal(t, authentication.ErrAccountExists, err)
}) })
} }
@@ -127,30 +127,30 @@ func TestSendVerificationMail(t *testing.T) {
t.Run("should use stored token and send mail", func(t *testing.T) { t.Run("should use stored token and send mail", func(t *testing.T) {
t.Parallel() t.Parallel()
token := types.NewToken( token := auth_types.NewToken(
uuid.New(), uuid.New(),
"sessionId", "sessionId",
"someRandomTokenToUse", "someRandomTokenToUse",
types.TokenTypeEmailVerify, auth_types.TokenTypeEmailVerify,
time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC),
time.Date(2020, 1, 2, 0, 0, 0, 0, time.UTC)) time.Date(2020, 1, 2, 0, 0, 0, 0, time.UTC))
tokens := []*types.Token{token} tokens := []*auth_types.Token{token}
email := "some@email.de" email := "some@email.de"
userId := uuid.New() userId := uuid.New()
mockAuthDb := mocks.NewMockAuth(t) mockAuthDb := mocks.NewMockDb(t)
mockRandom := mocks.NewMockRandom(t) mockRandom := mocks.NewMockRandom(t)
mockClock := mocks.NewMockClock(t) mockClock := mocks.NewMockClock(t)
mockMail := mocks.NewMockMail(t) mockMail := mocks.NewMockMail(t)
ctx := context.Background() ctx := context.Background()
mockAuthDb.EXPECT().GetTokensByUserIdAndType(context.Background(), userId, types.TokenTypeEmailVerify).Return(tokens, nil) mockAuthDb.EXPECT().GetTokensByUserIdAndType(context.Background(), userId, auth_types.TokenTypeEmailVerify).Return(tokens, nil)
mockMail.EXPECT().SendMail(ctx, email, "Welcome to spend-sparrow", mock.MatchedBy(func(message string) bool { mockMail.EXPECT().SendMail(ctx, email, "Welcome to spend-sparrow", mock.MatchedBy(func(message string) bool {
return strings.Contains(message, token.Token) return strings.Contains(message, token.Token)
})).Return() })).Return()
underTest := service.NewAuth(mockAuthDb, mockRandom, mockClock, mockMail, &settings) underTest := authentication.NewService(mockAuthDb, mockRandom, mockClock, mockMail, &settings)
underTest.SendVerificationMail(context.Background(), userId, email) underTest.SendVerificationMail(context.Background(), userId, email)
}) })

View File

@@ -7,8 +7,9 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"spend-sparrow/internal" "spend-sparrow/internal"
"spend-sparrow/internal/service" "spend-sparrow/internal/auth_types"
"spend-sparrow/internal/types" "spend-sparrow/internal/authentication"
"spend-sparrow/internal/core"
"strconv" "strconv"
"strings" "strings"
"sync/atomic" "sync/atomic"
@@ -117,7 +118,7 @@ func waitForReady(
default: default:
if time.Since(startTime) >= timeout { if time.Since(startTime) >= timeout {
t.Fatal("timeout reached while waiting for endpoint") t.Fatal("timeout reached while waiting for endpoint")
return types.ErrInternal return core.ErrInternal
} }
// wait a little while between checks // wait a little while between checks
time.Sleep(250 * time.Millisecond) time.Sleep(250 * time.Millisecond)
@@ -152,7 +153,7 @@ func getTokenAttribute(t *testing.T, data *html.Node) string {
for _, attr := range data.Attr { for _, attr := range data.Attr {
if attr.Key == "hx-headers" { if attr.Key == "hx-headers" {
var data map[string]interface{} var data map[string]any
err := json.Unmarshal([]byte(attr.Val), &data) err := json.Unmarshal([]byte(attr.Val), &data)
require.NoError(t, err) require.NoError(t, err)
result, ok := data["Csrf-Token"].(string) result, ok := data["Csrf-Token"].(string)
@@ -178,7 +179,7 @@ func createValidUserSession(t *testing.T, db *sqlx.DB, add string) (uuid.UUID, s
t.Helper() t.Helper()
userId := uuid.New() userId := uuid.New()
sessionId := "session-id" + add sessionId := "session-id" + add
pass := service.GetHashPassword("password", []byte("salt")) pass := authentication.GetHashPassword("password", []byte("salt"))
csrfToken := "my-verifying-token" + add csrfToken := "my-verifying-token" + add
email := add + "mail@mail.de" email := add + "mail@mail.de"
@@ -193,7 +194,7 @@ func createValidUserSession(t *testing.T, db *sqlx.DB, add string) (uuid.UUID, s
_, err = db.ExecContext(context.Background(), ` _, err = db.ExecContext(context.Background(), `
INSERT INTO token (token, user_id, session_id, type, created_at, expires_at) INSERT INTO token (token, user_id, session_id, type, created_at, expires_at)
VALUES (?, ?, ?, ?, datetime(), datetime("now", "+1 day"))`, csrfToken, userId, sessionId, types.TokenTypeCsrf) VALUES (?, ?, ?, ?, datetime(), datetime("now", "+1 day"))`, csrfToken, userId, sessionId, auth_types.TokenTypeCsrf)
require.NoError(t, err) require.NoError(t, err)
return userId, csrfToken, sessionId return userId, csrfToken, sessionId

Some files were not shown because too many files have changed in this diff Show More