1 Commits

Author SHA1 Message Date
061d63a8ad feat(ui): #111 draft for (unfinished) mobile transaction-ui
All checks were successful
Build Docker Image / Build-Docker-Image (push) Successful in 6m43s
2025-08-24 15:35:00 +02:00
103 changed files with 2135 additions and 3319 deletions

View File

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

View File

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

2
.gitignore vendored
View File

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

View File

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

View File

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

2
.nvmrc
View File

@@ -1 +1 @@
24.13.0 24.6.0

View File

@@ -1,4 +1,4 @@
FROM golang:1.25.6@sha256:fc24d3881a021e7b968a4610fc024fba749f98fe5c07d4f28e6cfa14dc65a84c AS builder_go FROM golang:1.25.0@sha256:4859242e2c392ddc9d3225fd41181c00a443d9cc005b8e5131ce164106fbc676 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.13.0@sha256:b2b2184ba9b78c022e1d6a7924ec6fba577adf28f15c9d9c457730cc4ad3807a AS builder_node FROM node:22.18.0@sha256:3266bc9e8bee1acc8a77386eefaf574987d2729b8c5ec35b0dbd6ddbc40b0ce2 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.3@sha256:5cf544fad978371b3df255b61e209b373583cb88b733475c86e49faa15ac2104 FROM debian:13.0@sha256:6d87375016340817ac2391e670971725a9981cfc24e221c47734681ed0f6c0f5
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
View File

@@ -1,32 +0,0 @@
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,14 +1,12 @@
#!/bin/bash #!/bin/sh
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 --cmd="go run ." & templ generate --watch --proxy="http://localhost:8080" --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 -r read -n1 -s
kill "$(jobs -p)" kill $(jobs -p)

70
go.mod
View File

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

143
go.sum
View File

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

View File

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

View File

@@ -1,97 +0,0 @@
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
}

View File

@@ -1,184 +0,0 @@
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")
}
}

View File

@@ -1,119 +0,0 @@
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

@@ -1,138 +0,0 @@
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>
}

View File

@@ -1,20 +0,0 @@
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

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

View File

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

View File

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

39
internal/db/error.go Normal file
View File

@@ -0,0 +1,39 @@
package db
import (
"context"
"database/sql"
"errors"
"log/slog"
"spend-sparrow/internal/types"
)
var (
ErrNotFound = errors.New("the value does not exist")
ErrAlreadyExists = errors.New("row already exists")
)
func TransformAndLogDbError(ctx context.Context, module string, r sql.Result, err error) error {
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
slog.ErrorContext(ctx, "database sql", "module", module, "err", err)
return types.ErrInternal
}
if r != nil {
rows, err := r.RowsAffected()
if err != nil {
slog.ErrorContext(ctx, "database rows affected", "module", module, "err", err)
return types.ErrInternal
}
if rows == 0 {
slog.InfoContext(ctx, "row not found", "module", module)
return ErrNotFound
}
}
return nil
}

View File

@@ -1,9 +1,10 @@
package core package db
import ( import (
"context" "context"
"errors" "errors"
"log/slog" "log/slog"
"spend-sparrow/internal/types"
"github.com/golang-migrate/migrate/v4" "github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/sqlite3" "github.com/golang-migrate/migrate/v4/database/sqlite3"
@@ -24,7 +25,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 ErrInternal return types.ErrInternal
} }
m, err := migrate.NewWithDatabaseInstance( m, err := migrate.NewWithDatabaseInstance(
@@ -33,14 +34,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 ErrInternal return types.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 ErrInternal return types.ErrInternal
} }
return nil return nil

View File

