57 Commits

Author SHA1 Message Date
42f0ecb713 chore(deps): update golang:1.25.6 docker digest to 06d1251
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 2m21s
2026-02-13 08:20:26 +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
71 changed files with 1978 additions and 732 deletions

2
.gitignore vendored
View File

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

2
.nvmrc
View File

@@ -1 +1 @@
24.12.0 24.13.1

View File

@@ -1,4 +1,4 @@
FROM golang:1.25.5@sha256:36b4f45d2874905b9e8573b783292629bcb346d0a70d8d7150b6df545234818f AS builder_go FROM golang:1.25.6@sha256:06d1251c59a75761ce4ebc8b299030576233d7437c886a68b43464bad62d4bb1 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:24.12.0@sha256:20988bcdc6dc76690023eb2505dd273bdeefddcd0bde4bfd1efe4ebf8707f747 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.2@sha256:0d01188e8dd0ac63bf155900fad49279131a876a1ea7fac917c62e87ccb2732d FROM debian:13.3@sha256:5cf544fad978371b3df255b61e209b373583cb88b733475c86e49faa15ac2104
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)"

14
go.mod
View File

@@ -2,15 +2,15 @@ module spend-sparrow
go 1.24.0 go 1.24.0
toolchain go1.25.5 toolchain go1.25.6
require ( require (
github.com/a-h/templ v0.3.960 github.com/a-h/templ v0.3.977
github.com/golang-migrate/migrate/v4 v4.19.1 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.33
github.com/stretchr/testify v1.11.1 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
@@ -25,8 +25,8 @@ require (
go.opentelemetry.io/otel/sdk/log v0.15.0 go.opentelemetry.io/otel/sdk/log v0.15.0
go.opentelemetry.io/otel/sdk/metric v1.39.0 go.opentelemetry.io/otel/sdk/metric v1.39.0
go.opentelemetry.io/otel/trace v1.39.0 go.opentelemetry.io/otel/trace v1.39.0
golang.org/x/crypto v0.46.0 golang.org/x/crypto v0.47.0
golang.org/x/net v0.48.0 golang.org/x/net v0.49.0
) )
require ( require (
@@ -43,8 +43,8 @@ require (
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 // indirect
go.opentelemetry.io/otel/metric v1.39.0 // indirect go.opentelemetry.io/otel/metric v1.39.0 // indirect
go.opentelemetry.io/proto/otlp v1.9.0 // indirect go.opentelemetry.io/proto/otlp v1.9.0 // indirect
golang.org/x/sys v0.39.0 // indirect golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.32.0 // indirect golang.org/x/text v0.33.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/grpc v1.77.0 // indirect google.golang.org/grpc v1.77.0 // indirect

24
go.sum
View File

@@ -1,7 +1,7 @@
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.960 h1:trshEpGa8clF5cdI39iY4ZrZG8Z/QixyzEyUnA7feTM= github.com/a-h/templ v0.3.977 h1:kiKAPXTZE2Iaf8JbtM21r54A8bCNsncrfnokZZSrSDg=
github.com/a-h/templ v0.3.960/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo= github.com/a-h/templ v0.3.977/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
@@ -38,8 +38,8 @@ 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.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/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.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
@@ -86,14 +86,14 @@ go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjce
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
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.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=

View File

@@ -4,7 +4,6 @@ import (
"github.com/a-h/templ" "github.com/a-h/templ"
"net/http" "net/http"
"spend-sparrow/internal/core" "spend-sparrow/internal/core"
"spend-sparrow/internal/utils"
) )
type Handler struct { type Handler struct {
@@ -32,7 +31,7 @@ func (h Handler) handleAccountPage() http.HandlerFunc {
user := core.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
} }
@@ -53,7 +52,7 @@ func (h Handler) handleAccountItemComp() http.HandlerFunc {
user := core.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
} }
@@ -86,7 +85,7 @@ func (h Handler) handleUpdateAccount() http.HandlerFunc {
user := core.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
} }
@@ -121,7 +120,7 @@ func (h Handler) handleDeleteAccount() http.HandlerFunc {
user := core.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
} }

View File

@@ -7,8 +7,6 @@ import (
"log/slog" "log/slog"
"spend-sparrow/internal/auth_types" "spend-sparrow/internal/auth_types"
"spend-sparrow/internal/core" "spend-sparrow/internal/core"
"spend-sparrow/internal/db"
"spend-sparrow/internal/service"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
@@ -46,7 +44,7 @@ func (s ServiceImpl) Add(ctx context.Context, user *auth_types.User, name string
return nil, core.ErrInternal return nil, core.ErrInternal
} }
err = service.ValidateString(name, "name") err = core.ValidateString(name, "name")
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -70,7 +68,7 @@ func (s ServiceImpl) Add(ctx context.Context, user *auth_types.User, name string
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
} }
@@ -82,7 +80,7 @@ func (s ServiceImpl) UpdateName(ctx context.Context, user *auth_types.User, id s
if user == nil { if user == nil {
return nil, core.ErrUnauthorized return nil, core.ErrUnauthorized
} }
err := service.ValidateString(name, "name") err := core.ValidateString(name, "name")
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -93,7 +91,7 @@ func (s ServiceImpl) UpdateName(ctx context.Context, user *auth_types.User, id s
} }
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
} }
@@ -103,7 +101,7 @@ func (s ServiceImpl) UpdateName(ctx context.Context, user *auth_types.User, id s
var account 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, core.ErrNotFound) { if errors.Is(err, core.ErrNotFound) {
return nil, fmt.Errorf("account %v not found: %w", id, core.ErrBadRequest) return nil, fmt.Errorf("account %v not found: %w", id, core.ErrBadRequest)
@@ -124,13 +122,13 @@ func (s ServiceImpl) UpdateName(ctx context.Context, user *auth_types.User, id s
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
} }
@@ -151,7 +149,7 @@ func (s ServiceImpl) Get(ctx context.Context, user *auth_types.User, id string)
var account 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
@@ -168,7 +166,7 @@ func (s ServiceImpl) GetAll(ctx context.Context, user *auth_types.User) ([]*Acco
accounts := make([]*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
} }
@@ -187,7 +185,7 @@ func (s ServiceImpl) Delete(ctx context.Context, user *auth_types.User, id strin
} }
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
} }
@@ -197,7 +195,7 @@ func (s ServiceImpl) Delete(ctx context.Context, user *auth_types.User, id strin
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
} }
@@ -206,13 +204,13 @@ func (s ServiceImpl) Delete(ctx context.Context, user *auth_types.User, id strin
} }
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,7 +1,9 @@
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 template(accounts []*Account) { templ template(accounts []*Account) {
<div class="max-w-6xl mt-10 mx-auto"> <div class="max-w-6xl mt-10 mx-auto">
@@ -11,7 +13,9 @@ templ template(accounts []*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"
> >
<div class="w-3">
@svg.Plus() @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">
@@ -82,9 +86,9 @@ templ accountItem(account *Account) {
<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

@@ -8,7 +8,6 @@ import (
"spend-sparrow/internal/auth_types" "spend-sparrow/internal/auth_types"
"spend-sparrow/internal/authentication/template" "spend-sparrow/internal/authentication/template"
"spend-sparrow/internal/core" "spend-sparrow/internal/core"
"spend-sparrow/internal/utils"
"time" "time"
) )
@@ -62,9 +61,9 @@ func (handler HandlerImpl) handleSignInPage() http.HandlerFunc {
user := core.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
} }
@@ -79,7 +78,7 @@ func (handler HandlerImpl) handleSignIn() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
core.UpdateSpan(r) core.UpdateSpan(r)
user, err := utils.WaitMinimumTime(securityWaitDuration, func() (*auth_types.User, error) { user, err := core.WaitMinimumTime(securityWaitDuration, func() (*auth_types.User, error) {
session := core.GetSession(r) session := core.GetSession(r)
email := r.FormValue("email") email := r.FormValue("email")
password := r.FormValue("password") password := r.FormValue("password")
@@ -97,17 +96,17 @@ func (handler HandlerImpl) handleSignIn() http.HandlerFunc {
if err != nil { if err != nil {
if errors.Is(err, 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")
} }
} }
} }
@@ -120,9 +119,9 @@ func (handler HandlerImpl) handleSignUpPage() http.HandlerFunc {
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
} }
@@ -138,12 +137,12 @@ func (handler HandlerImpl) handleSignUpVerifyPage() http.HandlerFunc {
user := core.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
} }
@@ -158,7 +157,7 @@ func (handler HandlerImpl) handleVerifyResendComp() http.HandlerFunc {
user := core.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
} }
@@ -200,7 +199,7 @@ func (handler HandlerImpl) handleSignUp() http.HandlerFunc {
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,19 +214,19 @@ func (handler HandlerImpl) handleSignUp() http.HandlerFunc {
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, core.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, 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, ErrInvalidPassword): case errors.Is(err, ErrInvalidPassword):
utils.TriggerToastWithStatus(r.Context(), w, r, "error", 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)
} }
} }
@@ -256,7 +255,7 @@ func (handler HandlerImpl) handleSignOut() http.HandlerFunc {
} }
http.SetCookie(w, &c) http.SetCookie(w, &c)
utils.DoRedirect(w, r, "/") core.DoRedirect(w, r, "/")
} }
} }
@@ -266,7 +265,7 @@ func (handler HandlerImpl) handleDeleteAccountPage() http.HandlerFunc {
user := core.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
} }
@@ -281,7 +280,7 @@ func (handler HandlerImpl) handleDeleteAccountComp() http.HandlerFunc {
user := core.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,14 +289,14 @@ func (handler HandlerImpl) 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, 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, "/")
} }
} }
@@ -310,7 +309,7 @@ func (handler HandlerImpl) handleChangePasswordPage() http.HandlerFunc {
user := core.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
} }
@@ -326,7 +325,7 @@ func (handler HandlerImpl) handleChangePasswordComp() http.HandlerFunc {
session := core.GetSession(r) session := core.GetSession(r)
user := core.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
} }
@@ -335,11 +334,11 @@ func (handler HandlerImpl) 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)
} }
} }
@@ -349,7 +348,7 @@ func (handler HandlerImpl) handleForgotPasswordPage() http.HandlerFunc {
user := core.GetUser(r) user := core.GetUser(r)
if user != nil { if user != nil {
utils.DoRedirect(w, r, "/") core.DoRedirect(w, r, "/")
return return
} }
@@ -364,19 +363,19 @@ func (handler HandlerImpl) handleForgotPasswordComp() http.HandlerFunc {
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)
} }
} }
} }
@@ -388,7 +387,7 @@ func (handler HandlerImpl) handleForgotPasswordResponseComp() http.HandlerFunc {
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
} }
@@ -397,9 +396,9 @@ func (handler HandlerImpl) 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

@@ -9,7 +9,6 @@ import (
"spend-sparrow/internal/auth_types" "spend-sparrow/internal/auth_types"
"spend-sparrow/internal/core" "spend-sparrow/internal/core"
mailTemplate "spend-sparrow/internal/template/mail" mailTemplate "spend-sparrow/internal/template/mail"
"spend-sparrow/internal/types"
"strings" "strings"
"time" "time"
@@ -54,10 +53,10 @@ type ServiceImpl struct {
random core.Random random core.Random
clock core.Clock clock core.Clock
mail core.Mail mail core.Mail
serverSettings *types.Settings serverSettings *core.Settings
} }
func NewService(db Db, random core.Random, clock core.Clock, mail core.Mail, serverSettings *types.Settings) *ServiceImpl { func NewService(db Db, random core.Random, clock core.Clock, mail core.Mail, serverSettings *core.Settings) *ServiceImpl {
return &ServiceImpl{ return &ServiceImpl{
db: db, db: db,
random: random, random: random,

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"`
}

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,44 +0,0 @@
package core
import (
"errors"
"net/http"
"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, ErrUnauthorized):
utils.TriggerToastWithStatus(r.Context(), w, r, "error", "You are not autorized to perform this operation.", http.StatusUnauthorized)
return
case errors.Is(err, ErrBadRequest):
utils.TriggerToastWithStatus(r.Context(), w, r, "error", extractErrorMessage(err), http.StatusBadRequest)
return
case errors.Is(err, 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

@@ -1,6 +1,13 @@
package core package core
import "errors" import (
"context"
"database/sql"
"errors"
"log/slog"
"net/http"
"strings"
)
var ( var (
ErrNotFound = errors.New("the value does not exist") ErrNotFound = errors.New("the value does not exist")
@@ -11,3 +18,53 @@ var (
ErrBadRequest = errors.New("bad request") 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 core 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

@@ -5,7 +5,6 @@ import (
"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/core"
"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 core.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 core.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 core.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,4 +1,4 @@
package types package core
import ( import (
"context" "context"

View File

@@ -1,25 +1,20 @@
package service package core
import ( import (
"fmt" "fmt"
"regexp" "regexp"
"spend-sparrow/internal/core"
)
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, core.ErrBadRequest) return fmt.Errorf("field \"%s\" needs to be set: %w", fieldName, ErrBadRequest)
case !safeInputRegex.MatchString(value): case !safeInputRegex.MatchString(value):
return fmt.Errorf("use only letters, dashes and spaces for \"%s\": %w", fieldName, core.ErrBadRequest) return fmt.Errorf("use only letters, dashes and spaces for \"%s\": %w", fieldName, ErrBadRequest)
default: default:
return nil return nil
} }

View File

@@ -1,51 +1,49 @@
package handler package dashboard
import ( import (
"fmt" "fmt"
"log/slog" "log/slog"
"net/http" "net/http"
"spend-sparrow/internal/core" "spend-sparrow/internal/core"
"spend-sparrow/internal/service" "spend-sparrow/internal/treasure_chest"
"spend-sparrow/internal/template/dashboard"
"spend-sparrow/internal/utils"
"strings" "strings"
"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 *core.Render r *core.Render
d *service.Dashboard s *Service
treasureChest service.TreasureChest treasureChest treasure_chest.Service
} }
func NewDashboard(r *core.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) {
core.UpdateSpan(r) core.UpdateSpan(r)
user := core.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
} }
@@ -55,18 +53,18 @@ func (handler DashboardImpl) handleDashboard() http.HandlerFunc {
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) {
core.UpdateSpan(r) core.UpdateSpan(r)
user := core.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 {
core.HandleError(w, r, err) core.HandleError(w, r, err)
return return
@@ -126,13 +124,13 @@ 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) {
core.UpdateSpan(r) core.UpdateSpan(r)
user := core.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 {
core.HandleError(w, r, err) core.HandleError(w, r, err)
return return
@@ -181,7 +179,7 @@ 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) {
core.UpdateSpan(r) core.UpdateSpan(r)
@@ -200,7 +198,7 @@ func (handler DashboardImpl) handleDashboardTreasureChest() http.HandlerFunc {
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 {
core.HandleError(w, r, err) core.HandleError(w, r, err)
return return

View File

@@ -1,49 +1,50 @@
package service package dashboard
import ( import (
"context" "context"
"spend-sparrow/internal/auth_types" "spend-sparrow/internal/auth_types"
"spend-sparrow/internal/core" "spend-sparrow/internal/core"
"spend-sparrow/internal/db" "spend-sparrow/internal/transaction"
"spend-sparrow/internal/types" "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 *auth_types.User, user *auth_types.User,
) ([]types.DashboardMainChartEntry, error) { ) ([]DashboardMainChartEntry, error) {
if user == nil { if user == nil {
return nil, core.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 {
@@ -52,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,
@@ -82,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 *auth_types.User, user *auth_types.User,
) ([]*types.DashboardTreasureChest, error) { ) ([]*DashboardTreasureChest, error) {
if user == nil { if user == nil {
return nil, core.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),
}) })
} }
} }
@@ -120,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 *auth_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, core.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 {
@@ -152,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,34 +0,0 @@
package db
import (
"context"
"database/sql"
"errors"
"log/slog"
"spend-sparrow/internal/core"
)
func TransformAndLogDbError(ctx context.Context, module string, r sql.Result, err error) error {
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return core.ErrNotFound
}
slog.ErrorContext(ctx, "database sql", "module", module, "err", err)
return core.ErrInternal
}
if r != nil {
rows, err := r.RowsAffected()
if err != nil {
slog.ErrorContext(ctx, "database rows affected", "module", module, "err", err)
return core.ErrInternal
}
if rows == 0 {
slog.InfoContext(ctx, "row not found", "module", module)
return core.ErrNotFound
}
}
return nil
}

View File

@@ -9,13 +9,15 @@ import (
"os/signal" "os/signal"
"spend-sparrow/internal/account" "spend-sparrow/internal/account"
"spend-sparrow/internal/authentication" "spend-sparrow/internal/authentication"
"spend-sparrow/internal/budget"
"spend-sparrow/internal/core" "spend-sparrow/internal/core"
"spend-sparrow/internal/db" "spend-sparrow/internal/dashboard"
"spend-sparrow/internal/handler" "spend-sparrow/internal/handler"
"spend-sparrow/internal/handler/middleware" "spend-sparrow/internal/handler/middleware"
"spend-sparrow/internal/log" "spend-sparrow/internal/tag"
"spend-sparrow/internal/service" "spend-sparrow/internal/transaction"
"spend-sparrow/internal/types" "spend-sparrow/internal/transaction_recurring"
"spend-sparrow/internal/treasure_chest"
"sync" "sync"
"syscall" "syscall"
"time" "time"
@@ -29,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 {
@@ -46,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)
} }
@@ -66,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)
@@ -105,10 +107,12 @@ 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 := authentication.NewDbSqlite(d) authDb := authentication.NewDbSqlite(d)
budgetDb := budget.NewDbSqlite(d)
tagDb := tag.NewDbSqlite(d)
randomService := core.NewRandom() randomService := core.NewRandom()
clockService := core.NewClock() clockService := core.NewClock()
@@ -116,21 +120,25 @@ func createHandlerWithServices(ctx context.Context, d *sqlx.DB, serverSettings *
authService := authentication.NewService(authDb, randomService, clockService, mailService, serverSettings) authService := authentication.NewService(authDb, randomService, clockService, mailService, serverSettings)
accountService := account.NewServiceImpl(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 := core.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 := authentication.NewHandler(authService, render) authHandler := authentication.NewHandler(authService, render)
accountHandler := account.NewHandler(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)
@@ -139,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/"))))
@@ -150,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")
@@ -158,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 authentication.Service) { 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()
@@ -168,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 authentication.Service) { 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

@@ -5,7 +5,6 @@ import (
"net/http" "net/http"
"spend-sparrow/internal/authentication" "spend-sparrow/internal/authentication"
"spend-sparrow/internal/core" "spend-sparrow/internal/core"
"spend-sparrow/internal/utils"
"strings" "strings"
) )
@@ -52,7 +51,7 @@ func CrossSiteRequestForgery(auth authentication.Service) func(http.Handler) htt
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)
} }
@@ -63,7 +62,7 @@ func CrossSiteRequestForgery(auth authentication.Service) func(http.Handler) htt
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

@@ -17,8 +17,14 @@ 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 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) {
if !enabled {
next.ServeHTTP(w, r)
return
}
start := time.Now() start := time.Now()
wrapped := &WrappedWriter{ wrapped := &WrappedWriter{
@@ -34,4 +40,5 @@ func Log(next http.Handler) http.Handler {
"path", r.URL.Path, "path", r.URL.Path,
"duration", time.Since(start).String()) "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

@@ -4,7 +4,6 @@ import (
"net/http" "net/http"
"spend-sparrow/internal/core" "spend-sparrow/internal/core"
"spend-sparrow/internal/template" "spend-sparrow/internal/template"
"spend-sparrow/internal/utils"
"github.com/a-h/templ" "github.com/a-h/templ"
) )
@@ -36,7 +35,7 @@ func (handler IndexImpl) handleRootAnd404() http.HandlerFunc {
user := core.GetUser(r) user := core.GetUser(r)
htmx := utils.IsHtmx(r) htmx := core.IsHtmx(r)
var comp templ.Component var comp templ.Component
@@ -46,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()

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
} }

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,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,4 +1,4 @@
package handler package transaction
import ( import (
"fmt" "fmt"
@@ -6,10 +6,8 @@ import (
"net/http" "net/http"
"spend-sparrow/internal/account" "spend-sparrow/internal/account"
"spend-sparrow/internal/core" "spend-sparrow/internal/core"
"spend-sparrow/internal/service" "spend-sparrow/internal/treasure_chest"
t "spend-sparrow/internal/template/transaction" "spend-sparrow/internal/treasure_chest_types"
"spend-sparrow/internal/types"
"spend-sparrow/internal/utils"
"strconv" "strconv"
"time" "time"
@@ -17,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 account.Service account account.Service
treasureChest service.TreasureChest treasureChest treasure_chest.Service
r *core.Render r *core.Render
} }
func NewTransaction(s service.Transaction, account account.Service, treasureChest service.TreasureChest, r *core.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,
@@ -37,7 +39,7 @@ func NewTransaction(s service.Transaction, account account.Service, 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())
@@ -45,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) {
core.UpdateSpan(r) core.UpdateSpan(r)
user := core.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"),
@@ -82,23 +84,23 @@ func (h TransactionImpl) handleTransactionPage() http.HandlerFunc {
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) {
core.UpdateSpan(r) core.UpdateSpan(r)
user := core.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
} }
@@ -116,7 +118,7 @@ func (h TransactionImpl) handleTransactionItemComp() http.HandlerFunc {
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
} }
@@ -129,22 +131,22 @@ func (h TransactionImpl) handleTransactionItemComp() http.HandlerFunc {
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) {
core.UpdateSpan(r) core.UpdateSpan(r)
user := core.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
} }
@@ -189,7 +191,7 @@ func (h TransactionImpl) handleUpdateTransaction() http.HandlerFunc {
core.HandleError(w, r, fmt.Errorf("could not parse value: %w", core.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 {
@@ -197,7 +199,7 @@ func (h TransactionImpl) handleUpdateTransaction() http.HandlerFunc {
return return
} }
input := types.Transaction{ input := Transaction{
Id: id, Id: id,
AccountId: accountId, AccountId: accountId,
TreasureChestId: treasureChestId, TreasureChestId: treasureChestId,
@@ -207,7 +209,7 @@ 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 {
@@ -235,18 +237,18 @@ func (h TransactionImpl) handleUpdateTransaction() http.HandlerFunc {
} }
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) {
core.UpdateSpan(r) core.UpdateSpan(r)
user := core.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
} }
@@ -256,17 +258,17 @@ func (h TransactionImpl) handleRecalculate() http.HandlerFunc {
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) {
core.UpdateSpan(r) core.UpdateSpan(r)
user := core.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
} }
@@ -280,7 +282,7 @@ func (h TransactionImpl) handleDeleteTransaction() http.HandlerFunc {
} }
} }
func (h TransactionImpl) getTransactionData(accounts []*account.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,4 +1,4 @@
package service package transaction
import ( import (
"context" "context"
@@ -7,8 +7,8 @@ import (
"log/slog" "log/slog"
"spend-sparrow/internal/auth_types" "spend-sparrow/internal/auth_types"
"spend-sparrow/internal/core" "spend-sparrow/internal/core"
"spend-sparrow/internal/db" "spend-sparrow/internal/transaction_recurring"
"spend-sparrow/internal/types" "spend-sparrow/internal/treasure_chest_types"
"strconv" "strconv"
"time" "time"
@@ -18,31 +18,32 @@ import (
const page_size = 25 const page_size = 25
type Transaction interface { type Service interface {
Add(ctx context.Context, tx *sqlx.Tx, user *auth_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 *auth_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 *auth_types.User, id string) (*types.Transaction, error) Get(ctx context.Context, user *auth_types.User, id string) (*Transaction, error)
GetAll(ctx context.Context, user *auth_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 *auth_types.User, id string) error Delete(ctx context.Context, user *auth_types.User, id string) error
RecalculateBalances(ctx context.Context, user *auth_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 core.Clock clock core.Clock
random core.Random random core.Random
} }
func NewTransaction(db *sqlx.DB, random core.Random, clock core.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 *auth_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, core.ErrUnauthorized return nil, core.ErrUnauthorized
} }
@@ -52,7 +53,7 @@ func (s TransactionImpl) Add(ctx context.Context, tx *sqlx.Tx, user *auth_types.
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
} }
@@ -71,7 +72,7 @@ func (s TransactionImpl) Add(ctx context.Context, tx *sqlx.Tx, user *auth_types.
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
} }
@@ -81,7 +82,7 @@ func (s TransactionImpl) Add(ctx context.Context, tx *sqlx.Tx, user *auth_types.
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
} }
@@ -92,7 +93,7 @@ func (s TransactionImpl) Add(ctx context.Context, tx *sqlx.Tx, user *auth_types.
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
} }
@@ -100,7 +101,7 @@ func (s TransactionImpl) Add(ctx context.Context, tx *sqlx.Tx, user *auth_types.
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
} }
@@ -109,13 +110,13 @@ func (s TransactionImpl) Add(ctx context.Context, tx *sqlx.Tx, user *auth_types.
return transaction, nil return transaction, nil
} }
func (s TransactionImpl) Update(ctx context.Context, user *auth_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, core.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
} }
@@ -123,9 +124,9 @@ func (s TransactionImpl) Update(ctx context.Context, user *auth_types.User, inpu
_ = 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, core.ErrNotFound) { if errors.Is(err, core.ErrNotFound) {
return nil, fmt.Errorf("transaction %v not found: %w", input.Id, core.ErrBadRequest) return nil, fmt.Errorf("transaction %v not found: %w", input.Id, core.ErrBadRequest)
@@ -138,7 +139,7 @@ func (s TransactionImpl) Update(ctx context.Context, user *auth_types.User, inpu
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
} }
@@ -148,7 +149,7 @@ func (s TransactionImpl) Update(ctx context.Context, user *auth_types.User, inpu
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
} }
@@ -164,7 +165,7 @@ func (s TransactionImpl) Update(ctx context.Context, user *auth_types.User, inpu
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
} }
@@ -174,7 +175,7 @@ func (s TransactionImpl) Update(ctx context.Context, user *auth_types.User, inpu
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
} }
@@ -194,13 +195,13 @@ func (s TransactionImpl) Update(ctx context.Context, user *auth_types.User, inpu
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
} }
@@ -208,7 +209,7 @@ func (s TransactionImpl) Update(ctx context.Context, user *auth_types.User, inpu
return transaction, nil return transaction, nil
} }
func (s TransactionImpl) Get(ctx context.Context, user *auth_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, core.ErrUnauthorized return nil, core.ErrUnauthorized
} }
@@ -218,9 +219,9 @@ func (s TransactionImpl) Get(ctx context.Context, user *auth_types.User, id stri
return nil, fmt.Errorf("could not parse Id: %w", core.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, core.ErrNotFound) { if errors.Is(err, core.ErrNotFound) {
return nil, fmt.Errorf("transaction %v not found: %w", id, core.ErrBadRequest) return nil, fmt.Errorf("transaction %v not found: %w", id, core.ErrBadRequest)
@@ -231,7 +232,7 @@ func (s TransactionImpl) Get(ctx context.Context, user *auth_types.User, id stri
return &transaction, nil return &transaction, nil
} }
func (s TransactionImpl) GetAll(ctx context.Context, user *auth_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, core.ErrUnauthorized return nil, core.ErrUnauthorized
} }
@@ -251,7 +252,7 @@ func (s TransactionImpl) GetAll(ctx context.Context, user *auth_types.User, filt
} }
} }
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"
@@ -271,7 +272,7 @@ func (s TransactionImpl) GetAll(ctx context.Context, user *auth_types.User, filt
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
} }
@@ -279,7 +280,7 @@ func (s TransactionImpl) GetAll(ctx context.Context, user *auth_types.User, filt
return transactions, nil return transactions, nil
} }
func (s TransactionImpl) Delete(ctx context.Context, user *auth_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 core.ErrUnauthorized return core.ErrUnauthorized
} }
@@ -290,7 +291,7 @@ func (s TransactionImpl) Delete(ctx context.Context, user *auth_types.User, id s
} }
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
} }
@@ -298,9 +299,9 @@ func (s TransactionImpl) Delete(ctx context.Context, user *auth_types.User, id s
_ = 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
} }
@@ -311,7 +312,7 @@ func (s TransactionImpl) Delete(ctx context.Context, user *auth_types.User, id s
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, core.ErrNotFound) { if err != nil && !errors.Is(err, core.ErrNotFound) {
return err return err
} }
@@ -323,20 +324,20 @@ func (s TransactionImpl) Delete(ctx context.Context, user *auth_types.User, id s
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, core.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
} }
@@ -344,13 +345,13 @@ func (s TransactionImpl) Delete(ctx context.Context, user *auth_types.User, id s
return nil return nil
} }
func (s TransactionImpl) RecalculateBalances(ctx context.Context, user *auth_types.User) error { func (s ServiceImpl) RecalculateBalances(ctx context.Context, user *auth_types.User) error {
if user == nil { if user == nil {
return core.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
} }
@@ -362,7 +363,7 @@ func (s TransactionImpl) RecalculateBalances(ctx context.Context, user *auth_typ
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, core.ErrNotFound) { if err != nil && !errors.Is(err, core.ErrNotFound) {
return err return err
} }
@@ -371,7 +372,7 @@ func (s TransactionImpl) RecalculateBalances(ctx context.Context, user *auth_typ
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, core.ErrNotFound) { if err != nil && !errors.Is(err, core.ErrNotFound) {
return err return err
} }
@@ -380,7 +381,7 @@ func (s TransactionImpl) RecalculateBalances(ctx context.Context, user *auth_typ
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, core.ErrNotFound) { if err != nil && !errors.Is(err, core.ErrNotFound) {
return err return err
} }
@@ -391,10 +392,10 @@ func (s TransactionImpl) RecalculateBalances(ctx context.Context, user *auth_typ
} }
}() }()
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
} }
@@ -405,7 +406,7 @@ func (s TransactionImpl) RecalculateBalances(ctx context.Context, user *auth_typ
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
} }
@@ -419,7 +420,7 @@ func (s TransactionImpl) RecalculateBalances(ctx context.Context, user *auth_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 RecalculateBalances", r, err) err = core.TransformAndLogDbError(ctx, "transaction RecalculateBalances", r, err)
if err != nil { if err != nil {
return err return err
} }
@@ -429,7 +430,7 @@ func (s TransactionImpl) RecalculateBalances(ctx context.Context, user *auth_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 RecalculateBalances", r, err) err = core.TransformAndLogDbError(ctx, "transaction RecalculateBalances", r, err)
if err != nil { if err != nil {
return err return err
} }
@@ -437,7 +438,7 @@ func (s TransactionImpl) RecalculateBalances(ctx context.Context, user *auth_typ
} }
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
} }
@@ -445,7 +446,63 @@ func (s TransactionImpl) RecalculateBalances(ctx context.Context, user *auth_typ
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
@@ -475,7 +532,7 @@ 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
} }
@@ -486,9 +543,9 @@ func (s TransactionImpl) validateAndEnrichTransaction(ctx context.Context, tx *s
} }
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, core.ErrNotFound) { if errors.Is(err, core.ErrNotFound) {
return nil, fmt.Errorf("treasure chest not found: %w", core.ErrBadRequest) return nil, fmt.Errorf("treasure chest not found: %w", core.ErrBadRequest)
@@ -501,19 +558,19 @@ func (s TransactionImpl) validateAndEnrichTransaction(ctx context.Context, tx *s
} }
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,
@@ -536,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,13 +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 "spend-sparrow/internal/account" "spend-sparrow/internal/core"
import "github.com/google/uuid" "spend-sparrow/internal/template/svg"
"spend-sparrow/internal/treasure_chest_types"
"time"
)
templ Transaction(items templ.Component, filter types.TransactionItemsFilter, accounts []*account.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
@@ -62,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"
> >
<div class="w-3">
@svg.Plus() @svg.Plus()
</div>
<p>New Transaction</p> <p>New Transaction</p>
</button> </button>
</div> </div>
@@ -88,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)
@@ -96,7 +101,7 @@ templ TransactionItems(transactions []*types.Transaction, accounts, treasureChes
</div> </div>
} }
templ EditTransaction(transaction *types.Transaction, accounts []*account.Account, treasureChests []*types.TreasureChest) { templ EditTransaction(transaction *Transaction, accounts []*account.Account, treasureChests []*treasure_chest_types.TreasureChest) {
{{ {{
var ( var (
timestamp time.Time timestamp time.Time
@@ -220,7 +225,7 @@ templ EditTransaction(transaction *types.Transaction, accounts []*account.Accoun
</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 {
@@ -273,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"

View File

@@ -1,44 +1,40 @@
package handler package transaction_recurring
import ( import (
"net/http" "net/http"
"spend-sparrow/internal/auth_types" "spend-sparrow/internal/auth_types"
"spend-sparrow/internal/core" "spend-sparrow/internal/core"
"spend-sparrow/internal/service"
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 *core.Render r *core.Render
} }
func NewTransactionRecurring(s service.TransactionRecurring, r *core.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) {
core.UpdateSpan(r) core.UpdateSpan(r)
user := core.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
} }
@@ -49,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) {
core.UpdateSpan(r) core.UpdateSpan(r)
user := core.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"),
@@ -88,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) {
core.UpdateSpan(r) core.UpdateSpan(r)
user := core.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
} }
@@ -112,11 +108,11 @@ func (h TransactionRecurringImpl) handleDeleteTransactionRecurring() http.Handle
} }
} }
func (h TransactionRecurringImpl) renderItems(w http.ResponseWriter, r *http.Request, user *auth_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)
@@ -132,6 +128,6 @@ func (h TransactionRecurringImpl) renderItems(w http.ResponseWriter, r *http.Req
} }
} }
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"
@@ -8,8 +8,7 @@ import (
"math" "math"
"spend-sparrow/internal/auth_types" "spend-sparrow/internal/auth_types"
"spend-sparrow/internal/core" "spend-sparrow/internal/core"
"spend-sparrow/internal/db" "spend-sparrow/internal/treasure_chest_types"
"spend-sparrow/internal/types"
"strconv" "strconv"
"time" "time"
@@ -17,43 +16,43 @@ import (
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
) )
type TransactionRecurring interface { const (
Add(ctx context.Context, user *auth_types.User, transactionRecurring types.TransactionRecurringInput) (*types.TransactionRecurring, error) DECIMALS_MULTIPLIER = 100
Update(ctx context.Context, user *auth_types.User, transactionRecurring types.TransactionRecurringInput) (*types.TransactionRecurring, error) )
GetAll(ctx context.Context, user *auth_types.User) ([]*types.TransactionRecurring, error)
GetAllByAccount(ctx context.Context, user *auth_types.User, accountId string) ([]*types.TransactionRecurring, error)
GetAllByTreasureChest(ctx context.Context, user *auth_types.User, treasureChestId string) ([]*types.TransactionRecurring, error)
Delete(ctx context.Context, user *auth_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 core.Clock clock core.Clock
random core.Random random core.Random
transaction Transaction
} }
func NewTransactionRecurring(db *sqlx.DB, random core.Random, clock core.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 *auth_types.User, user *auth_types.User,
transactionRecurringInput types.TransactionRecurringInput, transactionRecurringInput TransactionRecurringInput,
) (*types.TransactionRecurring, error) { ) (*TransactionRecurring, error) {
if user == nil { if user == nil {
return nil, core.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
} }
@@ -72,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
} }
@@ -86,10 +85,10 @@ 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 *auth_types.User, user *auth_types.User,
input types.TransactionRecurringInput, input TransactionRecurringInput,
) (*types.TransactionRecurring, error) { ) (*TransactionRecurring, error) {
if user == nil { if user == nil {
return nil, core.ErrUnauthorized return nil, core.ErrUnauthorized
} }
@@ -100,7 +99,7 @@ func (s TransactionRecurringImpl) Update(ctx context.Context,
} }
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
} }
@@ -108,9 +107,9 @@ 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, core.ErrNotFound) { if errors.Is(err, core.ErrNotFound) {
return nil, fmt.Errorf("transactionRecurring %v not found: %w", input.Id, core.ErrBadRequest) return nil, fmt.Errorf("transactionRecurring %v not found: %w", input.Id, core.ErrBadRequest)
@@ -137,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
} }
@@ -151,19 +150,19 @@ func (s TransactionRecurringImpl) Update(ctx context.Context,
return transactionRecurring, nil return transactionRecurring, nil
} }
func (s TransactionRecurringImpl) GetAll(ctx context.Context, user *auth_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, core.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
} }
@@ -171,7 +170,7 @@ func (s TransactionRecurringImpl) GetAll(ctx context.Context, user *auth_types.U
return transactionRecurrings, nil return transactionRecurrings, nil
} }
func (s TransactionRecurringImpl) GetAllByAccount(ctx context.Context, user *auth_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, core.ErrUnauthorized return nil, core.ErrUnauthorized
} }
@@ -183,7 +182,7 @@ func (s TransactionRecurringImpl) GetAllByAccount(ctx context.Context, user *aut
} }
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
} }
@@ -193,7 +192,7 @@ func (s TransactionRecurringImpl) GetAllByAccount(ctx context.Context, user *aut
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, core.ErrNotFound) { if errors.Is(err, core.ErrNotFound) {
return nil, fmt.Errorf("account %v not found: %w", accountId, core.ErrBadRequest) return nil, fmt.Errorf("account %v not found: %w", accountId, core.ErrBadRequest)
@@ -201,7 +200,7 @@ func (s TransactionRecurringImpl) GetAllByAccount(ctx context.Context, user *aut
return nil, core.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
@@ -209,13 +208,13 @@ func (s TransactionRecurringImpl) GetAllByAccount(ctx context.Context, user *aut
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
} }
@@ -223,10 +222,10 @@ func (s TransactionRecurringImpl) GetAllByAccount(ctx context.Context, user *aut
return transactionRecurrings, nil return transactionRecurrings, nil
} }
func (s TransactionRecurringImpl) GetAllByTreasureChest(ctx context.Context, func (s ServiceImpl) GetAllByTreasureChest(ctx context.Context,
user *auth_types.User, user *auth_types.User,
treasureChestId string, treasureChestId string,
) ([]*types.TransactionRecurring, error) { ) ([]*TransactionRecurring, error) {
if user == nil { if user == nil {
return nil, core.ErrUnauthorized return nil, core.ErrUnauthorized
} }
@@ -238,7 +237,7 @@ func (s TransactionRecurringImpl) GetAllByTreasureChest(ctx context.Context,
} }
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
} }
@@ -248,7 +247,7 @@ 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, core.ErrNotFound) { if errors.Is(err, core.ErrNotFound) {
return nil, fmt.Errorf("treasurechest %v not found: %w", treasureChestId, core.ErrBadRequest) return nil, fmt.Errorf("treasurechest %v not found: %w", treasureChestId, core.ErrBadRequest)
@@ -256,7 +255,7 @@ func (s TransactionRecurringImpl) GetAllByTreasureChest(ctx context.Context,
return nil, core.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
@@ -264,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
} }
@@ -278,7 +277,7 @@ func (s TransactionRecurringImpl) GetAllByTreasureChest(ctx context.Context,
return transactionRecurrings, nil return transactionRecurrings, nil
} }
func (s TransactionRecurringImpl) Delete(ctx context.Context, user *auth_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 core.ErrUnauthorized return core.ErrUnauthorized
} }
@@ -289,7 +288,7 @@ func (s TransactionRecurringImpl) Delete(ctx context.Context, user *auth_types.U
} }
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
} }
@@ -297,21 +296,21 @@ func (s TransactionRecurringImpl) Delete(ctx context.Context, user *auth_types.U
_ = 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
} }
@@ -319,69 +318,13 @@ func (s TransactionRecurringImpl) Delete(ctx context.Context, user *auth_types.U
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 := &auth_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
@@ -422,7 +365,7 @@ func (s TransactionRecurringImpl) validateAndEnrichTransactionRecurring(
} }
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
} }
@@ -441,9 +384,9 @@ func (s TransactionRecurringImpl) validateAndEnrichTransactionRecurring(
return nil, fmt.Errorf("could not parse treasureChestId: %w", core.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, core.ErrNotFound) { if errors.Is(err, core.ErrNotFound) {
return nil, fmt.Errorf("treasure chest not found: %w", core.ErrBadRequest) return nil, fmt.Errorf("treasure chest not found: %w", core.ErrBadRequest)
@@ -473,13 +416,13 @@ func (s TransactionRecurringImpl) validateAndEnrichTransactionRecurring(
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
} }
@@ -505,7 +448,7 @@ func (s TransactionRecurringImpl) validateAndEnrichTransactionRecurring(
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,50 +1,47 @@
package handler package treasure_chest
import ( import (
"net/http" "net/http"
"spend-sparrow/internal/core" "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 *core.Render r *core.Render
} }
func NewTreasureChest(s service.TreasureChest, transactionRecurring service.TransactionRecurring, r *core.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) {
core.UpdateSpan(r) core.UpdateSpan(r)
user := core.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
} }
@@ -62,18 +59,18 @@ func (h TreasureChestImpl) handleTreasureChestPage() http.HandlerFunc {
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) {
core.UpdateSpan(r) core.UpdateSpan(r)
user := core.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
} }
@@ -85,7 +82,7 @@ func (h TreasureChestImpl) handleTreasureChestItemComp() http.HandlerFunc {
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
} }
@@ -101,31 +98,31 @@ func (h TreasureChestImpl) handleTreasureChestItemComp() http.HandlerFunc {
core.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) {
core.UpdateSpan(r) core.UpdateSpan(r)
user := core.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")
@@ -151,21 +148,21 @@ func (h TreasureChestImpl) handleUpdateTreasureChest() http.HandlerFunc {
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) {
core.UpdateSpan(r) core.UpdateSpan(r)
user := core.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
} }
@@ -179,9 +176,9 @@ func (h TreasureChestImpl) handleDeleteTreasureChest() http.HandlerFunc {
} }
} }
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"
@@ -8,36 +8,35 @@ import (
"slices" "slices"
"spend-sparrow/internal/auth_types" "spend-sparrow/internal/auth_types"
"spend-sparrow/internal/core" "spend-sparrow/internal/core"
"spend-sparrow/internal/db" "spend-sparrow/internal/treasure_chest_types"
"spend-sparrow/internal/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 *auth_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 *auth_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 *auth_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 *auth_types.User) ([]*types.TreasureChest, error) GetAll(ctx context.Context, user *auth_types.User) ([]*treasure_chest_types.TreasureChest, error)
Delete(ctx context.Context, user *auth_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 core.Clock clock core.Clock
random core.Random random core.Random
} }
func NewTreasureChest(db *sqlx.DB, random core.Random, clock core.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 *auth_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, core.ErrUnauthorized return nil, core.ErrUnauthorized
} }
@@ -47,7 +46,7 @@ func (s TreasureChestImpl) Add(ctx context.Context, user *auth_types.User, paren
return nil, core.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
} }
@@ -64,7 +63,7 @@ func (s TreasureChestImpl) Add(ctx context.Context, user *auth_types.User, paren
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,
@@ -82,7 +81,7 @@ func (s TreasureChestImpl) Add(ctx context.Context, user *auth_types.User, paren
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
} }
@@ -90,11 +89,11 @@ func (s TreasureChestImpl) Add(ctx context.Context, user *auth_types.User, paren
return treasureChest, nil return treasureChest, nil
} }
func (s TreasureChestImpl) Update(ctx context.Context, user *auth_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, core.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
} }
@@ -105,7 +104,7 @@ func (s TreasureChestImpl) Update(ctx context.Context, user *auth_types.User, id
} }
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
} }
@@ -113,9 +112,9 @@ func (s TreasureChestImpl) Update(ctx context.Context, user *auth_types.User, id
_ = 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, core.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)
@@ -131,7 +130,7 @@ func (s TreasureChestImpl) Update(ctx context.Context, user *auth_types.User, id
} }
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
} }
@@ -158,13 +157,13 @@ func (s TreasureChestImpl) Update(ctx context.Context, user *auth_types.User, id
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
} }
@@ -172,7 +171,7 @@ func (s TreasureChestImpl) Update(ctx context.Context, user *auth_types.User, id
return treasureChest, nil return treasureChest, nil
} }
func (s TreasureChestImpl) Get(ctx context.Context, user *auth_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, core.ErrUnauthorized return nil, core.ErrUnauthorized
} }
@@ -182,9 +181,9 @@ func (s TreasureChestImpl) Get(ctx context.Context, user *auth_types.User, id st
return nil, fmt.Errorf("could not parse Id: %w", core.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, core.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)
@@ -195,22 +194,22 @@ func (s TreasureChestImpl) Get(ctx context.Context, user *auth_types.User, id st
return &treasureChest, nil return &treasureChest, nil
} }
func (s TreasureChestImpl) GetAll(ctx context.Context, user *auth_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, core.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 *auth_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 core.ErrUnauthorized return core.ErrUnauthorized
} }
@@ -221,7 +220,7 @@ func (s TreasureChestImpl) Delete(ctx context.Context, user *auth_types.User, id
} }
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
} }
@@ -231,7 +230,7 @@ func (s TreasureChestImpl) Delete(ctx context.Context, user *auth_types.User, id
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
} }
@@ -244,7 +243,7 @@ func (s TreasureChestImpl) Delete(ctx context.Context, user *auth_types.User, id
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
} }
@@ -256,7 +255,7 @@ func (s TreasureChestImpl) Delete(ctx context.Context, user *auth_types.User, id
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
} }
@@ -265,13 +264,13 @@ func (s TreasureChestImpl) Delete(ctx context.Context, user *auth_types.User, id
} }
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
} }
@@ -279,12 +278,12 @@ func (s TreasureChestImpl) Delete(ctx context.Context, user *auth_types.User, id
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 {
@@ -294,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)
}) })
@@ -303,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"
> >
<div class="w-3">
@svg.Plus() @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,7 +28,7 @@ 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
@@ -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"
> >
<div class="w-3">
@svg.Plus() @svg.Plus()
</div>
<p>New Monthly Transaction</p> <p>New Monthly Transaction</p>
</button> </button>
</div> </div>
@@ -115,7 +122,7 @@ 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 := ""
@@ -131,14 +138,14 @@ templ TreasureChestItem(treasureChest *types.TreasureChest, monthlySums map[uuid
<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

@@ -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;

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

@@ -5,7 +5,6 @@ import (
"spend-sparrow/internal/auth_types" "spend-sparrow/internal/auth_types"
"spend-sparrow/internal/authentication" "spend-sparrow/internal/authentication"
"spend-sparrow/internal/core" "spend-sparrow/internal/core"
"spend-sparrow/internal/db"
"testing" "testing"
"time" "time"
@@ -29,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)
} }

View File

@@ -5,7 +5,6 @@ import (
"spend-sparrow/internal/auth_types" "spend-sparrow/internal/auth_types"
"spend-sparrow/internal/authentication" "spend-sparrow/internal/authentication"
"spend-sparrow/internal/core" "spend-sparrow/internal/core"
"spend-sparrow/internal/types"
"spend-sparrow/mocks" "spend-sparrow/mocks"
"strings" "strings"
"testing" "testing"
@@ -18,7 +17,7 @@ import (
) )
var ( var (
settings = types.Settings{ settings = core.Settings{
Port: "", Port: "",
BaseUrl: "", BaseUrl: "",
Environment: "test", Environment: "test",