@@ -1,23 +1,19 @@
package internal package internal
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"net/http" "spend-sparrow/internal/db"
"os/signal"
"spend-sparrow/internal/account"
"spend-sparrow/internal/authentication"
"spend-sparrow/internal/budget"
"spend-sparrow/internal/core"
"spend-sparrow/internal/dashboard"
"spend-sparrow/internal/handler" "spend-sparrow/internal/handler"
"spend-sparrow/internal/handler/middleware" "spend-sparrow/internal/handler/middleware"
"spend-sparrow/internal/tag" "spend-sparrow/internal/log"
"spend-sparrow/internal/transaction" "spend-sparrow/internal/service"
"spend-sparrow/internal/transaction_recurring" "spend-sparrow/internal/types"
"spend-sparrow/internal/treasure_chest"
"context"
"net/http"
"os/signal"
"sync" "sync"
"syscall" "syscall"
"time" "time"
@@ -31,8 +27,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()
isOtelEnabled := core.IsOtelEnabled(env) otelEnabled := types.IsOtelEnabled(env)
if isOtelEnabled { if otelEnabled {
// 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 {
@@ -48,19 +44,19 @@ func Run(ctx context.Context, database *sqlx.DB, migrationsPrefix string, env fu
cancel() cancel()
}() }()
slog.SetDefault(core.NewLogPropagator()) slog.SetDefault(log.NewLogPropagator())
} }
slog.InfoContext(ctx, "Starting server...") slog.InfoContext(ctx, "Starting server...")
// init server settings // init server settings
serverSettings, err := core.NewSettingsFromEnv(ctx, env) serverSettings, err := types.NewSettingsFromEnv(ctx, env)
if err != nil { if err != nil {
return err return err
} }
// init db // init db
err = core.RunMigrations(ctx, database, migrationsPrefix) err = db.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)
} }
@@ -68,7 +64,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, isOtelEnabled), Handler: createHandlerWithServices(ctx, database, serverSettings),
ReadHeaderTimeout: 2 * time.Second, ReadHeaderTimeout: 2 * time.Second,
} }
go startServer(ctx, httpServer) go startServer(ctx, httpServer)
@@ -107,38 +103,32 @@ func shutdownServer(ctx context.Context, s *http.Server, wg *sync.WaitGroup) {
} }
} }
func createHandlerWithServices(ctx context.Context, d *sqlx.DB, serverSettings *core.Settings, isOtelEnabled bool) http.Handler { func createHandlerWithServices(ctx context.Context, d *sqlx.DB, serverSettings *types.Settings) http.Handler {
var router = http.NewServeMux() var router = http.NewServeMux()
authDb := authentication.NewDbSqlite(d) authDb := db.NewAuthSqlite(d)
budgetDb := budget.NewDbSqlite(d)
tagDb := tag.NewDbSqlite(d)
randomService := core.NewRandom() randomService := service.NewRandom()
clockService := core.NewClock() clockService := service.NewClock()
mailService := core.NewMail(serverSettings) mailService := service.NewMail(serverSettings)
authService := authentication.NewService(authDb, randomService, clockService, mailService, serverSettings) authService := service.NewAuth(authDb, randomService, clockService, mailService, serverSettings)
accountService := account.NewServiceImpl(d, randomService, clockService) accountService := service.NewAccount(d, randomService, clockService)
treasureChestService := treasure_chest.NewService(d, randomService, clockService) treasureChestService := service.NewTreasureChest(d, randomService, clockService)
transactionService := transaction.NewService(d, randomService, clockService) transactionService := service.NewTransaction(d, randomService, clockService)
transactionRecurringService := transaction_recurring.NewService(d, randomService, clockService) transactionRecurringService := service.NewTransactionRecurring(d, randomService, clockService, transactionService)
dashboardService := dashboard.NewService(d) dashboardService := service.NewDashboard(d)
budgetService := budget.NewService(budgetDb, randomService, clockService)
tagService := tag.NewService(tagDb, randomService, clockService)
render := core.NewRender() render := handler.NewRender()
indexHandler := handler.NewIndex(render, clockService) indexHandler := handler.NewIndex(render, clockService)
dashboardHandler := dashboard.NewHandler(render, dashboardService, treasureChestService) dashboardHandler := handler.NewDashboard(render, dashboardService, treasureChestService)
authHandler := authentication.NewHandler(authService, render) authHandler := handler.NewAuth(authService, render)
accountHandler := account.NewHandler(accountService, render) accountHandler := handler.NewAccount(accountService, render)
treasureChestHandler := treasure_chest.NewHandler(treasureChestService, transactionRecurringService, render) treasureChestHandler := handler.NewTreasureChest(treasureChestService, transactionRecurringService, render)
transactionHandler := transaction.NewHandler(transactionService, accountService, treasureChestService, render) transactionHandler := handler.NewTransaction(transactionService, accountService, treasureChestService, render)
transactionRecurringHandler := transaction_recurring.NewHandler(transactionRecurringService, render) transactionRecurringHandler := handler.NewTransactionRecurring(transactionRecurringService, render)
budgetHandler := budget.NewHandler(budgetService, render)
tagHandler := tag.NewHandler(tagService, render)
go dailyTaskTimer(ctx, transactionService, authService) go dailyTaskTimer(ctx, transactionRecurringService, authService)
indexHandler.Handle(router) indexHandler.Handle(router)
dashboardHandler.Handle(router) dashboardHandler.Handle(router)
@@ -147,8 +137,6 @@ 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/"))))
@@ -160,7 +148,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(isOtelEnabled), middleware.Log,
) )
wrapper = otelhttp.NewHandler(wrapper, "http.request") wrapper = otelhttp.NewHandler(wrapper, "http.request")
@@ -168,8 +156,8 @@ func createHandlerWithServices(ctx context.Context, d *sqlx.DB, serverSettings *
return wrapper return wrapper
} }
func dailyTaskTimer(ctx context.Context, transaction transaction.Service, auth authentication.Service) { func dailyTaskTimer(ctx context.Context, transactionRecurring service.TransactionRecurring, auth service.Auth) {
runDailyTasks(ctx, transaction, auth) runDailyTasks(ctx, transactionRecurring, auth)
ticker := time.NewTicker(24 * time.Hour) ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop() defer ticker.Stop()
@@ -178,13 +166,13 @@ func dailyTaskTimer(ctx context.Context, transaction transaction.Service, auth a
case <-ctx.Done(): case <-ctx.Done():
return return
case <-ticker.C: case <-ticker.C:
runDailyTasks(ctx, transaction, auth) runDailyTasks(ctx, transactionRecurring, auth)
} }
} }
} }
func runDailyTasks(ctx context.Context, transaction transaction.Service, auth authentication.Service) { func runDailyTasks(ctx context.Context, transactionRecurring service.TransactionRecurring, auth service.Auth) {
slog.InfoContext(ctx, "Running daily tasks") slog.InfoContext(ctx, "Running daily tasks")
_ = transaction.GenerateRecurringTransactions(ctx) _ = transactionRecurring.GenerateTransactions(ctx)
_ = auth.CleanupSessionsAndTokens(ctx) _ = auth.CleanupSessionsAndTokens(ctx)
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,14 @@
package transaction package handler
import ( import (
"fmt" "fmt"
"math" "math"
"net/http" "net/http"
"spend-sparrow/internal/account" "spend-sparrow/internal/handler/middleware"
"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"
@@ -15,23 +16,19 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
) )
const ( type Transaction interface {
DECIMALS_MULTIPLIER = 100
)
type Handler interface {
Handle(router *http.ServeMux) Handle(router *http.ServeMux)
} }
type HandlerImpl struct { type TransactionImpl struct {
s Service s service.Transaction
account account.Service account service.Account
treasureChest treasure_chest.Service treasureChest service.TreasureChest
r *core.Render r *Render
} }
func NewHandler(s Service, account account.Service, treasureChest treasure_chest.Service, r *core.Render) Handler { func NewTransaction(s service.Transaction, account service.Account, treasureChest service.TreasureChest, r *Render) Transaction {
return HandlerImpl{ return TransactionImpl{
s: s, s: s,
account: account, account: account,
treasureChest: treasureChest, treasureChest: treasureChest,
@@ -39,7 +36,7 @@ func NewHandler(s Service, account account.Service, treasureChest treasure_chest
} }
} }
func (h HandlerImpl) Handle(r *http.ServeMux) { func (h TransactionImpl) 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())
@@ -47,17 +44,17 @@ func (h HandlerImpl) Handle(r *http.ServeMux) {
r.Handle("DELETE /transaction/{id}", h.handleDeleteTransaction()) r.Handle("DELETE /transaction/{id}", h.handleDeleteTransaction())
} }
func (h HandlerImpl) handleTransactionPage() http.HandlerFunc { func (h TransactionImpl) handleTransactionPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
core.UpdateSpan(r) updateSpan(r)
user := core.GetUser(r) user := middleware.GetUser(r)
if user == nil { if user == nil {
core.DoRedirect(w, r, "/auth/signin") utils.DoRedirect(w, r, "/auth/signin")
return return
} }
filter := TransactionItemsFilter{ filter := types.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"),
@@ -66,87 +63,87 @@ func (h HandlerImpl) handleTransactionPage() http.HandlerFunc {
transactions, err := h.s.GetAll(r.Context(), user, filter) transactions, err := h.s.GetAll(r.Context(), user, filter)
if err != nil { if err != nil {
core.HandleError(w, r, err) handleError(w, r, err)
return return
} }
accounts, err := h.account.GetAll(r.Context(), user) accounts, err := h.account.GetAll(r.Context(), user)
if err != nil { if err != nil {
core.HandleError(w, r, err) handleError(w, r, err)
return return
} }
treasureChests, err := h.treasureChest.GetAll(r.Context(), user) treasureChests, err := h.treasureChest.GetAll(r.Context(), user)
if err != nil { if err != nil {
core.HandleError(w, r, err) handleError(w, r, err)
return return
} }
accountMap, treasureChestMap := h.getTransactionData(accounts, treasureChests) accountMap, treasureChestMap := h.getTransactionData(accounts, treasureChests)
items := TransactionItems(transactions, accountMap, treasureChestMap) items := t.TransactionItems(transactions, accountMap, treasureChestMap)
if core.IsHtmx(r) { if utils.IsHtmx(r) {
h.r.Render(r, w, items) h.r.Render(r, w, items)
} else { } else {
comp := TransactionComp(items, filter, accounts, treasureChests) comp := t.Transaction(items, filter, accounts, treasureChests)
h.r.RenderLayout(r, w, comp, user) h.r.RenderLayout(r, w, comp, user)
} }
} }
} }
func (h HandlerImpl) handleTransactionItemComp() http.HandlerFunc { func (h TransactionImpl) handleTransactionItemComp() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
core.UpdateSpan(r) updateSpan(r)
user := core.GetUser(r) user := middleware.GetUser(r)
if user == nil { if user == nil {
core.DoRedirect(w, r, "/auth/signin") utils.DoRedirect(w, r, "/auth/signin")
return return
} }
accounts, err := h.account.GetAll(r.Context(), user) accounts, err := h.account.GetAll(r.Context(), user)
if err != nil { if err != nil {
core.HandleError(w, r, err) handleError(w, r, err)
return return
} }
treasureChests, err := h.treasureChest.GetAll(r.Context(), user) treasureChests, err := h.treasureChest.GetAll(r.Context(), user)
if err != nil { if err != nil {
core.HandleError(w, r, err) handleError(w, r, err)
return return
} }
id := r.PathValue("id") id := r.PathValue("id")
if id == "new" { if id == "new" {
comp := EditTransaction(nil, accounts, treasureChests) comp := t.EditTransaction(nil, accounts, treasureChests)
h.r.Render(r, w, comp) h.r.Render(r, w, comp)
return return
} }
transaction, err := h.s.Get(r.Context(), user, id) transaction, err := h.s.Get(r.Context(), user, id)
if err != nil { if err != nil {
core.HandleError(w, r, err) handleError(w, r, err)
return return
} }
var comp templ.Component var comp templ.Component
if r.URL.Query().Get("edit") == "true" { if r.URL.Query().Get("edit") == "true" {
comp = EditTransaction(transaction, accounts, treasureChests) comp = t.EditTransaction(transaction, accounts, treasureChests)
} else { } else {
accountMap, treasureChestMap := h.getTransactionData(accounts, treasureChests) accountMap, treasureChestMap := h.getTransactionData(accounts, treasureChests)
comp = TransactionItem(transaction, accountMap, treasureChestMap) comp = t.TransactionItem(transaction, accountMap, treasureChestMap)
} }
h.r.Render(r, w, comp) h.r.Render(r, w, comp)
} }
} }
func (h HandlerImpl) handleUpdateTransaction() http.HandlerFunc { func (h TransactionImpl) handleUpdateTransaction() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
core.UpdateSpan(r) updateSpan(r)
user := core.GetUser(r) user := middleware.GetUser(r)
if user == nil { if user == nil {
core.DoRedirect(w, r, "/auth/signin") utils.DoRedirect(w, r, "/auth/signin")
return return
} }
@@ -159,7 +156,7 @@ func (h HandlerImpl) handleUpdateTransaction() http.HandlerFunc {
if idStr != "new" { if idStr != "new" {
id, err = uuid.Parse(idStr) id, err = uuid.Parse(idStr)
if err != nil { if err != nil {
core.HandleError(w, r, fmt.Errorf("could not parse Id: %w", core.ErrBadRequest)) handleError(w, r, fmt.Errorf("could not parse Id: %w", service.ErrBadRequest))
return return
} }
} }
@@ -169,7 +166,7 @@ func (h HandlerImpl) handleUpdateTransaction() http.HandlerFunc {
if accountIdStr != "" { if accountIdStr != "" {
i, err := uuid.Parse(accountIdStr) i, err := uuid.Parse(accountIdStr)
if err != nil { if err != nil {
core.HandleError(w, r, fmt.Errorf("could not parse account id: %w", core.ErrBadRequest)) handleError(w, r, fmt.Errorf("could not parse account id: %w", service.ErrBadRequest))
return return
} }
accountId = &i accountId = &i
@@ -180,7 +177,7 @@ func (h HandlerImpl) handleUpdateTransaction() http.HandlerFunc {
if treasureChestIdStr != "" { if treasureChestIdStr != "" {
i, err := uuid.Parse(treasureChestIdStr) i, err := uuid.Parse(treasureChestIdStr)
if err != nil { if err != nil {
core.HandleError(w, r, fmt.Errorf("could not parse treasure chest id: %w", core.ErrBadRequest)) handleError(w, r, fmt.Errorf("could not parse treasure chest id: %w", service.ErrBadRequest))
return return
} }
treasureChestId = &i treasureChestId = &i
@@ -188,18 +185,18 @@ func (h HandlerImpl) handleUpdateTransaction() http.HandlerFunc {
valueF, err := strconv.ParseFloat(r.FormValue("value"), 64) valueF, err := strconv.ParseFloat(r.FormValue("value"), 64)
if err != nil { if err != nil {
core.HandleError(w, r, fmt.Errorf("could not parse value: %w", core.ErrBadRequest)) handleError(w, r, fmt.Errorf("could not parse value: %w", service.ErrBadRequest))
return return
} }
value := int64(math.Round(valueF * DECIMALS_MULTIPLIER)) value := int64(math.Round(valueF * service.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 {
core.HandleError(w, r, fmt.Errorf("could not parse timestamp: %w", core.ErrBadRequest)) handleError(w, r, fmt.Errorf("could not parse timestamp: %w", service.ErrBadRequest))
return return
} }
input := Transaction{ input := types.Transaction{
Id: id, Id: id,
AccountId: accountId, AccountId: accountId,
TreasureChestId: treasureChestId, TreasureChestId: treasureChestId,
@@ -209,66 +206,66 @@ func (h HandlerImpl) handleUpdateTransaction() http.HandlerFunc {
Description: r.FormValue("description"), Description: r.FormValue("description"),
} }
var transaction *Transaction var transaction *types.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 {
core.HandleError(w, r, err) handleError(w, r, err)
return return
} }
} else { } else {
transaction, err = h.s.Update(r.Context(), user, input) transaction, err = h.s.Update(r.Context(), user, input)
if err != nil { if err != nil {
core.HandleError(w, r, err) handleError(w, r, err)
return return
} }
} }
accounts, err := h.account.GetAll(r.Context(), user) accounts, err := h.account.GetAll(r.Context(), user)
if err != nil { if err != nil {
core.HandleError(w, r, err) handleError(w, r, err)
return return
} }
treasureChests, err := h.treasureChest.GetAll(r.Context(), user) treasureChests, err := h.treasureChest.GetAll(r.Context(), user)
if err != nil { if err != nil {
core.HandleError(w, r, err) handleError(w, r, err)
return return
} }
accountMap, treasureChestMap := h.getTransactionData(accounts, treasureChests) accountMap, treasureChestMap := h.getTransactionData(accounts, treasureChests)
comp := TransactionItem(transaction, accountMap, treasureChestMap) comp := t.TransactionItem(transaction, accountMap, treasureChestMap)
h.r.Render(r, w, comp) h.r.Render(r, w, comp)
} }
} }
func (h HandlerImpl) handleRecalculate() http.HandlerFunc { func (h TransactionImpl) handleRecalculate() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
core.UpdateSpan(r) updateSpan(r)
user := core.GetUser(r) user := middleware.GetUser(r)
if user == nil { if user == nil {
core.DoRedirect(w, r, "/auth/signin") utils.DoRedirect(w, r, "/auth/signin")
return return
} }
err := h.s.RecalculateBalances(r.Context(), user) err := h.s.RecalculateBalances(r.Context(), user)
if err != nil { if err != nil {
core.HandleError(w, r, err) handleError(w, r, err)
return return
} }
core.TriggerToastWithStatus(r.Context(), w, r, "success", "Balances recalculated, please refresh", http.StatusOK) utils.TriggerToastWithStatus(r.Context(), w, r, "success", "Balances recalculated, please refresh", http.StatusOK)
} }
} }
func (h HandlerImpl) handleDeleteTransaction() http.HandlerFunc { func (h TransactionImpl) handleDeleteTransaction() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
core.UpdateSpan(r) updateSpan(r)
user := core.GetUser(r) user := middleware.GetUser(r)
if user == nil { if user == nil {
core.DoRedirect(w, r, "/auth/signin") utils.DoRedirect(w, r, "/auth/signin")
return return
} }
@@ -276,13 +273,13 @@ func (h HandlerImpl) handleDeleteTransaction() http.HandlerFunc {
err := h.s.Delete(r.Context(), user, id) err := h.s.Delete(r.Context(), user, id)
if err != nil { if err != nil {
core.HandleError(w, r, err) handleError(w, r, err)
return return
} }
} }
} }
func (h HandlerImpl) getTransactionData(accounts []*account.Account, treasureChests []*treasure_chest_types.TreasureChest) (map[uuid.UUID]string, map[uuid.UUID]string) { func (h TransactionImpl) getTransactionData(accounts []*types.Account, treasureChests []*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,40 +1,43 @@
package transaction_recurring package handler
import ( import (
"net/http" "net/http"
"spend-sparrow/internal/auth_types" "spend-sparrow/internal/handler/middleware"
"spend-sparrow/internal/core" "spend-sparrow/internal/service"
t "spend-sparrow/internal/template/transaction_recurring"
"spend-sparrow/internal/types"
"spend-sparrow/internal/utils"
) )
type Handler interface { type TransactionRecurring interface {
Handle(router *http.ServeMux) Handle(router *http.ServeMux)
} }
type HandlerImpl struct { type TransactionRecurringImpl struct {
s Service s service.TransactionRecurring
r *core.Render r *Render
} }
func NewHandler(s Service, r *core.Render) Handler { func NewTransactionRecurring(s service.TransactionRecurring, r *Render) TransactionRecurring {
return HandlerImpl{ return TransactionRecurringImpl{
s: s, s: s,
r: r, r: r,
} }
} }
func (h HandlerImpl) Handle(r *http.ServeMux) { func (h TransactionRecurringImpl) 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 HandlerImpl) handleTransactionRecurringItemComp() http.HandlerFunc { func (h TransactionRecurringImpl) handleTransactionRecurringItemComp() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
core.UpdateSpan(r) updateSpan(r)
user := core.GetUser(r) user := middleware.GetUser(r)
if user == nil { if user == nil {
core.DoRedirect(w, r, "/auth/signin") utils.DoRedirect(w, r, "/auth/signin")
return return
} }
@@ -45,17 +48,17 @@ func (h HandlerImpl) handleTransactionRecurringItemComp() http.HandlerFunc {
} }
} }
func (h HandlerImpl) handleUpdateTransactionRecurring() http.HandlerFunc { func (h TransactionRecurringImpl) handleUpdateTransactionRecurring() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
core.UpdateSpan(r) updateSpan(r)
user := core.GetUser(r) user := middleware.GetUser(r)
if user == nil { if user == nil {
core.DoRedirect(w, r, "/auth/signin") utils.DoRedirect(w, r, "/auth/signin")
return return
} }
input := TransactionRecurringInput{ input := types.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"),
@@ -69,13 +72,13 @@ func (h HandlerImpl) handleUpdateTransactionRecurring() http.HandlerFunc {
if input.Id == "new" { if input.Id == "new" {
_, err := h.s.Add(r.Context(), user, input) _, err := h.s.Add(r.Context(), user, input)
if err != nil { if err != nil {
core.HandleError(w, r, err) handleError(w, r, err)
return return
} }
} else { } else {
_, err := h.s.Update(r.Context(), user, input) _, err := h.s.Update(r.Context(), user, input)
if err != nil { if err != nil {
core.HandleError(w, r, err) handleError(w, r, err)
return return
} }
} }
@@ -84,13 +87,13 @@ func (h HandlerImpl) handleUpdateTransactionRecurring() http.HandlerFunc {
} }
} }
func (h HandlerImpl) handleDeleteTransactionRecurring() http.HandlerFunc { func (h TransactionRecurringImpl) handleDeleteTransactionRecurring() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
core.UpdateSpan(r) updateSpan(r)
user := core.GetUser(r) user := middleware.GetUser(r)
if user == nil { if user == nil {
core.DoRedirect(w, r, "/auth/signin") utils.DoRedirect(w, r, "/auth/signin")
return return
} }
@@ -100,7 +103,7 @@ func (h HandlerImpl) handleDeleteTransactionRecurring() http.HandlerFunc {
err := h.s.Delete(r.Context(), user, id) err := h.s.Delete(r.Context(), user, id)
if err != nil { if err != nil {
core.HandleError(w, r, err) handleError(w, r, err)
return return
} }
@@ -108,26 +111,26 @@ func (h HandlerImpl) handleDeleteTransactionRecurring() http.HandlerFunc {
} }
} }
func (h HandlerImpl) renderItems(w http.ResponseWriter, r *http.Request, user *auth_types.User, id, accountId, treasureChestId string) { func (h TransactionRecurringImpl) renderItems(w http.ResponseWriter, r *http.Request, user *types.User, id, accountId, treasureChestId string) {
var transactionsRecurring []*TransactionRecurring var transactionsRecurring []*types.TransactionRecurring
var err error var err error
if accountId == "" && treasureChestId == "" { if accountId == "" && treasureChestId == "" {
core.TriggerToastWithStatus(r.Context(), w, r, "error", "Please select an account or treasure chest", http.StatusBadRequest) utils.TriggerToastWithStatus(r.Context(), w, r, "error", "Please select an account or treasure chest", http.StatusBadRequest)
} }
if accountId != "" { if accountId != "" {
transactionsRecurring, err = h.s.GetAllByAccount(r.Context(), user, accountId) transactionsRecurring, err = h.s.GetAllByAccount(r.Context(), user, accountId)
if err != nil { if err != nil {
core.HandleError(w, r, err) handleError(w, r, err)
return return
} }
} else { } else {
transactionsRecurring, err = h.s.GetAllByTreasureChest(r.Context(), user, treasureChestId) transactionsRecurring, err = h.s.GetAllByTreasureChest(r.Context(), user, treasureChestId)
if err != nil { if err != nil {
core.HandleError(w, r, err) handleError(w, r, err)
return return
} }
} }
comp := TransactionRecurringItems(transactionsRecurring, id, accountId, treasureChestId) comp := t.TransactionRecurringItems(transactionsRecurring, id, accountId, treasureChestId)
h.r.Render(r, w, comp) h.r.Render(r, w, comp)
} }

View File

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

View File

@@ -16,6 +16,11 @@ 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.
@@ -45,7 +50,10 @@ func setupOTelSDK(ctx context.Context) (func(context.Context) error, error) {
prop := newPropagator() prop := newPropagator()
otel.SetTextMapPropagator(prop) otel.SetTextMapPropagator(prop)
resources, err := resource.New(ctx) resources, err := resource.New(
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)
} }
@@ -88,7 +96,11 @@ 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(ctx) exp, err := otlptracegrpc.New(
ctx,
otlptracegrpc.WithEndpoint(otelEndpoint),
otlptracegrpc.WithInsecure(),
)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -100,7 +112,10 @@ 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(ctx) exp, err := otlpmetricgrpc.New(
ctx,
otlpmetricgrpc.WithInsecure(),
otlpmetricgrpc.WithEndpoint(otelEndpoint))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -112,7 +127,10 @@ 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(ctx) logExporter, err := otlploggrpc.New(
ctx,
otlploggrpc.WithInsecure(),
otlploggrpc.WithEndpoint(otelEndpoint))
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,200 +0,0 @@
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")
}
}

View File

@@ -1,119 +0,0 @@
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
}

View File

@@ -1,159 +0,0 @@
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>
}

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
package dashboard package dashboard
import "spend-sparrow/internal/treasure_chest_types" import "spend-sparrow/internal/types"
templ DashboardComp(treasureChests []*treasure_chest_types.TreasureChest) { templ Dashboard(treasureChests []*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

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

View File

@@ -1,9 +1,6 @@
package core package template
import ( import "spend-sparrow/internal/template/svg"
"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"
@@ -90,17 +87,9 @@ 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(strings.HasPrefix(path, "/dashboard")) } href="/dashboard">Dashboard</a> <a class={ layoutLinkClass(path == "/dashboard") } href="/dashboard">Dashboard</a>
<a class={ layoutLinkClass(strings.HasPrefix(path, "/transaction")) } href="/transaction">Transaction</a> <a class={ layoutLinkClass(path == "/transaction") } href="/transaction">Transaction</a>
<a class={ layoutLinkClass(strings.HasPrefix(path, "/treasurechest")) } href="/treasurechest">Treasure Chest</a> <a class={ layoutLinkClass(path == "/treasurechest") } href="/treasurechest">Treasure Chest</a>
<a class={ layoutLinkClass(strings.HasPrefix(path, "/account")) } href="/account">Account</a> <a class={ layoutLinkClass(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

@@ -19,7 +19,7 @@ templ Eye() {
} }
templ Plus() { templ Plus() {
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 304 384" class="text-gray-500"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 304 384" class="h-4 w-4 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

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

View File

@@ -0,0 +1,297 @@
package transaction
import "fmt"
import "spend-sparrow/internal/types"
import "github.com/google/uuid"
templ Transaction(
items templ.Component,
filter types.TransactionItemsFilter,
accounts []*types.Account,
treasureChests []*types.TreasureChest) {
<div class="">
<div class="">
<!-- <form -->
<!-- id="transactionFilterForm" -->
<!-- hx-get="/transaction" -->
<!-- hx-target="#transaction-items" -->
<!-- hx-push-url="true" -->
<!-- hx-trigger="change" -->
<!-- > -->
<!-- <select name="account-id" class=""> -->
<!-- <option value="">- Filter Acount -</option> -->
<!-- for _, account := range accounts { -->
<!-- <option -->
<!-- value={ account.Id.String() } -->
<!-- selected?={ filter.AccountId == account.Id.String() } -->
<!-- >{ account.Name }</option> -->
<!-- } -->
<!-- </select> -->
<!-- <select name="treasure-chest-id" class=""> -->
<!-- <option value="">- Filter Treasure Chest -</option> -->
<!-- for _, parent := range treasureChests { -->
<!-- if parent.ParentId == nil { -->
<!-- <optgroup label={ parent.Name }> -->
<!-- for _, child := range treasureChests { -->
<!-- if child.ParentId != nil && *child.ParentId == parent.Id { -->
<!-- <option -->
<!-- value={ child.Id.String() } -->
<!-- selected?={ filter.TreasureChestId == child.Id.String() } -->
<!-- >{ child.Name }</option> -->
<!-- } -->
<!-- } -->
<!-- </optgroup> -->
<!-- } -->
<!-- } -->
<!-- </select> -->
<!-- <select name="error" class=""> -->
<!-- <option value="">- Filter Error -</option> -->
<!-- <option -->
<!-- value="true" -->
<!-- selected?={ filter.Error == "true" } -->
<!-- >Has Errors</option> -->
<!-- <option -->
<!-- value="false" -->
<!-- selected?={ filter.Error == "false" } -->
<!-- >Has no Errors</option> -->
<!-- </select> -->
<!-- <input id="page" name="page" type="hidden" value={ filter.Page }/> -->
<!-- </form> -->
<!-- <button -->
<!-- hx-get="/transaction/new" -->
<!-- hx-target="#transaction-items" -->
<!-- hx-swap="afterbegin" -->
<!-- class="" -->
<!-- > -->
<!-- @svg.Plus() -->
<!-- <p>New Transaction</p> -->
<!-- </button> -->
</div>
<!-- <div class=""> -->
<!-- <button id="pagePrev1" class=""> -->
<!-- &lt; -->
<!-- </button> -->
<!-- <span class="">Page: <span class="" id="page1">{ getPageNumber(filter.Page) }</span></span> -->
<!-- <button id="pageNext1" class=""> -->
<!-- &gt; -->
<!-- </button> -->
<!-- </div> -->
@items
<!-- <div class=""> -->
<!-- <button id="pagePrev2" class=""> -->
<!-- &lt; -->
<!-- </button> -->
<!-- <span class="">Page: <span class="" id="page2">{ getPageNumber(filter.Page) }</span></span> -->
<!-- <button id="pageNext2" class=""> -->
<!-- &gt; -->
<!-- </button> -->
<!-- </div> -->
</div>
}
templ TransactionItems(transactions []*types.Transaction, accounts, treasureChests map[uuid.UUID]string) {
<div id="transaction-items" class="flex flex-col gap-8">
for _, transaction := range transactions {
@TransactionItem(transaction, accounts, treasureChests)
}
</div>
}
templ EditTransaction(transaction *types.Transaction, accounts []*types.Account, treasureChests []*types.TreasureChest) {
// {{
// var (
// timestamp time.Time
//
// id string
// cancelUrl string
// )
// party := ""
// description := ""
// accountId := ""
// value := "0.00"
// treasureChestId := ""
// if transaction == nil {
// timestamp = time.Now().UTC().Truncate(time.Minute)
//
// id = "new"
// cancelUrl = "/empty"
// } else {
// timestamp = transaction.Timestamp.UTC().Truncate(time.Minute)
// party = transaction.Party
// description = transaction.Description
// if transaction.AccountId != nil {
// accountId = transaction.AccountId.String()
// }
// if transaction.TreasureChestId != nil {
// treasureChestId = transaction.TreasureChestId.String()
// }
// value = formatFloat(transaction.Value)
//
// id = transaction.Id.String()
// cancelUrl = "/transaction/" + id
// }
// }}
// <div id="transaction" class="">
// <form
// hx-post={ "/transaction/" + id }
// hx-target="closest #transaction"
// hx-swap="outerHTML"
// class=""
// >
// <div class="">
// <label for="timestamp" class="">Transaction Date</label>
// <input
// autofocus
// name="timestamp"
// type="date"
// value={ timestamp.String() }
// class=""
// />
// <label for="party" class="">Party</label>
// <input
// name="party"
// type="text"
// value={ party }
// class=""
// />
// <label for="description" class="">Description</label>
// <input
// name="description"
// type="text"
// value={ description }
// class=""
// />
// <label for="value" class="">Value (€)</label>
// <input
// name="value"
// step="0.01"
// type="number"
// value={ value }
// class=""
// />
// <label for="account-id" class="">Account</label>
// <select
// name="account-id"
// class=""
// >
// <option value="">-</option>
// for _, account := range accounts {
// <option selected?={ account.Id.String() == accountId } value={ account.Id.String() }>{ account.Name }</option>
// }
// </select>
// <label for="treasure-chest-id" class="">Treasure Chest</label>
// <select name="treasure-chest-id" class="">
// <option value="">- Filter Treasure Chest -</option>
// for _, parent := range treasureChests {
// if parent.ParentId == nil {
// <optgroup label={ parent.Name }>
// for _, child := range treasureChests {
// if child.ParentId != nil && *child.ParentId == parent.Id {
// <option
// value={ child.Id.String() }
// selected?={ treasureChestId == child.Id.String() }
// >{ child.Name }</option>
// }
// }
// </optgroup>
// }
// }
// </select>
// </div>
// <button type="submit" class="">
// @svg.Save()
// <span>
// Save
// </span>
// </button>
// <button
// hx-get={ cancelUrl }
// hx-target="closest #transaction"
// hx-swap="outerHTML"
// class=""
// >
// <span class="">
// @svg.Cancel()
// </span>
// <span>
// Cancel
// </span>
// </button>
// </form>
// </div>
}
templ TransactionItem(transaction *types.Transaction, accounts, treasureChests map[uuid.UUID]string) {
<div
id="transaction"
class="grid grid-cols-[1fr_auto]"
if transaction.Error != nil {
title={ *transaction.Error }
}
>
<p class="datetime">{ transaction.Timestamp.String() }</p>
<p class="text-2xl flex items-center col-start-2 row-start-1 row-end-3 align-center">
if transaction.Value < 0 {
<span class="text-red-700">- { types.FormatEuros(transaction.Value) }</span>
} else {
<span class="text-green-700">+ { types.FormatEuros(transaction.Value) }</span>
}
</p>
<!-- if transaction.AccountId != nil { -->
<!-- <p class="col-start-1"> -->
<!-- { accounts[*transaction.AccountId] } -->
<!-- </p> -->
<!-- } -->
if transaction.TreasureChestId != nil {
<p class="col-start-1">
{ treasureChests[*transaction.TreasureChestId] }
</p>
}
<!-- if transaction.Party != "" { -->
<!-- <p class="col-start-1"> -->
<!-- { transaction.Party } -->
<!-- </p> -->
<!-- } -->
<!-- if transaction.Description != "" { -->
<!-- <p class="col-start-1"> -->
<!-- { transaction.Description } -->
<!-- </p> -->
<!-- } -->
<!-- <div class="col-start-2 col-end-3 flex gap-10 justify-end"> -->
<!-- <button -->
<!-- hx-get={ "/transaction/" + transaction.Id.String() + "?edit=true" } -->
<!-- hx-target="closest #transaction" -->
<!-- hx-swap="outerHTML" -->
<!-- class="flex items-center gap-2" -->
<!-- > -->
<!-- @svg.Edit() -->
<!-- Edit -->
<!-- </button> -->
<!-- <button -->
<!-- hx-delete={ "/transaction/" + transaction.Id.String() } -->
<!-- hx-target="closest #transaction" -->
<!-- hx-swap="outerHTML" -->
<!-- hx-confirm="Are you sure you want to delete this transaction?" -->
<!-- class="flex items-center gap-2" -->
<!-- > -->
<!-- @svg.Delete() -->
<!-- Delete -->
<!-- </button> -->
<!-- </div> -->
</div>
}
func formatFloat(balance int64) string {
euros := float64(balance) / 100
return fmt.Sprintf("%.2f", euros)
}
func getPageNumber(page string) string {
if page == "" {
return "1"
} else {
return page
}
}

View File

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

View File

@@ -1,13 +1,11 @@
package transaction_recurring package transaction_recurring
import ( import "fmt"
"fmt" import "time"
"spend-sparrow/internal/core" import "spend-sparrow/internal/template/svg"
"spend-sparrow/internal/template/svg" import "spend-sparrow/internal/types"
"time"
)
templ TransactionRecurringItems(transactionsRecurring []*TransactionRecurring, editId, accountId, treasureChestId string) { templ TransactionRecurringItems(transactionsRecurring []*types.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>
@@ -29,7 +27,7 @@ templ TransactionRecurringItems(transactionsRecurring []*TransactionRecurring, e
</div> </div>
} }
templ TransactionRecurringItem(transactionRecurring *TransactionRecurring, accountId, treasureChestId string) { templ TransactionRecurringItem(transactionRecurring *types.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") }
@@ -55,9 +53,9 @@ templ TransactionRecurringItem(transactionRecurring *TransactionRecurring, accou
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">{ core.FormatEuros(transactionRecurring.Value) }</p> <p class="text-right text-red-700">{ types.FormatEuros(transactionRecurring.Value) }</p>
} else { } else {
<p class="text-right text-green-700">{ core.FormatEuros(transactionRecurring.Value) }</p> <p class="text-right text-green-700">{ types.FormatEuros(transactionRecurring.Value) }</p>
} }
<div class="flex gap-2"> <div class="flex gap-2">
<button <button
@@ -86,7 +84,7 @@ templ TransactionRecurringItem(transactionRecurring *TransactionRecurring, accou
</div> </div>
} }
templ EditTransactionRecurring(transactionRecurring *TransactionRecurring, accountId, treasureChestId string) { templ EditTransactionRecurring(transactionRecurring *types.TransactionRecurring, accountId, treasureChestId string) {
{{ {{
var ( var (
id string id string

View File

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

View File

@@ -1,13 +1,10 @@
package treasure_chest package treasurechest
import ( import "spend-sparrow/internal/template/svg"
"github.com/google/uuid" import "spend-sparrow/internal/types"
"spend-sparrow/internal/core" import "github.com/google/uuid"
"spend-sparrow/internal/template/svg"
"spend-sparrow/internal/treasure_chest_types"
)
templ TreasureChestComp(treasureChests []*treasure_chest_types.TreasureChest, monthlySums map[uuid.UUID]int64) { templ TreasureChest(treasureChests []*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"
@@ -15,9 +12,7 @@ templ TreasureChestComp(treasureChests []*treasure_chest_types.TreasureChest, mo
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">
@@ -28,30 +23,30 @@ templ TreasureChestComp(treasureChests []*treasure_chest_types.TreasureChest, mo
</div> </div>
} }
templ EditTreasureChest(treasureChest *treasure_chest_types.TreasureChest, parents []*treasure_chest_types.TreasureChest, transactionsRecurring templ.Component) { templ EditTreasureChest(treasureChest *types.TreasureChest, parents []*types.TreasureChest, transactionsRecurring templ.Component) {
{{ {{
var ( var (
id string id string
name string name string
parentId uuid.UUID parentId uuid.UUID
cancelUrl string cancelUrl string
) )
indentation := " mt-10" indentation := " mt-10"
if treasureChest == nil { if treasureChest == nil {
id = "new" id = "new"
name = "" name = ""
parentId = uuid.Nil parentId = uuid.Nil
cancelUrl = "/empty" cancelUrl = "/empty"
} else { } else {
id = treasureChest.Id.String() id = treasureChest.Id.String()
name = treasureChest.Name name = treasureChest.Name
if treasureChest.ParentId != nil { if treasureChest.ParentId != nil {
parentId = *treasureChest.ParentId parentId = *treasureChest.ParentId
indentation = " mt-2 ml-14" indentation = " mt-2 ml-14"
}
cancelUrl = "/treasurechest/" + id
} }
cancelUrl = "/treasurechest/" + id
}
}} }}
<div id={ "treasurechest-" + id } class={ "border-1 border-gray-300 p-4 bg-gray-50 rounded-lg" + indentation }> <div id={ "treasurechest-" + id } class={ "border-1 border-gray-300 p-4 bg-gray-50 rounded-lg" + indentation }>
<form <form
@@ -111,9 +106,7 @@ templ EditTreasureChest(treasureChest *treasure_chest_types.TreasureChest, paren
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>
@@ -122,30 +115,30 @@ templ EditTreasureChest(treasureChest *treasure_chest_types.TreasureChest, paren
</div> </div>
} }
templ TreasureChestItem(treasureChest *treasure_chest_types.TreasureChest, monthlySums map[uuid.UUID]int64) { templ TreasureChestItem(treasureChest *types.TreasureChest, monthlySums map[uuid.UUID]int64) {
{{ {{
var indentation string var indentation string
viewTransactions := "" viewTransactions := ""
if treasureChest.ParentId != nil { if treasureChest.ParentId != nil {
indentation = " mt-2 ml-14" indentation = " mt-2 ml-14"
} else { } else {
indentation = " mt-10" indentation = " mt-10"
viewTransactions = "hidden" viewTransactions = "hidden"
} }
}} }}
<div id={ "treasurechest-" + treasureChest.Id.String() } class={ "border-1 border-gray-300 p-4 bg-gray-50 rounded-lg" + indentation }> <div id={ "treasurechest-" + treasureChest.Id.String() } class={ "border-1 border-gray-300 p-4 bg-gray-50 rounded-lg" + indentation }>
<div class="text-xl flex justify-end items-center gap-4"> <div class="text-xl flex justify-end items-center gap-4">
<p class="mr-auto">{ treasureChest.Name }</p> <p class="mr-auto">{ treasureChest.Name }</p>
<p class="mr-20 text-gray-600"> <p class="mr-20 text-gray-600">
if treasureChest.ParentId != nil { if treasureChest.ParentId != nil {
+ { core.FormatEuros(monthlySums[treasureChest.Id]) } <span class="text-gray-500 text-sm">&nbsp;per month</span> + { types.FormatEuros(monthlySums[treasureChest.Id]) } <span class="text-gray-500 text-sm">&nbsp;per month</span>
} }
</p> </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">{ core.FormatEuros(treasureChest.CurrentBalance) }</p> <p class="mr-20 min-w-20 text-right text-red-700">{ types.FormatEuros(treasureChest.CurrentBalance) }</p>
} else { } else {
<p class="mr-20 min-w-20 text-right text-green-700">{ core.FormatEuros(treasureChest.CurrentBalance) }</p> <p class="mr-20 min-w-20 text-right text-green-700">{ types.FormatEuros(treasureChest.CurrentBalance) }</p>
} }
} }
<a <a
@@ -184,8 +177,8 @@ templ TreasureChestItem(treasureChest *treasure_chest_types.TreasureChest, month
</div> </div>
} }
func filterNoChildNoSelf(nodes []*treasure_chest_types.TreasureChest, selfId string) []*treasure_chest_types.TreasureChest { func filterNoChildNoSelf(nodes []*types.TreasureChest, selfId string) []*types.TreasureChest {
var result []*treasure_chest_types.TreasureChest var result []*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,323 +0,0 @@
package transaction
import (
"fmt"
"github.com/google/uuid"
"spend-sparrow/internal/account"
"spend-sparrow/internal/core"
"spend-sparrow/internal/template/svg"
"spend-sparrow/internal/treasure_chest_types"
"time"
)
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="flex items-center gap-4">
<form
id="transactionFilterForm"
hx-get="/transaction"
hx-target="#transaction-items"
hx-push-url="true"
hx-trigger="change"
>
<select name="account-id" class="bg-white input">
<option value="">- Filter Acount -</option>
for _, account := range accounts {
<option
value={ account.Id.String() }
selected?={ filter.AccountId == account.Id.String() }
>{ account.Name }</option>
}
</select>
<select name="treasure-chest-id" class="bg-white input">
<option value="">- Filter Treasure Chest -</option>
for _, parent := range treasureChests {
if parent.ParentId == nil {
<optgroup label={ parent.Name }>
for _, child := range treasureChests {
if child.ParentId != nil && *child.ParentId == parent.Id {
<option
value={ child.Id.String() }
selected?={ filter.TreasureChestId == child.Id.String() }
>{ child.Name }</option>
}
}
</optgroup>
}
}
</select>
<select name="error" class="bg-white input">
<option value="">- Filter Error -</option>
<option
value="true"
selected?={ filter.Error == "true" }
>Has Errors</option>
<option
value="false"
selected?={ filter.Error == "false" }
>Has no Errors</option>
</select>
<input id="page" name="page" type="hidden" value={ filter.Page }/>
</form>
<button
hx-get="/transaction/new"
hx-target="#transaction-items"
hx-swap="afterbegin"
class="button button-primary ml-auto px-2 flex items-center gap-2 justify-center"
>
<div class="w-3">
@svg.Plus()
</div>
<p>New Transaction</p>
</button>
</div>
<div class="flex justify-end items-center gap-5 mt-5">
<button id="pagePrev1" class="text-2xl p-2 text-yellow-700 font-black hover:bg-gray-200 rounded-lg decoration-yellow-400 decoration-[0.25rem] hover:underline">
&lt;
</button>
<span class="text-gray-400 text-sm">Page: <span class="text-gray-800 text-xl" id="page1">{ getPageNumber(filter.Page) }</span></span>
<button id="pageNext1" class="text-2xl p-2 text-yellow-700 font-black hover:bg-gray-200 rounded-lg decoration-yellow-400 decoration-[0.25rem] hover:underline">
&gt;
</button>
</div>
@items
<div class="flex justify-end items-center gap-5 mt-5">
<button id="pagePrev2" class="text-2xl p-2 text-yellow-700 font-black hover:bg-gray-200 rounded-lg decoration-yellow-400 decoration-[0.25rem] hover:underline">
&lt;
</button>
<span class="text-gray-400 text-sm">Page: <span class="text-gray-800 text-xl" id="page2">{ getPageNumber(filter.Page) }</span></span>
<button id="pageNext2" class="text-2xl p-2 text-yellow-700 font-black hover:bg-gray-200 rounded-lg decoration-yellow-400 decoration-[0.25rem] hover:underline">
&gt;
</button>
</div>
</div>
}
templ TransactionItems(transactions []*Transaction, accounts, treasureChests map[uuid.UUID]string) {
<div id="transaction-items" class="my-6">
for _, transaction := range transactions {
@TransactionItem(transaction, accounts, treasureChests)
}
</div>
}
templ EditTransaction(transaction *Transaction, accounts []*account.Account, treasureChests []*treasure_chest_types.TreasureChest) {
{{
var (
timestamp time.Time
id string
cancelUrl string
)
party := ""
description := ""
accountId := ""
value := "0.00"
treasureChestId := ""
if transaction == nil {
timestamp = time.Now().UTC().Truncate(time.Minute)
id = "new"
cancelUrl = "/empty"
} else {
timestamp = transaction.Timestamp.UTC().Truncate(time.Minute)
party = transaction.Party
description = transaction.Description
if transaction.AccountId != nil {
accountId = transaction.AccountId.String()
}
if transaction.TreasureChestId != nil {
treasureChestId = transaction.TreasureChestId.String()
}
value = formatFloat(transaction.Value)
id = transaction.Id.String()
cancelUrl = "/transaction/" + id
}
}}
<div id="transaction" class="border-1 border-gray-300 w-full my-4 p-4 bg-gray-50 rounded-lg">
<form
hx-post={ "/transaction/" + id }
hx-target="closest #transaction"
hx-swap="outerHTML"
class="text-xl flex justify-end gap-4 items-center"
>
<div class="grid grid-cols-[auto_auto] items-center gap-4 mr-auto">
<label for="timestamp" class="text-sm text-gray-500">Transaction Date</label>
<input
autofocus
name="timestamp"
type="date"
value={ timestamp.String() }
class="bg-white input datetime"
/>
<label for="party" class="text-sm text-gray-500">Party</label>
<input
name="party"
type="text"
value={ party }
class="mr-auto bg-white input"
/>
<label for="description" class="text-sm text-gray-500">Description</label>
<input
name="description"
type="text"
value={ description }
class="mr-auto bg-white input"
/>
<label for="value" class="text-sm text-gray-500">Value (€)</label>
<input
name="value"
step="0.01"
type="number"
value={ value }
class="bg-white input"
/>
<label for="account-id" class="text-sm text-gray-500">Account</label>
<select
name="account-id"
class="bg-white input"
>
<option value="">-</option>
for _, account := range accounts {
<option selected?={ account.Id.String() == accountId } value={ account.Id.String() }>{ account.Name }</option>
}
</select>
<label for="treasure-chest-id" class="text-sm text-gray-500">Treasure Chest</label>
<select name="treasure-chest-id" class="bg-white input">
<option value="">- Filter Treasure Chest -</option>
for _, parent := range treasureChests {
if parent.ParentId == nil {
<optgroup label={ parent.Name }>
for _, child := range treasureChests {
if child.ParentId != nil && *child.ParentId == parent.Id {
<option
value={ child.Id.String() }
selected?={ treasureChestId == child.Id.String() }
>{ child.Name }</option>
}
}
</optgroup>
}
}
</select>
</div>
<button type="submit" class="button button-neglect px-1 flex items-center gap-2">
@svg.Save()
<span>
Save
</span>
</button>
<button
hx-get={ cancelUrl }
hx-target="closest #transaction"
hx-swap="outerHTML"
class="button button-neglect px-1 flex items-center gap-2"
>
<span class="h-4 w-4">
@svg.Cancel()
</span>
<span>
Cancel
</span>
</button>
</form>
</div>
}
templ TransactionItem(transaction *Transaction, accounts, treasureChests map[uuid.UUID]string) {
{{
background := "bg-gray-50"
if transaction.Error != nil {
background = "bg-yellow-50"
}
}}
<div
id="transaction"
class={ "mt-4 border-1 grid grid-cols-[auto_auto_1fr_1fr_auto_auto_auto_auto] gap-4 items-center text-xl border-gray-300 w-full p-4 rounded-lg " + background }
if transaction.Error != nil {
title={ *transaction.Error }
}
>
<p class="mr-auto datetime">{ transaction.Timestamp.String() }</p>
<div class="w-6">
if transaction.Error != nil {
@svg.Info()
}
</div>
<div>
<p class="text-sm text-gray-500">
if transaction.AccountId != nil {
{ accounts[*transaction.AccountId] }
} else {
&nbsp;
}
</p>
<p class="text-sm text-gray-500">
if transaction.TreasureChestId != nil {
{ treasureChests[*transaction.TreasureChestId] }
} else {
&nbsp;
}
</p>
</div>
<div>
<p class="text-sm text-gray-500">
if transaction.Party != "" {
{ transaction.Party }
} else {
&nbsp;
}
</p>
<p class="text-sm text-gray-500">
if transaction.Description != "" {
{ transaction.Description }
} else {
&nbsp;
}
</p>
</div>
if transaction.Value < 0 {
<p class="mr-8 min-w-22 text-right text-red-700">{ core.FormatEuros(transaction.Value) }</p>
} else {
<p class="mr-8 w-22 text-right text-green-700">{ core.FormatEuros(transaction.Value) }</p>
}
<button
hx-get={ "/transaction/" + transaction.Id.String() + "?edit=true" }
hx-target="closest #transaction"
hx-swap="outerHTML"
class="button button-neglect px-1 flex items-center gap-2"
>
@svg.Edit()
<span>
Edit
</span>
</button>
<button
hx-delete={ "/transaction/" + transaction.Id.String() }
hx-target="closest #transaction"
hx-swap="outerHTML"
hx-confirm="Are you sure you want to delete this transaction?"
class="button button-neglect px-1 flex items-center gap-2"
>
@svg.Delete()
<span>
Delete
</span>
</button>
</div>
}
func formatFloat(balance int64) string {
euros := float64(balance) / 100
return fmt.Sprintf("%.2f", euros)
}
func getPageNumber(page string) string {
if page == "" {
return "1"
} else {
return page
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
package transaction package types
import ( import (
"time" "time"
@@ -6,7 +6,7 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
) )
// Transaction is at the center of the application. // At the center of the application is the transaction.
// //
// Every piece of data should be calculated based on transactions. // Every piece of data should be calculated based on transactions.
// This means potential calculation errors can be fixed later in time. // This means potential calculation errors can be fixed later in time.

View File

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

View File

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

10
internal/types/types.go Normal file
View File

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

View File

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

View File

@@ -22,11 +22,8 @@ func main() {
return return
} }
db, err := otelsqlx.Open( db, err := otelsqlx.Open("sqlite3", "./data/spend-sparrow.db?_journal_mode=WAL",
"sqlite3", otelsql.WithAttributes(semconv.DBSystemSqlite))
"./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

@@ -1,14 +0,0 @@
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

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

View File

@@ -1,13 +0,0 @@
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;

1
mocks/default.go Normal file
View File

@@ -0,0 +1 @@
package mocks

417
package-lock.json generated
View File

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

View File

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

View File

@@ -1,10 +1,11 @@
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
menuButton.addEventListener("click", function() { menuButton.addEventListener("click", function (e) {
menu.showModal(); menu.showModal();
}); });
menuButtonClose.addEventListener("click", function() { menuButtonClose.addEventListener("click", function (e) {
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'] && el.attributes['value'] !== "") { } else if (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,49 +1,43 @@
document.addEventListener("DOMContentLoaded", () => { // document.addEventListener("DOMContentLoaded", () => {
if (typeof page === "undefined" || // if (!page || !page1 || !pagePrev1 || !pageNext1 || !page2 || !pagePrev2 || !pageNext2 || !transactionFilterForm) {
typeof page1 === "undefined" || // return;
typeof pagePrev1 === "undefined" || // }
typeof pageNext1 === "undefined" || //
typeof page2 === "undefined" || //
typeof pagePrev2 === "undefined" || // const scrollToTop = function() {
typeof pageNext2 === "undefined" || // window.scrollTo(0, 0);
typeof transactionFilterForm === "undefined") { // };
return; // const incPage = function() {
} // const currPage = Number(page.value);
// var nextPage = currPage
const scrollToTop = function() { // if (currPage > 1) {
window.scrollTo(0, 0); // nextPage -= 1;
}; // page.value = nextPage;
const incPage = function() { // transactionFilterForm.dispatchEvent(new Event('change'));
const currPage = Number(page.value); // }
var nextPage = currPage // page1.textContent = nextPage;
if (currPage > 1) { // page2.textContent = nextPage;
nextPage -= 1; // scrollToTop();
page.value = nextPage; // };
transactionFilterForm.dispatchEvent(new Event('change')); // const decPage = function() {
} // const currPage = Number(page.value);
page1.textContent = nextPage; // var nextPage = currPage + 1;
page2.textContent = nextPage; // page.value = nextPage;
scrollToTop(); // transactionFilterForm.dispatchEvent(new Event('change'));
}; // page1.textContent = nextPage;
const decPage = function() { // page2.textContent = nextPage;
const currPage = Number(page.value); // scrollToTop();
var nextPage = currPage + 1; // };
page.value = nextPage; //
transactionFilterForm.dispatchEvent(new Event('change')); //
page1.textContent = nextPage; //
page2.textContent = nextPage; // pagePrev1.addEventListener("click", incPage);
scrollToTop(); // pagePrev2.addEventListener("click", incPage);
}; //
// pageNext1.addEventListener("click", decPage);
// pageNext2.addEventListener("click", decPage);
//
pagePrev1.addEventListener("click", incPage); // console.log("initialized pagination");
pagePrev2.addEventListener("click", incPage); // })
pageNext1.addEventListener("click", decPage);
pageNext2.addEventListener("click", decPage);
console.log("initialized pagination");
})

View File

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

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